update on timeline on portfolio view + some corrections

This commit is contained in:
fahed
2026-02-10 13:20:49 +03:00
parent d15e54044e
commit 334727b232
37 changed files with 5119 additions and 1440 deletions

View File

@@ -2,6 +2,7 @@ import { Routes, Route, Navigate } from 'react-router-dom'
import { useState, useEffect, createContext } from 'react'
import { AuthProvider, useAuth } from './contexts/AuthContext'
import { LanguageProvider } from './i18n/LanguageContext'
import { ToastProvider } from './components/ToastContainer'
import Layout from './components/Layout'
import Dashboard from './pages/Dashboard'
import PostProduction from './pages/PostProduction'
@@ -275,7 +276,9 @@ function App() {
return (
<LanguageProvider>
<AuthProvider>
<AppContent />
<ToastProvider>
<AppContent />
</ToastProvider>
</AuthProvider>
</LanguageProvider>
)

View File

@@ -0,0 +1,63 @@
import { useLanguage } from '../i18n/LanguageContext'
export default function EmptyState({
icon: Icon,
title,
description,
actionLabel,
onAction,
secondaryActionLabel,
onSecondaryAction,
compact = false
}) {
const { t } = useLanguage()
if (compact) {
return (
<div className="py-8 text-center">
{Icon && <Icon className="w-8 h-8 text-text-tertiary mx-auto mb-2" />}
<p className="text-sm text-text-secondary">{title}</p>
{description && <p className="text-xs text-text-tertiary mt-1">{description}</p>}
{actionLabel && (
<button
onClick={onAction}
className="mt-3 text-sm text-brand-primary hover:text-brand-primary-light font-medium"
>
{actionLabel}
</button>
)}
</div>
)
}
return (
<div className="py-16 text-center">
{Icon && (
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-surface-tertiary mb-4">
<Icon className="w-8 h-8 text-text-tertiary" />
</div>
)}
<h3 className="text-lg font-semibold text-text-primary mb-2">{title}</h3>
{description && <p className="text-sm text-text-secondary max-w-md mx-auto mb-6">{description}</p>}
<div className="flex items-center justify-center gap-3">
{actionLabel && (
<button
onClick={onAction}
className="px-5 py-2.5 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm transition-all hover:-translate-y-0.5"
>
{actionLabel}
</button>
)}
{secondaryActionLabel && (
<button
onClick={onSecondaryAction}
className="px-5 py-2.5 bg-white border border-border text-text-primary rounded-lg text-sm font-medium hover:bg-surface-secondary transition-colors"
>
{secondaryActionLabel}
</button>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,78 @@
import { AlertCircle, CheckCircle } from 'lucide-react'
export default function FormInput({
label,
type = 'text',
value,
onChange,
placeholder,
required = false,
error,
success,
helpText,
disabled = false,
className = '',
rows,
...props
}) {
const hasError = Boolean(error)
const hasSuccess = Boolean(success)
const isTextarea = type === 'textarea'
const inputClasses = `
w-full px-3 py-2 text-sm border rounded-lg
focus:outline-none focus:ring-2 transition-all
${hasError
? 'border-red-300 focus:border-red-500 focus:ring-red-500/20'
: hasSuccess
? 'border-emerald-300 focus:border-emerald-500 focus:ring-emerald-500/20'
: 'border-border focus:border-brand-primary focus:ring-brand-primary/20'
}
${disabled ? 'bg-surface-tertiary cursor-not-allowed opacity-60' : 'bg-white'}
${className}
`.trim()
const InputComponent = isTextarea ? 'textarea' : 'input'
return (
<div className="space-y-1">
{label && (
<label className="block text-sm font-medium text-text-primary">
{label}
{required && <span className="text-red-500 ml-0.5">*</span>}
</label>
)}
<div className="relative">
<InputComponent
type={isTextarea ? undefined : type}
value={value}
onChange={onChange}
placeholder={placeholder}
disabled={disabled}
className={inputClasses}
rows={rows}
{...props}
/>
{/* Validation icon */}
{(hasError || hasSuccess) && (
<div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none">
{hasError ? (
<AlertCircle className="w-4 h-4 text-red-500" />
) : (
<CheckCircle className="w-4 h-4 text-emerald-500" />
)}
</div>
)}
</div>
{/* Helper text or error message */}
{(error || success || helpText) && (
<p className={`text-xs ${hasError ? 'text-red-600' : hasSuccess ? 'text-emerald-600' : 'text-text-tertiary'}`}>
{error || success || helpText}
</p>
)}
</div>
)
}

View File

@@ -0,0 +1,507 @@
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
import { format, differenceInDays, startOfDay, addDays, isBefore, isAfter } from 'date-fns'
import { Calendar, Rows3, Rows4 } from 'lucide-react'
const STATUS_COLORS = {
todo: 'bg-gray-500',
in_progress: 'bg-blue-500',
done: 'bg-emerald-500',
planning: 'bg-amber-500',
active: 'bg-blue-500',
paused: 'bg-orange-500',
completed: 'bg-emerald-500',
cancelled: 'bg-red-400',
draft: 'bg-gray-400',
in_review: 'bg-yellow-500',
approved: 'bg-indigo-500',
scheduled: 'bg-purple-500',
published: 'bg-emerald-500',
planned: 'bg-amber-400',
// tracks
organic_social: 'bg-green-500',
paid_social: 'bg-blue-500',
paid_search: 'bg-amber-500',
seo_content: 'bg-purple-500',
production: 'bg-red-500',
}
const PRIORITY_BORDER = {
urgent: 'ring-2 ring-red-400',
high: 'ring-2 ring-orange-300',
medium: '',
low: '',
}
const ZOOM_LEVELS = [
{ key: 'day', label: 'Day', pxPerDay: 48 },
{ key: 'week', label: 'Week', pxPerDay: 20 },
]
function getInitials(name) {
if (!name) return '?'
return name.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2)
}
export default function InteractiveTimeline({ items = [], mapItem, onDateChange, onItemClick, readOnly = false }) {
const containerRef = useRef(null)
const didDragRef = useRef(false)
const optimisticRef = useRef({}) // { [itemId]: { startDate, endDate } } — keeps bar in place until fresh data arrives
const [zoomIdx, setZoomIdx] = useState(0)
const [barMode, setBarMode] = useState('expanded') // 'compact' | 'expanded'
const [tooltip, setTooltip] = useState(null)
const [dragState, setDragState] = useState(null) // { itemId, mode: 'move'|'resize-left'|'resize-right', startX, origStart, origEnd }
const dragStateRef = useRef(null)
const pxPerDay = ZOOM_LEVELS[zoomIdx].pxPerDay
const today = useMemo(() => startOfDay(new Date()), [])
// Clear optimistic overrides when fresh data arrives
useEffect(() => {
optimisticRef.current = {}
}, [items])
// Map items
const mapped = useMemo(() => {
return items.map(raw => {
const m = mapItem(raw)
const opt = optimisticRef.current[m.id]
return {
...m,
_raw: raw,
startDate: opt?.startDate || (m.startDate ? startOfDay(new Date(m.startDate)) : null),
endDate: opt?.endDate || (m.endDate ? startOfDay(new Date(m.endDate)) : null),
}
})
}, [items, mapItem])
// Compute time range
const { earliest, latest, totalDays, days } = useMemo(() => {
let earliest = addDays(today, -7)
let latest = addDays(today, 28)
mapped.forEach(item => {
const s = item.startDate || today
const e = item.endDate || addDays(s, 3)
if (isBefore(s, earliest)) earliest = addDays(s, -3)
if (isAfter(e, latest)) latest = addDays(e, 7)
})
const totalDays = differenceInDays(latest, earliest) + 1
const days = []
for (let i = 0; i < totalDays; i++) {
days.push(addDays(earliest, i))
}
return { earliest, latest, totalDays, days }
}, [mapped, today])
// Auto-scroll to today on mount
useEffect(() => {
if (containerRef.current) {
const todayOffset = differenceInDays(today, earliest) * pxPerDay
containerRef.current.scrollLeft = Math.max(0, todayOffset - 200)
}
}, [earliest, pxPerDay, today])
// Drag handlers
const handleMouseDown = useCallback((e, item, mode) => {
if (readOnly || !onDateChange) return
e.preventDefault()
e.stopPropagation()
didDragRef.current = false
const initial = {
itemId: item.id,
mode,
startX: e.clientX,
origStart: item.startDate || today,
origEnd: item.endDate || addDays(item.startDate || today, 3),
}
dragStateRef.current = initial
setDragState(initial)
}, [readOnly, onDateChange, today])
useEffect(() => {
if (!dragState) return
const handleMouseMove = (e) => {
const cur = dragStateRef.current
if (!cur) return
const dx = e.clientX - cur.startX
const dayDelta = Math.round(dx / pxPerDay)
if (dayDelta === 0) return
didDragRef.current = true
const newState = { ...cur }
if (cur.mode === 'move') {
newState.currentStart = addDays(cur.origStart, dayDelta)
newState.currentEnd = addDays(cur.origEnd, dayDelta)
} else if (cur.mode === 'resize-left') {
const newStart = addDays(cur.origStart, dayDelta)
if (isBefore(newStart, cur.origEnd)) {
newState.currentStart = newStart
newState.currentEnd = cur.origEnd
}
} else if (cur.mode === 'resize-right') {
const newEnd = addDays(cur.origEnd, dayDelta)
if (isAfter(newEnd, cur.origStart)) {
newState.currentStart = cur.origStart
newState.currentEnd = newEnd
}
}
dragStateRef.current = newState
setDragState(newState)
}
const handleMouseUp = () => {
const prev = dragStateRef.current
dragStateRef.current = null
setDragState(null)
if (prev && (prev.currentStart || prev.currentEnd) && onDateChange) {
const startDate = prev.currentStart || prev.origStart
const endDate = prev.currentEnd || prev.origEnd
// Keep bar in place visually until fresh data arrives
optimisticRef.current[prev.itemId] = { startDate, endDate }
onDateChange(prev.itemId, {
startDate: format(startDate, 'yyyy-MM-dd'),
endDate: format(endDate, 'yyyy-MM-dd'),
})
}
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
return () => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
}, [dragState?.itemId, pxPerDay, onDateChange])
const getBarPosition = useCallback((item) => {
let start, end
// If this item is being dragged, use the drag state
if (dragState && dragState.itemId === item.id && (dragState.currentStart || dragState.currentEnd)) {
start = dragState.currentStart || item.startDate || today
end = dragState.currentEnd || item.endDate || addDays(start, 3)
} else {
// Check optimistic position (keeps bar in place after drop, before API data refreshes)
const opt = optimisticRef.current[item.id]
start = opt?.startDate || item.startDate || today
end = opt?.endDate || item.endDate || addDays(start, 3)
}
// Ensure end is after start
if (!isAfter(end, start)) end = addDays(start, 1)
const left = differenceInDays(start, earliest) * pxPerDay
const width = Math.max(pxPerDay, (differenceInDays(end, start) + 1) * pxPerDay)
return { left, width }
}, [earliest, pxPerDay, today, dragState])
const scrollToToday = () => {
if (containerRef.current) {
const todayOffset = differenceInDays(today, earliest) * pxPerDay
containerRef.current.scrollTo({ left: Math.max(0, todayOffset - 200), behavior: 'smooth' })
}
}
const isExpanded = barMode === 'expanded'
const rowHeight = isExpanded ? 100 : 52
const barHeight = isExpanded ? 84 : 36
const headerHeight = 48
const labelWidth = isExpanded ? 280 : 220
const todayOffset = differenceInDays(today, earliest) * pxPerDay
if (items.length === 0) {
return (
<div className="bg-white rounded-xl border border-border py-16 text-center">
<Calendar className="w-12 h-12 text-text-tertiary mx-auto mb-3" />
<p className="text-text-secondary font-medium">No items to display</p>
<p className="text-sm text-text-tertiary mt-1">Add items with dates to see the timeline</p>
</div>
)
}
return (
<div className="bg-white rounded-xl border border-border overflow-hidden">
{/* 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">
{ZOOM_LEVELS.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>
<div className="flex items-center gap-2">
<button
onClick={() => setBarMode(m => m === 'compact' ? 'expanded' : 'compact')}
className="flex items-center gap-1.5 px-3 py-1 text-xs font-medium text-text-secondary hover:text-text-primary hover:bg-surface-tertiary rounded-md transition-colors"
title={isExpanded ? 'Compact bars' : 'Expanded bars'}
>
{isExpanded ? <Rows4 className="w-3.5 h-3.5" /> : <Rows3 className="w-3.5 h-3.5" />}
{isExpanded ? 'Compact' : 'Expand'}
</button>
<button
onClick={scrollToToday}
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>
{/* Timeline */}
<div ref={containerRef} className="overflow-x-auto relative" style={{ cursor: dragState ? 'grabbing' : undefined }}>
<div style={{ minWidth: `${labelWidth + totalDays * pxPerDay}px` }}>
{/* Day header */}
<div className="flex sticky top-0 z-20 bg-white border-b border-border" style={{ height: headerHeight }}>
<div className="shrink-0 border-r border-border bg-surface-secondary flex items-center px-4 sticky left-0 z-30" style={{ width: labelWidth }}>
<span className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">Item</span>
</div>
<div className="flex relative">
{days.map((day, i) => {
const isToday = differenceInDays(day, today) === 0
const isWeekend = day.getDay() === 0 || day.getDay() === 6
const isMonthStart = day.getDate() === 1
return (
<div
key={i}
style={{ width: pxPerDay, minWidth: pxPerDay }}
className={`flex flex-col items-center justify-center border-r border-border-light text-[10px] leading-tight ${
isToday ? 'bg-red-50 font-bold text-red-600' :
isWeekend ? 'bg-surface-tertiary/40 text-text-tertiary' :
'text-text-tertiary'
} ${isMonthStart ? 'border-l-2 border-l-text-tertiary/30' : ''}`}
>
{pxPerDay >= 30 && <div>{format(day, 'd')}</div>}
{pxPerDay >= 40 && <div className="text-[8px] uppercase">{format(day, 'EEE')}</div>}
{pxPerDay < 30 && day.getDate() % 7 === 1 && <div>{format(day, 'd')}</div>}
</div>
)
})}
</div>
</div>
{/* Rows */}
{mapped.map((item, idx) => {
const { left, width } = getBarPosition(item)
const statusColor = STATUS_COLORS[item.status] || STATUS_COLORS[item.type] || 'bg-gray-400'
const priorityRing = PRIORITY_BORDER[item.priority] || ''
const isDragging = dragState?.itemId === item.id
return (
<div
key={item.id}
className={`flex border-b border-border-light group/row hover:bg-surface-secondary/50 ${isDragging ? 'bg-blue-50/30' : ''}`}
style={{ height: rowHeight }}
>
{/* Label column */}
<div
className={`shrink-0 border-r border-border flex ${isExpanded ? 'flex-col justify-center gap-1' : 'items-center gap-2'} px-3 overflow-hidden sticky left-0 z-10 bg-white group-hover/row:bg-surface-secondary/50`}
style={{ width: labelWidth }}
>
{isExpanded ? (
<>
<div className="flex items-center gap-2">
{item.assigneeName && (
<div className="w-6 h-6 rounded-full bg-brand-primary/10 text-brand-primary flex items-center justify-center text-[9px] font-bold shrink-0">
{getInitials(item.assigneeName)}
</div>
)}
<span className="text-sm font-semibold text-text-primary truncate">{item.label}</span>
</div>
{item.description && (
<p className="text-[11px] text-text-tertiary line-clamp-2 leading-tight">{item.description}</p>
)}
{item.tags && item.tags.length > 0 && (
<div className="flex items-center gap-1 flex-wrap">
{item.tags.slice(0, 4).map((tag, i) => (
<span key={i} className="text-[9px] px-1.5 py-0.5 rounded bg-surface-tertiary text-text-secondary font-medium">{tag}</span>
))}
</div>
)}
</>
) : (
<>
{item.assigneeName && (
<div className="w-6 h-6 rounded-full bg-brand-primary/10 text-brand-primary flex items-center justify-center text-[9px] font-bold shrink-0">
{getInitials(item.assigneeName)}
</div>
)}
<span className="text-xs font-medium text-text-primary truncate">{item.label}</span>
</>
)}
</div>
{/* Bar area */}
<div className="relative flex-1" style={{ height: rowHeight }}>
{/* Today line */}
{todayOffset >= 0 && (
<div
className="absolute top-0 bottom-0 w-0.5 bg-red-400 z-10"
style={{ left: `${todayOffset + pxPerDay / 2}px` }}
>
{idx === 0 && (
<div className="absolute -top-0 left-1 text-[8px] font-bold text-red-500 bg-red-50 px-1 rounded whitespace-nowrap">
Today
</div>
)}
</div>
)}
{/* The bar */}
<div
className={`absolute rounded-lg ${statusColor} ${priorityRing} shadow-sm transition-shadow hover:shadow-md select-none overflow-hidden group ${
!readOnly && onDateChange ? 'cursor-grab active:cursor-grabbing' : 'cursor-pointer'
} ${isDragging ? 'opacity-80 shadow-lg' : ''}`}
style={{
left: `${left}px`,
width: `${width}px`,
height: `${barHeight}px`,
top: isExpanded ? '8px' : '8px',
}}
onMouseDown={(e) => handleMouseDown(e, item, 'move')}
onClick={(e) => {
if (didDragRef.current) {
didDragRef.current = false
return
}
if (onItemClick) {
onItemClick(item._raw)
}
}}
onMouseEnter={(e) => {
if (!dragState) {
const rect = e.currentTarget.getBoundingClientRect()
setTooltip({
item,
x: rect.left + rect.width / 2,
y: rect.top - 8,
})
}
}}
onMouseLeave={() => setTooltip(null)}
>
{/* Left resize handle */}
{!readOnly && onDateChange && (
<div
className="absolute left-0 top-0 bottom-0 w-2 cursor-col-resize hover:bg-white/30 z-10"
onMouseDown={(e) => handleMouseDown(e, item, 'resize-left')}
/>
)}
{/* Bar content */}
{isExpanded ? (
<div className="flex flex-col gap-0.5 px-3 py-1.5 flex-1 min-w-0 h-full">
<div className="flex items-center gap-1.5">
{item.assigneeName && width > 60 && (
<span className="text-[9px] font-bold text-white/80 bg-white/20 w-5 h-5 rounded-full flex items-center justify-center shrink-0">
{getInitials(item.assigneeName)}
</span>
)}
{width > 80 && (
<span className="text-xs font-semibold text-white truncate">
{item.label}
</span>
)}
{width > 120 && item.status && (
<span className="text-[9px] text-white/70 bg-white/15 px-1.5 py-0.5 rounded ml-auto shrink-0">
{item.status.replace(/_/g, ' ')}
</span>
)}
</div>
{width > 100 && item.description && (
<p className="text-[10px] text-white/60 line-clamp-2 leading-tight">
{item.description}
</p>
)}
{width > 80 && (
<div className="flex items-center gap-1.5 mt-auto">
{item.tags && item.tags.slice(0, 3).map((tag, i) => (
<span key={i} className="text-[8px] px-1 py-0.5 rounded bg-white/15 text-white/70 font-medium">{tag}</span>
))}
{width > 140 && item.startDate && item.endDate && (
<span className="text-[8px] text-white/50 ml-auto">
{format(item.startDate, 'MMM d')} {format(item.endDate, 'MMM d')}
</span>
)}
</div>
)}
</div>
) : (
<div className="flex items-center gap-1.5 px-3 flex-1 min-w-0 h-full">
{item.assigneeName && width > 60 && (
<span className="text-[9px] font-bold text-white/80 bg-white/20 w-5 h-5 rounded-full flex items-center justify-center shrink-0">
{getInitials(item.assigneeName)}
</span>
)}
{width > 80 && (
<span className="text-xs font-medium text-white truncate">
{item.label}
</span>
)}
</div>
)}
{/* Right resize handle */}
{!readOnly && onDateChange && (
<div
className="absolute right-0 top-0 bottom-0 w-2 cursor-col-resize hover:bg-white/30 z-10"
onMouseDown={(e) => handleMouseDown(e, item, 'resize-right')}
/>
)}
</div>
</div>
</div>
)
})}
</div>
</div>
{/* Tooltip */}
{tooltip && !dragState && (
<div
className="fixed z-50 pointer-events-none"
style={{
left: tooltip.x,
top: tooltip.y,
transform: 'translate(-50%, -100%)',
}}
>
<div className="bg-gray-900 text-white text-xs rounded-lg px-3 py-2 shadow-lg max-w-xs">
<div className="font-semibold mb-1">{tooltip.item.label}</div>
<div className="text-gray-300 space-y-0.5">
{tooltip.item.startDate && (
<div>Start: {format(tooltip.item.startDate, 'MMM d, yyyy')}</div>
)}
{tooltip.item.endDate && (
<div>End: {format(tooltip.item.endDate, 'MMM d, yyyy')}</div>
)}
{tooltip.item.assigneeName && (
<div>Assignee: {tooltip.item.assigneeName}</div>
)}
{tooltip.item.status && (
<div>Status: {tooltip.item.status.replace(/_/g, ' ')}</div>
)}
</div>
{!readOnly && onDateChange && (
<div className="text-gray-400 mt-1 text-[10px] italic">
Drag to move · Drag edges to resize
</div>
)}
</div>
</div>
)}
</div>
)
}

View File

@@ -19,7 +19,7 @@ export default function PostCard({ post, onClick, onMove, compact = false }) {
return (
<div
onClick={onClick}
className="bg-white rounded-lg p-3 border border-border hover:border-brand-primary/30 hover:shadow-md cursor-pointer transition-all group overflow-hidden"
className="bg-white rounded-lg p-3 border border-border hover:border-brand-primary/30 cursor-pointer transition-all group overflow-hidden card-hover"
>
{post.thumbnail_url && (
<div className="w-[calc(100%+1.5rem)] h-32 -mx-3 -mt-3 mb-2 rounded-t-lg overflow-hidden">

View File

@@ -0,0 +1,118 @@
// Reusable skeleton components for loading states
export function SkeletonCard() {
return (
<div className="bg-white rounded-xl border border-border p-5 animate-pulse">
<div className="h-4 bg-surface-tertiary rounded w-3/4 mb-3"></div>
<div className="h-3 bg-surface-tertiary rounded w-1/2 mb-2"></div>
<div className="h-3 bg-surface-tertiary rounded w-2/3"></div>
</div>
)
}
export function SkeletonStatCard() {
return (
<div className="bg-white rounded-xl border border-border p-5 animate-pulse">
<div className="flex items-start justify-between mb-4">
<div className="w-10 h-10 bg-surface-tertiary rounded-lg"></div>
<div className="h-3 bg-surface-tertiary rounded w-16"></div>
</div>
<div className="h-8 bg-surface-tertiary rounded w-20 mb-2"></div>
<div className="h-3 bg-surface-tertiary rounded w-24"></div>
</div>
)
}
export function SkeletonTable({ rows = 5, cols = 6 }) {
return (
<div className="bg-white rounded-xl border border-border overflow-hidden animate-pulse">
<div className="border-b border-border bg-surface-secondary p-4">
<div className="flex gap-4">
{[...Array(cols)].map((_, i) => (
<div key={i} className="h-3 bg-surface-tertiary rounded w-20"></div>
))}
</div>
</div>
<div className="divide-y divide-border-light">
{[...Array(rows)].map((_, i) => (
<div key={i} className="p-4">
<div className="flex gap-4">
{[...Array(cols)].map((_, j) => (
<div key={j} className="h-4 bg-surface-tertiary rounded flex-1"></div>
))}
</div>
</div>
))}
</div>
</div>
)
}
export function SkeletonKanbanBoard() {
return (
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-4">
{[...Array(5)].map((_, colIdx) => (
<div key={colIdx} className="animate-pulse">
<div className="flex items-center gap-2 mb-3">
<div className="w-2.5 h-2.5 bg-surface-tertiary rounded-full"></div>
<div className="h-4 bg-surface-tertiary rounded w-24"></div>
<div className="h-5 bg-surface-tertiary rounded-full w-8"></div>
</div>
<div className="bg-surface-secondary rounded-xl p-2 space-y-2 min-h-[400px]">
{[...Array(3)].map((_, cardIdx) => (
<div key={cardIdx} className="bg-white rounded-lg border border-border p-3">
<div className="h-4 bg-surface-tertiary rounded w-full mb-2"></div>
<div className="h-3 bg-surface-tertiary rounded w-3/4 mb-3"></div>
<div className="flex gap-2">
<div className="h-5 bg-surface-tertiary rounded w-16"></div>
<div className="h-5 bg-surface-tertiary rounded w-20"></div>
</div>
</div>
))}
</div>
</div>
))}
</div>
)
}
export function SkeletonDashboard() {
return (
<div className="space-y-6">
{/* Header */}
<div className="animate-pulse">
<div className="h-8 w-64 bg-surface-tertiary rounded-lg mb-2"></div>
<div className="h-4 w-48 bg-surface-tertiary rounded"></div>
</div>
{/* Stat cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{[...Array(4)].map((_, i) => (
<SkeletonStatCard key={i} />
))}
</div>
{/* Content cards */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{[...Array(2)].map((_, i) => (
<div key={i} className="bg-white rounded-xl border border-border animate-pulse">
<div className="px-5 py-4 border-b border-border">
<div className="h-5 bg-surface-tertiary rounded w-32"></div>
</div>
<div className="divide-y divide-border-light">
{[...Array(5)].map((_, j) => (
<div key={j} className="px-5 py-3 flex gap-3">
<div className="flex-1 space-y-2">
<div className="h-4 bg-surface-tertiary rounded w-2/3"></div>
<div className="h-3 bg-surface-tertiary rounded w-1/2"></div>
</div>
<div className="h-6 bg-surface-tertiary rounded w-16"></div>
</div>
))}
</div>
</div>
))}
</div>
</div>
)
}

View File

@@ -30,7 +30,7 @@ export default function TaskCard({ task, onMove, showProject = true }) {
const assignedName = task.assigned_name || task.assignedName
return (
<div className={`bg-white rounded-lg border border-border p-3 card-hover group ${isExternallyAssigned ? 'border-l-[3px] border-l-blue-400' : ''}`}>
<div className={`bg-white rounded-lg border border-border p-3 card-hover group cursor-pointer ${isExternallyAssigned ? 'border-l-[3px] border-l-blue-400' : ''}`}>
<div className="flex items-start gap-2.5">
{/* Priority dot */}
<div className={`w-2.5 h-2.5 rounded-full ${priority.color} mt-1.5 shrink-0`} title={priority.label} />

View File

@@ -0,0 +1,50 @@
import { useEffect } from 'react'
import { X, CheckCircle, AlertCircle, Info, AlertTriangle } from 'lucide-react'
const TOAST_ICONS = {
success: CheckCircle,
error: AlertCircle,
info: Info,
warning: AlertTriangle,
}
const TOAST_COLORS = {
success: 'bg-emerald-50 border-emerald-200 text-emerald-800',
error: 'bg-red-50 border-red-200 text-red-800',
info: 'bg-blue-50 border-blue-200 text-blue-800',
warning: 'bg-amber-50 border-amber-200 text-amber-800',
}
const ICON_COLORS = {
success: 'text-emerald-500',
error: 'text-red-500',
info: 'text-blue-500',
warning: 'text-amber-500',
}
export default function Toast({ message, type = 'info', onClose, duration = 4000 }) {
const Icon = TOAST_ICONS[type]
const colorClass = TOAST_COLORS[type]
const iconColor = ICON_COLORS[type]
useEffect(() => {
if (duration > 0) {
const timer = setTimeout(onClose, duration)
return () => clearTimeout(timer)
}
}, [duration, onClose])
return (
<div className={`flex items-start gap-3 p-4 rounded-xl border shadow-lg ${colorClass} animate-slide-in min-w-[300px] max-w-md`}>
<Icon className={`w-5 h-5 shrink-0 mt-0.5 ${iconColor}`} />
<p className="flex-1 text-sm font-medium leading-snug">{message}</p>
<button
onClick={onClose}
className="p-0.5 hover:bg-black/5 rounded transition-colors shrink-0"
aria-label="Close"
>
<X className="w-4 h-4" />
</button>
</div>
)
}

View File

@@ -0,0 +1,52 @@
import { createContext, useContext, useState, useCallback } from 'react'
import Toast from './Toast'
const ToastContext = createContext()
export function useToast() {
const context = useContext(ToastContext)
if (!context) {
throw new Error('useToast must be used within ToastProvider')
}
return context
}
export function ToastProvider({ children }) {
const [toasts, setToasts] = useState([])
const addToast = useCallback((message, type = 'info', duration = 4000) => {
const id = Date.now() + Math.random()
setToasts(prev => [...prev, { id, message, type, duration }])
}, [])
const removeToast = useCallback((id) => {
setToasts(prev => prev.filter(t => t.id !== id))
}, [])
const toast = {
success: (message, duration) => addToast(message, 'success', duration),
error: (message, duration) => addToast(message, 'error', duration),
info: (message, duration) => addToast(message, 'info', duration),
warning: (message, duration) => addToast(message, 'warning', duration),
}
return (
<ToastContext.Provider value={toast}>
{children}
{/* Toast container - fixed position */}
<div className="fixed top-4 right-4 z-50 flex flex-col gap-2 pointer-events-none">
<div className="flex flex-col gap-2 pointer-events-auto">
{toasts.map(t => (
<Toast
key={t.id}
message={t.message}
type={t.type}
duration={t.duration}
onClose={() => removeToast(t.id)}
/>
))}
</div>
</div>
</ToastContext.Provider>
)
}

View File

@@ -55,16 +55,15 @@ export function AuthProvider({ children }) {
// Check if current user owns a resource
const isOwner = (resource) => {
if (!user || !resource) return false
return resource.created_by_user_id === user.id
const creatorId = resource.created_by_user_id || resource.createdByUserId
return creatorId === user.id
}
// Check if current user is assigned to a resource
const isAssignedTo = (resource) => {
if (!user || !resource) return false
const teamMemberId = user.team_member_id || user.teamMemberId
if (!teamMemberId) return false
const assignedTo = resource.assigned_to || resource.assignedTo
return assignedTo === teamMemberId
return assignedTo === user.id
}
// Check if user can edit a specific resource (owns it, assigned to it, or has role)

View File

@@ -9,13 +9,13 @@ const LanguageContext = createContext()
export function LanguageProvider({ children }) {
const [lang, setLangState] = useState(() => {
// Load from localStorage or default to 'en'
return localStorage.getItem('samaya-lang') || 'en'
return localStorage.getItem('digitalhub-lang') || 'en'
})
const setLang = (newLang) => {
if (newLang !== 'en' && newLang !== 'ar') return
setLangState(newLang)
localStorage.setItem('samaya-lang', newLang)
localStorage.setItem('digitalhub-lang', newLang)
}
const dir = lang === 'ar' ? 'rtl' : 'ltr'

View File

@@ -1,6 +1,6 @@
{
"app.name": "سمايا",
"app.subtitle": "مركز التسويق",
"app.name": "المركز الرقمي",
"app.subtitle": "المنصة",
"nav.dashboard": "لوحة التحكم",
"nav.campaigns": "الحملات",
"nav.finance": "المالية والعائد",
@@ -26,6 +26,10 @@
"common.loading": "جاري التحميل...",
"common.unassigned": "غير مُسند",
"common.required": "مطلوب",
"common.saveFailed": "فشل الحفظ. حاول مجدداً.",
"common.updateFailed": "فشل التحديث. حاول مجدداً.",
"common.deleteFailed": "فشل الحذف. حاول مجدداً.",
"common.clearFilters": "مسح الفلاتر",
"auth.login": "تسجيل الدخول",
"auth.email": "البريد الإلكتروني",
@@ -61,7 +65,7 @@
"dashboard.noPostsYet": "لا توجد منشورات بعد. أنشئ منشورك الأول!",
"dashboard.upcomingDeadlines": "المواعيد النهائية القادمة",
"dashboard.noUpcomingDeadlines": "لا توجد مواعيد نهائية هذا الأسبوع. 🎉",
"dashboard.loadingHub": "جاري تحميل مركز سمايا للتسويق...",
"dashboard.loadingHub": "جاري تحميل المركز الرقمي...",
"posts.title": "إنتاج المحتوى",
"posts.newPost": "منشور جديد",
@@ -109,6 +113,13 @@
"posts.attachFromAssets": "إرفاق من الأصول",
"posts.selectAssets": "اختر أصلاً لإرفاقه",
"posts.noAssetsFound": "لا توجد أصول",
"posts.created": "تم إنشاء المنشور بنجاح!",
"posts.updated": "تم تحديث المنشور بنجاح!",
"posts.deleted": "تم حذف المنشور بنجاح!",
"posts.statusUpdated": "تم تحديث حالة المنشور!",
"posts.attachmentDeleted": "تم حذف المرفق!",
"posts.createFirstPost": "أنشئ أول منشور لك للبدء بإنتاج المحتوى.",
"posts.tryDifferentFilter": "جرب تعديل الفلاتر لرؤية المزيد من المنشورات.",
"posts.status.draft": "مسودة",
"posts.status.in_review": "قيد المراجعة",
@@ -151,6 +162,11 @@
"tasks.priority.medium": "متوسط",
"tasks.priority.high": "عالي",
"tasks.priority.urgent": "عاجل",
"tasks.created": "تم إنشاء المهمة بنجاح!",
"tasks.updated": "تم تحديث المهمة بنجاح!",
"tasks.deleted": "تم حذف المهمة بنجاح!",
"tasks.statusUpdated": "تم تحديث حالة المهمة!",
"tasks.canOnlyEditOwn": "يمكنك فقط تعديل مهامك الخاصة.",
"team.title": "الفريق",
"team.members": "أعضاء الفريق",
@@ -197,7 +213,7 @@
"settings.english": "English",
"settings.arabic": "عربي",
"settings.restartTutorial": "إعادة تشغيل الدليل التعليمي",
"settings.tutorialDesc": "هل تحتاج إلى تذكير؟ أعد تشغيل الدليل التفاعلي للتعرف على جميع ميزات مركز سمايا للتسويق.",
"settings.tutorialDesc": "هل تحتاج إلى تذكير؟ أعد تشغيل الدليل التفاعلي للتعرف على جميع ميزات المركز الرقمي.",
"settings.general": "عام",
"settings.onboardingTutorial": "الدليل التعليمي",
"settings.tutorialRestarted": "تم إعادة تشغيل الدليل!",
@@ -230,7 +246,7 @@
"tutorial.filters.title": "التصفية والتركيز",
"tutorial.filters.desc": "استخدم الفلاتر للتركيز على علامات أو منصات أو أعضاء فريق محددين.",
"login.title": "سمايا للتسويق",
"login.title": "المركز الرقمي",
"login.subtitle": "سجل دخولك للمتابعة",
"login.forgotPassword": "نسيت كلمة المرور؟",
"login.defaultCreds": "بيانات الدخول الافتراضية:",
@@ -246,5 +262,17 @@
"profile.completeYourProfile": "أكمل ملفك الشخصي",
"profile.completeDesc": "يرجى إكمال ملفك الشخصي للوصول إلى جميع الميزات ومساعدة فريقك في العثور عليك.",
"profile.completeProfileBtn": "إكمال الملف",
"profile.later": "لاحقاً"
"profile.later": "لاحقاً",
"timeline.title": "الجدول الزمني",
"timeline.day": "يوم",
"timeline.week": "أسبوع",
"timeline.today": "اليوم",
"timeline.startDate": "تاريخ البدء",
"timeline.dragToMove": "اسحب للنقل",
"timeline.dragToResize": "اسحب الحواف لتغيير الحجم",
"timeline.noItems": "لا توجد عناصر للعرض",
"timeline.addItems": "أضف عناصر بتواريخ لعرض الجدول الزمني",
"timeline.tracks": "المسارات",
"timeline.timeline": "الجدول الزمني"
}

View File

@@ -1,6 +1,6 @@
{
"app.name": "Samaya",
"app.subtitle": "Marketing Hub",
"app.name": "Digital Hub",
"app.subtitle": "Platform",
"nav.dashboard": "Dashboard",
"nav.campaigns": "Campaigns",
"nav.finance": "Finance & ROI",
@@ -26,6 +26,10 @@
"common.loading": "Loading...",
"common.unassigned": "Unassigned",
"common.required": "Required",
"common.saveFailed": "Failed to save. Please try again.",
"common.updateFailed": "Failed to update. Please try again.",
"common.deleteFailed": "Failed to delete. Please try again.",
"common.clearFilters": "Clear Filters",
"auth.login": "Sign In",
"auth.email": "Email",
@@ -61,7 +65,7 @@
"dashboard.noPostsYet": "No posts yet. Create your first post!",
"dashboard.upcomingDeadlines": "Upcoming Deadlines",
"dashboard.noUpcomingDeadlines": "No upcoming deadlines this week. 🎉",
"dashboard.loadingHub": "Loading Samaya Marketing Hub...",
"dashboard.loadingHub": "Loading Digital Hub...",
"posts.title": "Post Production",
"posts.newPost": "New Post",
@@ -109,6 +113,13 @@
"posts.attachFromAssets": "Attach from Assets",
"posts.selectAssets": "Select an asset to attach",
"posts.noAssetsFound": "No assets found",
"posts.created": "Post created successfully!",
"posts.updated": "Post updated successfully!",
"posts.deleted": "Post deleted successfully!",
"posts.statusUpdated": "Post status updated!",
"posts.attachmentDeleted": "Attachment deleted!",
"posts.createFirstPost": "Create your first post to get started with content production.",
"posts.tryDifferentFilter": "Try adjusting your filters to see more posts.",
"posts.status.draft": "Draft",
"posts.status.in_review": "In Review",
@@ -151,6 +162,11 @@
"tasks.priority.medium": "Medium",
"tasks.priority.high": "High",
"tasks.priority.urgent": "Urgent",
"tasks.created": "Task created successfully!",
"tasks.updated": "Task updated successfully!",
"tasks.deleted": "Task deleted successfully!",
"tasks.statusUpdated": "Task status updated!",
"tasks.canOnlyEditOwn": "You can only edit your own tasks.",
"team.title": "Team",
"team.members": "Team Members",
@@ -197,7 +213,7 @@
"settings.english": "English",
"settings.arabic": "Arabic",
"settings.restartTutorial": "Restart Tutorial",
"settings.tutorialDesc": "Need a refresher? Restart the interactive tutorial to learn about all the features of the Samaya Marketing Hub.",
"settings.tutorialDesc": "Need a refresher? Restart the interactive tutorial to learn about all the features of Digital Hub.",
"settings.general": "General",
"settings.onboardingTutorial": "Onboarding Tutorial",
"settings.tutorialRestarted": "Tutorial Restarted!",
@@ -230,7 +246,7 @@
"tutorial.filters.title": "Filter & Focus",
"tutorial.filters.desc": "Use filters to focus on specific brands, platforms, or team members.",
"login.title": "Samaya Marketing",
"login.title": "Digital Hub",
"login.subtitle": "Sign in to continue",
"login.forgotPassword": "Forgot password?",
"login.defaultCreds": "Default credentials:",
@@ -246,5 +262,17 @@
"profile.completeYourProfile": "Complete Your Profile",
"profile.completeDesc": "Please complete your profile to access all features and help your team find you.",
"profile.completeProfileBtn": "Complete Profile",
"profile.later": "Later"
"profile.later": "Later",
"timeline.title": "Timeline",
"timeline.day": "Day",
"timeline.week": "Week",
"timeline.today": "Today",
"timeline.startDate": "Start Date",
"timeline.dragToMove": "Drag to move",
"timeline.dragToResize": "Drag edges to resize",
"timeline.noItems": "No items to display",
"timeline.addItems": "Add items with dates to see the timeline",
"timeline.tracks": "Tracks",
"timeline.timeline": "Timeline"
}

View File

@@ -137,6 +137,21 @@ textarea {
100% { background-position: 200% 0; }
}
@keyframes pulse-subtle {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
@keyframes bounce-subtle {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-4px); }
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.animate-fade-in {
animation: fadeIn 0.3s ease-out forwards;
}
@@ -149,6 +164,14 @@ textarea {
animation: scaleIn 0.2s ease-out forwards;
}
.animate-pulse-subtle {
animation: pulse-subtle 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
.animate-spin {
animation: spin 1s linear infinite;
}
/* Stagger children */
.stagger-children > * {
opacity: 0;
@@ -204,7 +227,49 @@ button:hover:not(:disabled) {
box-shadow: 0 4px 12px -2px rgba(0, 0, 0, 0.12);
}
button:active:not(:disabled) {
transform: translateY(0);
transform: translateY(0) scale(0.98);
}
button:disabled {
cursor: not-allowed;
opacity: 0.6;
}
/* Focus states for accessibility */
button:focus-visible,
input:focus-visible,
textarea:focus-visible,
select:focus-visible {
outline: 2px solid var(--color-brand-primary);
outline-offset: 2px;
}
/* Input hover states */
input:not(:disabled):hover,
textarea:not(:disabled):hover,
select:not(:disabled):hover {
border-color: var(--color-brand-primary-light);
}
/* Loading button state */
.btn-loading {
position: relative;
pointer-events: none;
color: transparent !important;
}
.btn-loading::after {
content: '';
position: absolute;
left: 50%;
top: 50%;
width: 16px;
height: 16px;
margin-left: -8px;
margin-top: -8px;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: spin 0.6s linear infinite;
color: white;
}
/* Kanban column */
@@ -217,3 +282,146 @@ button:active:not(:disabled) {
display: grid;
grid-template-columns: repeat(7, 1fr);
}
/* Ripple effect on buttons (optional enhancement) */
@keyframes ripple {
0% {
transform: scale(0);
opacity: 0.5;
}
100% {
transform: scale(2.5);
opacity: 0;
}
}
/* Badge pulse animation */
.badge-pulse {
animation: pulse-subtle 2s ease-in-out infinite;
}
/* Smooth height transitions */
.transition-height {
transition: height 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Better table row hover */
tbody tr:hover {
background-color: var(--color-surface-secondary);
}
/* Better select styling */
select {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%2364748b' d='M6 9L1 4h10z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.75rem center;
background-size: 12px;
appearance: none;
padding-right: 2.5rem;
}
[dir="rtl"] select {
background-position: left 0.75rem center;
padding-right: 0.75rem;
padding-left: 2.5rem;
}
/* Checkbox and radio improvements */
input[type="checkbox"],
input[type="radio"] {
cursor: pointer;
transition: all 0.2s ease;
}
input[type="checkbox"]:checked,
input[type="radio"]:checked {
background-color: var(--color-brand-primary);
border-color: var(--color-brand-primary);
}
/* Disabled state improvements */
input:disabled,
textarea:disabled,
select:disabled {
background-color: var(--color-surface-tertiary);
cursor: not-allowed;
opacity: 0.6;
}
/* Success/error input states */
.input-error {
border-color: #ef4444 !important;
}
.input-error:focus {
border-color: #ef4444 !important;
ring-color: rgba(239, 68, 68, 0.2) !important;
}
.input-success {
border-color: #10b981 !important;
}
.input-success:focus {
border-color: #10b981 !important;
ring-color: rgba(16, 185, 129, 0.2) !important;
}
/* Tooltip (if needed) */
.tooltip {
position: relative;
}
.tooltip::after {
content: attr(data-tooltip);
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%) translateY(-8px);
padding: 0.5rem 0.75rem;
background: var(--color-sidebar);
color: white;
font-size: 0.75rem;
border-radius: 0.5rem;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s, transform 0.2s;
}
.tooltip:hover::after {
opacity: 1;
transform: translateX(-50%) translateY(-4px);
}
/* Loading spinner for inline use */
.spinner {
display: inline-block;
width: 1em;
height: 1em;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
/* Skeleton shimmer effect */
@keyframes shimmer-animation {
0% {
background-position: -468px 0;
}
100% {
background-position: 468px 0;
}
}
.skeleton-shimmer {
background: linear-gradient(
90deg,
var(--color-surface-tertiary) 0%,
var(--color-surface-secondary) 50%,
var(--color-surface-tertiary) 100%
);
background-size: 468px 100%;
animation: shimmer-animation 1.5s ease-in-out infinite;
}

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useContext } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { ArrowLeft, Plus, Edit2, Trash2, DollarSign, Eye, MousePointer, Target, TrendingUp, FileText, Megaphone, Search, Globe, Pencil, Users, X, UserPlus } from 'lucide-react'
import { ArrowLeft, Plus, Edit2, Trash2, DollarSign, Eye, MousePointer, Target, TrendingUp, FileText, Megaphone, Search, Globe, Pencil, Users, X, UserPlus, MessageCircle, Settings } from 'lucide-react'
import { format } from 'date-fns'
import { AppContext } from '../App'
import { useAuth } from '../contexts/AuthContext'
@@ -66,6 +66,9 @@ export default function CampaignDetail() {
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [trackToDelete, setTrackToDelete] = useState(null)
const [selectedPost, setSelectedPost] = useState(null)
const [showDiscussion, setShowDiscussion] = useState(false)
const [showEditModal, setShowEditModal] = useState(false)
const [editForm, setEditForm] = useState({})
const isCreator = campaign?.createdByUserId === user?.id || campaign?.created_by_user_id === user?.id
const canManage = isSuperadmin || (permissions?.canEditCampaigns && isCreator)
@@ -202,6 +205,39 @@ export default function CampaignDetail() {
setShowTrackModal(true)
}
const openEditCampaign = () => {
setEditForm({
name: campaign.name || '',
description: campaign.description || '',
status: campaign.status || 'planning',
start_date: campaign.start_date ? new Date(campaign.start_date).toISOString().slice(0, 10) : '',
end_date: campaign.end_date ? new Date(campaign.end_date).toISOString().slice(0, 10) : '',
goals: campaign.goals || '',
platforms: campaign.platforms || [],
notes: campaign.notes || '',
})
setShowEditModal(true)
}
const saveCampaignEdit = async () => {
try {
await api.patch(`/campaigns/${id}`, {
name: editForm.name,
description: editForm.description,
status: editForm.status,
start_date: editForm.start_date,
end_date: editForm.end_date,
goals: editForm.goals,
platforms: editForm.platforms,
notes: editForm.notes,
})
setShowEditModal(false)
loadAll()
} catch (err) {
console.error('Failed to update campaign:', err)
}
}
const openMetrics = (track) => {
setMetricsTrack(track)
setMetricsForm({
@@ -236,37 +272,65 @@ export default function CampaignDetail() {
const totalRevenue = tracks.reduce((s, t) => s + (t.revenue || 0), 0)
return (
<div className="space-y-6 animate-fade-in">
<div className="flex gap-6 animate-fade-in">
{/* Main content */}
<div className={`space-y-6 min-w-0 ${showDiscussion ? 'flex-1' : 'w-full'}`}>
{/* Header */}
<div className="flex items-start gap-4">
<button onClick={() => navigate('/campaigns')} className="mt-1 p-1.5 hover:bg-surface-tertiary rounded-lg">
<ArrowLeft className="w-5 h-5 text-text-secondary" />
</button>
<div className="flex-1">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-1">
<h1 className="text-xl font-bold text-text-primary">{campaign.name}</h1>
<StatusBadge status={campaign.status} />
{campaign.brand_name && <BrandBadge brand={campaign.brand_name} />}
</div>
{campaign.description && <p className="text-sm text-text-secondary">{campaign.description}</p>}
<div className="flex items-center gap-4 mt-2 text-xs text-text-tertiary">
<div className="flex items-center gap-3 mt-2 text-xs text-text-tertiary">
{campaign.start_date && campaign.end_date && (
<span>{format(new Date(campaign.start_date), 'MMM d')} {format(new Date(campaign.end_date), 'MMM d, yyyy')}</span>
)}
<span className="flex items-center gap-1">
<span>
Budget: {campaign.budget > 0 ? `${campaign.budget.toLocaleString()} SAR` : 'Not set'}
{canSetBudget && (
<button
onClick={() => { setBudgetValue(campaign.budget || ''); setEditingBudget(true) }}
className="p-0.5 rounded text-text-tertiary hover:text-brand-primary hover:bg-surface-tertiary"
title="Edit budget"
>
<Pencil className="w-3 h-3" />
</button>
)}
</span>
{campaign.platforms && campaign.platforms.length > 0 && (
<PlatformIcons platforms={campaign.platforms} size={16} />
)}
</div>
</div>
{/* Action buttons */}
<div className="flex items-center gap-2 shrink-0">
<button
onClick={() => setShowDiscussion(prev => !prev)}
className={`flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
showDiscussion
? 'bg-brand-primary text-white shadow-sm'
: 'bg-surface-tertiary text-text-secondary hover:bg-surface-tertiary/80 hover:text-text-primary'
}`}
>
<MessageCircle className="w-4 h-4" />
Discussion
</button>
{canSetBudget && (
<button
onClick={() => { setBudgetValue(campaign.budget || ''); setEditingBudget(true) }}
className="flex items-center gap-1.5 px-3 py-2 bg-surface-tertiary text-text-secondary hover:bg-surface-tertiary/80 hover:text-text-primary rounded-lg text-sm font-medium transition-colors"
>
<DollarSign className="w-4 h-4" />
Budget
</button>
)}
{canManage && (
<button
onClick={openEditCampaign}
className="flex items-center gap-1.5 px-3 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm transition-colors"
>
<Settings className="w-4 h-4" />
Edit
</button>
)}
</div>
</div>
{/* Assigned Team */}
@@ -480,10 +544,25 @@ export default function CampaignDetail() {
</div>
)}
{/* Discussion */}
<div className="bg-white rounded-xl border border-border p-6">
<CommentsSection entityType="campaign" entityId={Number(id)} />
</div>
</div>{/* end main content */}
{/* ─── DISCUSSION SIDEBAR ─── */}
{showDiscussion && (
<div className="w-[340px] shrink-0 bg-white 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="campaign" entityId={Number(id)} />
</div>
</div>
)}
{/* Add/Edit Track Modal */}
<Modal
@@ -756,6 +835,137 @@ export default function CampaignDetail() {
</div>
</Modal>
{/* Edit Campaign Modal */}
<Modal
isOpen={showEditModal}
onClose={() => setShowEditModal(false)}
title="Edit Campaign"
size="lg"
>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Name *</label>
<input
type="text"
value={editForm.name || ''}
onChange={e => setEditForm(f => ({ ...f, name: 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Description</label>
<textarea
value={editForm.description || ''}
onChange={e => setEditForm(f => ({ ...f, description: e.target.value }))}
rows={3}
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 resize-none"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Status</label>
<select
value={editForm.status || 'planning'}
onChange={e => setEditForm(f => ({ ...f, status: 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="planning">Planning</option>
<option value="active">Active</option>
<option value="paused">Paused</option>
<option value="completed">Completed</option>
<option value="cancelled">Cancelled</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Goals</label>
<input
type="text"
value={editForm.goals || ''}
onChange={e => setEditForm(f => ({ ...f, goals: 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"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Start Date</label>
<input
type="date"
value={editForm.start_date || ''}
onChange={e => setEditForm(f => ({ ...f, start_date: 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">End Date</label>
<input
type="date"
value={editForm.end_date || ''}
onChange={e => setEditForm(f => ({ ...f, end_date: 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"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Platforms</label>
<div className="flex flex-wrap gap-2 p-2 border border-border rounded-lg bg-white min-h-[42px]">
{Object.entries(PLATFORMS).map(([k, v]) => {
const checked = (editForm.platforms || []).includes(k)
return (
<label
key={k}
className={`flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-full cursor-pointer border transition-colors ${
checked
? 'bg-brand-primary/10 border-brand-primary/30 text-brand-primary font-medium'
: 'bg-surface-tertiary border-transparent text-text-secondary hover:bg-surface-tertiary/80'
}`}
>
<input
type="checkbox"
checked={checked}
onChange={() => {
setEditForm(f => ({
...f,
platforms: checked
? f.platforms.filter(p => p !== k)
: [...(f.platforms || []), k]
}))
}}
className="sr-only"
/>
{v.label}
</label>
)
})}
</div>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Notes</label>
<textarea
value={editForm.notes || ''}
onChange={e => setEditForm(f => ({ ...f, notes: e.target.value }))}
rows={2}
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 resize-none"
/>
</div>
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
<button
onClick={() => setShowEditModal(false)}
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
>
Cancel
</button>
<button
onClick={saveCampaignEdit}
disabled={!editForm.name}
className="px-5 py-2 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"
>
Save Changes
</button>
</div>
</div>
</Modal>
{/* Post Detail Modal */}
<Modal
isOpen={!!selectedPost}

View File

@@ -6,11 +6,11 @@ import { AppContext } from '../App'
import { useAuth } from '../contexts/AuthContext'
import { api, PLATFORMS } from '../utils/api'
import { PlatformIcons } from '../components/PlatformIcon'
import CampaignCalendar from '../components/CampaignCalendar'
import StatusBadge from '../components/StatusBadge'
import BrandBadge from '../components/BrandBadge'
import Modal from '../components/Modal'
import BudgetBar from '../components/BudgetBar'
import InteractiveTimeline from '../components/InteractiveTimeline'
const EMPTY_CAMPAIGN = {
name: '', description: '', brand_id: '', status: 'planning',
@@ -241,8 +241,32 @@ export default function Campaigns() {
</div>
)}
{/* Calendar */}
<CampaignCalendar campaigns={filtered} />
{/* Timeline */}
<InteractiveTimeline
items={filtered}
mapItem={(campaign) => ({
id: campaign._id || campaign.id,
label: campaign.name,
description: campaign.description,
startDate: campaign.startDate || campaign.start_date || campaign.createdAt,
endDate: campaign.endDate || campaign.end_date,
status: campaign.status,
assigneeName: campaign.brandName || campaign.brand_name,
tags: campaign.platforms || [],
})}
onDateChange={async (campaignId, { startDate, endDate }) => {
try {
await api.patch(`/campaigns/${campaignId}`, { start_date: startDate, end_date: endDate })
} catch (err) {
console.error('Timeline date update failed:', err)
} finally {
loadCampaigns()
}
}}
onItemClick={(campaign) => {
navigate(`/campaigns/${campaign._id || campaign.id}`)
}}
/>
{/* Campaign list */}
<div className="bg-white rounded-xl border border-border overflow-hidden">
@@ -261,47 +285,50 @@ export default function Campaigns() {
return (
<div
key={campaign.id || campaign._id}
onClick={() => permissions?.canEditCampaigns ? navigate(`/campaigns/${campaign.id || campaign._id}`) : navigate(`/campaigns/${campaign.id || campaign._id}`)}
className="flex items-center gap-4 px-5 py-4 hover:bg-surface-secondary cursor-pointer transition-colors"
onClick={() => navigate(`/campaigns/${campaign.id || campaign._id}`)}
className="relative px-5 py-4 hover:bg-surface-secondary cursor-pointer transition-colors"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h4 className="text-sm font-semibold text-text-primary">{campaign.name}</h4>
{campaign.brandName && <BrandBadge brand={campaign.brandName} />}
<ROIBadge revenue={campaign.revenue || 0} spent={spent} />
</div>
{campaign.description && (
<p className="text-xs text-text-secondary line-clamp-1">{campaign.description}</p>
)}
<div className="flex items-center gap-3 mt-1.5">
{campaign.platforms && campaign.platforms.length > 0 && (
<PlatformIcons platforms={campaign.platforms} size={16} />
)}
{budget > 0 && (
<div className="w-32">
<BudgetBar budget={budget} spent={spent} />
</div>
)}
</div>
{/* Quick metrics row */}
{(campaign.impressions > 0 || campaign.clicks > 0) && (
<div className="flex items-center gap-3 mt-1.5 text-[10px] text-text-tertiary">
{campaign.impressions > 0 && <span>👁 {campaign.impressions.toLocaleString()}</span>}
{campaign.clicks > 0 && <span>🖱 {campaign.clicks.toLocaleString()}</span>}
{campaign.conversions > 0 && <span>🎯 {campaign.conversions.toLocaleString()}</span>}
<div className="flex items-center gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h4 className="text-sm font-semibold text-text-primary">{campaign.name}</h4>
{campaign.brandName && <BrandBadge brand={campaign.brandName} />}
<ROIBadge revenue={campaign.revenue || 0} spent={spent} />
</div>
{campaign.description && (
<p className="text-xs text-text-secondary line-clamp-1">{campaign.description}</p>
)}
<div className="flex items-center gap-3 mt-1.5">
{budget > 0 && (
<div className="w-32">
<BudgetBar budget={budget} spent={spent} />
</div>
)}
{(campaign.impressions > 0 || campaign.clicks > 0) && (
<div className="flex items-center gap-3 text-[10px] text-text-tertiary">
{campaign.impressions > 0 && <span>👁 {campaign.impressions.toLocaleString()}</span>}
{campaign.clicks > 0 && <span>🖱 {campaign.clicks.toLocaleString()}</span>}
{campaign.conversions > 0 && <span>🎯 {campaign.conversions.toLocaleString()}</span>}
</div>
)}
</div>
</div>
<div className="text-right shrink-0">
<StatusBadge status={campaign.status} size="xs" />
<div className="text-xs text-text-tertiary mt-1">
{campaign.startDate && campaign.endDate ? (
<>
{format(new Date(campaign.startDate), 'MMM d')} {format(new Date(campaign.endDate), 'MMM d, yyyy')}
</>
) : '—'}
</div>
)}
</div>
<div className="text-right shrink-0">
<StatusBadge status={campaign.status} size="xs" />
<div className="text-xs text-text-tertiary mt-1">
{campaign.startDate && campaign.endDate ? (
<>
{format(new Date(campaign.startDate), 'MMM d')} {format(new Date(campaign.endDate), 'MMM d, yyyy')}
</>
) : '—'}
</div>
</div>
{campaign.platforms && campaign.platforms.length > 0 && (
<div className="flex justify-end mt-2">
<PlatformIcons platforms={campaign.platforms} size={16} />
</div>
)}
</div>
)
})

View File

@@ -8,6 +8,7 @@ import { api, PRIORITY_CONFIG } from '../utils/api'
import StatCard from '../components/StatCard'
import StatusBadge from '../components/StatusBadge'
import BrandBadge from '../components/BrandBadge'
import { SkeletonDashboard } from '../components/SkeletonLoader'
function getBudgetBarColor(percentage) {
if (percentage > 90) return 'bg-red-500'
@@ -180,16 +181,7 @@ export default function Dashboard() {
.slice(0, 8)
if (loading) {
return (
<div className="space-y-6 animate-pulse">
<div className="h-8 w-64 bg-surface-tertiary rounded-lg"></div>
<div className="grid grid-cols-4 gap-4">
{[...Array(4)].map((_, i) => (
<div key={i} className="h-28 bg-surface-tertiary rounded-xl"></div>
))}
</div>
</div>
)
return <SkeletonDashboard />
}
return (

View File

@@ -56,7 +56,7 @@ export default function Login() {
onChange={(e) => setEmail(e.target.value)}
dir="auto"
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
placeholder="f.mahidi@samayainvest.com"
placeholder="user@company.com"
required
autoFocus
/>
@@ -106,10 +106,10 @@ export default function Login() {
</button>
</form>
{/* Default Credentials */}
{/* Footer */}
<div className="mt-6 pt-6 border-t border-slate-700/50">
<p className="text-xs text-slate-500 text-center">
{t('login.defaultCreds')} <span className="text-slate-400 font-medium">f.mahidi@samayainvest.com</span> / <span className="text-slate-400 font-medium">admin123</span>
{t('login.forgotPassword')}
</p>
</div>
</div>

View File

@@ -9,6 +9,9 @@ import PostCard from '../components/PostCard'
import StatusBadge from '../components/StatusBadge'
import Modal from '../components/Modal'
import CommentsSection from '../components/CommentsSection'
import { SkeletonKanbanBoard, SkeletonTable } from '../components/SkeletonLoader'
import EmptyState from '../components/EmptyState'
import { useToast } from '../components/ToastContainer'
const EMPTY_POST = {
title: '', description: '', brand_id: '', platforms: [],
@@ -20,8 +23,10 @@ export default function PostProduction() {
const { t } = useLanguage()
const { teamMembers, brands } = useContext(AppContext)
const { canEditResource, canDeleteResource } = useAuth()
const toast = useToast()
const [posts, setPosts] = useState([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [view, setView] = useState('kanban')
const [showModal, setShowModal] = useState(false)
const [editingPost, setEditingPost] = useState(null)
@@ -59,6 +64,7 @@ export default function PostProduction() {
const handleSave = async () => {
setPublishError('')
setSaving(true)
try {
const data = {
title: formData.title,
@@ -82,14 +88,17 @@ export default function PostProduction() {
if (missingPlatforms.length > 0) {
const names = missingPlatforms.map(p => PLATFORMS[p]?.label || p).join(', ')
setPublishError(`${t('posts.publishMissing')} ${names}`)
setSaving(false)
return
}
}
if (editingPost) {
await api.patch(`/posts/${editingPost._id}`, data)
toast.success(t('posts.updated'))
} else {
await api.post('/posts', data)
toast.success(t('posts.created'))
}
setShowModal(false)
setEditingPost(null)
@@ -100,19 +109,27 @@ export default function PostProduction() {
console.error('Save failed:', err)
if (err.message?.includes('Cannot publish')) {
setPublishError(err.message.replace(/.*: /, ''))
} else {
toast.error(t('common.saveFailed'))
}
} finally {
setSaving(false)
}
}
const handleMovePost = async (postId, newStatus) => {
try {
await api.patch(`/posts/${postId}`, { status: newStatus })
toast.success(t('posts.statusUpdated'))
loadPosts()
} catch (err) {
console.error('Move failed:', err)
if (err.message?.includes('Cannot publish')) {
setMoveError(t('posts.publishRequired'))
setTimeout(() => setMoveError(''), 5000)
toast.error(t('posts.publishRequired'))
} else {
toast.error(t('common.updateFailed'))
}
}
}
@@ -153,8 +170,10 @@ export default function PostProduction() {
try {
await api.delete(`/attachments/${attachmentId}`)
if (editingPost) loadAttachments(editingPost._id || editingPost.id)
toast.success(t('posts.attachmentDeleted'))
} catch (err) {
console.error('Delete attachment failed:', err)
toast.error(t('common.deleteFailed'))
}
}
@@ -244,14 +263,7 @@ export default function PostProduction() {
})
if (loading) {
return (
<div className="space-y-4 animate-pulse">
<div className="h-12 bg-surface-tertiary rounded-xl"></div>
<div className="flex gap-4">
{[...Array(5)].map((_, i) => <div key={i} className="w-72 h-96 bg-surface-tertiary rounded-xl"></div>)}
</div>
</div>
)
return view === 'kanban' ? <SkeletonKanbanBoard /> : <SkeletonTable rows={8} cols={6} />
}
return (
@@ -342,30 +354,38 @@ export default function PostProduction() {
<KanbanBoard posts={filteredPosts} onPostClick={openEdit} onMovePost={handleMovePost} />
) : (
<div className="bg-white rounded-xl border border-border overflow-hidden">
<table className="w-full">
<thead>
<tr className="border-b border-border bg-surface-secondary">
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.postTitle')}</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.brand')}</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.status')}</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.platforms')}</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.assignTo')}</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.scheduledDate')}</th>
</tr>
</thead>
<tbody className="divide-y divide-border-light">
{filteredPosts.map(post => (
<PostCard key={post._id} post={post} onClick={() => openEdit(post)} />
))}
{filteredPosts.length === 0 && (
<tr>
<td colSpan={6} className="py-12 text-center text-sm text-text-tertiary">
{t('posts.noPostsFound')}
</td>
{filteredPosts.length === 0 ? (
<EmptyState
icon={FileText}
title={posts.length === 0 ? t('posts.noPosts') : t('posts.noPostsFound')}
description={posts.length === 0 ? t('posts.createFirstPost') : t('posts.tryDifferentFilter')}
actionLabel={posts.length === 0 ? t('posts.createPost') : null}
onAction={posts.length === 0 ? openNew : null}
secondaryActionLabel={posts.length > 0 ? t('common.clearFilters') : null}
onSecondaryAction={() => {
setFilters({ brand: '', platform: '', assignedTo: '', campaign: '' })
setSearchTerm('')
}}
/>
) : (
<table className="w-full">
<thead>
<tr className="border-b border-border bg-surface-secondary">
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.postTitle')}</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.brand')}</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.status')}</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.platforms')}</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.assignTo')}</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.scheduledDate')}</th>
</tr>
)}
</tbody>
</table>
</thead>
<tbody className="divide-y divide-border-light">
{filteredPosts.map(post => (
<PostCard key={post._id} post={post} onClick={() => openEdit(post)} />
))}
</tbody>
</table>
)}
</div>
)}
@@ -738,8 +758,8 @@ export default function PostProduction() {
</button>
<button
onClick={handleSave}
disabled={!formData.title}
className="px-5 py-2 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"
disabled={!formData.title || saving}
className={`px-5 py-2 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 ${saving ? 'btn-loading' : ''}`}
>
{editingPost ? t('posts.saveChanges') : t('posts.createPost')}
</button>
@@ -759,11 +779,13 @@ export default function PostProduction() {
if (editingPost) {
try {
await api.delete(`/posts/${editingPost._id || editingPost.id}`)
toast.success(t('posts.deleted'))
setShowModal(false)
setEditingPost(null)
loadPosts()
} catch (err) {
console.error('Delete failed:', err)
toast.error(t('common.deleteFailed'))
}
}
}}

View File

@@ -2,7 +2,7 @@ import { useState, useEffect, useContext } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import {
ArrowLeft, Plus, Check, Trash2, Edit3, LayoutGrid, List,
GanttChart, Settings, Calendar, Clock
GanttChart, Settings, Calendar, Clock, MessageCircle, X
} from 'lucide-react'
import { format, differenceInDays, startOfDay, addDays, isAfter, isBefore } from 'date-fns'
import { AppContext } from '../App'
@@ -24,7 +24,6 @@ export default function ProjectDetail() {
const navigate = useNavigate()
const { teamMembers, brands } = useContext(AppContext)
const { permissions, canEditResource, canDeleteResource } = useAuth()
const canEditProject = canEditResource('project', project)
const canManageProject = permissions?.canEditProjects
const [project, setProject] = useState(null)
const [tasks, setTasks] = useState([])
@@ -37,12 +36,14 @@ export default function ProjectDetail() {
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [taskToDelete, setTaskToDelete] = useState(null)
const [taskForm, setTaskForm] = useState({
title: '', description: '', priority: 'medium', assigned_to: '', due_date: '', status: 'todo'
title: '', description: '', priority: 'medium', assigned_to: '', start_date: '', due_date: '', status: 'todo'
})
const [projectForm, setProjectForm] = useState({
name: '', description: '', brand_id: '', owner_id: '', status: 'active', due_date: ''
name: '', description: '', brand_id: '', owner_id: '', status: 'active', start_date: '', due_date: ''
})
const [showDiscussion, setShowDiscussion] = useState(false)
// Drag state for kanban
const [draggedTask, setDraggedTask] = useState(null)
const [dragOverCol, setDragOverCol] = useState(null)
@@ -72,6 +73,7 @@ export default function ProjectDetail() {
description: taskForm.description,
priority: taskForm.priority,
assigned_to: taskForm.assigned_to ? Number(taskForm.assigned_to) : null,
start_date: taskForm.start_date || null,
due_date: taskForm.due_date || null,
status: taskForm.status,
project_id: Number(id),
@@ -83,7 +85,7 @@ export default function ProjectDetail() {
}
setShowTaskModal(false)
setEditingTask(null)
setTaskForm({ title: '', description: '', priority: 'medium', assigned_to: '', due_date: '', status: 'todo' })
setTaskForm({ title: '', description: '', priority: 'medium', assigned_to: '', start_date: '', due_date: '', status: 'todo' })
loadProject()
} catch (err) {
console.error('Task save failed:', err)
@@ -122,6 +124,7 @@ export default function ProjectDetail() {
description: task.description || '',
priority: task.priority || 'medium',
assigned_to: task.assignedTo || task.assigned_to || '',
start_date: task.startDate || task.start_date ? new Date(task.startDate || task.start_date).toISOString().slice(0, 10) : '',
due_date: task.dueDate ? new Date(task.dueDate).toISOString().slice(0, 10) : '',
status: task.status || 'todo',
})
@@ -130,7 +133,7 @@ export default function ProjectDetail() {
const openNewTask = () => {
setEditingTask(null)
setTaskForm({ title: '', description: '', priority: 'medium', assigned_to: '', due_date: '', status: 'todo' })
setTaskForm({ title: '', description: '', priority: 'medium', assigned_to: '', start_date: '', due_date: '', status: 'todo' })
setShowTaskModal(true)
}
@@ -142,6 +145,7 @@ export default function ProjectDetail() {
brand_id: project.brandId || project.brand_id || '',
owner_id: project.ownerId || project.owner_id || '',
status: project.status || 'active',
start_date: project.startDate || project.start_date ? new Date(project.startDate || project.start_date).toISOString().slice(0, 10) : '',
due_date: project.dueDate ? new Date(project.dueDate).toISOString().slice(0, 10) : '',
})
setShowProjectModal(true)
@@ -155,6 +159,7 @@ export default function ProjectDetail() {
brand_id: projectForm.brand_id ? Number(projectForm.brand_id) : null,
owner_id: projectForm.owner_id ? Number(projectForm.owner_id) : null,
status: projectForm.status,
start_date: projectForm.start_date || null,
due_date: projectForm.due_date || null,
})
setShowProjectModal(false)
@@ -212,13 +217,16 @@ export default function ProjectDetail() {
)
}
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="space-y-6 animate-fade-in">
<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')}
@@ -251,15 +259,26 @@ export default function ProjectDetail() {
)}
</div>
</div>
{canEditProject && (
<div className="flex items-center gap-2">
<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"
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'
}`}
>
<Settings className="w-4 h-4" />
Edit
<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 && (
@@ -282,11 +301,6 @@ export default function ProjectDetail() {
</div>
</div>
{/* Discussion */}
<div className="bg-white rounded-xl border border-border p-6">
<CommentsSection entityType="project" entityId={Number(id)} />
</div>
{/* 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">
@@ -432,6 +446,25 @@ export default function ProjectDetail() {
{/* ─── GANTT / TIMELINE VIEW ─── */}
{view === 'gantt' && <GanttView tasks={tasks} project={project} onEditTask={openEditTask} />}
</div>{/* end main content */}
{/* ─── DISCUSSION SIDEBAR ─── */}
{showDiscussion && (
<div className="w-[340px] shrink-0 bg-white 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>
)}
{/* ─── TASK MODAL ─── */}
<Modal
@@ -482,14 +515,19 @@ export default function ProjectDetail() {
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Assign To</label>
<select value={taskForm.assigned_to} onChange={e => setTaskForm(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="">Unassigned</option>
{assignableUsers.map(m => <option key={m._id || m.id} value={m._id || m.id}>{m.name}</option>)}
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Assign To</label>
<select value={taskForm.assigned_to} onChange={e => setTaskForm(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="">Unassigned</option>
{assignableUsers.map(m => <option key={m._id || m.team_member_id} value={m._id || m.team_member_id}>{m.name}</option>)}
</select>
<label className="block text-sm font-medium text-text-primary mb-1">Start Date</label>
<input type="date" value={taskForm.start_date} onChange={e => setTaskForm(f => ({ ...f, start_date: 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" />
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Due Date</label>
@@ -566,11 +604,16 @@ export default function ProjectDetail() {
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Due Date</label>
<input type="date" value={projectForm.due_date} onChange={e => setProjectForm(f => ({ ...f, due_date: e.target.value }))}
<label className="block text-sm font-medium text-text-primary mb-1">Start Date</label>
<input type="date" value={projectForm.start_date} onChange={e => setProjectForm(f => ({ ...f, start_date: 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" />
</div>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Due Date</label>
<input type="date" value={projectForm.due_date} onChange={e => setProjectForm(f => ({ ...f, due_date: 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" />
</div>
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
<button onClick={() => setShowProjectModal(false)}
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg">
@@ -698,7 +741,9 @@ function GanttView({ tasks, project, onEditTask }) {
const dayWidth = Math.max(36, Math.min(60, 800 / totalDays))
const getBarStyle = (task) => {
const start = task.createdAt ? startOfDay(new Date(task.createdAt)) : today
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)
@@ -775,7 +820,7 @@ function GanttView({ tasks, project, onEditTask }) {
})}
</div>
</div>
</div>
)
}

View File

@@ -1,17 +1,20 @@
import { useState, useEffect, useContext } from 'react'
import { Plus, Search, FolderKanban } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { Plus, Search, FolderKanban, LayoutGrid, GanttChart } from 'lucide-react'
import { AppContext } from '../App'
import { api } from '../utils/api'
import { useAuth } from '../contexts/AuthContext'
import ProjectCard from '../components/ProjectCard'
import Modal from '../components/Modal'
import InteractiveTimeline from '../components/InteractiveTimeline'
const EMPTY_PROJECT = {
name: '', description: '', brand_id: '', status: 'active',
owner_id: '', due_date: '',
owner_id: '', start_date: '', due_date: '',
}
export default function Projects() {
const navigate = useNavigate()
const { teamMembers, brands } = useContext(AppContext)
const { permissions } = useAuth()
const [projects, setProjects] = useState([])
@@ -19,6 +22,7 @@ export default function Projects() {
const [showModal, setShowModal] = useState(false)
const [formData, setFormData] = useState(EMPTY_PROJECT)
const [searchTerm, setSearchTerm] = useState('')
const [view, setView] = useState('timeline') // 'grid' | 'timeline'
useEffect(() => { loadProjects() }, [])
@@ -41,6 +45,7 @@ export default function Projects() {
brand_id: formData.brand_id ? Number(formData.brand_id) : null,
owner_id: formData.owner_id ? Number(formData.owner_id) : null,
status: formData.status,
start_date: formData.start_date || null,
due_date: formData.due_date || null,
}
await api.post('/projects', data)
@@ -83,6 +88,25 @@ export default function Projects() {
/>
</div>
{/* View switcher */}
<div className="flex items-center gap-1 bg-surface-tertiary rounded-lg p-0.5">
{[
{ id: 'grid', icon: LayoutGrid, label: 'Grid' },
{ id: 'timeline', 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-white shadow-sm text-text-primary' : 'text-text-tertiary hover:text-text-secondary'
}`}
>
<v.icon className="w-4 h-4" />
{v.label}
</button>
))}
</div>
{permissions?.canCreateProjects && (
<button
onClick={() => setShowModal(true)}
@@ -94,19 +118,46 @@ export default function Projects() {
)}
</div>
{/* Project grid */}
{/* Content */}
{filtered.length === 0 ? (
<div className="py-20 text-center">
<FolderKanban className="w-12 h-12 text-text-tertiary mx-auto mb-3" />
<p className="text-text-secondary font-medium">No projects yet</p>
<p className="text-sm text-text-tertiary mt-1">Create your first project to start organizing work</p>
</div>
) : (
) : view === 'grid' ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 stagger-children">
{filtered.map(project => (
<ProjectCard key={project._id} project={project} />
))}
</div>
) : (
<InteractiveTimeline
items={filtered}
mapItem={(project) => ({
id: project._id || project.id,
label: project.name,
description: project.description,
startDate: project.startDate || project.start_date || project.createdAt,
endDate: project.dueDate || project.due_date,
status: project.status,
priority: project.priority,
assigneeName: project.ownerName || project.owner_name,
tags: [project.status, project.priority].filter(Boolean),
})}
onDateChange={async (projectId, { startDate, endDate }) => {
try {
await api.patch(`/projects/${projectId}`, { start_date: startDate, due_date: endDate })
} catch (err) {
console.error('Timeline date update failed:', err)
} finally {
loadProjects()
}
}}
onItemClick={(project) => {
navigate(`/projects/${project._id || project.id}`)
}}
/>
)}
{/* Create Modal */}
@@ -174,16 +225,26 @@ export default function Projects() {
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Due Date</label>
<label className="block text-sm font-medium text-text-primary mb-1">Start Date</label>
<input
type="date"
value={formData.due_date}
onChange={e => setFormData(f => ({ ...f, due_date: e.target.value }))}
value={formData.start_date}
onChange={e => setFormData(f => ({ ...f, start_date: 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"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Due Date</label>
<input
type="date"
value={formData.due_date}
onChange={e => setFormData(f => ({ ...f, due_date: 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"
/>
</div>
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
<button
onClick={() => setShowModal(false)}

View File

@@ -7,13 +7,17 @@ import { api } from '../utils/api'
import TaskCard from '../components/TaskCard'
import Modal from '../components/Modal'
import CommentsSection from '../components/CommentsSection'
import EmptyState from '../components/EmptyState'
import { useToast } from '../components/ToastContainer'
export default function Tasks() {
const { t } = useLanguage()
const { currentUser } = useContext(AppContext)
const { user: authUser, canEditResource, canDeleteResource } = useAuth()
const toast = useToast()
const [tasks, setTasks] = useState([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [showModal, setShowModal] = useState(false)
const [editingTask, setEditingTask] = useState(null)
const [draggedTask, setDraggedTask] = useState(null)
@@ -24,7 +28,7 @@ export default function Tasks() {
const [users, setUsers] = useState([]) // for superadmin member filter
const [assignableUsers, setAssignableUsers] = useState([])
const [formData, setFormData] = useState({
title: '', description: '', priority: 'medium', due_date: '', status: 'todo', assigned_to: ''
title: '', description: '', priority: 'medium', start_date: '', due_date: '', status: 'todo', assigned_to: ''
})
const isSuperadmin = authUser?.role === 'superadmin'
@@ -55,29 +59,30 @@ export default function Tasks() {
if (filterView === 'all') return true
if (filterView === 'assigned_to_me') {
// Tasks where I'm the assignee (via team_member_id on my user record)
const myTeamMemberId = authUser?.team_member_id
return myTeamMemberId && task.assigned_to === myTeamMemberId
return task.assignedTo === authUser?.id || task.assigned_to === authUser?.id
}
if (filterView === 'created_by_me') {
return task.created_by_user_id === authUser?.id
return task.createdByUserId === authUser?.id || task.created_by_user_id === authUser?.id
}
// Superadmin filtering by specific team member (assigned_to = member id)
// Superadmin filtering by specific team member
if (isSuperadmin && !isNaN(Number(filterView))) {
return task.assigned_to === Number(filterView)
const memberId = Number(filterView)
return task.assignedTo === memberId || task.assigned_to === memberId
}
return true
})
const handleSave = async () => {
setSaving(true)
try {
const data = {
title: formData.title,
description: formData.description,
priority: formData.priority,
start_date: formData.start_date || null,
due_date: formData.due_date || null,
status: formData.status,
assigned_to: formData.assigned_to || null,
@@ -85,29 +90,38 @@ export default function Tasks() {
}
if (editingTask) {
await api.patch(`/tasks/${editingTask._id || editingTask.id}`, data)
toast.success(t('tasks.updated'))
} else {
await api.post('/tasks', data)
toast.success(t('tasks.created'))
}
setShowModal(false)
setEditingTask(null)
setFormData({ title: '', description: '', priority: 'medium', due_date: '', status: 'todo', assigned_to: '' })
setFormData({ title: '', description: '', priority: 'medium', start_date: '', due_date: '', status: 'todo', assigned_to: '' })
loadTasks()
} catch (err) {
console.error('Save failed:', err)
if (err.message?.includes('403') || err.message?.includes('modify your own')) {
alert('You can only edit your own tasks')
toast.error(t('tasks.canOnlyEditOwn'))
} else {
toast.error(t('common.saveFailed'))
}
} finally {
setSaving(false)
}
}
const handleMove = async (taskId, newStatus) => {
try {
await api.patch(`/tasks/${taskId}`, { status: newStatus })
toast.success(t('tasks.statusUpdated'))
loadTasks()
} catch (err) {
console.error('Move failed:', err)
if (err.message?.includes('403')) {
alert('You can only modify your own tasks')
toast.error(t('tasks.canOnlyEditOwn'))
} else {
toast.error(t('common.updateFailed'))
}
}
}
@@ -119,6 +133,7 @@ export default function Tasks() {
title: task.title || '',
description: task.description || '',
priority: task.priority || 'medium',
start_date: task.start_date || task.startDate || '',
due_date: task.due_date || task.dueDate || '',
status: task.status || 'todo',
assigned_to: task.assigned_to || '',
@@ -136,10 +151,12 @@ export default function Tasks() {
if (!taskToDelete) return
try {
await api.delete(`/tasks/${taskToDelete._id || taskToDelete.id}`)
toast.success(t('tasks.deleted'))
setTaskToDelete(null)
loadTasks()
} catch (err) {
console.error('Delete failed:', err)
toast.error(t('common.deleteFailed'))
}
}
@@ -232,7 +249,7 @@ export default function Tasks() {
</p>
</div>
<button
onClick={() => { setEditingTask(null); setFormData({ title: '', description: '', priority: 'medium', due_date: '', status: 'todo', assigned_to: '' }); setShowModal(true) }}
onClick={() => { setEditingTask(null); setFormData({ title: '', description: '', priority: 'medium', start_date: '', due_date: '', status: 'todo', assigned_to: '' }); setShowModal(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"
>
<Plus className="w-4 h-4" />
@@ -242,15 +259,19 @@ export default function Tasks() {
{/* Task columns */}
{filteredTasks.length === 0 ? (
<div className="py-20 text-center">
<CheckSquare className="w-12 h-12 text-text-tertiary mx-auto mb-3" />
<p className="text-text-secondary font-medium">
{tasks.length === 0 ? t('tasks.noTasks') : t('tasks.noMatch')}
</p>
<p className="text-sm text-text-tertiary mt-1">
{tasks.length === 0 ? t('tasks.createFirst') : t('tasks.tryFilter')}
</p>
</div>
<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 ? () => {
setEditingTask(null)
setFormData({ title: '', description: '', priority: 'medium', start_date: '', due_date: '', status: 'todo', assigned_to: '' })
setShowModal(true)
} : null}
secondaryActionLabel={tasks.length > 0 ? t('common.clearFilters') : null}
onSecondaryAction={() => setFilterView('all')}
/>
) : (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{columns.map(col => {
@@ -291,29 +312,18 @@ export default function Tasks() {
onDragEnd={handleDragEnd}
className={canEdit ? 'cursor-grab active:cursor-grabbing' : ''}
>
<div className="relative group">
<div className="relative group" onClick={() => canEdit && openEdit(task)}>
<TaskCard task={task} onMove={canEdit ? handleMove : undefined} showProject />
{/* Edit/Delete overlay */}
{(canEdit || canDelete) && (
{/* Delete overlay */}
{canDelete && (
<div className="absolute top-2 right-2 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
{canEdit && (
<button
onClick={(e) => { e.stopPropagation(); openEdit(task) }}
className="p-1 hover:bg-surface-tertiary rounded text-text-tertiary hover:text-text-primary"
title={t('tasks.editTask')}
>
<Edit2 className="w-3.5 h-3.5" />
</button>
)}
{canDelete && (
<button
onClick={(e) => { e.stopPropagation(); handleDelete(task) }}
className="p-1 hover:bg-red-50 rounded text-text-tertiary hover:text-red-500"
title={t('common.delete')}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
<button
onClick={(e) => { e.stopPropagation(); handleDelete(task) }}
className="p-1 hover:bg-red-50 rounded text-text-tertiary hover:text-red-500"
title={t('common.delete')}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
)}
</div>
@@ -382,16 +392,26 @@ export default function Tasks() {
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('tasks.dueDate')}</label>
<label className="block text-sm font-medium text-text-primary mb-1">{t('timeline.startDate')}</label>
<input
type="date"
value={formData.due_date}
onChange={e => setFormData(f => ({ ...f, due_date: e.target.value }))}
value={formData.start_date}
onChange={e => setFormData(f => ({ ...f, start_date: 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"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('tasks.dueDate')}</label>
<input
type="date"
value={formData.due_date}
onChange={e => setFormData(f => ({ ...f, due_date: 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"
/>
</div>
{/* Comments (only for existing tasks) */}
{editingTask && (
<CommentsSection entityType="task" entityId={editingTask._id || editingTask.id} />
@@ -406,8 +426,8 @@ export default function Tasks() {
</button>
<button
onClick={handleSave}
disabled={!formData.title}
className="px-5 py-2 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"
disabled={!formData.title || saving}
className={`px-5 py-2 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 ${saving ? 'btn-loading' : ''}`}
>
{editingTask ? t('tasks.saveChanges') : t('tasks.createTask')}
</button>

View File

@@ -388,7 +388,7 @@ export default function Team() {
value={formData.brands}
onChange={e => setFormData(f => ({ ...f, brands: 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"
placeholder="Samaya Investment, Hira Cultural District"
placeholder="Brand A, Brand B"
/>
<p className="text-xs text-text-tertiary mt-1">{t('team.brandsHelp')}</p>
</div>

View File

@@ -240,7 +240,7 @@ export default function Users() {
value={form.email}
onChange={e => setForm(f => ({ ...f, email: 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"
placeholder="user@samayainvest.com"
placeholder="user@company.com"
required
/>
</div>

View File

@@ -1,6 +1,6 @@
const API = '/api';
// Map SQLite fields to frontend-friendly format
// Map NocoDB / snake_case fields to frontend-friendly format
const toCamel = (s) => s.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
const normalize = (data) => {
@@ -12,12 +12,23 @@ const normalize = (data) => {
out[camelKey] = v;
if (camelKey !== k) out[k] = v;
}
// NocoDB uses Id (capital I) — map to id
if (out.Id !== undefined && out.id === undefined) out.id = out.Id;
// Add _id alias
if (out.id !== undefined && out._id === undefined) out._id = out.id;
// NocoDB timestamp fields
if (out.CreatedAt && !out.created_at) { out.created_at = out.CreatedAt; out.createdAt = out.CreatedAt; }
if (out.UpdatedAt && !out.updated_at) { out.updated_at = out.UpdatedAt; out.updatedAt = out.UpdatedAt; }
// Map brand_name → brand (frontend expects post.brand as string)
if (out.brandName && !out.brand) out.brand = out.brandName;
// Map assigned_name for display
if (out.assignedName && !out.assignedToName) out.assignedToName = out.assignedName;
// Parse JSON text fields from NocoDB (stored as LongText)
for (const jsonField of ['platforms', 'brands', 'tags', 'publicationLinks', 'publication_links', 'goals']) {
if (out[jsonField] && typeof out[jsonField] === 'string') {
try { out[jsonField] = JSON.parse(out[jsonField]); } catch {}
}
}
return out;
}
return data;
@@ -73,21 +84,32 @@ export const api = {
}).then(r => handleResponse(r, `UPLOAD ${path}`)),
};
// Brand colors map — matches Samaya brands from backend
export const BRAND_COLORS = {
'Samaya Investment': { bg: 'bg-indigo-100', text: 'text-indigo-700', dot: 'bg-indigo-500' },
'Hira Cultural District': { bg: 'bg-amber-100', text: 'text-amber-700', dot: 'bg-amber-500' },
'Holy Quran Museum': { bg: 'bg-emerald-100', text: 'text-emerald-700', dot: 'bg-emerald-500' },
'Al-Safiya Museum': { bg: 'bg-purple-100', text: 'text-purple-700', dot: 'bg-purple-500' },
'Hayhala': { bg: 'bg-red-100', text: 'text-red-700', dot: 'bg-red-500' },
'Jabal Thawr': { bg: 'bg-stone-100', text: 'text-stone-700', dot: 'bg-stone-500' },
'Coffee Chain': { bg: 'bg-orange-100', text: 'text-orange-700', dot: 'bg-orange-500' },
'Taibah Gifts': { bg: 'bg-pink-100', text: 'text-pink-700', dot: 'bg-pink-500' },
'Google Maps': { bg: 'bg-blue-100', text: 'text-blue-700', dot: 'bg-blue-500' },
'default': { bg: 'bg-gray-100', text: 'text-gray-700', dot: 'bg-gray-500' },
};
// Brand color palette — dynamically assigned from a rotating palette
const BRAND_COLOR_PALETTE = [
{ bg: 'bg-indigo-100', text: 'text-indigo-700', dot: 'bg-indigo-500' },
{ bg: 'bg-amber-100', text: 'text-amber-700', dot: 'bg-amber-500' },
{ bg: 'bg-emerald-100', text: 'text-emerald-700', dot: 'bg-emerald-500' },
{ bg: 'bg-purple-100', text: 'text-purple-700', dot: 'bg-purple-500' },
{ bg: 'bg-red-100', text: 'text-red-700', dot: 'bg-red-500' },
{ bg: 'bg-stone-100', text: 'text-stone-700', dot: 'bg-stone-500' },
{ bg: 'bg-orange-100', text: 'text-orange-700', dot: 'bg-orange-500' },
{ bg: 'bg-pink-100', text: 'text-pink-700', dot: 'bg-pink-500' },
{ bg: 'bg-blue-100', text: 'text-blue-700', dot: 'bg-blue-500' },
{ bg: 'bg-cyan-100', text: 'text-cyan-700', dot: 'bg-cyan-500' },
{ bg: 'bg-teal-100', text: 'text-teal-700', dot: 'bg-teal-500' },
{ bg: 'bg-rose-100', text: 'text-rose-700', dot: 'bg-rose-500' },
];
export const getBrandColor = (brand) => BRAND_COLORS[brand] || BRAND_COLORS['default'];
const DEFAULT_BRAND_COLOR = { bg: 'bg-gray-100', text: 'text-gray-700', dot: 'bg-gray-500' };
const brandColorCache = {};
export const getBrandColor = (brand) => {
if (!brand) return DEFAULT_BRAND_COLOR;
if (brandColorCache[brand]) return brandColorCache[brand];
const idx = Object.keys(brandColorCache).length % BRAND_COLOR_PALETTE.length;
brandColorCache[brand] = BRAND_COLOR_PALETTE[idx];
return brandColorCache[brand];
};
// Platform icons helper — svg paths for inline icons
export const PLATFORMS = {