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>
790 lines
36 KiB
React
790 lines
36 KiB
React
import { useState, useEffect, useContext, useMemo } from 'react'
|
|
import { Plus, CheckSquare, Search, LayoutGrid, List, CalendarDays, X, SlidersHorizontal } from 'lucide-react'
|
|
import { AppContext } from '../App'
|
|
import { useAuth } from '../contexts/AuthContext'
|
|
import { useLanguage } from '../i18n/LanguageContext'
|
|
import { api, PRIORITY_CONFIG, getBrandColor } from '../utils/api'
|
|
import TaskCard from '../components/TaskCard'
|
|
import KanbanBoard from '../components/KanbanBoard'
|
|
import KanbanCard from '../components/KanbanCard'
|
|
import TaskDetailPanel from '../components/TaskDetailPanel'
|
|
import BulkSelectBar from '../components/BulkSelectBar'
|
|
import Modal from '../components/Modal'
|
|
import TaskCalendarView from '../components/TaskCalendarView'
|
|
import DatePresetPicker from '../components/DatePresetPicker'
|
|
import EmptyState from '../components/EmptyState'
|
|
import { useToast } from '../components/ToastContainer'
|
|
import { SkeletonKanbanBoard, SkeletonTable } from '../components/SkeletonLoader'
|
|
import { format } from 'date-fns'
|
|
|
|
const VIEW_MODES = ['board', 'list', 'calendar']
|
|
const VIEW_ICONS = { board: LayoutGrid, list: List, calendar: CalendarDays }
|
|
|
|
export default function Tasks() {
|
|
const { t } = useLanguage()
|
|
const { currentUser, brands } = useContext(AppContext)
|
|
const { user: authUser, canEditResource, canDeleteResource } = useAuth()
|
|
const toast = useToast()
|
|
|
|
// Data
|
|
const [tasks, setTasks] = useState([])
|
|
const [projects, setProjects] = useState([])
|
|
const [loading, setLoading] = useState(true)
|
|
// UI state
|
|
const [viewMode, setViewMode] = useState('board')
|
|
const [selectedTask, setSelectedTask] = useState(null)
|
|
|
|
// Filters
|
|
const [searchQuery, setSearchQuery] = useState('')
|
|
const [filterProject, setFilterProject] = useState('')
|
|
const [filterBrand, setFilterBrand] = useState('')
|
|
const [filterStatus, setFilterStatus] = useState([]) // empty = all
|
|
const [filterPriority, setFilterPriority] = useState('')
|
|
const [filterAssignee, setFilterAssignee] = useState('')
|
|
const [filterCreator, setFilterCreator] = useState('')
|
|
const [filterDateFrom, setFilterDateFrom] = useState('')
|
|
const [filterDateTo, setFilterDateTo] = useState('')
|
|
const [filterOverdue, setFilterOverdue] = useState(false)
|
|
const [activePreset, setActivePreset] = useState('')
|
|
const [showFilters, setShowFilters] = useState(false)
|
|
const [selectedIds, setSelectedIds] = useState(new Set())
|
|
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false)
|
|
const [showCreateModal, setShowCreateModal] = useState(false)
|
|
const [createForm, setCreateForm] = useState({ title: '', project_id: '', brand_id: '', priority: 'medium', assigned_to: '' })
|
|
const [createSaving, setCreateSaving] = useState(false)
|
|
|
|
// Assignable users & team
|
|
const [assignableUsers, setAssignableUsers] = useState([])
|
|
const [users, setUsers] = useState([])
|
|
|
|
const isSuperadmin = authUser?.role === 'superadmin'
|
|
|
|
useEffect(() => { loadTasks() }, [currentUser])
|
|
useEffect(() => {
|
|
api.get('/users/assignable').then(res => setAssignableUsers(Array.isArray(res) ? res : [])).catch(() => {})
|
|
api.get('/projects').then(res => setProjects(Array.isArray(res) ? res : [])).catch(() => {})
|
|
if (isSuperadmin) {
|
|
api.get('/team').then(res => setUsers(Array.isArray(res) ? res : [])).catch(() => {})
|
|
}
|
|
}, [isSuperadmin])
|
|
|
|
const loadTasks = async () => {
|
|
try {
|
|
const res = await api.get('/tasks')
|
|
setTasks(Array.isArray(res) ? res : [])
|
|
} catch (err) {
|
|
console.error('Failed to load tasks:', err)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
// Determine if any filter is active
|
|
const hasActiveFilters = searchQuery || filterProject || filterBrand || filterStatus.length > 0 || filterPriority || filterAssignee || filterCreator || filterDateFrom || filterDateTo || filterOverdue
|
|
|
|
const clearFilters = () => {
|
|
setSearchQuery('')
|
|
setFilterProject('')
|
|
setFilterBrand('')
|
|
setFilterStatus([])
|
|
setFilterPriority('')
|
|
setFilterAssignee('')
|
|
setFilterCreator('')
|
|
setFilterDateFrom('')
|
|
setFilterDateTo('')
|
|
setFilterOverdue(false)
|
|
}
|
|
|
|
// Client-side filtering
|
|
const filteredTasks = useMemo(() => {
|
|
return tasks.filter(task => {
|
|
// Search
|
|
if (searchQuery) {
|
|
const q = searchQuery.toLowerCase()
|
|
if (!(task.title || '').toLowerCase().includes(q) && !(task.description || '').toLowerCase().includes(q)) return false
|
|
}
|
|
// Project
|
|
if (filterProject && String(task.project_id || task.projectId || '') !== String(filterProject)) return false
|
|
// Brand
|
|
if (filterBrand && String(task.brand_id || task.brandId || '') !== String(filterBrand)) return false
|
|
// Status
|
|
if (filterStatus.length > 0 && !filterStatus.includes(task.status)) return false
|
|
// Priority
|
|
if (filterPriority && task.priority !== filterPriority) return false
|
|
// Assignee
|
|
if (filterAssignee) {
|
|
const assignee = task.assigned_to || task.assignedTo || task.assigned_to_id || task.assignedToId
|
|
if (String(assignee || '') !== String(filterAssignee)) return false
|
|
}
|
|
// Creator
|
|
if (filterCreator) {
|
|
const creator = task.created_by_user_id || task.createdByUserId
|
|
if (String(creator || '') !== String(filterCreator)) return false
|
|
}
|
|
// Date range
|
|
if (filterDateFrom) {
|
|
const dd = task.due_date || task.dueDate
|
|
if (!dd || new Date(dd) < new Date(filterDateFrom)) return false
|
|
}
|
|
if (filterDateTo) {
|
|
const dd = task.due_date || task.dueDate
|
|
if (!dd || new Date(dd) > new Date(filterDateTo + 'T23:59:59')) return false
|
|
}
|
|
// Overdue
|
|
if (filterOverdue) {
|
|
const dd = task.due_date || task.dueDate
|
|
if (!dd || new Date(dd) >= new Date() || task.status === 'done') return false
|
|
}
|
|
return true
|
|
})
|
|
}, [tasks, searchQuery, filterProject, filterBrand, filterStatus, filterPriority, filterAssignee, filterCreator, filterDateFrom, filterDateTo, filterOverdue])
|
|
|
|
// ─── CRUD ──────────────────────────────────────────
|
|
const handlePanelSave = async (taskId, data, files = []) => {
|
|
try {
|
|
if (taskId) {
|
|
// Edit mode
|
|
await api.patch(`/tasks/${taskId}`, data)
|
|
toast.success(t('tasks.updated'))
|
|
loadTasks()
|
|
const updated = { ...selectedTask, ...data }
|
|
setSelectedTask(updated)
|
|
} else {
|
|
// Create mode — create task then upload any pending files
|
|
const newTask = await api.post('/tasks', { ...data, is_personal: false })
|
|
const newId = newTask.Id || newTask.id || newTask._id
|
|
for (const file of files) {
|
|
const fd = new FormData()
|
|
fd.append('file', file)
|
|
await api.upload(`/tasks/${newId}/attachments`, fd)
|
|
}
|
|
toast.success(t('tasks.created'))
|
|
setSelectedTask(null)
|
|
loadTasks()
|
|
}
|
|
} catch (err) {
|
|
console.error('Save failed:', err)
|
|
if (err.message?.includes('403')) {
|
|
toast.error(t('tasks.canOnlyEditOwn'))
|
|
} else {
|
|
toast.error(t('common.saveFailed'))
|
|
}
|
|
}
|
|
}
|
|
|
|
const handlePanelDelete = async (taskId) => {
|
|
try {
|
|
await api.delete(`/tasks/${taskId}`)
|
|
toast.success(t('tasks.deleted'))
|
|
setSelectedTask(null)
|
|
loadTasks()
|
|
} catch (err) {
|
|
console.error('Delete failed:', err)
|
|
toast.error(t('common.deleteFailed'))
|
|
}
|
|
}
|
|
|
|
const handleCreateTask = async () => {
|
|
setCreateSaving(true)
|
|
try {
|
|
const data = {
|
|
title: createForm.title,
|
|
priority: createForm.priority,
|
|
status: 'todo',
|
|
project_id: createForm.project_id ? Number(createForm.project_id) : null,
|
|
brand_id: createForm.brand_id ? Number(createForm.brand_id) : null,
|
|
assigned_to: createForm.assigned_to ? Number(createForm.assigned_to) : null,
|
|
is_personal: false,
|
|
}
|
|
const created = await api.post('/tasks', data)
|
|
setShowCreateModal(false)
|
|
toast.success(t('tasks.created'))
|
|
loadTasks()
|
|
// Open detail panel for further editing
|
|
if (created) setSelectedTask(created)
|
|
} catch (err) {
|
|
console.error('Create task failed:', err)
|
|
toast.error(t('common.saveFailed'))
|
|
} finally {
|
|
setCreateSaving(false)
|
|
}
|
|
}
|
|
|
|
const handleBulkDelete = async () => {
|
|
try {
|
|
await api.post('/tasks/bulk-delete', { ids: [...selectedIds] })
|
|
toast.success(t('tasks.deleted'))
|
|
setSelectedIds(new Set())
|
|
setShowBulkDeleteConfirm(false)
|
|
loadTasks()
|
|
} catch (err) {
|
|
console.error('Bulk delete failed:', err)
|
|
toast.error(t('common.deleteFailed'))
|
|
}
|
|
}
|
|
|
|
const toggleSelect = (id) => {
|
|
setSelectedIds(prev => {
|
|
const next = new Set(prev)
|
|
if (next.has(id)) next.delete(id)
|
|
else next.add(id)
|
|
return next
|
|
})
|
|
}
|
|
|
|
const toggleSelectAll = () => {
|
|
if (selectedIds.size === sortedListTasks.length) setSelectedIds(new Set())
|
|
else setSelectedIds(new Set(sortedListTasks.map(t => t._id || t.id)))
|
|
}
|
|
|
|
const handleMove = async (taskId, newStatus) => {
|
|
// Optimistic update — move the card instantly
|
|
const prev = tasks
|
|
setTasks(tasks.map(t => (t._id || t.id) === taskId ? { ...t, status: newStatus } : t))
|
|
try {
|
|
await api.patch(`/tasks/${taskId}`, { status: newStatus })
|
|
toast.success(t('tasks.statusUpdated'))
|
|
} catch (err) {
|
|
setTasks(prev)
|
|
if (err.message?.includes('403')) toast.error(t('tasks.canOnlyEditOwn'))
|
|
else toast.error(t('common.updateFailed'))
|
|
}
|
|
}
|
|
|
|
const openTask = (task) => {
|
|
setSelectedTask(task)
|
|
}
|
|
|
|
// ─── List view sorting ────────────────────────────────
|
|
const [sortBy, setSortBy] = useState('due_date')
|
|
const [sortDir, setSortDir] = useState('asc')
|
|
|
|
const sortedListTasks = useMemo(() => {
|
|
const priorityOrder = { urgent: 0, high: 1, medium: 2, low: 3 }
|
|
const statusOrder = { todo: 0, in_progress: 1, done: 2 }
|
|
return [...filteredTasks].sort((a, b) => {
|
|
let cmp = 0
|
|
if (sortBy === 'due_date') {
|
|
const da = a.due_date || a.dueDate || ''
|
|
const db = b.due_date || b.dueDate || ''
|
|
if (!da && !db) cmp = 0
|
|
else if (!da) cmp = 1
|
|
else if (!db) cmp = -1
|
|
else cmp = da.localeCompare(db)
|
|
} else if (sortBy === 'priority') {
|
|
cmp = (priorityOrder[a.priority] ?? 2) - (priorityOrder[b.priority] ?? 2)
|
|
} else if (sortBy === 'status') {
|
|
cmp = (statusOrder[a.status] ?? 0) - (statusOrder[b.status] ?? 0)
|
|
} else if (sortBy === 'title') {
|
|
cmp = (a.title || '').localeCompare(b.title || '')
|
|
}
|
|
return sortDir === 'asc' ? cmp : -cmp
|
|
})
|
|
}, [filteredTasks, sortBy, sortDir])
|
|
|
|
const toggleSort = (col) => {
|
|
if (sortBy === col) setSortDir(d => d === 'asc' ? 'desc' : 'asc')
|
|
else { setSortBy(col); setSortDir('asc') }
|
|
}
|
|
|
|
// ─── Unique brands from tasks ─────────────────────────
|
|
const taskBrands = useMemo(() => {
|
|
const map = new Map()
|
|
for (const t of tasks) {
|
|
const bid = t.brand_id || t.brandId
|
|
const bname = t.brand_name || t.brandName
|
|
if (bid && bname) map.set(String(bid), bname)
|
|
}
|
|
return Array.from(map, ([id, name]) => ({ id, name }))
|
|
}, [tasks])
|
|
|
|
// ─── Unique projects from tasks ───────────────────────
|
|
const taskProjects = useMemo(() => {
|
|
const map = new Map()
|
|
for (const t of tasks) {
|
|
const pid = t.project_id || t.projectId
|
|
const pname = t.project_name || t.projectName
|
|
if (pid && pname) map.set(String(pid), pname)
|
|
}
|
|
return Array.from(map, ([id, name]) => ({ id, name }))
|
|
}, [tasks])
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="h-12 bg-surface-tertiary rounded-xl animate-pulse"></div>
|
|
{viewMode === 'list' ? <SkeletonTable rows={8} cols={6} /> : <SkeletonKanbanBoard />}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4 animate-fade-in">
|
|
{/* ─── Toolbar ──────────────────────────────── */}
|
|
<div className="flex items-center justify-between gap-3 flex-wrap">
|
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
|
{/* Search */}
|
|
<div className="relative flex-1 max-w-xs">
|
|
<Search className="absolute start-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
|
|
<input
|
|
type="text"
|
|
value={searchQuery}
|
|
onChange={e => setSearchQuery(e.target.value)}
|
|
placeholder={t('tasks.search')}
|
|
className="w-full ps-9 pe-3 py-1.5 text-sm border border-border rounded-lg bg-surface-primary text-text-primary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
|
/>
|
|
{searchQuery && (
|
|
<button onClick={() => setSearchQuery('')} className="absolute end-2 top-1/2 -translate-y-1/2 p-0.5 rounded text-text-tertiary hover:text-text-primary">
|
|
<X className="w-3.5 h-3.5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* View switcher */}
|
|
<div className="flex items-center bg-surface-tertiary rounded-lg p-0.5">
|
|
{VIEW_MODES.map(mode => {
|
|
const Icon = VIEW_ICONS[mode]
|
|
return (
|
|
<button
|
|
key={mode}
|
|
onClick={() => setViewMode(mode)}
|
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
|
|
viewMode === mode
|
|
? 'bg-surface text-text-primary shadow-sm'
|
|
: 'text-text-tertiary hover:text-text-secondary'
|
|
}`}
|
|
>
|
|
<Icon className="w-3.5 h-3.5" />
|
|
{t(`tasks.${mode}`)}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{/* Filter toggle */}
|
|
<button
|
|
onClick={() => setShowFilters(!showFilters)}
|
|
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border transition-colors ${
|
|
showFilters || hasActiveFilters
|
|
? 'border-brand-primary/30 bg-brand-primary/5 text-brand-primary'
|
|
: 'border-border text-text-tertiary hover:text-text-secondary hover:border-border-dark'
|
|
}`}
|
|
>
|
|
<SlidersHorizontal className="w-3.5 h-3.5" />
|
|
{t('tasks.filters')}
|
|
{hasActiveFilters && (
|
|
<span className="w-1.5 h-1.5 rounded-full bg-brand-primary" />
|
|
)}
|
|
</button>
|
|
|
|
{/* Task count */}
|
|
<span className="text-xs text-text-tertiary whitespace-nowrap">
|
|
{filteredTasks.length}{hasActiveFilters ? ` ${t('tasks.of')} ${tasks.length}` : ''} {filteredTasks.length !== 1 ? t('tasks.tasks') : t('tasks.task')}
|
|
</span>
|
|
</div>
|
|
|
|
<button
|
|
onClick={() => { setCreateForm({ title: '', project_id: '', brand_id: '', priority: 'medium', assigned_to: '' }); setShowCreateModal(true) }}
|
|
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 shrink-0"
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
{t('tasks.newTask')}
|
|
</button>
|
|
</div>
|
|
|
|
{/* ─── Filter Bar ───────────────────────────── */}
|
|
{showFilters && (
|
|
<div className="flex items-center gap-2 flex-wrap bg-surface-secondary rounded-xl px-4 py-3 border border-border-light">
|
|
{/* Project */}
|
|
<select
|
|
value={filterProject}
|
|
onChange={e => setFilterProject(e.target.value)}
|
|
className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
|
|
>
|
|
<option value="">{t('tasks.allProjects')}</option>
|
|
{taskProjects.map(p => (
|
|
<option key={p.id} value={p.id}>{p.name}</option>
|
|
))}
|
|
</select>
|
|
|
|
{/* Brand */}
|
|
<select
|
|
value={filterBrand}
|
|
onChange={e => setFilterBrand(e.target.value)}
|
|
className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
|
|
>
|
|
<option value="">{t('tasks.allBrands')}</option>
|
|
{taskBrands.map(b => (
|
|
<option key={b.id} value={b.id}>{b.name}</option>
|
|
))}
|
|
</select>
|
|
|
|
{/* Status chips */}
|
|
<div className="flex items-center gap-1">
|
|
{['todo', 'in_progress', 'done'].map(s => {
|
|
const active = filterStatus.length === 0 || filterStatus.includes(s)
|
|
return (
|
|
<button
|
|
key={s}
|
|
onClick={() => {
|
|
if (filterStatus.length === 0) {
|
|
// Currently all shown — click means show only this one
|
|
setFilterStatus([s])
|
|
} else if (filterStatus.includes(s)) {
|
|
const next = filterStatus.filter(x => x !== s)
|
|
setFilterStatus(next.length === 0 ? [] : next)
|
|
} else {
|
|
setFilterStatus([...filterStatus, s])
|
|
}
|
|
}}
|
|
className={`px-2.5 py-1 text-[11px] font-medium rounded-full border transition-colors ${
|
|
active
|
|
? 'bg-brand-primary/10 border-brand-primary/20 text-brand-primary'
|
|
: 'bg-surface border-border text-text-tertiary'
|
|
}`}
|
|
>
|
|
{t(`tasks.${s}`)}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{/* Priority */}
|
|
<select
|
|
value={filterPriority}
|
|
onChange={e => setFilterPriority(e.target.value)}
|
|
className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
|
|
>
|
|
<option value="">{t('tasks.allPriorities')}</option>
|
|
<option value="low">{t('tasks.priority.low')}</option>
|
|
<option value="medium">{t('tasks.priority.medium')}</option>
|
|
<option value="high">{t('tasks.priority.high')}</option>
|
|
<option value="urgent">{t('tasks.priority.urgent')}</option>
|
|
</select>
|
|
|
|
{/* Assignee */}
|
|
<select
|
|
value={filterAssignee}
|
|
onChange={e => setFilterAssignee(e.target.value)}
|
|
className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
|
|
>
|
|
<option value="">{t('tasks.allAssignees')}</option>
|
|
{(assignableUsers || []).map(m => (
|
|
<option key={m._id || m.team_member_id} value={m._id || m.team_member_id}>{m.name}</option>
|
|
))}
|
|
</select>
|
|
|
|
{/* Creator (superadmin only) */}
|
|
{isSuperadmin && (
|
|
<select
|
|
value={filterCreator}
|
|
onChange={e => setFilterCreator(e.target.value)}
|
|
className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
|
|
>
|
|
<option value="">{t('tasks.allCreators')}</option>
|
|
{users.map(m => (
|
|
<option key={m.id} value={m.id}>{m.name}</option>
|
|
))}
|
|
</select>
|
|
)}
|
|
|
|
{/* Date presets */}
|
|
<DatePresetPicker
|
|
activePreset={activePreset}
|
|
onSelect={(from, to, key) => { setFilterDateFrom(from); setFilterDateTo(to); setActivePreset(key) }}
|
|
onClear={() => { setFilterDateFrom(''); setFilterDateTo(''); setActivePreset('') }}
|
|
/>
|
|
|
|
{/* Date range */}
|
|
<div className="flex items-center gap-1">
|
|
<input
|
|
type="date"
|
|
value={filterDateFrom}
|
|
onChange={e => { setFilterDateFrom(e.target.value); setActivePreset('') }}
|
|
className="px-2 py-1.5 text-xs border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
|
|
title={t('posts.periodFrom')}
|
|
/>
|
|
<span className="text-text-tertiary text-xs">-</span>
|
|
<input
|
|
type="date"
|
|
value={filterDateTo}
|
|
onChange={e => { setFilterDateTo(e.target.value); setActivePreset('') }}
|
|
className="px-2 py-1.5 text-xs border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
|
|
title={t('posts.periodTo')}
|
|
/>
|
|
</div>
|
|
|
|
{/* Overdue toggle */}
|
|
<button
|
|
onClick={() => setFilterOverdue(!filterOverdue)}
|
|
className={`px-2.5 py-1 text-[11px] font-medium rounded-full border transition-colors ${
|
|
filterOverdue
|
|
? 'bg-red-50 border-red-200 text-red-600'
|
|
: 'bg-surface border-border text-text-tertiary'
|
|
}`}
|
|
>
|
|
{t('tasks.overdue')}
|
|
</button>
|
|
|
|
{/* Clear all */}
|
|
{hasActiveFilters && (
|
|
<button
|
|
onClick={clearFilters}
|
|
className="px-2.5 py-1 text-[11px] font-medium text-text-tertiary hover:text-text-primary transition-colors"
|
|
>
|
|
{t('tasks.clearFilters')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* ─── Views ────────────────────────────────── */}
|
|
{filteredTasks.length === 0 ? (
|
|
<EmptyState
|
|
icon={CheckSquare}
|
|
title={tasks.length === 0 ? t('tasks.noTasks') : t('tasks.noMatch')}
|
|
description={tasks.length === 0 ? t('tasks.createFirst') : t('tasks.tryFilter')}
|
|
actionLabel={tasks.length === 0 ? t('tasks.createTask') : null}
|
|
onAction={tasks.length === 0 ? () => { setSelectedTask({ title: '', description: '', priority: 'medium', start_date: '', due_date: '', status: 'todo', assigned_to: '', project_id: '' }) } : null}
|
|
secondaryActionLabel={hasActiveFilters ? t('tasks.clearFilters') : null}
|
|
onSecondaryAction={clearFilters}
|
|
/>
|
|
) : (
|
|
<>
|
|
{/* ─── Board View ──────────────────────── */}
|
|
{viewMode === 'board' && (
|
|
<KanbanBoard
|
|
columns={[
|
|
{ id: 'todo', label: t('tasks.todo'), color: 'bg-gray-400' },
|
|
{ id: 'in_progress', label: t('tasks.in_progress'), color: 'bg-blue-400' },
|
|
{ id: 'done', label: t('tasks.done'), color: 'bg-emerald-400' },
|
|
]}
|
|
items={filteredTasks}
|
|
getItemId={(t) => t._id || t.id}
|
|
onMove={handleMove}
|
|
emptyLabel={t('tasks.noTasks')}
|
|
renderCard={(task) => {
|
|
const dueDate = task.due_date || task.dueDate
|
|
const isOverdue = dueDate && new Date(dueDate) < new Date() && task.status !== 'done'
|
|
const assignee = task.assigned_name || task.assignedName
|
|
const brandName = task.brand_name || task.brandName
|
|
const projectName = task.project_name || task.projectName
|
|
return (
|
|
<KanbanCard
|
|
title={task.title}
|
|
thumbnail={task.thumbnail_url}
|
|
brandName={brandName}
|
|
assigneeName={assignee}
|
|
date={dueDate}
|
|
dateOverdue={isOverdue}
|
|
onClick={() => openTask(task)}
|
|
tags={projectName && (
|
|
<span className="text-[10px] px-1.5 py-0.5 rounded bg-surface-tertiary text-text-tertiary">
|
|
{projectName}
|
|
</span>
|
|
)}
|
|
/>
|
|
)
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* ─── List View ───────────────────────── */}
|
|
{viewMode === 'list' && (
|
|
<>
|
|
{selectedIds.size > 0 && (
|
|
<BulkSelectBar
|
|
selectedCount={selectedIds.size}
|
|
onClear={() => setSelectedIds(new Set())}
|
|
onDelete={() => setShowBulkDeleteConfirm(true)}
|
|
/>
|
|
)}
|
|
<div className="bg-surface rounded-xl border border-border overflow-clip">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-border bg-surface-secondary/50">
|
|
<th className="w-8 px-3 py-2.5">
|
|
<input
|
|
type="checkbox"
|
|
checked={sortedListTasks.length > 0 && selectedIds.size === sortedListTasks.length}
|
|
onChange={toggleSelectAll}
|
|
className="w-3.5 h-3.5 rounded border-border text-brand-primary cursor-pointer"
|
|
onClick={e => e.stopPropagation()}
|
|
/>
|
|
</th>
|
|
<th className="w-8 px-3 py-2.5"></th>
|
|
<th
|
|
className="text-start px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase cursor-pointer hover:text-text-primary"
|
|
onClick={() => toggleSort('title')}
|
|
>
|
|
{t('tasks.taskTitle')} {sortBy === 'title' && (sortDir === 'asc' ? '↑' : '↓')}
|
|
</th>
|
|
<th className="text-start px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase">{t('tasks.project')}</th>
|
|
<th className="text-start px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase">{t('tasks.brand')}</th>
|
|
<th
|
|
className="text-start px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase cursor-pointer hover:text-text-primary"
|
|
onClick={() => toggleSort('status')}
|
|
>
|
|
{t('tasks.status')} {sortBy === 'status' && (sortDir === 'asc' ? '↑' : '↓')}
|
|
</th>
|
|
<th className="text-start px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase">{t('tasks.assignee')}</th>
|
|
<th
|
|
className="text-start px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase cursor-pointer hover:text-text-primary"
|
|
onClick={() => toggleSort('due_date')}
|
|
>
|
|
{t('tasks.dueDate')} {sortBy === 'due_date' && (sortDir === 'asc' ? '↑' : '↓')}
|
|
</th>
|
|
<th
|
|
className="text-start px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase cursor-pointer hover:text-text-primary"
|
|
onClick={() => toggleSort('priority')}
|
|
>
|
|
{t('tasks.priority')} {sortBy === 'priority' && (sortDir === 'asc' ? '↑' : '↓')}
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{sortedListTasks.map(task => {
|
|
const priority = PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium
|
|
const dueDate = task.due_date || task.dueDate
|
|
const isOverdue = dueDate && new Date(dueDate) < new Date() && task.status !== 'done'
|
|
const projectName = task.project_name || task.projectName
|
|
const brandName = task.brand_name || task.brandName
|
|
const assignedName = task.assigned_name || task.assignedName
|
|
const statusLabels = { todo: t('tasks.todo'), in_progress: t('tasks.in_progress'), done: t('tasks.done') }
|
|
const statusColors = { todo: 'bg-gray-100 text-text-secondary', in_progress: 'bg-blue-100 text-blue-700', done: 'bg-emerald-100 text-emerald-700' }
|
|
|
|
return (
|
|
<tr
|
|
key={task._id || task.id}
|
|
onClick={() => openTask(task)}
|
|
className="border-b border-border-light hover:bg-surface-secondary/30 cursor-pointer transition-colors group"
|
|
>
|
|
<td className="px-3 py-2.5" onClick={e => e.stopPropagation()}>
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedIds.has(task._id || task.id)}
|
|
onChange={() => toggleSelect(task._id || task.id)}
|
|
className="w-3.5 h-3.5 rounded border-border text-brand-primary cursor-pointer"
|
|
/>
|
|
</td>
|
|
<td className="px-3 py-2.5">
|
|
<div className={`w-2.5 h-2.5 rounded-full ${priority.color}`} title={priority.label} />
|
|
</td>
|
|
<td className="px-3 py-2.5">
|
|
<span className={`font-medium ${task.status === 'done' ? 'text-text-tertiary line-through' : 'text-text-primary'}`}>
|
|
{task.title}
|
|
</span>
|
|
{(task.comment_count || task.commentCount) > 0 && (
|
|
<span className="ms-2 text-[10px] text-text-tertiary">💬 {task.comment_count || task.commentCount}</span>
|
|
)}
|
|
</td>
|
|
<td className="px-3 py-2.5 text-text-tertiary text-xs">{projectName || '—'}</td>
|
|
<td className="px-3 py-2.5">
|
|
{brandName ? (
|
|
<span className={`text-[10px] px-1.5 py-0.5 rounded ${getBrandColor(brandName).bg} ${getBrandColor(brandName).text}`}>
|
|
{brandName}
|
|
</span>
|
|
) : <span className="text-text-tertiary text-xs">—</span>}
|
|
</td>
|
|
<td className="px-3 py-2.5">
|
|
<span className={`text-[11px] px-2 py-0.5 rounded-full font-medium ${statusColors[task.status] || ''}`}>
|
|
{statusLabels[task.status] || task.status}
|
|
</span>
|
|
</td>
|
|
<td className="px-3 py-2.5 text-text-tertiary text-xs">{assignedName || t('common.unassigned')}</td>
|
|
<td className="px-3 py-2.5">
|
|
{dueDate ? (
|
|
<span className={`text-xs ${isOverdue ? 'text-red-500 font-medium' : 'text-text-tertiary'}`}>
|
|
{format(new Date(dueDate), 'MMM d, yyyy')}
|
|
</span>
|
|
) : <span className="text-text-tertiary text-xs">—</span>}
|
|
</td>
|
|
<td className="px-3 py-2.5">
|
|
<span className="text-xs text-text-tertiary">{priority.label}</span>
|
|
</td>
|
|
</tr>
|
|
)
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* ─── Calendar View ───────────────────── */}
|
|
{viewMode === 'calendar' && (
|
|
<TaskCalendarView tasks={filteredTasks} onTaskClick={openTask} />
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* ─── Create Task Modal ──────────────────── */}
|
|
<Modal isOpen={showCreateModal} onClose={() => setShowCreateModal(false)} title={t('tasks.newTask')} size="md">
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.taskTitle')} *</label>
|
|
<input type="text" value={createForm.title} onChange={e => setCreateForm(f => ({ ...f, title: e.target.value }))}
|
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary" autoFocus />
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.project')}</label>
|
|
<select value={createForm.project_id} onChange={e => setCreateForm(f => ({ ...f, project_id: e.target.value }))}
|
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary">
|
|
<option value="">—</option>
|
|
{projects.map(p => <option key={p._id || p.id} value={p._id || p.id}>{p.name}</option>)}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.priority')}</label>
|
|
<select value={createForm.priority} onChange={e => setCreateForm(f => ({ ...f, priority: e.target.value }))}
|
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary">
|
|
{Object.entries(PRIORITY_CONFIG).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.assignedTo')}</label>
|
|
<select value={createForm.assigned_to} onChange={e => setCreateForm(f => ({ ...f, assigned_to: e.target.value }))}
|
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary">
|
|
<option value="">{t('common.unassigned')}</option>
|
|
{assignableUsers.map(u => <option key={u._id || u.id} value={u._id || u.id}>{u.name}</option>)}
|
|
</select>
|
|
</div>
|
|
<button onClick={handleCreateTask} disabled={!createForm.title || createSaving}
|
|
className={`w-full px-4 py-2.5 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm ${createSaving ? 'btn-loading' : ''}`}>
|
|
{t('tasks.newTask')}
|
|
</button>
|
|
</div>
|
|
</Modal>
|
|
|
|
{/* ─── Bulk Delete Confirmation Modal ─────── */}
|
|
<Modal
|
|
isOpen={showBulkDeleteConfirm}
|
|
onClose={() => setShowBulkDeleteConfirm(false)}
|
|
title={t('common.bulkDeleteConfirm').replace('{count}', selectedIds.size)}
|
|
isConfirm
|
|
danger
|
|
confirmText={t('common.deleteSelected')}
|
|
onConfirm={handleBulkDelete}
|
|
>
|
|
{t('common.bulkDeleteDesc')}
|
|
</Modal>
|
|
|
|
{/* ─── Task Detail Side Panel (edit only) ─── */}
|
|
{selectedTask && (
|
|
<TaskDetailPanel
|
|
task={selectedTask}
|
|
onClose={() => setSelectedTask(null)}
|
|
onSave={handlePanelSave}
|
|
onDelete={canDeleteResource('task', selectedTask) ? handlePanelDelete : undefined}
|
|
projects={projects}
|
|
users={assignableUsers}
|
|
brands={brands}
|
|
/>
|
|
)}
|
|
|
|
</div>
|
|
)
|
|
}
|