Marketing Hub: RBAC, i18n (AR/EN), tasks overhaul, team/user merge, tutorial

Features:
- Full RBAC with 3 roles (superadmin/manager/contributor)
- Ownership tracking on posts, tasks, campaigns, projects
- Task system: assign to anyone, filter combobox, visibility scoping
- Team members merged into users table (single source of truth)
- Post thumbnails on kanban cards from attachments
- Publication link validation before publishing
- Interactive onboarding tutorial with Settings restart
- Full Arabic/English i18n with RTL layout support
- Language toggle in sidebar, IBM Plex Sans Arabic font
- Brand-based visibility filtering for non-superadmins
- Manager can only create contributors
- Profile completion flow for new users
- Cookie-based sessions (express-session + SQLite)
This commit is contained in:
fahed
2026-02-08 20:46:58 +03:00
commit 35d84b6bff
2240 changed files with 846749 additions and 0 deletions

356
client/src/pages/Assets.jsx Normal file
View File

@@ -0,0 +1,356 @@
import { useState, useEffect, useRef } from 'react'
import { Plus, Upload, Search, FolderOpen, ChevronRight, Grid3X3, X } from 'lucide-react'
import { api } from '../utils/api'
import AssetCard from '../components/AssetCard'
import Modal from '../components/Modal'
export default function Assets() {
const [assets, setAssets] = useState([])
const [loading, setLoading] = useState(true)
const [filters, setFilters] = useState({ brand: '', tag: '', folder: '', search: '' })
const [showUpload, setShowUpload] = useState(false)
const [uploading, setUploading] = useState(false)
const [uploadProgress, setUploadProgress] = useState(0)
const [dragOver, setDragOver] = useState(false)
const [selectedAsset, setSelectedAsset] = useState(null)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [assetToDelete, setAssetToDelete] = useState(null)
const fileRef = useRef(null)
useEffect(() => { loadAssets() }, [])
const loadAssets = async () => {
try {
const res = await api.get('/assets')
const assetsData = res.data || res || []
// Map assets to include URL for thumbnails
const assetsWithUrls = assetsData.map(asset => ({
...asset,
_id: asset.id,
name: asset.original_name || asset.filename,
type: asset.mime_type?.startsWith('image') ? 'image' :
asset.mime_type?.startsWith('video') ? 'video' :
asset.mime_type?.startsWith('audio') ? 'audio' : 'document',
url: `/api/uploads/${asset.filename}`,
createdAt: asset.created_at,
fileType: asset.mime_type?.split('/')[1]?.toUpperCase() || 'FILE',
}))
setAssets(assetsWithUrls)
} catch (err) {
console.error('Failed to load assets:', err)
} finally {
setLoading(false)
}
}
const handleUpload = async (files) => {
if (!files || files.length === 0) return
setUploading(true)
setUploadProgress(0)
try {
for (let i = 0; i < files.length; i++) {
const file = files[i]
const formData = new FormData()
formData.append('file', file)
formData.append('folder', 'general')
formData.append('brand_id', '')
formData.append('uploaded_by', '')
// Use XMLHttpRequest to track upload progress
await new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const fileProgress = (e.loaded / e.total) * 100
const totalProgress = ((i + fileProgress / 100) / files.length) * 100
setUploadProgress(Math.round(totalProgress))
}
})
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(JSON.parse(xhr.responseText))
} else {
reject(new Error(`Upload failed with status ${xhr.status}`))
}
})
xhr.addEventListener('error', () => reject(new Error('Network error')))
xhr.open('POST', '/api/assets/upload')
xhr.send(formData)
})
}
loadAssets()
setShowUpload(false)
setUploadProgress(0)
} catch (err) {
console.error('Upload failed:', err)
alert('Upload failed: ' + err.message)
} finally {
setUploading(false)
}
}
const handleDeleteAsset = async (asset) => {
setAssetToDelete(asset)
setShowDeleteConfirm(true)
}
const confirmDeleteAsset = async () => {
if (!assetToDelete) return
try {
await api.delete(`/assets/${assetToDelete.id || assetToDelete._id}`)
setSelectedAsset(null)
setAssetToDelete(null)
loadAssets()
} catch (err) {
console.error('Delete failed:', err)
alert('Failed to delete asset')
}
}
const handleDrop = (e) => {
e.preventDefault()
setDragOver(false)
handleUpload(e.dataTransfer.files)
}
// Get unique values for filters
const brands = [...new Set(assets.map(a => a.brand).filter(Boolean))]
const allTags = [...new Set(assets.flatMap(a => a.tags || []))]
const folders = [...new Set(assets.map(a => a.folder).filter(Boolean))]
const filteredAssets = assets.filter(a => {
if (filters.brand && a.brand !== filters.brand) return false
if (filters.tag && !(a.tags || []).includes(filters.tag)) return false
if (filters.folder && a.folder !== filters.folder) return false
if (filters.search && !a.name?.toLowerCase().includes(filters.search.toLowerCase())) return false
return true
})
if (loading) {
return (
<div className="animate-pulse">
<div className="h-12 bg-surface-tertiary rounded-xl mb-4"></div>
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-4">
{[...Array(10)].map((_, i) => (
<div key={i} className="aspect-square bg-surface-tertiary rounded-xl"></div>
))}
</div>
</div>
)
}
return (
<div className="space-y-4 animate-fade-in">
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-3">
<div className="relative flex-1 min-w-[200px] max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<input
type="text"
placeholder="Search assets..."
value={filters.search}
onChange={e => setFilters(f => ({ ...f, search: e.target.value }))}
className="w-full pl-10 pr-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-white"
/>
</div>
<select
value={filters.brand}
onChange={e => setFilters(f => ({ ...f, brand: e.target.value }))}
className="text-sm border border-border rounded-lg px-3 py-2 bg-white text-text-secondary focus:outline-none"
>
<option value="">All Brands</option>
{brands.map(b => <option key={b} value={b}>{b}</option>)}
</select>
<select
value={filters.tag}
onChange={e => setFilters(f => ({ ...f, tag: e.target.value }))}
className="text-sm border border-border rounded-lg px-3 py-2 bg-white text-text-secondary focus:outline-none"
>
<option value="">All Tags</option>
{allTags.map(t => <option key={t} value={t}>{t}</option>)}
</select>
<button
onClick={() => setShowUpload(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 ml-auto"
>
<Upload className="w-4 h-4" />
Upload
</button>
</div>
{/* Folder breadcrumbs */}
{folders.length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
<button
onClick={() => setFilters(f => ({ ...f, folder: '' }))}
className={`flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg transition-colors ${
!filters.folder ? 'bg-brand-primary/10 text-brand-primary font-medium' : 'text-text-secondary hover:bg-surface-tertiary'
}`}
>
<FolderOpen className="w-4 h-4" />
All
</button>
{folders.map(folder => (
<button
key={folder}
onClick={() => setFilters(f => ({ ...f, folder }))}
className={`flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg transition-colors ${
filters.folder === folder ? 'bg-brand-primary/10 text-brand-primary font-medium' : 'text-text-secondary hover:bg-surface-tertiary'
}`}
>
<FolderOpen className="w-4 h-4" />
{folder}
</button>
))}
</div>
)}
{/* Asset grid */}
{filteredAssets.length === 0 ? (
<div className="py-20 text-center">
<Grid3X3 className="w-12 h-12 text-text-tertiary mx-auto mb-3" />
<p className="text-text-secondary font-medium">No assets found</p>
<p className="text-sm text-text-tertiary mt-1">Upload your first asset to get started</p>
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{filteredAssets.map(asset => (
<div key={asset._id || asset.id}>
<AssetCard asset={asset} onClick={setSelectedAsset} />
</div>
))}
</div>
)}
{/* Upload Modal */}
<Modal isOpen={showUpload} onClose={() => !uploading && setShowUpload(false)} title="Upload Assets" size="md">
<div
onDragOver={(e) => { e.preventDefault(); setDragOver(true) }}
onDragLeave={() => setDragOver(false)}
onDrop={handleDrop}
onClick={() => !uploading && fileRef.current?.click()}
className={`border-2 border-dashed rounded-xl p-12 text-center transition-colors ${
uploading ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'
} ${
dragOver ? 'border-brand-primary bg-brand-primary/5' : 'border-border hover:border-brand-primary/50'
}`}
>
<Upload className={`w-10 h-10 mx-auto mb-3 ${uploading ? 'animate-pulse' : ''} ${dragOver ? 'text-brand-primary' : 'text-text-tertiary'}`} />
<p className="text-sm font-medium text-text-primary">
{uploading ? `Uploading... ${uploadProgress}%` : 'Drop files here or click to browse'}
</p>
<p className="text-xs text-text-tertiary mt-1">
Images, videos, documents up to 50MB
</p>
{uploading && (
<div className="mt-4 w-full bg-surface-tertiary rounded-full h-2 overflow-hidden">
<div
className="h-full bg-brand-primary transition-all duration-300"
style={{ width: `${uploadProgress}%` }}
/>
</div>
)}
<input
ref={fileRef}
type="file"
multiple
disabled={uploading}
className="hidden"
onChange={e => handleUpload(e.target.files)}
/>
</div>
</Modal>
{/* Asset detail modal */}
<Modal
isOpen={!!selectedAsset}
onClose={() => setSelectedAsset(null)}
title={selectedAsset?.name || 'Asset Details'}
size="lg"
>
{selectedAsset && (
<div className="space-y-4">
{selectedAsset.type === 'image' && selectedAsset.url && (
<div className="rounded-lg overflow-hidden bg-surface-tertiary">
<img src={selectedAsset.url} alt={selectedAsset.name} className="w-full max-h-[400px] object-contain" />
</div>
)}
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-text-tertiary">Type</p>
<p className="font-medium text-text-primary capitalize">{selectedAsset.type}</p>
</div>
<div>
<p className="text-text-tertiary">Size</p>
<p className="font-medium text-text-primary">{selectedAsset.size ? `${(selectedAsset.size / 1024 / 1024).toFixed(2)} MB` : '—'}</p>
</div>
{selectedAsset.brand_name && (
<div>
<p className="text-text-tertiary">Brand</p>
<p className="font-medium text-text-primary">{selectedAsset.brand_name}</p>
</div>
)}
{selectedAsset.folder && (
<div>
<p className="text-text-tertiary">Folder</p>
<p className="font-medium text-text-primary">{selectedAsset.folder}</p>
</div>
)}
</div>
{selectedAsset.tags && selectedAsset.tags.length > 0 && (
<div>
<p className="text-sm text-text-tertiary mb-2">Tags</p>
<div className="flex flex-wrap gap-2">
{selectedAsset.tags.map(tag => (
<span key={tag} className="text-xs px-2 py-1 rounded-full bg-surface-tertiary text-text-secondary">
{tag}
</span>
))}
</div>
</div>
)}
<div className="flex items-center gap-3 pt-4 border-t border-border">
<button
onClick={() => handleDeleteAsset(selectedAsset)}
className="px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50 rounded-lg"
>
Delete Asset
</button>
<a
href={selectedAsset.url}
download={selectedAsset.name}
target="_blank"
rel="noopener noreferrer"
className="ml-auto px-4 py-2 text-sm font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light"
>
Download
</a>
</div>
</div>
)}
</Modal>
{/* Delete Asset Confirmation */}
<Modal
isOpen={showDeleteConfirm}
onClose={() => { setShowDeleteConfirm(false); setAssetToDelete(null) }}
title="Delete Asset?"
isConfirm
danger
confirmText="Delete Asset"
onConfirm={confirmDeleteAsset}
>
Are you sure you want to delete this asset? This file will be permanently removed from the server. This action cannot be undone.
</Modal>
</div>
)
}

View File

