Files
marketing-app/client/src/components/TaskCalendarView.jsx
T
fahed e1d1c392eb feat: comprehensive UI overhaul + budget allocation redesign
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>
2026-03-15 15:36:19 +03:00

226 lines
9.2 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}