ce4d6025d7
Post Workflow: - PostDetail full page (/posts/:id) replaces slide panel approach - Post = 1 Caption Copy + 1 Body Copy + 1 Design + 0-1 Video - copy_type field on Translations (caption/body) - Composition endpoint returns rich data (content preview, languages, thumbnails) - Stage auto-advances on translation/artefact changes (both link and unlink) - "Translations" renamed to "Copy" in navigation - GET /api/posts/:id, /api/translations/:id, /api/artefacts/:id routes added - PostProduction: "New Post" creates → navigates to full page - CampaignDetail: click post → navigates to full page - Inline link picker (no modals) with search + rich item display - PostComposition sub-components for caption, copy, designs, video, formats, readiness Budget Allocation: - Single source of truth: BudgetEntries (Campaign.budget deprecated) - Budget mutex for race conditions - Validation at all levels (main → campaign → track, expenses) - CEO approval workflow: BudgetRequests table, public approval page - Finance page: request budget UI, budget requests section - Settings: CEO email field - All emails branded with "Rawaj —" prefix Brand Identity: - Name: Rawaj (رواج) — trending/virality - Deep teal palette (#0d9488), forest-tinted dark mode - DM Sans font, custom SVG logo - Consistent across login, sidebar, emails, public pages Approval Workflow: - Single reviewer per artefact (not multi-select) - Reviewer redirect on public review page - Server blocks submit-review without reviewer - Review URLs use APP_URL (not server URL) UI/UX: - Scroll clipping fix: Modal, TabbedModal, SlidePanel restructured to avoid overflow-y-auto clipping native select dropdowns - section-card overflow-hidden → overflow-clip - All page titles via Header.jsx (removed duplicate h1s) - CampaignDetail redesigned: prominent budget card, compact team Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
820 lines
33 KiB
React
820 lines
33 KiB
React
import { useState, useEffect, useContext, useRef } from 'react'
|
|
import { useParams, useNavigate } from 'react-router-dom'
|
|
import {
|
|
ArrowLeft, Plus, Check, Trash2, LayoutGrid, List,
|
|
GanttChart, Settings, Calendar, Clock, MessageCircle, X,
|
|
Image as ImageIcon
|
|
} from 'lucide-react'
|
|
import { format, differenceInDays, startOfDay, addDays, isAfter, isBefore } from 'date-fns'
|
|
import { AppContext } from '../App'
|
|
import { api, PRIORITY_CONFIG } from '../utils/api'
|
|
import { useAuth } from '../contexts/AuthContext'
|
|
import StatusBadge from '../components/StatusBadge'
|
|
import BrandBadge from '../components/BrandBadge'
|
|
import Modal from '../components/Modal'
|
|
import CommentsSection from '../components/CommentsSection'
|
|
import ProjectEditPanel from '../components/ProjectEditPanel'
|
|
import TaskDetailPanel from '../components/TaskDetailPanel'
|
|
|
|
const TASK_COLUMNS = [
|
|
{ id: 'todo', label: 'To Do', color: 'bg-gray-400' },
|
|
{ id: 'in_progress', label: 'In Progress', color: 'bg-blue-400' },
|
|
{ id: 'done', label: 'Done', color: 'bg-emerald-400' },
|
|
]
|
|
|
|
export default function ProjectDetail() {
|
|
const { id } = useParams()
|
|
const navigate = useNavigate()
|
|
const { teamMembers, brands } = useContext(AppContext)
|
|
const { permissions, canEditResource, canDeleteResource } = useAuth()
|
|
const canManageProject = permissions?.canEditProjects
|
|
const [project, setProject] = useState(null)
|
|
const [tasks, setTasks] = useState([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [assignableUsers, setAssignableUsers] = useState([])
|
|
const [view, setView] = useState('kanban')
|
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
|
const [taskToDelete, setTaskToDelete] = useState(null)
|
|
|
|
const [showDiscussion, setShowDiscussion] = useState(false)
|
|
const [thumbnailUploading, setThumbnailUploading] = useState(false)
|
|
const thumbnailInputRef = useRef(null)
|
|
|
|
// Panel state
|
|
const [panelProject, setPanelProject] = useState(null)
|
|
const [panelTask, setPanelTask] = useState(null)
|
|
|
|
// Drag state for kanban
|
|
const [draggedTask, setDraggedTask] = useState(null)
|
|
const [dragOverCol, setDragOverCol] = useState(null)
|
|
|
|
useEffect(() => { loadProject() }, [id])
|
|
useEffect(() => {
|
|
api.get('/users/assignable').then(res => setAssignableUsers(Array.isArray(res) ? res : [])).catch(() => {})
|
|
}, [])
|
|
|
|
const loadProject = async () => {
|
|
try {
|
|
const proj = await api.get(`/projects/${id}`)
|
|
setProject(proj)
|
|
const tasksRes = await api.get(`/tasks?project_id=${id}`)
|
|
setTasks(Array.isArray(tasksRes) ? tasksRes : [])
|
|
} catch (err) {
|
|
console.error('Failed to load project:', err)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
const handleTaskStatusChange = async (taskId, newStatus) => {
|
|
try {
|
|
await api.patch(`/tasks/${taskId}`, { status: newStatus })
|
|
loadProject()
|
|
} catch (err) {
|
|
console.error('Status change failed:', err)
|
|
}
|
|
}
|
|
|
|
const handleDeleteTask = async (taskId) => {
|
|
setTaskToDelete(taskId)
|
|
setShowDeleteConfirm(true)
|
|
}
|
|
|
|
const confirmDeleteTask = async () => {
|
|
if (!taskToDelete) return
|
|
try {
|
|
await api.delete(`/tasks/${taskToDelete}`)
|
|
loadProject()
|
|
setTaskToDelete(null)
|
|
} catch (err) {
|
|
console.error('Delete failed:', err)
|
|
}
|
|
}
|
|
|
|
// Panel handlers
|
|
const handleProjectPanelSave = async (projectId, data) => {
|
|
await api.patch(`/projects/${projectId}`, data)
|
|
loadProject()
|
|
}
|
|
|
|
const handleProjectPanelDelete = async (projectId) => {
|
|
await api.delete(`/projects/${projectId}`)
|
|
navigate('/projects')
|
|
}
|
|
|
|
const handleTaskPanelSave = async (taskId, data) => {
|
|
if (taskId) {
|
|
await api.patch(`/tasks/${taskId}`, data)
|
|
} else {
|
|
await api.post('/tasks', { ...data, project_id: Number(id) })
|
|
}
|
|
setPanelTask(null)
|
|
loadProject()
|
|
}
|
|
|
|
const handleTaskPanelDelete = async (taskId) => {
|
|
await api.delete(`/tasks/${taskId}`)
|
|
setPanelTask(null)
|
|
loadProject()
|
|
}
|
|
|
|
const openEditTask = (task) => {
|
|
setPanelTask(task)
|
|
}
|
|
|
|
const openNewTask = () => {
|
|
setPanelTask({ title: '', status: 'todo', priority: 'medium', project_id: Number(id) })
|
|
}
|
|
|
|
const openEditProject = () => {
|
|
if (!project) return
|
|
setPanelProject(project)
|
|
}
|
|
|
|
const handleThumbnailUpload = async (file) => {
|
|
if (!file) return
|
|
setThumbnailUploading(true)
|
|
try {
|
|
const fd = new FormData()
|
|
fd.append('file', file)
|
|
await api.upload(`/projects/${id}/thumbnail`, fd)
|
|
loadProject()
|
|
} catch (err) {
|
|
console.error('Thumbnail upload failed:', err)
|
|
} finally {
|
|
setThumbnailUploading(false)
|
|
}
|
|
}
|
|
|
|
const handleThumbnailRemove = async () => {
|
|
try {
|
|
await api.delete(`/projects/${id}/thumbnail`)
|
|
loadProject()
|
|
} catch (err) {
|
|
console.error('Thumbnail remove failed:', err)
|
|
}
|
|
}
|
|
|
|
// Drag handlers
|
|
const handleDragStart = (e, task) => {
|
|
setDraggedTask(task)
|
|
e.dataTransfer.effectAllowed = 'move'
|
|
setTimeout(() => { e.target.style.opacity = '0.4' }, 0)
|
|
}
|
|
const handleDragEnd = (e) => {
|
|
e.target.style.opacity = '1'
|
|
setDraggedTask(null)
|
|
setDragOverCol(null)
|
|
}
|
|
const handleDragOver = (e, colId) => {
|
|
e.preventDefault()
|
|
e.dataTransfer.dropEffect = 'move'
|
|
setDragOverCol(colId)
|
|
}
|
|
const handleDragLeave = (e) => {
|
|
if (!e.currentTarget.contains(e.relatedTarget)) setDragOverCol(null)
|
|
}
|
|
const handleDrop = (e, colId) => {
|
|
e.preventDefault()
|
|
setDragOverCol(null)
|
|
if (draggedTask && draggedTask.status !== colId) {
|
|
handleTaskStatusChange(draggedTask._id, colId)
|
|
}
|
|
setDraggedTask(null)
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="animate-pulse space-y-4">
|
|
<div className="h-8 w-48 bg-surface-tertiary rounded-lg"></div>
|
|
<div className="h-40 bg-surface-tertiary rounded-xl"></div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (!project) {
|
|
return (
|
|
<div className="py-20 text-center">
|
|
<p className="text-text-secondary">Project not found</p>
|
|
<button onClick={() => navigate('/projects')} className="mt-4 text-brand-primary hover:underline text-sm">
|
|
Back to Projects
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const canEditProject = canEditResource('project', project)
|
|
const completedTasks = tasks.filter(t => t.status === 'done').length
|
|
const progress = tasks.length > 0 ? Math.round((completedTasks / tasks.length) * 100) : 0
|
|
const ownerName = project.ownerName || project.owner_name
|
|
const brandName = project.brandName || project.brand_name
|
|
|
|
return (
|
|
<div className="flex gap-6 animate-fade-in">
|
|
{/* Main content */}
|
|
<div className={`space-y-6 min-w-0 ${showDiscussion ? 'flex-1' : 'w-full'}`}>
|
|
{/* Back button */}
|
|
<button
|
|
onClick={() => navigate('/projects')}
|
|
className="flex items-center gap-2 text-sm text-text-secondary hover:text-text-primary transition-colors"
|
|
>
|
|
<ArrowLeft className="w-4 h-4" />
|
|
Back to Projects
|
|
</button>
|
|
|
|
{/* Project header */}
|
|
<div className="bg-surface rounded-xl border border-border overflow-clip">
|
|
{/* Thumbnail banner */}
|
|
{(project.thumbnail_url || project.thumbnailUrl) && (
|
|
<div className="relative w-full h-40 overflow-hidden">
|
|
<img src={project.thumbnail_url || project.thumbnailUrl} alt="" className="w-full h-full object-cover" loading="lazy" />
|
|
<div className="absolute inset-0 bg-gradient-to-t from-black/30 to-transparent" />
|
|
{canEditProject && (
|
|
<div className="absolute top-2 end-2 flex items-center gap-1">
|
|
<button
|
|
onClick={() => thumbnailInputRef.current?.click()}
|
|
className="px-2 py-1 text-xs bg-black/40 hover:bg-black/60 rounded text-white transition-colors"
|
|
>
|
|
Change
|
|
</button>
|
|
<button
|
|
onClick={handleThumbnailRemove}
|
|
className="p-1 bg-black/40 hover:bg-red-500/80 rounded text-white transition-colors"
|
|
>
|
|
<X className="w-3.5 h-3.5" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
<input
|
|
ref={thumbnailInputRef}
|
|
type="file"
|
|
accept="image/*"
|
|
className="hidden"
|
|
onChange={e => { handleThumbnailUpload(e.target.files[0]); e.target.value = '' }}
|
|
/>
|
|
<div className="p-6">
|
|
<div className="flex items-start justify-between gap-4 mb-4">
|
|
<div>
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<h1 className="text-2xl font-bold text-text-primary">{project.name}</h1>
|
|
<StatusBadge status={project.status} />
|
|
</div>
|
|
<div className="flex items-center gap-3 flex-wrap">
|
|
{brandName && <BrandBadge brand={brandName} />}
|
|
{ownerName && (
|
|
<span className="text-sm text-text-secondary">
|
|
Owned by <span className="font-medium">{ownerName}</span>
|
|
</span>
|
|
)}
|
|
{project.dueDate && (
|
|
<span className="text-sm text-text-tertiary flex items-center gap-1">
|
|
<Calendar className="w-3.5 h-3.5" />
|
|
Due {format(new Date(project.dueDate), 'MMMM d, yyyy')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{canEditProject && !project.thumbnail_url && !project.thumbnailUrl && (
|
|
<button
|
|
onClick={() => thumbnailInputRef.current?.click()}
|
|
disabled={thumbnailUploading}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-text-secondary hover:text-text-primary hover:bg-surface-tertiary rounded-lg transition-colors"
|
|
>
|
|
<ImageIcon className="w-4 h-4" />
|
|
{thumbnailUploading ? 'Uploading...' : 'Thumbnail'}
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={() => setShowDiscussion(prev => !prev)}
|
|
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-lg transition-colors ${
|
|
showDiscussion ? 'bg-brand-primary text-white' : 'text-text-secondary hover:text-text-primary hover:bg-surface-tertiary'
|
|
}`}
|
|
>
|
|
<MessageCircle className="w-4 h-4" />
|
|
Discussion
|
|
</button>
|
|
{canEditProject && (
|
|
<button
|
|
onClick={openEditProject}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-text-secondary hover:text-text-primary hover:bg-surface-tertiary rounded-lg transition-colors"
|
|
>
|
|
<Settings className="w-4 h-4" />
|
|
Edit
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{project.description && (
|
|
<p className="text-sm text-text-secondary mb-4">{project.description}</p>
|
|
)}
|
|
|
|
{/* Progress */}
|
|
<div className="max-w-md">
|
|
<div className="flex items-center justify-between text-sm mb-2">
|
|
<span className="text-text-secondary font-medium">Progress</span>
|
|
<span className="font-semibold text-text-primary">{progress}%</span>
|
|
</div>
|
|
<div className="h-2.5 bg-surface-tertiary rounded-full overflow-hidden">
|
|
<div
|
|
className="h-full bg-gradient-to-r from-brand-primary to-brand-primary-light rounded-full transition-all duration-500"
|
|
style={{ width: `${progress}%` }}
|
|
/>
|
|
</div>
|
|
<p className="text-xs text-text-tertiary mt-1">{completedTasks} of {tasks.length} tasks completed</p>
|
|
</div>
|
|
</div>{/* end p-6 wrapper */}
|
|
</div>{/* end project header card */}
|
|
|
|
{/* View switcher + Add Task */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-1 bg-surface-tertiary rounded-lg p-0.5">
|
|
{[
|
|
{ id: 'kanban', icon: LayoutGrid, label: 'Board' },
|
|
{ id: 'list', icon: List, label: 'List' },
|
|
{ id: 'gantt', icon: GanttChart, label: 'Timeline' },
|
|
].map(v => (
|
|
<button
|
|
key={v.id}
|
|
onClick={() => setView(v.id)}
|
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
|
view === v.id ? 'bg-surface shadow-sm text-text-primary' : 'text-text-tertiary hover:text-text-secondary'
|
|
}`}
|
|
>
|
|
<v.icon className="w-4 h-4" />
|
|
{v.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<button
|
|
onClick={openNewTask}
|
|
className="flex items-center gap-2 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm"
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
Add Task
|
|
</button>
|
|
</div>
|
|
|
|
{/* ─── KANBAN VIEW ─── */}
|
|
{view === 'kanban' && (
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
{TASK_COLUMNS.map(col => {
|
|
const colTasks = tasks.filter(t => t.status === col.id)
|
|
const isOver = dragOverCol === col.id && draggedTask?.status !== col.id
|
|
return (
|
|
<div key={col.id}>
|
|
<div className="flex items-center gap-2 mb-3">
|
|
<div className={`w-2.5 h-2.5 rounded-full ${col.color}`} />
|
|
<h4 className="text-sm font-semibold text-text-primary">{col.label}</h4>
|
|
<span className="text-xs font-medium text-text-tertiary bg-surface-tertiary px-2 py-0.5 rounded-full">
|
|
{colTasks.length}
|
|
</span>
|
|
</div>
|
|
<div
|
|
className={`rounded-xl p-2 space-y-2 min-h-[150px] border-2 transition-colors ${
|
|
isOver
|
|
? 'bg-brand-primary/5 border-brand-primary/40 border-dashed'
|
|
: 'bg-surface-secondary border-border-light border-solid'
|
|
}`}
|
|
onDragOver={(e) => handleDragOver(e, col.id)}
|
|
onDragLeave={handleDragLeave}
|
|
onDrop={(e) => handleDrop(e, col.id)}
|
|
>
|
|
{colTasks.length === 0 ? (
|
|
<div className={`py-8 text-center text-xs ${isOver ? 'text-brand-primary font-medium' : 'text-text-tertiary'}`}>
|
|
{isOver ? 'Drop here' : 'No tasks'}
|
|
</div>
|
|
) : (
|
|
colTasks.map(task => (
|
|
<TaskKanbanCard
|
|
key={task._id}
|
|
task={task}
|
|
canEdit={canEditResource('task', task)}
|
|
canDelete={canDeleteResource('task', task)}
|
|
onClick={() => openEditTask(task)}
|
|
onDelete={() => handleDeleteTask(task._id)}
|
|
onStatusChange={handleTaskStatusChange}
|
|
onDragStart={handleDragStart}
|
|
onDragEnd={handleDragEnd}
|
|
/>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* ─── LIST VIEW ─── */}
|
|
{view === 'list' && (
|
|
<div className="bg-surface rounded-xl border border-border overflow-clip">
|
|
<table className="w-full">
|
|
<thead>
|
|
<tr className="border-b border-border bg-surface-secondary">
|
|
<th className="text-start px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider w-8"></th>
|
|
<th className="text-start px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Task</th>
|
|
<th className="text-start px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Status</th>
|
|
<th className="text-start px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Priority</th>
|
|
<th className="text-start px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Assignee</th>
|
|
<th className="text-start px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Due</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-border-light">
|
|
{tasks.length === 0 ? (
|
|
<tr><td colSpan={6} className="py-12 text-center text-sm text-text-tertiary">{t('tasks.noTasks')}</td></tr>
|
|
) : (
|
|
tasks.map(task => {
|
|
const prio = PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium
|
|
const assigneeName = task.assignedName || task.assigned_name
|
|
const isOverdue = task.dueDate && new Date(task.dueDate) < new Date() && task.status !== 'done'
|
|
return (
|
|
<tr key={task._id} onClick={() => openEditTask(task)} className="hover:bg-surface-secondary cursor-pointer transition-colors">
|
|
<td className="px-4 py-3">
|
|
<div className={`w-2.5 h-2.5 rounded-full ${prio.color}`} />
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<span className="text-sm font-medium text-text-primary">
|
|
{task.title}
|
|
</span>
|
|
{task.description && <p className="text-xs text-text-tertiary line-clamp-1 mt-0.5">{task.description}</p>}
|
|
</td>
|
|
<td className="px-4 py-3"><StatusBadge status={task.status} size="xs" /></td>
|
|
<td className="px-4 py-3 text-xs font-medium text-text-secondary capitalize">{prio.label}</td>
|
|
<td className="px-4 py-3 text-xs text-text-secondary">{assigneeName || '—'}</td>
|
|
<td className={`px-4 py-3 text-xs ${isOverdue ? 'text-red-500 font-medium' : 'text-text-tertiary'}`}>
|
|
{task.dueDate ? format(new Date(task.dueDate), 'MMM d, yyyy') : '—'}
|
|
</td>
|
|
</tr>
|
|
)
|
|
})
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
|
|
{/* ─── GANTT / TIMELINE VIEW ─── */}
|
|
{view === 'gantt' && <GanttView tasks={tasks} project={project} onEditTask={openEditTask} onTaskColorChange={async (taskId, color) => {
|
|
try {
|
|
await api.patch(`/tasks/${taskId}`, { color: color || '' })
|
|
loadProject()
|
|
} catch (err) {
|
|
console.error('Task color update failed:', err)
|
|
}
|
|
}} />}
|
|
</div>{/* end main content */}
|
|
|
|
{/* ─── DISCUSSION SIDEBAR ─── */}
|
|
{showDiscussion && (
|
|
<div className="w-[340px] shrink-0 bg-surface rounded-xl border border-border flex flex-col self-start sticky top-4" style={{ maxHeight: 'calc(100vh - 6rem)' }}>
|
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
|
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-1.5">
|
|
<MessageCircle className="w-4 h-4" />
|
|
Discussion
|
|
</h3>
|
|
<button onClick={() => setShowDiscussion(false)} className="p-1 hover:bg-surface-tertiary rounded-lg text-text-tertiary">
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
<div className="flex-1 overflow-y-auto p-4">
|
|
<CommentsSection entityType="project" entityId={Number(id)} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ─── DELETE TASK CONFIRMATION ─── */}
|
|
<Modal
|
|
isOpen={showDeleteConfirm}
|
|
onClose={() => { setShowDeleteConfirm(false); setTaskToDelete(null) }}
|
|
title="Delete Task?"
|
|
isConfirm
|
|
danger
|
|
confirmText="Delete Task"
|
|
onConfirm={confirmDeleteTask}
|
|
>
|
|
Are you sure you want to delete this task? This action cannot be undone.
|
|
</Modal>
|
|
|
|
{/* Project Edit Panel */}
|
|
{panelProject && (
|
|
<ProjectEditPanel
|
|
project={panelProject}
|
|
onClose={() => setPanelProject(null)}
|
|
onSave={handleProjectPanelSave}
|
|
onDelete={handleProjectPanelDelete}
|
|
brands={brands}
|
|
teamMembers={teamMembers}
|
|
/>
|
|
)}
|
|
|
|
{/* Task Detail Panel */}
|
|
{panelTask && (
|
|
<TaskDetailPanel
|
|
task={panelTask}
|
|
onClose={() => setPanelTask(null)}
|
|
onSave={handleTaskPanelSave}
|
|
onDelete={handleTaskPanelDelete}
|
|
projects={project ? [project] : []}
|
|
users={assignableUsers}
|
|
brands={brands}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── Task Kanban Card ───────────────────────────────
|
|
function TaskKanbanCard({ task, canEdit, canDelete, onClick, onDelete, onStatusChange, onDragStart, onDragEnd }) {
|
|
const priority = PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium
|
|
const assigneeName = task.assignedName || task.assigned_name
|
|
const isOverdue = task.dueDate && new Date(task.dueDate) < new Date() && task.status !== 'done'
|
|
|
|
return (
|
|
<div
|
|
draggable={canEdit}
|
|
onDragStart={(e) => canEdit && onDragStart(e, task)}
|
|
onDragEnd={onDragEnd}
|
|
onClick={onClick}
|
|
className={`bg-surface rounded-lg border border-border p-3 group hover:border-brand-primary/30 hover:shadow-sm transition-all cursor-pointer ${canEdit ? 'active:cursor-grabbing' : ''}`}
|
|
>
|
|
<div className="flex items-start gap-2">
|
|
<div className={`w-2 h-2 rounded-full ${priority.color} mt-1.5 shrink-0`} title={priority.label} />
|
|
<div className="flex-1 min-w-0">
|
|
<h5 className={`text-sm font-medium ${task.status === 'done' ? 'text-text-tertiary line-through' : 'text-text-primary'}`}>
|
|
{task.title}
|
|
</h5>
|
|
<div className="flex items-center gap-2 mt-1.5 flex-wrap">
|
|
{assigneeName && (
|
|
<span className="text-[10px] text-text-tertiary">{assigneeName}</span>
|
|
)}
|
|
{task.dueDate && (
|
|
<span className={`text-[10px] flex items-center gap-0.5 ${isOverdue ? 'text-red-500 font-medium' : 'text-text-tertiary'}`}>
|
|
<Clock className="w-3 h-3" />
|
|
{format(new Date(task.dueDate), 'MMM d')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/* Actions on hover */}
|
|
{(canEdit || canDelete) && (
|
|
<div className="flex items-center gap-1 mt-2 pt-2 border-t border-border-light opacity-0 group-hover:opacity-100 transition-opacity">
|
|
{canEdit && task.status !== 'done' && (
|
|
<button onClick={(e) => { e.stopPropagation(); onStatusChange(task._id, task.status === 'todo' ? 'in_progress' : 'done') }}
|
|
className="text-[10px] text-brand-primary hover:bg-brand-primary/10 px-2 py-0.5 rounded-full flex items-center gap-1">
|
|
<Check className="w-3 h-3" />
|
|
{task.status === 'todo' ? 'Start' : 'Complete'}
|
|
</button>
|
|
)}
|
|
{canDelete && (
|
|
<button onClick={(e) => { e.stopPropagation(); onDelete() }}
|
|
className="text-[10px] text-red-400 hover:bg-red-50 px-2 py-0.5 rounded-full flex items-center gap-1 ms-auto">
|
|
<Trash2 className="w-3 h-3" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── Gantt / Timeline View ──────────────────────────
|
|
const GANTT_ZOOM = [
|
|
{ key: 'month', label: 'Month', pxPerDay: 8 },
|
|
{ key: 'week', label: 'Week', pxPerDay: 20 },
|
|
{ key: 'day', label: 'Day', pxPerDay: 48 },
|
|
]
|
|
|
|
const GANTT_COLOR_PALETTE = [
|
|
'#6366f1', '#ec4899', '#10b981', '#f59e0b',
|
|
'#8b5cf6', '#06b6d4', '#f43f5e', '#14b8a6',
|
|
'#3b82f6', '#ef4444', '#84cc16', '#a855f7',
|
|
]
|
|
|
|
function GanttView({ tasks, project, onEditTask, onTaskColorChange }) {
|
|
const [zoomIdx, setZoomIdx] = useState(0)
|
|
const ganttRef = useRef(null)
|
|
const [colorPicker, setColorPicker] = useState(null)
|
|
const colorPickerRef = useRef(null)
|
|
|
|
useEffect(() => {
|
|
if (!colorPicker) return
|
|
const handleClick = (e) => {
|
|
if (colorPickerRef.current && !colorPickerRef.current.contains(e.target)) {
|
|
setColorPicker(null)
|
|
}
|
|
}
|
|
document.addEventListener('mousedown', handleClick)
|
|
return () => document.removeEventListener('mousedown', handleClick)
|
|
}, [colorPicker])
|
|
|
|
if (tasks.length === 0) {
|
|
return (
|
|
<div className="bg-surface rounded-xl border border-border py-16 text-center">
|
|
<GanttChart className="w-12 h-12 text-text-tertiary mx-auto mb-3" />
|
|
<p className="text-text-secondary font-medium">No tasks to display</p>
|
|
<p className="text-sm text-text-tertiary mt-1">Add tasks with due dates to see the timeline</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const today = startOfDay(new Date())
|
|
|
|
// Calculate range
|
|
let earliest = addDays(today, -7)
|
|
let latest = addDays(today, 30)
|
|
tasks.forEach(t => {
|
|
const created = t.createdAt ? startOfDay(new Date(t.createdAt)) : today
|
|
const start = t.startDate || t.start_date ? startOfDay(new Date(t.startDate || t.start_date)) : created
|
|
const due = t.dueDate ? startOfDay(new Date(t.dueDate)) : null
|
|
if (isBefore(start, earliest)) earliest = addDays(start, -3)
|
|
if (isBefore(created, earliest)) earliest = addDays(created, -3)
|
|
if (due && isAfter(due, latest)) latest = addDays(due, 7)
|
|
})
|
|
if (project.dueDate) {
|
|
const pd = startOfDay(new Date(project.dueDate))
|
|
if (isAfter(pd, latest)) latest = addDays(pd, 7)
|
|
}
|
|
const totalDays = differenceInDays(latest, earliest) + 1
|
|
|
|
// Generate day headers
|
|
const days = []
|
|
for (let i = 0; i < totalDays; i++) {
|
|
days.push(addDays(earliest, i))
|
|
}
|
|
|
|
const dayWidth = GANTT_ZOOM[zoomIdx].pxPerDay
|
|
|
|
const getBarStyle = (task) => {
|
|
const start = task.startDate || task.start_date
|
|
? startOfDay(new Date(task.startDate || task.start_date))
|
|
: task.createdAt ? startOfDay(new Date(task.createdAt)) : today
|
|
const end = task.dueDate ? startOfDay(new Date(task.dueDate)) : addDays(start, 3)
|
|
const left = differenceInDays(start, earliest) * dayWidth
|
|
const width = Math.max(dayWidth, (differenceInDays(end, start) + 1) * dayWidth)
|
|
return { left: `${left}px`, width: `${width}px` }
|
|
}
|
|
|
|
const statusColors = {
|
|
todo: 'bg-gray-300',
|
|
in_progress: 'bg-blue-400',
|
|
done: 'bg-emerald-400',
|
|
}
|
|
|
|
return (
|
|
<div className="bg-surface rounded-xl border border-border overflow-clip">
|
|
{/* Zoom toolbar */}
|
|
<div className="flex items-center justify-between px-4 py-2 border-b border-border bg-surface-secondary">
|
|
<div className="flex items-center gap-2">
|
|
{GANTT_ZOOM.map((z, i) => (
|
|
<button
|
|
key={z.key}
|
|
onClick={() => setZoomIdx(i)}
|
|
className={`px-3 py-1 text-xs font-medium rounded-md transition-colors ${
|
|
zoomIdx === i
|
|
? 'bg-brand-primary text-white shadow-sm'
|
|
: 'text-text-tertiary hover:text-text-primary hover:bg-surface-tertiary'
|
|
}`}
|
|
>
|
|
{z.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<button
|
|
onClick={() => {
|
|
if (ganttRef.current) {
|
|
const todayOff = differenceInDays(today, earliest) * dayWidth
|
|
ganttRef.current.scrollTo({ left: Math.max(0, todayOff - 200), behavior: 'smooth' })
|
|
}
|
|
}}
|
|
className="flex items-center gap-1.5 px-3 py-1 text-xs font-medium text-brand-primary hover:bg-brand-primary/10 rounded-md transition-colors"
|
|
>
|
|
<Calendar className="w-3.5 h-3.5" />
|
|
Today
|
|
</button>
|
|
</div>
|
|
|
|
<div ref={ganttRef} className="overflow-x-auto">
|
|
<div style={{ minWidth: `${totalDays * dayWidth + 200}px` }}>
|
|
{/* Day headers */}
|
|
<div className="flex border-b border-border bg-surface-secondary sticky top-0 z-10">
|
|
<div className="w-[200px] shrink-0 px-4 py-2 text-xs font-semibold text-text-tertiary uppercase border-r border-border">
|
|
Task
|
|
</div>
|
|
<div className="flex">
|
|
{days.map((day, i) => {
|
|
const isToday = differenceInDays(day, today) === 0
|
|
const isWeekend = day.getDay() === 0 || day.getDay() === 6
|
|
const isMonthStart = day.getDate() === 1
|
|
const isWeekStart = day.getDay() === 1
|
|
return (
|
|
<div
|
|
key={i}
|
|
style={{ width: `${dayWidth}px` }}
|
|
className={`text-center py-2 border-r border-border-light text-[10px] ${
|
|
isToday ? 'bg-brand-primary/10 font-bold text-brand-primary' :
|
|
isWeekend ? 'bg-surface-tertiary/50 text-text-tertiary' : 'text-text-tertiary'
|
|
} ${isMonthStart ? 'border-l-2 border-l-text-tertiary/30' : ''}`}
|
|
>
|
|
{dayWidth >= 30 && <div>{format(day, 'd')}</div>}
|
|
{dayWidth >= 40 && <div className="text-[8px] uppercase">{format(day, 'EEE')}</div>}
|
|
{dayWidth >= 15 && dayWidth < 30 && day.getDate() % 7 === 1 && <div>{format(day, 'd')}</div>}
|
|
{dayWidth < 15 && isMonthStart && (
|
|
<div className="text-[8px] font-semibold whitespace-nowrap">{format(day, 'MMM')}</div>
|
|
)}
|
|
{dayWidth < 15 && !isMonthStart && isWeekStart && day.getDate() % 14 <= 7 && (
|
|
<div className="text-[8px]">{format(day, 'd')}</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Task rows */}
|
|
{tasks.map(task => {
|
|
const prio = PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium
|
|
const barStyle = getBarStyle(task)
|
|
return (
|
|
<div key={task._id} className="flex border-b border-border-light hover:bg-surface-secondary/50 group">
|
|
<div className="w-[200px] shrink-0 px-4 py-3 border-r border-border flex items-center gap-2">
|
|
{onTaskColorChange && (
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
const taskId = task._id || task.id
|
|
const rect = e.currentTarget.getBoundingClientRect()
|
|
setColorPicker(colorPicker?.taskId === taskId ? null : { taskId, x: rect.left, y: rect.bottom + 4 })
|
|
}}
|
|
className={`w-3.5 h-3.5 rounded-full border border-white shadow-sm shrink-0 hover:scale-125 transition-transform ${!task.color ? (statusColors[task.status] || 'bg-gray-300') : ''}`}
|
|
style={task.color ? { backgroundColor: task.color } : undefined}
|
|
title="Change color"
|
|
/>
|
|
)}
|
|
{!onTaskColorChange && <div className={`w-2 h-2 rounded-full ${prio.color} shrink-0`} />}
|
|
<button onClick={() => onEditTask(task)}
|
|
className="text-xs font-medium text-text-primary truncate hover:text-brand-primary text-start">
|
|
{task.title}
|
|
</button>
|
|
</div>
|
|
<div className="relative flex-1" style={{ height: '44px' }}>
|
|
{/* Today line */}
|
|
{differenceInDays(today, earliest) >= 0 && (
|
|
<div
|
|
className="absolute top-0 bottom-0 w-px bg-brand-primary/30 z-10"
|
|
style={{ left: `${differenceInDays(today, earliest) * dayWidth + dayWidth / 2}px` }}
|
|
/>
|
|
)}
|
|
{/* Bar */}
|
|
<div
|
|
className={`absolute top-2.5 h-5 rounded-full ${task.color ? '' : (statusColors[task.status] || 'bg-gray-300')} opacity-80 hover:opacity-100 transition-opacity cursor-pointer`}
|
|
style={{ ...barStyle, ...(task.color ? { backgroundColor: task.color } : {}) }}
|
|
onClick={() => onEditTask(task)}
|
|
title={`${task.title}${task.dueDate ? ` — Due ${format(new Date(task.dueDate), 'MMM d')}` : ''}`}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Color Picker Popover */}
|
|
{colorPicker && onTaskColorChange && (
|
|
<div
|
|
ref={colorPickerRef}
|
|
className="fixed z-50 bg-surface rounded-lg shadow-xl border border-border p-2"
|
|
style={{ left: colorPicker.x, top: colorPicker.y }}
|
|
>
|
|
<div className="grid grid-cols-4 gap-1.5 mb-2">
|
|
{GANTT_COLOR_PALETTE.map(c => (
|
|
<button
|
|
key={c}
|
|
onClick={() => {
|
|
onTaskColorChange(colorPicker.taskId, c)
|
|
setColorPicker(null)
|
|
}}
|
|
className="w-7 h-7 rounded-full border-2 border-transparent hover:border-gray-400 hover:scale-110 transition-all"
|
|
style={{ backgroundColor: c }}
|
|
/>
|
|
))}
|
|
</div>
|
|
<button
|
|
onClick={() => {
|
|
onTaskColorChange(colorPicker.taskId, null)
|
|
setColorPicker(null)
|
|
}}
|
|
className="w-full text-[10px] text-text-tertiary hover:text-text-primary text-center py-1 hover:bg-surface-tertiary rounded transition-colors"
|
|
>
|
|
Reset to default
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|