e1d1c392eb
Audit & Quality: - RTL: replaced 121 LTR-only utilities (text-left, pl-, left-) with logical properties - A11y: focus traps + ARIA on Modal/SlidePanel/TabbedModal, clickable divs→buttons - Theming: bg-white→bg-surface (170 instances), text-gray→semantic tokens - Performance: useMemo on filters, loading="lazy" on 24 images - CSS: prefers-reduced-motion, removed dead animations Component Splits: - PostDetailPanel: 1332→623 lines + 4 sub-components - ArtefactDetailPanel: 972→590 lines + 1 sub-component Brand Identity — Rawaj (رواج): - New name, DM Sans font, deep teal palette (#0d9488) - Custom SVG logo, forest-tinted dark mode - All emails branded with app name in subject line Design Refinement: - Dashboard: merged posts+deadlines into tabbed ActivityFeed, inline stats - Quieter: removed card lift, brand glow, gradient text, mesh backgrounds - CampaignDetail: prominent budget card, compact team avatars, Lucide icons - Consistent page titles via Header.jsx, standardized section headers - Finance page fully i18n'd (20+ hardcoded strings replaced) Budget Allocation Redesign: - Single source of truth: BudgetEntries (Campaign.budget deprecated) - Validation at all levels: main→campaign→track, expenses blocked if insufficient - Budget request workflow with CEO approval via public link - BudgetRequests table, CRUD routes, public approval page - Budget mutex for race condition prevention - Idempotent migration for existing campaign budgets Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
226 lines
9.2 KiB
React
226 lines
9.2 KiB
React
import { useState } from 'react'
|
||
import { ChevronLeft, ChevronRight, Calendar as CalendarIcon, CalendarDays } from 'lucide-react'
|
||
import { PRIORITY_CONFIG } from '../utils/api'
|
||
import { useLanguage } from '../i18n/LanguageContext'
|
||
|
||
const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
||
|
||
function getMonthData(year, month) {
|
||
const firstDay = new Date(year, month, 1).getDay()
|
||
const daysInMonth = new Date(year, month + 1, 0).getDate()
|
||
const prevDays = new Date(year, month, 0).getDate()
|
||
|
||
const cells = []
|
||
// Previous month trailing days
|
||
for (let i = firstDay - 1; i >= 0; i--) {
|
||
cells.push({ day: prevDays - i, current: false, date: new Date(year, month - 1, prevDays - i) })
|
||
}
|
||
// Current month
|
||
for (let d = 1; d <= daysInMonth; d++) {
|
||
cells.push({ day: d, current: true, date: new Date(year, month, d) })
|
||
}
|
||
// Next month leading days
|
||
const remaining = 42 - cells.length
|
||
for (let d = 1; d <= remaining; d++) {
|
||
cells.push({ day: d, current: false, date: new Date(year, month + 1, d) })
|
||
}
|
||
return cells
|
||
}
|
||
|
||
function getWeekData(startDate) {
|
||
const cells = []
|
||
const start = new Date(startDate)
|
||
start.setDate(start.getDate() - start.getDay())
|
||
for (let i = 0; i < 7; i++) {
|
||
const d = new Date(start)
|
||
d.setDate(start.getDate() + i)
|
||
cells.push({ day: d.getDate(), current: true, date: d })
|
||
}
|
||
return cells
|
||
}
|
||
|
||
function dateKey(d) {
|
||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||
}
|
||
|
||
export default function TaskCalendarView({ tasks, onTaskClick }) {
|
||
const { t } = useLanguage()
|
||
const today = new Date()
|
||
const [year, setYear] = useState(today.getFullYear())
|
||
const [month, setMonth] = useState(today.getMonth())
|
||
const [calView, setCalView] = useState('month')
|
||
const [weekStart, setWeekStart] = useState(() => {
|
||
const d = new Date(); d.setDate(d.getDate() - d.getDay()); return d
|
||
})
|
||
|
||
const cells = calView === 'month' ? getMonthData(year, month) : getWeekData(weekStart)
|
||
const todayKey = dateKey(today)
|
||
|
||
// Group tasks by due_date
|
||
const tasksByDate = {}
|
||
const unscheduled = []
|
||
for (const task of tasks) {
|
||
const dd = task.due_date || task.dueDate
|
||
if (dd) {
|
||
const key = dd.slice(0, 10) // yyyy-mm-dd
|
||
if (!tasksByDate[key]) tasksByDate[key] = []
|
||
tasksByDate[key].push(task)
|
||
} else {
|
||
unscheduled.push(task)
|
||
}
|
||
}
|
||
|
||
const prevMonth = () => {
|
||
if (month === 0) { setMonth(11); setYear(y => y - 1) }
|
||
else setMonth(m => m - 1)
|
||
}
|
||
const nextMonth = () => {
|
||
if (month === 11) { setMonth(0); setYear(y => y + 1) }
|
||
else setMonth(m => m + 1)
|
||
}
|
||
const prevWeek = () => setWeekStart(d => { const n = new Date(d); n.setDate(n.getDate() - 7); return n })
|
||
const nextWeek = () => setWeekStart(d => { const n = new Date(d); n.setDate(n.getDate() + 7); return n })
|
||
|
||
const goToday = () => {
|
||
setYear(today.getFullYear()); setMonth(today.getMonth())
|
||
const d = new Date(); d.setDate(d.getDate() - d.getDay()); setWeekStart(d)
|
||
}
|
||
|
||
const monthLabel = new Date(year, month).toLocaleString('default', { month: 'long', year: 'numeric' })
|
||
const weekLabel = (() => {
|
||
const start = new Date(weekStart)
|
||
start.setDate(start.getDate() - start.getDay())
|
||
const end = new Date(start); end.setDate(start.getDate() + 6)
|
||
const fmt = (d) => d.toLocaleString('default', { month: 'short', day: 'numeric' })
|
||
return `${fmt(start)} – ${fmt(end)}, ${end.getFullYear()}`
|
||
})()
|
||
|
||
const getPillColor = (task) => {
|
||
const p = task.priority || 'medium'
|
||
if (p === 'urgent') return 'bg-red-500 text-white'
|
||
if (p === 'high') return 'bg-orange-400 text-white'
|
||
if (p === 'medium') return 'bg-amber-400 text-amber-900'
|
||
return 'bg-gray-300 text-text-secondary'
|
||
}
|
||
|
||
return (
|
||
<div className="flex gap-4">
|
||
{/* Calendar grid */}
|
||
<div className="flex-1">
|
||
{/* Nav */}
|
||
<div className="flex items-center justify-between mb-4">
|
||
<div className="flex items-center gap-2">
|
||
<button onClick={calView === 'month' ? prevMonth : prevWeek} className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors">
|
||
<ChevronLeft className="w-4 h-4" />
|
||
</button>
|
||
<h3 className="text-sm font-semibold text-text-primary min-w-[180px] text-center">
|
||
{calView === 'month' ? monthLabel : weekLabel}
|
||
</h3>
|
||
<button onClick={calView === 'month' ? nextMonth : nextWeek} className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors">
|
||
<ChevronRight className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<div className="flex bg-surface-tertiary rounded-lg p-0.5">
|
||
<button
|
||
onClick={() => setCalView('month')}
|
||
className={`px-2.5 py-1 text-[11px] font-medium rounded-md transition-colors flex items-center gap-1 ${calView === 'month' ? 'bg-surface shadow-sm text-text-primary' : 'text-text-tertiary'}`}
|
||
>
|
||
<CalendarIcon className="w-3 h-3" />
|
||
Month
|
||
</button>
|
||
<button
|
||
onClick={() => setCalView('week')}
|
||
className={`px-2.5 py-1 text-[11px] font-medium rounded-md transition-colors flex items-center gap-1 ${calView === 'week' ? 'bg-surface shadow-sm text-text-primary' : 'text-text-tertiary'}`}
|
||
>
|
||
<CalendarDays className="w-3 h-3" />
|
||
Week
|
||
</button>
|
||
</div>
|
||
<button onClick={goToday} className="px-3 py-1 text-xs font-medium text-brand-primary hover:bg-brand-primary/5 rounded-lg transition-colors">
|
||
{t('tasks.today')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Day headers */}
|
||
<div className="grid grid-cols-7 mb-1">
|
||
{DAYS.map(d => (
|
||
<div key={d} className="text-center text-[10px] font-medium text-text-tertiary uppercase py-1">
|
||
{d}
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Cells */}
|
||
<div className="grid grid-cols-7 border-t border-l border-border">
|
||
{cells.map((cell, i) => {
|
||
const key = dateKey(cell.date)
|
||
const isToday = key === todayKey
|
||
const dayTasks = tasksByDate[key] || []
|
||
return (
|
||
<div
|
||
key={i}
|
||
className={`border-r border-b border-border ${calView === 'week' ? 'min-h-[300px]' : 'min-h-[90px]'} p-1 ${
|
||
cell.current ? 'bg-surface' : 'bg-surface-secondary/50'
|
||
}`}
|
||
>
|
||
<div className={`text-[11px] font-medium mb-0.5 w-6 h-6 flex items-center justify-center rounded-full ${
|
||
isToday ? 'bg-brand-primary text-white' : cell.current ? 'text-text-primary' : 'text-text-tertiary'
|
||
}`}>
|
||
{cell.day}
|
||
</div>
|
||
<div className="space-y-0.5">
|
||
{dayTasks.slice(0, calView === 'week' ? 10 : 3).map(task => (
|
||
<button
|
||
key={task._id || task.id}
|
||
onClick={() => onTaskClick(task)}
|
||
className={`w-full text-start text-[10px] px-1.5 py-0.5 rounded truncate font-medium hover:opacity-80 transition-opacity ${
|
||
task.status === 'done' ? 'bg-emerald-100 text-emerald-700 line-through' : getPillColor(task)
|
||
}`}
|
||
title={task.title}
|
||
>
|
||
{task.title}
|
||
</button>
|
||
))}
|
||
{dayTasks.length > (calView === 'week' ? 10 : 3) && (
|
||
<div className="text-[9px] text-text-tertiary text-center font-medium">
|
||
+{dayTasks.length - (calView === 'week' ? 10 : 3)} more
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Unscheduled sidebar */}
|
||
{unscheduled.length > 0 && (
|
||
<div className="w-48 shrink-0">
|
||
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-2">{t('tasks.unscheduled')}</h4>
|
||
<div className="space-y-1.5 max-h-[500px] overflow-y-auto">
|
||
{unscheduled.map(task => {
|
||
const priority = PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium
|
||
return (
|
||
<button
|
||
key={task._id || task.id}
|
||
onClick={() => onTaskClick(task)}
|
||
className="w-full text-start bg-surface border border-border rounded-lg p-2 hover:border-brand-primary/30 transition-colors"
|
||
>
|
||
<div className="flex items-center gap-1.5">
|
||
<div className={`w-2 h-2 rounded-full ${priority.color} shrink-0`} />
|
||
<span className={`text-xs font-medium truncate ${task.status === 'done' ? 'text-text-tertiary line-through' : 'text-text-primary'}`}>
|
||
{task.title}
|
||
</span>
|
||
</div>
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|