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>
133 lines
4.9 KiB
React
133 lines
4.9 KiB
React
import { useState, useMemo } from 'react'
|
|
import {
|
|
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
|
|
eachDayOfInterval, format, isSameMonth, isToday,
|
|
addMonths, subMonths, isBefore, isAfter, isSameDay
|
|
} from 'date-fns'
|
|
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
|
import { getBrandColor } from '../utils/api'
|
|
|
|
const CAMPAIGN_COLORS = [
|
|
'bg-indigo-400', 'bg-pink-400', 'bg-emerald-400', 'bg-amber-400',
|
|
'bg-purple-400', 'bg-cyan-400', 'bg-rose-400', 'bg-teal-400',
|
|
]
|
|
|
|
export default function CampaignCalendar({ campaigns = [] }) {
|
|
const [currentMonth, setCurrentMonth] = useState(new Date())
|
|
|
|
const days = useMemo(() => {
|
|
const monthStart = startOfMonth(currentMonth)
|
|
const monthEnd = endOfMonth(currentMonth)
|
|
const calStart = startOfWeek(monthStart, { weekStartsOn: 0 })
|
|
const calEnd = endOfWeek(monthEnd, { weekStartsOn: 0 })
|
|
return eachDayOfInterval({ start: calStart, end: calEnd })
|
|
}, [currentMonth])
|
|
|
|
const getCampaignsForDay = (day) => {
|
|
return campaigns.filter((c) => {
|
|
const start = new Date(c.startDate)
|
|
const end = new Date(c.endDate)
|
|
return (isSameDay(day, start) || isAfter(day, start)) &&
|
|
(isSameDay(day, end) || isBefore(day, end))
|
|
})
|
|
}
|
|
|
|
const isStartOfCampaign = (day, campaign) => {
|
|
return isSameDay(day, new Date(campaign.startDate))
|
|
}
|
|
|
|
const isEndOfCampaign = (day, campaign) => {
|
|
return isSameDay(day, new Date(campaign.endDate))
|
|
}
|
|
|
|
return (
|
|
<div className="bg-surface rounded-xl border border-border overflow-hidden">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
|
<h3 className="text-lg font-semibold text-text-primary">
|
|
{format(currentMonth, 'MMMM yyyy')}
|
|
</h3>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}
|
|
className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-secondary"
|
|
>
|
|
<ChevronLeft className="w-5 h-5" />
|
|
</button>
|
|
<button
|
|
onClick={() => setCurrentMonth(new Date())}
|
|
className="px-3 py-1 text-sm font-medium rounded-lg hover:bg-surface-tertiary text-text-secondary"
|
|
>
|
|
Today
|
|
</button>
|
|
<button
|
|
onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}
|
|
className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-secondary"
|
|
>
|
|
<ChevronRight className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Day names */}
|
|
<div className="calendar-grid border-b border-border">
|
|
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((d) => (
|
|
<div key={d} className="px-2 py-2 text-center text-xs font-semibold text-text-tertiary uppercase tracking-wider">
|
|
{d}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Calendar grid */}
|
|
<div className="calendar-grid">
|
|
{days.map((day, i) => {
|
|
const dayCampaigns = getCampaignsForDay(day)
|
|
const inMonth = isSameMonth(day, currentMonth)
|
|
const today = isToday(day)
|
|
|
|
return (
|
|
<div
|
|
key={i}
|
|
className={`min-h-[80px] p-1 border-b border-r border-border-light relative ${
|
|
!inMonth ? 'bg-surface-secondary/50' : ''
|
|
}`}
|
|
>
|
|
<span className={`text-xs font-medium inline-flex items-center justify-center w-6 h-6 rounded-full ${
|
|
today ? 'bg-brand-primary text-white' :
|
|
inMonth ? 'text-text-primary' : 'text-text-tertiary'
|
|
}`}>
|
|
{format(day, 'd')}
|
|
</span>
|
|
|
|
<div className="space-y-0.5 mt-0.5">
|
|
{dayCampaigns.slice(0, 3).map((campaign, ci) => {
|
|
const colorIndex = campaigns.indexOf(campaign) % CAMPAIGN_COLORS.length
|
|
const isStart = isStartOfCampaign(day, campaign)
|
|
const isEnd = isEndOfCampaign(day, campaign)
|
|
|
|
return (
|
|
<div
|
|
key={campaign._id || ci}
|
|
className={`h-5 text-[10px] font-medium text-white flex items-center px-1 truncate ${CAMPAIGN_COLORS[colorIndex]} ${
|
|
isStart ? 'rounded-l-full ms-0' : '-ms-1'
|
|
} ${isEnd ? 'rounded-r-full me-0' : '-me-1'}`}
|
|
title={campaign.name}
|
|
>
|
|
{isStart ? campaign.name : ''}
|
|
</div>
|
|
)
|
|
})}
|
|
{dayCampaigns.length > 3 && (
|
|
<div className="text-[10px] text-text-tertiary px-1">
|
|
+{dayCampaigns.length - 3} more
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|