@@ -0,0 +1,565 @@
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 } from 'lucide-react'
import { format } from 'date-fns'
import { AppContext } from '../App'
import { useAuth } from '../contexts/AuthContext'
import { api, PLATFORMS } from '../utils/api'
import PlatformIcon, { PlatformIcons } from '../components/PlatformIcon'
import StatusBadge from '../components/StatusBadge'
import BrandBadge from '../components/BrandBadge'
import Modal from '../components/Modal'
const TRACK_TYPES = {
organic_social: { label: 'Organic Social', icon: Megaphone, color: 'text-green-600 bg-green-50', hasBudget: false },
paid_social: { label: 'Paid Social', icon: DollarSign, color: 'text-blue-600 bg-blue-50', hasBudget: true },
paid_search: { label: 'Paid Search (PPC)', icon: Search, color: 'text-amber-600 bg-amber-50', hasBudget: true },
seo_content: { label: 'SEO / Content', icon: Globe, color: 'text-purple-600 bg-purple-50', hasBudget: false },
production: { label: 'Production', icon: FileText, color: 'text-red-600 bg-red-50', hasBudget: true },
}
const TRACK_STATUSES = ['planned', 'active', 'paused', 'completed']
const EMPTY_TRACK = {
name: '', type: 'organic_social', platform: '', budget_allocated: '', status: 'planned', notes: '',
}
const EMPTY_METRICS = {
budget_spent: '', revenue: '', impressions: '', clicks: '', conversions: '', notes: '',
}
function BudgetBar({ budget, spent }) {
if (!budget || budget <= 0) return null
const pct = Math.min((spent / budget) * 100, 100)
const color = pct > 90 ? 'bg-red-500' : pct > 70 ? 'bg-amber-500' : 'bg-emerald-500'
return (
<div className="w-full">
<div className="flex justify-between text-[10px] text-text-tertiary mb-0.5">
<span>{(spent || 0).toLocaleString()} spent</span>
<span>{budget.toLocaleString()} SAR</span>
</div>
<div className="h-2 bg-surface-tertiary rounded-full overflow-hidden">
<div className={`h-full ${color} rounded-full transition-all`} style={{ width: `${pct}%` }} />
</div>
</div>
)
}
function MetricBox({ label, value, icon: Icon, color = 'text-text-primary' }) {
return (
<div className="text-center">
<Icon className={`w-4 h-4 mx-auto mb-0.5 ${color}`} />
<div className={`text-base font-bold ${color}`}>{value ?? '—'}</div>
<div className="text-[10px] text-text-tertiary">{label}</div>
</div>
)
}
export default function CampaignDetail() {
const { id } = useParams()
const navigate = useNavigate()
const { brands } = useContext(AppContext)
const { permissions } = useAuth()
const canManage = permissions?.canEditCampaigns
const [campaign, setCampaign] = useState(null)
const [tracks, setTracks] = useState([])
const [posts, setPosts] = useState([])
const [loading, setLoading] = useState(true)
const [showTrackModal, setShowTrackModal] = useState(false)
const [editingTrack, setEditingTrack] = useState(null)
const [trackForm, setTrackForm] = useState(EMPTY_TRACK)
const [showMetricsModal, setShowMetricsModal] = useState(false)
const [metricsTrack, setMetricsTrack] = useState(null)
const [metricsForm, setMetricsForm] = useState(EMPTY_METRICS)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [trackToDelete, setTrackToDelete] = useState(null)
useEffect(() => { loadAll() }, [id])
const loadAll = async () => {
try {
const [campRes, tracksRes, postsRes] = await Promise.all([
api.get(`/campaigns`),
api.get(`/campaigns/${id}/tracks`),
api.get(`/campaigns/${id}/posts`),
])
const allCampaigns = campRes.data || campRes || []
const found = allCampaigns.find(c => String(c.id) === String(id) || String(c._id) === String(id))
setCampaign(found || null)
setTracks(tracksRes.data || tracksRes || [])
setPosts(postsRes.data || postsRes || [])
} catch (err) {
console.error('Failed to load campaign:', err)
} finally {
setLoading(false)
}
}
const saveTrack = async () => {
try {
const data = {
name: trackForm.name,
type: trackForm.type,
platform: trackForm.platform || null,
budget_allocated: trackForm.budget_allocated ? Number(trackForm.budget_allocated) : 0,
status: trackForm.status,
notes: trackForm.notes,
}
if (editingTrack) {
await api.patch(`/tracks/${editingTrack.id}`, data)
} else {
await api.post(`/campaigns/${id}/tracks`, data)
}
setShowTrackModal(false)
setEditingTrack(null)
setTrackForm(EMPTY_TRACK)
loadAll()
} catch (err) {
console.error('Save track failed:', err)
}
}
const deleteTrack = async (trackId) => {
setTrackToDelete(trackId)
setShowDeleteConfirm(true)
}
const confirmDeleteTrack = async () => {
if (!trackToDelete) return
await api.delete(`/tracks/${trackToDelete}`)
setTrackToDelete(null)
loadAll()
}
const saveMetrics = async () => {
try {
await api.patch(`/tracks/${metricsTrack.id}`, {
budget_spent: metricsForm.budget_spent ? Number(metricsForm.budget_spent) : 0,
revenue: metricsForm.revenue ? Number(metricsForm.revenue) : 0,
impressions: metricsForm.impressions ? Number(metricsForm.impressions) : 0,
clicks: metricsForm.clicks ? Number(metricsForm.clicks) : 0,
conversions: metricsForm.conversions ? Number(metricsForm.conversions) : 0,
notes: metricsForm.notes || '',
})
setShowMetricsModal(false)
setMetricsTrack(null)
loadAll()
} catch (err) {
console.error('Save metrics failed:', err)
}
}
const openEditTrack = (track) => {
setEditingTrack(track)
setTrackForm({
name: track.name || '',
type: track.type || 'organic_social',
platform: track.platform || '',
budget_allocated: track.budget_allocated || '',
status: track.status || 'planned',
notes: track.notes || '',
})
setShowTrackModal(true)
}
const openMetrics = (track) => {
setMetricsTrack(track)
setMetricsForm({
budget_spent: track.budget_spent || '',
revenue: track.revenue || '',
impressions: track.impressions || '',
clicks: track.clicks || '',
conversions: track.conversions || '',
notes: track.notes || '',
})
setShowMetricsModal(true)
}
if (loading) {
return <div className="animate-pulse"><div className="h-64 bg-surface-tertiary rounded-xl"></div></div>
}
if (!campaign) {
return (
<div className="text-center py-12 text-text-tertiary">
Campaign not found. <button onClick={() => navigate('/campaigns')} className="text-brand-primary underline">Go back</button>
</div>
)
}
// Aggregates from tracks
const totalAllocated = tracks.reduce((s, t) => s + (t.budget_allocated || 0), 0)
const totalSpent = tracks.reduce((s, t) => s + (t.budget_spent || 0), 0)
const totalImpressions = tracks.reduce((s, t) => s + (t.impressions || 0), 0)
const totalClicks = tracks.reduce((s, t) => s + (t.clicks || 0), 0)
const totalConversions = tracks.reduce((s, t) => s + (t.conversions || 0), 0)
const totalRevenue = tracks.reduce((s, t) => s + (t.revenue || 0), 0)
return (
<div className="space-y-6 animate-fade-in">
{/* 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 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">
{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>
)}
{campaign.budget > 0 && <span>Budget: {campaign.budget.toLocaleString()} SAR</span>}
</div>
</div>
</div>
{/* Aggregate Metrics */}
{tracks.length > 0 && (
<div className="bg-white rounded-xl border border-border p-5">
<h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium mb-4">Campaign Totals (from tracks)</h3>
<div className="grid grid-cols-3 md:grid-cols-6 gap-4">
<MetricBox icon={DollarSign} label="Allocated" value={`${totalAllocated.toLocaleString()}`} color="text-blue-600" />
<MetricBox icon={TrendingUp} label="Spent" value={`${totalSpent.toLocaleString()}`} color="text-amber-600" />
<MetricBox icon={Eye} label="Impressions" value={totalImpressions.toLocaleString()} color="text-purple-600" />
<MetricBox icon={MousePointer} label="Clicks" value={totalClicks.toLocaleString()} color="text-green-600" />
<MetricBox icon={Target} label="Conversions" value={totalConversions.toLocaleString()} color="text-red-600" />
<MetricBox icon={DollarSign} label="Revenue" value={`${totalRevenue.toLocaleString()}`} color="text-emerald-600" />
</div>
{totalAllocated > 0 && (
<div className="mt-4">
<BudgetBar budget={totalAllocated} spent={totalSpent} />
</div>
)}
</div>
)}
{/* Tracks */}
<div className="bg-white rounded-xl border border-border overflow-hidden">
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
<h3 className="font-semibold text-text-primary">Tracks</h3>
{canManage && (
<button
onClick={() => { setEditingTrack(null); setTrackForm(EMPTY_TRACK); setShowTrackModal(true) }}
className="flex items-center gap-1.5 px-3 py-1.5 bg-brand-primary text-white rounded-lg text-xs font-medium hover:bg-brand-primary-light"
>
<Plus className="w-3.5 h-3.5" /> Add Track
</button>
)}
</div>
{tracks.length === 0 ? (
<div className="py-12 text-center text-sm text-text-tertiary">
No tracks yet. Add organic, paid, or SEO tracks to organize this campaign.
</div>
) : (
<div className="divide-y divide-border-light">
{tracks.map(track => {
const typeInfo = TRACK_TYPES[track.type] || TRACK_TYPES.organic_social
const TypeIcon = typeInfo.icon
const trackPosts = posts.filter(p => p.track_id === track.id)
return (
<div key={track.id} className="px-5 py-4">
<div className="flex items-start gap-3">
<div className={`p-2 rounded-lg ${typeInfo.color}`}>
<TypeIcon className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<h4 className="text-sm font-semibold text-text-primary">
{track.name || typeInfo.label}
</h4>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-surface-tertiary text-text-tertiary">
{typeInfo.label}
</span>
{track.platform && (
<PlatformIcon platform={track.platform} size={16} />
)}
<StatusBadge status={track.status} size="xs" />
</div>
{/* Budget bar for paid tracks */}
{track.budget_allocated > 0 && (
<div className="w-48 mt-1.5">
<BudgetBar budget={track.budget_allocated} spent={track.budget_spent || 0} />
</div>
)}
{/* Quick metrics */}
{(track.impressions > 0 || track.clicks > 0 || track.conversions > 0) && (
<div className="flex items-center gap-3 mt-1.5 text-[10px] text-text-tertiary">
{track.impressions > 0 && <span>👁 {track.impressions.toLocaleString()}</span>}
{track.clicks > 0 && <span>🖱 {track.clicks.toLocaleString()}</span>}
{track.conversions > 0 && <span>🎯 {track.conversions.toLocaleString()}</span>}
{track.clicks > 0 && track.budget_spent > 0 && (
<span>CPC: {(track.budget_spent / track.clicks).toFixed(2)} SAR</span>
)}
{track.impressions > 0 && track.clicks > 0 && (
<span>CTR: {(track.clicks / track.impressions * 100).toFixed(2)}%</span>
)}
</div>
)}
{/* Linked posts count */}
{trackPosts.length > 0 && (
<div className="text-[10px] text-text-tertiary mt-1">
📝 {trackPosts.length} post{trackPosts.length !== 1 ? 's' : ''} linked
</div>
)}
{track.notes && (
<p className="text-xs text-text-secondary mt-1 line-clamp-1">{track.notes}</p>
)}
</div>
{/* Actions */}
{canManage && (
<div className="flex items-center gap-1 shrink-0">
<button
onClick={() => openMetrics(track)}
title="Update metrics"
className="p-1.5 hover:bg-surface-tertiary rounded-lg text-text-tertiary hover:text-brand-primary"
>
<TrendingUp className="w-4 h-4" />
</button>
<button
onClick={() => openEditTrack(track)}
title="Edit track"
className="p-1.5 hover:bg-surface-tertiary rounded-lg text-text-tertiary hover:text-text-primary"
>
<Edit2 className="w-4 h-4" />
</button>
<button
onClick={() => deleteTrack(track.id)}
title="Delete track"
className="p-1.5 hover:bg-red-50 rounded-lg text-text-tertiary hover:text-red-500"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
)}
</div>
</div>
)
})}
</div>
)}
</div>
{/* Linked Posts */}
{posts.length > 0 && (
<div className="bg-white rounded-xl border border-border overflow-hidden">
<div className="px-5 py-4 border-b border-border">
<h3 className="font-semibold text-text-primary">Linked Posts ({posts.length})</h3>
</div>
<div className="divide-y divide-border-light">
{posts.map(post => (
<div key={post.id} className="flex items-center gap-3 px-5 py-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h4 className="text-sm font-medium text-text-primary">{post.title}</h4>
<StatusBadge status={post.status} size="xs" />
</div>
<div className="flex items-center gap-2 mt-0.5 text-[10px] text-text-tertiary">
{post.track_name && <span className="px-1.5 py-0.5 rounded bg-surface-tertiary">{post.track_name}</span>}
{post.assigned_name && <span> {post.assigned_name}</span>}
{post.platforms && post.platforms.length > 0 && (
<PlatformIcons platforms={post.platforms} size={14} />
)}
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Add/Edit Track Modal */}
<Modal
isOpen={showTrackModal}
onClose={() => { setShowTrackModal(false); setEditingTrack(null) }}
title={editingTrack ? 'Edit Track' : 'Add Track'}
>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Track Name</label>
<input
type="text"
value={trackForm.name}
onChange={e => setTrackForm(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"
placeholder="e.g., Instagram Paid Ads, Organic Wave, Google Search..."
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Type</label>
<select
value={trackForm.type}
onChange={e => setTrackForm(f => ({ ...f, type: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
>
{Object.entries(TRACK_TYPES).map(([k, v]) => (
<option key={k} value={k}>{v.label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Platform</label>
<select
value={trackForm.platform}
onChange={e => setTrackForm(f => ({ ...f, platform: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
>
<option value="">All / Multiple</option>
{Object.entries(PLATFORMS).map(([k, v]) => (
<option key={k} value={k}>{v.label}</option>
))}
<option value="google_ads">Google Ads</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Budget Allocated (SAR)</label>
<input
type="number"
value={trackForm.budget_allocated}
onChange={e => setTrackForm(f => ({ ...f, budget_allocated: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
placeholder="0 for free/organic"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Status</label>
<select
value={trackForm.status}
onChange={e => setTrackForm(f => ({ ...f, status: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
>
{TRACK_STATUSES.map(s => (
<option key={s} value={s}>{s.charAt(0).toUpperCase() + s.slice(1)}</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Notes</label>
<textarea
value={trackForm.notes}
onChange={e => setTrackForm(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 resize-none"
placeholder="Keywords, targeting details, content plan..."
/>
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-border">
<button onClick={() => setShowTrackModal(false)} className="px-4 py-2 text-sm text-text-secondary hover:bg-surface-tertiary rounded-lg">Cancel</button>
<button onClick={saveTrack} className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm">
{editingTrack ? 'Save' : 'Add Track'}
</button>
</div>
</div>
</Modal>
{/* Update Metrics Modal */}
<Modal
isOpen={showMetricsModal}
onClose={() => { setShowMetricsModal(false); setMetricsTrack(null) }}
title={`Update Metrics — ${metricsTrack?.name || ''}`}
>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Budget Spent (SAR)</label>
<input
type="number"
value={metricsForm.budget_spent}
onChange={e => setMetricsForm(f => ({ ...f, budget_spent: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Revenue (SAR)</label>
<input
type="number"
value={metricsForm.revenue}
onChange={e => setMetricsForm(f => ({ ...f, revenue: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
/>
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Impressions</label>
<input
type="number"
value={metricsForm.impressions}
onChange={e => setMetricsForm(f => ({ ...f, impressions: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Clicks</label>
<input
type="number"
value={metricsForm.clicks}
onChange={e => setMetricsForm(f => ({ ...f, clicks: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Conversions</label>
<input
type="number"
value={metricsForm.conversions}
onChange={e => setMetricsForm(f => ({ ...f, conversions: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Notes</label>
<textarea
value={metricsForm.notes}
onChange={e => setMetricsForm(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 resize-none"
placeholder="What's working, what to adjust..."
/>
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-border">
<button onClick={() => setShowMetricsModal(false)} className="px-4 py-2 text-sm text-text-secondary hover:bg-surface-tertiary rounded-lg">Cancel</button>
<button onClick={saveMetrics} className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm">
Save Metrics
</button>
</div>
</div>
</Modal>
{/* Delete Track Confirmation */}
<Modal
isOpen={showDeleteConfirm}
onClose={() => { setShowDeleteConfirm(false); setTrackToDelete(null) }}
title="Delete Track?"
isConfirm
danger
confirmText="Delete Track"
onConfirm={confirmDeleteTrack}
>
Are you sure you want to delete this campaign track? This action cannot be undone.
</Modal>
</div>
)
}

View File

@@ -0,0 +1,637 @@
import { useState, useEffect, useContext } from 'react'
import { useNavigate } from 'react-router-dom'
import { Plus, Search, TrendingUp, DollarSign, Eye, MousePointer, Target, BarChart3 } from 'lucide-react'
import { format } from 'date-fns'
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'
const EMPTY_CAMPAIGN = {
name: '', description: '', brand_id: '', status: 'planning',
start_date: '', end_date: '', budget: '', goals: '', platforms: [],
budget_spent: '', revenue: '', impressions: '', clicks: '', conversions: '', cost_per_click: '', notes: '',
}
function BudgetBar({ budget, spent }) {
if (!budget || budget <= 0) return null
const pct = Math.min((spent / budget) * 100, 100)
const color = pct > 90 ? 'bg-red-500' : pct > 70 ? 'bg-amber-500' : 'bg-emerald-500'
return (
<div className="w-full">
<div className="flex justify-between text-[10px] text-text-tertiary mb-0.5">
<span>{spent?.toLocaleString() || 0} SAR spent</span>
<span>{budget?.toLocaleString()} SAR</span>
</div>
<div className="h-1.5 bg-surface-tertiary rounded-full overflow-hidden">
<div className={`h-full ${color} rounded-full transition-all`} style={{ width: `${pct}%` }} />
</div>
</div>
)
}
function ROIBadge({ revenue, spent }) {
if (!spent || spent <= 0) return null
const roi = ((revenue - spent) / spent * 100).toFixed(0)
const color = roi >= 0 ? 'text-emerald-600 bg-emerald-50' : 'text-red-600 bg-red-50'
return (
<span className={`text-[10px] font-semibold px-1.5 py-0.5 rounded ${color}`}>
ROI {roi}%
</span>
)
}
function MetricCard({ icon: Icon, label, value, color = 'text-text-primary' }) {
return (
<div className="bg-surface-secondary rounded-lg p-3 text-center">
<Icon className={`w-4 h-4 mx-auto mb-1 ${color}`} />
<div className={`text-sm font-bold ${color}`}>{value || '—'}</div>
<div className="text-[10px] text-text-tertiary">{label}</div>
</div>
)
}
export default function Campaigns() {
const { brands } = useContext(AppContext)
const { permissions } = useAuth()
const navigate = useNavigate()
const [campaigns, setCampaigns] = useState([])
const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false)
const [editingCampaign, setEditingCampaign] = useState(null)
const [formData, setFormData] = useState(EMPTY_CAMPAIGN)
const [filters, setFilters] = useState({ brand: '', status: '' })
const [activeTab, setActiveTab] = useState('details') // details | performance
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
useEffect(() => { loadCampaigns() }, [])
const loadCampaigns = async () => {
try {
const res = await api.get('/campaigns')
setCampaigns(res.data || res || [])
} catch (err) {
console.error('Failed to load campaigns:', err)
} finally {
setLoading(false)
}
}
const handleSave = async () => {
try {
const data = {
name: formData.name,
description: formData.description,
brand_id: formData.brand_id ? Number(formData.brand_id) : null,
status: formData.status,
start_date: formData.start_date,
end_date: formData.end_date,
budget: formData.budget ? Number(formData.budget) : null,
goals: formData.goals,
platforms: formData.platforms || [],
budget_spent: formData.budget_spent ? Number(formData.budget_spent) : 0,
revenue: formData.revenue ? Number(formData.revenue) : 0,
impressions: formData.impressions ? Number(formData.impressions) : 0,
clicks: formData.clicks ? Number(formData.clicks) : 0,
conversions: formData.conversions ? Number(formData.conversions) : 0,
cost_per_click: formData.cost_per_click ? Number(formData.cost_per_click) : 0,
notes: formData.notes || '',
}
if (editingCampaign) {
await api.patch(`/campaigns/${editingCampaign.id || editingCampaign._id}`, data)
} else {
await api.post('/campaigns', data)
}
setShowModal(false)
setEditingCampaign(null)
setFormData(EMPTY_CAMPAIGN)
loadCampaigns()
} catch (err) {
console.error('Save failed:', err)
}
}
const openEdit = (campaign) => {
setEditingCampaign(campaign)
setFormData({
name: campaign.name || '',
description: campaign.description || '',
brand_id: campaign.brandId || campaign.brand_id || '',
status: campaign.status || 'planning',
start_date: campaign.startDate ? new Date(campaign.startDate).toISOString().slice(0, 10) : '',
end_date: campaign.endDate ? new Date(campaign.endDate).toISOString().slice(0, 10) : '',
budget: campaign.budget || '',
goals: campaign.goals || '',
platforms: campaign.platforms || [],
budget_spent: campaign.budgetSpent || campaign.budget_spent || '',
revenue: campaign.revenue || '',
impressions: campaign.impressions || '',
clicks: campaign.clicks || '',
conversions: campaign.conversions || '',
cost_per_click: campaign.costPerClick || campaign.cost_per_click || '',
notes: campaign.notes || '',
})
setActiveTab('details')
setShowModal(true)
}
const openNew = () => {
setEditingCampaign(null)
setFormData(EMPTY_CAMPAIGN)
setActiveTab('details')
setShowModal(true)
}
const filtered = campaigns.filter(c => {
if (filters.brand && String(c.brandId || c.brand_id) !== filters.brand) return false
if (filters.status && c.status !== filters.status) return false
return true
})
// Aggregate stats
const totalBudget = filtered.reduce((sum, c) => sum + (c.budget || 0), 0)
const totalSpent = filtered.reduce((sum, c) => sum + (c.budgetSpent || c.budget_spent || 0), 0)
const totalImpressions = filtered.reduce((sum, c) => sum + (c.impressions || 0), 0)
const totalClicks = filtered.reduce((sum, c) => sum + (c.clicks || 0), 0)
const totalConversions = filtered.reduce((sum, c) => sum + (c.conversions || 0), 0)
const totalRevenue = filtered.reduce((sum, c) => sum + (c.revenue || 0), 0)
if (loading) {
return (
<div className="space-y-4 animate-pulse">
<div className="h-12 bg-surface-tertiary rounded-xl"></div>
<div className="h-[400px] bg-surface-tertiary rounded-xl"></div>
</div>
)
}
return (
<div className="space-y-6 animate-fade-in">
{/* Summary Cards */}
{(totalBudget > 0 || totalSpent > 0) && (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
<div className="bg-white rounded-xl border border-border p-4">
<div className="flex items-center gap-2 mb-1">
<DollarSign className="w-4 h-4 text-blue-500" />
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Budget</span>
</div>
<div className="text-lg font-bold text-text-primary">{totalBudget.toLocaleString()}</div>
<div className="text-[10px] text-text-tertiary">SAR total</div>
</div>
<div className="bg-white rounded-xl border border-border p-4">
<div className="flex items-center gap-2 mb-1">
<TrendingUp className="w-4 h-4 text-amber-500" />
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Spent</span>
</div>
<div className="text-lg font-bold text-text-primary">{totalSpent.toLocaleString()}</div>
<div className="text-[10px] text-text-tertiary">SAR spent</div>
</div>
<div className="bg-white rounded-xl border border-border p-4">
<div className="flex items-center gap-2 mb-1">
<Eye className="w-4 h-4 text-purple-500" />
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Impressions</span>
</div>
<div className="text-lg font-bold text-text-primary">{totalImpressions.toLocaleString()}</div>
</div>
<div className="bg-white rounded-xl border border-border p-4">
<div className="flex items-center gap-2 mb-1">
<MousePointer className="w-4 h-4 text-green-500" />
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Clicks</span>
</div>
<div className="text-lg font-bold text-text-primary">{totalClicks.toLocaleString()}</div>
</div>
<div className="bg-white rounded-xl border border-border p-4">
<div className="flex items-center gap-2 mb-1">
<Target className="w-4 h-4 text-red-500" />
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Conversions</span>
</div>
<div className="text-lg font-bold text-text-primary">{totalConversions.toLocaleString()}</div>
</div>
<div className="bg-white rounded-xl border border-border p-4">
<div className="flex items-center gap-2 mb-1">
<BarChart3 className="w-4 h-4 text-emerald-500" />
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Revenue</span>
</div>
<div className="text-lg font-bold text-text-primary">{totalRevenue.toLocaleString()}</div>
<div className="text-[10px] text-text-tertiary">SAR</div>
</div>
</div>
)}
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-3">
<select
value={filters.brand}
onChange={e => setFilters(f => ({ ...f, brand: e.target.value }))}
className="text-sm border border-border rounded-lg px-3 py-2 bg-white text-text-secondary focus:outline-none"
>
<option value="">All Brands</option>
{brands.map(b => <option key={b.id || b._id} value={b.id || b._id}>{b.name}</option>)}
</select>
<select
value={filters.status}
onChange={e => setFilters(f => ({ ...f, status: e.target.value }))}
className="text-sm border border-border rounded-lg px-3 py-2 bg-white text-text-secondary focus:outline-none"
>
<option value="">All Statuses</option>
<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>
{permissions?.canCreateCampaigns && (
<button
onClick={openNew}
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 ml-auto"
>
<Plus className="w-4 h-4" />
New Campaign
</button>
)}
</div>
{/* Calendar */}
<CampaignCalendar campaigns={filtered} />
{/* Campaign list */}
<div className="bg-white rounded-xl border border-border overflow-hidden">
<div className="px-5 py-4 border-b border-border">
<h3 className="font-semibold text-text-primary">All Campaigns</h3>
</div>
<div className="divide-y divide-border-light">
{filtered.length === 0 ? (
<div className="py-12 text-center text-sm text-text-tertiary">
No campaigns found
</div>
) : (
filtered.map(campaign => {
const spent = campaign.budgetSpent || campaign.budget_spent || 0
const budget = campaign.budget || 0
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"
>
<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>
)}
</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>
)
})
)}
</div>
</div>
{/* Create/Edit Modal */}
<Modal
isOpen={showModal}
onClose={() => { setShowModal(false); setEditingCampaign(null) }}
title={editingCampaign ? 'Edit Campaign' : 'Create Campaign'}
size="lg"
>
<div className="space-y-4">
{/* Tabs */}
{editingCampaign && (
<div className="flex gap-1 p-1 bg-surface-tertiary rounded-lg">
<button
onClick={() => setActiveTab('details')}
className={`flex-1 px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
activeTab === 'details' ? 'bg-white text-text-primary shadow-sm' : 'text-text-tertiary hover:text-text-secondary'
}`}
>
Details
</button>
<button
onClick={() => setActiveTab('performance')}
className={`flex-1 px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
activeTab === 'performance' ? 'bg-white text-text-primary shadow-sm' : 'text-text-tertiary hover:text-text-secondary'
}`}
>
Performance & ROI
</button>
</div>
)}
{activeTab === 'details' ? (
<>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Name *</label>
<input
type="text"
value={formData.name}
onChange={e => setFormData(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"
placeholder="Campaign name"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Description</label>
<textarea
value={formData.description}
onChange={e => setFormData(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"
placeholder="Campaign description..."
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Brand</label>
<select
value={formData.brand_id}
onChange={e => setFormData(f => ({ ...f, brand_id: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
<option value="">Select brand</option>
{brands.map(b => <option key={b.id || b._id} value={b.id || b._id}>{b.icon} {b.name}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Status</label>
<select
value={formData.status}
onChange={e => setFormData(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>
{/* Platforms multi-select */}
<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 = (formData.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={() => {
setFormData(f => ({
...f,
platforms: checked
? f.platforms.filter(p => p !== k)
: [...(f.platforms || []), k]
}))
}}
className="sr-only"
/>
{v.label}
</label>
)
})}
</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={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>
<label className="block text-sm font-medium text-text-primary mb-1">End Date *</label>
<input
type="date"
value={formData.end_date}
onChange={e => setFormData(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 className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Budget (SAR)</label>
<input
type="number"
value={formData.budget}
onChange={e => setFormData(f => ({ ...f, budget: 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="e.g., 50000"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Goals</label>
<input
type="text"
value={formData.goals}
onChange={e => setFormData(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"
placeholder="Campaign goals"
/>
</div>
</div>
</>
) : (
/* Performance & ROI Tab */
<>
{/* Live metrics summary */}
{(formData.budget_spent || formData.impressions || formData.clicks) && (
<div className="grid grid-cols-4 gap-2 mb-2">
<MetricCard icon={DollarSign} label="Spent" value={formData.budget_spent ? `${Number(formData.budget_spent).toLocaleString()} SAR` : null} color="text-amber-600" />
<MetricCard icon={Eye} label="Impressions" value={formData.impressions ? Number(formData.impressions).toLocaleString() : null} color="text-purple-600" />
<MetricCard icon={MousePointer} label="Clicks" value={formData.clicks ? Number(formData.clicks).toLocaleString() : null} color="text-blue-600" />
<MetricCard icon={Target} label="Conversions" value={formData.conversions ? Number(formData.conversions).toLocaleString() : null} color="text-emerald-600" />
</div>
)}
{formData.budget && formData.budget_spent && (
<div className="p-3 bg-surface-secondary rounded-lg">
<BudgetBar budget={Number(formData.budget)} spent={Number(formData.budget_spent)} />
<div className="flex items-center gap-2 mt-2">
<ROIBadge revenue={Number(formData.revenue) || 0} spent={Number(formData.budget_spent) || 0} />
{formData.clicks > 0 && formData.budget_spent > 0 && (
<span className="text-[10px] text-text-tertiary">
CPC: {(Number(formData.budget_spent) / Number(formData.clicks)).toFixed(2)} SAR
</span>
)}
{formData.impressions > 0 && formData.clicks > 0 && (
<span className="text-[10px] text-text-tertiary">
CTR: {(Number(formData.clicks) / Number(formData.impressions) * 100).toFixed(2)}%
</span>
)}
</div>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Budget Spent (SAR)</label>
<input
type="number"
value={formData.budget_spent}
onChange={e => setFormData(f => ({ ...f, budget_spent: 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="Amount spent so far"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Revenue (SAR)</label>
<input
type="number"
value={formData.revenue}
onChange={e => setFormData(f => ({ ...f, revenue: 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="Revenue generated"
/>
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Impressions</label>
<input
type="number"
value={formData.impressions}
onChange={e => setFormData(f => ({ ...f, impressions: 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="Total impressions"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Clicks</label>
<input
type="number"
value={formData.clicks}
onChange={e => setFormData(f => ({ ...f, clicks: 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="Total clicks"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Conversions</label>
<input
type="number"
value={formData.conversions}
onChange={e => setFormData(f => ({ ...f, conversions: 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="Conversions (visits, tickets...)"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Notes</label>
<textarea
value={formData.notes}
onChange={e => setFormData(f => ({ ...f, notes: 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"
placeholder="Performance notes, observations, what's working..."
/>
</div>
</>
)}
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
{editingCampaign && permissions?.canDeleteCampaigns && (
<button
onClick={() => setShowDeleteConfirm(true)}
className="px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50 rounded-lg mr-auto"
>
Delete
</button>
)}
<button
onClick={() => { setShowModal(false); setEditingCampaign(null) }}
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
>
Cancel
</button>
<button
onClick={handleSave}
disabled={!formData.name || !formData.start_date || !formData.end_date}
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"
>
{editingCampaign ? 'Save Changes' : 'Create Campaign'}
</button>
</div>
</div>
</Modal>
{/* Delete Confirmation */}
<Modal
isOpen={showDeleteConfirm}
onClose={() => setShowDeleteConfirm(false)}
title="Delete Campaign?"
isConfirm
danger
confirmText="Delete Campaign"
onConfirm={async () => {
if (editingCampaign) {
await api.delete(`/campaigns/${editingCampaign.id || editingCampaign._id}`)
setShowModal(false)
setEditingCampaign(null)
loadCampaigns()
}
}}
>
Are you sure you want to delete this campaign? All associated posts and tracks will also be deleted. This action cannot be undone.
</Modal>
</div>
)
}

View File

@@ -0,0 +1,311 @@
import { useContext, useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { format, isAfter, isBefore, addDays } from 'date-fns'
import { FileText, Megaphone, AlertTriangle, Users, ArrowRight, Clock, Wallet, TrendingUp, DollarSign, PiggyBank } from 'lucide-react'
import { AppContext } from '../App'
import { useLanguage } from '../i18n/LanguageContext'
import { api } from '../utils/api'
import StatCard from '../components/StatCard'
import StatusBadge from '../components/StatusBadge'
import BrandBadge from '../components/BrandBadge'
function FinanceMini({ finance }) {
const { t } = useLanguage()
if (!finance) return null
const totalReceived = finance.totalReceived || 0
const spent = finance.spent || 0
const remaining = finance.remaining || 0
const roi = finance.roi || 0
const pct = totalReceived > 0 ? (spent / totalReceived) * 100 : 0
const barColor = pct > 90 ? 'bg-red-500' : pct > 70 ? 'bg-amber-500' : 'bg-emerald-500'
return (
<div className="bg-white rounded-xl border border-border p-5">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-text-primary">{t('dashboard.budgetOverview')}</h3>
<Link to="/finance" className="text-sm text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1">
{t('dashboard.details')} <ArrowRight className="w-3.5 h-3.5" />
</Link>
</div>
{totalReceived === 0 ? (
<div className="text-center py-6 text-sm text-text-tertiary">
{t('dashboard.noBudgetRecorded')}. <Link to="/finance" className="text-brand-primary underline">{t('dashboard.addBudget')}</Link>
</div>
) : (
<>
{/* Budget bar */}
<div className="mb-4">
<div className="flex justify-between text-xs text-text-tertiary mb-1">
<span>{spent.toLocaleString()} {t('dashboard.sar')} {t('dashboard.spent')}</span>
<span>{totalReceived.toLocaleString()} {t('dashboard.sar')} {t('dashboard.received')}</span>
</div>
<div className="h-3 bg-surface-tertiary rounded-full overflow-hidden">
<div className={`h-full ${barColor} rounded-full transition-all`} style={{ width: `${Math.min(pct, 100)}%` }} />
</div>
</div>
{/* Key numbers */}
<div className="grid grid-cols-3 gap-3">
<div className="text-center p-2 bg-surface-secondary rounded-lg">
<PiggyBank className="w-4 h-4 mx-auto mb-1 text-emerald-500" />
<div className={`text-sm font-bold ${remaining >= 0 ? 'text-emerald-600' : 'text-red-600'}`}>
{remaining.toLocaleString()}
</div>
<div className="text-[10px] text-text-tertiary">{t('dashboard.remaining')}</div>
</div>
<div className="text-center p-2 bg-surface-secondary rounded-lg">
<DollarSign className="w-4 h-4 mx-auto mb-1 text-purple-500" />
<div className="text-sm font-bold text-purple-600">{(finance.revenue || 0).toLocaleString()}</div>
<div className="text-[10px] text-text-tertiary">{t('dashboard.revenue')}</div>
</div>
<div className="text-center p-2 bg-surface-secondary rounded-lg">
<TrendingUp className="w-4 h-4 mx-auto mb-1 text-blue-500" />
<div className={`text-sm font-bold ${roi >= 0 ? 'text-emerald-600' : 'text-red-600'}`}>
{roi.toFixed(0)}%
</div>
<div className="text-[10px] text-text-tertiary">{t('dashboard.roi')}</div>
</div>
</div>
</>
)}
</div>
)
}
function ActiveCampaignsList({ campaigns, finance }) {
const active = campaigns.filter(c => c.status === 'active')
const campaignData = (finance?.campaigns || []).filter(c => c.status === 'active')
if (active.length === 0) return null
return (
<div className="bg-white rounded-xl border border-border">
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
<h3 className="font-semibold text-text-primary">{t('dashboard.activeCampaigns')}</h3>
<Link to="/campaigns" className="text-sm text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1">
{t('dashboard.viewAll')} <ArrowRight className="w-3.5 h-3.5" />
</Link>
</div>
<div className="divide-y divide-border-light">
{active.map(c => {
const cd = campaignData.find(d => d.id === (c._id || c.id)) || {}
const spent = cd.tracks_spent || 0
const allocated = cd.tracks_allocated || 0
const pct = allocated > 0 ? (spent / allocated) * 100 : 0
const barColor = pct > 90 ? 'bg-red-500' : pct > 70 ? 'bg-amber-500' : 'bg-emerald-500'
return (
<Link key={c._id || c.id} to={`/campaigns/${c._id || c.id}`} className="flex items-center gap-4 px-5 py-3 hover:bg-surface-secondary transition-colors">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{c.name}</p>
{allocated > 0 && (
<div className="mt-1.5 w-32">
<div className="flex justify-between text-[9px] text-text-tertiary mb-0.5">
<span>{spent.toLocaleString()}</span>
<span>{allocated.toLocaleString()} SAR</span>
</div>
<div className="h-1.5 bg-surface-tertiary rounded-full overflow-hidden">
<div className={`h-full ${barColor} rounded-full`} style={{ width: `${Math.min(pct, 100)}%` }} />
</div>
</div>
)}
</div>
<div className="text-right shrink-0">
{cd.tracks_impressions > 0 && (
<div className="text-[10px] text-text-tertiary">
👁 {cd.tracks_impressions.toLocaleString()} · 🖱 {cd.tracks_clicks.toLocaleString()}
</div>
)}
</div>
</Link>
)
})}
</div>
</div>
)
}
export default function Dashboard() {
const { t } = useLanguage()
const { currentUser, teamMembers } = useContext(AppContext)
const [posts, setPosts] = useState([])
const [campaigns, setCampaigns] = useState([])
const [tasks, setTasks] = useState([])
const [finance, setFinance] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
loadData()
}, [])
const loadData = async () => {
try {
const [postsRes, campaignsRes, tasksRes, financeRes] = await Promise.allSettled([
api.get('/posts?limit=10&sort=-createdAt'),
api.get('/campaigns'),
api.get('/tasks'),
api.get('/finance/summary'),
])
setPosts(postsRes.status === 'fulfilled' ? (postsRes.value.data || postsRes.value || []) : [])
setCampaigns(campaignsRes.status === 'fulfilled' ? (campaignsRes.value.data || campaignsRes.value || []) : [])
setTasks(tasksRes.status === 'fulfilled' ? (tasksRes.value.data || tasksRes.value || []) : [])
setFinance(financeRes.status === 'fulfilled' ? (financeRes.value.data || financeRes.value || null) : null)
} catch (err) {
console.error('Dashboard load error:', err)
} finally {
setLoading(false)
}
}
const activeCampaigns = campaigns.filter(c => c.status === 'active').length
const overdueTasks = tasks.filter(t =>
t.dueDate && new Date(t.dueDate) < new Date() && t.status !== 'done'
).length
const upcomingDeadlines = tasks
.filter(t => {
if (!t.dueDate || t.status === 'done') return false
const due = new Date(t.dueDate)
const now = new Date()
return isAfter(due, now) && isBefore(due, addDays(now, 7))
})
.sort((a, b) => new Date(a.dueDate) - new Date(b.dueDate))
.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 (
<div className="space-y-6 animate-fade-in">
{/* Welcome */}
<div>
<h1 className="text-2xl font-bold text-text-primary">
Welcome back, {currentUser?.name || 'there'} 👋
</h1>
<p className="text-text-secondary mt-1">
Here's what's happening with your marketing today.
</p>
</div>
{/* Stats */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 stagger-children">
<StatCard
icon={FileText}
label="Total Posts"
value={posts.length || 0}
subtitle={`${posts.filter(p => p.status === 'published').length} published`}
color="brand-primary"
/>
<StatCard
icon={Megaphone}
label="Active Campaigns"
value={activeCampaigns}
subtitle={`${campaigns.length} total`}
color="brand-secondary"
/>
<StatCard
icon={Wallet}
label="Budget Spent"
value={`${((finance?.spent || 0)).toLocaleString()}`}
subtitle={finance?.totalReceived ? `of ${finance.totalReceived.toLocaleString()} SAR` : 'No budget yet'}
color="brand-tertiary"
/>
<StatCard
icon={AlertTriangle}
label="Overdue Tasks"
value={overdueTasks}
subtitle={overdueTasks > 0 ? 'Needs attention' : 'All on track'}
color="brand-quaternary"
/>
</div>
{/* Three columns on large, stack on small */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Budget Overview */}
<FinanceMini finance={finance} />
{/* Active Campaigns with budget bars */}
<div className="lg:col-span-2">
<ActiveCampaignsList campaigns={campaigns} finance={finance} />
</div>
</div>
{/* Two columns */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Recent Posts */}
<div className="bg-white rounded-xl border border-border">
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
<h3 className="font-semibold text-text-primary">Recent Posts</h3>
<Link to="/posts" className="text-sm text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1">
View all <ArrowRight className="w-3.5 h-3.5" />
</Link>
</div>
<div className="divide-y divide-border-light">
{posts.length === 0 ? (
<div className="py-12 text-center text-sm text-text-tertiary">
No posts yet. Create your first post!
</div>
) : (
posts.slice(0, 8).map((post) => (
<div key={post._id} className="flex items-center gap-3 px-5 py-3 hover:bg-surface-secondary transition-colors">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{post.title}</p>
<div className="flex items-center gap-2 mt-1">
{post.brand && <BrandBadge brand={post.brand} />}
</div>
</div>
<StatusBadge status={post.status} size="xs" />
</div>
))
)}
</div>
</div>
{/* Upcoming Deadlines */}
<div className="bg-white rounded-xl border border-border">
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
<h3 className="font-semibold text-text-primary">Upcoming Deadlines</h3>
<Link to="/tasks" className="text-sm text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1">
View all <ArrowRight className="w-3.5 h-3.5" />
</Link>
</div>
<div className="divide-y divide-border-light">
{upcomingDeadlines.length === 0 ? (
<div className="py-12 text-center text-sm text-text-tertiary">
No upcoming deadlines this week. 🎉
</div>
) : (
upcomingDeadlines.map((task) => (
<div key={task._id} className="flex items-center gap-3 px-5 py-3 hover:bg-surface-secondary transition-colors">
<div className={`w-2 h-2 rounded-full ${
task.priority === 'urgent' ? 'bg-red-500' :
task.priority === 'high' ? 'bg-orange-500' :
task.priority === 'medium' ? 'bg-amber-400' : 'bg-gray-400'
}`} />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{task.title}</p>
<StatusBadge status={task.status} size="xs" />
</div>
<div className="flex items-center gap-1 text-xs text-text-tertiary shrink-0">
<Clock className="w-3.5 h-3.5" />
{format(new Date(task.dueDate), 'MMM d')}
</div>
</div>
))
)}
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,434 @@
import { useState, useEffect, useContext } from 'react'
import { Plus, DollarSign, TrendingUp, TrendingDown, Wallet, PiggyBank, Eye, MousePointer, Target, Edit2, Trash2 } from 'lucide-react'
import { format } from 'date-fns'
import { AppContext } from '../App'
import { api } from '../utils/api'
import Modal from '../components/Modal'
import StatusBadge from '../components/StatusBadge'
const CATEGORIES = [
{ value: 'marketing', label: 'Marketing' },
{ value: 'production', label: 'Production' },
{ value: 'equipment', label: 'Equipment' },
{ value: 'travel', label: 'Travel' },
{ value: 'other', label: 'Other' },
]
const EMPTY_ENTRY = {
label: '', amount: '', source: '', campaign_id: '', category: 'marketing', date_received: new Date().toISOString().slice(0, 10), notes: '',
}
function StatCard({ icon: Icon, label, value, sub, color = 'text-text-primary', bgColor = 'bg-white' }) {
return (
<div className={`${bgColor} rounded-xl border border-border p-5`}>
<div className="flex items-center gap-2 mb-2">
<div className={`p-2 rounded-lg ${color.replace('text-', 'bg-')}/10`}>
<Icon className={`w-5 h-5 ${color}`} />
</div>
<span className="text-xs uppercase tracking-wider text-text-tertiary font-medium">{label}</span>
</div>
<div className={`text-2xl font-bold ${color}`}>{value}</div>
{sub && <div className="text-xs text-text-tertiary mt-1">{sub}</div>}
</div>
)
}
function ProgressRing({ pct, size = 80, stroke = 8, color = '#10b981' }) {
const r = (size - stroke) / 2
const circ = 2 * Math.PI * r
const offset = circ - (Math.min(pct, 100) / 100) * circ
return (
<svg width={size} height={size} className="transform -rotate-90">
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke="#f3f4f6" strokeWidth={stroke} />
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke={color} strokeWidth={stroke}
strokeDasharray={circ} strokeDashoffset={offset} strokeLinecap="round" className="transition-all duration-500" />
<text x={size / 2} y={size / 2} textAnchor="middle" dominantBaseline="central"
className="fill-text-primary text-sm font-bold" transform={`rotate(90 ${size / 2} ${size / 2})`}>
{Math.round(pct)}%
</text>
</svg>
)
}
export default function Finance() {
const { brands } = useContext(AppContext)
const [entries, setEntries] = useState([])
const [summary, setSummary] = useState(null)
const [campaigns, setCampaigns] = useState([])
const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false)
const [editing, setEditing] = useState(null)
const [form, setForm] = useState(EMPTY_ENTRY)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [entryToDelete, setEntryToDelete] = useState(null)
useEffect(() => { loadAll() }, [])
const loadAll = async () => {
try {
const [ent, sum, camp] = await Promise.all([
api.get('/budget'),
api.get('/finance/summary'),
api.get('/campaigns'),
])
setEntries(ent.data || ent || [])
setSummary(sum.data || sum || {})
setCampaigns(camp.data || camp || [])
} catch (err) {
console.error('Failed to load finance:', err)
} finally {
setLoading(false)
}
}
const handleSave = async () => {
try {
const data = {
label: form.label,
amount: Number(form.amount),
source: form.source || null,
campaign_id: form.campaign_id ? Number(form.campaign_id) : null,
category: form.category,
date_received: form.date_received,
notes: form.notes,
}
if (editing) {
await api.patch(`/budget/${editing._id || editing.id}`, data)
} else {
await api.post('/budget', data)
}
setShowModal(false)
setEditing(null)
setForm(EMPTY_ENTRY)
loadAll()
} catch (err) {
console.error('Save failed:', err)
}
}
const openEdit = (entry) => {
setEditing(entry)
setForm({
label: entry.label || '',
amount: entry.amount || '',
source: entry.source || '',
campaign_id: entry.campaignId || entry.campaign_id || '',
category: entry.category || 'marketing',
date_received: entry.dateReceived || entry.date_received || '',
notes: entry.notes || '',
})
setShowModal(true)
}
const handleDelete = async (id) => {
setEntryToDelete(id)
setShowDeleteConfirm(true)
}
const confirmDelete = async () => {
if (!entryToDelete) return
await api.delete(`/budget/${entryToDelete}`)
setEntryToDelete(null)
loadAll()
}
if (loading) {
return (
<div className="space-y-4 animate-pulse">
<div className="grid grid-cols-4 gap-4">
{[1, 2, 3, 4].map(i => <div key={i} className="h-28 bg-surface-tertiary rounded-xl" />)}
</div>
</div>
)
}
const s = summary || {}
const totalReceived = s.totalReceived || 0
const totalSpent = s.spent || 0
const remaining = s.remaining || 0
const totalRevenue = s.revenue || 0
const roi = s.roi || 0
const spendPct = totalReceived > 0 ? (totalSpent / totalReceived) * 100 : 0
return (
<div className="space-y-6 animate-fade-in">
{/* Top metrics */}
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4">
<StatCard icon={Wallet} label="Total Received" value={`${totalReceived.toLocaleString()} SAR`} color="text-blue-600" />
<StatCard icon={TrendingUp} label="Total Spent" value={`${totalSpent.toLocaleString()} SAR`} sub={`${spendPct.toFixed(1)}% of budget`} color="text-amber-600" />
<StatCard icon={PiggyBank} label="Remaining" value={`${remaining.toLocaleString()} SAR`} color={remaining >= 0 ? 'text-emerald-600' : 'text-red-600'} />
<StatCard icon={DollarSign} label="Revenue" value={`${totalRevenue.toLocaleString()} SAR`} color="text-purple-600" />
<StatCard icon={roi >= 0 ? TrendingUp : TrendingDown} label="Global ROI"
value={`${roi.toFixed(1)}%`}
color={roi >= 0 ? 'text-emerald-600' : 'text-red-600'} />
</div>
{/* Budget utilization + Global metrics */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{/* Utilization ring */}
<div className="bg-white rounded-xl border border-border p-5 flex flex-col items-center justify-center">
<h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium mb-4">Budget Utilization</h3>
<ProgressRing
pct={spendPct}
size={120}
stroke={10}
color={spendPct > 90 ? '#ef4444' : spendPct > 70 ? '#f59e0b' : '#10b981'}
/>
<div className="text-xs text-text-tertiary mt-3">
{totalSpent.toLocaleString()} of {totalReceived.toLocaleString()} SAR
</div>
</div>
{/* Global performance */}
<div className="bg-white rounded-xl border border-border p-5 lg:col-span-2">
<h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium mb-4">Global Performance</h3>
<div className="grid grid-cols-3 gap-6">
<div className="text-center">
<Eye className="w-5 h-5 mx-auto mb-1 text-purple-500" />
<div className="text-xl font-bold text-text-primary">{(s.impressions || 0).toLocaleString()}</div>
<div className="text-xs text-text-tertiary">Impressions</div>
</div>
<div className="text-center">
<MousePointer className="w-5 h-5 mx-auto mb-1 text-blue-500" />
<div className="text-xl font-bold text-text-primary">{(s.clicks || 0).toLocaleString()}</div>
<div className="text-xs text-text-tertiary">Clicks</div>
{s.clicks > 0 && s.spent > 0 && (
<div className="text-[10px] text-text-tertiary mt-0.5">CPC: {(s.spent / s.clicks).toFixed(2)} SAR</div>
)}
</div>
<div className="text-center">
<Target className="w-5 h-5 mx-auto mb-1 text-emerald-500" />
<div className="text-xl font-bold text-text-primary">{(s.conversions || 0).toLocaleString()}</div>
<div className="text-xs text-text-tertiary">Conversions</div>
{s.conversions > 0 && s.spent > 0 && (
<div className="text-[10px] text-text-tertiary mt-0.5">CPA: {(s.spent / s.conversions).toFixed(2)} SAR</div>
)}
</div>
</div>
{s.impressions > 0 && s.clicks > 0 && (
<div className="mt-4 pt-3 border-t border-border text-center">
<span className="text-xs text-text-tertiary">
CTR: {(s.clicks / s.impressions * 100).toFixed(2)}%
{s.conversions > 0 && s.clicks > 0 && ` · Conv. Rate: ${(s.conversions / s.clicks * 100).toFixed(2)}%`}
</span>
</div>
)}
</div>
</div>
{/* Per-campaign breakdown */}
{s.campaigns && s.campaigns.length > 0 && (
<div className="bg-white rounded-xl border border-border overflow-hidden">
<div className="px-5 py-4 border-b border-border">
<h3 className="font-semibold text-text-primary">Campaign Breakdown</h3>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-surface-secondary">
<th className="px-4 py-3 text-left text-xs font-medium text-text-tertiary">Campaign</th>
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">Allocated</th>
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">Spent</th>
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">Revenue</th>
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">ROI</th>
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">Impressions</th>
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">Clicks</th>
<th className="px-4 py-3 text-center text-xs font-medium text-text-tertiary">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-border-light">
{s.campaigns.map(c => {
const cRoi = c.tracks_spent > 0 ? ((c.tracks_revenue - c.tracks_spent) / c.tracks_spent * 100) : 0
return (
<tr key={c.id} className="hover:bg-surface-secondary">
<td className="px-4 py-3 font-medium text-text-primary">{c.name}</td>
<td className="px-4 py-3 text-right text-text-secondary">{c.tracks_allocated.toLocaleString()}</td>
<td className="px-4 py-3 text-right text-text-secondary">{c.tracks_spent.toLocaleString()}</td>
<td className="px-4 py-3 text-right text-text-secondary">{c.tracks_revenue.toLocaleString()}</td>
<td className="px-4 py-3 text-right">
{c.tracks_spent > 0 ? (
<span className={`text-xs font-semibold px-1.5 py-0.5 rounded ${cRoi >= 0 ? 'text-emerald-600 bg-emerald-50' : 'text-red-600 bg-red-50'}`}>
{cRoi.toFixed(0)}%
</span>
) : '—'}
</td>
<td className="px-4 py-3 text-right text-text-secondary">{c.tracks_impressions.toLocaleString()}</td>
<td className="px-4 py-3 text-right text-text-secondary">{c.tracks_clicks.toLocaleString()}</td>
<td className="px-4 py-3 text-center"><StatusBadge status={c.status} size="xs" /></td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)}
{/* Budget entries */}
<div className="bg-white rounded-xl border border-border overflow-hidden">
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
<h3 className="font-semibold text-text-primary">Budget Received</h3>
<button
onClick={() => { setEditing(null); setForm(EMPTY_ENTRY); setShowModal(true) }}
className="flex items-center gap-1.5 px-3 py-1.5 bg-brand-primary text-white rounded-lg text-xs font-medium hover:bg-brand-primary-light"
>
<Plus className="w-3.5 h-3.5" /> Add Entry
</button>
</div>
{entries.length === 0 ? (
<div className="py-12 text-center text-sm text-text-tertiary">
No budget entries yet. Add your first received budget.
</div>
) : (
<div className="divide-y divide-border-light">
{entries.map(entry => (
<div key={entry.id || entry._id} className="flex items-center gap-4 px-5 py-4 hover:bg-surface-secondary">
<div className="p-2 rounded-lg bg-emerald-50">
<DollarSign className="w-4 h-4 text-emerald-600" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<h4 className="text-sm font-semibold text-text-primary">{entry.label}</h4>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-surface-tertiary text-text-tertiary capitalize">
{entry.category}
</span>
</div>
<div className="text-xs text-text-tertiary">
{entry.source && <span>{entry.source} · </span>}
{entry.campaign_name && <span>{entry.campaign_name} · </span>}
{entry.date_received && format(new Date(entry.date_received), 'MMM d, yyyy')}
</div>
{entry.notes && <p className="text-xs text-text-secondary mt-0.5">{entry.notes}</p>}
</div>
<div className="text-right shrink-0">
<div className="text-base font-bold text-emerald-600">{Number(entry.amount).toLocaleString()} SAR</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<button onClick={() => openEdit(entry)} className="p-1.5 hover:bg-surface-tertiary rounded-lg text-text-tertiary hover:text-text-primary">
<Edit2 className="w-4 h-4" />
</button>
<button onClick={() => handleDelete(entry.id || entry._id)} className="p-1.5 hover:bg-red-50 rounded-lg text-text-tertiary hover:text-red-500">
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* Add/Edit Modal */}
<Modal
isOpen={showModal}
onClose={() => { setShowModal(false); setEditing(null) }}
title={editing ? 'Edit Budget Entry' : 'Add Budget Entry'}
>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Label *</label>
<input
type="text"
value={form.label}
onChange={e => setForm(f => ({ ...f, label: 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="e.g., Seerah Campaign Budget, Additional Q1 Funds..."
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Amount (SAR) *</label>
<input
type="number"
value={form.amount}
onChange={e => setForm(f => ({ ...f, amount: 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="50000"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Date Received *</label>
<input
type="date"
value={form.date_received}
onChange={e => setForm(f => ({ ...f, date_received: 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">Source</label>
<input
type="text"
value={form.source}
onChange={e => setForm(f => ({ ...f, source: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
placeholder="e.g., CEO Approval, Annual Budget..."
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Category</label>
<select
value={form.category}
onChange={e => setForm(f => ({ ...f, category: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
>
{CATEGORIES.map(c => <option key={c.value} value={c.value}>{c.label}</option>)}
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Campaign (optional)</label>
<select
value={form.campaign_id}
onChange={e => setForm(f => ({ ...f, campaign_id: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
>
<option value="">General / Not linked</option>
{campaigns.map(c => <option key={c._id || c.id} value={c._id || c.id}>{c.name}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Notes</label>
<textarea
value={form.notes}
onChange={e => setForm(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 resize-none"
placeholder="Any details about this budget entry..."
/>
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-border">
<button onClick={() => setShowModal(false)} className="px-4 py-2 text-sm text-text-secondary hover:bg-surface-tertiary rounded-lg">Cancel</button>
<button
onClick={handleSave}
disabled={!form.label || !form.amount || !form.date_received}
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"
>
{editing ? 'Save Changes' : 'Add Entry'}
</button>
</div>
</div>
</Modal>
{/* Delete Budget Entry Confirmation */}
<Modal
isOpen={showDeleteConfirm}
onClose={() => { setShowDeleteConfirm(false); setEntryToDelete(null) }}
title="Delete Budget Entry?"
isConfirm
danger
confirmText="Delete Entry"
onConfirm={confirmDelete}
>
Are you sure you want to delete this budget entry? This action cannot be undone.
</Modal>
</div>
)
}

119
client/src/pages/Login.jsx Normal file
View File

@@ -0,0 +1,119 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../i18n/LanguageContext'
import { Megaphone, Lock, Mail, AlertCircle } from 'lucide-react'
export default function Login() {
const navigate = useNavigate()
const { login } = useAuth()
const { t } = useLanguage()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
setLoading(true)
try {
await login(email, password)
navigate('/')
} catch (err) {
setError(err.message || 'Invalid email or password')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-blue-900 to-slate-900 flex items-center justify-center px-4">
<div className="w-full max-w-md">
{/* Logo & Title */}
<div className="text-center mb-8">
<div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-purple-600 rounded-2xl flex items-center justify-center mx-auto mb-4 shadow-lg">
<Megaphone className="w-8 h-8 text-white" />
</div>
<h1 className="text-3xl font-bold text-white mb-2">{t('login.title')}</h1>
<p className="text-slate-400">{t('login.subtitle')}</p>
</div>
{/* Login Card */}
<div className="bg-slate-800/50 backdrop-blur-sm rounded-2xl border border-slate-700/50 p-8 shadow-2xl">
<form onSubmit={handleSubmit} className="space-y-5">
{/* Email */}
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">
{t('auth.email')}
</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<input
type="email"
value={email}
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"
required
autoFocus
/>
</div>
</div>
{/* Password */}
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">
{t('auth.password')}
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
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="••••••••"
required
/>
</div>
</div>
{/* Error Message */}
{error && (
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
<AlertCircle className="w-5 h-5 text-red-400 shrink-0" />
<p className="text-sm text-red-400">{error}</p>
</div>
)}
{/* Submit Button */}
<button
type="submit"
disabled={loading}
className="w-full py-3 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? (
<span className="flex items-center justify-center gap-2">
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
{t('auth.signingIn')}
</span>
) : (
t('auth.loginBtn')
)}
</button>
</form>
{/* Default Credentials */}
<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>
</p>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,669 @@
import { useState, useEffect, useContext, useRef } from 'react'
import { Plus, LayoutGrid, List, Search, Filter, Upload, X, Paperclip, Link2, FileText, Image as ImageIcon, Trash2, ExternalLink } from 'lucide-react'
import { AppContext } from '../App'
import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../i18n/LanguageContext'
import { api, PLATFORMS } from '../utils/api'
import KanbanBoard from '../components/KanbanBoard'
import PostCard from '../components/PostCard'
import StatusBadge from '../components/StatusBadge'
import Modal from '../components/Modal'
const EMPTY_POST = {
title: '', description: '', brand_id: '', platforms: [],
status: 'draft', assigned_to: '', scheduled_date: '', notes: '', campaign_id: '',
publication_links: [],
}
export default function PostProduction() {
const { t } = useLanguage()
const { teamMembers, brands } = useContext(AppContext)
const { canEditResource, canDeleteResource } = useAuth()
const [posts, setPosts] = useState([])
const [loading, setLoading] = useState(true)
const [view, setView] = useState('kanban')
const [showModal, setShowModal] = useState(false)
const [editingPost, setEditingPost] = useState(null)
const [formData, setFormData] = useState(EMPTY_POST)
const [campaigns, setCampaigns] = useState([])
const [filters, setFilters] = useState({ brand: '', platform: '', assignedTo: '', campaign: '' })
const [searchTerm, setSearchTerm] = useState('')
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [attachments, setAttachments] = useState([])
const [uploading, setUploading] = useState(false)
const [uploadProgress, setUploadProgress] = useState(0)
const [publishError, setPublishError] = useState('')
const [dragActive, setDragActive] = useState(false)
const fileInputRef = useRef(null)
useEffect(() => {
loadPosts()
api.get('/campaigns').then(r => setCampaigns(Array.isArray(r) ? r : (r.data || []))).catch(() => {})
}, [])
const loadPosts = async () => {
try {
const res = await api.get('/posts')
setPosts(res.data || res || [])
} catch (err) {
console.error('Failed to load posts:', err)
} finally {
setLoading(false)
}
}
const handleSave = async () => {
setPublishError('')
try {
const data = {
title: formData.title,
description: formData.description,
brand_id: formData.brand_id ? Number(formData.brand_id) : null,
assigned_to: formData.assigned_to ? Number(formData.assigned_to) : null,
status: formData.status,
platforms: formData.platforms || [],
scheduled_date: formData.scheduled_date || null,
notes: formData.notes,
campaign_id: formData.campaign_id ? Number(formData.campaign_id) : null,
publication_links: formData.publication_links || [],
}
// Client-side validation: check publication links before publishing
if (data.status === 'published' && data.platforms.length > 0) {
const missingPlatforms = data.platforms.filter(platform => {
const link = (data.publication_links || []).find(l => l.platform === platform)
return !link || !link.url || !link.url.trim()
})
if (missingPlatforms.length > 0) {
const names = missingPlatforms.map(p => PLATFORMS[p]?.label || p).join(', ')
setPublishError(`${t('posts.publishMissing')} ${names}`)
return
}
}
if (editingPost) {
await api.patch(`/posts/${editingPost._id}`, data)
} else {
await api.post('/posts', data)
}
setShowModal(false)
setEditingPost(null)
setFormData(EMPTY_POST)
setAttachments([])
loadPosts()
} catch (err) {
console.error('Save failed:', err)
if (err.message?.includes('Cannot publish')) {
setPublishError(err.message.replace(/.*: /, ''))
}
}
}
const handleMovePost = async (postId, newStatus) => {
try {
await api.patch(`/posts/${postId}`, { status: newStatus })
loadPosts()
} catch (err) {
console.error('Move failed:', err)
if (err.message?.includes('Cannot publish')) {
alert('Cannot publish: all platform publication links must be filled first.')
}
}
}
const loadAttachments = async (postId) => {
try {
const data = await api.get(`/posts/${postId}/attachments`)
setAttachments(Array.isArray(data) ? data : (data.data || []))
} catch (err) {
console.error('Failed to load attachments:', err)
setAttachments([])
}
}
const handleFileUpload = async (files) => {
if (!editingPost || !files?.length) return
setUploading(true)
setUploadProgress(0)
const postId = editingPost._id || editingPost.id
for (let i = 0; i < files.length; i++) {
const fd = new FormData()
fd.append('file', files[i])
try {
await api.upload(`/posts/${postId}/attachments`, fd)
setUploadProgress(Math.round(((i + 1) / files.length) * 100))
} catch (err) {
console.error('Upload failed:', err)
}
}
setUploading(false)
setUploadProgress(0)
loadAttachments(postId)
}
const handleDeleteAttachment = async (attachmentId) => {
try {
await api.delete(`/attachments/${attachmentId}`)
if (editingPost) loadAttachments(editingPost._id || editingPost.id)
} catch (err) {
console.error('Delete attachment failed:', err)
}
}
const handleDragEnter = (e) => { e.preventDefault(); e.stopPropagation(); setDragActive(true) }
const handleDragLeaveZone = (e) => { e.preventDefault(); e.stopPropagation(); setDragActive(false) }
const handleDragOverZone = (e) => { e.preventDefault(); e.stopPropagation() }
const handleDropFiles = (e) => {
e.preventDefault(); e.stopPropagation(); setDragActive(false)
if (e.dataTransfer.files?.length) handleFileUpload(e.dataTransfer.files)
}
const updatePublicationLink = (platform, url) => {
setFormData(f => {
const links = [...(f.publication_links || [])]
const idx = links.findIndex(l => l.platform === platform)
if (idx >= 0) {
links[idx] = { ...links[idx], url }
} else {
links.push({ platform, url })
}
return { ...f, publication_links: links }
})
}
const openEdit = (post) => {
if (!canEditResource('post', post)) {
alert('You can only edit your own posts')
return
}
setEditingPost(post)
setPublishError('')
setFormData({
title: post.title || '',
description: post.description || '',
brand_id: post.brandId || post.brand_id || '',
platforms: post.platforms || (post.platform ? [post.platform] : []),
status: post.status || 'draft',
assigned_to: post.assignedTo || post.assigned_to || '',
scheduled_date: post.scheduledDate ? new Date(post.scheduledDate).toISOString().slice(0, 16) : '',
notes: post.notes || '',
campaign_id: post.campaignId || post.campaign_id || '',
publication_links: post.publication_links || post.publicationLinks || [],
})
loadAttachments(post._id || post.id)
setShowModal(true)
}
const openNew = () => {
setEditingPost(null)
setFormData(EMPTY_POST)
setAttachments([])
setPublishError('')
setShowModal(true)
}
const filteredPosts = posts.filter(p => {
if (filters.brand && String(p.brandId || p.brand_id) !== filters.brand) return false
if (filters.platform && !(p.platforms || []).includes(filters.platform) && p.platform !== filters.platform) return false
if (filters.assignedTo && String(p.assignedTo || p.assigned_to) !== filters.assignedTo) return false
if (filters.campaign && String(p.campaignId || p.campaign_id) !== filters.campaign) return false
if (searchTerm && !p.title?.toLowerCase().includes(searchTerm.toLowerCase())) return false
return true
})
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 (
<div className="space-y-4 animate-fade-in">
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-3">
{/* Search */}
<div className="relative flex-1 min-w-[200px] max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<input
type="text"
placeholder={t('posts.searchPosts')}
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-white"
/>
</div>
{/* Filters */}
<div data-tutorial="filters" className="flex gap-3">
<select
value={filters.brand}
onChange={e => setFilters(f => ({ ...f, brand: e.target.value }))}
className="text-sm border border-border rounded-lg px-3 py-2 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
>
<option value="">{t('posts.allBrands')}</option>
{brands.map(b => <option key={b._id} value={b._id}>{b.name}</option>)}
</select>
<select
value={filters.platform}
onChange={e => setFilters(f => ({ ...f, platform: e.target.value }))}
className="text-sm border border-border rounded-lg px-3 py-2 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
>
<option value="">{t('posts.allPlatforms')}</option>
{Object.entries(PLATFORMS).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
</select>
<select
value={filters.assignedTo}
onChange={e => setFilters(f => ({ ...f, assignedTo: e.target.value }))}
className="text-sm border border-border rounded-lg px-3 py-2 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
>
<option value="">{t('posts.allPeople')}</option>
{teamMembers.map(m => <option key={m._id} value={String(m._id)}>{m.name}</option>)}
</select>
</div>
{/* View toggle */}
<div className="flex bg-surface-tertiary rounded-lg p-0.5 ml-auto">
<button
onClick={() => setView('kanban')}
className={`p-2 rounded-md ${view === 'kanban' ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary'}`}
>
<LayoutGrid className="w-4 h-4" />
</button>
<button
onClick={() => setView('list')}
className={`p-2 rounded-md ${view === 'list' ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary'}`}
>
<List className="w-4 h-4" />
</button>
</div>
{/* New post */}
<button
data-tutorial="new-post"
onClick={openNew}
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" />
{t('posts.newPost')}
</button>
</div>
{/* Content */}
{view === 'kanban' ? (
<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>
</tr>
)}
</tbody>
</table>
</div>
)}
{/* Create/Edit Modal */}
<Modal
isOpen={showModal}
onClose={() => { setShowModal(false); setEditingPost(null) }}
title={editingPost ? t('posts.editPost') : t('posts.createPost')}
size="lg"
>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('posts.postTitle')} *</label>
<input
type="text"
value={formData.title}
onChange={e => setFormData(f => ({ ...f, title: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
placeholder={t('posts.postTitlePlaceholder')}
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('posts.description')}</label>
<textarea
value={formData.description}
onChange={e => setFormData(f => ({ ...f, description: e.target.value }))}
rows={4}
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"
placeholder={t('posts.postDescPlaceholder')}
/>
</div>
{/* Campaign */}
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('posts.campaign')}</label>
<select
value={formData.campaign_id}
onChange={e => setFormData(f => ({ ...f, campaign_id: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
<option value="">{t('posts.noCampaign')}</option>
{campaigns.map(c => <option key={c._id} value={c._id}>{c.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">{t('posts.brand')}</label>
<select
value={formData.brand_id}
onChange={e => setFormData(f => ({ ...f, brand_id: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
<option value="">{t('posts.selectBrand')}</option>
{brands.map(b => <option key={b._id} value={b._id}>{b.icon} {b.name}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('posts.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 = (formData.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={() => {
setFormData(f => ({
...f,
platforms: checked
? f.platforms.filter(p => p !== k)
: [...(f.platforms || []), k]
}))
}}
className="sr-only"
/>
{v.label}
</label>
)
})}
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('posts.status')}</label>
<select
value={formData.status}
onChange={e => setFormData(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="draft">{t('posts.status.draft')}</option>
<option value="in_review">{t('posts.status.in_review')}</option>
<option value="approved">{t('posts.status.approved')}</option>
<option value="scheduled">{t('posts.status.scheduled')}</option>
<option value="published">{t('posts.status.published')}</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('posts.assignTo')}</label>
<select
value={formData.assigned_to}
onChange={e => setFormData(f => ({ ...f, assigned_to: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
<option value="">{t('common.unassigned')}</option>
{teamMembers.map(m => <option key={m._id} value={m._id}>{m.name}</option>)}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('posts.scheduledDate')}</label>
<input
type="datetime-local"
value={formData.scheduled_date}
onChange={e => setFormData(f => ({ ...f, scheduled_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">{t('posts.notes')}</label>
<input
type="text"
value={formData.notes}
onChange={e => setFormData(f => ({ ...f, notes: 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={t('posts.additionalNotes')}
/>
</div>
</div>
{/* Publication Links */}
{(formData.platforms || []).length > 0 && (
<div>
<label className="block text-sm font-medium text-text-primary mb-2">
<span className="flex items-center gap-1.5">
<Link2 className="w-4 h-4" />
{t('posts.publicationLinks')}
</span>
</label>
<div className="space-y-2 border border-border rounded-lg p-3 bg-surface-secondary">
{(formData.platforms || []).map(platformKey => {
const platformInfo = PLATFORMS[platformKey] || { label: platformKey }
const existingLink = (formData.publication_links || []).find(l => l.platform === platformKey)
const linkUrl = existingLink?.url || ''
return (
<div key={platformKey} className="flex items-center gap-2">
<span className="text-xs font-medium text-text-secondary w-24 shrink-0 flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: platformInfo.color || '#888' }} />
{platformInfo.label}
</span>
<input
type="url"
value={linkUrl}
onChange={e => updatePublicationLink(platformKey, e.target.value)}
className="flex-1 px-3 py-1.5 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
placeholder="https://..."
/>
{linkUrl && (
<a href={linkUrl} target="_blank" rel="noopener noreferrer" className="p-1.5 text-text-tertiary hover:text-brand-primary">
<ExternalLink className="w-3.5 h-3.5" />
</a>
)}
</div>
)
})}
{formData.status === 'published' && (formData.platforms || []).some(p => {
const link = (formData.publication_links || []).find(l => l.platform === p)
return !link || !link.url?.trim()
}) && (
<p className="text-xs text-amber-600 mt-1"> {t('posts.publishRequired')}</p>
)}
</div>
</div>
)}
{/* Attachments (only for existing posts) */}
{editingPost && (
<div>
<label className="block text-sm font-medium text-text-primary mb-2">
<span className="flex items-center gap-1.5">
<Paperclip className="w-4 h-4" />
{t('posts.attachments')}
<span className="text-xs text-text-tertiary font-normal">({attachments.length})</span>
</span>
</label>
{/* Existing attachments */}
{attachments.length > 0 && (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mb-3">
{attachments.map(att => {
const isImage = att.mime_type?.startsWith('image/') || att.mimeType?.startsWith('image/')
const attUrl = att.url || `/api/uploads/${att.filename}`
const name = att.original_name || att.originalName || att.filename
return (
<div key={att.id || att._id} className="relative group/att border border-border rounded-lg overflow-hidden bg-white">
{isImage ? (
<a href={`http://localhost:3001${attUrl}`} target="_blank" rel="noopener noreferrer">
<img
src={`http://localhost:3001${attUrl}`}
alt={name}
className="w-full h-24 object-cover"
/>
</a>
) : (
<a href={`http://localhost:3001${attUrl}`} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 p-3 h-24">
<FileText className="w-8 h-8 text-text-tertiary shrink-0" />
<span className="text-xs text-text-secondary truncate">{name}</span>
</a>
)}
<button
onClick={() => handleDeleteAttachment(att.id || att._id)}
className="absolute top-1 right-1 p-1 bg-red-500 text-white rounded-full opacity-0 group-hover/att:opacity-100 transition-opacity shadow-sm"
title={t('posts.deleteAttachment')}
>
<X className="w-3 h-3" />
</button>
<div className="px-2 py-1 text-[10px] text-text-tertiary truncate border-t border-border-light">
{name}
</div>
</div>
)
})}
</div>
)}
{/* Upload area */}
<div
className={`border-2 border-dashed rounded-lg p-4 text-center cursor-pointer transition-colors ${
dragActive ? 'border-brand-primary bg-brand-primary/5' : 'border-border hover:border-brand-primary/40'
}`}
onClick={() => fileInputRef.current?.click()}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeaveZone}
onDragOver={handleDragOverZone}
onDrop={handleDropFiles}
>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={(e) => handleFileUpload(e.target.files)}
/>
<Upload className="w-6 h-6 text-text-tertiary mx-auto mb-1" />
<p className="text-xs text-text-secondary">
{dragActive ? t('posts.dropFiles') : t('posts.uploadFiles')}
</p>
<p className="text-[10px] text-text-tertiary mt-0.5">{t('posts.maxSize')}</p>
</div>
{/* Upload progress */}
{uploading && (
<div className="mt-2">
<div className="flex items-center justify-between text-xs text-text-secondary mb-1">
<span>{t('posts.uploading')}</span>
<span>{uploadProgress}%</span>
</div>
<div className="w-full bg-surface-tertiary rounded-full h-1.5">
<div
className="bg-brand-primary h-1.5 rounded-full transition-all"
style={{ width: `${uploadProgress}%` }}
/>
</div>
</div>
)}
</div>
)}
{/* Publish validation error */}
{publishError && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
{publishError}
</div>
)}
{/* Actions */}
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
{editingPost && canDeleteResource('post', editingPost) && (
<button
onClick={() => setShowDeleteConfirm(true)}
className="px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50 rounded-lg mr-auto"
>
{t('common.delete')}
</button>
)}
<button
onClick={() => { setShowModal(false); setEditingPost(null) }}
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
>
{t('common.cancel')}
</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"
>
{editingPost ? t('posts.saveChanges') : t('posts.createPost')}
</button>
</div>
</div>
</Modal>
{/* Delete Confirmation */}
<Modal
isOpen={showDeleteConfirm}
onClose={() => setShowDeleteConfirm(false)}
title={t('posts.deletePost')}
isConfirm
danger
confirmText={t('posts.deletePost')}
onConfirm={async () => {
if (editingPost) {
try {
await api.delete(`/posts/${editingPost._id || editingPost.id}`)
setShowModal(false)
setEditingPost(null)
loadPosts()
} catch (err) {
console.error('Delete failed:', err)
}
}
}}
>
{t('posts.deleteConfirm')}
</Modal>
</div>
)
}

View File

@@ -0,0 +1,777 @@
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
} from 'lucide-react'
import { format, differenceInDays, startOfDay, addDays, isAfter, isBefore, parseISO } from 'date-fns'
import { AppContext } from '../App'
import { api, PRIORITY_CONFIG } from '../utils/api'
import StatusBadge from '../components/StatusBadge'
import BrandBadge from '../components/BrandBadge'
import Modal from '../components/Modal'
const TASK_COLUMNS = [
{ id: 'todo', label: 'To Do', color: 'bg-gray-400' },
{ id: 'in_progress', label: 'In Progress', color: 'bg-blue-400' },
{ id: 'done', label: 'Done', color: 'bg-emerald-400' },
]
export default function ProjectDetail() {
const { id } = useParams()
const navigate = useNavigate()
const { teamMembers, brands } = useContext(AppContext)
const [project, setProject] = useState(null)
const [tasks, setTasks] = useState([])
const [loading, setLoading] = useState(true)
const [view, setView] = useState('kanban')
const [showTaskModal, setShowTaskModal] = useState(false)
const [showProjectModal, setShowProjectModal] = useState(false)
const [editingTask, setEditingTask] = useState(null)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [taskToDelete, setTaskToDelete] = useState(null)
const [taskForm, setTaskForm] = useState({
title: '', description: '', priority: 'medium', assigned_to: '', due_date: '', status: 'todo'
})
const [projectForm, setProjectForm] = useState({
name: '', description: '', brand_id: '', owner_id: '', status: 'active', due_date: ''
})
// Drag state for kanban
const [draggedTask, setDraggedTask] = useState(null)
const [dragOverCol, setDragOverCol] = useState(null)
useEffect(() => { loadProject() }, [id])
const loadProject = async () => {
try {
const proj = await api.get(`/projects/${id}`)
setProject(proj.data || proj)
const tasksRes = await api.get(`/tasks?project_id=${id}`)
setTasks(Array.isArray(tasksRes) ? tasksRes : (tasksRes.data || []))
} catch (err) {
console.error('Failed to load project:', err)
} finally {
setLoading(false)
}
}
const handleTaskSave = async () => {
try {
const data = {
title: taskForm.title,
description: taskForm.description,
priority: taskForm.priority,
assigned_to: taskForm.assigned_to ? Number(taskForm.assigned_to) : null,
due_date: taskForm.due_date || null,
status: taskForm.status,
project_id: Number(id),
}
if (editingTask) {
await api.patch(`/tasks/${editingTask._id}`, data)
} else {
await api.post('/tasks', data)
}
setShowTaskModal(false)
setEditingTask(null)
setTaskForm({ title: '', description: '', priority: 'medium', assigned_to: '', due_date: '', status: 'todo' })
loadProject()
} catch (err) {
console.error('Task save failed:', err)
}
}
const handleTaskStatusChange = async (taskId, newStatus) => {
try {
await api.patch(`/tasks/${taskId}`, { status: newStatus })
loadProject()
} catch (err) {
console.error('Status change failed:', err)
}
}
const handleDeleteTask = async (taskId) => {
setTaskToDelete(taskId)
setShowDeleteConfirm(true)
}
const confirmDeleteTask = async () => {
if (!taskToDelete) return
try {
await api.delete(`/tasks/${taskToDelete}`)
loadProject()
setTaskToDelete(null)
} catch (err) {
console.error('Delete failed:', err)
}
}
const openEditTask = (task) => {
setEditingTask(task)
setTaskForm({
title: task.title || '',
description: task.description || '',
priority: task.priority || 'medium',
assigned_to: task.assignedTo || task.assigned_to || '',
due_date: task.dueDate ? new Date(task.dueDate).toISOString().slice(0, 10) : '',
status: task.status || 'todo',
})
setShowTaskModal(true)
}
const openNewTask = () => {
setEditingTask(null)
setTaskForm({ title: '', description: '', priority: 'medium', assigned_to: '', due_date: '', status: 'todo' })
setShowTaskModal(true)
}
const openEditProject = () => {
if (!project) return
setProjectForm({
name: project.name || '',
description: project.description || '',
brand_id: project.brandId || project.brand_id || '',
owner_id: project.ownerId || project.owner_id || '',
status: project.status || 'active',
due_date: project.dueDate ? new Date(project.dueDate).toISOString().slice(0, 10) : '',
})
setShowProjectModal(true)
}
const handleProjectSave = async () => {
try {
await api.patch(`/projects/${id}`, {
name: projectForm.name,
description: projectForm.description,
brand_id: projectForm.brand_id ? Number(projectForm.brand_id) : null,
owner_id: projectForm.owner_id ? Number(projectForm.owner_id) : null,
status: projectForm.status,
due_date: projectForm.due_date || null,
})
setShowProjectModal(false)
loadProject()
} catch (err) {
console.error('Project save failed:', err)
}
}
// Drag handlers
const handleDragStart = (e, task) => {
setDraggedTask(task)
e.dataTransfer.effectAllowed = 'move'
setTimeout(() => { e.target.style.opacity = '0.4' }, 0)
}
const handleDragEnd = (e) => {
e.target.style.opacity = '1'
setDraggedTask(null)
setDragOverCol(null)
}
const handleDragOver = (e, colId) => {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
setDragOverCol(colId)
}
const handleDragLeave = (e) => {
if (!e.currentTarget.contains(e.relatedTarget)) setDragOverCol(null)
}
const handleDrop = (e, colId) => {
e.preventDefault()
setDragOverCol(null)
if (draggedTask && draggedTask.status !== colId) {
handleTaskStatusChange(draggedTask._id, colId)
}
setDraggedTask(null)
}
if (loading) {
return (
<div className="animate-pulse space-y-4">
<div className="h-8 w-48 bg-surface-tertiary rounded-lg"></div>
<div className="h-40 bg-surface-tertiary rounded-xl"></div>
</div>
)
}
if (!project) {
return (
<div className="py-20 text-center">
<p className="text-text-secondary">Project not found</p>
<button onClick={() => navigate('/projects')} className="mt-4 text-brand-primary hover:underline text-sm">
Back to Projects
</button>
</div>
)
}
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
// Gantt chart helpers
const getGanttRange = () => {
const today = startOfDay(new Date())
let earliest = today
let latest = addDays(today, 14)
tasks.forEach(t => {
if (t.createdAt) {
const d = startOfDay(new Date(t.createdAt))
if (isBefore(d, earliest)) earliest = d
}
if (t.dueDate) {
const d = startOfDay(new Date(t.dueDate))
if (isAfter(d, latest)) latest = addDays(d, 1)
}
})
if (project.dueDate) {
const d = startOfDay(new Date(project.dueDate))
if (isAfter(d, latest)) latest = addDays(d, 1)
}
// Ensure minimum 14 days
if (differenceInDays(latest, earliest) < 14) latest = addDays(earliest, 14)
return { earliest, latest, totalDays: differenceInDays(latest, earliest) + 1 }
}
return (
<div className="space-y-6 animate-fade-in">
{/* Back button */}
<button
onClick={() => navigate('/projects')}
className="flex items-center gap-2 text-sm text-text-secondary hover:text-text-primary transition-colors"
>
<ArrowLeft className="w-4 h-4" />
Back to Projects
</button>
{/* Project header */}
<div className="bg-white rounded-xl border border-border p-6">
<div className="flex items-start justify-between gap-4 mb-4">
<div>
<div className="flex items-center gap-3 mb-2">
<h1 className="text-2xl font-bold text-text-primary">{project.name}</h1>
<StatusBadge status={project.status} />
</div>
<div className="flex items-center gap-3 flex-wrap">
{brandName && <BrandBadge brand={brandName} />}
{ownerName && (
<span className="text-sm text-text-secondary">
Owned by <span className="font-medium">{ownerName}</span>
</span>
)}
{project.dueDate && (
<span className="text-sm text-text-tertiary flex items-center gap-1">
<Calendar className="w-3.5 h-3.5" />
Due {format(new Date(project.dueDate), 'MMMM d, yyyy')}
</span>
)}
</div>
</div>
<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>
{project.description && (
<p className="text-sm text-text-secondary mb-4">{project.description}</p>
)}
{/* Progress */}
<div className="max-w-md">
<div className="flex items-center justify-between text-sm mb-2">
<span className="text-text-secondary font-medium">Progress</span>
<span className="font-semibold text-text-primary">{progress}%</span>
</div>
<div className="h-2.5 bg-surface-tertiary rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-brand-primary to-brand-primary-light rounded-full transition-all duration-500"
style={{ width: `${progress}%` }}
/>
</div>
<p className="text-xs text-text-tertiary mt-1">{completedTasks} of {tasks.length} tasks completed</p>
</div>
</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">
{[
{ id: 'kanban', icon: LayoutGrid, label: 'Board' },
{ id: 'list', icon: List, label: 'List' },
{ id: 'gantt', 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>
<button
onClick={openNewTask}
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" />
Add Task
</button>
</div>
{/* ─── KANBAN VIEW ─── */}
{view === 'kanban' && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{TASK_COLUMNS.map(col => {
const colTasks = tasks.filter(t => t.status === col.id)
const isOver = dragOverCol === col.id && draggedTask?.status !== col.id
return (
<div key={col.id}>
<div className="flex items-center gap-2 mb-3">
<div className={`w-2.5 h-2.5 rounded-full ${col.color}`} />
<h4 className="text-sm font-semibold text-text-primary">{col.label}</h4>
<span className="text-xs font-medium text-text-tertiary bg-surface-tertiary px-2 py-0.5 rounded-full">
{colTasks.length}
</span>
</div>
<div
className={`rounded-xl p-2 space-y-2 min-h-[150px] border-2 transition-colors ${
isOver
? 'bg-brand-primary/5 border-brand-primary/40 border-dashed'
: 'bg-surface-secondary border-border-light border-solid'
}`}
onDragOver={(e) => handleDragOver(e, col.id)}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, col.id)}
>
{colTasks.length === 0 ? (
<div className={`py-8 text-center text-xs ${isOver ? 'text-brand-primary font-medium' : 'text-text-tertiary'}`}>
{isOver ? 'Drop here' : 'No tasks'}
</div>
) : (
colTasks.map(task => (
<TaskKanbanCard
key={task._id}
task={task}
onEdit={() => openEditTask(task)}
onDelete={() => handleDeleteTask(task._id)}
onStatusChange={handleTaskStatusChange}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
/>
))
)}
</div>
</div>
)
})}
</div>
)}
{/* ─── LIST VIEW ─── */}
{view === 'list' && (
<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 w-8"></th>
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Task</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Status</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Priority</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Assignee</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Due</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider w-16"></th>
</tr>
</thead>
<tbody className="divide-y divide-border-light">
{tasks.length === 0 ? (
<tr><td colSpan={7} className="py-12 text-center text-sm text-text-tertiary">No tasks yet</td></tr>
) : (
tasks.map(task => {
const prio = PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium
const assigneeName = task.assignedName || task.assigned_name
const isOverdue = task.dueDate && new Date(task.dueDate) < new Date() && task.status !== 'done'
return (
<tr key={task._id} className="hover:bg-surface-secondary group">
<td className="px-4 py-3">
<div className={`w-2.5 h-2.5 rounded-full ${prio.color}`} />
</td>
<td className="px-4 py-3">
<button onClick={() => openEditTask(task)} className="text-sm font-medium text-text-primary hover:text-brand-primary text-left">
{task.title}
</button>
{task.description && <p className="text-xs text-text-tertiary line-clamp-1 mt-0.5">{task.description}</p>}
</td>
<td className="px-4 py-3"><StatusBadge status={task.status} size="xs" /></td>
<td className="px-4 py-3 text-xs font-medium text-text-secondary capitalize">{prio.label}</td>
<td className="px-4 py-3 text-xs text-text-secondary">{assigneeName || '—'}</td>
<td className={`px-4 py-3 text-xs ${isOverdue ? 'text-red-500 font-medium' : 'text-text-tertiary'}`}>
{task.dueDate ? format(new Date(task.dueDate), 'MMM d, yyyy') : '—'}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={() => openEditTask(task)} className="p-1 rounded hover:bg-surface-tertiary text-text-tertiary">
<Edit3 className="w-3.5 h-3.5" />
</button>
<button onClick={() => handleDeleteTask(task._id)} className="p-1 rounded hover:bg-red-50 text-red-400">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</td>
</tr>
)
})
)}
</tbody>
</table>
</div>
)}
{/* ─── GANTT / TIMELINE VIEW ─── */}
{view === 'gantt' && <GanttView tasks={tasks} project={project} onEditTask={openEditTask} />}
{/* ─── TASK MODAL ─── */}
<Modal
isOpen={showTaskModal}
onClose={() => { setShowTaskModal(false); setEditingTask(null) }}
title={editingTask ? 'Edit Task' : 'Add Task'}
size="md"
>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Title *</label>
<input
type="text"
value={taskForm.title}
onChange={e => setTaskForm(f => ({ ...f, title: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
placeholder="Task title"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Description</label>
<textarea
value={taskForm.description}
onChange={e => setTaskForm(f => ({ ...f, description: 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"
placeholder="Optional description"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Priority</label>
<select value={taskForm.priority} onChange={e => setTaskForm(f => ({ ...f, priority: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary">
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="urgent">Urgent</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Status</label>
<select value={taskForm.status} onChange={e => setTaskForm(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="todo">To Do</option>
<option value="in_progress">In Progress</option>
<option value="done">Done</option>
</select>
</div>
</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>
{teamMembers.map(m => <option key={m._id} value={m._id}>{m.name}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Due Date</label>
<input type="date" value={taskForm.due_date} onChange={e => setTaskForm(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>
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
{editingTask && (
<button onClick={() => handleDeleteTask(editingTask._id)}
className="px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50 rounded-lg mr-auto">
Delete
</button>
)}
<button onClick={() => { setShowTaskModal(false); setEditingTask(null) }}
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg">
Cancel
</button>
<button onClick={handleTaskSave} disabled={!taskForm.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">
{editingTask ? 'Save Changes' : 'Add Task'}
</button>
</div>
</div>
</Modal>
{/* ─── PROJECT EDIT MODAL ─── */}
<Modal
isOpen={showProjectModal}
onClose={() => setShowProjectModal(false)}
title="Edit Project"
size="md"
>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Name *</label>
<input type="text" value={projectForm.name} onChange={e => setProjectForm(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"
placeholder="Project name" />
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Description</label>
<textarea value={projectForm.description} onChange={e => setProjectForm(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"
placeholder="Project description..." />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Brand</label>
<select value={projectForm.brand_id} onChange={e => setProjectForm(f => ({ ...f, brand_id: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary">
<option value="">Select brand</option>
{brands.map(b => <option key={b._id} value={b._id}>{b.icon} {b.name}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Status</label>
<select value={projectForm.status} onChange={e => setProjectForm(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="active">Active</option>
<option value="paused">Paused</option>
<option value="completed">Completed</option>
<option value="cancelled">Cancelled</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Owner</label>
<select value={projectForm.owner_id} onChange={e => setProjectForm(f => ({ ...f, owner_id: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary">
<option value="">Unassigned</option>
{teamMembers.map(m => <option key={m._id} value={m._id}>{m.name}</option>)}
</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 }))}
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="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">
Cancel
</button>
<button onClick={handleProjectSave} disabled={!projectForm.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>
</div>
)
}
// ─── Task Kanban Card ───────────────────────────────
function TaskKanbanCard({ task, onEdit, onDelete, onStatusChange, onDragStart, onDragEnd }) {
const priority = PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium
const assigneeName = task.assignedName || task.assigned_name
const isOverdue = task.dueDate && new Date(task.dueDate) < new Date() && task.status !== 'done'
return (
<div
draggable
onDragStart={(e) => onDragStart(e, task)}
onDragEnd={onDragEnd}
className="bg-white rounded-lg border border-border p-3 group hover:border-brand-primary/30 hover:shadow-sm transition-all cursor-grab active:cursor-grabbing"
>
<div className="flex items-start gap-2">
<div className={`w-2 h-2 rounded-full ${priority.color} mt-1.5 shrink-0`} title={priority.label} />
<div className="flex-1 min-w-0">
<h5 className={`text-sm font-medium ${task.status === 'done' ? 'text-text-tertiary line-through' : 'text-text-primary'}`}>
{task.title}
</h5>
<div className="flex items-center gap-2 mt-1.5 flex-wrap">
{assigneeName && (
<span className="text-[10px] text-text-tertiary">{assigneeName}</span>
)}
{task.dueDate && (
<span className={`text-[10px] flex items-center gap-0.5 ${isOverdue ? 'text-red-500 font-medium' : 'text-text-tertiary'}`}>
<Clock className="w-3 h-3" />
{format(new Date(task.dueDate), 'MMM d')}
</span>
)}
</div>
</div>
</div>
{/* Actions on hover */}
<div className="flex items-center gap-1 mt-2 pt-2 border-t border-border-light opacity-0 group-hover:opacity-100 transition-opacity">
{task.status !== 'done' && (
<button onClick={() => onStatusChange(task._id, task.status === 'todo' ? 'in_progress' : 'done')}
className="text-[10px] text-brand-primary hover:bg-brand-primary/10 px-2 py-0.5 rounded-full flex items-center gap-1">
<Check className="w-3 h-3" />
{task.status === 'todo' ? 'Start' : 'Complete'}
</button>
)}
<button onClick={onEdit}
className="text-[10px] text-text-tertiary hover:bg-surface-tertiary px-2 py-0.5 rounded-full flex items-center gap-1">
<Edit3 className="w-3 h-3" /> Edit
</button>
<button onClick={onDelete}
className="text-[10px] text-red-400 hover:bg-red-50 px-2 py-0.5 rounded-full flex items-center gap-1 ml-auto">
<Trash2 className="w-3 h-3" />
</button>
</div>
</div>
)
}
// ─── Gantt / Timeline View ──────────────────────────
function GanttView({ tasks, project, onEditTask }) {
if (tasks.length === 0) {
return (
<div className="bg-white rounded-xl border border-border py-16 text-center">
<GanttChart className="w-12 h-12 text-text-tertiary mx-auto mb-3" />
<p className="text-text-secondary font-medium">No tasks to display</p>
<p className="text-sm text-text-tertiary mt-1">Add tasks with due dates to see the timeline</p>
</div>
)
}
const today = startOfDay(new Date())
// Calculate range
let earliest = today
let latest = addDays(today, 21)
tasks.forEach(t => {
const created = t.createdAt ? startOfDay(new Date(t.createdAt)) : today
const due = t.dueDate ? startOfDay(new Date(t.dueDate)) : null
if (isBefore(created, earliest)) earliest = created
if (due && isAfter(due, latest)) latest = addDays(due, 2)
})
if (project.dueDate) {
const pd = startOfDay(new Date(project.dueDate))
if (isAfter(pd, latest)) latest = addDays(pd, 2)
}
const totalDays = differenceInDays(latest, earliest) + 1
// Generate day headers
const days = []
for (let i = 0; i < totalDays; i++) {
days.push(addDays(earliest, i))
}
const dayWidth = Math.max(36, Math.min(60, 800 / totalDays))
const getBarStyle = (task) => {
const start = 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)
return { left: `${left}px`, width: `${width}px` }
}
const statusColors = {
todo: 'bg-gray-300',
in_progress: 'bg-blue-400',
done: 'bg-emerald-400',
}
return (
<div className="bg-white rounded-xl border border-border overflow-hidden">
<div className="overflow-x-auto">
<div style={{ minWidth: `${totalDays * dayWidth + 200}px` }}>
{/* Day headers */}
<div className="flex border-b border-border bg-surface-secondary sticky top-0 z-10">
<div className="w-[200px] shrink-0 px-4 py-2 text-xs font-semibold text-text-tertiary uppercase border-r border-border">
Task
</div>
<div className="flex">
{days.map((day, i) => {
const isToday = differenceInDays(day, today) === 0
const isWeekend = day.getDay() === 0 || day.getDay() === 6
return (
<div
key={i}
style={{ width: `${dayWidth}px` }}
className={`text-center py-2 border-r border-border-light text-[10px] ${
isToday ? 'bg-brand-primary/10 font-bold text-brand-primary' :
isWeekend ? 'bg-surface-tertiary/50 text-text-tertiary' : 'text-text-tertiary'
}`}
>
<div>{format(day, 'd')}</div>
<div className="text-[8px] uppercase">{format(day, 'EEE')}</div>
</div>
)
})}
</div>
</div>
{/* Task rows */}
{tasks.map(task => {
const prio = PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium
const barStyle = getBarStyle(task)
return (
<div key={task._id} className="flex border-b border-border-light hover:bg-surface-secondary/50 group">
<div className="w-[200px] shrink-0 px-4 py-3 border-r border-border flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${prio.color} shrink-0`} />
<button onClick={() => onEditTask(task)}
className="text-xs font-medium text-text-primary truncate hover:text-brand-primary text-left">
{task.title}
</button>
</div>
<div className="relative flex-1" style={{ height: '44px' }}>
{/* Today line */}
{differenceInDays(today, earliest) >= 0 && (
<div
className="absolute top-0 bottom-0 w-px bg-brand-primary/30 z-10"
style={{ left: `${differenceInDays(today, earliest) * dayWidth + dayWidth / 2}px` }}
/>
)}
{/* Bar */}
<div
className={`absolute top-2.5 h-5 rounded-full ${statusColors[task.status] || 'bg-gray-300'} opacity-80 hover:opacity-100 transition-opacity cursor-pointer`}
style={barStyle}
onClick={() => onEditTask(task)}
title={`${task.title}${task.dueDate ? ` — Due ${format(new Date(task.dueDate), 'MMM d')}` : ''}`}
/>
</div>
</div>
)
})}
</div>
</div>
{/* Delete Task Confirmation */}
<Modal
isOpen={showDeleteConfirm}
onClose={() => { setShowDeleteConfirm(false); setTaskToDelete(null) }}
title="Delete Task?"
isConfirm
danger
confirmText="Delete Task"
onConfirm={confirmDeleteTask}
>
Are you sure you want to delete this task? This action cannot be undone.
</Modal>
</div>
)
}

View File

@@ -0,0 +1,202 @@
import { useState, useEffect, useContext } from 'react'
import { Plus, Search, FolderKanban } from 'lucide-react'
import { AppContext } from '../App'
import { api } from '../utils/api'
import ProjectCard from '../components/ProjectCard'
import Modal from '../components/Modal'
const EMPTY_PROJECT = {
name: '', description: '', brand_id: '', status: 'active',
owner_id: '', due_date: '',
}
export default function Projects() {
const { teamMembers, brands } = useContext(AppContext)
const [projects, setProjects] = useState([])
const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false)
const [formData, setFormData] = useState(EMPTY_PROJECT)
const [searchTerm, setSearchTerm] = useState('')
useEffect(() => { loadProjects() }, [])
const loadProjects = async () => {
try {
const res = await api.get('/projects')
setProjects(res.data || res || [])
} catch (err) {
console.error('Failed to load projects:', err)
} finally {
setLoading(false)
}
}
const handleCreate = async () => {
try {
const data = {
name: formData.name,
description: formData.description,
brand_id: formData.brand_id ? Number(formData.brand_id) : null,
owner_id: formData.owner_id ? Number(formData.owner_id) : null,
status: formData.status,
due_date: formData.due_date || null,
}
await api.post('/projects', data)
setShowModal(false)
setFormData(EMPTY_PROJECT)
loadProjects()
} catch (err) {
console.error('Create failed:', err)
}
}
const filtered = projects.filter(p => {
if (searchTerm && !p.name?.toLowerCase().includes(searchTerm.toLowerCase())) return false
return true
})
if (loading) {
return (
<div className="animate-pulse">
<div className="h-12 bg-surface-tertiary rounded-xl mb-4"></div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[...Array(6)].map((_, i) => <div key={i} className="h-56 bg-surface-tertiary rounded-xl"></div>)}
</div>
</div>
)
}
return (
<div className="space-y-4 animate-fade-in">
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-3">
<div className="relative flex-1 min-w-[200px] max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<input
type="text"
placeholder="Search projects..."
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-white"
/>
</div>
<button
onClick={() => 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 ml-auto"
>
<Plus className="w-4 h-4" />
New Project
</button>
</div>
{/* Project grid */}
{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>
) : (
<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>
)}
{/* Create Modal */}
<Modal isOpen={showModal} onClose={() => setShowModal(false)} title="Create New Project" size="md">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Name *</label>
<input
type="text"
value={formData.name}
onChange={e => setFormData(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"
placeholder="Project name"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Description</label>
<textarea
value={formData.description}
onChange={e => setFormData(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"
placeholder="Project description..."
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Brand</label>
<select
value={formData.brand_id}
onChange={e => setFormData(f => ({ ...f, brand_id: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
<option value="">Select brand</option>
{brands.map(b => <option key={b._id} value={b._id}>{b.icon} {b.name}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Status</label>
<select
value={formData.status}
onChange={e => setFormData(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="active">Active</option>
<option value="paused">Paused</option>
<option value="completed">Completed</option>
<option value="cancelled">Cancelled</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Owner</label>
<select
value={formData.owner_id}
onChange={e => setFormData(f => ({ ...f, owner_id: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
<option value="">Unassigned</option>
{teamMembers.map(m => <option key={m._id} value={m._id}>{m.name}</option>)}
</select>
</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>
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
<button
onClick={() => setShowModal(false)}
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
>
Cancel
</button>
<button
onClick={handleCreate}
disabled={!formData.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"
>
Create Project
</button>
</div>
</div>
</Modal>
</div>
)
}

View File

@@ -0,0 +1,110 @@
import { useState } from 'react'
import { Settings as SettingsIcon, Play, CheckCircle, Languages } from 'lucide-react'
import { api } from '../utils/api'
import { useLanguage } from '../i18n/LanguageContext'
export default function Settings() {
const { t, lang, setLang } = useLanguage()
const [restarting, setRestarting] = useState(false)
const [success, setSuccess] = useState(false)
const handleRestartTutorial = async () => {
setRestarting(true)
setSuccess(false)
try {
await api.patch('/users/me/tutorial', { completed: false })
setSuccess(true)
setTimeout(() => {
window.location.reload() // Reload to trigger tutorial
}, 1500)
} catch (err) {
console.error('Failed to restart tutorial:', err)
alert('Failed to restart tutorial')
} finally {
setRestarting(false)
}
}
return (
<div className="space-y-6 animate-fade-in max-w-3xl">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-text-primary flex items-center gap-3">
<SettingsIcon className="w-7 h-7 text-brand-primary" />
{t('settings.title')}
</h1>
<p className="text-sm text-text-tertiary mt-1">{t('settings.preferences')}</p>
</div>
{/* General Settings */}
<div className="bg-white rounded-xl border border-border overflow-hidden">
<div className="px-6 py-4 border-b border-border">
<h2 className="text-lg font-semibold text-text-primary">{t('settings.general')}</h2>
</div>
<div className="p-6 space-y-4">
{/* Language Selector */}
<div>
<label className="block text-sm font-medium text-text-primary mb-2 flex items-center gap-2">
<Languages className="w-4 h-4" />
{t('settings.language')}
</label>
<select
value={lang}
onChange={(e) => setLang(e.target.value)}
className="w-full max-w-xs px-4 py-2.5 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-white"
>
<option value="en">{t('settings.english')}</option>
<option value="ar">{t('settings.arabic')}</option>
</select>
</div>
</div>
</div>
{/* Tutorial Section */}
<div className="bg-white rounded-xl border border-border overflow-hidden">
<div className="px-6 py-4 border-b border-border">
<h2 className="text-lg font-semibold text-text-primary">{t('settings.onboardingTutorial')}</h2>
</div>
<div className="p-6 space-y-4">
<p className="text-sm text-text-secondary">
{t('settings.tutorialDesc')}
</p>
<button
onClick={handleRestartTutorial}
disabled={restarting || success}
className="flex items-center gap-2 px-4 py-2.5 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm transition-colors"
>
{success ? (
<>
<CheckCircle className="w-4 h-4" />
{t('settings.tutorialRestarted')}
</>
) : (
<>
<Play className="w-4 h-4" />
{restarting ? t('settings.restarting') : t('settings.restartTutorial')}
</>
)}
</button>
{success && (
<p className="text-xs text-emerald-600 font-medium">
{t('settings.reloadingPage')}
</p>
)}
</div>
</div>
{/* More settings can go here in the future */}
<div className="bg-white rounded-xl border border-border overflow-hidden opacity-50">
<div className="px-6 py-4 border-b border-border">
<h2 className="text-lg font-semibold text-text-primary">{t('settings.moreComingSoon')}</h2>
</div>
<div className="p-6">
<p className="text-sm text-text-secondary">
{t('settings.additionalSettings')}
</p>
</div>
</div>
</div>
)
}

423
client/src/pages/Tasks.jsx Normal file
View File

@@ -0,0 +1,423 @@
import { useState, useEffect, useContext } from 'react'
import { Plus, CheckSquare, Edit2, Trash2, Filter } from 'lucide-react'
import { AppContext } from '../App'
import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../i18n/LanguageContext'
import { api } from '../utils/api'
import TaskCard from '../components/TaskCard'
import Modal from '../components/Modal'
export default function Tasks() {
const { t } = useLanguage()
const { currentUser, teamMembers } = useContext(AppContext)
const { user: authUser, canEditResource, canDeleteResource } = useAuth()
const [tasks, setTasks] = useState([])
const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false)
const [editingTask, setEditingTask] = useState(null)
const [draggedTask, setDraggedTask] = useState(null)
const [dragOverCol, setDragOverCol] = useState(null)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [taskToDelete, setTaskToDelete] = useState(null)
const [filterView, setFilterView] = useState('all') // 'all' | 'assigned_to_me' | 'created_by_me' | member id
const [users, setUsers] = useState([]) // for superadmin member filter
const [formData, setFormData] = useState({
title: '', description: '', priority: 'medium', due_date: '', status: 'todo', assigned_to: ''
})
const isSuperadmin = authUser?.role === 'superadmin'
useEffect(() => { loadTasks() }, [currentUser])
useEffect(() => {
if (isSuperadmin) {
// Load team members for superadmin filter
api.get('/team').then(res => setUsers(res.data || res || [])).catch(() => {})
}
}, [isSuperadmin])
const loadTasks = async () => {
try {
const res = await api.get('/tasks')
setTasks(res.data || res || [])
} catch (err) {
console.error('Failed to load tasks:', err)
} finally {
setLoading(false)
}
}
// Filter tasks client-side based on selected view
const filteredTasks = tasks.filter(task => {
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
}
if (filterView === 'created_by_me') {
return task.created_by_user_id === authUser?.id
}
// Superadmin filtering by specific team member (assigned_to = member id)
if (isSuperadmin && !isNaN(Number(filterView))) {
return task.assigned_to === Number(filterView)
}
return true
})
const handleSave = async () => {
try {
const data = {
title: formData.title,
description: formData.description,
priority: formData.priority,
due_date: formData.due_date || null,
status: formData.status,
assigned_to: formData.assigned_to || null,
is_personal: false,
}
if (editingTask) {
await api.patch(`/tasks/${editingTask._id || editingTask.id}`, data)
} else {
await api.post('/tasks', data)
}
setShowModal(false)
setEditingTask(null)
setFormData({ title: '', description: '', priority: 'medium', 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')
}
}
}
const handleMove = async (taskId, newStatus) => {
try {
await api.patch(`/tasks/${taskId}`, { status: newStatus })
loadTasks()
} catch (err) {
console.error('Move failed:', err)
if (err.message?.includes('403')) {
alert('You can only modify your own tasks')
}
}
}
const openEdit = (task) => {
if (!canEditResource('task', task)) return
setEditingTask(task)
setFormData({
title: task.title || '',
description: task.description || '',
priority: task.priority || 'medium',
due_date: task.due_date || task.dueDate || '',
status: task.status || 'todo',
assigned_to: task.assigned_to || '',
})
setShowModal(true)
}
const handleDelete = (task) => {
if (!canDeleteResource('task', task)) return
setTaskToDelete(task)
setShowDeleteConfirm(true)
}
const confirmDelete = async () => {
if (!taskToDelete) return
try {
await api.delete(`/tasks/${taskToDelete._id || taskToDelete.id}`)
setTaskToDelete(null)
loadTasks()
} catch (err) {
console.error('Delete failed:', err)
}
}
const handleDragStart = (e, task) => {
setDraggedTask(task)
e.dataTransfer.effectAllowed = 'move'
if (e.target) {
setTimeout(() => e.target.style.opacity = '0.4', 0)
}
}
const handleDragEnd = (e) => {
e.target.style.opacity = '1'
setDraggedTask(null)
setDragOverCol(null)
}
const handleDragOver = (e, colStatus) => {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
setDragOverCol(colStatus)
}
const handleDragLeave = (e) => {
if (!e.currentTarget.contains(e.relatedTarget)) {
setDragOverCol(null)
}
}
const handleDrop = (e, colStatus) => {
e.preventDefault()
setDragOverCol(null)
if (draggedTask && draggedTask.status !== colStatus) {
const taskId = draggedTask._id || draggedTask.id
handleMove(taskId, colStatus)
}
setDraggedTask(null)
}
const todoTasks = filteredTasks.filter(t => t.status === 'todo')
const inProgressTasks = filteredTasks.filter(t => t.status === 'in_progress')
const doneTasks = filteredTasks.filter(t => t.status === 'done')
if (loading) {
return (
<div className="animate-pulse">
<div className="h-12 bg-surface-tertiary rounded-xl mb-4"></div>
<div className="grid grid-cols-3 gap-4">
{[...Array(3)].map((_, i) => <div key={i} className="h-64 bg-surface-tertiary rounded-xl"></div>)}
</div>
</div>
)
}
const columns = [
{ label: t('tasks.todo'), status: 'todo', items: todoTasks, color: 'bg-gray-400' },
{ label: t('tasks.in_progress'), status: 'in_progress', items: inProgressTasks, color: 'bg-blue-400' },
{ label: t('tasks.done'), status: 'done', items: doneTasks, color: 'bg-emerald-400' },
]
return (
<div className="space-y-4 animate-fade-in">
{/* Toolbar */}
<div className="flex items-center justify-between gap-4 flex-wrap">
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<Filter className="w-4 h-4 text-text-tertiary" />
<select
value={filterView}
onChange={e => setFilterView(e.target.value)}
className="px-3 py-1.5 text-sm border border-border rounded-lg bg-surface-primary text-text-primary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
<option value="all">{t('tasks.allTasks')}</option>
<option value="assigned_to_me">{t('tasks.assignedToMe')}</option>
<option value="created_by_me">{t('tasks.createdByMe')}</option>
{isSuperadmin && users.length > 0 && (
<optgroup label={t('tasks.byTeamMember')}>
{users.map(m => (
<option key={m.id} value={m.id}>{m.name}</option>
))}
</optgroup>
)}
</select>
</div>
<p className="text-sm text-text-secondary">
{filteredTasks.length} {filteredTasks.length !== 1 ? t('tasks.tasks') : t('tasks.task')}
{filterView !== 'all' && tasks.length !== filteredTasks.length && (
<span className="text-text-tertiary"> {t('tasks.of')} {tasks.length}</span>
)}
</p>
</div>
<button
onClick={() => { setEditingTask(null); setFormData({ title: '', description: '', priority: 'medium', 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" />
{t('tasks.newTask')}
</button>
</div>
{/* 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>
) : (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{columns.map(col => {
const isOver = dragOverCol === col.status && draggedTask?.status !== col.status
return (
<div key={col.status}>
<div className="flex items-center gap-2 mb-3">
<div className={`w-2.5 h-2.5 rounded-full ${col.color}`} />
<h4 className="text-sm font-semibold text-text-primary">{col.label}</h4>
<span className="text-xs font-medium text-text-tertiary bg-surface-tertiary px-2 py-0.5 rounded-full">
{col.items.length}
</span>
</div>
<div
className={`kanban-column rounded-xl p-2 space-y-2 min-h-[200px] border-2 transition-colors ${
isOver
? 'bg-brand-primary/5 border-brand-primary/40 border-dashed'
: 'bg-surface-secondary border-border-light border-solid'
}`}
onDragOver={(e) => handleDragOver(e, col.status)}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, col.status)}
>
{col.items.length === 0 ? (
<div className={`py-8 text-center text-xs ${isOver ? 'text-brand-primary font-medium' : 'text-text-tertiary'}`}>
{isOver ? t('posts.dropHere') : t('tasks.noTasks')}
</div>
) : (
col.items.map(task => {
const canEdit = canEditResource('task', task)
const canDelete = canDeleteResource('task', task)
return (
<div
key={task._id || task.id}
draggable={canEdit}
onDragStart={(e) => canEdit && handleDragStart(e, task)}
onDragEnd={handleDragEnd}
className={canEdit ? 'cursor-grab active:cursor-grabbing' : ''}
>
<div className="relative group">
<TaskCard task={task} onMove={canEdit ? handleMove : undefined} showProject />
{/* Edit/Delete overlay */}
{(canEdit || 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>
)}
</div>
)}
</div>
</div>
)
})
)}
</div>
</div>
)
})}
</div>
)}
{/* Create/Edit Task Modal */}
<Modal isOpen={showModal} onClose={() => { setShowModal(false); setEditingTask(null) }} title={editingTask ? t('tasks.editTask') : t('tasks.createTask')} size="md">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('tasks.taskTitle')} *</label>
<input
type="text"
value={formData.title}
onChange={e => setFormData(f => ({ ...f, title: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
placeholder={t('posts.whatNeedsDone')}
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('tasks.description')}</label>
<textarea
value={formData.description}
onChange={e => setFormData(f => ({ ...f, description: 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"
placeholder={t('posts.optionalDetails')}
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('tasks.assignTo')}</label>
<select
value={formData.assigned_to}
onChange={e => setFormData(f => ({ ...f, assigned_to: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
<option value="">{t('common.unassigned')}</option>
{(teamMembers || []).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">{t('tasks.priority')}</label>
<select
value={formData.priority}
onChange={e => setFormData(f => ({ ...f, priority: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
<option value="low">{t('tasks.priority.low')}</option>
<option value="medium">{t('tasks.priority.medium')}</option>
<option value="high">{t('tasks.priority.high')}</option>
<option value="urgent">{t('tasks.priority.urgent')}</option>
</select>
</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>
</div>
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
<button
onClick={() => { setShowModal(false); setEditingTask(null) }}
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
>
{t('common.cancel')}
</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"
>
{editingTask ? t('tasks.saveChanges') : t('tasks.createTask')}
</button>
</div>
</div>
</Modal>
{/* Delete Confirmation */}
<Modal
isOpen={showDeleteConfirm}
onClose={() => { setShowDeleteConfirm(false); setTaskToDelete(null) }}
title={t('tasks.deleteTask')}
isConfirm
danger
confirmText={t('tasks.deleteTask')}
onConfirm={confirmDelete}
>
{t('tasks.deleteConfirm')}
</Modal>
</div>
)
}

448
client/src/pages/Team.jsx Normal file
View File

@@ -0,0 +1,448 @@
import { useState, useEffect, useContext } from 'react'
import { Plus, Users, ArrowLeft, User as UserIcon } from 'lucide-react'
import { AppContext } from '../App'
import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../i18n/LanguageContext'
import { api } from '../utils/api'
import MemberCard from '../components/MemberCard'
import StatusBadge from '../components/StatusBadge'
import BrandBadge from '../components/BrandBadge'
import Modal from '../components/Modal'
const EMPTY_MEMBER = {
name: '', email: '', password: '', role: 'content_writer', brands: '', phone: '',
}
const ROLES = [
{ value: 'manager', label: 'Manager' },
{ value: 'approver', label: 'Approver' },
{ value: 'publisher', label: 'Publisher' },
{ value: 'content_creator', label: 'Content Creator' },
{ value: 'producer', label: 'Producer' },
{ value: 'designer', label: 'Designer' },
{ value: 'content_writer', label: 'Content Writer' },
{ value: 'social_media_manager', label: 'Social Media Manager' },
{ value: 'photographer', label: 'Photographer' },
{ value: 'videographer', label: 'Videographer' },
{ value: 'strategist', label: 'Strategist' },
]
export default function Team() {
const { t } = useLanguage()
const { teamMembers, loadTeam, currentUser } = useContext(AppContext)
const { user } = useAuth()
const [showModal, setShowModal] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [editingMember, setEditingMember] = useState(null)
const [isEditingSelf, setIsEditingSelf] = useState(false)
const [formData, setFormData] = useState(EMPTY_MEMBER)
const [selectedMember, setSelectedMember] = useState(null)
const [memberTasks, setMemberTasks] = useState([])
const [memberPosts, setMemberPosts] = useState([])
const [loadingDetail, setLoadingDetail] = useState(false)
const canManageTeam = user?.role === 'superadmin' || user?.role === 'manager'
const openNew = () => {
setEditingMember(null)
setIsEditingSelf(false)
setFormData(EMPTY_MEMBER)
setShowModal(true)
}
const openEdit = (member) => {
const isSelf = member._id === user?.id || member.id === user?.id
setEditingMember(member)
setIsEditingSelf(isSelf)
setFormData({
name: member.name || '',
email: member.email || '',
password: '',
role: member.team_role || member.role || 'content_writer',
brands: Array.isArray(member.brands) ? member.brands.join(', ') : (member.brands || ''),
phone: member.phone || '',
})
setShowModal(true)
}
const handleSave = async () => {
try {
const brands = typeof formData.brands === 'string'
? formData.brands.split(',').map(b => b.trim()).filter(Boolean)
: formData.brands
// If editing self, use self-service endpoint
if (isEditingSelf) {
const data = {
name: formData.name,
team_role: formData.role,
brands,
phone: formData.phone,
}
await api.patch('/users/me/profile', data)
} else {
// Manager/superadmin creating or editing other users
const data = {
name: formData.name,
email: formData.email,
team_role: formData.role,
brands,
phone: formData.phone,
}
if (formData.password) {
data.password = formData.password
}
if (editingMember) {
await api.patch(`/users/team/${editingMember._id}`, data)
} else {
await api.post('/users/team', data)
}
}
setShowModal(false)
setEditingMember(null)
setIsEditingSelf(false)
setFormData(EMPTY_MEMBER)
loadTeam()
} catch (err) {
console.error('Save failed:', err)
alert(err.message || 'Failed to save')
}
}
const openMemberDetail = async (member) => {
setSelectedMember(member)
setLoadingDetail(true)
try {
const [tasksRes, postsRes] = await Promise.allSettled([
api.get(`/tasks?assignedTo=${member._id}`),
api.get(`/posts?assignedTo=${member._id}`),
])
setMemberTasks(tasksRes.status === 'fulfilled' ? (tasksRes.value.data || tasksRes.value || []) : [])
setMemberPosts(postsRes.status === 'fulfilled' ? (postsRes.value.data || postsRes.value || []) : [])
} catch {
setMemberTasks([])
setMemberPosts([])
} finally {
setLoadingDetail(false)
}
}
// Member detail view
if (selectedMember) {
const todoCount = memberTasks.filter(t => t.status === 'todo').length
const inProgressCount = memberTasks.filter(t => t.status === 'in_progress').length
const doneCount = memberTasks.filter(t => t.status === 'done').length
return (
<div className="space-y-6 animate-fade-in">
<button
onClick={() => setSelectedMember(null)}
className="flex items-center gap-2 text-sm text-text-secondary hover:text-text-primary transition-colors"
>
<ArrowLeft className="w-4 h-4" />
{t('team.backToTeam')}
</button>
{/* Member profile */}
<div className="bg-white rounded-xl border border-border p-6">
<div className="flex items-start gap-4">
<div className={`w-16 h-16 rounded-full bg-gradient-to-br from-indigo-400 to-purple-500 flex items-center justify-center text-white text-xl font-bold`}>
{selectedMember.name?.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase()}
</div>
<div className="flex-1">
<h2 className="text-xl font-bold text-text-primary">{selectedMember.name}</h2>
<p className="text-sm text-text-secondary capitalize">{(selectedMember.team_role || selectedMember.role)?.replace('_', ' ')}</p>
{selectedMember.email && (
<p className="text-sm text-text-tertiary mt-1">{selectedMember.email}</p>
)}
{selectedMember.brands && selectedMember.brands.length > 0 && (
<div className="flex flex-wrap gap-2 mt-3">
{selectedMember.brands.map(b => <BrandBadge key={b} brand={b} />)}
</div>
)}
</div>
<button
onClick={() => openEdit(selectedMember)}
className="px-3 py-1.5 text-sm font-medium text-brand-primary hover:bg-brand-primary/10 rounded-lg"
>
{t('common.edit')}
</button>
</div>
</div>
{/* Workload stats */}
<div className="grid grid-cols-4 gap-4">
<div className="bg-white rounded-xl border border-border p-4 text-center">
<p className="text-2xl font-bold text-text-primary">{memberTasks.length}</p>
<p className="text-xs text-text-tertiary">{t('team.totalTasks')}</p>
</div>
<div className="bg-white rounded-xl border border-border p-4 text-center">
<p className="text-2xl font-bold text-amber-500">{todoCount}</p>
<p className="text-xs text-text-tertiary">{t('team.toDo')}</p>
</div>
<div className="bg-white rounded-xl border border-border p-4 text-center">
<p className="text-2xl font-bold text-blue-500">{inProgressCount}</p>
<p className="text-xs text-text-tertiary">{t('team.inProgress')}</p>
</div>
<div className="bg-white rounded-xl border border-border p-4 text-center">
<p className="text-2xl font-bold text-emerald-500">{doneCount}</p>
<p className="text-xs text-text-tertiary">{t('tasks.done')}</p>
</div>
</div>
{/* Tasks & Posts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Tasks */}
<div className="bg-white rounded-xl border border-border">
<div className="px-5 py-4 border-b border-border">
<h3 className="font-semibold text-text-primary">{t('tasks.title')} ({memberTasks.length})</h3>
</div>
<div className="divide-y divide-border-light max-h-[400px] overflow-y-auto">
{loadingDetail ? (
<div className="py-8 text-center text-sm text-text-tertiary">{t('common.loading')}</div>
) : memberTasks.length === 0 ? (
<div className="py-8 text-center text-sm text-text-tertiary">{t('team.noTasks')}</div>
) : (
memberTasks.map(task => (
<div key={task._id} className="flex items-center gap-3 px-5 py-3">
<div className="flex-1 min-w-0">
<p className={`text-sm font-medium ${task.status === 'done' ? 'text-text-tertiary line-through' : 'text-text-primary'}`}>
{task.title}
</p>
</div>
<StatusBadge status={task.status} size="xs" />
</div>
))
)}
</div>
</div>
{/* Posts */}
<div className="bg-white rounded-xl border border-border">
<div className="px-5 py-4 border-b border-border">
<h3 className="font-semibold text-text-primary">{t('nav.posts')} ({memberPosts.length})</h3>
</div>
<div className="divide-y divide-border-light max-h-[400px] overflow-y-auto">
{loadingDetail ? (
<div className="py-8 text-center text-sm text-text-tertiary">{t('common.loading')}</div>
) : memberPosts.length === 0 ? (
<div className="py-8 text-center text-sm text-text-tertiary">{t('posts.noPosts')}</div>
) : (
memberPosts.map(post => (
<div key={post._id} className="flex items-center gap-3 px-5 py-3">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{post.title}</p>
{post.brand && <BrandBadge brand={post.brand} />}
</div>
<StatusBadge status={post.status} size="xs" />
</div>
))
)}
</div>
</div>
</div>
</div>
)
}
// Team grid
return (
<div className="space-y-4 animate-fade-in">
{/* Toolbar */}
<div className="flex items-center justify-between">
<p className="text-sm text-text-secondary">
{teamMembers.length} {teamMembers.length !== 1 ? t('team.membersPlural') : t('team.member')}
</p>
<div className="flex gap-2">
{/* Edit own profile button */}
<button
onClick={() => {
const self = teamMembers.find(m => m._id === user?.id || m.id === user?.id)
if (self) openEdit(self)
}}
className="flex items-center gap-2 px-4 py-2 bg-white border border-border text-text-primary rounded-lg text-sm font-medium hover:bg-surface-secondary transition-colors"
>
<UserIcon className="w-4 h-4" />
{t('team.myProfile')}
</button>
{/* Add member button (managers and superadmins only) */}
{canManageTeam && (
<button
onClick={openNew}
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" />
{t('team.addMember')}
</button>
)}
</div>
</div>
{/* Member grid */}
{teamMembers.length === 0 ? (
<div className="py-20 text-center">
<Users className="w-12 h-12 text-text-tertiary mx-auto mb-3" />
<p className="text-text-secondary font-medium">{t('team.noMembers')}</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 stagger-children">
{teamMembers.map(member => (
<MemberCard key={member._id} member={member} onClick={openMemberDetail} />
))}
</div>
)}
{/* Create/Edit Modal */}
<Modal
isOpen={showModal}
onClose={() => { setShowModal(false); setEditingMember(null); setIsEditingSelf(false) }}
title={isEditingSelf ? t('team.editProfile') : (editingMember ? t('team.editMember') : t('team.newMember'))}
size="md"
>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('team.name')} *</label>
<input
type="text"
value={formData.name}
onChange={e => setFormData(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"
placeholder={t('team.fullName')}
/>
</div>
{!isEditingSelf && (
<>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('team.email')} *</label>
<input
type="email"
value={formData.email}
onChange={e => setFormData(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="email@example.com"
disabled={editingMember}
/>
</div>
{!editingMember && (
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('team.password')} {editingMember && t('team.optional')}</label>
<input
type="password"
value={formData.password}
onChange={e => setFormData(f => ({ ...f, password: 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="••••••••"
/>
{!formData.password && !editingMember && (
<p className="text-xs text-text-tertiary mt-1">{t('team.defaultPassword')}</p>
)}
</div>
)}
</>
)}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('team.teamRole')}</label>
{user?.role === 'manager' && !editingMember && !isEditingSelf ? (
<>
<input
type="text"
value="Contributor"
disabled
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface-tertiary text-text-tertiary cursor-not-allowed"
/>
<p className="text-xs text-text-tertiary mt-1">{t('team.fixedRole')}</p>
</>
) : (
<select
value={formData.role}
onChange={e => setFormData(f => ({ ...f, role: 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"
>
{ROLES.map(r => <option key={r.value} value={r.value}>{r.label}</option>)}
</select>
)}
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('team.phone')}</label>
<input
type="text"
value={formData.phone}
onChange={e => setFormData(f => ({ ...f, phone: 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="+966 ..."
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('team.brands')}</label>
<input
type="text"
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"
/>
<p className="text-xs text-text-tertiary mt-1">{t('team.brandsHelp')}</p>
</div>
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
{editingMember && !isEditingSelf && canManageTeam && (
<button
onClick={() => setShowDeleteConfirm(true)}
className="px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50 rounded-lg mr-auto"
>
{t('team.remove')}
</button>
)}
<button
onClick={() => { setShowModal(false); setEditingMember(null); setIsEditingSelf(false) }}
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
>
{t('common.cancel')}
</button>
<button
onClick={handleSave}
disabled={!formData.name || (!isEditingSelf && !editingMember && !formData.email)}
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"
>
{isEditingSelf ? t('team.saveProfile') : (editingMember ? t('team.saveChanges') : t('team.addMember'))}
</button>
</div>
</div>
</Modal>
{/* Delete Confirmation */}
<Modal
isOpen={showDeleteConfirm}
onClose={() => setShowDeleteConfirm(false)}
title={t('team.removeMember')}
isConfirm
danger
confirmText={t('team.remove')}
onConfirm={async () => {
if (editingMember) {
await api.delete(`/users/team/${editingMember._id}`)
setShowModal(false)
setEditingMember(null)
setIsEditingSelf(false)
setShowDeleteConfirm(false)
if (selectedMember?._id === editingMember._id) {
setSelectedMember(null)
}
loadTeam()
}
}}
>
{t('team.removeConfirm', { name: editingMember?.name })}
</Modal>
</div>
)
}

315
client/src/pages/Users.jsx Normal file
View File

@@ -0,0 +1,315 @@
import { useState, useEffect } from 'react'
import { Plus, Shield, Edit2, Trash2, UserCheck } from 'lucide-react'
import { api } from '../utils/api'
import Modal from '../components/Modal'
import { useAuth } from '../contexts/AuthContext'
const ROLES = [
{ value: 'superadmin', label: 'Superadmin', color: 'bg-purple-100 text-purple-700', icon: '👑' },
{ value: 'manager', label: 'Manager', color: 'bg-blue-100 text-blue-700', icon: '📊' },
{ value: 'contributor', label: 'Contributor', color: 'bg-green-100 text-green-700', icon: '✏️' },
]
const EMPTY_FORM = {
name: '', email: '', password: '', role: 'contributor', avatar: '',
}
function RoleBadge({ role }) {
const roleInfo = ROLES.find(r => r.value === role) || ROLES[2]
return (
<span className={`inline-flex items-center gap-1 text-xs font-medium px-2.5 py-1 rounded-full ${roleInfo.color}`}>
<span>{roleInfo.icon}</span>
{roleInfo.label}
</span>
)
}
export default function Users() {
const { user: currentUser } = useAuth()
const [users, setUsers] = useState([])
const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false)
const [editingUser, setEditingUser] = useState(null)
const [form, setForm] = useState(EMPTY_FORM)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [userToDelete, setUserToDelete] = useState(null)
useEffect(() => { loadUsers() }, [])
const loadUsers = async () => {
try {
const res = await api.get('/users')
setUsers(res)
} catch (err) {
console.error('Failed to load users:', err)
} finally {
setLoading(false)
}
}
const handleSave = async () => {
try {
const data = {
name: form.name,
email: form.email,
role: form.role,
avatar: form.avatar || null,
}
if (form.password) data.password = form.password
if (editingUser) {
await api.patch(`/users/${editingUser.id}`, data)
} else {
if (!form.password) {
alert('Password is required for new users')
return
}
data.password = form.password
await api.post('/users', data)
}
setShowModal(false)
setEditingUser(null)
setForm(EMPTY_FORM)
loadUsers()
} catch (err) {
console.error('Save failed:', err)
alert('Failed to save user: ' + err.message)
}
}
const openEdit = (user) => {
setEditingUser(user)
setForm({
name: user.name || '',
email: user.email || '',
password: '',
role: user.role || 'contributor',
avatar: user.avatar || '',
})
setShowModal(true)
}
const openNew = () => {
setEditingUser(null)
setForm(EMPTY_FORM)
setShowModal(true)
}
const confirmDelete = async () => {
if (!userToDelete) return
try {
await api.delete(`/users/${userToDelete.id}`)
loadUsers()
setUserToDelete(null)
} catch (err) {
console.error('Delete failed:', err)
alert('Failed to delete user')
}
}
if (loading) {
return (
<div className="space-y-4 animate-pulse">
<div className="h-12 bg-surface-tertiary rounded-xl"></div>
<div className="h-64 bg-surface-tertiary rounded-xl"></div>
</div>
)
}
return (
<div className="space-y-6 animate-fade-in">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-text-primary flex items-center gap-3">
<Shield className="w-7 h-7 text-purple-600" />
User Management
</h1>
<p className="text-sm text-text-tertiary mt-1">{users.length} user{users.length !== 1 ? 's' : ''}</p>
</div>
<button
onClick={openNew}
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" />
Add User
</button>
</div>
{/* Users List */}
<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-5 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">User</th>
<th className="text-left px-5 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Email</th>
<th className="text-left px-5 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Role</th>
<th className="text-left px-5 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Created</th>
<th className="text-right px-5 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider w-24">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-border-light">
{users.length === 0 ? (
<tr>
<td colSpan={5} className="py-12 text-center text-sm text-text-tertiary">
No users found
</td>
</tr>
) : (
users.map(user => {
const isCurrentUser = currentUser?.id === user.id
const roleInfo = ROLES.find(r => r.value === user.role) || ROLES[2]
return (
<tr key={user.id} className="hover:bg-surface-secondary group">
<td className="px-5 py-4">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-full bg-gradient-to-br ${user.role === 'superadmin' ? 'from-purple-500 to-pink-500' : 'from-blue-500 to-indigo-500'} flex items-center justify-center text-white font-bold text-sm`}>
{user.name?.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase()}
</div>
<div>
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-text-primary">{user.name}</p>
{isCurrentUser && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-100 text-emerald-700 font-medium">
You
</span>
)}
</div>
</div>
</div>
</td>
<td className="px-5 py-4 text-sm text-text-secondary">{user.email}</td>
<td className="px-5 py-4">
<RoleBadge role={user.role} />
</td>
<td className="px-5 py-4 text-sm text-text-tertiary">
{user.createdAt ? new Date(user.createdAt).toLocaleDateString() : '—'}
</td>
<td className="px-5 py-4">
<div className="flex items-center justify-end gap-1">
<button
onClick={() => openEdit(user)}
className="p-1.5 hover:bg-surface-tertiary rounded-lg text-text-tertiary hover:text-text-primary"
title="Edit user"
>
<Edit2 className="w-4 h-4" />
</button>
{!isCurrentUser && (
<button
onClick={() => { setUserToDelete(user); setShowDeleteConfirm(true) }}
className="p-1.5 hover:bg-red-50 rounded-lg text-text-tertiary hover:text-red-500"
title="Delete user"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
</td>
</tr>
)
})
)}
</tbody>
</table>
</div>
{/* Add/Edit User Modal */}
<Modal
isOpen={showModal}
onClose={() => { setShowModal(false); setEditingUser(null) }}
title={editingUser ? 'Edit User' : 'Add New User'}
size="md"
>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Name *</label>
<input
type="text"
value={form.name}
onChange={e => setForm(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"
placeholder="Full name"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Email *</label>
<input
type="email"
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"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">
Password {editingUser && '(leave blank to keep current)'}
</label>
<input
type="password"
value={form.password}
onChange={e => setForm(f => ({ ...f, password: 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="••••••••"
required={!editingUser}
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Role *</label>
<div className="grid grid-cols-3 gap-2">
{ROLES.map(r => (
<button
key={r.value}
type="button"
onClick={() => setForm(f => ({ ...f, role: r.value }))}
className={`p-3 rounded-lg border-2 text-center transition-all ${
form.role === r.value
? 'border-brand-primary bg-brand-primary/5'
: 'border-border hover:border-brand-primary/30'
}`}
>
<div className="text-2xl mb-1">{r.icon}</div>
<div className="text-xs font-medium text-text-primary">{r.label}</div>
</button>
))}
</div>
</div>
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
<button
onClick={() => { setShowModal(false); setEditingUser(null) }}
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
>
Cancel
</button>
<button
onClick={handleSave}
disabled={!form.name || !form.email || (!editingUser && !form.password)}
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"
>
{editingUser ? 'Save Changes' : 'Add User'}
</button>
</div>
</div>
</Modal>
{/* Delete Confirmation */}
<Modal
isOpen={showDeleteConfirm}
onClose={() => { setShowDeleteConfirm(false); setUserToDelete(null) }}
title="Delete User?"
isConfirm
danger
confirmText="Delete User"
onConfirm={confirmDelete}
>
Are you sure you want to delete <strong>{userToDelete?.name}</strong>? This action cannot be undone.
</Modal>
</div>
)
}