feat: comprehensive UI overhaul + budget allocation redesign

Audit & Quality:
- RTL: replaced 121 LTR-only utilities (text-left, pl-, left-) with logical properties
- A11y: focus traps + ARIA on Modal/SlidePanel/TabbedModal, clickable divs→buttons
- Theming: bg-white→bg-surface (170 instances), text-gray→semantic tokens
- Performance: useMemo on filters, loading="lazy" on 24 images
- CSS: prefers-reduced-motion, removed dead animations

Component Splits:
- PostDetailPanel: 1332→623 lines + 4 sub-components
- ArtefactDetailPanel: 972→590 lines + 1 sub-component

Brand Identity — Rawaj (رواج):
- New name, DM Sans font, deep teal palette (#0d9488)
- Custom SVG logo, forest-tinted dark mode
- All emails branded with app name in subject line

Design Refinement:
- Dashboard: merged posts+deadlines into tabbed ActivityFeed, inline stats
- Quieter: removed card lift, brand glow, gradient text, mesh backgrounds
- CampaignDetail: prominent budget card, compact team avatars, Lucide icons
- Consistent page titles via Header.jsx, standardized section headers
- Finance page fully i18n'd (20+ hardcoded strings replaced)

Budget Allocation Redesign:
- Single source of truth: BudgetEntries (Campaign.budget deprecated)
- Validation at all levels: main→campaign→track, expenses blocked if insufficient
- Budget request workflow with CEO approval via public link
- BudgetRequests table, CRUD routes, public approval page
- Budget mutex for race condition prevention
- Idempotent migration for existing campaign budgets

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
fahed
2026-03-15 15:36:19 +03:00
parent 3c857856c5
commit e1d1c392eb
77 changed files with 4351 additions and 2108 deletions
+1 -1
View File
@@ -7,7 +7,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=IBM+Plex+Sans+Arabic:wght@300;400;500;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=IBM+Plex+Sans+Arabic:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<title>Digital Hub</title> <title>Rawaj</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+3 -1
View File
@@ -37,6 +37,7 @@ const PublicIssueSubmit = lazy(() => import('./pages/PublicIssueSubmit'))
const PublicIssueTracker = lazy(() => import('./pages/PublicIssueTracker')) const PublicIssueTracker = lazy(() => import('./pages/PublicIssueTracker'))
const Translations = lazy(() => import('./pages/Translations')) const Translations = lazy(() => import('./pages/Translations'))
const PublicTranslationReview = lazy(() => import('./pages/PublicTranslationReview')) const PublicTranslationReview = lazy(() => import('./pages/PublicTranslationReview'))
const PublicBudgetApproval = lazy(() => import('./pages/PublicBudgetApproval'))
const ForgotPassword = lazy(() => import('./pages/ForgotPassword')) const ForgotPassword = lazy(() => import('./pages/ForgotPassword'))
const ResetPassword = lazy(() => import('./pages/ResetPassword')) const ResetPassword = lazy(() => import('./pages/ResetPassword'))
@@ -161,7 +162,7 @@ function AppContent() {
<AppContext.Provider value={{ currentUser: user, teamMembers, brands, loadTeam, getBrandName, teams, loadTeams, roles, loadRoles }}> <AppContext.Provider value={{ currentUser: user, teamMembers, brands, loadTeam, getBrandName, teams, loadTeams, roles, loadRoles }}>
{/* Profile completion prompt */} {/* Profile completion prompt */}
{showProfilePrompt && ( {showProfilePrompt && (
<div className="fixed top-4 right-4 z-50 bg-amber-50 border-2 border-amber-400 rounded-xl shadow-lg p-4 max-w-md animate-fade-in"> <div className="fixed top-4 end-4 z-50 bg-amber-50 border-2 border-amber-400 rounded-xl shadow-lg p-4 max-w-md animate-fade-in">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-full bg-amber-400 flex items-center justify-center text-white shrink-0"> <div className="w-10 h-10 rounded-full bg-amber-400 flex items-center justify-center text-white shrink-0">
@@ -298,6 +299,7 @@ function AppContent() {
<Route path="/submit-issue" element={<PublicIssueSubmit />} /> <Route path="/submit-issue" element={<PublicIssueSubmit />} />
<Route path="/track/:token" element={<PublicIssueTracker />} /> <Route path="/track/:token" element={<PublicIssueTracker />} />
<Route path="/review-translation/:token" element={<PublicTranslationReview />} /> <Route path="/review-translation/:token" element={<PublicTranslationReview />} />
<Route path="/approve-budget/:token" element={<PublicBudgetApproval />} />
<Route path="/" element={user ? <Layout /> : <Navigate to="/login" replace />}> <Route path="/" element={user ? <Layout /> : <Navigate to="/login" replace />}>
<Route index element={<Dashboard />} /> <Route index element={<Dashboard />} />
{hasModule('marketing') && <> {hasModule('marketing') && <>
@@ -64,7 +64,7 @@ export default function ApproverMultiSelect({ users = [], selected = [], onChang
</button> </button>
</span> </span>
))} ))}
<ChevronDown className={`w-4 h-4 text-text-tertiary ml-auto shrink-0 transition-transform ${open ? 'rotate-180' : ''}`} /> <ChevronDown className={`w-4 h-4 text-text-tertiary ms-auto shrink-0 transition-transform ${open ? 'rotate-180' : ''}`} />
</div> </div>
{open && ( {open && (
<div className={`absolute z-50 w-full bg-surface border border-border rounded-lg shadow-lg max-h-48 overflow-y-auto ${dropUp ? 'bottom-full mb-1' : 'top-full mt-1'}`}> <div className={`absolute z-50 w-full bg-surface border border-border rounded-lg shadow-lg max-h-48 overflow-y-auto ${dropUp ? 'bottom-full mb-1' : 'top-full mt-1'}`}>
@@ -76,7 +76,7 @@ export default function ApproverMultiSelect({ users = [], selected = [], onChang
key={uid} key={uid}
type="button" type="button"
onClick={() => toggle(uid)} onClick={() => toggle(uid)}
className={`w-full text-left px-3 py-2 text-sm hover:bg-surface-secondary flex items-center justify-between ${ className={`w-full text-start px-3 py-2 text-sm hover:bg-surface-secondary flex items-center justify-between ${
isSelected ? 'text-brand-primary font-medium' : 'text-text-primary' isSelected ? 'text-brand-primary font-medium' : 'text-text-primary'
}`} }`}
> >
+34 -416
View File
@@ -1,13 +1,13 @@
import { useState, useEffect, useContext } from 'react' import { useState, useEffect, useContext } from 'react'
import { Plus, Copy, Check, ExternalLink, Upload, Globe, Trash2, FileText, Image as ImageIcon, Film, Sparkles, MessageSquare, Save, FileEdit, Layers, ShieldCheck } from 'lucide-react' import { Copy, Check, ExternalLink, Trash2, FileText, Image as ImageIcon, Film, Sparkles, MessageSquare, Save, FileEdit, Layers, ShieldCheck } from 'lucide-react'
import { AppContext } from '../App' import { AppContext } from '../App'
import { useLanguage } from '../i18n/LanguageContext' import { useLanguage } from '../i18n/LanguageContext'
import { api } from '../utils/api' import { api } from '../utils/api'
import Modal from './Modal' import Modal from './Modal'
import TabbedModal from './TabbedModal' import TabbedModal from './TabbedModal'
import { useToast } from './ToastContainer' import { useToast } from './ToastContainer'
import ArtefactVersionTimeline from './ArtefactVersionTimeline'
import ApproverMultiSelect from './ApproverMultiSelect' import ApproverMultiSelect from './ApproverMultiSelect'
import { ArtefactDetailVersionsTab } from './ArtefactDetailVersionsTab'
const STATUS_COLORS = { const STATUS_COLORS = {
draft: 'bg-surface-tertiary text-text-secondary', draft: 'bg-surface-tertiary text-text-secondary',
@@ -17,13 +17,6 @@ const STATUS_COLORS = {
revision_requested: 'bg-orange-100 text-orange-700', revision_requested: 'bg-orange-100 text-orange-700',
} }
const AVAILABLE_LANGUAGES = [
{ code: 'AR', label: '\u0627\u0644\u0639\u0631\u0628\u064A\u0629' },
{ code: 'EN', label: 'English' },
{ code: 'FR', label: 'Fran\u00E7ais' },
{ code: 'ID', label: 'Bahasa Indonesia' },
]
const TYPE_ICONS = { const TYPE_ICONS = {
copy: FileText, copy: FileText,
design: ImageIcon, design: ImageIcon,
@@ -55,27 +48,10 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
const reviewUrl = freshReviewUrl || (artefact.approval_token ? `${window.location.origin}/review/${artefact.approval_token}` : '') const reviewUrl = freshReviewUrl || (artefact.approval_token ? `${window.location.origin}/review/${artefact.approval_token}` : '')
const [savingDraft, setSavingDraft] = useState(false) const [savingDraft, setSavingDraft] = useState(false)
const [deleting, setDeleting] = useState(false) const [deleting, setDeleting] = useState(false)
const [confirmDeleteLangId, setConfirmDeleteLangId] = useState(null)
const [confirmDeleteAttId, setConfirmDeleteAttId] = useState(null)
const [showDeleteArtefactConfirm, setShowDeleteArtefactConfirm] = useState(false) const [showDeleteArtefactConfirm, setShowDeleteArtefactConfirm] = useState(false)
// Language management (for copy type)
const [showLanguageModal, setShowLanguageModal] = useState(false)
const [languageForm, setLanguageForm] = useState({ language_code: '', language_label: '', content: '' })
const [savingLanguage, setSavingLanguage] = useState(false)
// New version modal
const [showNewVersionModal, setShowNewVersionModal] = useState(false)
const [newVersionNotes, setNewVersionNotes] = useState('')
const [copyFromPrevious, setCopyFromPrevious] = useState(false)
const [creatingVersion, setCreatingVersion] = useState(false)
// File upload (for design/video) // File upload (for design/video)
const [uploading, setUploading] = useState(false) const [uploading, setUploading] = useState(false)
// Video inline (Drive link input)
const [driveUrl, setDriveUrl] = useState('')
const [dragOver, setDragOver] = useState(false)
const [uploadProgress, setUploadProgress] = useState(0) const [uploadProgress, setUploadProgress] = useState(0)
// Comments // Comments
@@ -137,57 +113,23 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
loadVersionData(version.Id) loadVersionData(version.Id)
} }
const handleCreateVersion = async () => { const handleCreateVersion = async ({ notes, copy_from_previous }) => {
setCreatingVersion(true) await api.post(`/artefacts/${artefact.Id}/versions`, { notes, copy_from_previous })
try { toast.success(t('artefacts.versionCreated'))
await api.post(`/artefacts/${artefact.Id}/versions`, { loadVersions()
notes: newVersionNotes || `Version ${(versions[versions.length - 1]?.version_number || 0) + 1}`, onUpdate()
copy_from_previous: artefact.type === 'copy' ? copyFromPrevious : false,
})
toast.success(t('artefacts.versionCreated'))
setShowNewVersionModal(false)
setNewVersionNotes('')
setCopyFromPrevious(false)
loadVersions()
onUpdate()
} catch (err) {
console.error('Create version failed:', err)
toast.error(t('artefacts.failedCreateVersion'))
} finally {
setCreatingVersion(false)
}
} }
const handleAddLanguage = async () => { const handleAddLanguage = async (languageForm) => {
if (!languageForm.language_code || !languageForm.language_label || !languageForm.content) { await api.post(`/artefacts/${artefact.Id}/versions/${selectedVersion.Id}/texts`, languageForm)
toast.error(t('artefacts.allFieldsRequired')) toast.success(t('artefacts.languageAdded'))
return loadVersionData(selectedVersion.Id)
}
setSavingLanguage(true)
try {
await api.post(`/artefacts/${artefact.Id}/versions/${selectedVersion.Id}/texts`, languageForm)
toast.success(t('artefacts.languageAdded'))
setShowLanguageModal(false)
setLanguageForm({ language_code: '', language_label: '', content: '' })
loadVersionData(selectedVersion.Id)
} catch (err) {
console.error('Add language failed:', err)
toast.error(t('artefacts.failedAddLanguage'))
} finally {
setSavingLanguage(false)
}
} }
const handleDeleteLanguage = async (textId) => { const handleDeleteLanguage = async (textId) => {
try { await api.delete(`/artefact-version-texts/${textId}`)
await api.delete(`/artefact-version-texts/${textId}`) toast.success(t('artefacts.languageDeleted'))
toast.success(t('artefacts.languageDeleted')) loadVersionData(selectedVersion.Id)
loadVersionData(selectedVersion.Id)
} catch (err) {
toast.error(t('artefacts.failedDeleteLanguage'))
}
} }
const handleFileUpload = async (fileOrEvent) => { const handleFileUpload = async (fileOrEvent) => {
@@ -215,16 +157,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
} }
} }
const handleVideoDrop = (e) => { const handleAddDriveVideo = async (driveUrl) => {
e.preventDefault()
setDragOver(false)
const file = e.dataTransfer.files?.[0]
if (file && file.type.startsWith('video/')) {
handleFileUpload(file)
}
}
const handleAddDriveVideo = async () => {
if (!driveUrl.trim()) { if (!driveUrl.trim()) {
toast.error(t('artefacts.enterDriveUrl')) toast.error(t('artefacts.enterDriveUrl'))
return return
@@ -236,7 +169,6 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
drive_url: driveUrl, drive_url: driveUrl,
}) })
toast.success(t('artefacts.videoLinkAdded')) toast.success(t('artefacts.videoLinkAdded'))
setDriveUrl('')
loadVersionData(selectedVersion.Id) loadVersionData(selectedVersion.Id)
} catch (err) { } catch (err) {
console.error('Add Drive link failed:', err) console.error('Add Drive link failed:', err)
@@ -247,13 +179,9 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
} }
const handleDeleteAttachment = async (attId) => { const handleDeleteAttachment = async (attId) => {
try { await api.delete(`/artefact-attachments/${attId}`)
await api.delete(`/artefact-attachments/${attId}`) toast.success(t('artefacts.attachmentDeleted'))
toast.success(t('artefacts.attachmentDeleted')) loadVersionData(selectedVersion.Id)
loadVersionData(selectedVersion.Id)
} catch (err) {
toast.error(t('artefacts.failedDeleteAttachment'))
}
} }
const handleSubmitReview = async () => { const handleSubmitReview = async () => {
@@ -501,213 +429,22 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
{/* Versions Tab */} {/* Versions Tab */}
{activeTab === 'versions' && ( {activeTab === 'versions' && (
<div className="p-6 space-y-5"> <ArtefactDetailVersionsTab
{/* Version Timeline */} artefact={artefact}
<div> versions={versions}
<div className="flex items-center justify-between mb-3"> selectedVersion={selectedVersion}
<h4 className="text-xs font-semibold text-text-tertiary uppercase">{t('artefacts.versions')}</h4> versionData={versionData}
<button uploading={uploading}
onClick={() => setShowNewVersionModal(true)} uploadProgress={uploadProgress}
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors" onSelectVersion={handleSelectVersion}
> onCreateVersion={handleCreateVersion}
<Plus className="w-3 h-3" /> onAddLanguage={handleAddLanguage}
{t('artefacts.newVersion')} onDeleteLanguage={handleDeleteLanguage}
</button> onFileUpload={handleFileUpload}
</div> onDeleteAttachment={handleDeleteAttachment}
<ArtefactVersionTimeline onAddDriveVideo={handleAddDriveVideo}
versions={versions} getDriveEmbedUrl={getDriveEmbedUrl}
activeVersionId={selectedVersion?.Id} />
onSelectVersion={handleSelectVersion}
artefactType={artefact.type}
/>
</div>
{/* Type-specific content */}
{versionData && selectedVersion && (
<div className="border-t border-border pt-5">
{/* COPY TYPE: Language entries */}
{artefact.type === 'copy' && (
<div>
<div className="flex items-center justify-between mb-3">
<h4 className="text-xs font-semibold text-text-tertiary uppercase">{t('artefacts.languages')}</h4>
<button
onClick={() => setShowLanguageModal(true)}
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors"
>
<Plus className="w-3 h-3" />
{t('artefacts.addLanguage')}
</button>
</div>
{versionData.texts && versionData.texts.length > 0 ? (
<div className="space-y-3">
{versionData.texts.map(text => (
<div key={text.Id} className="bg-surface-secondary rounded-lg p-4 border border-border">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
<span className="px-2 py-0.5 bg-surface border border-border rounded text-xs font-mono font-medium">
{text.language_code}
</span>
<span className="text-sm font-medium text-text-primary">{text.language_label}</span>
</div>
<button
onClick={() => setConfirmDeleteLangId(text.Id)}
className="text-red-600 hover:text-red-700"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
<div className="bg-surface rounded border border-border p-3 text-sm text-text-primary whitespace-pre-wrap font-sans">
{text.content}
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8 bg-surface-secondary rounded-lg border-2 border-dashed border-border">
<Globe className="w-8 h-8 text-text-tertiary mx-auto mb-2" />
<p className="text-sm text-text-secondary">{t('artefacts.noLanguages')}</p>
</div>
)}
</div>
)}
{/* DESIGN TYPE: Image gallery */}
{artefact.type === 'design' && (
<div>
<div className="flex items-center justify-between mb-3">
<h4 className="text-xs font-semibold text-text-tertiary uppercase">{t('artefacts.imagesLabel')}</h4>
<label className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors cursor-pointer">
<Upload className="w-3 h-3" />
{uploading ? t('artefacts.uploading') : t('artefacts.uploadImage')}
<input
type="file"
className="hidden"
accept="image/*"
onChange={handleFileUpload}
disabled={uploading}
/>
</label>
</div>
{versionData.attachments && versionData.attachments.length > 0 ? (
<div className="grid grid-cols-2 gap-3">
{versionData.attachments.map(att => (
<div key={att.Id} className="relative group">
<img
src={att.url}
alt={att.original_name}
className="w-full h-48 object-cover rounded-lg border border-border"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors rounded-lg flex items-center justify-center">
<button
onClick={() => setConfirmDeleteAttId(att.Id)}
className="opacity-0 group-hover:opacity-100 transition-opacity px-3 py-2 bg-red-600 text-white rounded-lg text-xs font-medium hover:bg-red-700"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
<div className="mt-1 px-2 py-1 bg-surface-secondary rounded text-xs text-text-secondary truncate">
{att.original_name}
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8 bg-surface-secondary rounded-lg border-2 border-dashed border-border">
<ImageIcon className="w-8 h-8 text-text-tertiary mx-auto mb-2" />
<p className="text-sm text-text-secondary">{t('artefacts.noImages')}</p>
</div>
)}
</div>
)}
{/* VIDEO TYPE: Files and Drive links — all inline */}
{artefact.type === 'video' && (
<div>
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-3">{t('artefacts.videosLabel')}</h4>
{/* Existing attachments */}
{versionData.attachments && versionData.attachments.length > 0 && (
<div className="space-y-3 mb-4">
{versionData.attachments.map(att => (
<div key={att.Id} className="bg-surface-secondary rounded-lg p-4 border border-border">
{att.drive_url ? (
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-text-primary">{t('artefacts.googleDriveVideo')}</span>
<button onClick={() => setConfirmDeleteAttId(att.Id)} className="text-red-600 hover:text-red-700">
<Trash2 className="w-4 h-4" />
</button>
</div>
<iframe src={getDriveEmbedUrl(att.drive_url)} className="w-full h-64 rounded border border-border" allow="autoplay" />
</div>
) : (
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-text-primary truncate">{att.original_name}</span>
<button onClick={() => setConfirmDeleteAttId(att.Id)} className="text-red-600 hover:text-red-700">
<Trash2 className="w-4 h-4" />
</button>
</div>
<video src={att.url} controls className="w-full rounded border border-border" />
</div>
)}
</div>
))}
</div>
)}
{/* Drag-and-drop / click-to-upload zone */}
<label
className={`flex flex-col items-center gap-2 px-6 py-6 border-2 border-dashed rounded-lg cursor-pointer transition-colors ${
dragOver ? 'border-brand-primary bg-brand-primary/5' : 'border-border hover:border-brand-primary/30'
} ${uploading ? 'pointer-events-none opacity-60' : ''}`}
onDragOver={(e) => { e.preventDefault(); setDragOver(true) }}
onDragLeave={() => setDragOver(false)}
onDrop={handleVideoDrop}
>
{uploading ? (
<>
<div className="w-full max-w-xs bg-surface-tertiary rounded-full h-2 overflow-hidden">
<div className="bg-brand-primary h-full rounded-full transition-all duration-300" style={{ width: `${uploadProgress}%` }} />
</div>
<span className="text-sm text-text-secondary">{t('artefacts.uploading')} {uploadProgress}%</span>
</>
) : (
<>
<Upload className="w-7 h-7 text-text-tertiary" />
<span className="text-sm font-medium text-text-primary">{t('artefacts.dropOrClickVideo')}</span>
<span className="text-xs text-text-tertiary">{t('artefacts.videoFormats')}</span>
</>
)}
<input type="file" className="hidden" accept="video/*" onChange={handleFileUpload} disabled={uploading} />
</label>
{/* Google Drive URL inline input */}
<div className="flex items-center gap-2 mt-3">
<Globe className="w-4 h-4 text-text-tertiary shrink-0" />
<input
type="text"
value={driveUrl}
onChange={e => setDriveUrl(e.target.value)}
placeholder="https://drive.google.com/file/d/..."
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
onKeyDown={e => { if (e.key === 'Enter' && driveUrl.trim()) handleAddDriveVideo() }}
/>
<button
onClick={handleAddDriveVideo}
disabled={uploading || !driveUrl.trim()}
className="px-3 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 shrink-0"
>
{t('artefacts.addLink')}
</button>
</div>
</div>
)}
</div>
)}
</div>
)} )}
{/* Discussion Tab */} {/* Discussion Tab */}
@@ -836,125 +573,6 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
)} )}
</TabbedModal> </TabbedModal>
{/* Language Modal */}
<Modal isOpen={showLanguageModal} onClose={() => setShowLanguageModal(false)} title={t('artefacts.addLanguage')} size="md">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('artefacts.languageLabel')} *</label>
<select
value={languageForm.language_code}
onChange={e => {
const lang = AVAILABLE_LANGUAGES.find(l => l.code === e.target.value)
if (lang) setLanguageForm(f => ({ ...f, language_code: lang.code, language_label: lang.label }))
else setLanguageForm(f => ({ ...f, language_code: '', language_label: '' }))
}}
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
>
<option value="">{t('artefacts.selectLanguage')}</option>
{AVAILABLE_LANGUAGES
.filter(lang => !(versionData?.texts || []).some(t => t.language_code === lang.code))
.map(lang => (
<option key={lang.code} value={lang.code}>{lang.label} ({lang.code})</option>
))
}
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('artefacts.contentLabel')} *</label>
<textarea
value={languageForm.content}
onChange={e => setLanguageForm(f => ({ ...f, content: e.target.value }))}
rows={8}
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 font-sans"
placeholder={t('artefacts.enterContent')}
/>
</div>
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
<button
onClick={() => setShowLanguageModal(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={handleAddLanguage}
disabled={savingLanguage}
className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 shadow-sm"
>
{savingLanguage ? t('header.saving') : t('common.save')}
</button>
</div>
</div>
</Modal>
{/* New Version Modal */}
<Modal isOpen={showNewVersionModal} onClose={() => setShowNewVersionModal(false)} title={t('artefacts.createNewVersion')} size="sm">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('artefacts.versionNotes')}</label>
<textarea
value={newVersionNotes}
onChange={e => setNewVersionNotes(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"
placeholder={t('artefacts.whatChanged')}
/>
</div>
{artefact.type === 'copy' && versions.length > 0 && (
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={copyFromPrevious}
onChange={e => setCopyFromPrevious(e.target.checked)}
className="w-4 h-4 text-brand-primary border-border rounded focus:ring-brand-primary"
/>
<span className="text-sm text-text-secondary">{t('artefacts.copyLanguages')}</span>
</label>
)}
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
<button
onClick={() => setShowNewVersionModal(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={handleCreateVersion}
disabled={creatingVersion}
className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 shadow-sm"
>
{creatingVersion ? t('artefacts.creatingVersion') : t('artefacts.createVersion')}
</button>
</div>
</div>
</Modal>
{/* Delete Language Confirmation */}
<Modal
isOpen={!!confirmDeleteLangId}
onClose={() => setConfirmDeleteLangId(null)}
title={t('artefacts.deleteLanguage')}
isConfirm
danger
onConfirm={() => handleDeleteLanguage(confirmDeleteLangId)}
confirmText={t('common.delete')}
>
{t('artefacts.deleteLanguageDesc')}
</Modal>
{/* Delete Attachment Confirmation */}
<Modal
isOpen={!!confirmDeleteAttId}
onClose={() => setConfirmDeleteAttId(null)}
title={t('artefacts.deleteAttachment')}
isConfirm
danger
onConfirm={() => handleDeleteAttachment(confirmDeleteAttId)}
confirmText={t('common.delete')}
>
{t('artefacts.deleteAttachmentDesc')}
</Modal>
{/* Delete Artefact Confirmation */} {/* Delete Artefact Confirmation */}
<Modal <Modal
isOpen={showDeleteArtefactConfirm} isOpen={showDeleteArtefactConfirm}
@@ -0,0 +1,429 @@
import { useState } from 'react'
import { Plus, Upload, Trash2, Globe, Image as ImageIcon } from 'lucide-react'
import { useLanguage } from '../i18n/LanguageContext'
import Modal from './Modal'
import ArtefactVersionTimeline from './ArtefactVersionTimeline'
const AVAILABLE_LANGUAGES = [
{ code: 'AR', label: '\u0627\u0644\u0639\u0631\u0628\u064A\u0629' },
{ code: 'EN', label: 'English' },
{ code: 'FR', label: 'Fran\u00E7ais' },
{ code: 'ID', label: 'Bahasa Indonesia' },
]
export function ArtefactDetailVersionsTab({
artefact,
versions,
selectedVersion,
versionData,
uploading,
uploadProgress,
onSelectVersion,
onCreateVersion,
onAddLanguage,
onDeleteLanguage,
onFileUpload,
onDeleteAttachment,
onAddDriveVideo,
getDriveEmbedUrl,
}) {
const { t } = useLanguage()
const [showNewVersionModal, setShowNewVersionModal] = useState(false)
const [newVersionNotes, setNewVersionNotes] = useState('')
const [copyFromPrevious, setCopyFromPrevious] = useState(false)
const [creatingVersion, setCreatingVersion] = useState(false)
const [showLanguageModal, setShowLanguageModal] = useState(false)
const [languageForm, setLanguageForm] = useState({ language_code: '', language_label: '', content: '' })
const [savingLanguage, setSavingLanguage] = useState(false)
const [confirmDeleteLangId, setConfirmDeleteLangId] = useState(null)
const [confirmDeleteAttId, setConfirmDeleteAttId] = useState(null)
const [dragOver, setDragOver] = useState(false)
const [driveUrl, setDriveUrl] = useState('')
const handleCreateVersion = async () => {
setCreatingVersion(true)
try {
await onCreateVersion({
notes: newVersionNotes || `Version ${(versions[versions.length - 1]?.version_number || 0) + 1}`,
copy_from_previous: artefact.type === 'copy' ? copyFromPrevious : false,
})
setShowNewVersionModal(false)
setNewVersionNotes('')
setCopyFromPrevious(false)
} finally {
setCreatingVersion(false)
}
}
const handleAddLanguage = async () => {
if (!languageForm.language_code || !languageForm.language_label || !languageForm.content) return
setSavingLanguage(true)
try {
await onAddLanguage(languageForm)
setShowLanguageModal(false)
setLanguageForm({ language_code: '', language_label: '', content: '' })
} finally {
setSavingLanguage(false)
}
}
const handleDeleteLanguage = async (textId) => {
await onDeleteLanguage(textId)
setConfirmDeleteLangId(null)
}
const handleDeleteAttachment = async (attId) => {
await onDeleteAttachment(attId)
setConfirmDeleteAttId(null)
}
const handleVideoDrop = (e) => {
e.preventDefault()
setDragOver(false)
const file = e.dataTransfer.files?.[0]
if (file && file.type.startsWith('video/')) {
onFileUpload(file)
}
}
const handleAddDriveVideo = async () => {
if (!driveUrl.trim()) return
await onAddDriveVideo(driveUrl)
setDriveUrl('')
}
return (
<>
<div className="p-6 space-y-5">
{/* Version Timeline */}
<div>
<div className="flex items-center justify-between mb-3">
<h4 className="text-xs font-semibold text-text-tertiary uppercase">{t('artefacts.versions')}</h4>
<button
onClick={() => setShowNewVersionModal(true)}
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors"
>
<Plus className="w-3 h-3" />
{t('artefacts.newVersion')}
</button>
</div>
<ArtefactVersionTimeline
versions={versions}
activeVersionId={selectedVersion?.Id}
onSelectVersion={onSelectVersion}
artefactType={artefact.type}
/>
</div>
{/* Type-specific content */}
{versionData && selectedVersion && (
<div className="border-t border-border pt-5">
{/* COPY TYPE: Language entries */}
{artefact.type === 'copy' && (
<div>
<div className="flex items-center justify-between mb-3">
<h4 className="text-xs font-semibold text-text-tertiary uppercase">{t('artefacts.languages')}</h4>
<button
onClick={() => setShowLanguageModal(true)}
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors"
>
<Plus className="w-3 h-3" />
{t('artefacts.addLanguage')}
</button>
</div>
{versionData.texts && versionData.texts.length > 0 ? (
<div className="space-y-3">
{versionData.texts.map(text => (
<div key={text.Id} className="bg-surface-secondary rounded-lg p-4 border border-border">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
<span className="px-2 py-0.5 bg-surface border border-border rounded text-xs font-mono font-medium">
{text.language_code}
</span>
<span className="text-sm font-medium text-text-primary">{text.language_label}</span>
</div>
<button
onClick={() => setConfirmDeleteLangId(text.Id)}
className="text-red-600 hover:text-red-700"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
<div className="bg-surface rounded border border-border p-3 text-sm text-text-primary whitespace-pre-wrap font-sans">
{text.content}
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8 bg-surface-secondary rounded-lg border-2 border-dashed border-border">
<Globe className="w-8 h-8 text-text-tertiary mx-auto mb-2" />
<p className="text-sm text-text-secondary">{t('artefacts.noLanguages')}</p>
</div>
)}
</div>
)}
{/* DESIGN TYPE: Image gallery */}
{artefact.type === 'design' && (
<div>
<div className="flex items-center justify-between mb-3">
<h4 className="text-xs font-semibold text-text-tertiary uppercase">{t('artefacts.imagesLabel')}</h4>
<label className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors cursor-pointer">
<Upload className="w-3 h-3" />
{uploading ? t('artefacts.uploading') : t('artefacts.uploadImage')}
<input
type="file"
className="hidden"
accept="image/*"
onChange={onFileUpload}
disabled={uploading}
/>
</label>
</div>
{versionData.attachments && versionData.attachments.length > 0 ? (
<div className="grid grid-cols-2 gap-3">
{versionData.attachments.map(att => (
<div key={att.Id} className="relative group">
<img
src={att.url}
alt={att.original_name}
className="w-full h-48 object-cover rounded-lg border border-border"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors rounded-lg flex items-center justify-center">
<button
onClick={() => setConfirmDeleteAttId(att.Id)}
className="opacity-0 group-hover:opacity-100 transition-opacity px-3 py-2 bg-red-600 text-white rounded-lg text-xs font-medium hover:bg-red-700"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
<div className="mt-1 px-2 py-1 bg-surface-secondary rounded text-xs text-text-secondary truncate">
{att.original_name}
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8 bg-surface-secondary rounded-lg border-2 border-dashed border-border">
<ImageIcon className="w-8 h-8 text-text-tertiary mx-auto mb-2" />
<p className="text-sm text-text-secondary">{t('artefacts.noImages')}</p>
</div>
)}
</div>
)}
{/* VIDEO TYPE: Files and Drive links -- all inline */}
{artefact.type === 'video' && (
<div>
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-3">{t('artefacts.videosLabel')}</h4>
{/* Existing attachments */}
{versionData.attachments && versionData.attachments.length > 0 && (
<div className="space-y-3 mb-4">
{versionData.attachments.map(att => (
<div key={att.Id} className="bg-surface-secondary rounded-lg p-4 border border-border">
{att.drive_url ? (
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-text-primary">{t('artefacts.googleDriveVideo')}</span>
<button onClick={() => setConfirmDeleteAttId(att.Id)} className="text-red-600 hover:text-red-700">
<Trash2 className="w-4 h-4" />
</button>
</div>
<iframe src={getDriveEmbedUrl(att.drive_url)} className="w-full h-64 rounded border border-border" allow="autoplay" />
</div>
) : (
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-text-primary truncate">{att.original_name}</span>
<button onClick={() => setConfirmDeleteAttId(att.Id)} className="text-red-600 hover:text-red-700">
<Trash2 className="w-4 h-4" />
</button>
</div>
<video src={att.url} controls className="w-full rounded border border-border" />
</div>
)}
</div>
))}
</div>
)}
{/* Drag-and-drop / click-to-upload zone */}
<label
className={`flex flex-col items-center gap-2 px-6 py-6 border-2 border-dashed rounded-lg cursor-pointer transition-colors ${
dragOver ? 'border-brand-primary bg-brand-primary/5' : 'border-border hover:border-brand-primary/30'
} ${uploading ? 'pointer-events-none opacity-60' : ''}`}
onDragOver={(e) => { e.preventDefault(); setDragOver(true) }}
onDragLeave={() => setDragOver(false)}
onDrop={handleVideoDrop}
>
{uploading ? (
<>
<div className="w-full max-w-xs bg-surface-tertiary rounded-full h-2 overflow-hidden">
<div className="bg-brand-primary h-full rounded-full transition-all duration-300" style={{ width: `${uploadProgress}%` }} />
</div>
<span className="text-sm text-text-secondary">{t('artefacts.uploading')} {uploadProgress}%</span>
</>
) : (
<>
<Upload className="w-7 h-7 text-text-tertiary" />
<span className="text-sm font-medium text-text-primary">{t('artefacts.dropOrClickVideo')}</span>
<span className="text-xs text-text-tertiary">{t('artefacts.videoFormats')}</span>
</>
)}
<input type="file" className="hidden" accept="video/*" onChange={onFileUpload} disabled={uploading} />
</label>
{/* Google Drive URL inline input */}
<div className="flex items-center gap-2 mt-3">
<Globe className="w-4 h-4 text-text-tertiary shrink-0" />
<input
type="text"
value={driveUrl}
onChange={e => setDriveUrl(e.target.value)}
placeholder="https://drive.google.com/file/d/..."
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
onKeyDown={e => { if (e.key === 'Enter' && driveUrl.trim()) handleAddDriveVideo() }}
/>
<button
onClick={handleAddDriveVideo}
disabled={uploading || !driveUrl.trim()}
className="px-3 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 shrink-0"
>
{t('artefacts.addLink')}
</button>
</div>
</div>
)}
</div>
)}
</div>
{/* Language Modal */}
<Modal isOpen={showLanguageModal} onClose={() => setShowLanguageModal(false)} title={t('artefacts.addLanguage')} size="md">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('artefacts.languageLabel')} *</label>
<select
value={languageForm.language_code}
onChange={e => {
const lang = AVAILABLE_LANGUAGES.find(l => l.code === e.target.value)
if (lang) setLanguageForm(f => ({ ...f, language_code: lang.code, language_label: lang.label }))
else setLanguageForm(f => ({ ...f, language_code: '', language_label: '' }))
}}
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
>
<option value="">{t('artefacts.selectLanguage')}</option>
{AVAILABLE_LANGUAGES
.filter(lang => !(versionData?.texts || []).some(t => t.language_code === lang.code))
.map(lang => (
<option key={lang.code} value={lang.code}>{lang.label} ({lang.code})</option>
))
}
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('artefacts.contentLabel')} *</label>
<textarea
value={languageForm.content}
onChange={e => setLanguageForm(f => ({ ...f, content: e.target.value }))}
rows={8}
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 font-sans"
placeholder={t('artefacts.enterContent')}
/>
</div>
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
<button
onClick={() => setShowLanguageModal(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={handleAddLanguage}
disabled={savingLanguage}
className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 shadow-sm"
>
{savingLanguage ? t('header.saving') : t('common.save')}
</button>
</div>
</div>
</Modal>
{/* New Version Modal */}
<Modal isOpen={showNewVersionModal} onClose={() => setShowNewVersionModal(false)} title={t('artefacts.createNewVersion')} size="sm">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('artefacts.versionNotes')}</label>
<textarea
value={newVersionNotes}
onChange={e => setNewVersionNotes(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"
placeholder={t('artefacts.whatChanged')}
/>
</div>
{artefact.type === 'copy' && versions.length > 0 && (
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={copyFromPrevious}
onChange={e => setCopyFromPrevious(e.target.checked)}
className="w-4 h-4 text-brand-primary border-border rounded focus:ring-brand-primary"
/>
<span className="text-sm text-text-secondary">{t('artefacts.copyLanguages')}</span>
</label>
)}
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
<button
onClick={() => setShowNewVersionModal(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={handleCreateVersion}
disabled={creatingVersion}
className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 shadow-sm"
>
{creatingVersion ? t('artefacts.creatingVersion') : t('artefacts.createVersion')}
</button>
</div>
</div>
</Modal>
{/* Delete Language Confirmation */}
<Modal
isOpen={!!confirmDeleteLangId}
onClose={() => setConfirmDeleteLangId(null)}
title={t('artefacts.deleteLanguage')}
isConfirm
danger
onConfirm={() => handleDeleteLanguage(confirmDeleteLangId)}
confirmText={t('common.delete')}
>
{t('artefacts.deleteLanguageDesc')}
</Modal>
{/* Delete Attachment Confirmation */}
<Modal
isOpen={!!confirmDeleteAttId}
onClose={() => setConfirmDeleteAttId(null)}
title={t('artefacts.deleteAttachment')}
isConfirm
danger
onConfirm={() => handleDeleteAttachment(confirmDeleteAttId)}
confirmText={t('common.delete')}
>
{t('artefacts.deleteAttachmentDesc')}
</Modal>
</>
)
}
@@ -85,6 +85,7 @@ export default function ArtefactVersionTimeline({ versions, activeVersionId, onS
src={version.thumbnail} src={version.thumbnail}
alt={`Version ${version.version_number}`} alt={`Version ${version.version_number}`}
className="w-full h-20 object-cover rounded border border-border" className="w-full h-20 object-cover rounded border border-border"
loading="lazy"
/> />
</div> </div>
)} )}
+2 -1
View File
@@ -24,7 +24,7 @@ export default function AssetCard({ asset, onClick }) {
return ( return (
<div <div
onClick={() => onClick?.(asset)} onClick={() => onClick?.(asset)}
className="bg-white rounded-xl border border-border overflow-hidden card-hover cursor-pointer group" className="bg-surface rounded-xl border border-border overflow-hidden card-hover cursor-pointer group"
> >
{/* Thumbnail */} {/* Thumbnail */}
<div className="aspect-square bg-surface-tertiary flex items-center justify-center overflow-hidden relative"> <div className="aspect-square bg-surface-tertiary flex items-center justify-center overflow-hidden relative">
@@ -33,6 +33,7 @@ export default function AssetCard({ asset, onClick }) {
src={asset.url} src={asset.url}
alt={asset.name} alt={asset.name}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
loading="lazy"
onError={(e) => { onError={(e) => {
e.target.style.display = 'none' e.target.style.display = 'none'
e.target.nextSibling.style.display = 'flex' e.target.nextSibling.style.display = 'flex'
+3 -3
View File
@@ -41,7 +41,7 @@ export default function CampaignCalendar({ campaigns = [] }) {
} }
return ( return (
<div className="bg-white rounded-xl border border-border overflow-hidden"> <div className="bg-surface rounded-xl border border-border overflow-hidden">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border"> <div className="flex items-center justify-between px-6 py-4 border-b border-border">
<h3 className="text-lg font-semibold text-text-primary"> <h3 className="text-lg font-semibold text-text-primary">
@@ -109,8 +109,8 @@ export default function CampaignCalendar({ campaigns = [] }) {
<div <div
key={campaign._id || ci} key={campaign._id || ci}
className={`h-5 text-[10px] font-medium text-white flex items-center px-1 truncate ${CAMPAIGN_COLORS[colorIndex]} ${ className={`h-5 text-[10px] font-medium text-white flex items-center px-1 truncate ${CAMPAIGN_COLORS[colorIndex]} ${
isStart ? 'rounded-l-full ml-0' : '-ml-1' isStart ? 'rounded-l-full ms-0' : '-ms-1'
} ${isEnd ? 'rounded-r-full mr-0' : '-mr-1'}`} } ${isEnd ? 'rounded-r-full me-0' : '-me-1'}`}
title={campaign.name} title={campaign.name}
> >
{isStart ? campaign.name : ''} {isStart ? campaign.name : ''}
@@ -130,7 +130,7 @@ export default function CampaignDetailPanel({ campaign, onClose, onSave, onDelet
form.status === 'paused' ? 'bg-amber-100 text-amber-700' : form.status === 'paused' ? 'bg-amber-100 text-amber-700' :
form.status === 'completed' ? 'bg-blue-100 text-blue-700' : form.status === 'completed' ? 'bg-blue-100 text-blue-700' :
form.status === 'cancelled' ? 'bg-red-100 text-red-700' : form.status === 'cancelled' ? 'bg-red-100 text-red-700' :
'bg-gray-100 text-gray-600' 'bg-gray-100 text-text-secondary'
}`}> }`}>
{statusOptions.find(s => s.value === form.status)?.label} {statusOptions.find(s => s.value === form.status)?.label}
</span> </span>
@@ -226,7 +226,7 @@ export default function CampaignDetailPanel({ campaign, onClose, onSave, onDelet
{/* Platforms */} {/* Platforms */}
<div> <div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.platforms')}</label> <label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.platforms')}</label>
<div className="flex flex-wrap gap-2 p-2 border border-border rounded-lg bg-white min-h-[38px]"> <div className="flex flex-wrap gap-2 p-2 border border-border rounded-lg bg-surface min-h-[38px]">
{Object.entries(PLATFORMS).map(([k, v]) => { {Object.entries(PLATFORMS).map(([k, v]) => {
const checked = (form.platforms || []).includes(k) const checked = (form.platforms || []).includes(k)
return ( return (
@@ -281,7 +281,7 @@ export default function CampaignDetailPanel({ campaign, onClose, onSave, onDelet
<div> <div>
<label className="block text-xs font-medium text-text-tertiary mb-1"> <label className="block text-xs font-medium text-text-tertiary mb-1">
{t('campaigns.budget')} ({currencySymbol}) {t('campaigns.budget')} ({currencySymbol})
{!permissions?.canSetBudget && <span className="text-[10px] text-text-tertiary ml-1">(Superadmin only)</span>} {!permissions?.canSetBudget && <span className="text-[10px] text-text-tertiary ms-1">(Superadmin only)</span>}
</label> </label>
<input <input
type="number" type="number"
+2 -2
View File
@@ -116,7 +116,7 @@ export default function CommentsSection({ entityType, entityId }) {
<div key={c.id} className="flex items-start gap-2 group"> <div key={c.id} className="flex items-start gap-2 group">
<div className="w-7 h-7 rounded-full bg-brand-primary/10 text-brand-primary flex items-center justify-center text-[10px] font-bold shrink-0 mt-0.5"> <div className="w-7 h-7 rounded-full bg-brand-primary/10 text-brand-primary flex items-center justify-center text-[10px] font-bold shrink-0 mt-0.5">
{c.user_avatar ? ( {c.user_avatar ? (
<img src={c.user_avatar} className="w-full h-full rounded-full object-cover" alt="" /> <img src={c.user_avatar} className="w-full h-full rounded-full object-cover" alt="" loading="lazy" />
) : ( ) : (
getInitials(c.user_name) getInitials(c.user_name)
)} )}
@@ -125,7 +125,7 @@ export default function CommentsSection({ entityType, entityId }) {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs font-medium text-text-primary">{c.user_name}</span> <span className="text-xs font-medium text-text-primary">{c.user_name}</span>
<span className="text-[10px] text-text-tertiary">{relativeTime(c.created_at, t)}</span> <span className="text-[10px] text-text-tertiary">{relativeTime(c.created_at, t)}</span>
<div className="flex items-center gap-0.5 ml-auto opacity-0 group-hover:opacity-100 transition-opacity"> <div className="flex items-center gap-0.5 ms-auto opacity-0 group-hover:opacity-100 transition-opacity">
{canEdit(c) && editingId !== c.id && ( {canEdit(c) && editingId !== c.id && (
<button <button
onClick={() => startEdit(c)} onClick={() => startEdit(c)}
+1 -1
View File
@@ -17,7 +17,7 @@ export default function DatePresetPicker({ onSelect, activePreset, onClear }) {
className={`px-2.5 py-1 text-[11px] font-medium rounded-full border transition-colors ${ className={`px-2.5 py-1 text-[11px] font-medium rounded-full border transition-colors ${
activePreset === preset.key activePreset === preset.key
? 'bg-brand-primary/10 border-brand-primary/30 text-brand-primary' ? 'bg-brand-primary/10 border-brand-primary/30 text-brand-primary'
: 'bg-white border-border text-text-tertiary hover:text-text-primary hover:border-border-dark' : 'bg-surface border-border text-text-tertiary hover:text-text-primary hover:border-border-dark'
}`} }`}
> >
{t(preset.labelKey)} {t(preset.labelKey)}
+3 -3
View File
@@ -21,7 +21,7 @@ export default function EmptyState({
{actionLabel && ( {actionLabel && (
<button <button
onClick={onAction} onClick={onAction}
className="mt-3 text-sm text-brand-primary hover:text-brand-primary-light font-medium" className="mt-3 text-sm text-brand-primary hover:text-brand-primary-light font-medium transition-colors"
> >
{actionLabel} {actionLabel}
</button> </button>
@@ -44,7 +44,7 @@ export default function EmptyState({
{actionLabel && ( {actionLabel && (
<button <button
onClick={onAction} onClick={onAction}
className="px-5 py-2.5 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm transition-all hover:-translate-y-0.5" className="px-5 py-2.5 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light transition-colors"
> >
{actionLabel} {actionLabel}
</button> </button>
@@ -52,7 +52,7 @@ export default function EmptyState({
{secondaryActionLabel && ( {secondaryActionLabel && (
<button <button
onClick={onSecondaryAction} onClick={onSecondaryAction}
className="px-5 py-2.5 bg-white border border-border text-text-primary rounded-lg text-sm font-medium hover:bg-surface-secondary transition-colors" className="px-5 py-2.5 bg-surface border border-border text-text-primary rounded-lg text-sm font-medium hover:bg-surface-secondary transition-colors"
> >
{secondaryActionLabel} {secondaryActionLabel}
</button> </button>
+3 -3
View File
@@ -28,7 +28,7 @@ export default function FormInput({
? 'border-emerald-300 focus:border-emerald-500 focus:ring-emerald-500/20' ? 'border-emerald-300 focus:border-emerald-500 focus:ring-emerald-500/20'
: 'border-border focus:border-brand-primary focus:ring-brand-primary/20' : 'border-border focus:border-brand-primary focus:ring-brand-primary/20'
} }
${disabled ? 'bg-surface-tertiary cursor-not-allowed opacity-60' : 'bg-white'} ${disabled ? 'bg-surface-tertiary cursor-not-allowed opacity-60' : 'bg-surface'}
${className} ${className}
`.trim() `.trim()
@@ -39,7 +39,7 @@ export default function FormInput({
{label && ( {label && (
<label className="block text-sm font-medium text-text-primary"> <label className="block text-sm font-medium text-text-primary">
{label} {label}
{required && <span className="text-red-500 ml-0.5">*</span>} {required && <span className="text-red-500 ms-0.5">*</span>}
</label> </label>
)} )}
@@ -57,7 +57,7 @@ export default function FormInput({
{/* Validation icon */} {/* Validation icon */}
{(hasError || hasSuccess) && ( {(hasError || hasSuccess) && (
<div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none"> <div className="absolute end-3 top-1/2 -translate-y-1/2 pointer-events-none">
{hasError ? ( {hasError ? (
<AlertCircle className="w-4 h-4 text-red-500" /> <AlertCircle className="w-4 h-4 text-red-500" />
) : ( ) : (
+10 -6
View File
@@ -22,6 +22,7 @@ const PAGE_TITLE_KEYS = {
'/issues': 'header.issues', '/issues': 'header.issues',
'/team': 'header.team', '/team': 'header.team',
'/settings': 'header.settings', '/settings': 'header.settings',
'/translations': 'header.translations',
} }
const ROLE_INFO = { const ROLE_INFO = {
@@ -99,7 +100,7 @@ export default function Header() {
return ( return (
<> <>
<header className="h-16 bg-white border-b border-border flex items-center justify-between px-6 shrink-0 sticky top-0 z-20"> <header className="h-16 bg-surface border-b border-border flex items-center justify-between px-6 shrink-0 sticky top-0 z-20">
{/* Page title */} {/* Page title */}
<div> <div>
<h2 className="text-xl font-bold text-text-primary">{pageTitle}</h2> <h2 className="text-xl font-bold text-text-primary">{pageTitle}</h2>
@@ -118,8 +119,8 @@ export default function Header() {
> >
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-semibold ${ <div className={`w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-semibold ${
user?.role === 'superadmin' user?.role === 'superadmin'
? 'bg-gradient-to-br from-purple-500 to-pink-500' ? 'bg-brand-primary'
: 'bg-gradient-to-br from-blue-500 to-indigo-500' : 'bg-teal-700'
}`}> }`}>
{getInitials(user?.name)} {getInitials(user?.name)}
</div> </div>
@@ -135,7 +136,7 @@ export default function Header() {
</button> </button>
{showDropdown && ( {showDropdown && (
<div className="absolute end-0 top-full mt-2 w-64 bg-white rounded-xl shadow-lg border border-border overflow-hidden animate-scale-in"> <div className="absolute end-0 top-full mt-2 w-64 bg-surface rounded-xl shadow-lg border border-border overflow-hidden animate-scale-in" role="menu">
{/* User info */} {/* User info */}
<div className="px-4 py-3 border-b border-border-light bg-surface-secondary"> <div className="px-4 py-3 border-b border-border-light bg-surface-secondary">
<p className="text-sm font-semibold text-text-primary">{user?.name}</p> <p className="text-sm font-semibold text-text-primary">{user?.name}</p>
@@ -174,7 +175,7 @@ export default function Header() {
setShowDropdown(false) setShowDropdown(false)
logout() logout()
}} }}
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-red-50 transition-colors text-left group" className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-red-50 transition-colors text-start group"
> >
<LogOut className="w-4 h-4 text-text-tertiary group-hover:text-red-500" /> <LogOut className="w-4 h-4 text-text-tertiary group-hover:text-red-500" />
<span className="text-sm text-text-primary group-hover:text-red-500">{t('header.signOut')}</span> <span className="text-sm text-text-primary group-hover:text-red-500">{t('header.signOut')}</span>
@@ -197,6 +198,7 @@ export default function Header() {
onChange={e => { setPasswordForm(f => ({ ...f, currentPassword: e.target.value })); setPasswordError('') }} onChange={e => { setPasswordForm(f => ({ ...f, currentPassword: e.target.value })); setPasswordError('') }}
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" 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="••••••••" placeholder="••••••••"
aria-describedby={passwordError ? 'password-error' : undefined}
/> />
</div> </div>
<div> <div>
@@ -208,6 +210,7 @@ export default function Header() {
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" 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="••••••••" placeholder="••••••••"
minLength={6} minLength={6}
aria-describedby={passwordError ? 'password-error' : undefined}
/> />
</div> </div>
<div> <div>
@@ -219,11 +222,12 @@ export default function Header() {
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" 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="••••••••" placeholder="••••••••"
minLength={6} minLength={6}
aria-describedby={passwordError ? 'password-error' : undefined}
/> />
</div> </div>
{passwordError && ( {passwordError && (
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg"> <div id="password-error" className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg" role="alert">
<AlertCircle className="w-4 h-4 text-red-500 shrink-0" /> <AlertCircle className="w-4 h-4 text-red-500 shrink-0" />
<p className="text-sm text-red-500">{passwordError}</p> <p className="text-sm text-red-500">{passwordError}</p>
</div> </div>
+12 -12
View File
@@ -237,7 +237,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
if (items.length === 0) { if (items.length === 0) {
return ( return (
<div className="bg-white rounded-xl border border-border py-16 text-center"> <div className="bg-surface rounded-xl border border-border py-16 text-center">
<Calendar className="w-12 h-12 text-text-tertiary mx-auto mb-3" /> <Calendar className="w-12 h-12 text-text-tertiary mx-auto mb-3" />
<p className="text-text-secondary font-medium">{t('timeline.noItems')}</p> <p className="text-text-secondary font-medium">{t('timeline.noItems')}</p>
<p className="text-sm text-text-tertiary mt-1">{t('timeline.addItems')}</p> <p className="text-sm text-text-tertiary mt-1">{t('timeline.addItems')}</p>
@@ -246,7 +246,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
} }
return ( return (
<div className="bg-white rounded-xl border border-border overflow-hidden"> <div className="bg-surface rounded-xl border border-border overflow-hidden">
{/* Toolbar */} {/* Toolbar */}
<div className="flex items-center justify-between px-4 py-2 border-b border-border bg-surface-secondary"> <div className="flex items-center justify-between px-4 py-2 border-b border-border bg-surface-secondary">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -287,8 +287,8 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
<div ref={containerRef} dir="ltr" className="overflow-x-auto relative" style={{ cursor: dragState ? 'grabbing' : undefined }}> <div ref={containerRef} dir="ltr" className="overflow-x-auto relative" style={{ cursor: dragState ? 'grabbing' : undefined }}>
<div style={{ minWidth: `${labelWidth + totalDays * pxPerDay}px` }}> <div style={{ minWidth: `${labelWidth + totalDays * pxPerDay}px` }}>
{/* Day header */} {/* Day header */}
<div className="flex sticky top-0 z-20 bg-white border-b border-border" style={{ height: headerHeight }}> <div className="flex sticky top-0 z-20 bg-surface border-b border-border" style={{ height: headerHeight }}>
<div className="shrink-0 border-e border-border bg-surface-secondary flex items-center px-4 sticky left-0 z-30" style={{ width: labelWidth }}> <div className="shrink-0 border-e border-border bg-surface-secondary flex items-center px-4 sticky start-0 z-30" style={{ width: labelWidth }}>
<span className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('timeline.item')}</span> <span className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('timeline.item')}</span>
</div> </div>
<div className="flex relative"> <div className="flex relative">
@@ -338,7 +338,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
> >
{/* Label column */} {/* Label column */}
<div <div
className={`shrink-0 border-e border-border flex ${isExpanded ? 'flex-col justify-center gap-1' : 'items-center gap-2'} px-3 overflow-hidden sticky left-0 z-10 bg-white group-hover/row:bg-surface-secondary/50`} className={`shrink-0 border-e border-border flex ${isExpanded ? 'flex-col justify-center gap-1' : 'items-center gap-2'} px-3 overflow-hidden sticky start-0 z-10 bg-surface group-hover/row:bg-surface-secondary/50`}
style={{ width: labelWidth }} style={{ width: labelWidth }}
> >
{isExpanded ? ( {isExpanded ? (
@@ -358,7 +358,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
)} )}
{item.thumbnailUrl ? ( {item.thumbnailUrl ? (
<div className="w-8 h-8 rounded overflow-hidden shrink-0"> <div className="w-8 h-8 rounded overflow-hidden shrink-0">
<img src={item.thumbnailUrl} alt="" className="w-full h-full object-cover" /> <img src={item.thumbnailUrl} alt="" className="w-full h-full object-cover" loading="lazy" />
</div> </div>
) : item.assigneeName ? ( ) : item.assigneeName ? (
<div className="w-6 h-6 rounded-full bg-brand-primary/10 text-brand-primary flex items-center justify-center text-[9px] font-bold shrink-0"> <div className="w-6 h-6 rounded-full bg-brand-primary/10 text-brand-primary flex items-center justify-center text-[9px] font-bold shrink-0">
@@ -394,7 +394,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
)} )}
{item.thumbnailUrl ? ( {item.thumbnailUrl ? (
<div className="w-6 h-6 rounded overflow-hidden shrink-0"> <div className="w-6 h-6 rounded overflow-hidden shrink-0">
<img src={item.thumbnailUrl} alt="" className="w-full h-full object-cover" /> <img src={item.thumbnailUrl} alt="" className="w-full h-full object-cover" loading="lazy" />
</div> </div>
) : item.assigneeName ? ( ) : item.assigneeName ? (
<div className="w-6 h-6 rounded-full bg-brand-primary/10 text-brand-primary flex items-center justify-center text-[9px] font-bold shrink-0"> <div className="w-6 h-6 rounded-full bg-brand-primary/10 text-brand-primary flex items-center justify-center text-[9px] font-bold shrink-0">
@@ -415,7 +415,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
style={{ left: `${todayOffset + pxPerDay / 2}px` }} style={{ left: `${todayOffset + pxPerDay / 2}px` }}
> >
{idx === 0 && ( {idx === 0 && (
<div className="absolute -top-0 left-1 text-[8px] font-bold text-red-500 bg-red-50 px-1 rounded whitespace-nowrap"> <div className="absolute -top-0 start-1 text-[8px] font-bold text-red-500 bg-red-50 px-1 rounded whitespace-nowrap">
{t('timeline.today')} {t('timeline.today')}
</div> </div>
)} )}
@@ -459,7 +459,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
{/* Left resize handle */} {/* Left resize handle */}
{!readOnly && onDateChange && ( {!readOnly && onDateChange && (
<div <div
className="absolute left-0 top-0 bottom-0 w-2 cursor-col-resize hover:bg-white/30 z-10" className="absolute start-0 top-0 bottom-0 w-2 cursor-col-resize hover:bg-white/30 z-10"
onMouseDown={(e) => handleMouseDown(e, item, 'resize-left')} onMouseDown={(e) => handleMouseDown(e, item, 'resize-left')}
/> />
)} )}
@@ -520,7 +520,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
{/* Right resize handle */} {/* Right resize handle */}
{!readOnly && onDateChange && ( {!readOnly && onDateChange && (
<div <div
className="absolute right-0 top-0 bottom-0 w-2 cursor-col-resize hover:bg-white/30 z-10" className="absolute end-0 top-0 bottom-0 w-2 cursor-col-resize hover:bg-white/30 z-10"
onMouseDown={(e) => handleMouseDown(e, item, 'resize-right')} onMouseDown={(e) => handleMouseDown(e, item, 'resize-right')}
/> />
)} )}
@@ -536,7 +536,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
{colorPicker && onColorChange && ( {colorPicker && onColorChange && (
<div <div
ref={colorPickerRef} ref={colorPickerRef}
className="fixed z-50 bg-white rounded-lg shadow-xl border border-border p-2" className="fixed z-50 bg-surface rounded-lg shadow-xl border border-border p-2"
style={{ left: colorPicker.x, top: colorPicker.y }} style={{ left: colorPicker.x, top: colorPicker.y }}
> >
<div className="grid grid-cols-4 gap-1.5 mb-2"> <div className="grid grid-cols-4 gap-1.5 mb-2">
@@ -591,7 +591,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
)} )}
</div> </div>
{!readOnly && onDateChange && ( {!readOnly && onDateChange && (
<div className="text-gray-400 mt-1 text-[10px] italic"> <div className="text-text-tertiary mt-1 text-[10px] italic">
{t('timeline.dragToMove')} · {t('timeline.dragToResize')} {t('timeline.dragToMove')} · {t('timeline.dragToResize')}
</div> </div>
)} )}
+2 -2
View File
@@ -10,12 +10,12 @@ export default function KanbanCard({ title, thumbnail, brandName, tags, assignee
return ( return (
<div <div
onClick={onClick} onClick={onClick}
className="bg-white rounded-lg p-3 border border-border hover:border-brand-primary/30 cursor-pointer transition-all group overflow-hidden" className="bg-surface rounded-lg p-3 border border-border hover:border-brand-primary/30 cursor-pointer transition-all group overflow-hidden"
> >
{/* Thumbnail */} {/* Thumbnail */}
{thumbnail && ( {thumbnail && (
<div className="w-[calc(100%+1.5rem)] h-32 -mx-3 -mt-3 mb-2 rounded-t-lg overflow-hidden"> <div className="w-[calc(100%+1.5rem)] h-32 -mx-3 -mt-3 mb-2 rounded-t-lg overflow-hidden">
<img src={thumbnail} alt="" className="w-full h-full object-cover" /> <img src={thumbnail} alt="" className="w-full h-full object-cover" loading="lazy" />
</div> </div>
)} )}
+2 -2
View File
@@ -15,7 +15,7 @@ const ROLE_BADGES = {
strategist: { bg: 'bg-rose-50', text: 'text-rose-700', label: 'Strategist' }, strategist: { bg: 'bg-rose-50', text: 'text-rose-700', label: 'Strategist' },
superadmin: { bg: 'bg-red-50', text: 'text-red-700', label: 'Super Admin' }, superadmin: { bg: 'bg-red-50', text: 'text-red-700', label: 'Super Admin' },
contributor: { bg: 'bg-slate-50', text: 'text-slate-700', label: 'Contributor' }, contributor: { bg: 'bg-slate-50', text: 'text-slate-700', label: 'Contributor' },
default: { bg: 'bg-gray-50', text: 'text-gray-700', label: 'Team Member' }, default: { bg: 'bg-gray-50', text: 'text-text-secondary', label: 'Team Member' },
} }
export default function MemberCard({ member, onClick }) { export default function MemberCard({ member, onClick }) {
@@ -33,7 +33,7 @@ export default function MemberCard({ member, onClick }) {
return ( return (
<div <div
onClick={() => onClick?.(member)} onClick={() => onClick?.(member)}
className="bg-white rounded-xl border border-border p-5 card-hover cursor-pointer text-center" className="bg-surface rounded-xl border border-border p-5 card-hover cursor-pointer text-center"
> >
{/* Avatar */} {/* Avatar */}
<div className={`w-16 h-16 rounded-full bg-gradient-to-br ${avatarColors[colorIndex]} flex items-center justify-center text-white text-xl font-bold mx-auto mb-3`}> <div className={`w-16 h-16 rounded-full bg-gradient-to-br ${avatarColors[colorIndex]} flex items-center justify-center text-white text-xl font-bold mx-auto mb-3`}>
+42 -17
View File
@@ -1,15 +1,38 @@
import { useEffect } from 'react' import { useEffect, useRef, useCallback } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { X, AlertTriangle } from 'lucide-react' import { X, AlertTriangle } from 'lucide-react'
import { useLanguage } from '../i18n/LanguageContext' import { useLanguage } from '../i18n/LanguageContext'
function useFocusTrap(ref, isOpen) {
useEffect(() => {
if (!isOpen || !ref.current) return
const el = ref.current
const focusable = el.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
if (focusable.length > 0) focusable[0].focus()
const handleTab = (e) => {
if (e.key !== 'Tab' || focusable.length === 0) return
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (e.shiftKey) {
if (document.activeElement === first) { e.preventDefault(); last.focus() }
} else {
if (document.activeElement === last) { e.preventDefault(); first.focus() }
}
}
el.addEventListener('keydown', handleTab)
return () => el.removeEventListener('keydown', handleTab)
}, [isOpen, ref])
}
export default function Modal({ export default function Modal({
isOpen, isOpen,
onClose, onClose,
title, title,
children, children,
size = 'md', size = 'md',
// Confirmation mode props
isConfirm = false, isConfirm = false,
confirmText, confirmText,
cancelText, cancelText,
@@ -17,10 +40,11 @@ export default function Modal({
danger = false, danger = false,
}) { }) {
const { t } = useLanguage() const { t } = useLanguage()
const modalRef = useRef(null)
// Default translations
const finalConfirmText = confirmText || (danger ? t('common.delete') : t('common.save')) const finalConfirmText = confirmText || (danger ? t('common.delete') : t('common.save'))
const finalCancelText = cancelText || t('common.cancel') const finalCancelText = cancelText || t('common.cancel')
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
document.body.style.overflow = 'hidden' document.body.style.overflow = 'hidden'
@@ -30,6 +54,12 @@ export default function Modal({
return () => { document.body.style.overflow = '' } return () => { document.body.style.overflow = '' }
}, [isOpen]) }, [isOpen])
const handleKeyDown = useCallback((e) => {
if (e.key === 'Escape') onClose()
}, [onClose])
useFocusTrap(modalRef, isOpen)
if (!isOpen) return null if (!isOpen) return null
const sizeClasses = { const sizeClasses = {
@@ -39,25 +69,23 @@ export default function Modal({
xl: 'max-w-4xl', xl: 'max-w-4xl',
} }
// Confirmation dialog
if (isConfirm) { if (isConfirm) {
return createPortal( return createPortal(
<div className="fixed inset-0 z-[9999] flex items-center justify-center px-4"> <div className="fixed inset-0 z-[9999] flex items-center justify-center px-4" onKeyDown={handleKeyDown} ref={modalRef}>
{/* Backdrop */}
<div <div
className="fixed inset-0 bg-black/40 backdrop-blur-sm animate-backdrop-in" className="fixed inset-0 bg-black/40 backdrop-blur-sm animate-backdrop-in"
onClick={onClose} onClick={onClose}
aria-label="Close dialog"
/> />
{/* Modal content */} <div className="relative bg-surface rounded-2xl shadow-2xl w-full max-w-md animate-scale-in" role="dialog" aria-modal="true" aria-labelledby="modal-title">
<div className="relative bg-white rounded-2xl shadow-2xl w-full max-w-md animate-scale-in">
<div className="p-6"> <div className="p-6">
{danger && ( {danger && (
<div className="w-12 h-12 rounded-full bg-red-100 flex items-center justify-center mx-auto mb-4"> <div className="w-12 h-12 rounded-full bg-red-100 flex items-center justify-center mx-auto mb-4">
<AlertTriangle className="w-6 h-6 text-red-600" /> <AlertTriangle className="w-6 h-6 text-red-600" />
</div> </div>
)} )}
<h3 className="text-lg font-semibold text-text-primary text-center mb-2">{title}</h3> <h3 id="modal-title" className="text-lg font-semibold text-text-primary text-center mb-2">{title}</h3>
<div className="text-sm text-text-secondary text-center mb-6"> <div className="text-sm text-text-secondary text-center mb-6">
{children} {children}
</div> </div>
@@ -89,29 +117,26 @@ export default function Modal({
) )
} }
// Regular modal
return createPortal( return createPortal(
<div className="fixed inset-0 z-[9999] flex items-start justify-center pt-[10vh] px-4"> <div className="fixed inset-0 z-[9999] flex items-start justify-center pt-[10vh] px-4" onKeyDown={handleKeyDown} ref={modalRef}>
{/* Backdrop */}
<div <div
className="fixed inset-0 bg-black/40 backdrop-blur-sm animate-backdrop-in" className="fixed inset-0 bg-black/40 backdrop-blur-sm animate-backdrop-in"
onClick={onClose} onClick={onClose}
aria-label="Close dialog"
/> />
{/* Modal content */} <div className={`relative bg-surface rounded-2xl shadow-2xl w-full ${sizeClasses[size]} max-h-[80vh] flex flex-col animate-scale-in`} role="dialog" aria-modal="true" aria-labelledby="modal-title">
<div className={`relative bg-white rounded-2xl shadow-2xl w-full ${sizeClasses[size]} max-h-[80vh] flex flex-col animate-scale-in`}>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border shrink-0"> <div className="flex items-center justify-between px-6 py-4 border-b border-border shrink-0">
<h3 className="text-lg font-semibold text-text-primary">{title}</h3> <h3 id="modal-title" className="text-lg font-semibold text-text-primary">{title}</h3>
<button <button
onClick={onClose} onClick={onClose}
className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors" className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors"
aria-label="Close dialog"
> >
<X className="w-5 h-5" /> <X className="w-5 h-5" />
</button> </button>
</div> </div>
{/* Body */}
<div className="px-6 py-4 overflow-y-auto flex-1"> <div className="px-6 py-4 overflow-y-auto flex-1">
{children} {children}
</div> </div>
+2 -2
View File
@@ -23,11 +23,11 @@ export default function PostCard({ post, onClick, onMove, compact = false, check
return ( return (
<div <div
onClick={onClick} onClick={onClick}
className="bg-white rounded-lg p-3 border border-border hover:border-brand-primary/30 cursor-pointer transition-all group overflow-hidden card-hover" className="bg-surface rounded-lg p-3 border border-border hover:border-brand-primary/30 cursor-pointer transition-all group overflow-hidden card-hover"
> >
{post.thumbnail_url && ( {post.thumbnail_url && (
<div className="w-[calc(100%+1.5rem)] h-32 -mx-3 -mt-3 mb-2 rounded-t-lg overflow-hidden"> <div className="w-[calc(100%+1.5rem)] h-32 -mx-3 -mt-3 mb-2 rounded-t-lg overflow-hidden">
<img src={post.thumbnail_url} alt="" className="w-full h-full object-cover" /> <img src={post.thumbnail_url} alt="" className="w-full h-full object-cover" loading="lazy" />
</div> </div>
)} )}
@@ -0,0 +1,109 @@
import { Send, CheckCircle2, XCircle, Copy, Check, Clock } from 'lucide-react'
import { useLanguage } from '../i18n/LanguageContext'
import ApproverMultiSelect from './ApproverMultiSelect'
export function PostDetailApproval({
form,
update,
post,
isCreateMode,
reviewUrl,
copied,
submittingReview,
saving,
teamMembers,
onSubmitReview,
onCopyReviewLink,
onStatusAction,
}) {
const { t } = useLanguage()
return (
<div className="p-6 space-y-5 w-full">
<div className="bg-surface-secondary rounded-xl p-4">
<label className="block text-xs font-medium text-text-primary mb-2">{t('posts.approvers')}</label>
<ApproverMultiSelect
users={teamMembers || []}
selected={form.approver_ids || []}
onChange={ids => update('approver_ids', ids)}
/>
</div>
{!isCreateMode && (
<div className="space-y-4">
{/* Approval status cards */}
{form.status === 'approved' && post.approved_by_name && (
<div className="bg-emerald-50 border border-emerald-200 rounded-xl p-4 flex items-start gap-3">
<div className="w-8 h-8 rounded-full bg-emerald-100 flex items-center justify-center shrink-0 mt-0.5">
<CheckCircle2 className="w-4 h-4 text-emerald-600" />
</div>
<div>
<p className="text-sm font-semibold text-emerald-800">{t('posts.approvedBy')} {post.approved_by_name}</p>
{post.feedback && <p className="text-xs text-emerald-700 mt-1.5 leading-relaxed">{post.feedback}</p>}
</div>
</div>
)}
{form.status === 'rejected' && post.approved_by_name && (
<div className="bg-red-50 border border-red-200 rounded-xl p-4 flex items-start gap-3">
<div className="w-8 h-8 rounded-full bg-red-100 flex items-center justify-center shrink-0 mt-0.5">
<XCircle className="w-4 h-4 text-red-600" />
</div>
<div>
<p className="text-sm font-semibold text-red-800">{t('posts.rejectedBy')} {post.approved_by_name}</p>
{post.feedback && <p className="text-xs text-red-700 mt-1.5 leading-relaxed">{post.feedback}</p>}
</div>
</div>
)}
{form.status === 'in_review' && (
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4 text-center">
<div className="w-10 h-10 rounded-full bg-amber-100 flex items-center justify-center mx-auto mb-2">
<Clock className="w-5 h-5 text-amber-600" />
</div>
<p className="text-sm font-semibold text-amber-800">{t('posts.awaitingReview')}</p>
<p className="text-xs text-amber-600 mt-1">{t('posts.awaitingReviewDesc')}</p>
</div>
)}
{/* Review link */}
{reviewUrl && (
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
<div className="text-xs font-semibold text-blue-900 mb-2.5">{t('posts.reviewLinkTitle')}</div>
<div className="flex items-center gap-2">
<input type="text" value={reviewUrl} readOnly className="flex-1 px-3 py-2 text-xs bg-surface border border-blue-200 rounded-lg font-mono" />
<button onClick={onCopyReviewLink} className="p-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors shadow-sm">
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
</button>
</div>
</div>
)}
{/* Action buttons */}
<div className="flex gap-3">
{!reviewUrl && (
<button
onClick={onSubmitReview}
disabled={submittingReview}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-amber-500 text-white rounded-xl hover:bg-amber-600 transition-colors font-medium text-sm disabled:opacity-50 shadow-sm"
>
<Send className="w-4 h-4" />
{submittingReview ? t('posts.submitting') : t('posts.sendToReview')}
</button>
)}
{form.status === 'approved' && (
<button
onClick={() => onStatusAction('scheduled')}
disabled={saving}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-purple-600 text-white rounded-xl hover:bg-purple-700 transition-colors font-medium text-sm disabled:opacity-50 shadow-sm"
>
{t('posts.schedule')}
</button>
)}
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,247 @@
import { useState, useRef } from 'react'
import { X, Upload, FileText, FolderOpen, Image as ImageIcon, Music, Film } from 'lucide-react'
import { useLanguage } from '../i18n/LanguageContext'
export function PostDetailAttachments({
attachments,
uploading,
onFileUpload,
onDeleteAttachment,
onAttachAsset,
}) {
const { t } = useLanguage()
const imageInputRef = useRef(null)
const [dragActive, setDragActive] = useState(false)
const [showAssetPicker, setShowAssetPicker] = useState(false)
const [availableAssets, setAvailableAssets] = useState([])
const [assetSearch, setAssetSearch] = useState('')
const handleDrop = (e) => {
e.preventDefault(); e.stopPropagation(); setDragActive(false)
if (e.dataTransfer.files?.length) onFileUpload(e.dataTransfer.files)
}
const openAssetPicker = async () => {
const { api } = await import('../utils/api')
try {
const data = await api.get('/assets')
setAvailableAssets(Array.isArray(data) ? data : [])
} catch {
setAvailableAssets([])
}
setAssetSearch('')
setShowAssetPicker(true)
}
const handleAttachAsset = async (assetId) => {
await onAttachAsset(assetId)
setShowAssetPicker(false)
}
const images = attachments.filter(a => (a.mime_type || a.mimeType || '').startsWith('image/'))
const audio = attachments.filter(a => (a.mime_type || a.mimeType || '').startsWith('audio/'))
const videos = attachments.filter(a => (a.mime_type || a.mimeType || '').startsWith('video/'))
const others = attachments.filter(a => {
const mime = a.mime_type || a.mimeType || ''
return !mime.startsWith('image/') && !mime.startsWith('audio/') && !mime.startsWith('video/')
})
return (
<div className="space-y-4">
{/* Images */}
<div>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-1.5 text-xs font-medium text-text-secondary">
<ImageIcon className="w-3.5 h-3.5" />
{t('posts.images')}
{images.length > 0 && <span className="text-text-tertiary">({images.length})</span>}
</div>
<label className="flex items-center gap-1 px-2 py-1 text-[11px] font-medium text-brand-primary hover:bg-brand-primary/5 rounded cursor-pointer transition-colors">
<Upload className="w-3 h-3" />
{t('posts.addImage')}
<input ref={imageInputRef} type="file" multiple accept="image/*" className="hidden"
onChange={e => { onFileUpload(e.target.files); e.target.value = '' }} />
</label>
</div>
{images.length > 0 && (
<div className="grid grid-cols-3 gap-2">
{images.map(att => {
const attUrl = att.url || `/api/uploads/${att.filename}`
const name = att.original_name || att.originalName || att.filename
const attId = att.id || att._id
return (
<div key={attId} className="relative group/att border border-border rounded-lg overflow-hidden bg-surface">
<div className="h-20 relative">
<a href={attUrl} target="_blank" rel="noopener noreferrer" className="block h-full">
<img src={attUrl} alt={name} className="absolute inset-0 w-full h-full object-cover" />
</a>
<button onClick={() => onDeleteAttachment(attId)}
className="absolute top-1 end-1 p-1 bg-black/50 hover:bg-red-500 rounded-full text-white opacity-0 group-hover/att:opacity-100 transition-opacity"
title={t('common.delete')}><X className="w-2.5 h-2.5" /></button>
</div>
<div className="px-2 py-1 text-[10px] text-text-tertiary truncate border-t border-border-light">{name}</div>
</div>
)
})}
</div>
)}
</div>
{/* Audio */}
{audio.length > 0 && (
<div>
<div className="flex items-center gap-1.5 text-xs font-medium text-text-secondary mb-2">
<Music className="w-3.5 h-3.5" />
{t('posts.audio')} <span className="text-text-tertiary">({audio.length})</span>
</div>
<div className="space-y-2">
{audio.map(att => {
const attUrl = att.url || `/api/uploads/${att.filename}`
const name = att.original_name || att.originalName || att.filename
const attId = att.id || att._id
return (
<div key={attId} className="flex items-center gap-2 border border-border rounded-lg p-2 bg-surface group/att">
<Music className="w-4 h-4 text-text-tertiary shrink-0" />
<span className="text-xs text-text-secondary truncate flex-1">{name}</span>
<audio src={attUrl} controls className="h-7 max-w-[160px]" />
<button onClick={() => onDeleteAttachment(attId)}
className="p-1 text-text-tertiary hover:text-red-500 opacity-0 group-hover/att:opacity-100 transition-opacity"
title={t('common.delete')}><X className="w-3 h-3" /></button>
</div>
)
})}
</div>
</div>
)}
{/* Videos */}
{videos.length > 0 && (
<div>
<div className="flex items-center gap-1.5 text-xs font-medium text-text-secondary mb-2">
<Film className="w-3.5 h-3.5" />
{t('posts.videos')} <span className="text-text-tertiary">({videos.length})</span>
</div>
<div className="space-y-2">
{videos.map(att => {
const attUrl = att.url || `/api/uploads/${att.filename}`
const name = att.original_name || att.originalName || att.filename
const attId = att.id || att._id
return (
<div key={attId} className="border border-border rounded-lg overflow-hidden bg-surface group/att">
<video src={attUrl} controls className="w-full max-h-40" />
<div className="flex items-center justify-between px-2 py-1 border-t border-border-light">
<span className="text-[10px] text-text-tertiary truncate">{name}</span>
<button onClick={() => onDeleteAttachment(attId)}
className="p-1 text-text-tertiary hover:text-red-500 opacity-0 group-hover/att:opacity-100 transition-opacity"
title={t('common.delete')}><X className="w-3 h-3" /></button>
</div>
</div>
)
})}
</div>
</div>
)}
{/* Other files */}
{others.length > 0 && (
<div>
<div className="flex items-center gap-1.5 text-xs font-medium text-text-secondary mb-2">
<FileText className="w-3.5 h-3.5" />
{t('posts.otherFiles')} <span className="text-text-tertiary">({others.length})</span>
</div>
<div className="grid grid-cols-3 gap-2">
{others.map(att => {
const attUrl = att.url || `/api/uploads/${att.filename}`
const name = att.original_name || att.originalName || att.filename
const attId = att.id || att._id
return (
<div key={attId} className="relative group/att border border-border rounded-lg overflow-hidden bg-surface">
<a href={attUrl} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 p-3 h-16">
<FileText className="w-6 h-6 text-text-tertiary shrink-0" />
<span className="text-xs text-text-secondary truncate">{name}</span>
</a>
<button onClick={() => onDeleteAttachment(attId)}
className="absolute top-1 end-1 p-1 bg-black/50 hover:bg-red-500 rounded-full text-white opacity-0 group-hover/att:opacity-100 transition-opacity"
title={t('common.delete')}><X className="w-2.5 h-2.5" /></button>
</div>
)
})}
</div>
</div>
)}
{/* Drag and drop zone */}
<div
className={`border-2 border-dashed rounded-lg p-3 text-center cursor-pointer transition-colors ${
dragActive ? 'border-brand-primary bg-brand-primary/5' : 'border-border hover:border-brand-primary/40'
}`}
onDragEnter={e => { e.preventDefault(); setDragActive(true) }}
onDragLeave={e => { e.preventDefault(); setDragActive(false) }}
onDragOver={e => e.preventDefault()}
onDrop={handleDrop}
>
<Upload className={`w-4 h-4 text-text-tertiary mx-auto mb-1 ${uploading ? 'animate-pulse' : ''}`} />
<p className="text-[11px] text-text-secondary">
{dragActive ? t('posts.dropFiles') : t('posts.dragToUpload')}
</p>
</div>
<button
type="button"
onClick={openAssetPicker}
className="flex items-center gap-2 px-3 py-2 text-sm text-text-secondary border border-border rounded-lg hover:bg-surface-tertiary transition-colors w-full justify-center"
>
<FolderOpen className="w-4 h-4" />
{t('posts.attachFromAssets')}
</button>
{showAssetPicker && (
<div className="border border-border rounded-lg p-3 bg-surface-secondary">
<div className="flex items-center justify-between mb-2">
<p className="text-xs font-medium text-text-secondary">{t('posts.selectAssets')}</p>
<button onClick={() => setShowAssetPicker(false)} className="p-1 text-text-tertiary hover:text-text-primary">
<X className="w-3.5 h-3.5" />
</button>
</div>
<input
type="text"
value={assetSearch}
onChange={e => setAssetSearch(e.target.value)}
placeholder={t('common.search')}
className="w-full px-3 py-1.5 text-xs border border-border rounded-lg mb-2 focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
/>
<div className="grid grid-cols-4 gap-2 max-h-48 overflow-y-auto">
{availableAssets
.filter(a => !assetSearch || (a.original_name || a.filename || '').toLowerCase().includes(assetSearch.toLowerCase()))
.map(asset => {
const isImage = asset.mime_type?.startsWith('image/')
const assetUrl = `/api/uploads/${asset.filename}`
const name = asset.original_name || asset.filename
return (
<button
key={asset.id || asset._id}
onClick={() => handleAttachAsset(asset.id || asset._id)}
className="block w-full border border-border rounded-lg overflow-hidden bg-surface hover:border-brand-primary hover:ring-2 hover:ring-brand-primary/20 transition-all text-start"
>
<div className="aspect-square relative">
{isImage ? (
<img src={assetUrl} alt={name} className="absolute inset-0 w-full h-full object-cover" />
) : (
<div className="absolute inset-0 flex items-center justify-center bg-surface-tertiary">
<FileText className="w-6 h-6 text-text-tertiary" />
</div>
)}
</div>
<div className="px-1.5 py-1 text-[10px] text-text-tertiary truncate border-t border-border-light">{name}</div>
</button>
)
})}
</div>
{availableAssets.filter(a => !assetSearch || (a.original_name || a.filename || '').toLowerCase().includes(assetSearch.toLowerCase())).length === 0 && (
<p className="text-xs text-text-tertiary text-center py-4">{t('posts.noAssetsFound')}</p>
)}
</div>
)}
</div>
)
}
+62 -771
View File
@@ -1,28 +1,21 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import { X, Trash2, Upload, FileText, Link2, ExternalLink, FolderOpen, Image as ImageIcon, Music, Film, Send, CheckCircle2, XCircle, Copy, Check, Plus, Globe, Clock, User, FileEdit, Layers, Share2, ShieldCheck, MessageSquare } from 'lucide-react' import { Trash2, XCircle, FileEdit, Layers, Share2, ShieldCheck, MessageSquare } from 'lucide-react'
import { useLanguage } from '../i18n/LanguageContext' import { useLanguage } from '../i18n/LanguageContext'
import { api, PLATFORMS, getBrandColor } from '../utils/api' import { api, getBrandColor } from '../utils/api'
import ApproverMultiSelect from './ApproverMultiSelect'
import CommentsSection from './CommentsSection' import CommentsSection from './CommentsSection'
import Modal from './Modal' import Modal from './Modal'
import TabbedModal from './TabbedModal' import TabbedModal from './TabbedModal'
import { useToast } from './ToastContainer' import { useToast } from './ToastContainer'
import { PostDetailVersions } from './PostDetailVersions'
const AVAILABLE_LANGUAGES = [ import { PostDetailPlatforms } from './PostDetailPlatforms'
{ code: 'ar', label: 'Arabic' }, import { PostDetailApproval } from './PostDetailApproval'
{ code: 'en', label: 'English' }, import { PostDetailAttachments } from './PostDetailAttachments'
{ code: 'fr', label: 'French' },
{ code: 'id', label: 'Bahasa Indonesia' },
]
const TABS = ['details', 'versions', 'platforms', 'approval', 'discussion'] const TABS = ['details', 'versions', 'platforms', 'approval', 'discussion']
export default function PostDetailPanel({ post, onClose, onSave, onDelete, brands, teamMembers, campaigns }) { export default function PostDetailPanel({ post, onClose, onSave, onDelete, brands, teamMembers, campaigns }) {
const { t, lang } = useLanguage() const { t, lang } = useLanguage()
const toast = useToast() const toast = useToast()
const imageInputRef = useRef(null)
const audioInputRef = useRef(null)
const videoInputRef = useRef(null)
const versionFileInputRef = useRef(null) const versionFileInputRef = useRef(null)
const [activeTab, setActiveTab] = useState('details') const [activeTab, setActiveTab] = useState('details')
const [form, setForm] = useState({}) const [form, setForm] = useState({})
@@ -38,24 +31,11 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
// Attachments state (non-versioned, legacy) // Attachments state (non-versioned, legacy)
const [attachments, setAttachments] = useState([]) const [attachments, setAttachments] = useState([])
const [uploading, setUploading] = useState(false) const [uploading, setUploading] = useState(false)
const [dragActive, setDragActive] = useState(false)
const [showAssetPicker, setShowAssetPicker] = useState(false)
const [availableAssets, setAvailableAssets] = useState([])
const [assetSearch, setAssetSearch] = useState('')
// Versions state // Versions state
const [versions, setVersions] = useState([]) const [versions, setVersions] = useState([])
const [selectedVersion, setSelectedVersion] = useState(null) const [selectedVersion, setSelectedVersion] = useState(null)
const [versionData, setVersionData] = useState(null) const [versionData, setVersionData] = useState(null)
const [showNewVersionModal, setShowNewVersionModal] = useState(false)
const [newVersionNotes, setNewVersionNotes] = useState('')
const [copyFromPrevious, setCopyFromPrevious] = useState(false)
const [creatingVersion, setCreatingVersion] = useState(false)
const [showLanguageModal, setShowLanguageModal] = useState(false)
const [languageForm, setLanguageForm] = useState({ language_code: '', language_label: '', content: '' })
const [savingLanguage, setSavingLanguage] = useState(false)
const [confirmDeleteLangId, setConfirmDeleteLangId] = useState(null)
const [confirmDeleteAttId, setConfirmDeleteAttId] = useState(null)
const [uploadingVersionFile, setUploadingVersionFile] = useState(false) const [uploadingVersionFile, setUploadingVersionFile] = useState(false)
const postId = post?._id || post?.id const postId = post?._id || post?.id
@@ -136,6 +116,7 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
} }
if (data.status === 'published' && data.platforms.length > 0) { if (data.status === 'published' && data.platforms.length > 0) {
const { PLATFORMS } = await import('../utils/api')
const missingPlatforms = data.platforms.filter(platform => { const missingPlatforms = data.platforms.filter(platform => {
const link = (data.publication_links || []).find(l => l.platform === platform) const link = (data.publication_links || []).find(l => l.platform === platform)
return !link || !link.url || !link.url.trim() return !link || !link.url || !link.url.trim()
@@ -237,33 +218,16 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
} }
} }
const openAssetPicker = async () => {
try {
const data = await api.get('/assets')
setAvailableAssets(Array.isArray(data) ? data : [])
} catch {
setAvailableAssets([])
}
setAssetSearch('')
setShowAssetPicker(true)
}
const handleAttachAsset = async (assetId) => { const handleAttachAsset = async (assetId) => {
if (!postId) return if (!postId) return
try { try {
await api.post(`/posts/${postId}/attachments/from-asset`, { asset_id: assetId }) await api.post(`/posts/${postId}/attachments/from-asset`, { asset_id: assetId })
loadAttachments() loadAttachments()
setShowAssetPicker(false)
} catch (err) { } catch (err) {
console.error('Attach asset failed:', err) console.error('Attach asset failed:', err)
} }
} }
const handleDrop = (e) => {
e.preventDefault(); e.stopPropagation(); setDragActive(false)
if (e.dataTransfer.files?.length) handleFileUpload(e.dataTransfer.files)
}
// ─── Versions ────────────────────────── // ─── Versions ──────────────────────────
async function loadVersions() { async function loadVersions() {
if (!postId) return if (!postId) return
@@ -299,44 +263,28 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
loadVersionData(version.Id || version.id || version._id) loadVersionData(version.Id || version.id || version._id)
} }
const handleCreateVersion = async () => { const handleCreateVersion = async ({ notes, copy_from_previous }) => {
setCreatingVersion(true)
try { try {
await api.post(`/posts/${postId}/versions`, { await api.post(`/posts/${postId}/versions`, {
notes: newVersionNotes || undefined, notes: notes || undefined,
copy_from_previous: copyFromPrevious, copy_from_previous,
}) })
setShowNewVersionModal(false)
setNewVersionNotes('')
setCopyFromPrevious(false)
loadVersions() loadVersions()
} catch (err) { } catch (err) {
console.error('Create version failed:', err) console.error('Create version failed:', err)
} finally {
setCreatingVersion(false)
} }
} }
const handleAddLanguage = async () => { const handleAddLanguage = async (languageForm) => {
if (!selectedVersion || !languageForm.language_code || !languageForm.content) return if (!selectedVersion) return
setSavingLanguage(true) const vId = selectedVersion.Id || selectedVersion.id || selectedVersion._id
try { await api.post(`/posts/${postId}/versions/${vId}/texts`, languageForm)
const vId = selectedVersion.Id || selectedVersion.id || selectedVersion._id loadVersionData(vId)
await api.post(`/posts/${postId}/versions/${vId}/texts`, languageForm)
setShowLanguageModal(false)
setLanguageForm({ language_code: '', language_label: '', content: '' })
loadVersionData(vId)
} catch (err) {
console.error('Add language failed:', err)
} finally {
setSavingLanguage(false)
}
} }
const handleDeleteLanguage = async (textId) => { const handleDeleteLanguage = async (textId) => {
try { try {
await api.delete(`/post-version-texts/${textId}`) await api.delete(`/post-version-texts/${textId}`)
setConfirmDeleteLangId(null)
const vId = selectedVersion.Id || selectedVersion.id || selectedVersion._id const vId = selectedVersion.Id || selectedVersion.id || selectedVersion._id
loadVersionData(vId) loadVersionData(vId)
} catch (err) { } catch (err) {
@@ -364,7 +312,6 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
const handleDeleteVersionAttachment = async (attId) => { const handleDeleteVersionAttachment = async (attId) => {
try { try {
await api.delete(`/attachments/${attId}`) await api.delete(`/attachments/${attId}`)
setConfirmDeleteAttId(null)
const vId = selectedVersion.Id || selectedVersion.id || selectedVersion._id const vId = selectedVersion.Id || selectedVersion.id || selectedVersion._id
loadVersionData(vId) loadVersionData(vId)
} catch (err) { } catch (err) {
@@ -409,7 +356,7 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
form.status === 'approved' ? 'bg-blue-100 text-blue-700' : form.status === 'approved' ? 'bg-blue-100 text-blue-700' :
form.status === 'in_review' ? 'bg-amber-100 text-amber-700' : form.status === 'in_review' ? 'bg-amber-100 text-amber-700' :
form.status === 'rejected' ? 'bg-red-100 text-red-700' : form.status === 'rejected' ? 'bg-red-100 text-red-700' :
'bg-gray-100 text-gray-600' 'bg-gray-100 text-text-secondary'
}`}> }`}>
{statusOptions.find(s => s.value === form.status)?.label} {statusOptions.find(s => s.value === form.status)?.label}
</span> </span>
@@ -498,7 +445,7 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
value={form.description} value={form.description}
onChange={e => update('description', e.target.value)} onChange={e => update('description', e.target.value)}
rows={4} rows={4}
className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary resize-none" className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary resize-none"
placeholder={t('posts.postDescPlaceholder')} placeholder={t('posts.postDescPlaceholder')}
/> />
</div> </div>
@@ -508,7 +455,7 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
type="text" type="text"
value={form.notes} value={form.notes}
onChange={e => update('notes', e.target.value)} onChange={e => update('notes', e.target.value)}
className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary" className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
placeholder={t('posts.additionalNotes')} placeholder={t('posts.additionalNotes')}
/> />
</div> </div>
@@ -532,7 +479,13 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
</span> </span>
)} )}
</div> </div>
{renderAttachments()} <PostDetailAttachments
attachments={attachments}
uploading={uploading}
onFileUpload={handleFileUpload}
onDeleteAttachment={handleDeleteAttachment}
onAttachAsset={handleAttachAsset}
/>
</div> </div>
)} )}
</div> </div>
@@ -545,7 +498,7 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
<select <select
value={form.status} value={form.status}
onChange={e => update('status', e.target.value)} onChange={e => update('status', e.target.value)}
className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary" className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
> >
{statusOptions.map(s => <option key={s.value} value={s.value}>{s.label}</option>)} {statusOptions.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
</select> </select>
@@ -556,7 +509,7 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
type="date" type="date"
value={form.scheduled_date} value={form.scheduled_date}
onChange={e => update('scheduled_date', e.target.value)} onChange={e => update('scheduled_date', e.target.value)}
className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary" className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
/> />
</div> </div>
<div> <div>
@@ -564,7 +517,7 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
<select <select
value={form.assigned_to} value={form.assigned_to}
onChange={e => update('assigned_to', e.target.value)} onChange={e => update('assigned_to', e.target.value)}
className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary" className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
> >
<option value="">{t('common.unassigned')}</option> <option value="">{t('common.unassigned')}</option>
{(teamMembers || []).map(m => <option key={m._id || m.id} value={m._id || m.id}>{m.name}</option>)} {(teamMembers || []).map(m => <option key={m._id || m.id} value={m._id || m.id}>{m.name}</option>)}
@@ -578,7 +531,7 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
<select <select
value={form.brand_id} value={form.brand_id}
onChange={e => update('brand_id', e.target.value)} onChange={e => update('brand_id', e.target.value)}
className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary" className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
> >
<option value="">{t('posts.selectBrand')}</option> <option value="">{t('posts.selectBrand')}</option>
{(brands || []).map(b => <option key={b._id || b.id} value={b._id || b.id}>{b.icon} {lang === 'ar' && b.name_ar ? b.name_ar : b.name}</option>)} {(brands || []).map(b => <option key={b._id || b.id} value={b._id || b.id}>{b.icon} {lang === 'ar' && b.name_ar ? b.name_ar : b.name}</option>)}
@@ -589,7 +542,7 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
<select <select
value={form.campaign_id} value={form.campaign_id}
onChange={e => update('campaign_id', e.target.value)} onChange={e => update('campaign_id', e.target.value)}
className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary" className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
> >
<option value="">{t('posts.noCampaign')}</option> <option value="">{t('posts.noCampaign')}</option>
{(campaigns || []).map(c => <option key={c._id || c.id} value={c._id || c.id}>{c.name}</option>)} {(campaigns || []).map(c => <option key={c._id || c.id} value={c._id || c.id}>{c.name}</option>)}
@@ -603,395 +556,46 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
{/* ─── Versions Tab ─── */} {/* ─── Versions Tab ─── */}
{activeTab === 'versions' && !isCreateMode && ( {activeTab === 'versions' && !isCreateMode && (
<div className="flex h-full"> <PostDetailVersions
{/* Version Timeline (left sidebar) */} versions={versions}
<div className="w-64 shrink-0 border-e border-border p-4 overflow-y-auto bg-surface-secondary/50"> selectedVersion={selectedVersion}
<div className="flex items-center justify-between mb-4"> versionData={versionData}
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wide">{t('posts.versions')}</h4> onSelectVersion={handleSelectVersion}
<button onCreateVersion={handleCreateVersion}
onClick={() => setShowNewVersionModal(true)} onAddLanguage={handleAddLanguage}
className="flex items-center gap-1 px-2.5 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors shadow-sm" onDeleteLanguage={handleDeleteLanguage}
> onVersionFileUpload={handleVersionFileUpload}
<Plus className="w-3 h-3" /> onDeleteVersionAttachment={handleDeleteVersionAttachment}
{t('posts.newVersion')} uploadingVersionFile={uploadingVersionFile}
</button> versionFileInputRef={versionFileInputRef}
</div> />
{versions.length === 0 ? (
<div className="text-center py-10">
<div className="w-12 h-12 rounded-full bg-surface-tertiary flex items-center justify-center mx-auto mb-3">
<Layers className="w-6 h-6 text-text-quaternary" />
</div>
<p className="text-xs text-text-tertiary leading-relaxed px-2">{t('posts.noVersions')}</p>
</div>
) : (
<div className="space-y-1.5">
{versions.map((version, idx) => {
const vId = version.Id || version.id || version._id
const isActive = vId === (selectedVersion?.Id || selectedVersion?.id || selectedVersion?._id)
const isLatest = idx === versions.length - 1
return (
<button
key={vId}
onClick={() => handleSelectVersion(version)}
className={`w-full text-start p-3 rounded-xl border transition-all ${
isActive
? 'border-brand-primary bg-white shadow-sm ring-1 ring-brand-primary/20'
: 'border-transparent hover:bg-white hover:border-border'
}`}
>
<div className="flex items-center gap-2.5">
<div className={`flex-shrink-0 w-7 h-7 rounded-lg flex items-center justify-center text-[11px] font-bold ${
isActive ? 'bg-brand-primary text-white' : 'bg-surface-tertiary text-text-secondary'
}`}>
{version.version_number}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className={`text-sm font-medium ${isActive ? 'text-brand-primary' : 'text-text-primary'}`}>
v{version.version_number}
</span>
{isLatest && (
<span className="text-[9px] px-1.5 py-px bg-emerald-100 text-emerald-700 rounded font-semibold uppercase">
Latest
</span>
)}
</div>
{version.notes && (
<p className="text-[11px] text-text-tertiary line-clamp-1 mt-0.5">{version.notes}</p>
)}
</div>
</div>
{(version.creator_name || version.created_at) && (
<div className="flex items-center gap-2 mt-2 ms-[38px] text-[10px] text-text-quaternary">
{version.creator_name && <span>{version.creator_name}</span>}
{version.creator_name && version.created_at && <span>·</span>}
{version.created_at && <span>{new Date(version.created_at).toLocaleDateString()}</span>}
</div>
)}
</button>
)
})}
</div>
)}
</div>
{/* Version Content (right side) */}
<div className="flex-1 min-w-0 overflow-y-auto p-6">
{selectedVersion && versionData ? (
<div className="space-y-6 w-full">
{/* Languages */}
<div>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Globe className="w-4 h-4 text-text-tertiary" />
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wide">{t('posts.languages')}</h4>
{versionData.texts?.length > 0 && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-surface-tertiary text-text-tertiary font-medium">
{versionData.texts.length}
</span>
)}
</div>
<button
onClick={() => setShowLanguageModal(true)}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-brand-primary border border-brand-primary/30 hover:bg-brand-primary/5 rounded-lg transition-colors"
>
<Plus className="w-3 h-3" />
{t('posts.addLanguage')}
</button>
</div>
{versionData.texts && versionData.texts.length > 0 ? (
<div className="space-y-3">
{versionData.texts.map(text => {
const tId = text.Id || text.id || text._id
return (
<div key={tId} className="rounded-xl border border-border overflow-hidden">
<div className="flex items-center justify-between px-4 py-2.5 bg-surface-secondary">
<div className="flex items-center gap-2">
<span className="px-2 py-0.5 bg-white border border-border rounded text-[11px] font-semibold uppercase text-text-secondary">{text.language_code}</span>
<span className="text-sm font-medium text-text-primary">{text.language_label}</span>
</div>
<button
onClick={() => setConfirmDeleteLangId(tId)}
className="p-1.5 text-text-quaternary hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
<div className="px-4 py-3 text-sm whitespace-pre-wrap text-text-primary leading-relaxed" dir={text.language_code === 'ar' ? 'rtl' : 'ltr'}>
{text.content}
</div>
</div>
)
})}
</div>
) : (
<div className="text-center py-8 rounded-xl border-2 border-dashed border-border">
<Globe className="w-8 h-8 text-text-quaternary mx-auto mb-2" />
<p className="text-sm text-text-tertiary">{t('posts.noLanguages')}</p>
<button
onClick={() => setShowLanguageModal(true)}
className="mt-3 text-xs font-medium text-brand-primary hover:underline"
>
{t('posts.addLanguage')}
</button>
</div>
)}
</div>
{/* Media / Attachments for this version */}
<div>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<ImageIcon className="w-4 h-4 text-text-tertiary" />
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wide">{t('posts.media')}</h4>
{versionData.attachments?.length > 0 && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-surface-tertiary text-text-tertiary font-medium">
{versionData.attachments.length}
</span>
)}
</div>
<label className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-brand-primary border border-brand-primary/30 hover:bg-brand-primary/5 rounded-lg cursor-pointer transition-colors">
<Upload className="w-3 h-3" />
{uploadingVersionFile ? t('posts.uploading') : t('posts.addImage')}
<input
ref={versionFileInputRef}
type="file"
multiple
className="hidden"
onChange={e => { handleVersionFileUpload(e.target.files); e.target.value = '' }}
disabled={uploadingVersionFile}
/>
</label>
</div>
{versionData.attachments && versionData.attachments.length > 0 ? (
<div className="grid grid-cols-2 gap-3">
{versionData.attachments.map(att => {
const attId = att.Id || att.id || att._id
const attUrl = att.url || `/api/uploads/${att.filename}`
const name = att.original_name || att.filename
const mime = att.mime_type || ''
const isImage = mime.startsWith('image/')
const isVideo = mime.startsWith('video/')
return (
<div key={attId} className="relative group rounded-xl border border-border overflow-hidden bg-white hover:shadow-md transition-shadow">
{isImage ? (
<a href={attUrl} target="_blank" rel="noopener noreferrer">
<img src={attUrl} alt={name} className="w-full h-44 object-cover" />
</a>
) : isVideo ? (
<video src={attUrl} controls className="w-full h-44 object-cover" />
) : (
<a href={attUrl} target="_blank" rel="noopener noreferrer" className="flex items-center justify-center h-44 bg-surface-tertiary">
<FileText className="w-10 h-10 text-text-quaternary" />
</a>
)}
<div className="flex items-center justify-between px-3 py-2 border-t border-border bg-surface-secondary/50">
<span className="text-[11px] text-text-secondary truncate">{name}</span>
<button
onClick={() => setConfirmDeleteAttId(attId)}
className="p-1 text-text-quaternary hover:text-red-500 opacity-0 group-hover:opacity-100 transition-all"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
)
})}
</div>
) : (
<div className="text-center py-8 rounded-xl border-2 border-dashed border-border">
<ImageIcon className="w-8 h-8 text-text-quaternary mx-auto mb-2" />
<p className="text-sm text-text-tertiary">{t('posts.noMedia')}</p>
</div>
)}
</div>
</div>
) : versions.length > 0 ? (
<div className="flex items-center justify-center h-40">
<div className="w-6 h-6 border-2 border-brand-primary border-t-transparent rounded-full animate-spin" />
</div>
) : null}
</div>
</div>
)} )}
{/* ─── Platforms & Links Tab ─── */} {/* ─── Platforms & Links Tab ─── */}
{activeTab === 'platforms' && ( {activeTab === 'platforms' && (
<div className="p-6 space-y-6 w-full"> <PostDetailPlatforms
<div> form={form}
<div className="flex items-center gap-2 mb-3"> update={update}
<Share2 className="w-4 h-4 text-text-tertiary" /> updatePublicationLink={updatePublicationLink}
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wide">{t('posts.platforms')}</h4> />
</div>
<div className="flex flex-wrap gap-2 p-3 rounded-xl bg-surface-secondary min-h-[44px]">
{Object.entries(PLATFORMS).map(([k, v]) => {
const checked = (form.platforms || []).includes(k)
return (
<label
key={k}
className={`flex items-center gap-1.5 text-xs px-3 py-2 rounded-lg cursor-pointer border transition-all ${
checked
? 'bg-white border-brand-primary/30 text-brand-primary font-medium shadow-sm'
: 'bg-white/50 border-transparent text-text-secondary hover:bg-white hover:shadow-sm'
}`}
>
<input
type="checkbox"
checked={checked}
onChange={() => {
update('platforms', checked
? form.platforms.filter(p => p !== k)
: [...(form.platforms || []), k]
)
}}
className="sr-only"
/>
<span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: v.color || '#888' }} />
{v.label}
</label>
)
})}
</div>
</div>
{(form.platforms || []).length > 0 && (
<div>
<div className="flex items-center gap-2 mb-3">
<Link2 className="w-4 h-4 text-text-tertiary" />
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wide">{t('posts.publicationLinks')}</h4>
</div>
<div className="space-y-2.5">
{(form.platforms || []).map(platformKey => {
const platformInfo = PLATFORMS[platformKey] || { label: platformKey }
const existingLink = (form.publication_links || []).find(l => l.platform === platformKey)
const linkUrl = existingLink?.url || ''
return (
<div key={platformKey} className="flex items-center gap-3 p-3 rounded-xl bg-surface-secondary">
<span className="text-xs font-medium text-text-primary w-28 shrink-0 flex items-center gap-2">
<span className="w-2.5 h-2.5 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-2 text-sm border border-border rounded-lg bg-white 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-2 text-text-tertiary hover:text-brand-primary hover:bg-white rounded-lg transition-colors">
<ExternalLink className="w-4 h-4" />
</a>
)}
</div>
)
})}
</div>
{form.status === 'published' && (form.platforms || []).some(p => {
const link = (form.publication_links || []).find(l => l.platform === p)
return !link || !link.url?.trim()
}) && (
<p className="text-xs text-amber-600 mt-3 flex items-center gap-1.5">
<XCircle className="w-3.5 h-3.5" />
{t('posts.publishRequired')}
</p>
)}
</div>
)}
</div>
)} )}
{/* ─── Approval Tab ─── */} {/* ─── Approval Tab ─── */}
{activeTab === 'approval' && ( {activeTab === 'approval' && (
<div className="p-6 space-y-5 w-full"> <PostDetailApproval
<div className="bg-surface-secondary rounded-xl p-4"> form={form}
<label className="block text-xs font-medium text-text-primary mb-2">{t('posts.approvers')}</label> update={update}
<ApproverMultiSelect post={post}
users={teamMembers || []} isCreateMode={isCreateMode}
selected={form.approver_ids || []} reviewUrl={reviewUrl}
onChange={ids => update('approver_ids', ids)} copied={copied}
/> submittingReview={submittingReview}
</div> saving={saving}
teamMembers={teamMembers}
{!isCreateMode && ( onSubmitReview={handleSubmitReview}
<div className="space-y-4"> onCopyReviewLink={copyReviewLink}
{/* Approval status cards */} onStatusAction={handleStatusAction}
{form.status === 'approved' && post.approved_by_name && ( />
<div className="bg-emerald-50 border border-emerald-200 rounded-xl p-4 flex items-start gap-3">
<div className="w-8 h-8 rounded-full bg-emerald-100 flex items-center justify-center shrink-0 mt-0.5">
<CheckCircle2 className="w-4 h-4 text-emerald-600" />
</div>
<div>
<p className="text-sm font-semibold text-emerald-800">{t('posts.approvedBy')} {post.approved_by_name}</p>
{post.feedback && <p className="text-xs text-emerald-700 mt-1.5 leading-relaxed">{post.feedback}</p>}
</div>
</div>
)}
{form.status === 'rejected' && post.approved_by_name && (
<div className="bg-red-50 border border-red-200 rounded-xl p-4 flex items-start gap-3">
<div className="w-8 h-8 rounded-full bg-red-100 flex items-center justify-center shrink-0 mt-0.5">
<XCircle className="w-4 h-4 text-red-600" />
</div>
<div>
<p className="text-sm font-semibold text-red-800">{t('posts.rejectedBy')} {post.approved_by_name}</p>
{post.feedback && <p className="text-xs text-red-700 mt-1.5 leading-relaxed">{post.feedback}</p>}
</div>
</div>
)}
{form.status === 'in_review' && (
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4 text-center">
<div className="w-10 h-10 rounded-full bg-amber-100 flex items-center justify-center mx-auto mb-2">
<Clock className="w-5 h-5 text-amber-600" />
</div>
<p className="text-sm font-semibold text-amber-800">{t('posts.awaitingReview')}</p>
<p className="text-xs text-amber-600 mt-1">{t('posts.awaitingReviewDesc')}</p>
</div>
)}
{/* Review link */}
{reviewUrl && (
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
<div className="text-xs font-semibold text-blue-900 mb-2.5">{t('posts.reviewLinkTitle')}</div>
<div className="flex items-center gap-2">
<input type="text" value={reviewUrl} readOnly className="flex-1 px-3 py-2 text-xs bg-white border border-blue-200 rounded-lg font-mono" />
<button onClick={copyReviewLink} className="p-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors shadow-sm">
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
</button>
</div>
</div>
)}
{/* Action buttons */}
<div className="flex gap-3">
{!reviewUrl && (
<button
onClick={handleSubmitReview}
disabled={submittingReview}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-amber-500 text-white rounded-xl hover:bg-amber-600 transition-colors font-medium text-sm disabled:opacity-50 shadow-sm"
>
<Send className="w-4 h-4" />
{submittingReview ? t('posts.submitting') : t('posts.sendToReview')}
</button>
)}
{form.status === 'approved' && (
<button
onClick={() => handleStatusAction('scheduled')}
disabled={saving}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-purple-600 text-white rounded-xl hover:bg-purple-700 transition-colors font-medium text-sm disabled:opacity-50 shadow-sm"
>
{t('posts.schedule')}
</button>
)}
</div>
</div>
)}
</div>
)} )}
{/* ─── Discussion Tab ─── */} {/* ─── Discussion Tab ─── */}
@@ -1014,319 +618,6 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
> >
{t('posts.deleteConfirm')} {t('posts.deleteConfirm')}
</Modal> </Modal>
{/* New Version Modal */}
<Modal
isOpen={showNewVersionModal}
onClose={() => { setShowNewVersionModal(false); setNewVersionNotes(''); setCopyFromPrevious(false) }}
title={t('posts.createNewVersion')}
size="sm"
>
<div className="space-y-4">
<textarea
value={newVersionNotes}
onChange={e => setNewVersionNotes(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={t('posts.whatChanged')}
/>
{versions.length > 0 && (
<label className="flex items-center gap-2 text-sm text-text-secondary cursor-pointer">
<input
type="checkbox"
checked={copyFromPrevious}
onChange={e => setCopyFromPrevious(e.target.checked)}
className="rounded border-border text-brand-primary focus:ring-brand-primary/20"
/>
{t('posts.copyLanguages')}
</label>
)}
<button
onClick={handleCreateVersion}
disabled={creatingVersion}
className="w-full px-4 py-2.5 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 shadow-sm"
>
{creatingVersion ? t('posts.creatingVersion') : t('posts.createVersion')}
</button>
</div>
</Modal>
{/* Add Language Modal */}
<Modal
isOpen={showLanguageModal}
onClose={() => { setShowLanguageModal(false); setLanguageForm({ language_code: '', language_label: '', content: '' }) }}
title={t('posts.addLanguage')}
size="md"
>
<div className="space-y-4">
<select
value={languageForm.language_code}
onChange={e => {
const lang = AVAILABLE_LANGUAGES.find(l => l.code === e.target.value)
setLanguageForm(f => ({ ...f, language_code: e.target.value, language_label: lang?.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"
>
<option value="">{t('posts.selectLanguage')}</option>
{AVAILABLE_LANGUAGES
.filter(lang => !(versionData?.texts || []).some(t => t.language_code === lang.code))
.map(lang => (
<option key={lang.code} value={lang.code}>{lang.label} ({lang.code.toUpperCase()})</option>
))}
</select>
<textarea
value={languageForm.content}
onChange={e => setLanguageForm(f => ({ ...f, content: e.target.value }))}
rows={8}
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.enterContent')}
dir={languageForm.language_code === 'ar' ? 'rtl' : 'ltr'}
/>
<button
onClick={handleAddLanguage}
disabled={savingLanguage || !languageForm.language_code || !languageForm.content}
className="w-full px-4 py-2.5 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 shadow-sm"
>
{savingLanguage ? t('common.loading') : t('common.save')}
</button>
</div>
</Modal>
{/* Delete Language Confirmation */}
<Modal
isOpen={!!confirmDeleteLangId}
onClose={() => setConfirmDeleteLangId(null)}
title={t('posts.deleteLanguage')}
isConfirm
danger
confirmText={t('common.delete')}
onConfirm={() => handleDeleteLanguage(confirmDeleteLangId)}
>
{t('posts.deleteLanguageConfirm')}
</Modal>
{/* Delete Version Attachment Confirmation */}
<Modal
isOpen={!!confirmDeleteAttId}
onClose={() => setConfirmDeleteAttId(null)}
title={t('posts.deleteAttachment')}
isConfirm
danger
confirmText={t('common.delete')}
onConfirm={() => handleDeleteVersionAttachment(confirmDeleteAttId)}
>
{t('posts.deleteConfirm')}
</Modal>
</> </>
) )
// ─── Render legacy attachments helper ──────────────────────────
function renderAttachments() {
const images = attachments.filter(a => (a.mime_type || a.mimeType || '').startsWith('image/'))
const audio = attachments.filter(a => (a.mime_type || a.mimeType || '').startsWith('audio/'))
const videos = attachments.filter(a => (a.mime_type || a.mimeType || '').startsWith('video/'))
const others = attachments.filter(a => {
const mime = a.mime_type || a.mimeType || ''
return !mime.startsWith('image/') && !mime.startsWith('audio/') && !mime.startsWith('video/')
})
return (
<div className="space-y-4">
{/* Images */}
<div>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-1.5 text-xs font-medium text-text-secondary">
<ImageIcon className="w-3.5 h-3.5" />
{t('posts.images')}
{images.length > 0 && <span className="text-text-tertiary">({images.length})</span>}
</div>
<label className="flex items-center gap-1 px-2 py-1 text-[11px] font-medium text-brand-primary hover:bg-brand-primary/5 rounded cursor-pointer transition-colors">
<Upload className="w-3 h-3" />
{t('posts.addImage')}
<input ref={imageInputRef} type="file" multiple accept="image/*" className="hidden"
onChange={e => { handleFileUpload(e.target.files); e.target.value = '' }} />
</label>
</div>
{images.length > 0 && (
<div className="grid grid-cols-3 gap-2">
{images.map(att => {
const attUrl = att.url || `/api/uploads/${att.filename}`
const name = att.original_name || att.originalName || att.filename
const attId = att.id || att._id
return (
<div key={attId} className="relative group/att border border-border rounded-lg overflow-hidden bg-white">
<div className="h-20 relative">
<a href={attUrl} target="_blank" rel="noopener noreferrer" className="block h-full">
<img src={attUrl} alt={name} className="absolute inset-0 w-full h-full object-cover" />
</a>
<button onClick={() => handleDeleteAttachment(attId)}
className="absolute top-1 right-1 p-1 bg-black/50 hover:bg-red-500 rounded-full text-white opacity-0 group-hover/att:opacity-100 transition-opacity"
title={t('common.delete')}><X className="w-2.5 h-2.5" /></button>
</div>
<div className="px-2 py-1 text-[10px] text-text-tertiary truncate border-t border-border-light">{name}</div>
</div>
)
})}
</div>
)}
</div>
{/* Audio */}
{audio.length > 0 && (
<div>
<div className="flex items-center gap-1.5 text-xs font-medium text-text-secondary mb-2">
<Music className="w-3.5 h-3.5" />
{t('posts.audio')} <span className="text-text-tertiary">({audio.length})</span>
</div>
<div className="space-y-2">
{audio.map(att => {
const attUrl = att.url || `/api/uploads/${att.filename}`
const name = att.original_name || att.originalName || att.filename
const attId = att.id || att._id
return (
<div key={attId} className="flex items-center gap-2 border border-border rounded-lg p-2 bg-white group/att">
<Music className="w-4 h-4 text-text-tertiary shrink-0" />
<span className="text-xs text-text-secondary truncate flex-1">{name}</span>
<audio src={attUrl} controls className="h-7 max-w-[160px]" />
<button onClick={() => handleDeleteAttachment(attId)}
className="p-1 text-text-tertiary hover:text-red-500 opacity-0 group-hover/att:opacity-100 transition-opacity"
title={t('common.delete')}><X className="w-3 h-3" /></button>
</div>
)
})}
</div>
</div>
)}
{/* Videos */}
{videos.length > 0 && (
<div>
<div className="flex items-center gap-1.5 text-xs font-medium text-text-secondary mb-2">
<Film className="w-3.5 h-3.5" />
{t('posts.videos')} <span className="text-text-tertiary">({videos.length})</span>
</div>
<div className="space-y-2">
{videos.map(att => {
const attUrl = att.url || `/api/uploads/${att.filename}`
const name = att.original_name || att.originalName || att.filename
const attId = att.id || att._id
return (
<div key={attId} className="border border-border rounded-lg overflow-hidden bg-white group/att">
<video src={attUrl} controls className="w-full max-h-40" />
<div className="flex items-center justify-between px-2 py-1 border-t border-border-light">
<span className="text-[10px] text-text-tertiary truncate">{name}</span>
<button onClick={() => handleDeleteAttachment(attId)}
className="p-1 text-text-tertiary hover:text-red-500 opacity-0 group-hover/att:opacity-100 transition-opacity"
title={t('common.delete')}><X className="w-3 h-3" /></button>
</div>
</div>
)
})}
</div>
</div>
)}
{/* Other files */}
{others.length > 0 && (
<div>
<div className="flex items-center gap-1.5 text-xs font-medium text-text-secondary mb-2">
<FileText className="w-3.5 h-3.5" />
{t('posts.otherFiles')} <span className="text-text-tertiary">({others.length})</span>
</div>
<div className="grid grid-cols-3 gap-2">
{others.map(att => {
const attUrl = att.url || `/api/uploads/${att.filename}`
const name = att.original_name || att.originalName || att.filename
const attId = att.id || att._id
return (
<div key={attId} className="relative group/att border border-border rounded-lg overflow-hidden bg-white">
<a href={attUrl} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 p-3 h-16">
<FileText className="w-6 h-6 text-text-tertiary shrink-0" />
<span className="text-xs text-text-secondary truncate">{name}</span>
</a>
<button onClick={() => handleDeleteAttachment(attId)}
className="absolute top-1 right-1 p-1 bg-black/50 hover:bg-red-500 rounded-full text-white opacity-0 group-hover/att:opacity-100 transition-opacity"
title={t('common.delete')}><X className="w-2.5 h-2.5" /></button>
</div>
)
})}
</div>
</div>
)}
{/* Drag and drop zone */}
<div
className={`border-2 border-dashed rounded-lg p-3 text-center cursor-pointer transition-colors ${
dragActive ? 'border-brand-primary bg-brand-primary/5' : 'border-border hover:border-brand-primary/40'
}`}
onDragEnter={e => { e.preventDefault(); setDragActive(true) }}
onDragLeave={e => { e.preventDefault(); setDragActive(false) }}
onDragOver={e => e.preventDefault()}
onDrop={handleDrop}
>
<Upload className={`w-4 h-4 text-text-tertiary mx-auto mb-1 ${uploading ? 'animate-pulse' : ''}`} />
<p className="text-[11px] text-text-secondary">
{dragActive ? t('posts.dropFiles') : t('posts.dragToUpload')}
</p>
</div>
<button
type="button"
onClick={openAssetPicker}
className="flex items-center gap-2 px-3 py-2 text-sm text-text-secondary border border-border rounded-lg hover:bg-surface-tertiary transition-colors w-full justify-center"
>
<FolderOpen className="w-4 h-4" />
{t('posts.attachFromAssets')}
</button>
{showAssetPicker && (
<div className="border border-border rounded-lg p-3 bg-surface-secondary">
<div className="flex items-center justify-between mb-2">
<p className="text-xs font-medium text-text-secondary">{t('posts.selectAssets')}</p>
<button onClick={() => setShowAssetPicker(false)} className="p-1 text-text-tertiary hover:text-text-primary">
<X className="w-3.5 h-3.5" />
</button>
</div>
<input
type="text"
value={assetSearch}
onChange={e => setAssetSearch(e.target.value)}
placeholder={t('common.search')}
className="w-full px-3 py-1.5 text-xs border border-border rounded-lg mb-2 focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
/>
<div className="grid grid-cols-4 gap-2 max-h-48 overflow-y-auto">
{availableAssets
.filter(a => !assetSearch || (a.original_name || a.filename || '').toLowerCase().includes(assetSearch.toLowerCase()))
.map(asset => {
const isImage = asset.mime_type?.startsWith('image/')
const assetUrl = `/api/uploads/${asset.filename}`
const name = asset.original_name || asset.filename
return (
<button
key={asset.id || asset._id}
onClick={() => handleAttachAsset(asset.id || asset._id)}
className="block w-full border border-border rounded-lg overflow-hidden bg-white hover:border-brand-primary hover:ring-2 hover:ring-brand-primary/20 transition-all text-left"
>
<div className="aspect-square relative">
{isImage ? (
<img src={assetUrl} alt={name} className="absolute inset-0 w-full h-full object-cover" />
) : (
<div className="absolute inset-0 flex items-center justify-center bg-surface-tertiary">
<FileText className="w-6 h-6 text-text-tertiary" />
</div>
)}
</div>
<div className="px-1.5 py-1 text-[10px] text-text-tertiary truncate border-t border-border-light">{name}</div>
</button>
)
})}
</div>
{availableAssets.filter(a => !assetSearch || (a.original_name || a.filename || '').toLowerCase().includes(assetSearch.toLowerCase())).length === 0 && (
<p className="text-xs text-text-tertiary text-center py-4">{t('posts.noAssetsFound')}</p>
)}
</div>
)}
</div>
)
}
} }
@@ -0,0 +1,92 @@
import { Link2, ExternalLink, XCircle, Share2 } from 'lucide-react'
import { useLanguage } from '../i18n/LanguageContext'
import { PLATFORMS } from '../utils/api'
export function PostDetailPlatforms({ form, update, updatePublicationLink }) {
const { t } = useLanguage()
return (
<div className="p-6 space-y-6 w-full">
<div>
<div className="flex items-center gap-2 mb-3">
<Share2 className="w-4 h-4 text-text-tertiary" />
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wide">{t('posts.platforms')}</h4>
</div>
<div className="flex flex-wrap gap-2 p-3 rounded-xl bg-surface-secondary min-h-[44px]">
{Object.entries(PLATFORMS).map(([k, v]) => {
const checked = (form.platforms || []).includes(k)
return (
<label
key={k}
className={`flex items-center gap-1.5 text-xs px-3 py-2 rounded-lg cursor-pointer border transition-all ${
checked
? 'bg-surface border-brand-primary/30 text-brand-primary font-medium shadow-sm'
: 'bg-white/50 border-transparent text-text-secondary hover:bg-surface hover:shadow-sm'
}`}
>
<input
type="checkbox"
checked={checked}
onChange={() => {
update('platforms', checked
? form.platforms.filter(p => p !== k)
: [...(form.platforms || []), k]
)
}}
className="sr-only"
/>
<span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: v.color || '#888' }} />
{v.label}
</label>
)
})}
</div>
</div>
{(form.platforms || []).length > 0 && (
<div>
<div className="flex items-center gap-2 mb-3">
<Link2 className="w-4 h-4 text-text-tertiary" />
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wide">{t('posts.publicationLinks')}</h4>
</div>
<div className="space-y-2.5">
{(form.platforms || []).map(platformKey => {
const platformInfo = PLATFORMS[platformKey] || { label: platformKey }
const existingLink = (form.publication_links || []).find(l => l.platform === platformKey)
const linkUrl = existingLink?.url || ''
return (
<div key={platformKey} className="flex items-center gap-3 p-3 rounded-xl bg-surface-secondary">
<span className="text-xs font-medium text-text-primary w-28 shrink-0 flex items-center gap-2">
<span className="w-2.5 h-2.5 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-2 text-sm border border-border rounded-lg bg-surface 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-2 text-text-tertiary hover:text-brand-primary hover:bg-surface rounded-lg transition-colors">
<ExternalLink className="w-4 h-4" />
</a>
)}
</div>
)
})}
</div>
{form.status === 'published' && (form.platforms || []).some(p => {
const link = (form.publication_links || []).find(l => l.platform === p)
return !link || !link.url?.trim()
}) && (
<p className="text-xs text-amber-600 mt-3 flex items-center gap-1.5">
<XCircle className="w-3.5 h-3.5" />
{t('posts.publishRequired')}
</p>
)}
</div>
)}
</div>
)
}
@@ -0,0 +1,391 @@
import { useState } from 'react'
import { Trash2, Upload, FileText, Image as ImageIcon, Plus, Globe, Layers } from 'lucide-react'
import { useLanguage } from '../i18n/LanguageContext'
import Modal from './Modal'
const AVAILABLE_LANGUAGES = [
{ code: 'ar', label: 'Arabic' },
{ code: 'en', label: 'English' },
{ code: 'fr', label: 'French' },
{ code: 'id', label: 'Bahasa Indonesia' },
]
export function PostDetailVersions({
versions,
selectedVersion,
versionData,
onSelectVersion,
onCreateVersion,
onAddLanguage,
onDeleteLanguage,
onVersionFileUpload,
onDeleteVersionAttachment,
uploadingVersionFile,
versionFileInputRef,
}) {
const { t } = useLanguage()
const [showNewVersionModal, setShowNewVersionModal] = useState(false)
const [newVersionNotes, setNewVersionNotes] = useState('')
const [copyFromPrevious, setCopyFromPrevious] = useState(false)
const [creatingVersion, setCreatingVersion] = useState(false)
const [showLanguageModal, setShowLanguageModal] = useState(false)
const [languageForm, setLanguageForm] = useState({ language_code: '', language_label: '', content: '' })
const [savingLanguage, setSavingLanguage] = useState(false)
const [confirmDeleteLangId, setConfirmDeleteLangId] = useState(null)
const [confirmDeleteAttId, setConfirmDeleteAttId] = useState(null)
const handleCreateVersion = async () => {
setCreatingVersion(true)
try {
await onCreateVersion({ notes: newVersionNotes || undefined, copy_from_previous: copyFromPrevious })
setShowNewVersionModal(false)
setNewVersionNotes('')
setCopyFromPrevious(false)
} finally {
setCreatingVersion(false)
}
}
const handleAddLanguage = async () => {
if (!selectedVersion || !languageForm.language_code || !languageForm.content) return
setSavingLanguage(true)
try {
await onAddLanguage(languageForm)
setShowLanguageModal(false)
setLanguageForm({ language_code: '', language_label: '', content: '' })
} finally {
setSavingLanguage(false)
}
}
const handleDeleteLanguage = async (textId) => {
await onDeleteLanguage(textId)
setConfirmDeleteLangId(null)
}
const handleDeleteAttachment = async (attId) => {
await onDeleteVersionAttachment(attId)
setConfirmDeleteAttId(null)
}
return (
<>
<div className="flex h-full">
{/* Version Timeline (left sidebar) */}
<div className="w-64 shrink-0 border-e border-border p-4 overflow-y-auto bg-surface-secondary/50">
<div className="flex items-center justify-between mb-4">
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wide">{t('posts.versions')}</h4>
<button
onClick={() => setShowNewVersionModal(true)}
className="flex items-center gap-1 px-2.5 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors shadow-sm"
>
<Plus className="w-3 h-3" />
{t('posts.newVersion')}
</button>
</div>
{versions.length === 0 ? (
<div className="text-center py-10">
<div className="w-12 h-12 rounded-full bg-surface-tertiary flex items-center justify-center mx-auto mb-3">
<Layers className="w-6 h-6 text-text-quaternary" />
</div>
<p className="text-xs text-text-tertiary leading-relaxed px-2">{t('posts.noVersions')}</p>
</div>
) : (
<div className="space-y-1.5">
{versions.map((version, idx) => {
const vId = version.Id || version.id || version._id
const isActive = vId === (selectedVersion?.Id || selectedVersion?.id || selectedVersion?._id)
const isLatest = idx === versions.length - 1
return (
<button
key={vId}
onClick={() => onSelectVersion(version)}
className={`w-full text-start p-3 rounded-xl border transition-all ${
isActive
? 'border-brand-primary bg-surface shadow-sm ring-1 ring-brand-primary/20'
: 'border-transparent hover:bg-surface hover:border-border'
}`}
>
<div className="flex items-center gap-2.5">
<div className={`flex-shrink-0 w-7 h-7 rounded-lg flex items-center justify-center text-[11px] font-bold ${
isActive ? 'bg-brand-primary text-white' : 'bg-surface-tertiary text-text-secondary'
}`}>
{version.version_number}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className={`text-sm font-medium ${isActive ? 'text-brand-primary' : 'text-text-primary'}`}>
v{version.version_number}
</span>
{isLatest && (
<span className="text-[9px] px-1.5 py-px bg-emerald-100 text-emerald-700 rounded font-semibold uppercase">
Latest
</span>
)}
</div>
{version.notes && (
<p className="text-[11px] text-text-tertiary line-clamp-1 mt-0.5">{version.notes}</p>
)}
</div>
</div>
{(version.creator_name || version.created_at) && (
<div className="flex items-center gap-2 mt-2 ms-[38px] text-[10px] text-text-quaternary">
{version.creator_name && <span>{version.creator_name}</span>}
{version.creator_name && version.created_at && <span>·</span>}
{version.created_at && <span>{new Date(version.created_at).toLocaleDateString()}</span>}
</div>
)}
</button>
)
})}
</div>
)}
</div>
{/* Version Content (right side) */}
<div className="flex-1 min-w-0 overflow-y-auto p-6">
{selectedVersion && versionData ? (
<div className="space-y-6 w-full">
{/* Languages */}
<div>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Globe className="w-4 h-4 text-text-tertiary" />
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wide">{t('posts.languages')}</h4>
{versionData.texts?.length > 0 && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-surface-tertiary text-text-tertiary font-medium">
{versionData.texts.length}
</span>
)}
</div>
<button
onClick={() => setShowLanguageModal(true)}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-brand-primary border border-brand-primary/30 hover:bg-brand-primary/5 rounded-lg transition-colors"
>
<Plus className="w-3 h-3" />
{t('posts.addLanguage')}
</button>
</div>
{versionData.texts && versionData.texts.length > 0 ? (
<div className="space-y-3">
{versionData.texts.map(text => {
const tId = text.Id || text.id || text._id
return (
<div key={tId} className="rounded-xl border border-border overflow-hidden">
<div className="flex items-center justify-between px-4 py-2.5 bg-surface-secondary">
<div className="flex items-center gap-2">
<span className="px-2 py-0.5 bg-surface border border-border rounded text-[11px] font-semibold uppercase text-text-secondary">{text.language_code}</span>
<span className="text-sm font-medium text-text-primary">{text.language_label}</span>
</div>
<button
onClick={() => setConfirmDeleteLangId(tId)}
className="p-1.5 text-text-quaternary hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
<div className="px-4 py-3 text-sm whitespace-pre-wrap text-text-primary leading-relaxed" dir={text.language_code === 'ar' ? 'rtl' : 'ltr'}>
{text.content}
</div>
</div>
)
})}
</div>
) : (
<div className="text-center py-8 rounded-xl border-2 border-dashed border-border">
<Globe className="w-8 h-8 text-text-quaternary mx-auto mb-2" />
<p className="text-sm text-text-tertiary">{t('posts.noLanguages')}</p>
<button
onClick={() => setShowLanguageModal(true)}
className="mt-3 text-xs font-medium text-brand-primary hover:underline"
>
{t('posts.addLanguage')}
</button>
</div>
)}
</div>
{/* Media / Attachments for this version */}
<div>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<ImageIcon className="w-4 h-4 text-text-tertiary" />
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wide">{t('posts.media')}</h4>
{versionData.attachments?.length > 0 && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-surface-tertiary text-text-tertiary font-medium">
{versionData.attachments.length}
</span>
)}
</div>
<label className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-brand-primary border border-brand-primary/30 hover:bg-brand-primary/5 rounded-lg cursor-pointer transition-colors">
<Upload className="w-3 h-3" />
{uploadingVersionFile ? t('posts.uploading') : t('posts.addImage')}
<input
ref={versionFileInputRef}
type="file"
multiple
className="hidden"
onChange={e => { onVersionFileUpload(e.target.files); e.target.value = '' }}
disabled={uploadingVersionFile}
/>
</label>
</div>
{versionData.attachments && versionData.attachments.length > 0 ? (
<div className="grid grid-cols-2 gap-3">
{versionData.attachments.map(att => {
const attId = att.Id || att.id || att._id
const attUrl = att.url || `/api/uploads/${att.filename}`
const name = att.original_name || att.filename
const mime = att.mime_type || ''
const isImage = mime.startsWith('image/')
const isVideo = mime.startsWith('video/')
return (
<div key={attId} className="relative group rounded-xl border border-border overflow-hidden bg-surface hover:shadow-md transition-shadow">
{isImage ? (
<a href={attUrl} target="_blank" rel="noopener noreferrer">
<img src={attUrl} alt={name} className="w-full h-44 object-cover" loading="lazy" />
</a>
) : isVideo ? (
<video src={attUrl} controls className="w-full h-44 object-cover" />
) : (
<a href={attUrl} target="_blank" rel="noopener noreferrer" className="flex items-center justify-center h-44 bg-surface-tertiary">
<FileText className="w-10 h-10 text-text-quaternary" />
</a>
)}
<div className="flex items-center justify-between px-3 py-2 border-t border-border bg-surface-secondary/50">
<span className="text-[11px] text-text-secondary truncate">{name}</span>
<button
onClick={() => setConfirmDeleteAttId(attId)}
className="p-1 text-text-quaternary hover:text-red-500 opacity-0 group-hover:opacity-100 transition-all"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
)
})}
</div>
) : (
<div className="text-center py-8 rounded-xl border-2 border-dashed border-border">
<ImageIcon className="w-8 h-8 text-text-quaternary mx-auto mb-2" />
<p className="text-sm text-text-tertiary">{t('posts.noMedia')}</p>
</div>
)}
</div>
</div>
) : versions.length > 0 ? (
<div className="flex items-center justify-center h-40">
<div className="w-6 h-6 border-2 border-brand-primary border-t-transparent rounded-full animate-spin" />
</div>
) : null}
</div>
</div>
{/* New Version Modal */}
<Modal
isOpen={showNewVersionModal}
onClose={() => { setShowNewVersionModal(false); setNewVersionNotes(''); setCopyFromPrevious(false) }}
title={t('posts.createNewVersion')}
size="sm"
>
<div className="space-y-4">
<textarea
value={newVersionNotes}
onChange={e => setNewVersionNotes(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={t('posts.whatChanged')}
/>
{versions.length > 0 && (
<label className="flex items-center gap-2 text-sm text-text-secondary cursor-pointer">
<input
type="checkbox"
checked={copyFromPrevious}
onChange={e => setCopyFromPrevious(e.target.checked)}
className="rounded border-border text-brand-primary focus:ring-brand-primary/20"
/>
{t('posts.copyLanguages')}
</label>
)}
<button
onClick={handleCreateVersion}
disabled={creatingVersion}
className="w-full px-4 py-2.5 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 shadow-sm"
>
{creatingVersion ? t('posts.creatingVersion') : t('posts.createVersion')}
</button>
</div>
</Modal>
{/* Add Language Modal */}
<Modal
isOpen={showLanguageModal}
onClose={() => { setShowLanguageModal(false); setLanguageForm({ language_code: '', language_label: '', content: '' }) }}
title={t('posts.addLanguage')}
size="md"
>
<div className="space-y-4">
<select
value={languageForm.language_code}
onChange={e => {
const lang = AVAILABLE_LANGUAGES.find(l => l.code === e.target.value)
setLanguageForm(f => ({ ...f, language_code: e.target.value, language_label: lang?.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"
>
<option value="">{t('posts.selectLanguage')}</option>
{AVAILABLE_LANGUAGES
.filter(lang => !(versionData?.texts || []).some(t => t.language_code === lang.code))
.map(lang => (
<option key={lang.code} value={lang.code}>{lang.label} ({lang.code.toUpperCase()})</option>
))}
</select>
<textarea
value={languageForm.content}
onChange={e => setLanguageForm(f => ({ ...f, content: e.target.value }))}
rows={8}
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.enterContent')}
dir={languageForm.language_code === 'ar' ? 'rtl' : 'ltr'}
/>
<button
onClick={handleAddLanguage}
disabled={savingLanguage || !languageForm.language_code || !languageForm.content}
className="w-full px-4 py-2.5 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 shadow-sm"
>
{savingLanguage ? t('common.loading') : t('common.save')}
</button>
</div>
</Modal>
{/* Delete Language Confirmation */}
<Modal
isOpen={!!confirmDeleteLangId}
onClose={() => setConfirmDeleteLangId(null)}
title={t('posts.deleteLanguage')}
isConfirm
danger
confirmText={t('common.delete')}
onConfirm={() => handleDeleteLanguage(confirmDeleteLangId)}
>
{t('posts.deleteLanguageConfirm')}
</Modal>
{/* Delete Version Attachment Confirmation */}
<Modal
isOpen={!!confirmDeleteAttId}
onClose={() => setConfirmDeleteAttId(null)}
title={t('posts.deleteAttachment')}
isConfirm
danger
confirmText={t('common.delete')}
onConfirm={() => handleDeleteAttachment(confirmDeleteAttId)}
>
{t('posts.deleteConfirm')}
</Modal>
</>
)
}
+2 -2
View File
@@ -21,11 +21,11 @@ export default function ProjectCard({ project }) {
return ( return (
<div <div
onClick={() => navigate(`/projects/${project._id}`)} onClick={() => navigate(`/projects/${project._id}`)}
className="bg-white rounded-xl border border-border card-hover cursor-pointer overflow-hidden" className="bg-surface rounded-xl border border-border card-hover cursor-pointer overflow-hidden"
> >
{thumbnailUrl ? ( {thumbnailUrl ? (
<div className="w-full h-32 overflow-hidden"> <div className="w-full h-32 overflow-hidden">
<img src={thumbnailUrl} alt="" className="w-full h-full object-cover" /> <img src={thumbnailUrl} alt="" className="w-full h-full object-cover" loading="lazy" />
</div> </div>
) : null} ) : null}
<div className="p-5"> <div className="p-5">
+3 -3
View File
@@ -131,7 +131,7 @@ export default function ProjectEditPanel({ project, onClose, onSave, onDelete, b
form.status === 'paused' ? 'bg-amber-100 text-amber-700' : form.status === 'paused' ? 'bg-amber-100 text-amber-700' :
form.status === 'completed' ? 'bg-blue-100 text-blue-700' : form.status === 'completed' ? 'bg-blue-100 text-blue-700' :
form.status === 'cancelled' ? 'bg-red-100 text-red-700' : form.status === 'cancelled' ? 'bg-red-100 text-red-700' :
'bg-gray-100 text-gray-600' 'bg-gray-100 text-text-secondary'
}`}> }`}>
{statusOptions.find(s => s.value === form.status)?.label} {statusOptions.find(s => s.value === form.status)?.label}
</span> </span>
@@ -257,11 +257,11 @@ export default function ProjectEditPanel({ project, onClose, onSave, onDelete, b
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('projects.thumbnail')}</label> <label className="block text-xs font-medium text-text-tertiary mb-1">{t('projects.thumbnail')}</label>
{(project.thumbnail_url || project.thumbnailUrl) ? ( {(project.thumbnail_url || project.thumbnailUrl) ? (
<div className="relative group rounded-lg overflow-hidden border border-border"> <div className="relative group rounded-lg overflow-hidden border border-border">
<img src={project.thumbnail_url || project.thumbnailUrl} alt="" className="w-full h-24 object-cover" /> <img src={project.thumbnail_url || project.thumbnailUrl} alt="" className="w-full h-24 object-cover" loading="lazy" />
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/30 transition-colors flex items-center justify-center gap-2 opacity-0 group-hover:opacity-100"> <div className="absolute inset-0 bg-black/0 group-hover:bg-black/30 transition-colors flex items-center justify-center gap-2 opacity-0 group-hover:opacity-100">
<button <button
onClick={() => thumbnailInputRef.current?.click()} onClick={() => thumbnailInputRef.current?.click()}
className="px-3 py-1.5 text-xs bg-white/90 hover:bg-white rounded-lg font-medium text-text-primary transition-colors" className="px-3 py-1.5 text-xs bg-white/90 hover:bg-surface rounded-lg font-medium text-text-primary transition-colors"
> >
{t('projects.changeThumbnail')} {t('projects.changeThumbnail')}
</button> </button>
+13 -4
View File
@@ -3,8 +3,17 @@ import { NavLink } from 'react-router-dom'
import { import {
LayoutDashboard, FileEdit, Image, Calendar, Wallet, LayoutDashboard, FileEdit, Image, Calendar, Wallet,
FolderKanban, CheckSquare, Users, ChevronLeft, ChevronRight, ChevronDown, FolderKanban, CheckSquare, Users, ChevronLeft, ChevronRight, ChevronDown,
Sparkles, LogOut, User, Settings, Languages, Tag, LayoutList, Receipt, BarChart3, Palette, CalendarDays, AlertCircle LogOut, User, Settings, Languages, Tag, LayoutList, Receipt, BarChart3, Palette, CalendarDays, AlertCircle
} from 'lucide-react' } from 'lucide-react'
function MarkaLogo({ className = '' }) {
return (
<svg viewBox="0 0 32 32" fill="none" className={className}>
<path d="M4 26V6l10 10L4 26z" fill="currentColor" opacity="0.85" />
<path d="M18 26V6l10 10-10 10z" fill="currentColor" opacity="0.5" />
</svg>
)
}
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../i18n/LanguageContext' import { useLanguage } from '../i18n/LanguageContext'
@@ -115,8 +124,8 @@ export default function Sidebar({ collapsed, setCollapsed }) {
> >
{/* Logo */} {/* Logo */}
<div className="flex items-center gap-3 px-4 h-16 border-b border-white/10 shrink-0"> <div className="flex items-center gap-3 px-4 h-16 border-b border-white/10 shrink-0">
<div className="w-9 h-9 rounded-lg bg-gradient-to-br from-indigo-400 via-purple-500 to-pink-500 flex items-center justify-center shrink-0 shadow-lg shadow-indigo-500/30"> <div className="w-9 h-9 rounded-lg bg-brand-primary flex items-center justify-center shrink-0">
<Sparkles className="w-5 h-5 text-white" /> <MarkaLogo className="w-5 h-5 text-white" />
</div> </div>
{!collapsed && ( {!collapsed && (
<div className="animate-fade-in overflow-hidden"> <div className="animate-fade-in overflow-hidden">
@@ -191,7 +200,7 @@ export default function Sidebar({ collapsed, setCollapsed }) {
<div className="flex items-center gap-3 px-3 py-2 rounded-lg bg-white/5"> <div className="flex items-center gap-3 px-3 py-2 rounded-lg bg-white/5">
<div className="w-8 h-8 rounded-full bg-brand-primary flex items-center justify-center shrink-0"> <div className="w-8 h-8 rounded-full bg-brand-primary flex items-center justify-center shrink-0">
{currentUser.avatar ? ( {currentUser.avatar ? (
<img src={currentUser.avatar} alt={currentUser.name} className="w-full h-full rounded-full object-cover" /> <img src={currentUser.avatar} alt={currentUser.name} className="w-full h-full rounded-full object-cover" loading="lazy" />
) : ( ) : (
<User className="w-4 h-4 text-white" /> <User className="w-4 h-4 text-white" />
)} )}
+6 -6
View File
@@ -2,7 +2,7 @@
export function SkeletonCard() { export function SkeletonCard() {
return ( return (
<div className="bg-white rounded-xl border border-border p-5 animate-pulse"> <div className="bg-surface rounded-xl border border-border p-5 animate-pulse">
<div className="h-4 bg-surface-tertiary rounded w-3/4 mb-3"></div> <div className="h-4 bg-surface-tertiary rounded w-3/4 mb-3"></div>
<div className="h-3 bg-surface-tertiary rounded w-1/2 mb-2"></div> <div className="h-3 bg-surface-tertiary rounded w-1/2 mb-2"></div>
<div className="h-3 bg-surface-tertiary rounded w-2/3"></div> <div className="h-3 bg-surface-tertiary rounded w-2/3"></div>
@@ -12,7 +12,7 @@ export function SkeletonCard() {
export function SkeletonStatCard() { export function SkeletonStatCard() {
return ( return (
<div className="bg-white rounded-xl border border-border p-5 animate-pulse"> <div className="bg-surface rounded-xl border border-border p-5 animate-pulse">
<div className="flex items-start justify-between mb-4"> <div className="flex items-start justify-between mb-4">
<div className="w-10 h-10 bg-surface-tertiary rounded-lg"></div> <div className="w-10 h-10 bg-surface-tertiary rounded-lg"></div>
<div className="h-3 bg-surface-tertiary rounded w-16"></div> <div className="h-3 bg-surface-tertiary rounded w-16"></div>
@@ -25,7 +25,7 @@ export function SkeletonStatCard() {
export function SkeletonTable({ rows = 5, cols = 6 }) { export function SkeletonTable({ rows = 5, cols = 6 }) {
return ( return (
<div className="bg-white rounded-xl border border-border overflow-hidden animate-pulse"> <div className="bg-surface rounded-xl border border-border overflow-hidden animate-pulse">
<div className="border-b border-border bg-surface-secondary p-4"> <div className="border-b border-border bg-surface-secondary p-4">
<div className="flex gap-4"> <div className="flex gap-4">
{[...Array(cols)].map((_, i) => ( {[...Array(cols)].map((_, i) => (
@@ -60,7 +60,7 @@ export function SkeletonKanbanBoard() {
</div> </div>
<div className="bg-surface-secondary rounded-xl p-2 space-y-2 min-h-[400px]"> <div className="bg-surface-secondary rounded-xl p-2 space-y-2 min-h-[400px]">
{[...Array(3)].map((_, cardIdx) => ( {[...Array(3)].map((_, cardIdx) => (
<div key={cardIdx} className="bg-white rounded-lg border border-border p-3"> <div key={cardIdx} className="bg-surface rounded-lg border border-border p-3">
<div className="h-4 bg-surface-tertiary rounded w-full mb-2"></div> <div className="h-4 bg-surface-tertiary rounded w-full mb-2"></div>
<div className="h-3 bg-surface-tertiary rounded w-3/4 mb-3"></div> <div className="h-3 bg-surface-tertiary rounded w-3/4 mb-3"></div>
<div className="flex gap-2"> <div className="flex gap-2">
@@ -78,7 +78,7 @@ export function SkeletonKanbanBoard() {
export function SkeletonCalendar() { export function SkeletonCalendar() {
return ( return (
<div className="bg-white rounded-xl border border-border overflow-hidden animate-pulse"> <div className="bg-surface rounded-xl border border-border overflow-hidden animate-pulse">
<div className="flex items-center justify-between px-6 py-4 border-b border-border"> <div className="flex items-center justify-between px-6 py-4 border-b border-border">
<div className="h-6 bg-surface-tertiary rounded w-40"></div> <div className="h-6 bg-surface-tertiary rounded w-40"></div>
<div className="h-8 bg-surface-tertiary rounded w-20"></div> <div className="h-8 bg-surface-tertiary rounded w-20"></div>
@@ -138,7 +138,7 @@ export function SkeletonDashboard() {
{/* Content cards */} {/* Content cards */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{[...Array(2)].map((_, i) => ( {[...Array(2)].map((_, i) => (
<div key={i} className="bg-white rounded-xl border border-border animate-pulse"> <div key={i} className="bg-surface rounded-xl border border-border animate-pulse">
<div className="px-5 py-4 border-b border-border"> <div className="px-5 py-4 border-b border-border">
<div className="h-5 bg-surface-tertiary rounded w-32"></div> <div className="h-5 bg-surface-tertiary rounded w-32"></div>
</div> </div>
+35 -2
View File
@@ -1,12 +1,45 @@
import { useEffect, useRef, useCallback } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
export default function SlidePanel({ onClose, maxWidth = '420px', header, footer, children }) { export default function SlidePanel({ onClose, maxWidth = '420px', header, footer, children }) {
const panelRef = useRef(null)
const handleKeyDown = useCallback((e) => {
if (e.key === 'Escape') onClose()
}, [onClose])
useEffect(() => {
if (!panelRef.current) return
const el = panelRef.current
const focusable = el.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
if (focusable.length > 0) focusable[0].focus()
const handleTab = (e) => {
if (e.key !== 'Tab' || focusable.length === 0) return
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (e.shiftKey) {
if (document.activeElement === first) { e.preventDefault(); last.focus() }
} else {
if (document.activeElement === last) { e.preventDefault(); first.focus() }
}
}
el.addEventListener('keydown', handleTab)
return () => el.removeEventListener('keydown', handleTab)
}, [])
return createPortal( return createPortal(
<> <>
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm animate-backdrop-in z-[9998]" onClick={onClose} /> <div className="fixed inset-0 bg-black/40 backdrop-blur-sm animate-backdrop-in z-[9998]" onClick={onClose} aria-label="Close panel" />
<div <div
className="fixed top-0 right-0 h-full w-full bg-white shadow-2xl z-[9998] flex flex-col animate-slide-in-right overflow-hidden" ref={panelRef}
className="fixed top-0 right-0 h-full w-full bg-surface shadow-2xl z-[9998] flex flex-col animate-slide-in-right overflow-hidden"
style={{ maxWidth }} style={{ maxWidth }}
role="dialog"
aria-modal="true"
onKeyDown={handleKeyDown}
> >
{header} {header}
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
+6 -6
View File
@@ -7,20 +7,20 @@ export default function StatCard({ icon: Icon, label, value, subtitle, color = '
} }
const iconBgMap = { const iconBgMap = {
'brand-primary': 'bg-indigo-50 text-indigo-600 shadow-lg shadow-indigo-500/20', 'brand-primary': 'bg-teal-50 text-teal-700',
'brand-secondary': 'bg-pink-50 text-pink-600 shadow-lg shadow-pink-500/20', 'brand-secondary': 'bg-pink-50 text-pink-600',
'brand-tertiary': 'bg-amber-50 text-amber-600 shadow-lg shadow-amber-500/20', 'brand-tertiary': 'bg-amber-50 text-amber-600',
'brand-quaternary': 'bg-emerald-50 text-emerald-600 shadow-lg shadow-emerald-500/20', 'brand-quaternary': 'bg-teal-50 text-teal-600',
} }
const accentClass = accentMap[color] || 'accent-primary' const accentClass = accentMap[color] || 'accent-primary'
return ( return (
<div className={`stat-card-premium ${accentClass} bg-white rounded-xl border border-border p-5 card-hover`}> <div className={`stat-card-premium ${accentClass} bg-surface rounded-xl border border-border p-5 card-hover`}>
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div> <div>
<p className="text-sm font-medium text-text-tertiary">{label}</p> <p className="text-sm font-medium text-text-tertiary">{label}</p>
<p className="text-3xl font-bold text-text-primary mt-1">{value}</p> <p className="text-2xl font-bold text-text-primary mt-1">{value}</p>
{subtitle && ( {subtitle && (
<p className="text-xs text-text-tertiary mt-1">{subtitle}</p> <p className="text-xs text-text-tertiary mt-1">{subtitle}</p>
)} )}
+40 -9
View File
@@ -1,4 +1,4 @@
import { useEffect } from 'react' import { useEffect, useRef, useCallback } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { X } from 'lucide-react' import { X } from 'lucide-react'
@@ -19,26 +19,55 @@ export default function TabbedModal({
footer, footer,
children, children,
}) { }) {
const modalRef = useRef(null)
useEffect(() => { useEffect(() => {
document.body.style.overflow = 'hidden' document.body.style.overflow = 'hidden'
return () => { document.body.style.overflow = '' } return () => { document.body.style.overflow = '' }
}, []) }, [])
return createPortal( useEffect(() => {
<div className="fixed inset-0 z-[9999] flex items-start justify-center pt-[5vh] px-4"> if (!modalRef.current) return
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm animate-backdrop-in" onClick={onClose} /> const el = modalRef.current
const focusable = el.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
if (focusable.length > 0) focusable[0].focus()
<div className={`relative bg-white rounded-2xl shadow-2xl w-full ${SIZE_CLASSES[size] || SIZE_CLASSES.md} max-h-[90vh] flex flex-col animate-scale-in`}> const handleTab = (e) => {
if (e.key !== 'Tab' || focusable.length === 0) return
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (e.shiftKey) {
if (document.activeElement === first) { e.preventDefault(); last.focus() }
} else {
if (document.activeElement === last) { e.preventDefault(); first.focus() }
}
}
el.addEventListener('keydown', handleTab)
return () => el.removeEventListener('keydown', handleTab)
}, [])
const handleKeyDown = useCallback((e) => {
if (e.key === 'Escape') onClose()
}, [onClose])
return createPortal(
<div className="fixed inset-0 z-[9999] flex items-start justify-center pt-[5vh] px-4" onKeyDown={handleKeyDown} ref={modalRef}>
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm animate-backdrop-in" onClick={onClose} aria-label="Close dialog" />
<div className={`relative bg-surface rounded-2xl shadow-2xl w-full ${SIZE_CLASSES[size] || SIZE_CLASSES.md} max-h-[90vh] flex flex-col animate-scale-in`} role="dialog" aria-modal="true" aria-labelledby="tabbed-modal-title">
{/* Header */} {/* Header */}
<div className="shrink-0"> <div className="shrink-0">
<div className="px-6 pt-5 pb-3"> <div className="px-6 pt-5 pb-3">
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0"> <div id="tabbed-modal-title" className="flex-1 min-w-0">
{header} {header}
</div> </div>
<button <button
onClick={onClose} onClick={onClose}
className="p-2 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors shrink-0 -mt-1 -me-1" className="p-2 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors shrink-0 -mt-1 -me-1"
aria-label="Close dialog"
> >
<X className="w-5 h-5" /> <X className="w-5 h-5" />
</button> </button>
@@ -47,13 +76,15 @@ export default function TabbedModal({
{/* Tabs */} {/* Tabs */}
{tabs.length > 0 && ( {tabs.length > 0 && (
<div className="flex gap-0 px-6 border-b border-border overflow-x-auto"> <div className="flex gap-0 px-6 border-b border-border overflow-x-auto" role="tablist">
{tabs.map(tab => { {tabs.map(tab => {
const TabIcon = tab.icon const TabIcon = tab.icon
return ( return (
<button <button
key={tab.key} key={tab.key}
onClick={() => onTabChange(tab.key)} onClick={() => onTabChange(tab.key)}
role="tab"
aria-selected={activeTab === tab.key}
className={`relative flex items-center gap-2 px-4 py-3 text-[13px] font-medium whitespace-nowrap transition-colors ${ className={`relative flex items-center gap-2 px-4 py-3 text-[13px] font-medium whitespace-nowrap transition-colors ${
activeTab === tab.key activeTab === tab.key
? 'text-brand-primary' ? 'text-brand-primary'
@@ -80,13 +111,13 @@ export default function TabbedModal({
</div> </div>
{/* Body */} {/* Body */}
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto" role="tabpanel">
{children} {children}
</div> </div>
{/* Footer */} {/* Footer */}
{footer && ( {footer && (
<div className="border-t border-border px-6 py-3.5 flex items-center justify-between shrink-0 rounded-b-2xl bg-white"> <div className="border-t border-border px-6 py-3.5 flex items-center justify-between shrink-0 rounded-b-2xl bg-surface">
{footer} {footer}
</div> </div>
)} )}
+6 -6
View File
@@ -100,7 +100,7 @@ export default function TaskCalendarView({ tasks, onTaskClick }) {
if (p === 'urgent') return 'bg-red-500 text-white' if (p === 'urgent') return 'bg-red-500 text-white'
if (p === 'high') return 'bg-orange-400 text-white' if (p === 'high') return 'bg-orange-400 text-white'
if (p === 'medium') return 'bg-amber-400 text-amber-900' if (p === 'medium') return 'bg-amber-400 text-amber-900'
return 'bg-gray-300 text-gray-700' return 'bg-gray-300 text-text-secondary'
} }
return ( return (
@@ -124,14 +124,14 @@ export default function TaskCalendarView({ tasks, onTaskClick }) {
<div className="flex bg-surface-tertiary rounded-lg p-0.5"> <div className="flex bg-surface-tertiary rounded-lg p-0.5">
<button <button
onClick={() => setCalView('month')} onClick={() => setCalView('month')}
className={`px-2.5 py-1 text-[11px] font-medium rounded-md transition-colors flex items-center gap-1 ${calView === 'month' ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary'}`} className={`px-2.5 py-1 text-[11px] font-medium rounded-md transition-colors flex items-center gap-1 ${calView === 'month' ? 'bg-surface shadow-sm text-text-primary' : 'text-text-tertiary'}`}
> >
<CalendarIcon className="w-3 h-3" /> <CalendarIcon className="w-3 h-3" />
Month Month
</button> </button>
<button <button
onClick={() => setCalView('week')} onClick={() => setCalView('week')}
className={`px-2.5 py-1 text-[11px] font-medium rounded-md transition-colors flex items-center gap-1 ${calView === 'week' ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary'}`} className={`px-2.5 py-1 text-[11px] font-medium rounded-md transition-colors flex items-center gap-1 ${calView === 'week' ? 'bg-surface shadow-sm text-text-primary' : 'text-text-tertiary'}`}
> >
<CalendarDays className="w-3 h-3" /> <CalendarDays className="w-3 h-3" />
Week Week
@@ -162,7 +162,7 @@ export default function TaskCalendarView({ tasks, onTaskClick }) {
<div <div
key={i} key={i}
className={`border-r border-b border-border ${calView === 'week' ? 'min-h-[300px]' : 'min-h-[90px]'} p-1 ${ className={`border-r border-b border-border ${calView === 'week' ? 'min-h-[300px]' : 'min-h-[90px]'} p-1 ${
cell.current ? 'bg-white' : 'bg-surface-secondary/50' cell.current ? 'bg-surface' : 'bg-surface-secondary/50'
}`} }`}
> >
<div className={`text-[11px] font-medium mb-0.5 w-6 h-6 flex items-center justify-center rounded-full ${ <div className={`text-[11px] font-medium mb-0.5 w-6 h-6 flex items-center justify-center rounded-full ${
@@ -175,7 +175,7 @@ export default function TaskCalendarView({ tasks, onTaskClick }) {
<button <button
key={task._id || task.id} key={task._id || task.id}
onClick={() => onTaskClick(task)} onClick={() => onTaskClick(task)}
className={`w-full text-left text-[10px] px-1.5 py-0.5 rounded truncate font-medium hover:opacity-80 transition-opacity ${ className={`w-full text-start text-[10px] px-1.5 py-0.5 rounded truncate font-medium hover:opacity-80 transition-opacity ${
task.status === 'done' ? 'bg-emerald-100 text-emerald-700 line-through' : getPillColor(task) task.status === 'done' ? 'bg-emerald-100 text-emerald-700 line-through' : getPillColor(task)
}`} }`}
title={task.title} title={task.title}
@@ -206,7 +206,7 @@ export default function TaskCalendarView({ tasks, onTaskClick }) {
<button <button
key={task._id || task.id} key={task._id || task.id}
onClick={() => onTaskClick(task)} onClick={() => onTaskClick(task)}
className="w-full text-left bg-white border border-border rounded-lg p-2 hover:border-brand-primary/30 transition-colors" className="w-full text-start bg-surface border border-border rounded-lg p-2 hover:border-brand-primary/30 transition-colors"
> >
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<div className={`w-2 h-2 rounded-full ${priority.color} shrink-0`} /> <div className={`w-2 h-2 rounded-full ${priority.color} shrink-0`} />
+1 -1
View File
@@ -32,7 +32,7 @@ export default function TaskCard({ task, onMove, showProject = true }) {
const assignedName = task.assigned_name || task.assignedName const assignedName = task.assigned_name || task.assignedName
return ( return (
<div className={`bg-white rounded-lg border border-border p-3 card-hover group cursor-pointer ${isExternallyAssigned ? 'border-l-[3px] border-l-blue-400' : ''}`}> <div className={`bg-surface rounded-lg border border-border p-3 card-hover group cursor-pointer ${isExternallyAssigned ? 'border-l-[3px] border-l-blue-400' : ''}`}>
<div className="flex items-start gap-2.5"> <div className="flex items-start gap-2.5">
{/* Priority dot */} {/* Priority dot */}
<div className={`w-2.5 h-2.5 rounded-full ${priority.color} mt-1.5 shrink-0`} title={priority.label} /> <div className={`w-2.5 h-2.5 rounded-full ${priority.color} mt-1.5 shrink-0`} title={priority.label} />
+11 -11
View File
@@ -199,11 +199,11 @@ export default function TaskDetailPanel({ task, onClose, onSave, onDelete, proje
{/* Thumbnail banner */} {/* Thumbnail banner */}
{currentThumbnail && ( {currentThumbnail && (
<div className="relative -mx-6 -mt-5 mb-3 h-32 overflow-hidden rounded-t-2xl"> <div className="relative -mx-6 -mt-5 mb-3 h-32 overflow-hidden rounded-t-2xl">
<img src={currentThumbnail} alt="" className="w-full h-full object-cover" /> <img src={currentThumbnail} alt="" className="w-full h-full object-cover" loading="lazy" />
<div className="absolute inset-0 bg-gradient-to-t from-white/80 to-transparent" /> <div className="absolute inset-0 bg-gradient-to-t from-white/80 to-transparent" />
<button <button
onClick={handleRemoveThumbnail} onClick={handleRemoveThumbnail}
className="absolute top-2 right-2 p-1 bg-black/40 hover:bg-black/60 rounded-full text-white transition-colors" className="absolute top-2 end-2 p-1 bg-black/40 hover:bg-black/60 rounded-full text-white transition-colors"
title={t('tasks.removeThumbnail')} title={t('tasks.removeThumbnail')}
> >
<X className="w-3 h-3" /> <X className="w-3 h-3" />
@@ -218,11 +218,11 @@ export default function TaskDetailPanel({ task, onClose, onSave, onDelete, proje
placeholder={t('tasks.taskTitle')} placeholder={t('tasks.taskTitle')}
/> />
<div className="flex items-center gap-2 mt-2"> <div className="flex items-center gap-2 mt-2">
<span className={`inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full font-medium ${priority.color === 'bg-gray-400' ? 'bg-gray-100 text-gray-600' : priority.color === 'bg-amber-400' ? 'bg-amber-100 text-amber-700' : priority.color === 'bg-orange-500' ? 'bg-orange-100 text-orange-700' : 'bg-red-100 text-red-700'}`}> <span className={`inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full font-medium ${priority.color === 'bg-gray-400' ? 'bg-gray-100 text-text-secondary' : priority.color === 'bg-amber-400' ? 'bg-amber-100 text-amber-700' : priority.color === 'bg-orange-500' ? 'bg-orange-100 text-orange-700' : 'bg-red-100 text-red-700'}`}>
<div className={`w-1.5 h-1.5 rounded-full ${priority.color}`} /> <div className={`w-1.5 h-1.5 rounded-full ${priority.color}`} />
{priorityOptions.find(p => p.value === form.priority)?.label} {priorityOptions.find(p => p.value === form.priority)?.label}
</span> </span>
<span className={`text-[11px] px-2 py-0.5 rounded-full font-medium ${form.status === 'done' ? 'bg-emerald-100 text-emerald-700' : form.status === 'in_progress' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600'}`}> <span className={`text-[11px] px-2 py-0.5 rounded-full font-medium ${form.status === 'done' ? 'bg-emerald-100 text-emerald-700' : form.status === 'in_progress' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-text-secondary'}`}>
{statusOptions.find(s => s.value === form.status)?.label} {statusOptions.find(s => s.value === form.status)?.label}
</span> </span>
{isOverdue && !isCreateMode && ( {isOverdue && !isCreateMode && (
@@ -401,11 +401,11 @@ export default function TaskDetailPanel({ task, onClose, onSave, onDelete, proje
const isThumbnail = currentThumbnail && attUrl === currentThumbnail const isThumbnail = currentThumbnail && attUrl === currentThumbnail
return ( return (
<div key={attId} className="relative group/att border border-border rounded-lg overflow-hidden bg-white"> <div key={attId} className="relative group/att border border-border rounded-lg overflow-hidden bg-surface">
<div className="h-20 relative"> <div className="h-20 relative">
{isImage ? ( {isImage ? (
<a href={attUrl} target="_blank" rel="noopener noreferrer" className="block h-full"> <a href={attUrl} target="_blank" rel="noopener noreferrer" className="block h-full">
<img src={attUrl} alt={name} className="absolute inset-0 w-full h-full object-cover" /> <img src={attUrl} alt={name} className="absolute inset-0 w-full h-full object-cover" loading="lazy" />
</a> </a>
) : ( ) : (
<a href={attUrl} target="_blank" rel="noopener noreferrer" className="absolute inset-0 flex items-center gap-2 p-3"> <a href={attUrl} target="_blank" rel="noopener noreferrer" className="absolute inset-0 flex items-center gap-2 p-3">
@@ -414,11 +414,11 @@ export default function TaskDetailPanel({ task, onClose, onSave, onDelete, proje
</a> </a>
)} )}
{isThumbnail && ( {isThumbnail && (
<div className="absolute top-1 left-1 p-0.5 bg-amber-400 rounded-full text-white"> <div className="absolute top-1 start-1 p-0.5 bg-amber-400 rounded-full text-white">
<Star className="w-2.5 h-2.5 fill-current" /> <Star className="w-2.5 h-2.5 fill-current" />
</div> </div>
)} )}
<div className="absolute top-1 right-1 flex items-center gap-0.5 opacity-0 group-hover/att:opacity-100 transition-opacity"> <div className="absolute top-1 end-1 flex items-center gap-0.5 opacity-0 group-hover/att:opacity-100 transition-opacity">
{isImage && !isThumbnail && ( {isImage && !isThumbnail && (
<button <button
onClick={() => handleSetThumbnail(att)} onClick={() => handleSetThumbnail(att)}
@@ -454,17 +454,17 @@ export default function TaskDetailPanel({ task, onClose, onSave, onDelete, proje
const previewUrl = isImage ? URL.createObjectURL(file) : null const previewUrl = isImage ? URL.createObjectURL(file) : null
return ( return (
<div key={i} className="relative group/att border border-border rounded-lg overflow-hidden bg-white"> <div key={i} className="relative group/att border border-border rounded-lg overflow-hidden bg-surface">
<div className="h-20 relative"> <div className="h-20 relative">
{isImage ? ( {isImage ? (
<img src={previewUrl} alt={file.name} className="absolute inset-0 w-full h-full object-cover" /> <img src={previewUrl} alt={file.name} className="absolute inset-0 w-full h-full object-cover" loading="lazy" />
) : ( ) : (
<div className="absolute inset-0 flex items-center gap-2 p-3"> <div className="absolute inset-0 flex items-center gap-2 p-3">
<FileText className="w-6 h-6 text-text-tertiary shrink-0" /> <FileText className="w-6 h-6 text-text-tertiary shrink-0" />
<span className="text-xs text-text-secondary truncate">{file.name}</span> <span className="text-xs text-text-secondary truncate">{file.name}</span>
</div> </div>
)} )}
<div className="absolute top-1 right-1 flex items-center gap-0.5 opacity-0 group-hover/att:opacity-100 transition-opacity"> <div className="absolute top-1 end-1 flex items-center gap-0.5 opacity-0 group-hover/att:opacity-100 transition-opacity">
<button <button
onClick={() => setPendingFiles(prev => prev.filter((_, j) => j !== i))} onClick={() => setPendingFiles(prev => prev.filter((_, j) => j !== i))}
className="p-1 bg-black/50 hover:bg-red-500 rounded-full text-white transition-colors" className="p-1 bg-black/50 hover:bg-red-500 rounded-full text-white transition-colors"
+7 -7
View File
@@ -11,9 +11,9 @@ import { AppContext, PERMISSION_LEVELS } from '../App'
const ALL_MODULES = ['marketing', 'projects', 'finance'] const ALL_MODULES = ['marketing', 'projects', 'finance']
const MODULE_LABELS = { marketing: 'Marketing', projects: 'Projects', finance: 'Finance' } const MODULE_LABELS = { marketing: 'Marketing', projects: 'Projects', finance: 'Finance' }
const MODULE_COLORS = { const MODULE_COLORS = {
marketing: { on: 'bg-emerald-100 text-emerald-700 border-emerald-300', off: 'bg-gray-100 text-gray-400 border-gray-200' }, marketing: { on: 'bg-emerald-100 text-emerald-700 border-emerald-300', off: 'bg-gray-100 text-text-tertiary border-gray-200' },
projects: { on: 'bg-blue-100 text-blue-700 border-blue-300', off: 'bg-gray-100 text-gray-400 border-gray-200' }, projects: { on: 'bg-blue-100 text-blue-700 border-blue-300', off: 'bg-gray-100 text-text-tertiary border-gray-200' },
finance: { on: 'bg-amber-100 text-amber-700 border-amber-300', off: 'bg-gray-100 text-gray-400 border-gray-200' }, finance: { on: 'bg-amber-100 text-amber-700 border-amber-300', off: 'bg-gray-100 text-text-tertiary border-gray-200' },
} }
export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave, onDelete, canManageTeam, userRole, teams, brands: brandsList }) { export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave, onDelete, canManageTeam, userRole, teams, brands: brandsList }) {
@@ -285,7 +285,7 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
<button <button
type="button" type="button"
onClick={() => setShowBrandsDropdown(prev => !prev)} onClick={() => setShowBrandsDropdown(prev => !prev)}
className="w-full flex items-center justify-between 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 bg-white text-left" className="w-full flex items-center justify-between 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 bg-surface text-start"
> >
<span className={`flex-1 truncate ${(form.brands || []).length === 0 ? 'text-text-tertiary' : 'text-text-primary'}`}> <span className={`flex-1 truncate ${(form.brands || []).length === 0 ? 'text-text-tertiary' : 'text-text-primary'}`}>
{(form.brands || []).length === 0 {(form.brands || []).length === 0
@@ -315,7 +315,7 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
{/* Dropdown */} {/* Dropdown */}
{showBrandsDropdown && ( {showBrandsDropdown && (
<div className="absolute z-20 mt-1 w-full bg-white border border-border rounded-lg shadow-lg max-h-48 overflow-y-auto"> <div className="absolute z-20 mt-1 w-full bg-surface border border-border rounded-lg shadow-lg max-h-48 overflow-y-auto">
{brandsList && brandsList.length > 0 ? ( {brandsList && brandsList.length > 0 ? (
brandsList.map(brand => { brandsList.map(brand => {
const name = lang === 'ar' && brand.name_ar ? brand.name_ar : brand.name const name = lang === 'ar' && brand.name_ar ? brand.name_ar : brand.name
@@ -325,7 +325,7 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
type="button" type="button"
key={brand.id || brand._id} key={brand.id || brand._id}
onClick={() => toggleBrand(name)} onClick={() => toggleBrand(name)}
className={`w-full flex items-center gap-2.5 px-3 py-2 cursor-pointer hover:bg-surface-secondary transition-colors text-left ${checked ? 'bg-brand-primary/5' : ''}`} className={`w-full flex items-center gap-2.5 px-3 py-2 cursor-pointer hover:bg-surface-secondary transition-colors text-start ${checked ? 'bg-brand-primary/5' : ''}`}
> >
<div className={`w-4 h-4 rounded border flex items-center justify-center shrink-0 transition-colors ${ <div className={`w-4 h-4 rounded border flex items-center justify-center shrink-0 transition-colors ${
checked ? 'bg-brand-primary border-brand-primary' : 'border-border' checked ? 'bg-brand-primary border-brand-primary' : 'border-border'
@@ -393,7 +393,7 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
className={`text-xs px-3 py-1.5 rounded-full border font-medium transition-colors ${ className={`text-xs px-3 py-1.5 rounded-full border font-medium transition-colors ${
active active
? 'bg-blue-100 text-blue-700 border-blue-300' ? 'bg-blue-100 text-blue-700 border-blue-300'
: 'bg-gray-100 text-gray-400 border-gray-200' : 'bg-gray-100 text-text-tertiary border-gray-200'
}`} }`}
> >
{team.name} {team.name}
+2 -2
View File
@@ -149,13 +149,13 @@ export default function TeamPanel({ team, onClose, onSave, onDelete, teamMembers
{activeTab === 'members' && ( {activeTab === 'members' && (
<div className="p-6"> <div className="p-6">
<div className="relative mb-3"> <div className="relative mb-3">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-text-tertiary" /> <Search className="absolute start-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-text-tertiary" />
<input <input
type="text" type="text"
value={memberSearch} value={memberSearch}
onChange={e => setMemberSearch(e.target.value)} onChange={e => setMemberSearch(e.target.value)}
placeholder={t('teams.selectMembers')} placeholder={t('teams.selectMembers')}
className="w-full pl-9 pr-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" className="w-full ps-9 pe-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="space-y-1 max-h-80 overflow-y-auto"> <div className="space-y-1 max-h-80 overflow-y-auto">
+1 -1
View File
@@ -14,7 +14,7 @@ export default function ThemeToggle({ className = '' }) {
{darkMode ? ( {darkMode ? (
<Sun className="w-5 h-5 text-yellow-500" /> <Sun className="w-5 h-5 text-yellow-500" />
) : ( ) : (
<Moon className="w-5 h-5 text-gray-600" /> <Moon className="w-5 h-5 text-text-secondary" />
)} )}
</button> </button>
) )
+1 -1
View File
@@ -37,7 +37,7 @@ export function ToastProvider({ children }) {
<ToastContext.Provider value={toast}> <ToastContext.Provider value={toast}>
{children} {children}
{/* Toast container - fixed position */} {/* Toast container - fixed position */}
<div className="fixed top-4 right-4 z-[10000] flex flex-col gap-2 pointer-events-none"> <div className="fixed top-4 end-4 z-[10000] flex flex-col gap-2 pointer-events-none">
<div className="flex flex-col gap-2 pointer-events-auto"> <div className="flex flex-col gap-2 pointer-events-auto">
{toasts.map(t => ( {toasts.map(t => (
<Toast <Toast
+1 -1
View File
@@ -114,7 +114,7 @@ export default function TrackDetailPanel({ track, campaignId, onClose, onSave, o
form.status === 'active' ? 'bg-emerald-100 text-emerald-700' : form.status === 'active' ? 'bg-emerald-100 text-emerald-700' :
form.status === 'paused' ? 'bg-amber-100 text-amber-700' : form.status === 'paused' ? 'bg-amber-100 text-amber-700' :
form.status === 'completed' ? 'bg-blue-100 text-blue-700' : form.status === 'completed' ? 'bg-blue-100 text-blue-700' :
'bg-gray-100 text-gray-600' 'bg-gray-100 text-text-secondary'
}`}> }`}>
{form.status?.charAt(0).toUpperCase() + form.status?.slice(1)} {form.status?.charAt(0).toUpperCase() + form.status?.slice(1)}
</span> </span>
@@ -441,7 +441,7 @@ export default function TranslationDetailPanel({ translation, onClose, onUpdate,
<div className="flex items-center justify-between mb-1.5"> <div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-medium text-text-tertiary"> <span className="text-xs font-medium text-text-tertiary">
{t('translations.optionLabel')} {text.option_number || idx + 1} {t('translations.optionLabel')} {text.option_number || idx + 1}
{selected && <span className="ml-2 text-emerald-600 font-semibold">{t('translations.selected')}</span>} {selected && <span className="ms-2 text-emerald-600 font-semibold">{t('translations.selected')}</span>}
</span> </span>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{editingTextId !== text.Id && ( {editingTextId !== text.Id && (
@@ -520,7 +520,7 @@ export default function TranslationDetailPanel({ translation, onClose, onUpdate,
type="text" type="text"
value={currentReviewUrl} value={currentReviewUrl}
readOnly readOnly
className="flex-1 px-3 py-2 text-sm bg-white border border-blue-200 rounded-lg text-blue-800" className="flex-1 px-3 py-2 text-sm bg-surface border border-blue-200 rounded-lg text-blue-800"
/> />
<button <button
onClick={copyReviewLink} onClick={copyReviewLink}
+2 -2
View File
@@ -177,7 +177,7 @@ export default function Tutorial({ onComplete }) {
{/* Tooltip card */} {/* Tooltip card */}
<div <div
className="absolute bg-white rounded-xl shadow-2xl border border-border p-6 animate-fade-in pointer-events-auto" className="absolute bg-surface rounded-xl shadow-2xl border border-border p-6 animate-fade-in pointer-events-auto"
style={{ style={{
top: tooltipPosition.top, top: tooltipPosition.top,
left: tooltipPosition.left, left: tooltipPosition.left,
@@ -188,7 +188,7 @@ export default function Tutorial({ onComplete }) {
{/* Close button */} {/* Close button */}
<button <button
onClick={handleSkip} onClick={handleSkip}
className="absolute top-4 right-4 text-text-tertiary hover:text-text-primary transition-colors" className="absolute top-4 end-4 text-text-tertiary hover:text-text-primary transition-colors"
> >
<X className="w-5 h-5" /> <X className="w-5 h-5" />
</button> </button>
+71 -3
View File
@@ -1,6 +1,6 @@
{ {
"app.name": "المركز الرقمي", "app.name": "رواج",
"app.subtitle": "المنصة", "app.subtitle": "مركز التسويق",
"nav.dashboard": "لوحة التحكم", "nav.dashboard": "لوحة التحكم",
"nav.campaigns": "الحملات", "nav.campaigns": "الحملات",
"nav.finance": "المالية والعائد", "nav.finance": "المالية والعائد",
@@ -396,6 +396,16 @@
"campaigns.editCampaign": "تعديل الحملة", "campaigns.editCampaign": "تعديل الحملة",
"campaigns.deleteCampaign": "حذف الحملة؟", "campaigns.deleteCampaign": "حذف الحملة؟",
"campaigns.deleteConfirm": "هل أنت متأكد من حذف هذه الحملة؟ سيتم حذف جميع البيانات المرتبطة. لا يمكن التراجع.", "campaigns.deleteConfirm": "هل أنت متأكد من حذف هذه الحملة؟ سيتم حذف جميع البيانات المرتبطة. لا يمكن التراجع.",
"campaigns.tracks": "المسارات",
"campaigns.addTrack": "إضافة مسار",
"campaigns.noTracks": "لا توجد مسارات بعد. أضف مسارات عضوية أو مدفوعة أو SEO لتنظيم هذه الحملة.",
"campaigns.postsLinked": "منشورات مرتبطة",
"campaigns.team": "الفريق",
"campaigns.assignMembers": "تعيين أعضاء",
"campaigns.linkedPosts": "المنشورات المرتبطة",
"campaigns.notFound": "الحملة غير موجودة.",
"common.goBack": "رجوع",
"finance.allocated": "مخصص",
"tracks.details": "التفاصيل", "tracks.details": "التفاصيل",
"tracks.metrics": "المقاييس", "tracks.metrics": "المقاييس",
"tracks.trackName": "اسم المسار", "tracks.trackName": "اسم المسار",
@@ -503,6 +513,59 @@
"budgets.dateExpensed": "التاريخ", "budgets.dateExpensed": "التاريخ",
"dashboard.expenses": "المصروفات", "dashboard.expenses": "المصروفات",
"finance.expenses": "إجمالي المصروفات", "finance.expenses": "إجمالي المصروفات",
"finance.totalReceived": "إجمالي المستلم",
"finance.totalSpent": "إجمالي المنفق",
"finance.remaining": "المتبقي",
"finance.revenue": "الإيرادات",
"finance.globalROI": "العائد الإجمالي",
"finance.budgetAllocation": "توزيع الميزانية",
"finance.manageBudgets": "إدارة الميزانيات",
"finance.campaigns": "الحملات",
"finance.projects": "المشاريع",
"finance.unallocated": "غير مخصص",
"finance.budgetUtilization": "استخدام الميزانية",
"finance.globalPerformance": "الأداء العام",
"finance.impressions": "مرات الظهور",
"finance.clicks": "النقرات",
"finance.conversions": "التحويلات",
"finance.campaignBreakdown": "توزيع الحملات",
"finance.allocatedFunds": "الأموال المخصصة",
"finance.requestBudget": "طلب ميزانية",
"finance.budgetRequests": "طلبات الميزانية",
"finance.pendingApproval": "بانتظار موافقة المدير التنفيذي",
"finance.justification": "المبرر",
"finance.earmarkFor": "تخصيص لـ",
"finance.submitRequest": "إرسال الطلب",
"finance.cancelRequest": "إلغاء الطلب",
"finance.approved": "تمت الموافقة",
"finance.rejected": "مرفوض",
"finance.cancelled": "ملغي",
"finance.pending": "قيد الانتظار",
"finance.ceoNote": "ملاحظة المدير",
"finance.requestPending": "طلب(ات) ميزانية بانتظار الموافقة",
"finance.insufficientBudget": "ميزانية غير كافية",
"finance.availableBudget": "المتاح",
"finance.requestMore": "طلب المزيد من الأموال",
"finance.noCeoEmail": "لم يتم تكوين بريد المدير التنفيذي. اذهب إلى الإعدادات.",
"finance.amount": "المبلغ",
"finance.justificationPlaceholder": "لماذا هذه الميزانية مطلوبة؟",
"finance.optional": "اختياري",
"settings.budgetApproval": "موافقة الميزانية",
"settings.ceoEmail": "بريد المدير التنفيذي / المعتمد",
"settings.ceoEmailHint": "عنوان البريد الإلكتروني الذي يستلم طلبات الموافقة على الميزانية",
"budgetApproval.title": "موافقة الميزانية",
"budgetApproval.amount": "المبلغ المطلوب",
"budgetApproval.requestedBy": "مقدم الطلب",
"budgetApproval.justification": "المبرر",
"budgetApproval.earmarkedFor": "مخصص لـ",
"budgetApproval.approve": "موافقة",
"budgetApproval.reject": "رفض",
"budgetApproval.addNote": "أضف ملاحظة (اختياري)",
"budgetApproval.approved": "تمت الموافقة على هذا الطلب.",
"budgetApproval.rejected": "تم رفض هذا الطلب.",
"budgetApproval.expired": "انتهت صلاحية هذا الطلب.",
"budgetApproval.alreadyHandled": "تمت معالجة هذا الطلب بالفعل.",
"finance.ofBudget": "من الميزانية",
"settings.uploads": "الرفع", "settings.uploads": "الرفع",
"settings.maxFileSize": "الحد الأقصى لحجم الملف", "settings.maxFileSize": "الحد الأقصى لحجم الملف",
"settings.maxFileSizeHint": "الحد الأقصى المسموح لحجم المرفقات (١-٥٠٠ ميجابايت)", "settings.maxFileSizeHint": "الحد الأقصى المسموح لحجم المرفقات (١-٥٠٠ ميجابايت)",
@@ -629,7 +692,7 @@
"review.alreadyReviewed": "تمت مراجعة هذا المحتوى بالفعل.", "review.alreadyReviewed": "تمت مراجعة هذا المحتوى بالفعل.",
"review.statusLabel": "الحالة", "review.statusLabel": "الحالة",
"review.reviewedBy": "تمت المراجعة بواسطة", "review.reviewedBy": "تمت المراجعة بواسطة",
"review.poweredBy": "مدعوم بواسطة Samaya Digital Hub", "review.poweredBy": "مدعوم بواسطة Rawaj",
"review.loadFailed": "فشل في تحميل المحتوى", "review.loadFailed": "فشل في تحميل المحتوى",
"review.actionFailed": "فشل الإجراء", "review.actionFailed": "فشل الإجراء",
"review.actionCompleted": "تم الإجراء بنجاح", "review.actionCompleted": "تم الإجراء بنجاح",
@@ -694,6 +757,8 @@
"team.selectRole": "اختر دوراً...", "team.selectRole": "اختر دوراً...",
"common.team": "الفريق", "common.team": "الفريق",
"common.noTeam": "بدون فريق", "common.noTeam": "بدون فريق",
"common.none": "بدون",
"common.success": "تم بنجاح",
"common.error": "حدث خطأ", "common.error": "حدث خطأ",
"settings.roles": "الأدوار", "settings.roles": "الأدوار",
"settings.rolesDesc": "حدد أدوار العمل مثل مصمم، استراتيجي، إلخ. يتم تعيينها لأعضاء الفريق بشكل منفصل عن مستويات الصلاحية.", "settings.rolesDesc": "حدد أدوار العمل مثل مصمم، استراتيجي، إلخ. يتم تعيينها لأعضاء الفريق بشكل منفصل عن مستويات الصلاحية.",
@@ -717,6 +782,9 @@
"header.budgets": "الميزانيات", "header.budgets": "الميزانيات",
"header.issues": "البلاغات", "header.issues": "البلاغات",
"header.settings": "الإعدادات", "header.settings": "الإعدادات",
"header.translations": "الترجمات",
"calendar.unscheduledPosts": "منشورات غير مجدولة",
"calendar.statusLegend": "دليل الحالات",
"header.users": "إدارة المستخدمين", "header.users": "إدارة المستخدمين",
"header.projectDetails": "تفاصيل المشروع", "header.projectDetails": "تفاصيل المشروع",
"header.campaignDetails": "تفاصيل الحملة", "header.campaignDetails": "تفاصيل الحملة",
+74 -6
View File
@@ -1,6 +1,6 @@
{ {
"app.name": "Digital Hub", "app.name": "Rawaj",
"app.subtitle": "Platform", "app.subtitle": "Marketing Hub",
"nav.dashboard": "Dashboard", "nav.dashboard": "Dashboard",
"nav.campaigns": "Campaigns", "nav.campaigns": "Campaigns",
"nav.finance": "Finance & ROI", "nav.finance": "Finance & ROI",
@@ -70,7 +70,7 @@
"dashboard.noPostsYet": "No posts yet. Create your first post!", "dashboard.noPostsYet": "No posts yet. Create your first post!",
"dashboard.upcomingDeadlines": "Upcoming Deadlines", "dashboard.upcomingDeadlines": "Upcoming Deadlines",
"dashboard.noUpcomingDeadlines": "No upcoming deadlines this week. 🎉", "dashboard.noUpcomingDeadlines": "No upcoming deadlines this week. 🎉",
"dashboard.loadingHub": "Loading Digital Hub...", "dashboard.loadingHub": "Loading Rawaj...",
"posts.title": "Post Production", "posts.title": "Post Production",
"posts.newPost": "New Post", "posts.newPost": "New Post",
"posts.editPost": "Edit Post", "posts.editPost": "Edit Post",
@@ -271,7 +271,7 @@
"settings.english": "English", "settings.english": "English",
"settings.arabic": "Arabic", "settings.arabic": "Arabic",
"settings.restartTutorial": "Restart Tutorial", "settings.restartTutorial": "Restart Tutorial",
"settings.tutorialDesc": "Need a refresher? Restart the interactive tutorial to learn about all the features of Digital Hub.", "settings.tutorialDesc": "Need a refresher? Restart the interactive tutorial to learn about all the features of Rawaj.",
"settings.general": "General", "settings.general": "General",
"settings.onboardingTutorial": "Onboarding Tutorial", "settings.onboardingTutorial": "Onboarding Tutorial",
"settings.tutorialRestarted": "Tutorial Restarted!", "settings.tutorialRestarted": "Tutorial Restarted!",
@@ -315,7 +315,7 @@
"tutorial.newPost.desc": "Start creating content here. Pick your brand, platforms, and assign it to a team member.", "tutorial.newPost.desc": "Start creating content here. Pick your brand, platforms, and assign it to a team member.",
"tutorial.filters.title": "Filter & Focus", "tutorial.filters.title": "Filter & Focus",
"tutorial.filters.desc": "Use filters to focus on specific brands, platforms, or team members.", "tutorial.filters.desc": "Use filters to focus on specific brands, platforms, or team members.",
"login.title": "Digital Hub", "login.title": "Rawaj",
"login.subtitle": "Sign in to continue", "login.subtitle": "Sign in to continue",
"login.forgotPassword": "Forgot password?", "login.forgotPassword": "Forgot password?",
"login.defaultCreds": "Default credentials:", "login.defaultCreds": "Default credentials:",
@@ -396,6 +396,16 @@
"campaigns.editCampaign": "Edit Campaign", "campaigns.editCampaign": "Edit Campaign",
"campaigns.deleteCampaign": "Delete Campaign?", "campaigns.deleteCampaign": "Delete Campaign?",
"campaigns.deleteConfirm": "Are you sure you want to delete this campaign? All associated data will be removed. This action cannot be undone.", "campaigns.deleteConfirm": "Are you sure you want to delete this campaign? All associated data will be removed. This action cannot be undone.",
"campaigns.tracks": "Tracks",
"campaigns.addTrack": "Add Track",
"campaigns.noTracks": "No tracks yet. Add organic, paid, or SEO tracks to organize this campaign.",
"campaigns.postsLinked": "posts linked",
"campaigns.team": "Team",
"campaigns.assignMembers": "Assign Members",
"campaigns.linkedPosts": "Linked Posts",
"campaigns.notFound": "Campaign not found.",
"common.goBack": "Go back",
"finance.allocated": "allocated",
"tracks.details": "Details", "tracks.details": "Details",
"tracks.metrics": "Metrics", "tracks.metrics": "Metrics",
"tracks.trackName": "Track Name", "tracks.trackName": "Track Name",
@@ -503,6 +513,59 @@
"budgets.dateExpensed": "Date", "budgets.dateExpensed": "Date",
"dashboard.expenses": "Expenses", "dashboard.expenses": "Expenses",
"finance.expenses": "Total Expenses", "finance.expenses": "Total Expenses",
"finance.totalReceived": "Total Received",
"finance.totalSpent": "Total Spent",
"finance.remaining": "Remaining",
"finance.revenue": "Revenue",
"finance.globalROI": "Global ROI",
"finance.budgetAllocation": "Budget Allocation",
"finance.manageBudgets": "Manage Budgets",
"finance.campaigns": "Campaigns",
"finance.projects": "Projects",
"finance.unallocated": "Unallocated",
"finance.budgetUtilization": "Budget Utilization",
"finance.globalPerformance": "Global Performance",
"finance.impressions": "Impressions",
"finance.clicks": "Clicks",
"finance.conversions": "Conversions",
"finance.campaignBreakdown": "Campaign Breakdown",
"finance.allocatedFunds": "Allocated Funds",
"finance.requestBudget": "Request Budget",
"finance.budgetRequests": "Budget Requests",
"finance.pendingApproval": "pending CEO approval",
"finance.justification": "Justification",
"finance.earmarkFor": "Earmark for",
"finance.submitRequest": "Submit Request",
"finance.cancelRequest": "Cancel Request",
"finance.approved": "Approved",
"finance.rejected": "Rejected",
"finance.cancelled": "Cancelled",
"finance.pending": "Pending",
"finance.ceoNote": "CEO Note",
"finance.requestPending": "budget request(s) pending CEO approval",
"finance.insufficientBudget": "Insufficient budget",
"finance.availableBudget": "Available",
"finance.requestMore": "Request more funds",
"finance.noCeoEmail": "CEO email not configured. Go to Settings.",
"finance.amount": "Amount",
"finance.justificationPlaceholder": "Why is this budget needed?",
"finance.optional": "Optional",
"settings.budgetApproval": "Budget Approval",
"settings.ceoEmail": "CEO / Budget Approver Email",
"settings.ceoEmailHint": "Email address that receives budget approval requests",
"budgetApproval.title": "Budget Approval",
"budgetApproval.amount": "Requested Amount",
"budgetApproval.requestedBy": "Requested by",
"budgetApproval.justification": "Justification",
"budgetApproval.earmarkedFor": "Earmarked for",
"budgetApproval.approve": "Approve",
"budgetApproval.reject": "Reject",
"budgetApproval.addNote": "Add a note (optional)",
"budgetApproval.approved": "This request has been approved.",
"budgetApproval.rejected": "This request has been rejected.",
"budgetApproval.expired": "This request has expired.",
"budgetApproval.alreadyHandled": "This request has already been processed.",
"finance.ofBudget": "of budget",
"settings.uploads": "Uploads", "settings.uploads": "Uploads",
"settings.maxFileSize": "Maximum File Size", "settings.maxFileSize": "Maximum File Size",
"settings.maxFileSizeHint": "Maximum allowed file size for attachments (1-500 MB)", "settings.maxFileSizeHint": "Maximum allowed file size for attachments (1-500 MB)",
@@ -629,7 +692,7 @@
"review.alreadyReviewed": "This artefact has already been reviewed.", "review.alreadyReviewed": "This artefact has already been reviewed.",
"review.statusLabel": "Status", "review.statusLabel": "Status",
"review.reviewedBy": "Reviewed by", "review.reviewedBy": "Reviewed by",
"review.poweredBy": "Powered by Samaya Digital Hub", "review.poweredBy": "Powered by Rawaj",
"review.loadFailed": "Failed to load artefact", "review.loadFailed": "Failed to load artefact",
"review.actionFailed": "Action failed", "review.actionFailed": "Action failed",
"review.actionCompleted": "Action completed successfully", "review.actionCompleted": "Action completed successfully",
@@ -694,6 +757,8 @@
"team.selectRole": "Select role...", "team.selectRole": "Select role...",
"common.team": "Team", "common.team": "Team",
"common.noTeam": "No team", "common.noTeam": "No team",
"common.none": "None",
"common.success": "Success",
"common.error": "An error occurred", "common.error": "An error occurred",
"settings.roles": "Roles", "settings.roles": "Roles",
"settings.rolesDesc": "Define job roles like Designer, Strategist, etc. These are assigned to team members separately from permission levels.", "settings.rolesDesc": "Define job roles like Designer, Strategist, etc. These are assigned to team members separately from permission levels.",
@@ -717,6 +782,9 @@
"header.budgets": "Budgets", "header.budgets": "Budgets",
"header.issues": "Issues", "header.issues": "Issues",
"header.settings": "Settings", "header.settings": "Settings",
"header.translations": "Translations",
"calendar.unscheduledPosts": "Unscheduled Posts",
"calendar.statusLegend": "Status Legend",
"header.users": "User Management", "header.users": "User Management",
"header.projectDetails": "Project Details", "header.projectDetails": "Project Details",
"header.campaignDetails": "Campaign Details", "header.campaignDetails": "Campaign Details",
+90 -123
View File
@@ -1,16 +1,16 @@
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+Arabic:wght@300;400;500;600;700&family=Inter:wght@300;400;500;600;700&display=swap'); @import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500;600;700&family=IBM+Plex+Sans+Arabic:wght@300;400;500;600;700&display=swap');
@import "tailwindcss"; @import "tailwindcss";
@theme { @theme {
--font-sans: 'Inter', 'IBM Plex Sans Arabic', system-ui, -apple-system, sans-serif; --font-sans: 'DM Sans', 'IBM Plex Sans Arabic', system-ui, -apple-system, sans-serif;
--color-sidebar: #0f172a; --color-sidebar: #0a1f1c;
--color-sidebar-hover: #1e293b; --color-sidebar-hover: #123b35;
--color-sidebar-active: #020617; --color-sidebar-active: #061411;
--color-brand-primary: #4f46e5; --color-brand-primary: #0d9488;
--color-brand-primary-light: #6366f1; --color-brand-primary-light: #14b8a6;
--color-brand-secondary: #db2777; --color-brand-secondary: #db2777;
--color-brand-tertiary: #f59e0b; --color-brand-tertiary: #f59e0b;
--color-brand-quaternary: #059669; --color-brand-quaternary: #0d9488;
--color-surface: #ffffff; --color-surface: #ffffff;
--color-surface-secondary: #f9fafb; --color-surface-secondary: #f9fafb;
--color-surface-tertiary: #f3f4f6; --color-surface-tertiary: #f3f4f6;
@@ -37,40 +37,39 @@
} }
/* ═══════════════════════════════════════════════ /* ═══════════════════════════════════════════════
DARK MODE — Inspired by SpaceTime DARK MODE — Forest teal tinted surfaces
Deep layered surfaces, glass edges, ambient glow
═══════════════════════════════════════════════ */ ═══════════════════════════════════════════════ */
.dark { .dark {
/* Layered depth: void → surface → surface-2surface-3 */ /* Layered depth: deep forest → surface → elevated */
--color-surface: #15151e; --color-surface: #0f1a18;
--color-surface-secondary: #1c1c2a; --color-surface-secondary: #162220;
--color-surface-tertiary: #24243a; --color-surface-tertiary: #1e2e2b;
--color-border: rgba(255, 255, 255, 0.08); --color-border: rgba(255, 255, 255, 0.08);
--color-border-light: rgba(255, 255, 255, 0.04); --color-border-light: rgba(255, 255, 255, 0.04);
/* Text — crisp hierarchy */ /* Text — warm neutrals, teal-tinted */
--color-text-primary: #eeecf5; --color-text-primary: #e8f0ee;
--color-text-secondary: #a8a3c0; --color-text-secondary: #9db5b0;
--color-text-tertiary: #706b8a; --color-text-tertiary: #637e78;
/* Sidebar */ /* Sidebar */
--color-sidebar: #0e0e16; --color-sidebar: #0a1412;
--color-sidebar-hover: #15151e; --color-sidebar-hover: #0f1a18;
--color-sidebar-active: #0a0a12; --color-sidebar-active: #060e0c;
/* Brand — brighter on dark */ /* Brand — brighter on dark */
--color-brand-primary: #8b5cf6; --color-brand-primary: #14b8a6;
--color-brand-primary-light: #a78bfa; --color-brand-primary-light: #2dd4bf;
color-scheme: dark; color-scheme: dark;
background-color: #15151e; background-color: #0f1a18;
color: #eeecf5; color: #e8f0ee;
} }
/* ─── Ambient background glow ────────────────── */ /* ─── Ambient background glow ────────────────── */
.dark .bg-mesh { .dark .bg-mesh {
background-color: #15151e !important; background-color: #0f1a18 !important;
background-image: none !important; background-image: none !important;
} }
.dark .bg-mesh::before { .dark .bg-mesh::before {
@@ -78,9 +77,8 @@
position: fixed; position: fixed;
inset: 0; inset: 0;
background: background:
radial-gradient(ellipse 70% 50% at 20% 50%, rgba(139, 92, 246, 0.045) 0%, transparent 60%), radial-gradient(ellipse 70% 50% at 20% 50%, rgba(13, 148, 136, 0.04) 0%, transparent 60%),
radial-gradient(ellipse 50% 40% at 80% 30%, rgba(56, 189, 248, 0.03) 0%, transparent 60%), radial-gradient(ellipse 50% 40% at 80% 30%, rgba(20, 184, 166, 0.025) 0%, transparent 60%);
radial-gradient(ellipse 60% 40% at 50% 90%, rgba(232, 168, 56, 0.02) 0%, transparent 60%);
pointer-events: none; pointer-events: none;
z-index: 0; z-index: 0;
} }
@@ -89,11 +87,11 @@
.dark .bg-white, .dark .bg-white,
.dark .bg-\[\#fff\], .dark .bg-\[\#fff\],
.dark .bg-\[\#ffffff\] { .dark .bg-\[\#ffffff\] {
background-color: #22223a !important; background-color: #1a2a28 !important;
} }
.dark .bg-gray-50 { background-color: #15151e !important; } .dark .bg-gray-50 { background-color: #0f1a18 !important; }
.dark .bg-gray-100 { background-color: #1c1c2a !important; } .dark .bg-gray-100 { background-color: #162220 !important; }
.dark .bg-gray-200 { background-color: #24243a !important; } .dark .bg-gray-200 { background-color: #1e2e2b !important; }
/* ─── Borders ────────────────────────────────── */ /* ─── Borders ────────────────────────────────── */
.dark .border-gray-100, .dark .border-gray-100,
@@ -104,12 +102,12 @@
.dark .divide-border-light > :not(:first-child) { border-color: rgba(255, 255, 255, 0.05) !important; } .dark .divide-border-light > :not(:first-child) { border-color: rgba(255, 255, 255, 0.05) !important; }
/* ─── Text ───────────────────────────────────── */ /* ─── Text ───────────────────────────────────── */
.dark .text-gray-900 { color: #eeecf5 !important; } .dark .text-gray-900 { color: #e8f0ee !important; }
.dark .text-gray-800 { color: #d8d5e8 !important; } .dark .text-gray-800 { color: #d0ddd9 !important; }
.dark .text-gray-700 { color: #c2bedb !important; } .dark .text-gray-700 { color: #b5cac5 !important; }
.dark .text-gray-600 { color: #a8a3c0 !important; } .dark .text-gray-600 { color: #9db5b0 !important; }
.dark .text-gray-500 { color: #8b85a8 !important; } .dark .text-gray-500 { color: #7e9a94 !important; }
.dark .text-gray-400 { color: #706b8a !important; } .dark .text-gray-400 { color: #637e78 !important; }
/* ─── Status badges — translucent glass ──────── */ /* ─── Status badges — translucent glass ──────── */
.dark .bg-emerald-100, .dark .bg-emerald-50 { background-color: rgba(74, 222, 128, 0.12) !important; } .dark .bg-emerald-100, .dark .bg-emerald-50 { background-color: rgba(74, 222, 128, 0.12) !important; }
@@ -150,49 +148,49 @@
.dark input:focus, .dark input:focus,
.dark select:focus, .dark select:focus,
.dark textarea:focus { .dark textarea:focus {
border-color: rgba(139, 92, 246, 0.5); border-color: rgba(20, 184, 166, 0.5);
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.1); box-shadow: 0 0 0 3px rgba(20, 184, 166, 0.1);
} }
.dark input::placeholder, .dark input::placeholder,
.dark textarea::placeholder { .dark textarea::placeholder {
color: #706b8a; color: #637e78;
} }
.dark input:disabled, .dark input:disabled,
.dark select:disabled, .dark select:disabled,
.dark textarea:disabled { .dark textarea:disabled {
background-color: rgba(255, 255, 255, 0.02) !important; background-color: rgba(255, 255, 255, 0.02) !important;
color: #706b8a !important; color: #637e78 !important;
opacity: 0.6; opacity: 0.6;
} }
/* Dark select arrow */ /* Dark select arrow */
.dark select { .dark select {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23706b8a' d='M6 9L1 4h10z'/%3E%3C/svg%3E"); background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23637e78' d='M6 9L1 4h10z'/%3E%3C/svg%3E");
} }
/* ─── Cards — glass edges ────────────────────── */ /* ─── Cards — glass edges ────────────────────── */
.dark .card-hover { .dark .card-hover {
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.04), 0 2px 8px rgba(0, 0, 0, 0.3); box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.04);
} }
.dark .card-hover:hover { .dark .card-hover:hover {
box-shadow: 0 0 0 1px rgba(139, 92, 246, 0.15), 0 16px 48px -12px rgba(0, 0, 0, 0.5); box-shadow: 0 0 0 1px rgba(20, 184, 166, 0.12), 0 4px 16px -4px rgba(0, 0, 0, 0.4);
} }
.dark .section-card { .dark .section-card {
background: #1c1c2a; background: #162220;
border-color: rgba(255, 255, 255, 0.06); border-color: rgba(255, 255, 255, 0.06);
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.04); box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.04);
} }
.dark .section-card:hover { .dark .section-card:hover {
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.06), 0 8px 32px -8px rgba(0, 0, 0, 0.4); box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.06), 0 4px 16px -4px rgba(0, 0, 0, 0.3);
} }
.dark .section-card-header { .dark .section-card-header {
background: linear-gradient(180deg, rgba(36, 36, 58, 0.5) 0%, #1c1c2a 100%); background: rgba(30, 46, 43, 0.3);
} }
/* ─── Sidebar ────────────────────────────────── */ /* ─── Sidebar ────────────────────────────────── */
.dark .sidebar { .dark .sidebar {
background: linear-gradient(180deg, #0e0e16 0%, #0a0a12 100%); background: linear-gradient(180deg, #0a1412 0%, #060e0c 100%);
box-shadow: 2px 0 24px rgba(0, 0, 0, 0.5); box-shadow: 2px 0 24px rgba(0, 0, 0, 0.5);
} }
@@ -216,22 +214,22 @@
.dark .hover\:bg-red-50:hover { background-color: rgba(251, 113, 133, 0.08) !important; } .dark .hover\:bg-red-50:hover { background-color: rgba(251, 113, 133, 0.08) !important; }
.dark .hover\:bg-blue-100:hover { background-color: rgba(96, 165, 250, 0.08) !important; } .dark .hover\:bg-blue-100:hover { background-color: rgba(96, 165, 250, 0.08) !important; }
/* ─── Brand glow ─────────────────────────────── */ /* ─── Brand accent ────────────────────────────── */
.dark .bg-brand-primary { .dark .bg-brand-primary {
box-shadow: 0 0 24px -4px rgba(139, 92, 246, 0.35); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
} }
.dark .bg-brand-primary:hover { .dark .bg-brand-primary:hover {
box-shadow: 0 0 32px -4px rgba(139, 92, 246, 0.45); box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4);
} }
/* ─── White/light text overrides on colored badges ── */ /* ─── White/light text overrides on colored badges ── */
.dark .bg-white\/90 { background-color: rgba(28, 28, 42, 0.9) !important; } .dark .bg-white\/90 { background-color: rgba(22, 34, 32, 0.9) !important; }
/* ─── Toasts — solid backgrounds, no transparency ── */ /* ─── Toasts — solid backgrounds ────────────────── */
.dark .bg-emerald-50.border-emerald-200 { background-color: #132a1e !important; border-color: #1a4a2e !important; } .dark .bg-emerald-50.border-emerald-200 { background-color: #0f2a1e !important; border-color: #154a2e !important; }
.dark .bg-red-50.border-red-200 { background-color: #2a1318 !important; border-color: #4a1a22 !important; } .dark .bg-red-50.border-red-200 { background-color: #2a1315 !important; border-color: #4a1a20 !important; }
.dark .bg-blue-50.border-blue-200 { background-color: #131d2a !important; border-color: #1a2e4a !important; } .dark .bg-blue-50.border-blue-200 { background-color: #0f1d2a !important; border-color: #152e4a !important; }
.dark .bg-amber-50.border-amber-200 { background-color: #2a2213 !important; border-color: #4a3a1a !important; } .dark .bg-amber-50.border-amber-200 { background-color: #2a2210 !important; border-color: #4a3a15 !important; }
.dark .text-emerald-800 { color: #6ee7b7 !important; } .dark .text-emerald-800 { color: #6ee7b7 !important; }
.dark .text-red-800 { color: #fca5a5 !important; } .dark .text-red-800 { color: #fca5a5 !important; }
.dark .text-blue-800 { color: #93c5fd !important; } .dark .text-blue-800 { color: #93c5fd !important; }
@@ -239,10 +237,19 @@
/* ─── Selection ──────────────────────────────── */ /* ─── Selection ──────────────────────────────── */
.dark ::selection { .dark ::selection {
background: rgba(139, 92, 246, 0.4); background: rgba(20, 184, 166, 0.4);
color: white; color: white;
} }
/* Reduced motion — disable animations for accessibility */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* Custom scrollbar */ /* Custom scrollbar */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 6px; width: 6px;
@@ -315,15 +322,15 @@ textarea {
margin-right: 0; margin-right: 0;
} }
/* Enhanced sidebar with gradient */ /* Enhanced sidebar */
.sidebar { .sidebar {
background: linear-gradient(180deg, #0f172a 0%, #020617 100%); background: linear-gradient(180deg, #0a1f1c 0%, #061411 100%);
box-shadow: 2px 0 12px rgba(0, 0, 0, 0.08); box-shadow: 2px 0 12px rgba(0, 0, 0, 0.08);
} }
/* Animation keyframes */ /* Animation keyframes */
@keyframes fadeIn { @keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); } from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); } to { opacity: 1; transform: translateY(0); }
} }
@@ -347,11 +354,6 @@ textarea {
50% { opacity: 0.7; } 50% { opacity: 0.7; }
} }
@keyframes bounce-subtle {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-4px); }
}
@keyframes spin { @keyframes spin {
from { transform: rotate(0deg); } from { transform: rotate(0deg); }
to { transform: rotate(360deg); } to { transform: rotate(360deg); }
@@ -425,29 +427,24 @@ textarea {
overflow: visible; overflow: visible;
} }
/* Stagger children */ /* Stagger children — short, max 4 items */
.stagger-children > * { .stagger-children > * {
opacity: 0; opacity: 0;
animation: fadeIn 0.3s ease-out forwards; animation: fadeIn 0.2s ease-out forwards;
} }
.stagger-children > *:nth-child(1) { animation-delay: 0ms; } .stagger-children > *:nth-child(1) { animation-delay: 0ms; }
.stagger-children > *:nth-child(2) { animation-delay: 50ms; } .stagger-children > *:nth-child(2) { animation-delay: 40ms; }
.stagger-children > *:nth-child(3) { animation-delay: 100ms; } .stagger-children > *:nth-child(3) { animation-delay: 80ms; }
.stagger-children > *:nth-child(4) { animation-delay: 150ms; } .stagger-children > *:nth-child(n+4) { animation-delay: 120ms; }
.stagger-children > *:nth-child(5) { animation-delay: 200ms; }
.stagger-children > *:nth-child(6) { animation-delay: 250ms; }
.stagger-children > *:nth-child(7) { animation-delay: 300ms; }
.stagger-children > *:nth-child(8) { animation-delay: 350ms; }
/* Card hover effect - smooth and elegant */ /* Card hover effect - refined, no lift */
.card-hover { .card-hover {
position: relative; position: relative;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); transition: box-shadow 0.2s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05), 0 1px 2px rgba(0, 0, 0, 0.06); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
} }
.card-hover:hover { .card-hover:hover {
transform: translateY(-3px); box-shadow: 0 4px 12px -2px rgba(0, 0, 0, 0.08);
box-shadow: 0 12px 28px -6px rgba(0, 0, 0, 0.12), 0 6px 16px -8px rgba(0, 0, 0, 0.08);
} }
/* Stat card accents - subtle colored top borders */ /* Stat card accents - subtle colored top borders */
@@ -470,24 +467,12 @@ textarea {
opacity: 1; opacity: 1;
} }
/* Mesh background - subtle radial gradients */ /* Mesh background — flat, no gradients */
.bg-mesh { .bg-mesh {
background-color: #f8fafc; background-color: #f8fafc;
background-image:
radial-gradient(at 20% 20%, rgba(79, 70, 229, 0.04) 0, transparent 50%),
radial-gradient(at 80% 40%, rgba(219, 39, 119, 0.03) 0, transparent 50%),
radial-gradient(at 40% 80%, rgba(5, 150, 105, 0.03) 0, transparent 50%);
} }
/* Gradient text */ /* Stat card accent — subtle top border, no gradient */
.text-gradient {
background: linear-gradient(135deg, var(--color-brand-primary) 0%, #7c3aed 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Premium stat card - always-visible gradient top bar */
.stat-card-premium { .stat-card-premium {
position: relative; position: relative;
overflow: hidden; overflow: hidden;
@@ -498,20 +483,20 @@ textarea {
top: 0; top: 0;
left: 0; left: 0;
right: 0; right: 0;
height: 3px; height: 2px;
opacity: 1; opacity: 0.6;
} }
.stat-card-premium.accent-primary::before { .stat-card-premium.accent-primary::before {
background: linear-gradient(90deg, #4f46e5, #7c3aed); background: #0d9488;
} }
.stat-card-premium.accent-secondary::before { .stat-card-premium.accent-secondary::before {
background: linear-gradient(90deg, #db2777, #ec4899); background: #db2777;
} }
.stat-card-premium.accent-tertiary::before { .stat-card-premium.accent-tertiary::before {
background: linear-gradient(90deg, #f59e0b, #fbbf24); background: #f59e0b;
} }
.stat-card-premium.accent-quaternary::before { .stat-card-premium.accent-quaternary::before {
background: linear-gradient(90deg, #059669, #34d399); background: #059669;
} }
/* Section card - premium container */ /* Section card - premium container */
@@ -524,20 +509,19 @@ textarea {
transition: box-shadow 0.3s ease; transition: box-shadow 0.3s ease;
} }
.section-card:hover { .section-card:hover {
box-shadow: 0 4px 20px -4px rgba(0, 0, 0, 0.08); box-shadow: 0 2px 8px -2px rgba(0, 0, 0, 0.06);
} }
.section-card-header { .section-card-header {
padding: 1rem 1.25rem; padding: 1rem 1.25rem;
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
background: linear-gradient(180deg, rgba(249, 250, 251, 0.5) 0%, white 100%);
} }
/* Sidebar active glow */ /* Sidebar active glow */
.sidebar-active-glow { .sidebar-active-glow {
box-shadow: inset 3px 0 0 rgba(129, 140, 248, 0.8); box-shadow: inset 3px 0 0 rgba(20, 184, 166, 0.8);
} }
[dir="rtl"] .sidebar-active-glow { [dir="rtl"] .sidebar-active-glow {
box-shadow: inset -3px 0 0 rgba(129, 140, 248, 0.8); box-shadow: inset -3px 0 0 rgba(20, 184, 166, 0.8);
} }
/* Refined button styles */ /* Refined button styles */
@@ -594,23 +578,6 @@ select:not(:disabled):hover {
grid-template-columns: repeat(7, 1fr); grid-template-columns: repeat(7, 1fr);
} }
/* Ripple effect on buttons (optional enhancement) */
@keyframes ripple {
0% {
transform: scale(0);
opacity: 0.5;
}
100% {
transform: scale(2.5);
opacity: 0;
}
}
/* Badge pulse animation */
.badge-pulse {
animation: pulse-subtle 2s ease-in-out infinite;
}
/* Smooth height transitions */ /* Smooth height transitions */
.transition-height { .transition-height {
transition: height 0.3s cubic-bezier(0.4, 0, 0.2, 1); transition: height 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+17 -21
View File
@@ -199,8 +199,8 @@ export default function Artefacts() {
const SortIcon = ({ col }) => { const SortIcon = ({ col }) => {
if (listSortBy !== col) return null if (listSortBy !== col) return null
return listSortDir === 'asc' return listSortDir === 'asc'
? <ChevronUp className="w-3 h-3 inline ml-0.5" /> ? <ChevronUp className="w-3 h-3 inline ms-0.5" />
: <ChevronDown className="w-3 h-3 inline ml-0.5" /> : <ChevronDown className="w-3 h-3 inline ms-0.5" />
} }
const formatDate = (dateStr) => { const formatDate = (dateStr) => {
@@ -211,11 +211,7 @@ export default function Artefacts() {
return ( return (
<div className="space-y-4 animate-fade-in"> <div className="space-y-4 animate-fade-in">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-end">
<div>
<h1 className="text-2xl font-bold text-text-primary">{t('artefacts.title')}</h1>
<p className="text-sm text-text-secondary mt-1">{t('artefacts.subtitle')}</p>
</div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{/* View switcher */} {/* View switcher */}
<div className="flex items-center bg-surface-tertiary rounded-lg p-0.5"> <div className="flex items-center bg-surface-tertiary rounded-lg p-0.5">
@@ -228,7 +224,7 @@ export default function Artefacts() {
onClick={() => setViewMode(mode)} onClick={() => setViewMode(mode)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${ className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
viewMode === mode viewMode === mode
? 'bg-white text-text-primary shadow-sm' ? 'bg-surface text-text-primary shadow-sm'
: 'text-text-tertiary hover:text-text-secondary' : 'text-text-tertiary hover:text-text-secondary'
}`} }`}
> >
@@ -251,13 +247,13 @@ export default function Artefacts() {
{/* Filters */} {/* Filters */}
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<div className="relative flex-1 min-w-[200px] max-w-md"> <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" /> <Search className="absolute start-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<input <input
type="text" type="text"
placeholder={t('artefacts.searchArtefacts')} placeholder={t('artefacts.searchArtefacts')}
value={searchTerm} value={searchTerm}
onChange={e => setSearchTerm(e.target.value)} 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-surface transition-colors" className="w-full ps-10 pe-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-surface transition-colors"
/> />
</div> </div>
@@ -351,7 +347,7 @@ export default function Artefacts() {
<button <button
key={artefact.Id} key={artefact.Id}
onClick={() => setSelectedArtefact(artefact)} onClick={() => setSelectedArtefact(artefact)}
className="bg-surface border border-border rounded-xl p-4 hover:border-brand-primary/30 hover:shadow-sm transition-colors text-left" className="bg-surface border border-border rounded-xl p-4 hover:border-brand-primary/30 hover:shadow-sm transition-colors text-start"
> >
<div className="flex items-start gap-3 mb-2"> <div className="flex items-start gap-3 mb-2">
<div className="w-10 h-10 rounded-lg bg-brand-primary/10 flex items-center justify-center shrink-0"> <div className="w-10 h-10 rounded-lg bg-brand-primary/10 flex items-center justify-center shrink-0">
@@ -418,22 +414,22 @@ export default function Artefacts() {
<th className="w-10 px-3 py-3" onClick={e => e.stopPropagation()}> <th className="w-10 px-3 py-3" onClick={e => e.stopPropagation()}>
<input type="checkbox" checked={selectedIds.size === sortedArtefacts.length && sortedArtefacts.length > 0} onChange={toggleSelectAll} className="rounded border-border" /> <input type="checkbox" checked={selectedIds.size === sortedArtefacts.length && sortedArtefacts.length > 0} onChange={toggleSelectAll} className="rounded border-border" />
</th> </th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleListSort('title')}> <th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleListSort('title')}>
{t('artefacts.titleLabel')} <SortIcon col="title" /> {t('artefacts.titleLabel')} <SortIcon col="title" />
</th> </th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleListSort('type')}> <th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleListSort('type')}>
{t('artefacts.type')} <SortIcon col="type" /> {t('artefacts.type')} <SortIcon col="type" />
</th> </th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleListSort('status')}> <th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleListSort('status')}>
{t('artefacts.status')} <SortIcon col="status" /> {t('artefacts.status')} <SortIcon col="status" />
</th> </th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('artefacts.brand')}</th> <th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('artefacts.brand')}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('artefacts.project')}</th> <th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('artefacts.project')}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('artefacts.campaign')}</th> <th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('artefacts.campaign')}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('artefacts.creator')}</th> <th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('artefacts.creator')}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('artefacts.approvers')}</th> <th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('artefacts.approvers')}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('artefacts.version')}</th> <th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('artefacts.version')}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleListSort('updated_at')}> <th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleListSort('updated_at')}>
{t('artefacts.updated')} <SortIcon col="updated_at" /> {t('artefacts.updated')} <SortIcon col="updated_at" />
</th> </th>
</tr> </tr>
+8 -8
View File
@@ -181,20 +181,20 @@ export default function Assets() {
{/* Toolbar */} {/* Toolbar */}
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<div className="relative flex-1 min-w-[200px] max-w-md"> <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" /> <Search className="absolute start-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<input <input
type="text" type="text"
placeholder="Search assets..." placeholder="Search assets..."
value={filters.search} value={filters.search}
onChange={e => setFilters(f => ({ ...f, search: e.target.value }))} 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" className="w-full ps-10 pe-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-surface"
/> />
</div> </div>
<select <select
value={filters.brand} value={filters.brand}
onChange={e => setFilters(f => ({ ...f, brand: e.target.value }))} 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" className="text-sm border border-border rounded-lg px-3 py-2 bg-surface text-text-secondary focus:outline-none"
> >
<option value="">All Brands</option> <option value="">All Brands</option>
{brands.map(b => <option key={b} value={b}>{b}</option>)} {brands.map(b => <option key={b} value={b}>{b}</option>)}
@@ -203,7 +203,7 @@ export default function Assets() {
<select <select
value={filters.tag} value={filters.tag}
onChange={e => setFilters(f => ({ ...f, tag: e.target.value }))} 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" className="text-sm border border-border rounded-lg px-3 py-2 bg-surface text-text-secondary focus:outline-none"
> >
<option value="">All Tags</option> <option value="">All Tags</option>
{allTags.map(t => <option key={t} value={t}>{t}</option>)} {allTags.map(t => <option key={t} value={t}>{t}</option>)}
@@ -211,7 +211,7 @@ export default function Assets() {
<button <button
onClick={() => setShowUpload(true)} 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" 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 ms-auto"
> >
<Upload className="w-4 h-4" /> <Upload className="w-4 h-4" />
Upload Upload
@@ -260,7 +260,7 @@ export default function Assets() {
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4 stagger-children"> <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4 stagger-children">
{filteredAssets.map(asset => ( {filteredAssets.map(asset => (
<div key={asset._id || asset.id} className="relative"> <div key={asset._id || asset.id} className="relative">
<div className="absolute top-2 left-2 z-10" onClick={e => e.stopPropagation()}> <div className="absolute top-2 start-2 z-10" onClick={e => e.stopPropagation()}>
<input type="checkbox" checked={selectedIds.has(asset._id || asset.id)} onChange={() => toggleSelect(asset._id || asset.id)} className="rounded border-border" /> <input type="checkbox" checked={selectedIds.has(asset._id || asset.id)} onChange={() => toggleSelect(asset._id || asset.id)} className="rounded border-border" />
</div> </div>
<AssetCard asset={asset} onClick={setSelectedAsset} /> <AssetCard asset={asset} onClick={setSelectedAsset} />
@@ -319,7 +319,7 @@ export default function Assets() {
<div className="space-y-4"> <div className="space-y-4">
{selectedAsset.type === 'image' && selectedAsset.url && ( {selectedAsset.type === 'image' && selectedAsset.url && (
<div className="rounded-lg overflow-hidden bg-surface-tertiary"> <div className="rounded-lg overflow-hidden bg-surface-tertiary">
<img src={selectedAsset.url} alt={selectedAsset.name} className="w-full max-h-[400px] object-contain" /> <img src={selectedAsset.url} alt={selectedAsset.name} className="w-full max-h-[400px] object-contain" loading="lazy" />
</div> </div>
)} )}
{selectedAsset.type === 'video' && selectedAsset.url && ( {selectedAsset.type === 'video' && selectedAsset.url && (
@@ -374,7 +374,7 @@ export default function Assets() {
download={selectedAsset.name} download={selectedAsset.name}
target="_blank" target="_blank"
rel="noopener noreferrer" 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" className="ms-auto px-4 py-2 text-sm font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light"
> >
Download Download
</a> </a>
+7 -5
View File
@@ -143,7 +143,7 @@ export default function Brands() {
{/* Brand Cards Grid */} {/* Brand Cards Grid */}
{brands.length === 0 ? ( {brands.length === 0 ? (
<div className="bg-white rounded-xl border border-border py-16 text-center"> <div className="bg-surface rounded-xl border border-border py-16 text-center">
<Tag className="w-12 h-12 text-text-quaternary mx-auto mb-3" /> <Tag className="w-12 h-12 text-text-quaternary mx-auto mb-3" />
<p className="text-sm text-text-tertiary">{t('brands.noBrands')}</p> <p className="text-sm text-text-tertiary">{t('brands.noBrands')}</p>
</div> </div>
@@ -154,7 +154,7 @@ export default function Brands() {
return ( return (
<div <div
key={getBrandId(brand)} key={getBrandId(brand)}
className={`bg-white rounded-xl border border-border overflow-hidden hover:shadow-md transition-all aspect-square flex flex-col ${isSuperadminOrManager ? 'cursor-pointer' : ''}`} className={`bg-surface rounded-xl border border-border overflow-hidden hover:shadow-md transition-all aspect-square flex flex-col ${isSuperadminOrManager ? 'cursor-pointer' : ''}`}
onClick={() => isSuperadminOrManager && openEditBrand(brand)} onClick={() => isSuperadminOrManager && openEditBrand(brand)}
> >
{/* Logo area */} {/* Logo area */}
@@ -164,6 +164,7 @@ export default function Brands() {
src={`${API_BASE}/uploads/${brand.logo}`} src={`${API_BASE}/uploads/${brand.logo}`}
alt={displayName} alt={displayName}
className="w-full h-full object-contain p-4" className="w-full h-full object-contain p-4"
loading="lazy"
/> />
) : ( ) : (
<div className="text-3xl"> <div className="text-3xl">
@@ -171,17 +172,17 @@ export default function Brands() {
</div> </div>
)} )}
{isSuperadminOrManager && ( {isSuperadminOrManager && (
<div className="absolute top-1.5 right-1.5 flex gap-1 opacity-0 group-hover:opacity-100" onClick={e => e.stopPropagation()}> <div className="absolute top-1.5 end-1.5 flex gap-1 opacity-0 group-hover:opacity-100" onClick={e => e.stopPropagation()}>
<button <button
onClick={() => openEditBrand(brand)} onClick={() => openEditBrand(brand)}
className="p-1 bg-white/90 hover:bg-white rounded-md text-text-tertiary hover:text-text-primary shadow-sm" className="p-1 bg-white/90 hover:bg-surface rounded-md text-text-tertiary hover:text-text-primary shadow-sm"
title={t('common.edit')} title={t('common.edit')}
> >
<Edit2 className="w-3 h-3" /> <Edit2 className="w-3 h-3" />
</button> </button>
<button <button
onClick={() => { setBrandToDelete(brand); setShowDeleteModal(true) }} onClick={() => { setBrandToDelete(brand); setShowDeleteModal(true) }}
className="p-1 bg-white/90 hover:bg-white rounded-md text-text-tertiary hover:text-red-500 shadow-sm" className="p-1 bg-white/90 hover:bg-surface rounded-md text-text-tertiary hover:text-red-500 shadow-sm"
title={t('common.delete')} title={t('common.delete')}
> >
<Trash2 className="w-3 h-3" /> <Trash2 className="w-3 h-3" />
@@ -269,6 +270,7 @@ export default function Brands() {
src={`${API_BASE}/uploads/${editingBrand.logo}`} src={`${API_BASE}/uploads/${editingBrand.logo}`}
alt="Logo" alt="Logo"
className="h-16 object-contain" className="h-16 object-contain"
loading="lazy"
/> />
</div> </div>
)} )}
+16 -20
View File
@@ -153,11 +153,7 @@ export default function Budgets() {
return ( return (
<div className="space-y-6 animate-fade-in"> <div className="space-y-6 animate-fade-in">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-end">
<div>
<h1 className="text-2xl font-bold text-text-primary">{t('budgets.title')}</h1>
<p className="text-sm text-text-tertiary mt-0.5">{t('budgets.subtitle')}</p>
</div>
{canManageFinance && ( {canManageFinance && (
<button <button
onClick={() => { setEditing(null); setForm(EMPTY_ENTRY); setShowModal(true) }} onClick={() => { setEditing(null); setForm(EMPTY_ENTRY); setShowModal(true) }}
@@ -171,19 +167,19 @@ export default function Budgets() {
{/* Filters */} {/* Filters */}
<div className="flex items-center gap-3 flex-wrap"> <div className="flex items-center gap-3 flex-wrap">
<div className="relative flex-1 min-w-[200px] max-w-xs"> <div className="relative flex-1 min-w-[200px] max-w-xs">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" /> <Search className="absolute start-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<input <input
type="text" type="text"
value={searchQuery} value={searchQuery}
onChange={e => setSearchQuery(e.target.value)} onChange={e => setSearchQuery(e.target.value)}
placeholder={t('budgets.searchEntries')} placeholder={t('budgets.searchEntries')}
className="w-full pl-9 pr-3 py-2 text-sm border border-border rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-brand-primary/20" className="w-full ps-9 pe-3 py-2 text-sm border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
/> />
</div> </div>
<select <select
value={filterCategory} value={filterCategory}
onChange={e => setFilterCategory(e.target.value)} onChange={e => setFilterCategory(e.target.value)}
className="px-3 py-2 text-sm border border-border rounded-lg bg-white focus:outline-none" className="px-3 py-2 text-sm border border-border rounded-lg bg-surface focus:outline-none"
> >
<option value="">{t('budgets.allCategories')}</option> <option value="">{t('budgets.allCategories')}</option>
{CATEGORIES.map(c => <option key={c.value} value={c.value}>{c.label}</option>)} {CATEGORIES.map(c => <option key={c.value} value={c.value}>{c.label}</option>)}
@@ -191,7 +187,7 @@ export default function Budgets() {
<select <select
value={filterDestination} value={filterDestination}
onChange={e => setFilterDestination(e.target.value)} onChange={e => setFilterDestination(e.target.value)}
className="px-3 py-2 text-sm border border-border rounded-lg bg-white focus:outline-none" className="px-3 py-2 text-sm border border-border rounded-lg bg-surface focus:outline-none"
> >
<option value="">{t('budgets.allDestinations')}</option> <option value="">{t('budgets.allDestinations')}</option>
{DESTINATIONS.map(d => <option key={d.value} value={d.value}>{t(d.labelKey)}</option>)} {DESTINATIONS.map(d => <option key={d.value} value={d.value}>{t(d.labelKey)}</option>)}
@@ -206,7 +202,7 @@ export default function Budgets() {
className={`px-3 py-1.5 text-xs font-medium transition-colors ${ className={`px-3 py-1.5 text-xs font-medium transition-colors ${
filterType === opt.value filterType === opt.value
? opt.value === 'expense' ? 'bg-red-500 text-white' : opt.value === 'income' ? 'bg-emerald-500 text-white' : 'bg-brand-primary text-white' ? opt.value === 'expense' ? 'bg-red-500 text-white' : opt.value === 'income' ? 'bg-emerald-500 text-white' : 'bg-brand-primary text-white'
: 'bg-white text-text-secondary hover:bg-surface-secondary' : 'bg-surface text-text-secondary hover:bg-surface-secondary'
}`} }`}
> >
{opt.label} {opt.label}
@@ -215,7 +211,7 @@ export default function Budgets() {
</div> </div>
{filteredEntries.length > 0 && ( {filteredEntries.length > 0 && (
<div className="ml-auto flex items-center gap-3 text-sm text-text-tertiary"> <div className="ms-auto flex items-center gap-3 text-sm text-text-tertiary">
<span>{filteredEntries.length} {filteredEntries.length === 1 ? 'entry' : 'entries'}</span> <span>{filteredEntries.length} {filteredEntries.length === 1 ? 'entry' : 'entries'}</span>
<span className="text-emerald-600 font-semibold">+{totalIncome.toLocaleString()}</span> <span className="text-emerald-600 font-semibold">+{totalIncome.toLocaleString()}</span>
{totalExpenseAmt > 0 && <span className="text-red-500 font-semibold">-{totalExpenseAmt.toLocaleString()}</span>} {totalExpenseAmt > 0 && <span className="text-red-500 font-semibold">-{totalExpenseAmt.toLocaleString()}</span>}
@@ -235,12 +231,12 @@ export default function Budgets() {
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b border-border bg-surface-secondary"> <tr className="border-b border-border bg-surface-secondary">
<th className="px-4 py-3 text-left text-xs font-medium text-text-tertiary">{t('budgets.label')}</th> <th className="px-4 py-3 text-start text-xs font-medium text-text-tertiary">{t('budgets.label')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-text-tertiary">{t('budgets.source')}</th> <th className="px-4 py-3 text-start text-xs font-medium text-text-tertiary">{t('budgets.source')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-text-tertiary">{t('budgets.destination')}</th> <th className="px-4 py-3 text-start text-xs font-medium text-text-tertiary">{t('budgets.destination')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-text-tertiary">{t('budgets.linkedTo')}</th> <th className="px-4 py-3 text-start text-xs font-medium text-text-tertiary">{t('budgets.linkedTo')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-text-tertiary">{t('budgets.date')}</th> <th className="px-4 py-3 text-start text-xs font-medium text-text-tertiary">{t('budgets.date')}</th>
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">{t('budgets.amount')}</th> <th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">{t('budgets.amount')}</th>
{canManageFinance && <th className="px-4 py-3 w-20" />} {canManageFinance && <th className="px-4 py-3 w-20" />}
</tr> </tr>
</thead> </thead>
@@ -289,7 +285,7 @@ export default function Budgets() {
<td className="px-4 py-3 text-text-secondary whitespace-nowrap"> <td className="px-4 py-3 text-text-secondary whitespace-nowrap">
{entry.date_received ? format(new Date(entry.date_received), 'MMM d, yyyy') : '--'} {entry.date_received ? format(new Date(entry.date_received), 'MMM d, yyyy') : '--'}
</td> </td>
<td className={`px-4 py-3 text-right font-semibold whitespace-nowrap ${ <td className={`px-4 py-3 text-end font-semibold whitespace-nowrap ${
(entry.type || 'income') === 'expense' ? 'text-red-500' : 'text-emerald-600' (entry.type || 'income') === 'expense' ? 'text-red-500' : 'text-emerald-600'
}`}> }`}>
{(entry.type || 'income') === 'expense' ? '-' : '+'}{Number(entry.amount).toLocaleString()} {currencySymbol} {(entry.type || 'income') === 'expense' ? '-' : '+'}{Number(entry.amount).toLocaleString()} {currencySymbol}
@@ -332,7 +328,7 @@ export default function Budgets() {
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium border-2 transition-all ${ className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium border-2 transition-all ${
form.type === 'income' form.type === 'income'
? 'border-emerald-500 bg-emerald-50 text-emerald-700' ? 'border-emerald-500 bg-emerald-50 text-emerald-700'
: 'border-border bg-white text-text-secondary hover:bg-surface-secondary' : 'border-border bg-surface text-text-secondary hover:bg-surface-secondary'
}`} }`}
> >
<TrendingUp className="w-4 h-4" /> <TrendingUp className="w-4 h-4" />
@@ -344,7 +340,7 @@ export default function Budgets() {
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium border-2 transition-all ${ className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium border-2 transition-all ${
form.type === 'expense' form.type === 'expense'
? 'border-red-500 bg-red-50 text-red-700' ? 'border-red-500 bg-red-50 text-red-700'
: 'border-border bg-white text-text-secondary hover:bg-surface-secondary' : 'border-border bg-surface text-text-secondary hover:bg-surface-secondary'
}`} }`}
> >
<TrendingDown className="w-4 h-4" /> <TrendingDown className="w-4 h-4" />
+67 -96
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useContext } from 'react' import { useState, useEffect, useContext } from 'react'
import { useParams, useNavigate } from 'react-router-dom' import { useParams, useNavigate } from 'react-router-dom'
import { ArrowLeft, Plus, Edit2, Trash2, DollarSign, Eye, MousePointer, Target, TrendingUp, FileText, Megaphone, Search, Globe, Pencil, Users, X, UserPlus, MessageCircle, Settings } from 'lucide-react' import { ArrowLeft, Plus, Edit2, Trash2, DollarSign, Eye, MousePointer, Target, TrendingUp, FileText, Megaphone, Search, Globe, Users, X, MessageCircle, Settings } from 'lucide-react'
import { format } from 'date-fns' import { format } from 'date-fns'
import { AppContext } from '../App' import { AppContext } from '../App'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
@@ -26,21 +26,11 @@ const TRACK_TYPES = {
const TRACK_STATUSES = ['planned', 'active', 'paused', 'completed'] const TRACK_STATUSES = ['planned', 'active', 'paused', 'completed']
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() { export default function CampaignDetail() {
const { id } = useParams() const { id } = useParams()
const navigate = useNavigate() const navigate = useNavigate()
const { brands, getBrandName, teamMembers } = useContext(AppContext) const { brands, getBrandName, teamMembers } = useContext(AppContext)
const { lang, currencySymbol } = useLanguage() const { t, lang, currencySymbol } = useLanguage()
const { permissions, user } = useAuth() const { permissions, user } = useAuth()
const isSuperadmin = user?.role === 'superadmin' const isSuperadmin = user?.role === 'superadmin'
const [campaign, setCampaign] = useState(null) const [campaign, setCampaign] = useState(null)
@@ -211,7 +201,7 @@ export default function CampaignDetail() {
if (!campaign) { if (!campaign) {
return ( return (
<div className="text-center py-12 text-text-tertiary"> <div className="text-center py-12 text-text-tertiary">
Campaign not found. <button onClick={() => navigate('/campaigns')} className="text-brand-primary underline">Go back</button> {t('campaigns.notFound')} <button onClick={() => navigate('/campaigns')} className="text-brand-primary underline">{t('common.goBack')}</button>
</div> </div>
) )
} }
@@ -244,9 +234,6 @@ export default function CampaignDetail() {
{campaign.start_date && campaign.end_date && ( {campaign.start_date && campaign.end_date && (
<span>{format(new Date(campaign.start_date), 'MMM d')} {format(new Date(campaign.end_date), 'MMM d, yyyy')}</span> <span>{format(new Date(campaign.start_date), 'MMM d')} {format(new Date(campaign.end_date), 'MMM d, yyyy')}</span>
)} )}
<span>
Budget: {campaign.budget > 0 ? `${campaign.budget.toLocaleString()} ${currencySymbol}` : 'Not set'}
</span>
{campaign.platforms && campaign.platforms.length > 0 && ( {campaign.platforms && campaign.platforms.length > 0 && (
<PlatformIcons platforms={campaign.platforms} size={16} /> <PlatformIcons platforms={campaign.platforms} size={16} />
)} )}
@@ -263,109 +250,73 @@ export default function CampaignDetail() {
}`} }`}
> >
<MessageCircle className="w-4 h-4" /> <MessageCircle className="w-4 h-4" />
Discussion {t('campaigns.discussion')}
</button> </button>
{canSetBudget && (
<button
onClick={() => { setBudgetValue(campaign.budget || ''); setEditingBudget(true) }}
className="flex items-center gap-1.5 px-3 py-2 bg-surface-tertiary text-text-secondary hover:bg-surface-tertiary/80 hover:text-text-primary rounded-lg text-sm font-medium transition-colors"
>
<DollarSign className="w-4 h-4" />
Budget
</button>
)}
{canManage && ( {canManage && (
<button <button
onClick={() => setPanelCampaign(campaign)} onClick={() => setPanelCampaign(campaign)}
className="flex items-center gap-1.5 px-3 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm transition-colors" className="flex items-center gap-1.5 px-3 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm transition-colors"
> >
<Settings className="w-4 h-4" /> <Settings className="w-4 h-4" />
Edit {t('common.edit')}
</button> </button>
)} )}
</div> </div>
</div> </div>
{/* Assigned Team */} {/* Budget Card */}
<div className="bg-white rounded-xl border border-border p-5"> <div className="bg-surface rounded-xl border border-border p-5">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium flex items-center gap-1.5"> <h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium">{t('campaigns.budget')}</h3>
<Users className="w-3.5 h-3.5" /> Assigned Team {canSetBudget && (
</h3> <button onClick={() => { setBudgetValue(campaign.budget || ''); setEditingBudget(true) }}
{canAssign && ( className="text-xs text-brand-primary hover:text-brand-primary-light font-medium">
<button {t('common.edit')}
onClick={openAssignModal}
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"
>
<UserPlus className="w-3.5 h-3.5" /> Assign Members
</button> </button>
)} )}
</div> </div>
{assignments.length === 0 ? ( <div className="flex items-baseline gap-2 mb-3">
<p className="text-xs text-text-tertiary py-2">No team members assigned yet.</p> <span className="text-2xl font-bold text-text-primary">
) : ( {totalAllocated.toLocaleString()} {currencySymbol}
<div className="flex flex-wrap gap-2"> </span>
{assignments.map(a => ( <span className="text-sm text-text-tertiary">{t('finance.allocated')}</span>
<div key={a.user_id} className="flex items-center gap-2 bg-surface-secondary rounded-full pl-1 pr-2 py-1"> </div>
<div className="w-6 h-6 rounded-full bg-brand-primary/10 text-brand-primary flex items-center justify-center text-[10px] font-bold shrink-0"> {totalAllocated > 0 && (
{a.user_avatar ? ( <>
<img src={a.user_avatar} className="w-full h-full rounded-full object-cover" alt="" /> <BudgetBar budget={totalAllocated} spent={totalSpent} height="h-2.5" />
) : ( <div className="flex justify-between mt-2 text-xs text-text-tertiary">
getInitials(a.user_name) <span>{totalSpent.toLocaleString()} {currencySymbol} {t('dashboard.spent')}</span>
)} <span>{(totalAllocated - totalSpent).toLocaleString()} {currencySymbol} {t('dashboard.remaining')}</span>
</div> </div>
<span className="text-xs font-medium text-text-primary">{a.user_name}</span> </>
{canAssign && ( )}
<button {(totalImpressions > 0 || totalClicks > 0) && (
onClick={() => removeAssignment(a.user_id)} <div className="flex items-center gap-4 mt-4 pt-3 border-t border-border-light text-xs text-text-secondary">
className="p-0.5 rounded-full hover:bg-red-100 text-text-tertiary hover:text-red-500" <span><Eye className="w-3.5 h-3.5 inline me-1" />{totalImpressions.toLocaleString()}</span>
> <span><MousePointer className="w-3.5 h-3.5 inline me-1" />{totalClicks.toLocaleString()}</span>
<X className="w-3 h-3" /> {totalConversions > 0 && <span><Target className="w-3.5 h-3.5 inline me-1" />{totalConversions.toLocaleString()}</span>}
</button> {totalRevenue > 0 && <span><DollarSign className="w-3.5 h-3.5 inline me-1" />{totalRevenue.toLocaleString()} {currencySymbol}</span>}
)}
</div>
))}
</div> </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} height="h-2" />
</div>
)}
</div>
)}
{/* Tracks */} {/* Tracks */}
<div className="bg-white rounded-xl border border-border overflow-hidden"> <div className="bg-surface rounded-xl border border-border overflow-hidden">
<div className="flex items-center justify-between px-5 py-4 border-b border-border"> <div className="flex items-center justify-between px-5 py-4 border-b border-border">
<h3 className="font-semibold text-text-primary">Tracks</h3> <h3 className="font-semibold text-text-primary">{t('campaigns.tracks')}</h3>
{canManage && ( {canManage && (
<button <button
onClick={() => { setPanelTrack({}); setTrackScrollToMetrics(false) }} onClick={() => { setPanelTrack({}); setTrackScrollToMetrics(false) }}
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" 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 <Plus className="w-3.5 h-3.5" /> {t('campaigns.addTrack')}
</button> </button>
)} )}
</div> </div>
{tracks.length === 0 ? ( {tracks.length === 0 ? (
<div className="py-12 text-center text-sm text-text-tertiary"> <div className="py-12 text-center text-sm text-text-tertiary">
No tracks yet. Add organic, paid, or SEO tracks to organize this campaign. {t('campaigns.noTracks')}
</div> </div>
) : ( ) : (
<div className="divide-y divide-border-light"> <div className="divide-y divide-border-light">
@@ -403,9 +354,9 @@ export default function CampaignDetail() {
{/* Quick metrics */} {/* Quick metrics */}
{(track.impressions > 0 || track.clicks > 0 || track.conversions > 0) && ( {(track.impressions > 0 || track.clicks > 0 || track.conversions > 0) && (
<div className="flex items-center gap-3 mt-1.5 text-[10px] text-text-tertiary"> <div className="flex items-center gap-3 mt-1.5 text-[10px] text-text-tertiary">
{track.impressions > 0 && <span>👁 {track.impressions.toLocaleString()}</span>} {track.impressions > 0 && <span><Eye className="w-3 h-3 inline" /> {track.impressions.toLocaleString()}</span>}
{track.clicks > 0 && <span>🖱 {track.clicks.toLocaleString()}</span>} {track.clicks > 0 && <span><MousePointer className="w-3 h-3 inline" /> {track.clicks.toLocaleString()}</span>}
{track.conversions > 0 && <span>🎯 {track.conversions.toLocaleString()}</span>} {track.conversions > 0 && <span><Target className="w-3 h-3 inline" /> {track.conversions.toLocaleString()}</span>}
{track.clicks > 0 && track.budget_spent > 0 && ( {track.clicks > 0 && track.budget_spent > 0 && (
<span>CPC: {(track.budget_spent / track.clicks).toFixed(2)} {currencySymbol}</span> <span>CPC: {(track.budget_spent / track.clicks).toFixed(2)} {currencySymbol}</span>
)} )}
@@ -418,7 +369,7 @@ export default function CampaignDetail() {
{/* Linked posts count */} {/* Linked posts count */}
{trackPosts.length > 0 && ( {trackPosts.length > 0 && (
<div className="text-[10px] text-text-tertiary mt-1"> <div className="text-[10px] text-text-tertiary mt-1">
📝 {trackPosts.length} post{trackPosts.length !== 1 ? 's' : ''} linked <FileText className="w-3 h-3 inline" /> {trackPosts.length} {t('campaigns.postsLinked')}
</div> </div>
)} )}
@@ -461,11 +412,31 @@ export default function CampaignDetail() {
)} )}
</div> </div>
{/* Team */}
{(assignments.length > 0 || canAssign) && (
<div className="flex items-center gap-3">
<span className="text-xs text-text-tertiary font-medium">{t('campaigns.team')}:</span>
<div className="flex -space-x-1.5">
{assignments.slice(0, 6).map(a => (
<div key={a.user_id} className="w-7 h-7 rounded-full bg-brand-primary/10 text-brand-primary flex items-center justify-center text-[10px] font-bold border-2 border-surface" title={a.user_name}>
{a.user_avatar ? <img src={a.user_avatar} className="w-full h-full rounded-full object-cover" alt="" loading="lazy" /> : getInitials(a.user_name)}
</div>
))}
{assignments.length > 6 && <div className="w-7 h-7 rounded-full bg-surface-tertiary flex items-center justify-center text-[10px] text-text-tertiary font-medium border-2 border-surface">+{assignments.length - 6}</div>}
</div>
{canAssign && (
<button onClick={openAssignModal} className="text-xs text-brand-primary hover:text-brand-primary-light font-medium">
{t('campaigns.assignMembers')}
</button>
)}
</div>
)}
{/* Linked Posts */} {/* Linked Posts */}
{posts.length > 0 && ( {posts.length > 0 && (
<div className="bg-white rounded-xl border border-border overflow-hidden"> <div className="bg-surface rounded-xl border border-border overflow-hidden">
<div className="px-5 py-4 border-b border-border"> <div className="px-5 py-4 border-b border-border">
<h3 className="font-semibold text-text-primary">Linked Posts ({posts.length})</h3> <h3 className="font-semibold text-text-primary">{t('campaigns.linkedPosts')} ({posts.length})</h3>
</div> </div>
<div className="divide-y divide-border-light"> <div className="divide-y divide-border-light">
{posts.map(post => ( {posts.map(post => (
@@ -475,7 +446,7 @@ export default function CampaignDetail() {
className="flex items-center gap-3 px-5 py-3 hover:bg-surface-secondary cursor-pointer transition-colors" className="flex items-center gap-3 px-5 py-3 hover:bg-surface-secondary cursor-pointer transition-colors"
> >
{post.thumbnail_url && ( {post.thumbnail_url && (
<img src={post.thumbnail_url} alt="" className="w-10 h-10 rounded-lg object-cover shrink-0" /> <img src={post.thumbnail_url} alt="" className="w-10 h-10 rounded-lg object-cover shrink-0" loading="lazy" />
)} )}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -501,11 +472,11 @@ export default function CampaignDetail() {
{/* ─── DISCUSSION SIDEBAR ─── */} {/* ─── DISCUSSION SIDEBAR ─── */}
{showDiscussion && ( {showDiscussion && (
<div className="w-[340px] shrink-0 bg-white rounded-xl border border-border flex flex-col self-start sticky top-4" style={{ maxHeight: 'calc(100vh - 6rem)' }}> <div className="w-[340px] shrink-0 bg-surface rounded-xl border border-border flex flex-col self-start sticky top-4" style={{ maxHeight: 'calc(100vh - 6rem)' }}>
<div className="flex items-center justify-between px-4 py-3 border-b border-border"> <div className="flex items-center justify-between px-4 py-3 border-b border-border">
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-1.5"> <h3 className="text-sm font-semibold text-text-primary flex items-center gap-1.5">
<MessageCircle className="w-4 h-4" /> <MessageCircle className="w-4 h-4" />
Discussion {t('campaigns.discussion')}
</h3> </h3>
<button onClick={() => setShowDiscussion(false)} className="p-1 hover:bg-surface-tertiary rounded-lg text-text-tertiary"> <button onClick={() => setShowDiscussion(false)} className="p-1 hover:bg-surface-tertiary rounded-lg text-text-tertiary">
<X className="w-4 h-4" /> <X className="w-4 h-4" />
@@ -557,7 +528,7 @@ export default function CampaignDetail() {
/> />
<div className="w-7 h-7 rounded-full bg-brand-primary/10 text-brand-primary flex items-center justify-center text-[10px] font-bold shrink-0"> <div className="w-7 h-7 rounded-full bg-brand-primary/10 text-brand-primary flex items-center justify-center text-[10px] font-bold shrink-0">
{u.avatar ? ( {u.avatar ? (
<img src={u.avatar} className="w-full h-full rounded-full object-cover" alt="" /> <img src={u.avatar} className="w-full h-full rounded-full object-cover" alt="" loading="lazy" />
) : ( ) : (
getInitials(u.name) getInitials(u.name)
)} )}
+11 -11
View File
@@ -145,7 +145,7 @@ export default function Campaigns() {
<select <select
value={filters.brand} value={filters.brand}
onChange={e => setFilters(f => ({ ...f, brand: e.target.value }))} 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" className="text-sm border border-border rounded-lg px-3 py-2 bg-surface text-text-secondary focus:outline-none"
> >
<option value="">All Brands</option> <option value="">All Brands</option>
{brands.map(b => <option key={b.id || b._id} value={b.id || b._id}>{lang === 'ar' && b.name_ar ? b.name_ar : b.name}</option>)} {brands.map(b => <option key={b.id || b._id} value={b.id || b._id}>{lang === 'ar' && b.name_ar ? b.name_ar : b.name}</option>)}
@@ -154,7 +154,7 @@ export default function Campaigns() {
<select <select
value={filters.status} value={filters.status}
onChange={e => setFilters(f => ({ ...f, status: e.target.value }))} 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" className="text-sm border border-border rounded-lg px-3 py-2 bg-surface text-text-secondary focus:outline-none"
> >
<option value="">All Statuses</option> <option value="">All Statuses</option>
<option value="planning">Planning</option> <option value="planning">Planning</option>
@@ -167,7 +167,7 @@ export default function Campaigns() {
{permissions?.canCreateCampaigns && ( {permissions?.canCreateCampaigns && (
<button <button
onClick={openNew} 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" 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 ms-auto"
> >
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
New Campaign New Campaign
@@ -178,7 +178,7 @@ export default function Campaigns() {
{/* Summary Cards */} {/* Summary Cards */}
{(totalBudget > 0 || totalSpent > 0) && ( {(totalBudget > 0 || totalSpent > 0) && (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3 stagger-children"> <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3 stagger-children">
<div className="bg-white rounded-xl border border-border p-4"> <div className="bg-surface rounded-xl border border-border p-4">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<DollarSign className="w-4 h-4 text-blue-500" /> <DollarSign className="w-4 h-4 text-blue-500" />
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Budget</span> <span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Budget</span>
@@ -186,7 +186,7 @@ export default function Campaigns() {
<div className="text-lg font-bold text-text-primary">{totalBudget.toLocaleString()}</div> <div className="text-lg font-bold text-text-primary">{totalBudget.toLocaleString()}</div>
<div className="text-[10px] text-text-tertiary">{currencySymbol} total</div> <div className="text-[10px] text-text-tertiary">{currencySymbol} total</div>
</div> </div>
<div className="bg-white rounded-xl border border-border p-4"> <div className="bg-surface rounded-xl border border-border p-4">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<TrendingUp className="w-4 h-4 text-amber-500" /> <TrendingUp className="w-4 h-4 text-amber-500" />
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Spent</span> <span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Spent</span>
@@ -194,28 +194,28 @@ export default function Campaigns() {
<div className="text-lg font-bold text-text-primary">{totalSpent.toLocaleString()}</div> <div className="text-lg font-bold text-text-primary">{totalSpent.toLocaleString()}</div>
<div className="text-[10px] text-text-tertiary">{currencySymbol} spent</div> <div className="text-[10px] text-text-tertiary">{currencySymbol} spent</div>
</div> </div>
<div className="bg-white rounded-xl border border-border p-4"> <div className="bg-surface rounded-xl border border-border p-4">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<Eye className="w-4 h-4 text-purple-500" /> <Eye className="w-4 h-4 text-purple-500" />
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Impressions</span> <span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Impressions</span>
</div> </div>
<div className="text-lg font-bold text-text-primary">{totalImpressions.toLocaleString()}</div> <div className="text-lg font-bold text-text-primary">{totalImpressions.toLocaleString()}</div>
</div> </div>
<div className="bg-white rounded-xl border border-border p-4"> <div className="bg-surface rounded-xl border border-border p-4">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<MousePointer className="w-4 h-4 text-green-500" /> <MousePointer className="w-4 h-4 text-green-500" />
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Clicks</span> <span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Clicks</span>
</div> </div>
<div className="text-lg font-bold text-text-primary">{totalClicks.toLocaleString()}</div> <div className="text-lg font-bold text-text-primary">{totalClicks.toLocaleString()}</div>
</div> </div>
<div className="bg-white rounded-xl border border-border p-4"> <div className="bg-surface rounded-xl border border-border p-4">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<Target className="w-4 h-4 text-red-500" /> <Target className="w-4 h-4 text-red-500" />
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Conversions</span> <span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Conversions</span>
</div> </div>
<div className="text-lg font-bold text-text-primary">{totalConversions.toLocaleString()}</div> <div className="text-lg font-bold text-text-primary">{totalConversions.toLocaleString()}</div>
</div> </div>
<div className="bg-white rounded-xl border border-border p-4"> <div className="bg-surface rounded-xl border border-border p-4">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<BarChart3 className="w-4 h-4 text-emerald-500" /> <BarChart3 className="w-4 h-4 text-emerald-500" />
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Revenue</span> <span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Revenue</span>
@@ -264,7 +264,7 @@ export default function Campaigns() {
/> />
{/* Campaign list */} {/* Campaign list */}
<div className="bg-white rounded-xl border border-border overflow-hidden"> <div className="bg-surface rounded-xl border border-border overflow-hidden">
<div className="px-5 py-4 border-b border-border"> <div className="px-5 py-4 border-b border-border">
<h3 className="font-semibold text-text-primary">All Campaigns</h3> <h3 className="font-semibold text-text-primary">All Campaigns</h3>
</div> </div>
@@ -308,7 +308,7 @@ export default function Campaigns() {
)} )}
</div> </div>
</div> </div>
<div className="text-right shrink-0"> <div className="text-end shrink-0">
<StatusBadge status={campaign.status} size="xs" /> <StatusBadge status={campaign.status} size="xs" />
<div className="text-xs text-text-tertiary mt-1"> <div className="text-xs text-text-tertiary mt-1">
{campaign.startDate && campaign.endDate ? ( {campaign.startDate && campaign.endDate ? (
+125 -210
View File
@@ -1,12 +1,11 @@
import { useContext, useEffect, useState, useMemo } from 'react' import { useContext, useEffect, useState, useMemo } from 'react'
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import { format, isAfter, isBefore, addDays } from 'date-fns' import { format, isAfter, isBefore, addDays } from 'date-fns'
import { FileText, Megaphone, AlertTriangle, ArrowRight, Clock, Wallet, TrendingUp, TrendingDown, DollarSign, Landmark, CheckSquare, FolderKanban } from 'lucide-react' import { FileText, Megaphone, AlertTriangle, ArrowRight, Clock, Landmark, CheckSquare, FolderKanban } from 'lucide-react'
import { AppContext } from '../App' import { AppContext } from '../App'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../i18n/LanguageContext' import { useLanguage } from '../i18n/LanguageContext'
import { api, PRIORITY_CONFIG } from '../utils/api' import { api, PRIORITY_CONFIG } from '../utils/api'
import StatCard from '../components/StatCard'
import StatusBadge from '../components/StatusBadge' import StatusBadge from '../components/StatusBadge'
import BrandBadge from '../components/BrandBadge' import BrandBadge from '../components/BrandBadge'
import DatePresetPicker from '../components/DatePresetPicker' import DatePresetPicker from '../components/DatePresetPicker'
@@ -18,24 +17,17 @@ function getBudgetBarColor(percentage) {
return 'bg-emerald-500' return 'bg-emerald-500'
} }
function FinanceMini({ finance }) { function BudgetSummary({ finance }) {
const { t, currencySymbol } = useLanguage() const { t, currencySymbol } = useLanguage()
if (!finance) return null if (!finance) return null
const totalReceived = finance.totalReceived || 0 const totalReceived = finance.totalReceived || 0
const spent = finance.spent || 0 const mainAvailable = finance.mainAvailable != null ? finance.mainAvailable : (finance.remaining || 0)
const remaining = finance.remaining || 0 const consumed = totalReceived - mainAvailable
const roi = finance.roi || 0 const pct = totalReceived > 0 ? (consumed / totalReceived) * 100 : 0
const totalExpenses = finance.totalExpenses || 0
const campaignBudget = finance.totalCampaignBudget || 0
const projectBudget = finance.totalProjectBudget || 0
const unallocated = finance.unallocated ?? (totalReceived - campaignBudget - projectBudget)
const pct = totalReceived > 0 ? (spent / totalReceived) * 100 : 0
const barColor = getBudgetBarColor(pct) const barColor = getBudgetBarColor(pct)
const campPct = totalReceived > 0 ? (campaignBudget / totalReceived) * 100 : 0
const projPct = totalReceived > 0 ? (projectBudget / totalReceived) * 100 : 0
return ( return (
<div className="bg-white rounded-xl border border-border p-5"> <div className="bg-surface rounded-xl border border-border p-5">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-text-primary">{t('dashboard.budgetOverview')}</h3> <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"> <Link to="/finance" className="text-sm text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1">
@@ -49,58 +41,15 @@ function FinanceMini({ finance }) {
</div> </div>
) : ( ) : (
<> <>
{/* Spending bar */} <div className="flex justify-between text-xs text-text-tertiary mb-1">
<div className="mb-3"> <span>{consumed.toLocaleString()} {currencySymbol} {t('dashboard.spent')}</span>
<div className="flex justify-between text-xs text-text-tertiary mb-1"> <span>{totalReceived.toLocaleString()} {currencySymbol} {t('dashboard.received')}</span>
<span>{spent.toLocaleString()} {currencySymbol} {t('dashboard.spent')}</span>
<span>{totalReceived.toLocaleString()} {currencySymbol} {t('dashboard.received')}</span>
</div>
<div className="h-2.5 bg-surface-tertiary rounded-full overflow-hidden">
<div className={`h-full ${barColor} rounded-full transition-all`} style={{ width: `${Math.min(pct, 100)}%` }} />
</div>
</div> </div>
<div className="h-2.5 bg-surface-tertiary rounded-full overflow-hidden">
{/* Allocation bar */} <div className={`h-full ${barColor} rounded-full transition-all`} style={{ width: `${Math.min(pct, 100)}%` }} />
{(campaignBudget > 0 || projectBudget > 0) && ( </div>
<div className="mb-3"> <div className={`mt-3 text-sm font-semibold ${mainAvailable >= 0 ? 'text-emerald-600' : 'text-red-600'}`}>
<div className="text-[10px] text-text-tertiary mb-1 font-medium uppercase tracking-wide">Allocation</div> {mainAvailable.toLocaleString()} {currencySymbol} {t('dashboard.remaining')}
<div className="h-2 bg-surface-tertiary rounded-full overflow-hidden flex">
{campPct > 0 && <div className="h-full bg-blue-500" style={{ width: `${campPct}%` }} />}
{projPct > 0 && <div className="h-full bg-purple-500" style={{ width: `${projPct}%` }} />}
</div>
<div className="flex gap-3 mt-1 text-[10px] text-text-tertiary">
{campaignBudget > 0 && <span><span className="inline-block w-1.5 h-1.5 rounded-full bg-blue-500 mr-1" />{campaignBudget.toLocaleString()}</span>}
{projectBudget > 0 && <span><span className="inline-block w-1.5 h-1.5 rounded-full bg-purple-500 mr-1" />{projectBudget.toLocaleString()}</span>}
{unallocated > 0 && <span><span className="inline-block w-1.5 h-1.5 rounded-full bg-gray-300 mr-1" />{unallocated.toLocaleString()} free</span>}
</div>
</div>
)}
{/* Key numbers */}
<div className={`grid ${totalExpenses > 0 ? 'grid-cols-3' : 'grid-cols-2'} gap-3`}>
<div className="text-center p-2 bg-surface-secondary rounded-lg">
<Landmark 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>
{totalExpenses > 0 && (
<div className="text-center p-2 bg-surface-secondary rounded-lg">
<TrendingDown className="w-4 h-4 mx-auto mb-1 text-red-500" />
<div className="text-sm font-bold text-red-600">
{totalExpenses.toLocaleString()}
</div>
<div className="text-[10px] text-text-tertiary">{t('dashboard.expenses')}</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>
</> </>
)} )}
@@ -146,13 +95,6 @@ function ActiveCampaignsList({ campaigns, finance }) {
</div> </div>
)} )}
</div> </div>
<div className="text-right shrink-0">
{cd.tracks_impressions > 0 && (
<div className="text-[10px] text-text-tertiary">
{cd.tracks_impressions.toLocaleString()} imp. / {cd.tracks_clicks.toLocaleString()} clicks
</div>
)}
</div>
</Link> </Link>
) )
})} })}
@@ -162,12 +104,12 @@ function ActiveCampaignsList({ campaigns, finance }) {
} }
function MyTasksList({ tasks, currentUserId, navigate, t }) { function MyTasksList({ tasks, currentUserId, navigate, t }) {
const myTasks = tasks const myTasks = useMemo(() => tasks
.filter(task => { .filter(task => {
const assignedId = task.assigned_to_id || task.assignedTo const assignedId = task.assigned_to_id || task.assignedTo
return assignedId === currentUserId && task.status !== 'done' return assignedId === currentUserId && task.status !== 'done'
}) })
.slice(0, 5) .slice(0, 5), [tasks, currentUserId])
return ( return (
<div className="section-card"> <div className="section-card">
@@ -187,10 +129,10 @@ function MyTasksList({ tasks, currentUserId, navigate, t }) {
</div> </div>
) : ( ) : (
myTasks.map(task => ( myTasks.map(task => (
<div <button
key={task._id || task.id} key={task._id || task.id}
onClick={() => navigate('/tasks')} onClick={() => navigate('/tasks')}
className="flex items-center gap-3 px-5 py-3 hover:bg-surface-secondary transition-colors cursor-pointer" className="w-full flex items-center gap-3 px-5 py-3 hover:bg-surface-secondary transition-colors text-start"
> >
<div className={`w-2 h-2 rounded-full shrink-0 ${(PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium).color}`} /> <div className={`w-2 h-2 rounded-full shrink-0 ${(PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium).color}`} />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
@@ -203,7 +145,7 @@ function MyTasksList({ tasks, currentUserId, navigate, t }) {
{format(new Date(task.dueDate), 'MMM d')} {format(new Date(task.dueDate), 'MMM d')}
</div> </div>
)} )}
</div> </button>
)) ))
)} )}
</div> </div>
@@ -261,10 +203,84 @@ function ProjectProgress({ projects, tasks, t }) {
) )
} }
function ActivityFeed({ posts, deadlines, navigate, t }) {
const [tab, setTab] = useState('posts')
const hasPosts = posts.length > 0
const hasDeadlines = deadlines.length > 0
return (
<div className="section-card">
<div className="section-card-header flex items-center justify-between">
<div className="flex items-center gap-1">
<button
onClick={() => setTab('posts')}
className={`px-3 py-1 text-sm font-medium rounded-md transition-colors ${
tab === 'posts' ? 'bg-brand-primary/10 text-brand-primary' : 'text-text-tertiary hover:text-text-secondary'
}`}
>
{t('dashboard.recentPosts')}
</button>
<button
onClick={() => setTab('deadlines')}
className={`px-3 py-1 text-sm font-medium rounded-md transition-colors ${
tab === 'deadlines' ? 'bg-brand-primary/10 text-brand-primary' : 'text-text-tertiary hover:text-text-secondary'
}`}
>
{t('dashboard.upcomingDeadlines')}
</button>
</div>
<Link to={tab === 'posts' ? '/posts' : '/tasks'} 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">
{tab === 'posts' ? (
!hasPosts ? (
<div className="py-10 text-center text-sm text-text-tertiary">{t('dashboard.noPostsYet')}</div>
) : (
posts.slice(0, 6).map(post => (
<button key={post._id} onClick={() => navigate('/posts')} className="w-full flex items-center gap-3 px-5 py-3 hover:bg-surface-secondary transition-colors text-start">
<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" />
</button>
))
)
) : (
!hasDeadlines ? (
<div className="py-10 text-center text-sm text-text-tertiary">{t('dashboard.noUpcomingDeadlines')}</div>
) : (
deadlines.map(task => (
<button key={task._id} onClick={() => navigate('/tasks')} className="w-full flex items-center gap-3 px-5 py-3 hover:bg-surface-secondary transition-colors text-start">
<div className={`w-2 h-2 rounded-full ${(PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium).color}`} />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{task.title}</p>
<div className="flex items-center gap-2 mt-0.5">
<StatusBadge status={task.status} size="xs" />
{task.assignedName && <span className="text-xs text-text-tertiary truncate">{task.assignedName}</span>}
</div>
</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>
</button>
))
)
)}
</div>
</div>
)
}
export default function Dashboard() { export default function Dashboard() {
const { t, currencySymbol } = useLanguage() const { t, currencySymbol } = useLanguage()
const navigate = useNavigate() const navigate = useNavigate()
const { currentUser, teamMembers } = useContext(AppContext) const { currentUser } = useContext(AppContext)
const { hasModule } = useAuth() const { hasModule } = useAuth()
const [posts, setPosts] = useState([]) const [posts, setPosts] = useState([])
const [campaigns, setCampaigns] = useState([]) const [campaigns, setCampaigns] = useState([])
@@ -273,7 +289,6 @@ export default function Dashboard() {
const [finance, setFinance] = useState(null) const [finance, setFinance] = useState(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
// Date filtering
const [dateFrom, setDateFrom] = useState('') const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('') const [dateTo, setDateTo] = useState('')
const [activePreset, setActivePreset] = useState('') const [activePreset, setActivePreset] = useState('')
@@ -285,7 +300,6 @@ export default function Dashboard() {
const loadData = async () => { const loadData = async () => {
try { try {
const fetches = [] const fetches = []
// Only fetch data for modules the user has access to
if (hasModule('marketing')) { if (hasModule('marketing')) {
fetches.push(api.get('/posts?limit=50&sort=-createdAt').then(r => ({ key: 'posts', data: Array.isArray(r) ? r : [] }))) fetches.push(api.get('/posts?limit=50&sort=-createdAt').then(r => ({ key: 'posts', data: Array.isArray(r) ? r : [] })))
fetches.push(api.get('/campaigns').then(r => ({ key: 'campaigns', data: Array.isArray(r) ? r : [] }))) fetches.push(api.get('/campaigns').then(r => ({ key: 'campaigns', data: Array.isArray(r) ? r : [] })))
@@ -315,7 +329,6 @@ export default function Dashboard() {
} }
} }
// Filtered data based on date range
const filteredPosts = useMemo(() => { const filteredPosts = useMemo(() => {
if (!dateFrom && !dateTo) return posts if (!dateFrom && !dateTo) return posts
return posts.filter(p => { return posts.filter(p => {
@@ -343,7 +356,7 @@ export default function Dashboard() {
t.dueDate && new Date(t.dueDate) < new Date() && t.status !== 'done' t.dueDate && new Date(t.dueDate) < new Date() && t.status !== 'done'
).length ).length
const upcomingDeadlines = filteredTasks const upcomingDeadlines = useMemo(() => filteredTasks
.filter(t => { .filter(t => {
if (!t.dueDate || t.status === 'done') return false if (!t.dueDate || t.status === 'done') return false
const due = new Date(t.dueDate) const due = new Date(t.dueDate)
@@ -351,60 +364,27 @@ export default function Dashboard() {
return isAfter(due, now) && isBefore(due, addDays(now, 7)) return isAfter(due, now) && isBefore(due, addDays(now, 7))
}) })
.sort((a, b) => new Date(a.dueDate) - new Date(b.dueDate)) .sort((a, b) => new Date(a.dueDate) - new Date(b.dueDate))
.slice(0, 8) .slice(0, 6), [filteredTasks])
const statCards = [] // Inline stat values — no card component needed
const stats = []
if (hasModule('marketing')) { if (hasModule('marketing')) {
statCards.push({ stats.push({ label: t('dashboard.totalPosts'), value: filteredPosts.length, detail: `${filteredPosts.filter(p => p.status === 'published').length} ${t('dashboard.published')}`, icon: FileText, accent: 'text-indigo-600' })
icon: FileText, stats.push({ label: t('dashboard.activeCampaigns'), value: activeCampaigns, detail: `${campaigns.length} ${t('dashboard.total')}`, icon: Megaphone, accent: 'text-pink-600' })
label: t('dashboard.totalPosts'),
value: filteredPosts.length || 0,
subtitle: `${filteredPosts.filter(p => p.status === 'published').length} ${t('dashboard.published')}`,
color: 'brand-primary',
})
statCards.push({
icon: Megaphone,
label: t('dashboard.activeCampaigns'),
value: activeCampaigns,
subtitle: `${campaigns.length} ${t('dashboard.total')}`,
color: 'brand-secondary',
})
}
if (hasModule('finance')) {
statCards.push({
icon: Landmark,
label: t('dashboard.budgetRemaining'),
value: `${(finance?.remaining ?? 0).toLocaleString()}`,
subtitle: finance?.totalReceived ? `${(finance.spent || 0).toLocaleString()} ${t('dashboard.spent')} ${t('dashboard.of')} ${finance.totalReceived.toLocaleString()} ${currencySymbol}` : t('dashboard.noBudget'),
color: 'brand-tertiary',
})
} }
if (hasModule('projects')) { if (hasModule('projects')) {
statCards.push({ stats.push({ label: t('dashboard.overdueTasks'), value: overdueTasks, detail: overdueTasks > 0 ? t('dashboard.needsAttention') : t('dashboard.allOnTrack'), icon: AlertTriangle, accent: overdueTasks > 0 ? 'text-red-600' : 'text-emerald-600' })
icon: AlertTriangle,
label: t('dashboard.overdueTasks'),
value: overdueTasks,
subtitle: overdueTasks > 0 ? t('dashboard.needsAttention') : t('dashboard.allOnTrack'),
color: 'brand-quaternary',
})
} }
if (loading) { if (loading) return <SkeletonDashboard />
return <SkeletonDashboard />
}
return ( return (
<div className="space-y-6 animate-fade-in"> <div className="space-y-6 animate-fade-in">
{/* Welcome + Date presets */} {/* Welcome + Date presets */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div> <p className="text-lg font-medium text-text-primary">
<h1 className="text-2xl font-bold text-gradient"> {t('dashboard.welcomeBack')}, {currentUser?.name || 'there'}
{t('dashboard.welcomeBack')}, {currentUser?.name || 'there'} </p>
</h1>
<p className="text-text-secondary mt-1">
{t('dashboard.happeningToday')}
</p>
</div>
<DatePresetPicker <DatePresetPicker
activePreset={activePreset} activePreset={activePreset}
onSelect={(from, to, key) => { setDateFrom(from); setDateTo(to); setActivePreset(key) }} onSelect={(from, to, key) => { setDateFrom(from); setDateTo(to); setActivePreset(key) }}
@@ -412,11 +392,18 @@ export default function Dashboard() {
/> />
</div> </div>
{/* Stats */} {/* Stats — compact inline row, no cards */}
{statCards.length > 0 && ( {stats.length > 0 && (
<div className={`grid grid-cols-1 sm:grid-cols-2 ${statCards.length >= 4 ? 'lg:grid-cols-4' : statCards.length === 3 ? 'lg:grid-cols-3' : 'lg:grid-cols-2'} gap-4 stagger-children`}> <div className="flex flex-wrap gap-6">
{statCards.map((card, i) => ( {stats.map((s, i) => (
<StatCard key={i} {...card} /> <div key={i} className="flex items-center gap-3">
<s.icon className={`w-5 h-5 ${s.accent}`} />
<div>
<span className="text-2xl font-bold text-text-primary">{s.value}</span>
<span className="text-sm text-text-tertiary ms-1.5">{s.label}</span>
<p className="text-xs text-text-tertiary">{s.detail}</p>
</div>
</div>
))} ))}
</div> </div>
)} )}
@@ -432,7 +419,7 @@ export default function Dashboard() {
{/* Budget + Active Campaigns */} {/* Budget + Active Campaigns */}
{(hasModule('finance') || hasModule('marketing')) && ( {(hasModule('finance') || hasModule('marketing')) && (
<div className={`grid grid-cols-1 ${hasModule('finance') && hasModule('marketing') ? 'lg:grid-cols-3' : ''} gap-6`}> <div className={`grid grid-cols-1 ${hasModule('finance') && hasModule('marketing') ? 'lg:grid-cols-3' : ''} gap-6`}>
{hasModule('finance') && <FinanceMini finance={finance} />} {hasModule('finance') && <BudgetSummary finance={finance} />}
{hasModule('marketing') && ( {hasModule('marketing') && (
<div className={hasModule('finance') ? 'lg:col-span-2' : ''}> <div className={hasModule('finance') ? 'lg:col-span-2' : ''}>
<ActiveCampaignsList campaigns={campaigns} finance={finance} /> <ActiveCampaignsList campaigns={campaigns} finance={finance} />
@@ -441,86 +428,14 @@ export default function Dashboard() {
</div> </div>
)} )}
{/* Recent Posts + Upcoming Deadlines */} {/* Activity — merged posts + deadlines */}
{(hasModule('marketing') || hasModule('projects')) && ( {(hasModule('marketing') || hasModule('projects')) && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <ActivityFeed
{/* Recent Posts */} posts={hasModule('marketing') ? filteredPosts : []}
{hasModule('marketing') && ( deadlines={hasModule('projects') ? upcomingDeadlines : []}
<div className="section-card"> navigate={navigate}
<div className="section-card-header flex items-center justify-between"> t={t}
<h3 className="font-semibold text-text-primary">{t('dashboard.recentPosts')}</h3> />
<Link to="/posts" 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">
{filteredPosts.length === 0 ? (
<div className="py-12 text-center text-sm text-text-tertiary">
{t('dashboard.noPostsYet')}
</div>
) : (
filteredPosts.slice(0, 8).map((post) => (
<div
key={post._id}
onClick={() => navigate('/posts')}
className="flex items-center gap-3 px-5 py-3 hover:bg-surface-secondary transition-colors cursor-pointer"
>
<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 */}
{hasModule('projects') && (
<div className="section-card">
<div className="section-card-header flex items-center justify-between">
<h3 className="font-semibold text-text-primary">{t('dashboard.upcomingDeadlines')}</h3>
<Link to="/tasks" 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">
{upcomingDeadlines.length === 0 ? (
<div className="py-12 text-center text-sm text-text-tertiary">
{t('dashboard.noUpcomingDeadlines')}
</div>
) : (
upcomingDeadlines.map((task) => (
<div
key={task._id}
onClick={() => navigate('/tasks')}
className="flex items-center gap-3 px-5 py-3 hover:bg-surface-secondary transition-colors cursor-pointer"
>
<div className={`w-2 h-2 rounded-full ${(PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium).color}`} />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{task.title}</p>
<div className="flex items-center gap-2 mt-0.5">
<StatusBadge status={task.status} size="xs" />
{task.assignedName && (
<span className="text-xs text-text-tertiary truncate">{task.assignedName}</span>
)}
</div>
</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> </div>
) )
+257 -41
View File
@@ -1,14 +1,16 @@
import { useState, useEffect, useContext } from 'react' import { useState, useEffect, useContext } from 'react'
import { DollarSign, TrendingUp, TrendingDown, Wallet, Landmark, Eye, MousePointer, Target, Briefcase, ArrowRight, Receipt } from 'lucide-react' import { DollarSign, TrendingUp, TrendingDown, Wallet, Landmark, Eye, MousePointer, Target, Briefcase, ArrowRight, Receipt, Plus, X } from 'lucide-react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { AppContext } from '../App' import { AppContext } from '../App'
import { api } from '../utils/api' import { api } from '../utils/api'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../i18n/LanguageContext' import { useLanguage } from '../i18n/LanguageContext'
import StatusBadge from '../components/StatusBadge' import StatusBadge from '../components/StatusBadge'
import Modal from '../components/Modal'
import { useToast } from '../components/ToastContainer'
import { SkeletonStatCard, SkeletonTable } from '../components/SkeletonLoader' import { SkeletonStatCard, SkeletonTable } from '../components/SkeletonLoader'
function FinanceStatCard({ icon: Icon, label, value, sub, color = 'text-text-primary', bgColor = 'bg-white' }) { function FinanceStatCard({ icon: Icon, label, value, sub, color = 'text-text-primary', bgColor = 'bg-surface' }) {
return ( return (
<div className={`${bgColor} rounded-xl border border-border p-5`}> <div className={`${bgColor} rounded-xl border border-border p-5`}>
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
@@ -40,19 +42,36 @@ function ProgressRing({ pct, size = 80, stroke = 8, color = '#10b981' }) {
) )
} }
const BUDGET_REQUEST_STATUS_COLORS = {
pending: 'bg-amber-100 text-amber-800',
approved: 'bg-emerald-100 text-emerald-800',
rejected: 'bg-red-100 text-red-800',
cancelled: 'bg-gray-100 text-gray-600',
}
export default function Finance() { export default function Finance() {
const { brands } = useContext(AppContext) const { brands } = useContext(AppContext)
const { permissions } = useAuth() const { permissions, user } = useAuth()
const { currencySymbol } = useLanguage() const { t, currencySymbol } = useLanguage()
const toast = useToast()
const [summary, setSummary] = useState(null) const [summary, setSummary] = useState(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [budgetRequests, setBudgetRequests] = useState([])
const [showRequestModal, setShowRequestModal] = useState(false)
const [requestForm, setRequestForm] = useState({ amount: '', justification: '', earmark_type: '', earmark_id: '' })
const [submittingRequest, setSubmittingRequest] = useState(false)
const isSuperadmin = user?.role === 'superadmin'
useEffect(() => { loadAll() }, []) useEffect(() => { loadAll() }, [])
const loadAll = async () => { const loadAll = async () => {
try { try {
const sum = await api.get('/finance/summary') const fetches = [api.get('/finance/summary')]
if (isSuperadmin) fetches.push(api.get('/budget-requests').catch(() => []))
const [sum, reqs] = await Promise.all(fetches)
setSummary(sum.data || sum || {}) setSummary(sum.data || sum || {})
if (reqs) setBudgetRequests(Array.isArray(reqs) ? reqs : [])
} catch (err) { } catch (err) {
console.error('Failed to load finance:', err) console.error('Failed to load finance:', err)
} finally { } finally {
@@ -60,6 +79,41 @@ export default function Finance() {
} }
} }
const handleSubmitRequest = async () => {
if (!requestForm.amount || !requestForm.justification.trim()) return
setSubmittingRequest(true)
try {
const body = {
amount: Number(requestForm.amount),
justification: requestForm.justification.trim(),
}
if (requestForm.earmark_type === 'campaign' && requestForm.earmark_id) {
body.earmarked_campaign_id = Number(requestForm.earmark_id)
} else if (requestForm.earmark_type === 'project' && requestForm.earmark_id) {
body.earmarked_project_id = Number(requestForm.earmark_id)
}
await api.post('/budget-requests', body)
toast.success(t('finance.requestBudget') + ' — ' + t('common.success'))
setShowRequestModal(false)
setRequestForm({ amount: '', justification: '', earmark_type: '', earmark_id: '' })
loadAll()
} catch (err) {
toast.error(err.message || t('common.error'))
} finally {
setSubmittingRequest(false)
}
}
const handleCancelRequest = async (id) => {
try {
await api.patch(`/budget-requests/${id}/cancel`)
toast.success(t('common.success'))
loadAll()
} catch (err) {
toast.error(err.message || t('common.error'))
}
}
if (loading) { if (loading) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@@ -86,18 +140,35 @@ export default function Finance() {
const projectPct = totalReceived > 0 ? (totalProjectBudget / totalReceived) * 100 : 0 const projectPct = totalReceived > 0 ? (totalProjectBudget / totalReceived) * 100 : 0
const unallocatedPct = totalReceived > 0 ? (Math.max(0, unallocated) / totalReceived) * 100 : 0 const unallocatedPct = totalReceived > 0 ? (Math.max(0, unallocated) / totalReceived) * 100 : 0
const campaigns = s.campaigns || []
const projects = s.projects || []
const pendingCount = budgetRequests.filter(r => r.status === 'pending').length
return ( return (
<div className="space-y-6 animate-fade-in"> <div className="space-y-6 animate-fade-in">
{/* Request Budget button (superadmin) */}
{isSuperadmin && (
<div className="flex justify-end">
<button
onClick={() => setShowRequestModal(true)}
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 transition-colors shadow-sm"
>
<Plus className="w-4 h-4" />
{t('finance.requestBudget')}
</button>
</div>
)}
{/* Top metrics */} {/* Top metrics */}
<div className={`grid grid-cols-2 ${totalExpenses > 0 ? 'lg:grid-cols-6' : 'lg:grid-cols-5'} gap-4 stagger-children`}> <div className={`grid grid-cols-2 ${totalExpenses > 0 ? 'lg:grid-cols-6' : 'lg:grid-cols-5'} gap-4 stagger-children`}>
<FinanceStatCard icon={Wallet} label="Total Received" value={`${totalReceived.toLocaleString()} ${currencySymbol}`} color="text-blue-600" /> <FinanceStatCard icon={Wallet} label={t('finance.totalReceived')} value={`${totalReceived.toLocaleString()} ${currencySymbol}`} color="text-blue-600" />
<FinanceStatCard icon={TrendingUp} label="Total Spent" value={`${totalSpent.toLocaleString()} ${currencySymbol}`} sub={`${spendPct.toFixed(1)}% of budget`} color="text-amber-600" /> <FinanceStatCard icon={TrendingUp} label={t('finance.totalSpent')} value={`${totalSpent.toLocaleString()} ${currencySymbol}`} sub={`${spendPct.toFixed(1)}% ${t('finance.ofBudget')}`} color="text-amber-600" />
{totalExpenses > 0 && ( {totalExpenses > 0 && (
<FinanceStatCard icon={Receipt} label="Expenses" value={`${totalExpenses.toLocaleString()} ${currencySymbol}`} color="text-red-600" /> <FinanceStatCard icon={Receipt} label={t('finance.expenses')} value={`${totalExpenses.toLocaleString()} ${currencySymbol}`} color="text-red-600" />
)} )}
<FinanceStatCard icon={Landmark} label="Remaining" value={`${remaining.toLocaleString()} ${currencySymbol}`} color={remaining >= 0 ? 'text-emerald-600' : 'text-red-600'} /> <FinanceStatCard icon={Landmark} label={t('finance.remaining')} value={`${remaining.toLocaleString()} ${currencySymbol}`} color={remaining >= 0 ? 'text-emerald-600' : 'text-red-600'} />
<FinanceStatCard icon={DollarSign} label="Revenue" value={`${totalRevenue.toLocaleString()} ${currencySymbol}`} color="text-purple-600" /> <FinanceStatCard icon={DollarSign} label={t('finance.revenue')} value={`${totalRevenue.toLocaleString()} ${currencySymbol}`} color="text-purple-600" />
<FinanceStatCard icon={roi >= 0 ? TrendingUp : TrendingDown} label="Global ROI" <FinanceStatCard icon={roi >= 0 ? TrendingUp : TrendingDown} label={t('finance.globalROI')}
value={`${roi.toFixed(1)}%`} value={`${roi.toFixed(1)}%`}
color={roi >= 0 ? 'text-emerald-600' : 'text-red-600'} /> color={roi >= 0 ? 'text-emerald-600' : 'text-red-600'} />
</div> </div>
@@ -106,9 +177,9 @@ export default function Finance() {
{totalReceived > 0 && ( {totalReceived > 0 && (
<div className="section-card p-5"> <div className="section-card p-5">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium">Budget Allocation</h3> <h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium">{t('finance.budgetAllocation')}</h3>
<Link to="/budgets" className="text-xs text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1"> <Link to="/budgets" className="text-xs text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1">
Manage Budgets <ArrowRight className="w-3 h-3" /> {t('finance.manageBudgets')} <ArrowRight className="w-3 h-3" />
</Link> </Link>
</div> </div>
<div className="h-4 bg-surface-tertiary rounded-full overflow-hidden flex"> <div className="h-4 bg-surface-tertiary rounded-full overflow-hidden flex">
@@ -122,17 +193,17 @@ export default function Finance() {
<div className="flex items-center gap-4 mt-2.5 text-xs"> <div className="flex items-center gap-4 mt-2.5 text-xs">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-full bg-blue-500" /> <div className="w-2.5 h-2.5 rounded-full bg-blue-500" />
<span className="text-text-secondary">Campaigns: <span className="font-semibold text-text-primary">{totalCampaignBudget.toLocaleString()} {currencySymbol}</span></span> <span className="text-text-secondary">{t('finance.campaigns')}: <span className="font-semibold text-text-primary">{totalCampaignBudget.toLocaleString()} {currencySymbol}</span></span>
<span className="text-text-tertiary">({campaignPct.toFixed(0)}%)</span> <span className="text-text-tertiary">({campaignPct.toFixed(0)}%)</span>
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-full bg-purple-500" /> <div className="w-2.5 h-2.5 rounded-full bg-purple-500" />
<span className="text-text-secondary">Projects: <span className="font-semibold text-text-primary">{totalProjectBudget.toLocaleString()} {currencySymbol}</span></span> <span className="text-text-secondary">{t('finance.projects')}: <span className="font-semibold text-text-primary">{totalProjectBudget.toLocaleString()} {currencySymbol}</span></span>
<span className="text-text-tertiary">({projectPct.toFixed(0)}%)</span> <span className="text-text-tertiary">({projectPct.toFixed(0)}%)</span>
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-full bg-gray-300" /> <div className="w-2.5 h-2.5 rounded-full bg-gray-300" />
<span className="text-text-secondary">Unallocated: <span className="font-semibold text-text-primary">{Math.max(0, unallocated).toLocaleString()} {currencySymbol}</span></span> <span className="text-text-secondary">{t('finance.unallocated')}: <span className="font-semibold text-text-primary">{Math.max(0, unallocated).toLocaleString()} {currencySymbol}</span></span>
<span className="text-text-tertiary">({unallocatedPct.toFixed(0)}%)</span> <span className="text-text-tertiary">({unallocatedPct.toFixed(0)}%)</span>
</div> </div>
</div> </div>
@@ -143,7 +214,7 @@ export default function Finance() {
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{/* Utilization ring */} {/* Utilization ring */}
<div className="section-card p-5 flex flex-col items-center justify-center"> <div className="section-card 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> <h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium mb-4">{t('finance.budgetUtilization')}</h3>
<ProgressRing <ProgressRing
pct={spendPct} pct={spendPct}
size={120} size={120}
@@ -157,17 +228,17 @@ export default function Finance() {
{/* Global performance */} {/* Global performance */}
<div className="section-card p-5 lg:col-span-2"> <div className="section-card p-5 lg:col-span-2">
<h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium mb-4">Global Performance</h3> <h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium mb-4">{t('finance.globalPerformance')}</h3>
<div className="grid grid-cols-3 gap-6"> <div className="grid grid-cols-3 gap-6">
<div className="text-center"> <div className="text-center">
<Eye className="w-5 h-5 mx-auto mb-1 text-purple-500" /> <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-xl font-bold text-text-primary">{(s.impressions || 0).toLocaleString()}</div>
<div className="text-xs text-text-tertiary">Impressions</div> <div className="text-xs text-text-tertiary">{t('finance.impressions')}</div>
</div> </div>
<div className="text-center"> <div className="text-center">
<MousePointer className="w-5 h-5 mx-auto mb-1 text-blue-500" /> <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-xl font-bold text-text-primary">{(s.clicks || 0).toLocaleString()}</div>
<div className="text-xs text-text-tertiary">Clicks</div> <div className="text-xs text-text-tertiary">{t('finance.clicks')}</div>
{s.clicks > 0 && s.spent > 0 && ( {s.clicks > 0 && s.spent > 0 && (
<div className="text-[10px] text-text-tertiary mt-0.5">CPC: {(s.spent / s.clicks).toFixed(2)} {currencySymbol}</div> <div className="text-[10px] text-text-tertiary mt-0.5">CPC: {(s.spent / s.clicks).toFixed(2)} {currencySymbol}</div>
)} )}
@@ -175,7 +246,7 @@ export default function Finance() {
<div className="text-center"> <div className="text-center">
<Target className="w-5 h-5 mx-auto mb-1 text-emerald-500" /> <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-xl font-bold text-text-primary">{(s.conversions || 0).toLocaleString()}</div>
<div className="text-xs text-text-tertiary">Conversions</div> <div className="text-xs text-text-tertiary">{t('finance.conversions')}</div>
{s.conversions > 0 && s.spent > 0 && ( {s.conversions > 0 && s.spent > 0 && (
<div className="text-[10px] text-text-tertiary mt-0.5">CPA: {(s.spent / s.conversions).toFixed(2)} {currencySymbol}</div> <div className="text-[10px] text-text-tertiary mt-0.5">CPA: {(s.spent / s.conversions).toFixed(2)} {currencySymbol}</div>
)} )}
@@ -200,7 +271,7 @@ export default function Finance() {
<Target className="w-4 h-4 text-blue-600" /> <Target className="w-4 h-4 text-blue-600" />
</div> </div>
<div> <div>
<h3 className="font-semibold text-text-primary text-base">Campaign Breakdown</h3> <h3 className="font-semibold text-text-primary">{t('finance.campaignBreakdown')}</h3>
<p className="text-xs text-text-tertiary mt-0.5">{s.campaigns.length} campaigns &middot; Track-level budget allocation</p> <p className="text-xs text-text-tertiary mt-0.5">{s.campaigns.length} campaigns &middot; Track-level budget allocation</p>
</div> </div>
</div> </div>
@@ -208,13 +279,13 @@ export default function Finance() {
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b border-border bg-surface-secondary"> <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-start text-xs font-medium text-text-tertiary">Campaign</th>
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">Budget Assigned</th> <th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">Budget Assigned</th>
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">Track Allocated</th> <th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">Track 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-end text-xs font-medium text-text-tertiary">Spent</th>
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">Expenses</th> <th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">Expenses</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-end 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-end text-xs font-medium text-text-tertiary">ROI</th>
<th className="px-4 py-3 text-center text-xs font-medium text-text-tertiary">Status</th> <th className="px-4 py-3 text-center text-xs font-medium text-text-tertiary">Status</th>
</tr> </tr>
</thead> </thead>
@@ -225,20 +296,20 @@ export default function Finance() {
return ( return (
<tr key={c.id} className="hover:bg-surface-secondary"> <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 font-medium text-text-primary">{c.name}</td>
<td className="px-4 py-3 text-right"> <td className="px-4 py-3 text-end">
{c.budget_from_entries > 0 ? ( {c.budget_from_entries > 0 ? (
<span className="font-semibold text-blue-600">{c.budget_from_entries.toLocaleString()}</span> <span className="font-semibold text-blue-600">{c.budget_from_entries.toLocaleString()}</span>
) : <span className="text-text-tertiary">{'\u2014'}</span>} ) : <span className="text-text-tertiary">{'\u2014'}</span>}
</td> </td>
<td className="px-4 py-3 text-right text-text-secondary">{c.tracks_allocated.toLocaleString()}</td> <td className="px-4 py-3 text-end 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-end text-text-secondary">{c.tracks_spent.toLocaleString()}</td>
<td className="px-4 py-3 text-right"> <td className="px-4 py-3 text-end">
{c.expenses > 0 ? ( {c.expenses > 0 ? (
<span className="font-semibold text-red-500">-{c.expenses.toLocaleString()}</span> <span className="font-semibold text-red-500">-{c.expenses.toLocaleString()}</span>
) : <span className="text-text-tertiary">{'\u2014'}</span>} ) : <span className="text-text-tertiary">{'\u2014'}</span>}
</td> </td>
<td className="px-4 py-3 text-right text-text-secondary">{c.tracks_revenue.toLocaleString()}</td> <td className="px-4 py-3 text-end text-text-secondary">{c.tracks_revenue.toLocaleString()}</td>
<td className="px-4 py-3 text-right"> <td className="px-4 py-3 text-end">
{totalCampaignConsumed > 0 ? ( {totalCampaignConsumed > 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'}`}> <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)}% {cRoi.toFixed(0)}%
@@ -263,7 +334,7 @@ export default function Finance() {
<Briefcase className="w-4 h-4 text-purple-600" /> <Briefcase className="w-4 h-4 text-purple-600" />
</div> </div>
<div> <div>
<h3 className="font-semibold text-text-primary text-base">Allocated Funds</h3> <h3 className="font-semibold text-text-primary">{t('finance.allocatedFunds')}</h3>
<p className="text-xs text-text-tertiary mt-0.5">{s.projects.filter(p => p.budget_allocated > 0 || p.expenses > 0).length} work orders with assigned budget</p> <p className="text-xs text-text-tertiary mt-0.5">{s.projects.filter(p => p.budget_allocated > 0 || p.expenses > 0).length} work orders with assigned budget</p>
</div> </div>
</div> </div>
@@ -271,9 +342,9 @@ export default function Finance() {
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b border-border bg-surface-secondary"> <tr className="border-b border-border bg-surface-secondary">
<th className="px-4 py-3 text-left text-xs font-medium text-text-tertiary">Work Order</th> <th className="px-4 py-3 text-start text-xs font-medium text-text-tertiary">Work Order</th>
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">Budget Allocated</th> <th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">Budget Allocated</th>
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">Expenses</th> <th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">Expenses</th>
<th className="px-4 py-3 text-center text-xs font-medium text-text-tertiary">Status</th> <th className="px-4 py-3 text-center text-xs font-medium text-text-tertiary">Status</th>
</tr> </tr>
</thead> </thead>
@@ -281,8 +352,8 @@ export default function Finance() {
{s.projects.filter(p => p.budget_allocated > 0 || p.expenses > 0).map(p => ( {s.projects.filter(p => p.budget_allocated > 0 || p.expenses > 0).map(p => (
<tr key={p.id} className="hover:bg-surface-secondary"> <tr key={p.id} className="hover:bg-surface-secondary">
<td className="px-4 py-3 font-medium text-text-primary">{p.name}</td> <td className="px-4 py-3 font-medium text-text-primary">{p.name}</td>
<td className="px-4 py-3 text-right text-text-secondary">{p.budget_allocated.toLocaleString()} {currencySymbol}</td> <td className="px-4 py-3 text-end text-text-secondary">{p.budget_allocated.toLocaleString()} {currencySymbol}</td>
<td className="px-4 py-3 text-right"> <td className="px-4 py-3 text-end">
{p.expenses > 0 ? ( {p.expenses > 0 ? (
<span className="font-semibold text-red-500">-{p.expenses.toLocaleString()}</span> <span className="font-semibold text-red-500">-{p.expenses.toLocaleString()}</span>
) : <span className="text-text-tertiary">{'\u2014'}</span>} ) : <span className="text-text-tertiary">{'\u2014'}</span>}
@@ -295,6 +366,151 @@ export default function Finance() {
</div> </div>
</div> </div>
)} )}
{/* Budget Requests (superadmin) */}
{isSuperadmin && (
<div className="section-card">
<div className="section-card-header flex items-center gap-3">
<div className="p-2 rounded-lg bg-amber-50">
<Wallet className="w-4 h-4 text-amber-600" />
</div>
<div>
<h3 className="font-semibold text-text-primary">{t('finance.budgetRequests')}</h3>
</div>
</div>
{pendingCount > 0 && (
<div className="mx-5 mt-4 px-4 py-2.5 bg-amber-50 border border-amber-200 rounded-lg text-sm text-amber-800 font-medium">
{pendingCount} {t('finance.requestPending')}
</div>
)}
{budgetRequests.length === 0 ? (
<div className="px-5 py-8 text-center text-sm text-text-tertiary">
{t('common.noData')}
</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-end text-xs font-medium text-text-tertiary">{t('finance.amount')}</th>
<th className="px-4 py-3 text-start text-xs font-medium text-text-tertiary">{t('budgetApproval.justification')}</th>
<th className="px-4 py-3 text-center text-xs font-medium text-text-tertiary">{t('common.status')}</th>
<th className="px-4 py-3 text-start text-xs font-medium text-text-tertiary">{t('budgetApproval.earmarkedFor')}</th>
<th className="px-4 py-3 text-start text-xs font-medium text-text-tertiary">{t('common.date')}</th>
<th className="px-4 py-3 text-center text-xs font-medium text-text-tertiary"></th>
</tr>
</thead>
<tbody className="divide-y divide-border-light">
{budgetRequests.map(req => (
<tr key={req.id || req.Id} className="hover:bg-surface-secondary">
<td className="px-4 py-3 text-end font-semibold text-text-primary">
{Number(req.amount).toLocaleString()} {currencySymbol}
</td>
<td className="px-4 py-3 text-text-secondary max-w-[200px]">
<span title={req.justification}>
{req.justification?.length > 60 ? req.justification.slice(0, 60) + '...' : req.justification}
</span>
</td>
<td className="px-4 py-3 text-center">
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${BUDGET_REQUEST_STATUS_COLORS[req.status] || 'bg-gray-100 text-gray-600'}`}>
{req.status}
</span>
</td>
<td className="px-4 py-3 text-text-secondary text-xs">
{req.earmark_name || '\u2014'}
</td>
<td className="px-4 py-3 text-text-tertiary text-xs">
{req.created_at ? new Date(req.created_at).toLocaleDateString() : '\u2014'}
</td>
<td className="px-4 py-3 text-center">
{req.status === 'pending' && (
<button
onClick={() => handleCancelRequest(req.id || req.Id)}
className="text-xs text-red-600 hover:text-red-700 font-medium hover:bg-red-50 px-2 py-1 rounded transition-colors"
>
{t('common.cancel')}
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
{/* Budget Request Modal */}
<Modal isOpen={showRequestModal} onClose={() => setShowRequestModal(false)} title={t('finance.requestBudget')} size="md">
<div className="space-y-4">
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('finance.amount')}</label>
<div className="flex items-center gap-2">
<input
type="number"
min="1"
value={requestForm.amount}
onChange={e => setRequestForm(f => ({ ...f, amount: e.target.value }))}
className="flex-1 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 bg-surface"
placeholder="0"
autoFocus
/>
<span className="text-sm text-text-tertiary">{currencySymbol}</span>
</div>
</div>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('budgetApproval.justification')}</label>
<textarea
value={requestForm.justification}
onChange={e => setRequestForm(f => ({ ...f, justification: 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 bg-surface"
placeholder={t('budgetApproval.justification')}
/>
</div>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('budgetApproval.earmarkedFor')}</label>
<select
value={requestForm.earmark_type ? `${requestForm.earmark_type}:${requestForm.earmark_id}` : ''}
onChange={e => {
if (!e.target.value) {
setRequestForm(f => ({ ...f, earmark_type: '', earmark_id: '' }))
} else {
const [type, id] = e.target.value.split(':')
setRequestForm(f => ({ ...f, earmark_type: type, earmark_id: id }))
}
}}
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 bg-surface"
>
<option value="">{t('common.none')}</option>
{campaigns.length > 0 && (
<optgroup label={t('finance.campaigns')}>
{campaigns.map(c => (
<option key={`campaign:${c.id}`} value={`campaign:${c.id}`}>{c.name}</option>
))}
</optgroup>
)}
{projects.length > 0 && (
<optgroup label={t('finance.projects')}>
{projects.map(p => (
<option key={`project:${p.id}`} value={`project:${p.id}`}>{p.name}</option>
))}
</optgroup>
)}
</select>
</div>
<button
onClick={handleSubmitRequest}
disabled={!requestForm.amount || !requestForm.justification.trim() || submittingRequest}
className={`w-full 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 ${submittingRequest ? 'btn-loading' : ''}`}
>
{t('finance.requestBudget')}
</button>
</div>
</Modal>
</div> </div>
) )
} }
+16 -7
View File
@@ -1,9 +1,18 @@
import { useState } from 'react' import { useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { useLanguage } from '../i18n/LanguageContext' import { useLanguage } from '../i18n/LanguageContext'
import { Megaphone, Mail, AlertCircle, CheckCircle, ArrowLeft } from 'lucide-react' import { Mail, AlertCircle, CheckCircle, ArrowLeft } from 'lucide-react'
import { api } from '../utils/api' import { api } from '../utils/api'
function MarkaLogo({ className = '' }) {
return (
<svg viewBox="0 0 32 32" fill="none" className={className}>
<path d="M4 26V6l10 10L4 26z" fill="currentColor" opacity="0.85" />
<path d="M18 26V6l10 10-10 10z" fill="currentColor" opacity="0.5" />
</svg>
)
}
export default function ForgotPassword() { export default function ForgotPassword() {
const { t } = useLanguage() const { t } = useLanguage()
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
@@ -27,11 +36,11 @@ export default function ForgotPassword() {
} }
return ( 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="min-h-screen bg-slate-900 flex items-center justify-center px-4">
<div className="w-full max-w-md"> <div className="w-full max-w-md">
<div className="text-center mb-8"> <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"> <div className="w-16 h-16 bg-brand-primary rounded-2xl flex items-center justify-center mx-auto mb-4">
<Megaphone className="w-8 h-8 text-white" /> <MarkaLogo className="w-9 h-9 text-white" />
</div> </div>
<h1 className="text-3xl font-bold text-white mb-2">{t('forgotPassword.title')}</h1> <h1 className="text-3xl font-bold text-white mb-2">{t('forgotPassword.title')}</h1>
<p className="text-slate-400">{t('forgotPassword.subtitle')}</p> <p className="text-slate-400">{t('forgotPassword.subtitle')}</p>
@@ -57,13 +66,13 @@ export default function ForgotPassword() {
<div> <div>
<label className="block text-sm font-medium text-slate-300 mb-2">{t('auth.email')}</label> <label className="block text-sm font-medium text-slate-300 mb-2">{t('auth.email')}</label>
<div className="relative"> <div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" /> <Mail className="absolute start-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<input <input
type="email" type="email"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
dir="auto" 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" className="w-full ps-11 pe-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={t('forgotPassword.emailPlaceholder')} placeholder={t('forgotPassword.emailPlaceholder')}
required required
autoFocus autoFocus
@@ -81,7 +90,7 @@ export default function ForgotPassword() {
<button <button
type="submit" type="submit"
disabled={loading} 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" className="w-full py-3 bg-brand-primary hover:bg-brand-primary-light text-white font-semibold rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
> >
{loading ? ( {loading ? (
<span className="flex items-center justify-center gap-2"> <span className="flex items-center justify-center gap-2">
+15 -23
View File
@@ -196,8 +196,8 @@ export default function Issues() {
const SortIcon = ({ col }) => { const SortIcon = ({ col }) => {
if (sortBy !== col) return null if (sortBy !== col) return null
return sortDir === 'asc' return sortDir === 'asc'
? <ChevronUp className="w-3 h-3 inline ml-0.5" /> ? <ChevronUp className="w-3 h-3 inline ms-0.5" />
: <ChevronDown className="w-3 h-3 inline ml-0.5" /> : <ChevronDown className="w-3 h-3 inline ms-0.5" />
} }
if (loading) { if (loading) {
@@ -211,15 +211,7 @@ export default function Issues() {
return ( return (
<div className="space-y-4 animate-fade-in"> <div className="space-y-4 animate-fade-in">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-end">
<div>
<h1 className="text-2xl font-bold text-text-primary flex items-center gap-2">
<AlertCircle className="w-7 h-7" />
{t('issues.title')}
</h1>
<p className="text-text-secondary mt-1">{t('issues.subtitle')}</p>
</div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<button <button
onClick={copyPublicLink} onClick={copyPublicLink}
@@ -241,7 +233,7 @@ export default function Issues() {
onClick={() => setViewMode(mode)} onClick={() => setViewMode(mode)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${ className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
viewMode === mode viewMode === mode
? 'bg-white text-text-primary shadow-sm' ? 'bg-surface text-text-primary shadow-sm'
: 'text-text-tertiary hover:text-text-secondary' : 'text-text-tertiary hover:text-text-secondary'
}`} }`}
> >
@@ -276,13 +268,13 @@ export default function Issues() {
<div className="flex items-center gap-3 flex-wrap"> <div className="flex items-center gap-3 flex-wrap">
{/* Search */} {/* Search */}
<div className="relative flex-1 min-w-[200px] max-w-xs"> <div className="relative flex-1 min-w-[200px] max-w-xs">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" /> <Search className="absolute start-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<input <input
type="text" type="text"
placeholder={t('issues.searchPlaceholder')} placeholder={t('issues.searchPlaceholder')}
value={searchTerm} value={searchTerm}
onChange={e => setSearchTerm(e.target.value)} onChange={e => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 bg-surface" className="w-full ps-10 pe-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 bg-surface"
/> />
</div> </div>
@@ -413,21 +405,21 @@ export default function Issues() {
<th className="w-10 px-3 py-3" onClick={e => e.stopPropagation()}> <th className="w-10 px-3 py-3" onClick={e => e.stopPropagation()}>
<input type="checkbox" checked={selectedIds.size === sortedIssues.length && sortedIssues.length > 0} onChange={toggleSelectAll} className="rounded border-border" /> <input type="checkbox" checked={selectedIds.size === sortedIssues.length && sortedIssues.length > 0} onChange={toggleSelectAll} className="rounded border-border" />
</th> </th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleSort('title')}> <th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleSort('title')}>
{t('issues.tableTitle')} <SortIcon col="title" /> {t('issues.tableTitle')} <SortIcon col="title" />
</th> </th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('issues.tableSubmitter')}</th> <th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('issues.tableSubmitter')}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('issues.tableBrand')}</th> <th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('issues.tableBrand')}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('issues.tableCategory')}</th> <th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('issues.tableCategory')}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('issues.tableType')}</th> <th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('issues.tableType')}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleSort('priority')}> <th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleSort('priority')}>
{t('issues.tablePriority')} <SortIcon col="priority" /> {t('issues.tablePriority')} <SortIcon col="priority" />
</th> </th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleSort('status')}> <th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleSort('status')}>
{t('issues.tableStatus')} <SortIcon col="status" /> {t('issues.tableStatus')} <SortIcon col="status" />
</th> </th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('issues.tableAssignedTo')}</th> <th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('issues.tableAssignedTo')}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleSort('created_at')}> <th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleSort('created_at')}>
{t('issues.tableCreated')} <SortIcon col="created_at" /> {t('issues.tableCreated')} <SortIcon col="created_at" />
</th> </th>
</tr> </tr>
+33 -21
View File
@@ -2,9 +2,18 @@ import { useState, useEffect } from 'react'
import { useNavigate, Link } from 'react-router-dom' import { useNavigate, Link } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../i18n/LanguageContext' import { useLanguage } from '../i18n/LanguageContext'
import { Megaphone, Lock, Mail, AlertCircle, User, CheckCircle } from 'lucide-react' import { Lock, Mail, AlertCircle, User, CheckCircle } from 'lucide-react'
import { api } from '../utils/api' import { api } from '../utils/api'
function MarkaLogo({ className = '' }) {
return (
<svg viewBox="0 0 32 32" fill="none" className={className}>
<path d="M4 26V6l10 10L4 26z" fill="currentColor" opacity="0.85" />
<path d="M18 26V6l10 10-10 10z" fill="currentColor" opacity="0.5" />
</svg>
)
}
export default function Login() { export default function Login() {
const navigate = useNavigate() const navigate = useNavigate()
const { login } = useAuth() const { login } = useAuth()
@@ -63,19 +72,19 @@ export default function Login() {
if (needsSetup === null) { if (needsSetup === null) {
return ( return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-blue-900 to-slate-900 flex items-center justify-center"> <div className="min-h-screen bg-slate-900 flex items-center justify-center">
<div className="w-8 h-8 border-2 border-white/30 border-t-white rounded-full animate-spin" /> <div className="w-8 h-8 border-2 border-white/30 border-t-white rounded-full animate-spin" />
</div> </div>
) )
} }
return ( 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="min-h-screen bg-slate-900 flex items-center justify-center px-4">
<div className="w-full max-w-md"> <div className="w-full max-w-md">
{/* Logo & Title */} {/* Logo & Title */}
<div className="text-center mb-8"> <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"> <div className="w-16 h-16 bg-brand-primary rounded-2xl flex items-center justify-center mx-auto mb-4">
<Megaphone className="w-8 h-8 text-white" /> <MarkaLogo className="w-9 h-9 text-white" />
</div> </div>
<h1 className="text-3xl font-bold text-white mb-2"> <h1 className="text-3xl font-bold text-white mb-2">
{needsSetup ? t('login.initialSetup') : t('login.title')} {needsSetup ? t('login.initialSetup') : t('login.title')}
@@ -101,15 +110,16 @@ export default function Login() {
<div> <div>
<label className="block text-sm font-medium text-slate-300 mb-2">{t('login.fullName')}</label> <label className="block text-sm font-medium text-slate-300 mb-2">{t('login.fullName')}</label>
<div className="relative"> <div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" /> <User className="absolute start-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<input <input
type="text" type="text"
value={setupName} value={setupName}
onChange={(e) => setSetupName(e.target.value)} onChange={(e) => setSetupName(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" className="w-full ps-11 pe-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={t('login.fullNamePlaceholder')} placeholder={t('login.fullNamePlaceholder')}
required required
autoFocus autoFocus
aria-describedby={error ? 'setup-error' : undefined}
/> />
</div> </div>
</div> </div>
@@ -118,13 +128,13 @@ export default function Login() {
<div> <div>
<label className="block text-sm font-medium text-slate-300 mb-2">{t('login.email')}</label> <label className="block text-sm font-medium text-slate-300 mb-2">{t('login.email')}</label>
<div className="relative"> <div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" /> <Mail className="absolute start-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<input <input
type="email" type="email"
value={setupEmail} value={setupEmail}
onChange={(e) => setSetupEmail(e.target.value)} onChange={(e) => setSetupEmail(e.target.value)}
dir="auto" 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" className="w-full ps-11 pe-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="admin@company.com" placeholder="admin@company.com"
required required
/> />
@@ -135,12 +145,12 @@ export default function Login() {
<div> <div>
<label className="block text-sm font-medium text-slate-300 mb-2">{t('login.password')}</label> <label className="block text-sm font-medium text-slate-300 mb-2">{t('login.password')}</label>
<div className="relative"> <div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" /> <Lock className="absolute start-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<input <input
type="password" type="password"
value={setupPassword} value={setupPassword}
onChange={(e) => setSetupPassword(e.target.value)} onChange={(e) => setSetupPassword(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" className="w-full ps-11 pe-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={t('login.passwordPlaceholder')} placeholder={t('login.passwordPlaceholder')}
required required
minLength={6} minLength={6}
@@ -152,12 +162,12 @@ export default function Login() {
<div> <div>
<label className="block text-sm font-medium text-slate-300 mb-2">{t('login.confirmPassword')}</label> <label className="block text-sm font-medium text-slate-300 mb-2">{t('login.confirmPassword')}</label>
<div className="relative"> <div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" /> <Lock className="absolute start-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<input <input
type="password" type="password"
value={setupConfirm} value={setupConfirm}
onChange={(e) => setSetupConfirm(e.target.value)} onChange={(e) => setSetupConfirm(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" className="w-full ps-11 pe-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={t('login.confirmPasswordPlaceholder')} placeholder={t('login.confirmPasswordPlaceholder')}
required required
minLength={6} minLength={6}
@@ -167,7 +177,7 @@ export default function Login() {
{/* Error */} {/* Error */}
{error && ( {error && (
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg"> <div id="setup-error" className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg" role="alert">
<AlertCircle className="w-5 h-5 text-red-400 shrink-0" /> <AlertCircle className="w-5 h-5 text-red-400 shrink-0" />
<p className="text-sm text-red-400">{error}</p> <p className="text-sm text-red-400">{error}</p>
</div> </div>
@@ -177,7 +187,7 @@ export default function Login() {
<button <button
type="submit" type="submit"
disabled={loading} 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" className="w-full py-3 bg-brand-primary hover:bg-brand-primary-light text-white font-semibold rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
> >
{loading ? ( {loading ? (
<span className="flex items-center justify-center gap-2"> <span className="flex items-center justify-center gap-2">
@@ -197,16 +207,17 @@ export default function Login() {
{t('auth.email')} {t('auth.email')}
</label> </label>
<div className="relative"> <div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" /> <Mail className="absolute start-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<input <input
type="email" type="email"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
dir="auto" 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" className="w-full ps-11 pe-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="user@company.com" placeholder="user@company.com"
required required
autoFocus autoFocus
aria-describedby={error ? 'login-error' : undefined}
/> />
</div> </div>
</div> </div>
@@ -217,21 +228,22 @@ export default function Login() {
{t('auth.password')} {t('auth.password')}
</label> </label>
<div className="relative"> <div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" /> <Lock className="absolute start-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<input <input
type="password" type="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} 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" className="w-full ps-11 pe-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="••••••••" placeholder="••••••••"
required required
aria-describedby={error ? 'login-error' : undefined}
/> />
</div> </div>
</div> </div>
{/* Error */} {/* Error */}
{error && ( {error && (
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg"> <div id="login-error" className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg" role="alert">
<AlertCircle className="w-5 h-5 text-red-400 shrink-0" /> <AlertCircle className="w-5 h-5 text-red-400 shrink-0" />
<p className="text-sm text-red-400">{error}</p> <p className="text-sm text-red-400">{error}</p>
</div> </div>
@@ -241,7 +253,7 @@ export default function Login() {
<button <button
type="submit" type="submit"
disabled={loading} 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" className="w-full py-3 bg-brand-primary hover:bg-brand-primary-light text-white font-semibold rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
> >
{loading ? ( {loading ? (
<span className="flex items-center justify-center gap-2"> <span className="flex items-center justify-center gap-2">
+6 -14
View File
@@ -158,14 +158,6 @@ export default function PostCalendar() {
return ( return (
<div className="space-y-4 animate-fade-in"> <div className="space-y-4 animate-fade-in">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-text-primary">Content Calendar</h1>
<p className="text-sm text-text-secondary mt-1">Schedule and plan your posts</p>
</div>
</div>
{/* Filters */} {/* Filters */}
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<select <select
@@ -220,14 +212,14 @@ export default function PostCalendar() {
<div className="flex bg-surface-tertiary rounded-lg p-0.5"> <div className="flex bg-surface-tertiary rounded-lg p-0.5">
<button <button
onClick={() => setCalView('month')} onClick={() => setCalView('month')}
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors flex items-center gap-1.5 ${calView === 'month' ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary'}`} className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors flex items-center gap-1.5 ${calView === 'month' ? 'bg-surface shadow-sm text-text-primary' : 'text-text-tertiary'}`}
> >
<CalendarIcon className="w-3.5 h-3.5" /> <CalendarIcon className="w-3.5 h-3.5" />
Month Month
</button> </button>
<button <button
onClick={() => setCalView('week')} onClick={() => setCalView('week')}
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors flex items-center gap-1.5 ${calView === 'week' ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary'}`} className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors flex items-center gap-1.5 ${calView === 'week' ? 'bg-surface shadow-sm text-text-primary' : 'text-text-tertiary'}`}
> >
<CalendarDays className="w-3.5 h-3.5" /> <CalendarDays className="w-3.5 h-3.5" />
Week Week
@@ -271,7 +263,7 @@ export default function PostCalendar() {
<button <button
key={post.Id || post._id} key={post.Id || post._id}
onClick={() => handlePostClick(post)} onClick={() => handlePostClick(post)}
className={`w-full text-left text-[10px] px-2 py-1 rounded font-medium hover:opacity-80 transition-opacity truncate ${ className={`w-full text-start text-[10px] px-2 py-1 rounded font-medium hover:opacity-80 transition-opacity truncate ${
STATUS_COLORS[post.status] || 'bg-surface-tertiary text-text-secondary' STATUS_COLORS[post.status] || 'bg-surface-tertiary text-text-secondary'
}`} }`}
title={post.title} title={post.title}
@@ -294,13 +286,13 @@ export default function PostCalendar() {
{/* Unscheduled Posts */} {/* Unscheduled Posts */}
{unscheduled.length > 0 && ( {unscheduled.length > 0 && (
<div className="bg-surface rounded-xl border border-border p-6"> <div className="bg-surface rounded-xl border border-border p-6">
<h3 className="text-sm font-semibold text-text-tertiary uppercase mb-3">Unscheduled Posts</h3> <h3 className="text-sm font-semibold text-text-tertiary uppercase mb-3">{t('calendar.unscheduledPosts')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{unscheduled.map(post => ( {unscheduled.map(post => (
<button <button
key={post.Id || post._id} key={post.Id || post._id}
onClick={() => handlePostClick(post)} onClick={() => handlePostClick(post)}
className="text-left bg-surface-secondary border border-border rounded-lg p-3 hover:border-brand-primary/30 transition-colors" className="text-start bg-surface-secondary border border-border rounded-lg p-3 hover:border-brand-primary/30 transition-colors"
> >
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<span className={`text-xs px-2 py-0.5 rounded-full ${STATUS_COLORS[post.status] || 'bg-surface-tertiary text-text-secondary'}`}> <span className={`text-xs px-2 py-0.5 rounded-full ${STATUS_COLORS[post.status] || 'bg-surface-tertiary text-text-secondary'}`}>
@@ -319,7 +311,7 @@ export default function PostCalendar() {
{/* Legend */} {/* Legend */}
<div className="bg-surface rounded-xl border border-border p-4"> <div className="bg-surface rounded-xl border border-border p-4">
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-3">Status Legend</h4> <h4 className="text-xs font-semibold text-text-tertiary uppercase mb-3">{t('calendar.statusLegend')}</h4>
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
{Object.entries(STATUS_COLORS).map(([status, color]) => ( {Object.entries(STATUS_COLORS).map(([status, color]) => (
<div key={status} className="flex items-center gap-2"> <div key={status} className="flex items-center gap-2">
+21 -21
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useContext } from 'react' import { useState, useEffect, useContext, useMemo } from 'react'
import { Plus, LayoutGrid, List, Search, X, FileText, Filter } from 'lucide-react' import { Plus, LayoutGrid, List, Search, X, FileText, Filter } from 'lucide-react'
import { AppContext } from '../App' import { AppContext } from '../App'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
@@ -167,7 +167,7 @@ export default function PostProduction() {
} }
} }
const filteredPosts = posts.filter(p => { const filteredPosts = useMemo(() => posts.filter(p => {
if (filters.brand && String(p.brandId || p.brand_id) !== filters.brand) return false 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.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.assignedTo && String(p.assignedTo || p.assigned_to) !== filters.assignedTo) return false
@@ -181,7 +181,7 @@ export default function PostProduction() {
if (filters.periodTo && d > filters.periodTo) return false if (filters.periodTo && d > filters.periodTo) return false
} }
return true return true
}) }), [posts, filters, searchTerm])
if (loading) { if (loading) {
return view === 'kanban' ? <SkeletonKanbanBoard /> : <SkeletonTable rows={8} cols={6} /> return view === 'kanban' ? <SkeletonKanbanBoard /> : <SkeletonTable rows={8} cols={6} />
@@ -193,20 +193,20 @@ export default function PostProduction() {
<div className="space-y-2"> <div className="space-y-2">
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<div className="relative flex-1 min-w-[200px] max-w-md"> <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" /> <Search className="absolute start-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<input <input
type="text" type="text"
placeholder={t('posts.searchPosts')} placeholder={t('posts.searchPosts')}
value={searchTerm} value={searchTerm}
onChange={e => setSearchTerm(e.target.value)} 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" className="w-full ps-10 pe-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-surface"
/> />
</div> </div>
<button <button
data-tutorial="filters" data-tutorial="filters"
onClick={() => setShowFilters(f => !f)} onClick={() => setShowFilters(f => !f)}
className={`relative flex items-center gap-1.5 px-3 py-2 text-sm border rounded-lg transition-colors ${showFilters ? 'border-brand-primary bg-brand-primary/5 text-brand-primary' : 'border-border bg-white text-text-secondary hover:border-brand-primary/40'}`} className={`relative flex items-center gap-1.5 px-3 py-2 text-sm border rounded-lg transition-colors ${showFilters ? 'border-brand-primary bg-brand-primary/5 text-brand-primary' : 'border-border bg-surface text-text-secondary hover:border-brand-primary/40'}`}
> >
<Filter className="w-4 h-4" /> <Filter className="w-4 h-4" />
{t('common.filter')} {t('common.filter')}
@@ -215,16 +215,16 @@ export default function PostProduction() {
)} )}
</button> </button>
<div className="flex bg-surface-tertiary rounded-lg p-0.5 ml-auto"> <div className="flex bg-surface-tertiary rounded-lg p-0.5 ms-auto">
<button <button
onClick={() => setView('kanban')} onClick={() => setView('kanban')}
className={`p-2 rounded-md ${view === 'kanban' ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary'}`} className={`p-2 rounded-md ${view === 'kanban' ? 'bg-surface shadow-sm text-text-primary' : 'text-text-tertiary'}`}
> >
<LayoutGrid className="w-4 h-4" /> <LayoutGrid className="w-4 h-4" />
</button> </button>
<button <button
onClick={() => setView('list')} onClick={() => setView('list')}
className={`p-2 rounded-md ${view === 'list' ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary'}`} className={`p-2 rounded-md ${view === 'list' ? 'bg-surface shadow-sm text-text-primary' : 'text-text-tertiary'}`}
> >
<List className="w-4 h-4" /> <List className="w-4 h-4" />
</button> </button>
@@ -245,7 +245,7 @@ export default function PostProduction() {
<select <select
value={filters.brand} value={filters.brand}
onChange={e => setFilters(f => ({ ...f, brand: e.target.value }))} onChange={e => setFilters(f => ({ ...f, brand: e.target.value }))}
className="text-xs border border-border rounded-lg px-2.5 py-1.5 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20" className="text-xs border border-border rounded-lg px-2.5 py-1.5 bg-surface text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
> >
<option value="">{t('posts.allBrands')}</option> <option value="">{t('posts.allBrands')}</option>
{brands.map(b => <option key={b._id} value={b._id}>{lang === 'ar' && b.name_ar ? b.name_ar : b.name}</option>)} {brands.map(b => <option key={b._id} value={b._id}>{lang === 'ar' && b.name_ar ? b.name_ar : b.name}</option>)}
@@ -254,7 +254,7 @@ export default function PostProduction() {
<select <select
value={filters.platform} value={filters.platform}
onChange={e => setFilters(f => ({ ...f, platform: e.target.value }))} onChange={e => setFilters(f => ({ ...f, platform: e.target.value }))}
className="text-xs border border-border rounded-lg px-2.5 py-1.5 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20" className="text-xs border border-border rounded-lg px-2.5 py-1.5 bg-surface text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
> >
<option value="">{t('posts.allPlatforms')}</option> <option value="">{t('posts.allPlatforms')}</option>
{Object.entries(PLATFORMS).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)} {Object.entries(PLATFORMS).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
@@ -263,7 +263,7 @@ export default function PostProduction() {
<select <select
value={filters.assignedTo} value={filters.assignedTo}
onChange={e => setFilters(f => ({ ...f, assignedTo: e.target.value }))} onChange={e => setFilters(f => ({ ...f, assignedTo: e.target.value }))}
className="text-xs border border-border rounded-lg px-2.5 py-1.5 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20" className="text-xs border border-border rounded-lg px-2.5 py-1.5 bg-surface text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
> >
<option value="">{t('posts.allPeople')}</option> <option value="">{t('posts.allPeople')}</option>
{teamMembers.map(m => <option key={m._id} value={String(m._id)}>{m.name}</option>)} {teamMembers.map(m => <option key={m._id} value={String(m._id)}>{m.name}</option>)}
@@ -281,7 +281,7 @@ export default function PostProduction() {
value={filters.periodFrom} value={filters.periodFrom}
onChange={e => { setFilters(f => ({ ...f, periodFrom: e.target.value })); setActivePreset('') }} onChange={e => { setFilters(f => ({ ...f, periodFrom: e.target.value })); setActivePreset('') }}
title={t('posts.periodFrom')} title={t('posts.periodFrom')}
className="text-xs border border-border rounded-lg px-2 py-1.5 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20" className="text-xs border border-border rounded-lg px-2 py-1.5 bg-surface text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
/> />
<span className="text-xs text-text-tertiary"></span> <span className="text-xs text-text-tertiary"></span>
<input <input
@@ -289,7 +289,7 @@ export default function PostProduction() {
value={filters.periodTo} value={filters.periodTo}
onChange={e => { setFilters(f => ({ ...f, periodTo: e.target.value })); setActivePreset('') }} onChange={e => { setFilters(f => ({ ...f, periodTo: e.target.value })); setActivePreset('') }}
title={t('posts.periodTo')} title={t('posts.periodTo')}
className="text-xs border border-border rounded-lg px-2 py-1.5 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20" className="text-xs border border-border rounded-lg px-2 py-1.5 bg-surface text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
/> />
</div> </div>
</div> </div>
@@ -334,7 +334,7 @@ export default function PostProduction() {
}} }}
/> />
) : ( ) : (
<div className="bg-white rounded-xl border border-border overflow-hidden"> <div className="bg-surface rounded-xl border border-border overflow-hidden">
{filteredPosts.length === 0 ? ( {filteredPosts.length === 0 ? (
<EmptyState <EmptyState
icon={FileText} icon={FileText}
@@ -361,12 +361,12 @@ export default function PostProduction() {
<th className="w-10 px-3 py-3" onClick={e => e.stopPropagation()}> <th className="w-10 px-3 py-3" onClick={e => e.stopPropagation()}>
<input type="checkbox" checked={selectedIds.size === filteredPosts.length && filteredPosts.length > 0} onChange={toggleSelectAll} className="rounded border-border" /> <input type="checkbox" checked={selectedIds.size === filteredPosts.length && filteredPosts.length > 0} onChange={toggleSelectAll} className="rounded border-border" />
</th> </th>
<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-start 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-start 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-start 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-start 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-start 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> <th className="text-start px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.scheduledDate')}</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-border-light"> <tbody className="divide-y divide-border-light">
+19 -19
View File
@@ -223,14 +223,14 @@ export default function ProjectDetail() {
</button> </button>
{/* Project header */} {/* Project header */}
<div className="bg-white rounded-xl border border-border overflow-hidden"> <div className="bg-surface rounded-xl border border-border overflow-hidden">
{/* Thumbnail banner */} {/* Thumbnail banner */}
{(project.thumbnail_url || project.thumbnailUrl) && ( {(project.thumbnail_url || project.thumbnailUrl) && (
<div className="relative w-full h-40 overflow-hidden"> <div className="relative w-full h-40 overflow-hidden">
<img src={project.thumbnail_url || project.thumbnailUrl} alt="" className="w-full h-full object-cover" /> <img src={project.thumbnail_url || project.thumbnailUrl} alt="" className="w-full h-full object-cover" loading="lazy" />
<div className="absolute inset-0 bg-gradient-to-t from-black/30 to-transparent" /> <div className="absolute inset-0 bg-gradient-to-t from-black/30 to-transparent" />
{canEditProject && ( {canEditProject && (
<div className="absolute top-2 right-2 flex items-center gap-1"> <div className="absolute top-2 end-2 flex items-center gap-1">
<button <button
onClick={() => thumbnailInputRef.current?.click()} onClick={() => thumbnailInputRef.current?.click()}
className="px-2 py-1 text-xs bg-black/40 hover:bg-black/60 rounded text-white transition-colors" className="px-2 py-1 text-xs bg-black/40 hover:bg-black/60 rounded text-white transition-colors"
@@ -341,7 +341,7 @@ export default function ProjectDetail() {
key={v.id} key={v.id}
onClick={() => setView(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 ${ 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' view === v.id ? 'bg-surface shadow-sm text-text-primary' : 'text-text-tertiary hover:text-text-secondary'
}`} }`}
> >
<v.icon className="w-4 h-4" /> <v.icon className="w-4 h-4" />
@@ -411,21 +411,21 @@ export default function ProjectDetail() {
{/* ─── LIST VIEW ─── */} {/* ─── LIST VIEW ─── */}
{view === 'list' && ( {view === 'list' && (
<div className="bg-white rounded-xl border border-border overflow-hidden"> <div className="bg-surface rounded-xl border border-border overflow-hidden">
<table className="w-full"> <table className="w-full">
<thead> <thead>
<tr className="border-b border-border bg-surface-secondary"> <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-start 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-start 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-start 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-start 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-start 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-start px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Due</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-border-light"> <tbody className="divide-y divide-border-light">
{tasks.length === 0 ? ( {tasks.length === 0 ? (
<tr><td colSpan={6} className="py-12 text-center text-sm text-text-tertiary">No tasks yet</td></tr> <tr><td colSpan={6} className="py-12 text-center text-sm text-text-tertiary">{t('tasks.noTasks')}</td></tr>
) : ( ) : (
tasks.map(task => { tasks.map(task => {
const prio = PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium const prio = PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium
@@ -470,7 +470,7 @@ export default function ProjectDetail() {
{/* ─── DISCUSSION SIDEBAR ─── */} {/* ─── DISCUSSION SIDEBAR ─── */}
{showDiscussion && ( {showDiscussion && (
<div className="w-[340px] shrink-0 bg-white rounded-xl border border-border flex flex-col self-start sticky top-4" style={{ maxHeight: 'calc(100vh - 6rem)' }}> <div className="w-[340px] shrink-0 bg-surface rounded-xl border border-border flex flex-col self-start sticky top-4" style={{ maxHeight: 'calc(100vh - 6rem)' }}>
<div className="flex items-center justify-between px-4 py-3 border-b border-border"> <div className="flex items-center justify-between px-4 py-3 border-b border-border">
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-1.5"> <h3 className="text-sm font-semibold text-text-primary flex items-center gap-1.5">
<MessageCircle className="w-4 h-4" /> <MessageCircle className="w-4 h-4" />
@@ -539,7 +539,7 @@ function TaskKanbanCard({ task, canEdit, canDelete, onClick, onDelete, onStatusC
onDragStart={(e) => canEdit && onDragStart(e, task)} onDragStart={(e) => canEdit && onDragStart(e, task)}
onDragEnd={onDragEnd} onDragEnd={onDragEnd}
onClick={onClick} onClick={onClick}
className={`bg-white rounded-lg border border-border p-3 group hover:border-brand-primary/30 hover:shadow-sm transition-all cursor-pointer ${canEdit ? 'active:cursor-grabbing' : ''}`} className={`bg-surface rounded-lg border border-border p-3 group hover:border-brand-primary/30 hover:shadow-sm transition-all cursor-pointer ${canEdit ? 'active:cursor-grabbing' : ''}`}
> >
<div className="flex items-start gap-2"> <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={`w-2 h-2 rounded-full ${priority.color} mt-1.5 shrink-0`} title={priority.label} />
@@ -572,7 +572,7 @@ function TaskKanbanCard({ task, canEdit, canDelete, onClick, onDelete, onStatusC
)} )}
{canDelete && ( {canDelete && (
<button onClick={(e) => { e.stopPropagation(); onDelete() }} <button onClick={(e) => { e.stopPropagation(); 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"> className="text-[10px] text-red-400 hover:bg-red-50 px-2 py-0.5 rounded-full flex items-center gap-1 ms-auto">
<Trash2 className="w-3 h-3" /> <Trash2 className="w-3 h-3" />
</button> </button>
)} )}
@@ -614,7 +614,7 @@ function GanttView({ tasks, project, onEditTask, onTaskColorChange }) {
if (tasks.length === 0) { if (tasks.length === 0) {
return ( return (
<div className="bg-white rounded-xl border border-border py-16 text-center"> <div className="bg-surface rounded-xl border border-border py-16 text-center">
<GanttChart className="w-12 h-12 text-text-tertiary mx-auto mb-3" /> <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-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> <p className="text-sm text-text-tertiary mt-1">Add tasks with due dates to see the timeline</p>
@@ -666,7 +666,7 @@ function GanttView({ tasks, project, onEditTask, onTaskColorChange }) {
} }
return ( return (
<div className="bg-white rounded-xl border border-border overflow-hidden"> <div className="bg-surface rounded-xl border border-border overflow-hidden">
{/* Zoom toolbar */} {/* Zoom toolbar */}
<div className="flex items-center justify-between px-4 py-2 border-b border-border bg-surface-secondary"> <div className="flex items-center justify-between px-4 py-2 border-b border-border bg-surface-secondary">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -757,7 +757,7 @@ function GanttView({ tasks, project, onEditTask, onTaskColorChange }) {
)} )}
{!onTaskColorChange && <div className={`w-2 h-2 rounded-full ${prio.color} shrink-0`} />} {!onTaskColorChange && <div className={`w-2 h-2 rounded-full ${prio.color} shrink-0`} />}
<button onClick={() => onEditTask(task)} <button onClick={() => onEditTask(task)}
className="text-xs font-medium text-text-primary truncate hover:text-brand-primary text-left"> className="text-xs font-medium text-text-primary truncate hover:text-brand-primary text-start">
{task.title} {task.title}
</button> </button>
</div> </div>
@@ -787,7 +787,7 @@ function GanttView({ tasks, project, onEditTask, onTaskColorChange }) {
{colorPicker && onTaskColorChange && ( {colorPicker && onTaskColorChange && (
<div <div
ref={colorPickerRef} ref={colorPickerRef}
className="fixed z-50 bg-white rounded-lg shadow-xl border border-border p-2" className="fixed z-50 bg-surface rounded-lg shadow-xl border border-border p-2"
style={{ left: colorPicker.x, top: colorPicker.y }} style={{ left: colorPicker.x, top: colorPicker.y }}
> >
<div className="grid grid-cols-4 gap-1.5 mb-2"> <div className="grid grid-cols-4 gap-1.5 mb-2">
+4 -4
View File
@@ -80,13 +80,13 @@ export default function Projects() {
{/* Toolbar */} {/* Toolbar */}
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<div className="relative flex-1 min-w-[200px] max-w-md"> <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" /> <Search className="absolute start-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<input <input
type="text" type="text"
placeholder="Search projects..." placeholder="Search projects..."
value={searchTerm} value={searchTerm}
onChange={e => setSearchTerm(e.target.value)} 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" className="w-full ps-10 pe-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-surface"
/> />
</div> </div>
@@ -100,7 +100,7 @@ export default function Projects() {
key={v.id} key={v.id}
onClick={() => setView(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 ${ 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' view === v.id ? 'bg-surface shadow-sm text-text-primary' : 'text-text-tertiary hover:text-text-secondary'
}`} }`}
> >
<v.icon className="w-4 h-4" /> <v.icon className="w-4 h-4" />
@@ -112,7 +112,7 @@ export default function Projects() {
{permissions?.canCreateProjects && ( {permissions?.canCreateProjects && (
<button <button
onClick={() => setShowModal(true)} 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" 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 ms-auto"
> >
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
New Project New Project
+246
View File
@@ -0,0 +1,246 @@
import { useState, useEffect } from 'react'
import { useParams } from 'react-router-dom'
import { CheckCircle, XCircle, DollarSign, User, FileText, Clock, Sparkles } from 'lucide-react'
import { useLanguage } from '../i18n/LanguageContext'
export default function PublicBudgetApproval() {
const { token } = useParams()
const { t, currencySymbol } = useLanguage()
const [request, setRequest] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [expired, setExpired] = useState(false)
const [success, setSuccess] = useState('')
const [note, setNote] = useState('')
const [submitting, setSubmitting] = useState(false)
useEffect(() => { loadRequest() }, [token])
const loadRequest = async () => {
try {
const res = await fetch(`/api/budget-approval/${token}`)
if (!res.ok) {
const err = await res.json()
if (res.status === 410 || err.error?.toLowerCase().includes('expired')) {
setExpired(true)
} else {
setError(err.error || t('budgetApproval.loadFailed') || 'Failed to load request')
}
setLoading(false)
return
}
const data = await res.json()
setRequest(data)
} catch {
setError(t('budgetApproval.loadFailed') || 'Failed to load request')
} finally {
setLoading(false)
}
}
const handleAction = async (action) => {
setSubmitting(true)
try {
const res = await fetch(`/api/budget-approval/${token}/respond`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action, note: note.trim() || undefined }),
})
if (!res.ok) {
const err = await res.json()
setError(err.error || t('budgetApproval.actionFailed') || 'Action failed')
setSubmitting(false)
return
}
setSuccess(action === 'approve'
? (t('budgetApproval.approved') || 'Budget request approved')
: (t('budgetApproval.rejected') || 'Budget request rejected'))
} catch {
setError(t('budgetApproval.actionFailed') || 'Action failed')
} finally {
setSubmitting(false)
}
}
// Loading state
if (loading) {
return (
<div className="min-h-screen bg-slate-900 flex items-center justify-center">
<div className="w-12 h-12 border-4 border-brand-primary border-t-transparent rounded-full animate-spin" />
</div>
)
}
// Expired state
if (expired) {
return (
<div className="min-h-screen bg-slate-900 flex items-center justify-center p-4">
<div className="max-w-md w-full bg-white rounded-2xl shadow-2xl p-8 text-center">
<div className="w-16 h-16 rounded-full bg-amber-100 flex items-center justify-center mx-auto mb-4">
<Clock className="w-8 h-8 text-amber-600" />
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">{t('budgetApproval.expired') || 'Request Expired'}</h2>
<p className="text-gray-500">{t('budgetApproval.expiredDesc') || 'This budget approval request has expired.'}</p>
</div>
</div>
)
}
// Error state
if (error) {
return (
<div className="min-h-screen bg-slate-900 flex items-center justify-center p-4">
<div className="max-w-md w-full bg-white rounded-2xl shadow-2xl p-8 text-center">
<div className="w-16 h-16 rounded-full bg-red-100 flex items-center justify-center mx-auto mb-4">
<XCircle className="w-8 h-8 text-red-600" />
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">{t('budgetApproval.error') || 'Error'}</h2>
<p className="text-gray-500">{error}</p>
</div>
</div>
)
}
// Success state
if (success) {
const isApproved = success.toLowerCase().includes('approved') || success.toLowerCase().includes('approve')
return (
<div className="min-h-screen bg-slate-900 flex items-center justify-center p-4">
<div className="max-w-md w-full bg-white rounded-2xl shadow-2xl p-8 text-center">
<div className={`w-16 h-16 rounded-full ${isApproved ? 'bg-emerald-100' : 'bg-red-100'} flex items-center justify-center mx-auto mb-4`}>
{isApproved
? <CheckCircle className="w-8 h-8 text-emerald-600" />
: <XCircle className="w-8 h-8 text-red-600" />
}
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">{t('budgetApproval.thankYou') || 'Thank You'}</h2>
<p className="text-gray-500">{success}</p>
</div>
</div>
)
}
if (!request) return null
// Already handled (not pending)
if (request.status && request.status !== 'pending') {
const statusLabel = request.status.charAt(0).toUpperCase() + request.status.slice(1)
return (
<div className="min-h-screen bg-slate-900 flex items-center justify-center p-4">
<div className="max-w-md w-full bg-white rounded-2xl shadow-2xl p-8 text-center">
<div className="w-16 h-16 rounded-full bg-blue-100 flex items-center justify-center mx-auto mb-4">
<FileText className="w-8 h-8 text-blue-600" />
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">{t('budgetApproval.alreadyHandled') || 'Already Handled'}</h2>
<p className="text-gray-500">
{t('budgetApproval.statusIs') || 'This request has been'}: <span className="font-semibold">{statusLabel}</span>
</p>
</div>
</div>
)
}
// Active state — show request details + approve/reject
return (
<div className="min-h-screen bg-slate-900 flex items-center justify-center p-4 py-12">
<div className="max-w-lg w-full">
{/* Header card */}
<div className="bg-white rounded-2xl shadow-2xl overflow-hidden">
<div className="bg-brand-primary px-8 py-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-white/20 flex items-center justify-center">
<Sparkles className="w-6 h-6 text-white" />
</div>
<div>
<h1 className="text-xl font-bold text-white">{t('budgetApproval.title') || 'Budget Approval'}</h1>
<p className="text-white/80 text-sm">Rawaj</p>
</div>
</div>
</div>
<div className="p-8 space-y-6">
{/* Amount */}
<div className="text-center">
<div className="inline-flex items-center gap-2 bg-emerald-50 px-6 py-4 rounded-2xl">
<DollarSign className="w-6 h-6 text-emerald-600" />
<span className="text-3xl font-bold text-emerald-700">
{Number(request.amount).toLocaleString()} {currencySymbol}
</span>
</div>
</div>
{/* Requested by */}
{request.requested_by_name && (
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-slate-100 flex items-center justify-center shrink-0">
<User className="w-4 h-4 text-slate-500" />
</div>
<div>
<p className="text-xs text-gray-400 uppercase tracking-wider">{t('budgetApproval.requestedBy') || 'Requested by'}</p>
<p className="text-sm font-semibold text-gray-900">{request.requested_by_name}</p>
</div>
</div>
)}
{/* Justification */}
<div>
<p className="text-xs text-gray-400 uppercase tracking-wider mb-1">{t('budgetApproval.justification') || 'Justification'}</p>
<p className="text-sm text-gray-700 whitespace-pre-wrap bg-gray-50 rounded-xl p-4 border border-gray-100">
{request.justification}
</p>
</div>
{/* Earmarked for */}
{request.earmark_name && (
<div>
<p className="text-xs text-gray-400 uppercase tracking-wider mb-1">{t('budgetApproval.earmarkedFor') || 'Earmarked for'}</p>
<p className="text-sm font-medium text-gray-700">
{request.earmark_type && <span className="text-gray-400 capitalize">{request.earmark_type}: </span>}
{request.earmark_name}
</p>
</div>
)}
{/* Note textarea */}
<div>
<label className="block text-xs text-gray-400 uppercase tracking-wider mb-1">
{t('budgetApproval.note') || 'Note'} ({t('common.optional') || 'optional'})
</label>
<textarea
value={note}
onChange={e => setNote(e.target.value)}
rows={3}
placeholder={t('budgetApproval.notePlaceholder') || 'Add a note...'}
className="w-full px-4 py-2.5 text-sm border border-gray-200 rounded-xl bg-white text-gray-900 focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
/>
</div>
{/* Action buttons */}
<div className="grid grid-cols-2 gap-3 pt-2">
<button
onClick={() => handleAction('approve')}
disabled={submitting}
className="flex items-center justify-center gap-2 px-4 py-3.5 bg-emerald-600 text-white rounded-xl font-medium hover:bg-emerald-700 transition-colors disabled:opacity-50 shadow-sm"
>
<CheckCircle className="w-5 h-5" />
{t('budgetApproval.approve') || 'Approve'}
</button>
<button
onClick={() => handleAction('reject')}
disabled={submitting}
className="flex items-center justify-center gap-2 px-4 py-3.5 bg-red-600 text-white rounded-xl font-medium hover:bg-red-700 transition-colors disabled:opacity-50 shadow-sm"
>
<XCircle className="w-5 h-5" />
{t('budgetApproval.reject') || 'Reject'}
</button>
</div>
</div>
</div>
<div className="text-center text-slate-500 text-sm mt-6">
<p>{t('review.poweredBy') || 'Powered by Rawaj'}</p>
</div>
</div>
</div>
)
}
+7 -7
View File
@@ -174,11 +174,11 @@ export default function PublicIssueTracker() {
acknowledged: { label: t('acknowledged'), bg: 'bg-blue-100', text: 'text-blue-700', dot: 'bg-blue-500', icon: CheckCircle2 }, acknowledged: { label: t('acknowledged'), bg: 'bg-blue-100', text: 'text-blue-700', dot: 'bg-blue-500', icon: CheckCircle2 },
in_progress: { label: t('in_progress'), bg: 'bg-amber-100', text: 'text-amber-700', dot: 'bg-amber-500', icon: Clock }, in_progress: { label: t('in_progress'), bg: 'bg-amber-100', text: 'text-amber-700', dot: 'bg-amber-500', icon: Clock },
resolved: { label: t('resolved'), bg: 'bg-emerald-100', text: 'text-emerald-700', dot: 'bg-emerald-500', icon: CheckCircle2 }, resolved: { label: t('resolved'), bg: 'bg-emerald-100', text: 'text-emerald-700', dot: 'bg-emerald-500', icon: CheckCircle2 },
declined: { label: t('declined_status'), bg: 'bg-gray-100', text: 'text-gray-700', dot: 'bg-gray-500', icon: XCircle }, declined: { label: t('declined_status'), bg: 'bg-gray-100', text: 'text-text-secondary', dot: 'bg-gray-500', icon: XCircle },
} }
const PRIORITY_CONFIG = { const PRIORITY_CONFIG = {
low: { label: t('low'), color: 'text-gray-700' }, low: { label: t('low'), color: 'text-text-secondary' },
medium: { label: t('medium'), color: 'text-blue-700' }, medium: { label: t('medium'), color: 'text-blue-700' },
high: { label: t('high'), color: 'text-orange-700' }, high: { label: t('high'), color: 'text-orange-700' },
urgent: { label: t('urgent'), color: 'text-red-700' }, urgent: { label: t('urgent'), color: 'text-red-700' },
@@ -267,16 +267,16 @@ export default function PublicIssueTracker() {
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
{issue.status === 'resolved' {issue.status === 'resolved'
? <CheckCircle2 className="w-6 h-6 text-emerald-600 shrink-0 mt-1" /> ? <CheckCircle2 className="w-6 h-6 text-emerald-600 shrink-0 mt-1" />
: <XCircle className="w-6 h-6 text-gray-600 shrink-0 mt-1" />} : <XCircle className="w-6 h-6 text-text-secondary shrink-0 mt-1" />}
<div className="flex-1"> <div className="flex-1">
<h2 className={`text-lg font-bold mb-2 ${issue.status === 'resolved' ? 'text-emerald-900' : 'text-gray-900'}`}> <h2 className={`text-lg font-bold mb-2 ${issue.status === 'resolved' ? 'text-emerald-900' : 'text-text-primary'}`}>
{issue.status === 'resolved' ? t('resolution') : t('declined')} {issue.status === 'resolved' ? t('resolution') : t('declined')}
</h2> </h2>
<p className={`${issue.status === 'resolved' ? 'text-emerald-800' : 'text-gray-800'} whitespace-pre-wrap`}> <p className={`${issue.status === 'resolved' ? 'text-emerald-800' : 'text-text-primary'} whitespace-pre-wrap`}>
{issue.resolution_summary} {issue.resolution_summary}
</p> </p>
{issue.resolved_at && ( {issue.resolved_at && (
<p className={`text-sm mt-2 ${issue.status === 'resolved' ? 'text-emerald-600' : 'text-gray-600'}`}> <p className={`text-sm mt-2 ${issue.status === 'resolved' ? 'text-emerald-600' : 'text-text-secondary'}`}>
{dateFmt(issue.resolved_at)} {dateFmt(issue.resolved_at)}
</p> </p>
)} )}
@@ -303,7 +303,7 @@ export default function PublicIssueTracker() {
<div className="flex items-start justify-between gap-3 mb-2"> <div className="flex items-start justify-between gap-3 mb-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-semibold text-text-primary">{update.author_name}</span> <span className="font-semibold text-text-primary">{update.author_name}</span>
<span className={`text-xs px-2 py-0.5 rounded-full ${update.author_type === 'staff' ? 'bg-purple-100 text-purple-700' : 'bg-gray-200 text-gray-700'}`}> <span className={`text-xs px-2 py-0.5 rounded-full ${update.author_type === 'staff' ? 'bg-purple-100 text-purple-700' : 'bg-gray-200 text-text-secondary'}`}>
{update.author_type === 'staff' ? t('team') : t('you')} {update.author_type === 'staff' ? t('team') : t('you')}
</span> </span>
</div> </div>
+2 -2
View File
@@ -146,7 +146,7 @@ export default function PublicPostReview() {
</div> </div>
<div> <div>
<h1 className="text-2xl font-bold text-white">{t('review.postReview')}</h1> <h1 className="text-2xl font-bold text-white">{t('review.postReview')}</h1>
<p className="text-white/80 text-sm">Samaya Digital Hub</p> <p className="text-white/80 text-sm">Rawaj</p>
</div> </div>
</div> </div>
</div> </div>
@@ -181,7 +181,7 @@ export default function PublicPostReview() {
{images.map((att, idx) => ( {images.map((att, idx) => (
<a key={idx} href={att.url} target="_blank" rel="noopener noreferrer" <a key={idx} href={att.url} target="_blank" rel="noopener noreferrer"
className="block rounded-xl overflow-hidden border-2 border-border hover:border-brand-primary transition-colors shadow-sm"> className="block rounded-xl overflow-hidden border-2 border-border hover:border-brand-primary transition-colors shadow-sm">
<img src={att.url} alt={att.original_name || `Image ${idx + 1}`} className="w-full h-64 object-cover" /> <img src={att.url} alt={att.original_name || `Image ${idx + 1}`} className="w-full h-64 object-cover" loading="lazy" />
{att.original_name && ( {att.original_name && (
<div className="bg-surface-secondary px-4 py-2 border-t border-border"> <div className="bg-surface-secondary px-4 py-2 border-t border-border">
<p className="text-sm text-text-secondary truncate">{att.original_name}</p> <p className="text-sm text-text-secondary truncate">{att.original_name}</p>
+3 -1
View File
@@ -184,7 +184,7 @@ export default function PublicReview() {
</div> </div>
<div> <div>
<h1 className="text-2xl font-bold text-white">{t('review.contentReview')}</h1> <h1 className="text-2xl font-bold text-white">{t('review.contentReview')}</h1>
<p className="text-white/80 text-sm">Samaya Digital Hub</p> <p className="text-white/80 text-sm">Rawaj</p>
</div> </div>
</div> </div>
</div> </div>
@@ -281,6 +281,7 @@ export default function PublicReview() {
src={att.url} src={att.url}
alt={att.original_name || `Design ${idx + 1}`} alt={att.original_name || `Design ${idx + 1}`}
className="w-full h-64 object-cover" className="w-full h-64 object-cover"
loading="lazy"
/> />
{att.original_name && ( {att.original_name && (
<div className="bg-surface-secondary px-4 py-2 border-t border-border"> <div className="bg-surface-secondary px-4 py-2 border-t border-border">
@@ -354,6 +355,7 @@ export default function PublicReview() {
src={att.url} src={att.url}
alt={att.original_name} alt={att.original_name}
className="w-full h-48 object-cover" className="w-full h-48 object-cover"
loading="lazy"
/> />
<div className="bg-surface-secondary px-3 py-2 border-t border-border"> <div className="bg-surface-secondary px-3 py-2 border-t border-border">
<p className="text-xs text-text-secondary truncate">{att.original_name}</p> <p className="text-xs text-text-secondary truncate">{att.original_name}</p>
+1 -1
View File
@@ -350,7 +350,7 @@ export default function PublicTranslationReview() {
value={suggestionContent} value={suggestionContent}
onChange={e => setSuggestionContent(e.target.value)} onChange={e => setSuggestionContent(e.target.value)}
placeholder={t('translations.enterSuggestion')} placeholder={t('translations.enterSuggestion')}
className="w-full px-3 py-2 text-sm border border-amber-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-amber-400/30 min-h-[80px] resize-y bg-white" className="w-full px-3 py-2 text-sm border border-amber-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-amber-400/30 min-h-[80px] resize-y bg-surface"
/> />
<div className="flex items-center gap-2 mt-2"> <div className="flex items-center gap-2 mt-2">
<button <button
+19 -10
View File
@@ -1,9 +1,18 @@
import { useState } from 'react' import { useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom' import { Link, useSearchParams } from 'react-router-dom'
import { useLanguage } from '../i18n/LanguageContext' import { useLanguage } from '../i18n/LanguageContext'
import { Megaphone, Lock, AlertCircle, CheckCircle, ArrowLeft } from 'lucide-react' import { Lock, AlertCircle, CheckCircle, ArrowLeft } from 'lucide-react'
import { api } from '../utils/api' import { api } from '../utils/api'
function MarkaLogo({ className = '' }) {
return (
<svg viewBox="0 0 32 32" fill="none" className={className}>
<path d="M4 26V6l10 10L4 26z" fill="currentColor" opacity="0.85" />
<path d="M18 26V6l10 10-10 10z" fill="currentColor" opacity="0.5" />
</svg>
)
}
export default function ResetPassword() { export default function ResetPassword() {
const { t } = useLanguage() const { t } = useLanguage()
const [searchParams] = useSearchParams() const [searchParams] = useSearchParams()
@@ -16,7 +25,7 @@ export default function ResetPassword() {
if (!token) { if (!token) {
return ( 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="min-h-screen bg-slate-900 flex items-center justify-center px-4">
<div className="w-full max-w-md text-center"> <div className="w-full max-w-md text-center">
<div className="bg-slate-800/50 backdrop-blur-sm rounded-2xl border border-slate-700/50 p-8 shadow-2xl"> <div className="bg-slate-800/50 backdrop-blur-sm rounded-2xl border border-slate-700/50 p-8 shadow-2xl">
<AlertCircle className="w-12 h-12 text-red-400 mx-auto mb-4" /> <AlertCircle className="w-12 h-12 text-red-400 mx-auto mb-4" />
@@ -51,11 +60,11 @@ export default function ResetPassword() {
} }
return ( 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="min-h-screen bg-slate-900 flex items-center justify-center px-4">
<div className="w-full max-w-md"> <div className="w-full max-w-md">
<div className="text-center mb-8"> <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"> <div className="w-16 h-16 bg-brand-primary rounded-2xl flex items-center justify-center mx-auto mb-4">
<Megaphone className="w-8 h-8 text-white" /> <MarkaLogo className="w-9 h-9 text-white" />
</div> </div>
<h1 className="text-3xl font-bold text-white mb-2">{t('resetPassword.title')}</h1> <h1 className="text-3xl font-bold text-white mb-2">{t('resetPassword.title')}</h1>
<p className="text-slate-400">{t('resetPassword.subtitle')}</p> <p className="text-slate-400">{t('resetPassword.subtitle')}</p>
@@ -81,12 +90,12 @@ export default function ResetPassword() {
<div> <div>
<label className="block text-sm font-medium text-slate-300 mb-2">{t('resetPassword.newPassword')}</label> <label className="block text-sm font-medium text-slate-300 mb-2">{t('resetPassword.newPassword')}</label>
<div className="relative"> <div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" /> <Lock className="absolute start-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<input <input
type="password" type="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} 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" className="w-full ps-11 pe-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="••••••••" placeholder="••••••••"
required required
minLength={6} minLength={6}
@@ -98,12 +107,12 @@ export default function ResetPassword() {
<div> <div>
<label className="block text-sm font-medium text-slate-300 mb-2">{t('resetPassword.confirmPassword')}</label> <label className="block text-sm font-medium text-slate-300 mb-2">{t('resetPassword.confirmPassword')}</label>
<div className="relative"> <div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" /> <Lock className="absolute start-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<input <input
type="password" type="password"
value={confirm} value={confirm}
onChange={(e) => setConfirm(e.target.value)} onChange={(e) => setConfirm(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" className="w-full ps-11 pe-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="••••••••" placeholder="••••••••"
required required
minLength={6} minLength={6}
@@ -121,7 +130,7 @@ export default function ResetPassword() {
<button <button
type="submit" type="submit"
disabled={loading} 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" className="w-full py-3 bg-brand-primary hover:bg-brand-primary-light text-white font-semibold rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
> >
{loading ? ( {loading ? (
<span className="flex items-center justify-center gap-2"> <span className="flex items-center justify-center gap-2">
+71 -15
View File
@@ -1,5 +1,5 @@
import { useState, useEffect, useContext } from 'react' import { useState, useEffect, useContext } from 'react'
import { Settings as SettingsIcon, Play, CheckCircle, Languages, Coins, Upload, Tag, Plus, Pencil, Trash2, X } from 'lucide-react' import { Settings as SettingsIcon, Play, CheckCircle, Languages, Coins, Upload, Tag, Plus, Pencil, Trash2, X, Mail } from 'lucide-react'
import { api } from '../utils/api' import { api } from '../utils/api'
import { useLanguage } from '../i18n/LanguageContext' import { useLanguage } from '../i18n/LanguageContext'
import { useToast } from '../components/ToastContainer' import { useToast } from '../components/ToastContainer'
@@ -23,9 +23,15 @@ export default function Settings() {
const [maxSizeMB, setMaxSizeMB] = useState(50) const [maxSizeMB, setMaxSizeMB] = useState(50)
const [sizeSaving, setSizeSaving] = useState(false) const [sizeSaving, setSizeSaving] = useState(false)
const [sizeSaved, setSizeSaved] = useState(false) const [sizeSaved, setSizeSaved] = useState(false)
const [ceoEmail, setCeoEmail] = useState('')
const [ceoSaving, setCeoSaving] = useState(false)
const [ceoSaved, setCeoSaved] = useState(false)
useEffect(() => { useEffect(() => {
api.get('/settings/app').then(s => setMaxSizeMB(s.uploadMaxSizeMB || 50)).catch(() => {}) api.get('/settings/app').then(s => {
setMaxSizeMB(s.uploadMaxSizeMB || 50)
if (s.ceoEmail) setCeoEmail(s.ceoEmail)
}).catch(() => {})
}, []) }, [])
const handleSaveMaxSize = async () => { const handleSaveMaxSize = async () => {
@@ -65,9 +71,9 @@ export default function Settings() {
<p className="text-sm text-text-tertiary">{t('settings.preferences')}</p> <p className="text-sm text-text-tertiary">{t('settings.preferences')}</p>
{/* General Settings */} {/* General Settings */}
<div className="bg-white rounded-xl border border-border overflow-hidden"> <div className="bg-surface rounded-xl border border-border overflow-hidden">
<div className="px-6 py-4 border-b border-border"> <div className="px-6 py-4 border-b border-border">
<h2 className="text-lg font-semibold text-text-primary">{t('settings.general')}</h2> <h3 className="font-semibold text-text-primary">{t('settings.general')}</h3>
</div> </div>
<div className="p-6 space-y-4"> <div className="p-6 space-y-4">
{/* Language Selector */} {/* Language Selector */}
@@ -79,7 +85,7 @@ export default function Settings() {
<select <select
value={lang} value={lang}
onChange={(e) => setLang(e.target.value)} 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" 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-surface"
> >
<option value="en">{t('settings.english')}</option> <option value="en">{t('settings.english')}</option>
<option value="ar">{t('settings.arabic')}</option> <option value="ar">{t('settings.arabic')}</option>
@@ -95,7 +101,7 @@ export default function Settings() {
<select <select
value={currency} value={currency}
onChange={(e) => setCurrency(e.target.value)} onChange={(e) => setCurrency(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" 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-surface"
> >
{CURRENCIES.map(c => ( {CURRENCIES.map(c => (
<option key={c.code} value={c.code}> <option key={c.code} value={c.code}>
@@ -109,12 +115,12 @@ export default function Settings() {
</div> </div>
{/* Uploads Section */} {/* Uploads Section */}
<div className="bg-white rounded-xl border border-border overflow-hidden"> <div className="bg-surface rounded-xl border border-border overflow-hidden">
<div className="px-6 py-4 border-b border-border"> <div className="px-6 py-4 border-b border-border">
<h2 className="text-lg font-semibold text-text-primary flex items-center gap-2"> <h3 className="font-semibold text-text-primary flex items-center gap-2">
<Upload className="w-5 h-5 text-brand-primary" /> <Upload className="w-5 h-5 text-brand-primary" />
{t('settings.uploads')} {t('settings.uploads')}
</h2> </h3>
</div> </div>
<div className="p-6 space-y-4"> <div className="p-6 space-y-4">
<div> <div>
@@ -128,7 +134,7 @@ export default function Settings() {
max="500" max="500"
value={maxSizeMB} value={maxSizeMB}
onChange={(e) => setMaxSizeMB(Number(e.target.value))} onChange={(e) => setMaxSizeMB(Number(e.target.value))}
className="w-24 px-3 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" className="w-24 px-3 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-surface"
/> />
<span className="text-sm text-text-secondary">{t('settings.mb')}</span> <span className="text-sm text-text-secondary">{t('settings.mb')}</span>
<button <button
@@ -147,9 +153,9 @@ export default function Settings() {
</div> </div>
{/* Tutorial Section */} {/* Tutorial Section */}
<div className="bg-white rounded-xl border border-border overflow-hidden"> <div className="bg-surface rounded-xl border border-border overflow-hidden">
<div className="px-6 py-4 border-b border-border"> <div className="px-6 py-4 border-b border-border">
<h2 className="text-lg font-semibold text-text-primary">{t('settings.onboardingTutorial')}</h2> <h3 className="font-semibold text-text-primary">{t('settings.onboardingTutorial')}</h3>
</div> </div>
<div className="p-6 space-y-4"> <div className="p-6 space-y-4">
<p className="text-sm text-text-secondary"> <p className="text-sm text-text-secondary">
@@ -180,6 +186,56 @@ export default function Settings() {
</div> </div>
</div> </div>
{/* Budget Approval (Superadmin only) */}
{user?.role === 'superadmin' && (
<div className="bg-surface rounded-xl border border-border overflow-hidden">
<div className="px-6 py-4 border-b border-border">
<h3 className="font-semibold text-text-primary flex items-center gap-2">
<Mail className="w-5 h-5 text-brand-primary" />
{t('settings.budgetApproval') || 'Budget Approval'}
</h3>
</div>
<div className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-2">
{t('settings.ceoEmail')}
</label>
<div className="flex items-center gap-3">
<input
type="email"
value={ceoEmail}
onChange={(e) => setCeoEmail(e.target.value)}
placeholder="ceo@company.com"
className="flex-1 max-w-sm 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-surface"
/>
<button
onClick={async () => {
setCeoSaving(true)
setCeoSaved(false)
try {
await api.patch('/settings/app', { ceoEmail })
setCeoSaved(true)
setTimeout(() => setCeoSaved(false), 2000)
} catch (err) {
toast.error(err.message || t('settings.saveFailed'))
} finally {
setCeoSaving(false)
}
}}
disabled={ceoSaving}
className="px-4 py-2.5 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 transition-colors"
>
{ceoSaved ? (
<span className="flex items-center gap-1.5"><CheckCircle className="w-4 h-4" />{t('settings.saved')}</span>
) : ceoSaving ? '...' : t('common.save')}
</button>
</div>
<p className="text-xs text-text-tertiary mt-1.5">{t('settings.ceoEmailHint')}</p>
</div>
</div>
</div>
)}
{/* Roles Management (Superadmin only) */} {/* Roles Management (Superadmin only) */}
{user?.role === 'superadmin' && <RolesSection roles={roles} loadRoles={loadRoles} t={t} toast={toast} />} {user?.role === 'superadmin' && <RolesSection roles={roles} loadRoles={loadRoles} t={t} toast={toast} />}
</div> </div>
@@ -235,12 +291,12 @@ function RolesSection({ roles, loadRoles, t, toast }) {
return ( return (
<> <>
<div className="bg-white dark:bg-surface-primary rounded-xl border border-border overflow-hidden"> <div className="bg-surface dark:bg-surface-primary rounded-xl border border-border overflow-hidden">
<div className="px-6 py-4 border-b border-border flex items-center justify-between"> <div className="px-6 py-4 border-b border-border flex items-center justify-between">
<h2 className="text-lg font-semibold text-text-primary flex items-center gap-2"> <h3 className="font-semibold text-text-primary flex items-center gap-2">
<Tag className="w-5 h-5 text-brand-primary" /> <Tag className="w-5 h-5 text-brand-primary" />
{t('settings.roles')} {t('settings.roles')}
</h2> </h3>
<button <button
onClick={openAddModal} onClick={openAddModal}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors" className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors"
+23 -23
View File
@@ -325,16 +325,16 @@ export default function Tasks() {
<div className="flex items-center gap-3 flex-1 min-w-0"> <div className="flex items-center gap-3 flex-1 min-w-0">
{/* Search */} {/* Search */}
<div className="relative flex-1 max-w-xs"> <div className="relative flex-1 max-w-xs">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" /> <Search className="absolute start-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<input <input
type="text" type="text"
value={searchQuery} value={searchQuery}
onChange={e => setSearchQuery(e.target.value)} onChange={e => setSearchQuery(e.target.value)}
placeholder={t('tasks.search')} placeholder={t('tasks.search')}
className="w-full pl-9 pr-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" className="w-full ps-9 pe-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"
/> />
{searchQuery && ( {searchQuery && (
<button onClick={() => setSearchQuery('')} className="absolute right-2 top-1/2 -translate-y-1/2 p-0.5 rounded text-text-tertiary hover:text-text-primary"> <button onClick={() => setSearchQuery('')} className="absolute end-2 top-1/2 -translate-y-1/2 p-0.5 rounded text-text-tertiary hover:text-text-primary">
<X className="w-3.5 h-3.5" /> <X className="w-3.5 h-3.5" />
</button> </button>
)} )}
@@ -350,7 +350,7 @@ export default function Tasks() {
onClick={() => setViewMode(mode)} onClick={() => setViewMode(mode)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${ className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
viewMode === mode viewMode === mode
? 'bg-white text-text-primary shadow-sm' ? 'bg-surface text-text-primary shadow-sm'
: 'text-text-tertiary hover:text-text-secondary' : 'text-text-tertiary hover:text-text-secondary'
}`} }`}
> >
@@ -399,7 +399,7 @@ export default function Tasks() {
<select <select
value={filterProject} value={filterProject}
onChange={e => setFilterProject(e.target.value)} onChange={e => setFilterProject(e.target.value)}
className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-white text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20" className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
> >
<option value="">{t('tasks.allProjects')}</option> <option value="">{t('tasks.allProjects')}</option>
{taskProjects.map(p => ( {taskProjects.map(p => (
@@ -411,7 +411,7 @@ export default function Tasks() {
<select <select
value={filterBrand} value={filterBrand}
onChange={e => setFilterBrand(e.target.value)} onChange={e => setFilterBrand(e.target.value)}
className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-white text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20" className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
> >
<option value="">{t('tasks.allBrands')}</option> <option value="">{t('tasks.allBrands')}</option>
{taskBrands.map(b => ( {taskBrands.map(b => (
@@ -440,7 +440,7 @@ export default function Tasks() {
className={`px-2.5 py-1 text-[11px] font-medium rounded-full border transition-colors ${ className={`px-2.5 py-1 text-[11px] font-medium rounded-full border transition-colors ${
active active
? 'bg-brand-primary/10 border-brand-primary/20 text-brand-primary' ? 'bg-brand-primary/10 border-brand-primary/20 text-brand-primary'
: 'bg-white border-border text-text-tertiary' : 'bg-surface border-border text-text-tertiary'
}`} }`}
> >
{t(`tasks.${s}`)} {t(`tasks.${s}`)}
@@ -453,7 +453,7 @@ export default function Tasks() {
<select <select
value={filterPriority} value={filterPriority}
onChange={e => setFilterPriority(e.target.value)} onChange={e => setFilterPriority(e.target.value)}
className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-white text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20" className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
> >
<option value="">{t('tasks.allPriorities')}</option> <option value="">{t('tasks.allPriorities')}</option>
<option value="low">{t('tasks.priority.low')}</option> <option value="low">{t('tasks.priority.low')}</option>
@@ -466,7 +466,7 @@ export default function Tasks() {
<select <select
value={filterAssignee} value={filterAssignee}
onChange={e => setFilterAssignee(e.target.value)} onChange={e => setFilterAssignee(e.target.value)}
className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-white text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20" className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
> >
<option value="">{t('tasks.allAssignees')}</option> <option value="">{t('tasks.allAssignees')}</option>
{(assignableUsers || []).map(m => ( {(assignableUsers || []).map(m => (
@@ -479,7 +479,7 @@ export default function Tasks() {
<select <select
value={filterCreator} value={filterCreator}
onChange={e => setFilterCreator(e.target.value)} onChange={e => setFilterCreator(e.target.value)}
className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-white text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20" className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
> >
<option value="">{t('tasks.allCreators')}</option> <option value="">{t('tasks.allCreators')}</option>
{users.map(m => ( {users.map(m => (
@@ -501,7 +501,7 @@ export default function Tasks() {
type="date" type="date"
value={filterDateFrom} value={filterDateFrom}
onChange={e => { setFilterDateFrom(e.target.value); setActivePreset('') }} onChange={e => { setFilterDateFrom(e.target.value); setActivePreset('') }}
className="px-2 py-1.5 text-xs border border-border rounded-lg bg-white text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20" className="px-2 py-1.5 text-xs border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
title={t('posts.periodFrom')} title={t('posts.periodFrom')}
/> />
<span className="text-text-tertiary text-xs">-</span> <span className="text-text-tertiary text-xs">-</span>
@@ -509,7 +509,7 @@ export default function Tasks() {
type="date" type="date"
value={filterDateTo} value={filterDateTo}
onChange={e => { setFilterDateTo(e.target.value); setActivePreset('') }} onChange={e => { setFilterDateTo(e.target.value); setActivePreset('') }}
className="px-2 py-1.5 text-xs border border-border rounded-lg bg-white text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20" className="px-2 py-1.5 text-xs border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
title={t('posts.periodTo')} title={t('posts.periodTo')}
/> />
</div> </div>
@@ -520,7 +520,7 @@ export default function Tasks() {
className={`px-2.5 py-1 text-[11px] font-medium rounded-full border transition-colors ${ className={`px-2.5 py-1 text-[11px] font-medium rounded-full border transition-colors ${
filterOverdue filterOverdue
? 'bg-red-50 border-red-200 text-red-600' ? 'bg-red-50 border-red-200 text-red-600'
: 'bg-white border-border text-text-tertiary' : 'bg-surface border-border text-text-tertiary'
}`} }`}
> >
{t('tasks.overdue')} {t('tasks.overdue')}
@@ -599,7 +599,7 @@ export default function Tasks() {
onDelete={() => setShowBulkDeleteConfirm(true)} onDelete={() => setShowBulkDeleteConfirm(true)}
/> />
)} )}
<div className="bg-white rounded-xl border border-border overflow-hidden"> <div className="bg-surface rounded-xl border border-border overflow-hidden">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b border-border bg-surface-secondary/50"> <tr className="border-b border-border bg-surface-secondary/50">
@@ -614,28 +614,28 @@ export default function Tasks() {
</th> </th>
<th className="w-8 px-3 py-2.5"></th> <th className="w-8 px-3 py-2.5"></th>
<th <th
className="text-left px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase cursor-pointer hover:text-text-primary" className="text-start px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase cursor-pointer hover:text-text-primary"
onClick={() => toggleSort('title')} onClick={() => toggleSort('title')}
> >
{t('tasks.taskTitle')} {sortBy === 'title' && (sortDir === 'asc' ? '↑' : '↓')} {t('tasks.taskTitle')} {sortBy === 'title' && (sortDir === 'asc' ? '↑' : '↓')}
</th> </th>
<th className="text-left px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase">{t('tasks.project')}</th> <th className="text-start px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase">{t('tasks.project')}</th>
<th className="text-left px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase">{t('tasks.brand')}</th> <th className="text-start px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase">{t('tasks.brand')}</th>
<th <th
className="text-left px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase cursor-pointer hover:text-text-primary" className="text-start px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase cursor-pointer hover:text-text-primary"
onClick={() => toggleSort('status')} onClick={() => toggleSort('status')}
> >
{t('tasks.status')} {sortBy === 'status' && (sortDir === 'asc' ? '↑' : '↓')} {t('tasks.status')} {sortBy === 'status' && (sortDir === 'asc' ? '↑' : '↓')}
</th> </th>
<th className="text-left px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase">{t('tasks.assignee')}</th> <th className="text-start px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase">{t('tasks.assignee')}</th>
<th <th
className="text-left px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase cursor-pointer hover:text-text-primary" className="text-start px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase cursor-pointer hover:text-text-primary"
onClick={() => toggleSort('due_date')} onClick={() => toggleSort('due_date')}
> >
{t('tasks.dueDate')} {sortBy === 'due_date' && (sortDir === 'asc' ? '↑' : '↓')} {t('tasks.dueDate')} {sortBy === 'due_date' && (sortDir === 'asc' ? '↑' : '↓')}
</th> </th>
<th <th
className="text-left px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase cursor-pointer hover:text-text-primary" className="text-start px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase cursor-pointer hover:text-text-primary"
onClick={() => toggleSort('priority')} onClick={() => toggleSort('priority')}
> >
{t('tasks.priority')} {sortBy === 'priority' && (sortDir === 'asc' ? '↑' : '↓')} {t('tasks.priority')} {sortBy === 'priority' && (sortDir === 'asc' ? '↑' : '↓')}
@@ -651,7 +651,7 @@ export default function Tasks() {
const brandName = task.brand_name || task.brandName const brandName = task.brand_name || task.brandName
const assignedName = task.assigned_name || task.assignedName const assignedName = task.assigned_name || task.assignedName
const statusLabels = { todo: t('tasks.todo'), in_progress: t('tasks.in_progress'), done: t('tasks.done') } const statusLabels = { todo: t('tasks.todo'), in_progress: t('tasks.in_progress'), done: t('tasks.done') }
const statusColors = { todo: 'bg-gray-100 text-gray-600', in_progress: 'bg-blue-100 text-blue-700', done: 'bg-emerald-100 text-emerald-700' } const statusColors = { todo: 'bg-gray-100 text-text-secondary', in_progress: 'bg-blue-100 text-blue-700', done: 'bg-emerald-100 text-emerald-700' }
return ( return (
<tr <tr
@@ -675,7 +675,7 @@ export default function Tasks() {
{task.title} {task.title}
</span> </span>
{(task.comment_count || task.commentCount) > 0 && ( {(task.comment_count || task.commentCount) > 0 && (
<span className="ml-2 text-[10px] text-text-tertiary">💬 {task.comment_count || task.commentCount}</span> <span className="ms-2 text-[10px] text-text-tertiary">💬 {task.comment_count || task.commentCount}</span>
)} )}
</td> </td>
<td className="px-3 py-2.5 text-text-tertiary text-xs">{projectName || '—'}</td> <td className="px-3 py-2.5 text-text-tertiary text-xs">{projectName || '—'}</td>
+28 -26
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useContext, useRef } from 'react' import { useState, useEffect, useContext, useRef, useMemo } from 'react'
import { Plus, Users, ArrowLeft, User as UserIcon, Edit2, LayoutGrid, Network, Link2, ChevronDown, Check, X } from 'lucide-react' import { Plus, Users, ArrowLeft, User as UserIcon, Edit2, LayoutGrid, Network, Link2, ChevronDown, Check, X } from 'lucide-react'
import { getInitials } from '../utils/api' import { getInitials } from '../utils/api'
import { AppContext, PERMISSION_LEVELS } from '../App' import { AppContext, PERMISSION_LEVELS } from '../App'
@@ -16,9 +16,9 @@ import { useToast } from '../components/ToastContainer'
const ALL_MODULES = ['marketing', 'projects', 'finance'] const ALL_MODULES = ['marketing', 'projects', 'finance']
const MODULE_LABELS = { marketing: 'Marketing', projects: 'Projects', finance: 'Finance' } const MODULE_LABELS = { marketing: 'Marketing', projects: 'Projects', finance: 'Finance' }
const MODULE_COLORS = { const MODULE_COLORS = {
marketing: { on: 'bg-emerald-100 text-emerald-700 border-emerald-300', off: 'bg-gray-100 text-gray-400 border-gray-200' }, marketing: { on: 'bg-emerald-100 text-emerald-700 border-emerald-300', off: 'bg-gray-100 text-text-tertiary border-gray-200' },
projects: { on: 'bg-blue-100 text-blue-700 border-blue-300', off: 'bg-gray-100 text-gray-400 border-gray-200' }, projects: { on: 'bg-blue-100 text-blue-700 border-blue-300', off: 'bg-gray-100 text-text-tertiary border-gray-200' },
finance: { on: 'bg-amber-100 text-amber-700 border-amber-300', off: 'bg-gray-100 text-gray-400 border-gray-200' }, finance: { on: 'bg-amber-100 text-amber-700 border-amber-300', off: 'bg-gray-100 text-text-tertiary border-gray-200' },
} }
const EMPTY_MEMBER = { const EMPTY_MEMBER = {
@@ -238,9 +238,11 @@ export default function Team() {
// Member detail view // Member detail view
if (selectedMember) { if (selectedMember) {
const todoCount = memberTasks.filter(t => t.status === 'todo').length const { todoCount, inProgressCount, doneCount } = useMemo(() => ({
const inProgressCount = memberTasks.filter(t => t.status === 'in_progress').length todoCount: memberTasks.filter(t => t.status === 'todo').length,
const doneCount = memberTasks.filter(t => t.status === 'done').length inProgressCount: memberTasks.filter(t => t.status === 'in_progress').length,
doneCount: memberTasks.filter(t => t.status === 'done').length,
}), [memberTasks])
return ( return (
<div className="space-y-6 animate-fade-in"> <div className="space-y-6 animate-fade-in">
@@ -253,7 +255,7 @@ export default function Team() {
</button> </button>
{/* Member profile */} {/* Member profile */}
<div className="bg-white rounded-xl border border-border p-6"> <div className="bg-surface rounded-xl border border-border p-6">
<div className="flex items-start gap-4"> <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`}> <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()} {selectedMember.name?.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase()}
@@ -281,19 +283,19 @@ export default function Team() {
{/* Workload stats */} {/* Workload stats */}
<div className="grid grid-cols-4 gap-4"> <div className="grid grid-cols-4 gap-4">
<div className="bg-white rounded-xl border border-border p-4 text-center"> <div className="bg-surface rounded-xl border border-border p-4 text-center">
<p className="text-2xl font-bold text-text-primary">{memberTasks.length}</p> <p className="text-2xl font-bold text-text-primary">{memberTasks.length}</p>
<p className="text-xs text-text-tertiary">{t('team.totalTasks')}</p> <p className="text-xs text-text-tertiary">{t('team.totalTasks')}</p>
</div> </div>
<div className="bg-white rounded-xl border border-border p-4 text-center"> <div className="bg-surface rounded-xl border border-border p-4 text-center">
<p className="text-2xl font-bold text-amber-500">{todoCount}</p> <p className="text-2xl font-bold text-amber-500">{todoCount}</p>
<p className="text-xs text-text-tertiary">{t('team.toDo')}</p> <p className="text-xs text-text-tertiary">{t('team.toDo')}</p>
</div> </div>
<div className="bg-white rounded-xl border border-border p-4 text-center"> <div className="bg-surface rounded-xl border border-border p-4 text-center">
<p className="text-2xl font-bold text-blue-500">{inProgressCount}</p> <p className="text-2xl font-bold text-blue-500">{inProgressCount}</p>
<p className="text-xs text-text-tertiary">{t('team.inProgress')}</p> <p className="text-xs text-text-tertiary">{t('team.inProgress')}</p>
</div> </div>
<div className="bg-white rounded-xl border border-border p-4 text-center"> <div className="bg-surface rounded-xl border border-border p-4 text-center">
<p className="text-2xl font-bold text-emerald-500">{doneCount}</p> <p className="text-2xl font-bold text-emerald-500">{doneCount}</p>
<p className="text-xs text-text-tertiary">{t('tasks.done')}</p> <p className="text-xs text-text-tertiary">{t('tasks.done')}</p>
</div> </div>
@@ -302,7 +304,7 @@ export default function Team() {
{/* Tasks & Posts */} {/* Tasks & Posts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Tasks */} {/* Tasks */}
<div className="bg-white rounded-xl border border-border"> <div className="bg-surface rounded-xl border border-border">
<div className="px-5 py-4 border-b 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> <h3 className="font-semibold text-text-primary">{t('tasks.title')} ({memberTasks.length})</h3>
</div> </div>
@@ -327,7 +329,7 @@ export default function Team() {
</div> </div>
{/* Posts */} {/* Posts */}
<div className="bg-white rounded-xl border border-border"> <div className="bg-surface rounded-xl border border-border">
<div className="px-5 py-4 border-b 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> <h3 className="font-semibold text-text-primary">{t('nav.posts')} ({memberPosts.length})</h3>
</div> </div>
@@ -394,7 +396,7 @@ export default function Team() {
{displayedMembers.length} {displayedMembers.length !== 1 ? t('team.membersPlural') : t('team.member')} {displayedMembers.length} {displayedMembers.length !== 1 ? t('team.membersPlural') : t('team.member')}
</p> </p>
{/* View toggle */} {/* View toggle */}
<div className="flex items-center bg-white border border-border rounded-lg overflow-hidden"> <div className="flex items-center bg-surface border border-border rounded-lg overflow-hidden">
<button <button
onClick={() => setViewMode('grid')} onClick={() => setViewMode('grid')}
className={`p-2 transition-colors ${viewMode === 'grid' ? 'bg-brand-primary text-white' : 'text-text-tertiary hover:text-text-primary'}`} className={`p-2 transition-colors ${viewMode === 'grid' ? 'bg-brand-primary text-white' : 'text-text-tertiary hover:text-text-primary'}`}
@@ -415,7 +417,7 @@ export default function Team() {
{/* Copy generic issue link */} {/* Copy generic issue link */}
<button <button
onClick={() => copyIssueLink()} onClick={() => copyIssueLink()}
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" className="flex items-center gap-2 px-4 py-2 bg-surface border border-border text-text-primary rounded-lg text-sm font-medium hover:bg-surface-secondary transition-colors"
title={t('team.copyGenericIssueLink')} title={t('team.copyGenericIssueLink')}
> >
<Link2 className="w-4 h-4" /> <Link2 className="w-4 h-4" />
@@ -428,7 +430,7 @@ export default function Team() {
const self = teamMembers.find(m => m._id === user?.id || m.id === user?.id) const self = teamMembers.find(m => m._id === user?.id || m.id === user?.id)
if (self) openEdit(self) 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" className="flex items-center gap-2 px-4 py-2 bg-surface border border-border text-text-primary rounded-lg text-sm font-medium hover:bg-surface-secondary transition-colors"
> >
<UserIcon className="w-4 h-4" /> <UserIcon className="w-4 h-4" />
{t('team.myProfile')} {t('team.myProfile')}
@@ -438,7 +440,7 @@ export default function Team() {
{canManageTeam && ( {canManageTeam && (
<button <button
onClick={() => setPanelTeam({})} onClick={() => setPanelTeam({})}
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" className="flex items-center gap-2 px-4 py-2 bg-surface border border-border text-text-primary rounded-lg text-sm font-medium hover:bg-surface-secondary transition-colors"
> >
<Users className="w-4 h-4" /> <Users className="w-4 h-4" />
{t('teams.createTeam')} {t('teams.createTeam')}
@@ -468,7 +470,7 @@ export default function Team() {
<button <button
onClick={() => setTeamFilter(null)} onClick={() => setTeamFilter(null)}
className={`text-xs px-3 py-1.5 rounded-full border font-medium transition-colors ${ className={`text-xs px-3 py-1.5 rounded-full border font-medium transition-colors ${
!teamFilter ? 'bg-brand-primary text-white border-brand-primary' : 'bg-white text-text-secondary border-border hover:bg-surface-tertiary' !teamFilter ? 'bg-brand-primary text-white border-brand-primary' : 'bg-surface text-text-secondary border-border hover:bg-surface-tertiary'
}`} }`}
> >
{t('common.all')} {t('common.all')}
@@ -481,7 +483,7 @@ export default function Team() {
<button <button
onClick={() => setTeamFilter(active ? null : tid)} onClick={() => setTeamFilter(active ? null : tid)}
className={`text-xs px-3 py-1.5 rounded-full border font-medium transition-colors ${ className={`text-xs px-3 py-1.5 rounded-full border font-medium transition-colors ${
active ? 'bg-blue-600 text-white border-blue-600' : 'bg-white text-text-secondary border-border hover:bg-surface-tertiary' active ? 'bg-blue-600 text-white border-blue-600' : 'bg-surface text-text-secondary border-border hover:bg-surface-tertiary'
}`} }`}
> >
{team.name} ({team.member_count || 0}) {team.name} ({team.member_count || 0})
@@ -531,7 +533,7 @@ export default function Team() {
const tid = team.id || team._id const tid = team.id || team._id
const members = teamMembers.filter(m => m.teams?.some(t => t.id === tid)) const members = teamMembers.filter(m => m.teams?.some(t => t.id === tid))
return ( return (
<div key={tid} className="bg-white rounded-xl border border-border overflow-hidden"> <div key={tid} className="bg-surface rounded-xl border border-border overflow-hidden">
{/* Team header */} {/* Team header */}
<div className="flex items-center justify-between px-5 py-4 bg-gradient-to-r from-blue-50 to-indigo-50 border-b border-border"> <div className="flex items-center justify-between px-5 py-4 bg-gradient-to-r from-blue-50 to-indigo-50 border-b border-border">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -601,7 +603,7 @@ export default function Team() {
{/* Unassigned members */} {/* Unassigned members */}
{unassignedMembers.length > 0 && ( {unassignedMembers.length > 0 && (
<div className="bg-white rounded-xl border border-border overflow-hidden"> <div className="bg-surface rounded-xl border border-border overflow-hidden">
<div className="flex items-center gap-3 px-5 py-4 bg-gray-50 border-b border-border"> <div className="flex items-center gap-3 px-5 py-4 bg-gray-50 border-b border-border">
<div className="w-10 h-10 rounded-lg bg-gray-400 flex items-center justify-center text-white"> <div className="w-10 h-10 rounded-lg bg-gray-400 flex items-center justify-center text-white">
<UserIcon className="w-5 h-5" /> <UserIcon className="w-5 h-5" />
@@ -707,7 +709,7 @@ export default function Team() {
<div ref={addBrandsRef} className="relative"> <div ref={addBrandsRef} className="relative">
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.brands')}</label> <label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.brands')}</label>
<button type="button" onClick={() => setShowAddBrandsDropdown(p => !p)} <button type="button" onClick={() => setShowAddBrandsDropdown(p => !p)}
className="w-full flex items-center justify-between px-3 py-2 text-sm border border-border rounded-lg bg-white text-left focus:outline-none focus:ring-2 focus:ring-brand-primary/20"> className="w-full flex items-center justify-between px-3 py-2 text-sm border border-border rounded-lg bg-surface text-start focus:outline-none focus:ring-2 focus:ring-brand-primary/20">
<span className={`flex-1 truncate ${addForm.brands.length === 0 ? 'text-text-tertiary' : 'text-text-primary'}`}> <span className={`flex-1 truncate ${addForm.brands.length === 0 ? 'text-text-tertiary' : 'text-text-primary'}`}>
{addForm.brands.length === 0 ? t('team.selectBrands') : addForm.brands.join(', ')} {addForm.brands.length === 0 ? t('team.selectBrands') : addForm.brands.join(', ')}
</span> </span>
@@ -724,13 +726,13 @@ export default function Team() {
</div> </div>
)} )}
{showAddBrandsDropdown && ( {showAddBrandsDropdown && (
<div className="absolute z-20 mt-1 w-full bg-white border border-border rounded-lg shadow-lg max-h-48 overflow-y-auto"> <div className="absolute z-20 mt-1 w-full bg-surface border border-border rounded-lg shadow-lg max-h-48 overflow-y-auto">
{brands.map(brand => { {brands.map(brand => {
const name = lang === 'ar' && brand.name_ar ? brand.name_ar : brand.name const name = lang === 'ar' && brand.name_ar ? brand.name_ar : brand.name
const checked = addForm.brands.includes(name) const checked = addForm.brands.includes(name)
return ( return (
<button key={brand.id || brand._id} type="button" onClick={() => toggleAddBrand(name)} <button key={brand.id || brand._id} type="button" onClick={() => toggleAddBrand(name)}
className={`w-full flex items-center gap-2.5 px-3 py-2 hover:bg-surface-secondary transition-colors text-left ${checked ? 'bg-brand-primary/5' : ''}`}> className={`w-full flex items-center gap-2.5 px-3 py-2 hover:bg-surface-secondary transition-colors text-start ${checked ? 'bg-brand-primary/5' : ''}`}>
<div className={`w-4 h-4 rounded border flex items-center justify-center shrink-0 ${checked ? 'bg-brand-primary border-brand-primary' : 'border-border'}`}> <div className={`w-4 h-4 rounded border flex items-center justify-center shrink-0 ${checked ? 'bg-brand-primary border-brand-primary' : 'border-border'}`}>
{checked && <Check className="w-3 h-3 text-white" />} {checked && <Check className="w-3 h-3 text-white" />}
</div> </div>
@@ -771,7 +773,7 @@ export default function Team() {
return ( return (
<button key={tid} type="button" <button key={tid} type="button"
onClick={() => updateAdd('team_ids', active ? addForm.team_ids.filter(id => id !== tid) : [...addForm.team_ids, tid])} onClick={() => updateAdd('team_ids', active ? addForm.team_ids.filter(id => id !== tid) : [...addForm.team_ids, tid])}
className={`text-xs px-3 py-1.5 rounded-full border font-medium transition-colors ${active ? 'bg-blue-100 text-blue-700 border-blue-300' : 'bg-gray-100 text-gray-400 border-gray-200'}`}> className={`text-xs px-3 py-1.5 rounded-full border font-medium transition-colors ${active ? 'bg-blue-100 text-blue-700 border-blue-300' : 'bg-gray-100 text-text-tertiary border-gray-200'}`}>
{team.name} {team.name}
</button> </button>
) )
+13 -13
View File
@@ -189,8 +189,8 @@ export default function Translations() {
const SortIcon = ({ col }) => { const SortIcon = ({ col }) => {
if (listSortBy !== col) return null if (listSortBy !== col) return null
return listSortDir === 'asc' return listSortDir === 'asc'
? <ChevronUp className="w-3 h-3 inline ml-0.5" /> ? <ChevronUp className="w-3 h-3 inline ms-0.5" />
: <ChevronDown className="w-3 h-3 inline ml-0.5" /> : <ChevronDown className="w-3 h-3 inline ms-0.5" />
} }
const formatDate = (dateStr) => { const formatDate = (dateStr) => {
@@ -219,7 +219,7 @@ export default function Translations() {
onClick={() => setViewMode(mode)} onClick={() => setViewMode(mode)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${ className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
viewMode === mode viewMode === mode
? 'bg-white text-text-primary shadow-sm' ? 'bg-surface text-text-primary shadow-sm'
: 'text-text-tertiary hover:text-text-secondary' : 'text-text-tertiary hover:text-text-secondary'
}`} }`}
> >
@@ -242,13 +242,13 @@ export default function Translations() {
{/* Filters */} {/* Filters */}
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<div className="relative flex-1 min-w-[200px] max-w-md"> <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" /> <Search className="absolute start-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<input <input
type="text" type="text"
placeholder={t('translations.searchTranslations')} placeholder={t('translations.searchTranslations')}
value={searchTerm} value={searchTerm}
onChange={e => setSearchTerm(e.target.value)} 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-surface transition-colors" className="w-full ps-10 pe-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-surface transition-colors"
/> />
</div> </div>
@@ -356,22 +356,22 @@ export default function Translations() {
<table className="w-full"> <table className="w-full">
<thead> <thead>
<tr className="border-b border-border bg-surface-secondary"> <tr className="border-b border-border bg-surface-secondary">
<th className="px-4 py-3 text-left w-10"> <th className="px-4 py-3 text-start w-10">
<input type="checkbox" checked={selectedIds.size === sortedTranslations.length && sortedTranslations.length > 0} onChange={toggleSelectAll} className="rounded border-border" /> <input type="checkbox" checked={selectedIds.size === sortedTranslations.length && sortedTranslations.length > 0} onChange={toggleSelectAll} className="rounded border-border" />
</th> </th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer" onClick={() => toggleListSort('title')}> <th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase cursor-pointer" onClick={() => toggleListSort('title')}>
{t('translations.titleLabel')} <SortIcon col="title" /> {t('translations.titleLabel')} <SortIcon col="title" />
</th> </th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase"> <th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">
{t('translations.sourceLanguage')} {t('translations.sourceLanguage')}
</th> </th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer" onClick={() => toggleListSort('status')}> <th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase cursor-pointer" onClick={() => toggleListSort('status')}>
{t('translations.status')} <SortIcon col="status" /> {t('translations.status')} <SortIcon col="status" />
</th> </th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('translations.brand')}</th> <th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('translations.brand')}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('translations.creator')}</th> <th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('translations.creator')}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('translations.languagesLabel')}</th> <th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('translations.languagesLabel')}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer" onClick={() => toggleListSort('updated_at')}> <th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase cursor-pointer" onClick={() => toggleListSort('updated_at')}>
{t('translations.updated')} <SortIcon col="updated_at" /> {t('translations.updated')} <SortIcon col="updated_at" />
</th> </th>
</tr> </tr>
@@ -0,0 +1,635 @@
# Budget Allocation Redesign — Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the dual budget system with a single source of truth (BudgetEntries), add validation at all levels, and implement a CEO approval workflow for new income.
**Architecture:** BudgetEntries table is the only source for all budget calculations. Campaign/project allocations are income entries with a FK set. A new BudgetRequests table + public approval page handles CEO approval for new income. Budget mutex prevents race conditions.
**Tech Stack:** Express.js (server), React (client), NocoDB (database), nodemailer (emails)
**Spec:** `docs/superpowers/specs/2026-03-15-budget-allocation-redesign.md`
---
## Chunk 1: Server — Budget Model Fix + Validation
### Task 1: Add budget mutex utility
**Files:**
- Create: `server/budget-mutex.js`
- [ ] **Step 1: Create the mutex module**
```javascript
// server/budget-mutex.js
let _lock = null;
async function acquireBudgetLock() {
while (_lock) await _lock;
let resolve;
_lock = new Promise(r => { resolve = r; });
return () => { _lock = null; resolve(); };
}
module.exports = { acquireBudgetLock };
```
- [ ] **Step 2: Commit**
```bash
git add server/budget-mutex.js
git commit -m "feat: add budget mutex for race condition prevention"
```
### Task 2: Add budget availability helper
**Files:**
- Create: `server/budget-helpers.js`
This module computes `mainAvailable` and `campaignAvailable` from BudgetEntries — the single source of truth. Every route that modifies budget will call these.
- [ ] **Step 1: Create the helper module**
```javascript
// server/budget-helpers.js
const nocodb = require('./nocodb');
const QUERY_LIMITS = { max: 10000 };
async function getMainAvailable() {
const entries = await nocodb.list('BudgetEntries', { limit: QUERY_LIMITS.max });
const income = entries.filter(e => (e.type || 'income') === 'income');
const expenses = entries.filter(e => e.type === 'expense');
const totalReceived = income.reduce((s, e) => s + (e.amount || 0), 0);
const totalExpenses = expenses.reduce((s, e) => s + (e.amount || 0), 0);
const totalCampaignBudget = income.filter(e => e.campaign_id).reduce((s, e) => s + (e.amount || 0), 0);
const totalProjectBudget = income.filter(e => e.project_id).reduce((s, e) => s + (e.amount || 0), 0);
return {
totalReceived,
totalExpenses,
totalCampaignBudget,
totalProjectBudget,
available: totalReceived - totalExpenses - totalCampaignBudget - totalProjectBudget,
};
}
async function getCampaignAvailable(campaignId) {
const entries = await nocodb.list('BudgetEntries', {
where: `(campaign_id,eq,${campaignId})~and(type,neq,expense)`,
limit: QUERY_LIMITS.max,
});
const allocated = entries.reduce((s, e) => s + (e.amount || 0), 0);
const tracks = await nocodb.list('CampaignTracks', {
where: `(campaign_id,eq,${campaignId})`,
limit: QUERY_LIMITS.max,
});
const trackAllocated = tracks.reduce((s, t) => s + (t.budget_allocated || 0), 0);
return { allocated, trackAllocated, available: allocated - trackAllocated };
}
async function getCampaignAllocatedFromEntries(campaignId) {
const entries = await nocodb.list('BudgetEntries', {
where: `(campaign_id,eq,${campaignId})~and(type,neq,expense)`,
limit: QUERY_LIMITS.max,
});
return entries.reduce((s, e) => s + (e.amount || 0), 0);
}
module.exports = { getMainAvailable, getCampaignAvailable, getCampaignAllocatedFromEntries };
```
- [ ] **Step 2: Commit**
```bash
git add server/budget-helpers.js
git commit -m "feat: add budget availability helpers (single source of truth)"
```
### Task 3: Fix finance summary endpoint
**Files:**
- Modify: `server/server.js` — the `GET /api/finance/summary` handler (~lines 2405-2488)
- [ ] **Step 1: Rewrite the finance summary to use BudgetEntries only**
Replace the entire handler body. Key changes:
- `totalReceived` = sum of ALL income entries (same for all roles — remove the superadmin/manager fork)
- `totalCampaignBudget` = sum of income entries with `campaign_id` set (not `Campaign.budget`)
- `remaining` = mainAvailable (no track double-counting)
- Keep track aggregations (spent, revenue, impressions) for the campaign breakdown table
- Add `mainAvailable` to the response
The handler still filters by user's campaign access for managers. Managers see a subset of campaigns but the SAME calculation logic.
- [ ] **Step 2: Verify build**
```bash
cd client && npx vite build --logLevel error
```
- [ ] **Step 3: Commit**
```bash
git add server/server.js
git commit -m "fix: finance summary uses BudgetEntries as single source of truth"
```
### Task 4: Add budget validation to campaign creation
**Files:**
- Modify: `server/server.js``POST /api/campaigns` (~line 2097)
- [ ] **Step 1: Add validation + auto-create BudgetEntry**
In the campaign creation handler, after creating the campaign:
1. Import `{ acquireBudgetLock }` from `./budget-mutex`
2. Import `{ getMainAvailable }` from `./budget-helpers`
3. If `budget > 0`:
- Acquire lock
- Check `mainAvailable >= budget`
- If insufficient: delete the just-created campaign, release lock, return 400
- If OK: create BudgetEntry `{ type: 'income', amount: budget, campaign_id: created.Id, label: 'Campaign allocation', source: 'Campaign creation', date_received: new Date().toISOString().slice(0,10) }`
- Release lock
- [ ] **Step 2: Add validation to campaign PATCH for budget changes**
Modify: `server/server.js``PATCH /api/campaigns/:id`
If `budget` field is being updated:
1. Get current allocated = sum of income BudgetEntries for this campaign
2. If increasing: check `mainAvailable >= (newBudget - currentAllocated)`
3. If decreasing: check `newBudget >= sum(tracks.budget_allocated)` for this campaign
4. Update (or create) the BudgetEntry to match new budget amount
- [ ] **Step 3: Commit**
```bash
git add server/server.js
git commit -m "feat: validate campaign budget against main available, auto-create BudgetEntry"
```
### Task 5: Add budget validation to track creation/edit
**Files:**
- Modify: `server/server.js``POST /api/campaigns/:id/tracks` (~line 2504) and `PATCH /api/campaigns/:id/tracks/:trackId`
- [ ] **Step 1: Add campaignAvailable check to track POST**
Before creating the track:
1. Import `{ getCampaignAvailable }` from `./budget-helpers`
2. If `budget_allocated > 0`: check `campaignAvailable >= budget_allocated`
3. If insufficient: return 400 `{ error: 'Insufficient campaign budget', available: campaignAvailable }`
- [ ] **Step 2: Add same check to track PATCH**
If `budget_allocated` is being updated:
1. Get current track's `budget_allocated`
2. Delta = newAmount - currentAmount
3. If delta > 0: check `campaignAvailable >= delta`
- [ ] **Step 3: Commit**
```bash
git add server/server.js
git commit -m "feat: validate track budget against campaign available"
```
### Task 6: Add budget validation to expense creation
**Files:**
- Modify: `server/server.js``POST /api/budget` (~line 2343)
- [ ] **Step 1: Add mainAvailable check for expenses**
In the budget entry creation handler:
1. Validate `amount > 0`
2. If `type === 'expense'`: acquire lock, check `mainAvailable >= amount`, release
3. If insufficient: return 400
- [ ] **Step 2: Commit**
```bash
git add server/server.js
git commit -m "feat: validate expense entries against available budget"
```
### Task 7: Handle campaign/project deletion — release budget
**Files:**
- Modify: `server/server.js``DELETE /api/campaigns/:id` (~line 2174) and `DELETE /api/projects/:id`
- [ ] **Step 1: Null out BudgetEntry FKs on campaign delete**
In the campaign delete handler, before deleting the campaign:
```javascript
// Release budget entries back to main
const budgetEntries = await nocodb.list('BudgetEntries', { where: `(campaign_id,eq,${id})`, limit: QUERY_LIMITS.max });
for (const e of budgetEntries) await nocodb.update('BudgetEntries', e.Id, { campaign_id: null });
```
- [ ] **Step 2: Same for project delete**
```javascript
const budgetEntries = await nocodb.list('BudgetEntries', { where: `(project_id,eq,${id})`, limit: QUERY_LIMITS.max });
for (const e of budgetEntries) await nocodb.update('BudgetEntries', e.Id, { project_id: null });
```
- [ ] **Step 3: Commit**
```bash
git add server/server.js
git commit -m "feat: release budget on campaign/project deletion"
```
### Task 8: Migration — create BudgetEntries for existing campaigns
**Files:**
- Modify: `server/server.js` — add migration in `startServer()` function
- [ ] **Step 1: Add migration after ensureTextColumns**
```javascript
// Migrate Campaign.budget → BudgetEntries (one-time, idempotent)
async function migrateCampaignBudgets() {
const campaigns = await nocodb.list('Campaigns', { limit: 10000 });
const entries = await nocodb.list('BudgetEntries', { limit: 10000 });
for (const c of campaigns) {
if (!c.budget || c.budget <= 0) continue;
const existing = entries.find(e => e.campaign_id && Number(e.campaign_id) === c.Id && (e.type || 'income') === 'income');
if (existing) continue;
await nocodb.create('BudgetEntries', {
label: `Campaign allocation: ${c.name}`,
amount: c.budget,
type: 'income',
campaign_id: c.Id,
source: 'Migrated from Campaign.budget',
date_received: c.CreatedAt ? c.CreatedAt.slice(0, 10) : new Date().toISOString().slice(0, 10),
category: 'marketing',
});
console.log(` ✓ Migrated budget $${c.budget} for campaign "${c.name}"`);
}
}
```
Call `await migrateCampaignBudgets()` in `startServer()` after table creation.
- [ ] **Step 2: Commit**
```bash
git add server/server.js
git commit -m "feat: migrate Campaign.budget to BudgetEntries (idempotent)"
```
---
## Chunk 2: Server — Budget Request Workflow + CEO Approval
### Task 9: Add BudgetRequests table schema + CEO email setting
**Files:**
- Modify: `server/server.js` — REQUIRED_TABLES, TEXT_COLUMNS, appSettings
- [ ] **Step 1: Add BudgetRequests to REQUIRED_TABLES**
```javascript
BudgetRequests: [
{ title: 'amount', uidt: 'Decimal' },
{ title: 'justification', uidt: 'LongText' },
{ title: 'status', uidt: 'SingleLineText' },
{ title: 'requested_by_user_id', uidt: 'Number' },
{ title: 'approval_token', uidt: 'SingleLineText' },
{ title: 'response_note', uidt: 'LongText' },
{ title: 'earmarked_campaign_id', uidt: 'Number' },
{ title: 'earmarked_project_id', uidt: 'Number' },
{ title: 'created_budget_entry_id', uidt: 'Number' },
],
```
Add to TEXT_COLUMNS:
```javascript
BudgetRequests: [
{ name: 'token_expires_at', uidt: 'SingleLineText' },
{ name: 'resolved_at', uidt: 'SingleLineText' },
],
```
- [ ] **Step 2: Add ceoEmail to appSettings default**
In the `defaultSettings` object (wherever `uploadMaxSizeMB` is initialized), add:
```javascript
ceoEmail: ''
```
In `PATCH /api/settings/app`, add handling for `ceoEmail`:
```javascript
if (ceoEmail !== undefined) {
appSettings.ceoEmail = String(ceoEmail).trim();
}
```
- [ ] **Step 3: Commit**
```bash
git add server/server.js
git commit -m "feat: add BudgetRequests table schema + ceoEmail setting"
```
### Task 10: Add budget request CRUD routes
**Files:**
- Modify: `server/server.js`
- [ ] **Step 1: Add GET /api/budget-requests**
```javascript
app.get('/api/budget-requests', requireAuth, requireRole('superadmin'), async (req, res) => {
try {
const requests = await nocodb.list('BudgetRequests', { limit: 200, sort: '-CreatedAt' });
// Enrich with requester name, campaign/project names
res.json(requests);
} catch (err) {
res.status(500).json({ error: 'Failed to load budget requests' });
}
});
```
- [ ] **Step 2: Add POST /api/budget-requests**
```javascript
app.post('/api/budget-requests', requireAuth, requireRole('superadmin'), async (req, res) => {
const { amount, justification, earmarked_campaign_id, earmarked_project_id } = req.body;
if (!amount || amount <= 0) return res.status(400).json({ error: 'Amount must be positive' });
if (!justification?.trim()) return res.status(400).json({ error: 'Justification is required' });
const ceoEmail = appSettings.ceoEmail;
if (!ceoEmail) return res.status(400).json({ error: 'CEO email not configured. Go to Settings.' });
const token = require('crypto').randomUUID();
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString();
try {
const created = await nocodb.create('BudgetRequests', {
amount,
justification: justification.trim(),
status: 'pending',
requested_by_user_id: req.session.userId,
approval_token: token,
token_expires_at: expiresAt,
earmarked_campaign_id: earmarked_campaign_id ? Number(earmarked_campaign_id) : null,
earmarked_project_id: earmarked_project_id ? Number(earmarked_project_id) : null,
});
// Send email to CEO
const appUrl = process.env.APP_URL || process.env.CORS_ORIGIN || 'http://localhost:3001';
const approvalUrl = `${appUrl}/approve-budget/${token}`;
const requesterName = req.session.userName || 'Team member';
// Use notifications.js pattern for email
const { sendMail } = require('./mail');
await sendMail({
to: ceoEmail,
subject: `Rawaj — Budget Request: ${amount}`,
html: renderBudgetRequestEmail({ amount, requesterName, justification: justification.trim(), approvalUrl }),
text: `${requesterName} is requesting ${amount}. Justification: ${justification.trim()}\n\nReview: ${approvalUrl}`,
});
res.status(201).json(created);
} catch (err) {
console.error('Budget request error:', err);
res.status(500).json({ error: 'Failed to create budget request' });
}
});
```
Add `renderBudgetRequestEmail` helper near the route (uses the same branded template pattern as notifications.js).
- [ ] **Step 3: Add PATCH /api/budget-requests/:id/cancel**
```javascript
app.patch('/api/budget-requests/:id/cancel', requireAuth, requireRole('superadmin'), async (req, res) => {
try {
const request = await nocodb.get('BudgetRequests', req.params.id);
if (!request) return res.status(404).json({ error: 'Not found' });
if (request.status !== 'pending') return res.status(400).json({ error: 'Can only cancel pending requests' });
await nocodb.update('BudgetRequests', request.Id, { status: 'cancelled', resolved_at: new Date().toISOString() });
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: 'Failed to cancel request' });
}
});
```
- [ ] **Step 4: Commit**
```bash
git add server/server.js
git commit -m "feat: add budget request CRUD routes"
```
### Task 11: Add public approval endpoints
**Files:**
- Modify: `server/server.js`
- [ ] **Step 1: Add GET /api/budget-approval/:token (public, no auth)**
Returns request details for the approval page. Validates token exists and hasn't expired.
- [ ] **Step 2: Add POST /api/budget-approval/:token/respond (public, no auth)**
Body: `{ action: 'approve' | 'reject', note?: string }`
On approve:
1. Check status === 'pending' and token not expired (idempotent: if already approved, return 200 with existing result)
2. Auto-create income BudgetEntry with campaign_id/project_id from earmarked fields
3. Update request: status='approved', resolved_at=now, created_budget_entry_id=entry.Id
4. Send notification email to requester (superadmin)
On reject:
1. Update request: status='rejected', response_note=note, resolved_at=now
2. Send notification email to requester
- [ ] **Step 3: Add notification helpers for budget approval/rejection**
Add to `server/notifications.js`:
- `notifyBudgetApproved({ request, entryId })` — emails the requesting superadmin
- `notifyBudgetRejected({ request, note })` — emails the requesting superadmin
- [ ] **Step 4: Commit**
```bash
git add server/server.js server/notifications.js
git commit -m "feat: add public budget approval endpoints + notifications"
```
---
## Chunk 3: Client — Finance Page + Budget Request UI
### Task 12: Update Finance page to show budget requests
**Files:**
- Modify: `client/src/pages/Finance.jsx`
- [ ] **Step 1: Add budget requests fetch and state**
Add `budgetRequests` state, fetch from `GET /api/budget-requests` alongside the finance summary.
- [ ] **Step 2: Add "Request Budget" button in header (superadmin only)**
Next to the page title, show a teal button that opens a modal.
- [ ] **Step 3: Add budget request modal**
Modal with fields: amount (number input), justification (textarea), optional earmark dropdown (campaign or project). Submit calls `POST /api/budget-requests`.
- [ ] **Step 4: Add budget requests section**
Below the existing finance sections, add a "Budget Requests" list:
- Pending: amber badge, shows cancel button
- Approved: green badge, shows linked entry amount
- Rejected: red badge, shows CEO's note
- Cancelled: gray badge
If any pending requests exist, show a banner at the top: "N budget request(s) pending CEO approval"
- [ ] **Step 5: Add i18n keys for budget requests**
Add to both `en.json` and `ar.json`:
- `finance.requestBudget`, `finance.budgetRequests`, `finance.pendingApproval`, `finance.justification`, `finance.earmarkFor`, `finance.submitRequest`, `finance.cancelRequest`, `finance.approved`, `finance.rejected`, `finance.cancelled`, `finance.pending`, `finance.ceoNote`, `finance.requestPending`
- [ ] **Step 6: Commit**
```bash
git add client/src/pages/Finance.jsx client/src/i18n/en.json client/src/i18n/ar.json
git commit -m "feat: budget requests UI on Finance page"
```
### Task 13: Update Settings page — CEO email field
**Files:**
- Modify: `client/src/pages/Settings.jsx`
- [ ] **Step 1: Add CEO email field in settings (superadmin only)**
In the settings form, add a section "Budget Approval":
- Label: "CEO / Budget Approver Email"
- Input: email type, bound to `appSettings.ceoEmail`
- Save alongside existing settings via `PATCH /api/settings/app`
- [ ] **Step 2: Add i18n keys**
`settings.ceoEmail`, `settings.ceoEmailHint`, `settings.budgetApproval`
- [ ] **Step 3: Commit**
```bash
git add client/src/pages/Settings.jsx client/src/i18n/en.json client/src/i18n/ar.json
git commit -m "feat: CEO email setting for budget approval"
```
### Task 14: Update Dashboard BudgetSummary
**Files:**
- Modify: `client/src/pages/Dashboard.jsx`
- [ ] **Step 1: Update BudgetSummary to use new response shape**
The finance summary response now has `mainAvailable` instead of computing `remaining` from the old formula. Update the component to use the new field. The `spent` field from tracks is no longer subtracted from main — it lives within campaign allocations.
- [ ] **Step 2: Commit**
```bash
git add client/src/pages/Dashboard.jsx
git commit -m "fix: dashboard budget uses new single-source response"
```
---
## Chunk 4: Client — Public Approval Page + Campaign Budget Validation UI
### Task 15: Create public budget approval page
**Files:**
- Create: `client/src/pages/PublicBudgetApproval.jsx`
- Modify: `client/src/App.jsx` — add route `/approve-budget/:token`
- [ ] **Step 1: Create the page component**
Follow the same pattern as `PublicReview.jsx`:
1. Fetch request via `GET /api/budget-approval/:token`
2. Show: amount, requester name, justification, earmarked for (if set)
3. Approve / Reject buttons + optional note textarea
4. Submit via `POST /api/budget-approval/:token/respond`
5. States: loading, active, success (with approved/rejected message), already-handled, expired, error
Use the teal brand color for the approve button, red for reject.
- [ ] **Step 2: Add route in App.jsx**
```jsx
<Route path="/approve-budget/:token" element={<PublicBudgetApproval />} />
```
Add this alongside other public routes (before the auth-protected layout).
- [ ] **Step 3: Add i18n keys**
`budgetApproval.title`, `budgetApproval.amount`, `budgetApproval.requestedBy`, `budgetApproval.justification`, `budgetApproval.earmarkedFor`, `budgetApproval.approve`, `budgetApproval.reject`, `budgetApproval.addNote`, `budgetApproval.approved`, `budgetApproval.rejected`, `budgetApproval.expired`, `budgetApproval.alreadyHandled`
- [ ] **Step 4: Commit**
```bash
git add client/src/pages/PublicBudgetApproval.jsx client/src/App.jsx client/src/i18n/en.json client/src/i18n/ar.json
git commit -m "feat: public budget approval page"
```
### Task 16: Add budget validation feedback to campaign creation UI
**Files:**
- Modify: `client/src/pages/Campaigns.jsx` (or wherever campaign creation modal lives)
- [ ] **Step 1: Show available budget near the budget input**
When user enters a budget amount for a new campaign, fetch `mainAvailable` from the finance summary (or a lightweight endpoint) and show: "Available: $X". If the entered amount exceeds available, show error inline and disable the submit button.
- [ ] **Step 2: Handle 400 error from server**
If campaign creation returns 400 with `{ error: 'Insufficient budget', available: X }`, show a toast or inline error with the available amount and a suggestion to request more.
- [ ] **Step 3: Same for track creation in CampaignDetail**
When adding a track, show campaign available budget. Handle 400 insufficient errors.
- [ ] **Step 4: Commit**
```bash
git add client/src/pages/Campaigns.jsx client/src/pages/CampaignDetail.jsx
git commit -m "feat: budget validation UI for campaigns and tracks"
```
### Task 17: Final verification
- [ ] **Step 1: Build check**
```bash
cd client && npx vite build --logLevel error
```
- [ ] **Step 2: Manual test checklist**
1. Create income via budget request → CEO approves → funds appear
2. Create campaign with budget > available → blocked
3. Create campaign with budget ≤ available → succeeds, BudgetEntry created
4. Create track exceeding campaign budget → blocked
5. Delete campaign → funds return to main
6. Create expense > available → blocked
7. Reduce campaign budget below track allocations → blocked
8. Finance summary shows correct numbers (same for superadmin and manager)
- [ ] **Step 3: Commit any final fixes**
@@ -0,0 +1,245 @@
# Budget Allocation Redesign — Single Source of Truth + CEO Approval Workflow
**Date:** 2026-03-15
**Status:** Draft
## Problem
The current budget system has two parallel sources of truth:
- `Campaign.budget` field (set directly on campaigns)
- `BudgetEntries` table (income/expense records linked to campaigns/projects)
These don't sync. The finance summary uses `Campaign.budget` for `totalCampaignBudget` but `BudgetEntries` for `totalReceived` (superadmin only). Managers see a completely different `totalReceived` calculation. There's no validation preventing over-allocation, and no approval workflow for incoming funds.
## Design
### Budget Hierarchy
```
Main Budget (sum of approved income BudgetEntries)
├─ Expenses (BudgetEntries with type='expense', deducted from main)
├─ Campaign allocations (income BudgetEntries with campaign_id set)
├─ Project allocations (income BudgetEntries with project_id set)
└─ Available = Main Budget - expenses - campaign allocations - project allocations
Campaign "Summer Sale" ($10K allocated)
├─ Track "Facebook Ads" ($3K from campaign)
├─ Track "Google Ads" ($5K from campaign)
└─ Campaign Available = $2K
```
Campaign allocation entries are a **subset** of income entries — they are income entries that happen to have a `campaign_id` set. An earmarked CEO-approved income entry counts as both `totalReceived` and `totalCampaignBudget` (which is correct — the money enters the system AND is allocated).
### Single Source of Truth
**BudgetEntries is the only source.** `Campaign.budget` field is deprecated — kept in schema but ignored in all calculations.
All calculations:
- `totalReceived` = sum of all income BudgetEntries (same for all roles)
- `totalExpenses` = sum of all expense BudgetEntries
- `totalCampaignBudget` = sum of income BudgetEntries where `campaign_id` is set
- `totalProjectBudget` = sum of income BudgetEntries where `project_id` is set
- `mainAvailable` = totalReceived - totalExpenses - totalCampaignBudget - totalProjectBudget
- `campaignAvailable(id)` = campaign's allocated budget - sum of its tracks' `budget_allocated`
- `remaining` = mainAvailable (same thing — no more double-counting tracks)
### Validation Rules
| Action | Guard | Error message |
|--------|-------|---------------|
| All amounts | amount > 0 | "Amount must be positive" |
| Create expense entry | mainAvailable >= amount | "Insufficient budget. Available: $X" |
| Set campaign budget (at creation or edit) | mainAvailable >= amount (or >= increase delta) | "Insufficient budget. Available: $X. Request more funds." |
| Decrease campaign budget | newBudget >= sum(tracks.budget_allocated) | "Cannot reduce below track allocations ($X assigned to tracks)" |
| Set project budget | mainAvailable >= amount (or >= increase delta) | "Insufficient budget. Available: $X. Request more funds." |
| Set track budget_allocated | campaignAvailable >= amount (or >= increase delta) | "Insufficient campaign budget. Available: $X of $Y allocated" |
| Create income entry | Must go through budget request workflow (superadmin only) | N/A — handled by request workflow |
**Race condition mitigation:** Budget-modifying operations (campaign/project/expense creation, budget changes) acquire an in-memory mutex before reading availability and releasing after the write. Single-server app — no distributed lock needed.
### Campaign/Project Deletion
When a campaign or project is deleted:
- All linked BudgetEntries have their `campaign_id` / `project_id` set to null
- This returns the allocated funds to the main available balance
- Tracks under the campaign are already deleted by the existing cascade logic
### Budget Request Workflow (CEO Approval)
**Who can request:** Superadmin only.
**Who approves:** CEO — external email address configured in Settings page.
#### Flow
```
1. Superadmin opens Finance page → clicks "Request Budget"
2. Fills form: amount, justification note
Optional: earmarked for specific campaign or project
3. System creates BudgetRequest (status: pending)
4. System generates approval token + public URL (expires in 7 days)
5. System emails CEO: amount, requester name, justification, approve/reject links
(no internal budget details exposed)
6a. CEO clicks Approve:
→ BudgetRequest.status = 'approved', resolved_at = now
→ Auto-creates income BudgetEntry (amount, source: "CEO Approved — {justification}")
→ If earmarked: BudgetEntry gets campaign_id or project_id
→ Email notification to superadmin: "Your budget request for $X has been approved"
6b. CEO clicks Reject:
→ BudgetRequest.status = 'rejected', resolved_at = now
→ CEO can add a response note
→ Email notification to superadmin: "Your budget request for $X has been rejected"
```
Idempotent: if CEO clicks approve/reject twice, return 200 with existing result — no duplicate entries.
#### New Table: BudgetRequests
| Column | Type | Description |
|--------|------|-------------|
| amount | Decimal | Requested amount (must be > 0) |
| justification | LongText | Why the money is needed |
| status | SingleLineText | pending / approved / rejected / cancelled |
| requested_by_user_id | Number | FK to Users |
| approval_token | SingleLineText | UUID for public approval URL |
| token_expires_at | DateTime | Token expiry (7 days from creation) |
| response_note | LongText | CEO's note on approval/rejection |
| resolved_at | DateTime | When CEO acted (null while pending) |
| earmarked_campaign_id | Number | Optional FK — intended campaign |
| earmarked_project_id | Number | Optional FK — intended project |
| created_budget_entry_id | Number | FK to the auto-created BudgetEntry (set on approval) |
#### API Endpoints
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | /api/budget-requests | Superadmin | List all requests (sorted by CreatedAt desc) |
| POST | /api/budget-requests | Superadmin | Create new request, sends email to CEO |
| PATCH | /api/budget-requests/:id/cancel | Superadmin | Cancel a pending request |
| GET | /api/budget-approval/:token | Public | Get request details for approval page |
| POST | /api/budget-approval/:token/respond | Public | Approve or reject (body: `{ action: 'approve'|'reject', note?: string }`) |
#### Public Approval Page: `/approve-budget/:token`
Minimal page showing:
- Requested amount
- Requester name
- Justification note
- Earmarked for (campaign/project name, if set)
- Two buttons: Approve / Reject
- Optional text field for response note
- States: loading, active, success, already-handled, expired
Same pattern as existing public review pages (`PublicReview.jsx`, `PublicPostReview.jsx`).
Token validation: check `token_expires_at > now` and `status === 'pending'`. Expired tokens show "This request has expired" with no action buttons.
### Settings: CEO Email
Add to Settings page (superadmin only):
- Field: "CEO / Budget Approver Email"
- Stored in `AppSettings` table (key-value: `{ key: 'ceo_email', value: 'ceo@company.com' }`)
**AppSettings table schema:**
| Column | Type | Description |
|--------|------|-------------|
| key | SingleLineText | Setting key (unique) |
| value | LongText | Setting value |
Add to `REQUIRED_TABLES`. Read via `GET /api/settings/:key` (superadmin), write via `PATCH /api/settings/:key` (superadmin).
### Finance Page Changes
Add a "Budget Requests" section to the Finance page:
- Shows all requests with status badge (pending/approved/rejected/cancelled)
- Pending requests show a subtle banner at top: "1 budget request pending CEO approval"
- Each row: amount, justification (truncated), status, date, earmarked for, resolved_at
- Superadmin sees "Request Budget" button in the page header
### Campaign Creation Change
When creating/editing a campaign with a budget:
1. Acquire budget mutex
2. Server calculates `mainAvailable`
3. If `budget > mainAvailable`: return 400 with `{ error: 'Insufficient budget', available: mainAvailable }`
4. If OK: create campaign, then auto-create BudgetEntry (type=income, campaign_id=new campaign ID, amount=budget)
5. `Campaign.budget` field is still written for backward compat but NOT used in calculations
6. Release mutex
When increasing a campaign's budget:
- Delta = newBudget - currentAllocated (where currentAllocated = sum of income BudgetEntries with this campaign_id)
- Acquire mutex, check `mainAvailable >= delta`, update BudgetEntry amount, release
When decreasing a campaign's budget:
- Check `newBudget >= sum(tracks.budget_allocated for this campaign)`
- If not: return 400 "Cannot reduce below track allocations"
- Update BudgetEntry amount (freed funds return to main automatically)
### Track Creation/Edit Change
When creating/editing a track with `budget_allocated`:
1. Calculate `campaignAllocated` = sum of income BudgetEntries with this campaign_id
2. Calculate `tracksTotalAllocated` = sum of all tracks' `budget_allocated` for this campaign (excluding current track if editing)
3. `campaignAvailable = campaignAllocated - tracksTotalAllocated`
4. If `budget_allocated > campaignAvailable`: return 400
5. If OK: save normally
### Finance Summary Endpoint Fix
`GET /api/finance/summary` — rewrite calculation:
```javascript
// Single source of truth — BudgetEntries only
const incomeEntries = budgetEntries.filter(e => (e.type || 'income') === 'income');
const expenseEntries = budgetEntries.filter(e => e.type === 'expense');
const totalReceived = incomeEntries.reduce((s, e) => s + (e.amount || 0), 0); // SAME for all roles
const totalExpenses = expenseEntries.reduce((s, e) => s + (e.amount || 0), 0);
const totalCampaignBudget = incomeEntries
.filter(e => e.campaign_id)
.reduce((s, e) => s + (e.amount || 0), 0); // FROM ENTRIES, not Campaign.budget
const totalProjectBudget = incomeEntries
.filter(e => e.project_id)
.reduce((s, e) => s + (e.amount || 0), 0);
const mainAvailable = totalReceived - totalExpenses - totalCampaignBudget - totalProjectBudget;
// Track spending stays within campaign allocation — not subtracted from main
const remaining = mainAvailable; // Simple. No double-counting.
```
### Migration
Existing data:
1. For each campaign with `budget > 0` that has NO corresponding income BudgetEntry with that campaign_id: auto-create an income BudgetEntry linked to that campaign
2. Skip campaigns with `budget = 0` or `budget = null`
3. Log migrations to console for audit
4. Run once on server startup (idempotent — skip if matching entry already exists)
### Email Templates
Budget request email to CEO:
- Subject: `Rawaj — Budget Request: $X`
- Header: Rawaj branded (dark forest `#0a1f1c`)
- Body: "{requester} is requesting $X. Justification: {note}"
- CTA: "Review Request" button → public approval page
- No internal budget details
Approval/rejection notification to superadmin:
- Subject: `Rawaj — Budget Request Approved/Rejected: $X`
- Body: result + CEO's response note if any
## Out of Scope
- Multi-currency support
- Budget periods/fiscal years
- Partial approval (CEO can't approve a different amount)
- Delegation (CEO can't forward approval to someone else)
- Audit log (beyond the BudgetRequests table itself)
- Currency precision (uses NocoDB Decimal as-is)
+2 -1
View File
@@ -1,3 +1,4 @@
{ {
"uploadMaxSizeMB": 500 "uploadMaxSizeMB": 500,
"ceoEmail": "fahed@softhouse.io"
} }
+48
View File
@@ -0,0 +1,48 @@
// server/budget-helpers.js — Budget availability calculations
// Single source of truth: BudgetEntries table
const nocodb = require('./nocodb');
async function getMainAvailable() {
const entries = await nocodb.list('BudgetEntries', { limit: 10000 });
const income = entries.filter(e => (e.type || 'income') === 'income');
const expenses = entries.filter(e => e.type === 'expense');
const totalReceived = income.reduce((s, e) => s + (e.amount || 0), 0);
const totalExpenses = expenses.reduce((s, e) => s + (e.amount || 0), 0);
const totalCampaignBudget = income.filter(e => e.campaign_id).reduce((s, e) => s + (e.amount || 0), 0);
const totalProjectBudget = income.filter(e => e.project_id).reduce((s, e) => s + (e.amount || 0), 0);
return {
totalReceived,
totalExpenses,
totalCampaignBudget,
totalProjectBudget,
available: totalReceived - totalExpenses - totalCampaignBudget - totalProjectBudget,
};
}
async function getCampaignAvailable(campaignId) {
const entries = await nocodb.list('BudgetEntries', { limit: 10000 });
const campaignIncome = entries.filter(e =>
e.campaign_id && Number(e.campaign_id) === Number(campaignId) && (e.type || 'income') === 'income'
);
const allocated = campaignIncome.reduce((s, e) => s + (e.amount || 0), 0);
const tracks = await nocodb.list('CampaignTracks', {
where: `(campaign_id,eq,${campaignId})`,
limit: 10000,
});
const trackAllocated = tracks.reduce((s, t) => s + (t.budget_allocated || 0), 0);
return { allocated, trackAllocated, available: allocated - trackAllocated };
}
async function getCampaignAllocatedFromEntries(campaignId) {
const entries = await nocodb.list('BudgetEntries', { limit: 10000 });
return entries
.filter(e => e.campaign_id && Number(e.campaign_id) === Number(campaignId) && (e.type || 'income') === 'income')
.reduce((s, e) => s + (e.amount || 0), 0);
}
module.exports = { getMainAvailable, getCampaignAvailable, getCampaignAllocatedFromEntries };
+13
View File
@@ -0,0 +1,13 @@
// server/budget-mutex.js — In-memory mutex for budget-modifying operations
// Prevents race conditions when multiple requests check availability simultaneously
let _lock = null;
async function acquireBudgetLock() {
while (_lock) await _lock;
let resolve;
_lock = new Promise(r => { resolve = r; });
return () => { _lock = null; resolve(); };
}
module.exports = { acquireBudgetLock };
+70 -5
View File
@@ -4,8 +4,8 @@ const nocodb = require('./nocodb');
const { parseApproverIds } = require('./helpers'); const { parseApproverIds } = require('./helpers');
const APP_URL = process.env.APP_URL || process.env.CORS_ORIGIN || 'http://localhost:3001'; const APP_URL = process.env.APP_URL || process.env.CORS_ORIGIN || 'http://localhost:3001';
const APP_NAME_EN = "Samaya's Digital Hub"; const APP_NAME_EN = 'Rawaj';
const APP_NAME_AR = 'المركز الرقمي لسمايا'; const APP_NAME_AR = 'رواج';
// ─── TRANSLATIONS ─────────────────────────────────────────────── // ─── TRANSLATIONS ───────────────────────────────────────────────
@@ -94,6 +94,21 @@ const t = {
view: { en: 'View', ar: 'عرض' }, view: { en: 'View', ar: 'عرض' },
viewTask: { en: 'View Task', ar: 'عرض المهمة' }, viewTask: { en: 'View Task', ar: 'عرض المهمة' },
viewIssue:{ en: 'View Issue', ar: 'عرض المشكلة' }, viewIssue:{ en: 'View Issue', ar: 'عرض المشكلة' },
// Budget
budgetRequest: { en: 'Budget Request', ar: 'طلب ميزانية' },
budgetRequestHeading: { en: 'Budget Request', ar: 'طلب ميزانية' },
budgetRequestBody: { en: (name, amount) => `<strong>${name}</strong> is requesting <strong>${amount}</strong>.`,
ar: (name, amount) => `يطلب <strong>${name}</strong> مبلغ <strong>${amount}</strong>.` },
budgetJustification: { en: 'Justification', ar: 'المبرر' },
budgetEarmarkedFor: { en: 'Earmarked for', ar: 'مخصص لـ' },
reviewRequest: { en: 'Review Request', ar: 'مراجعة الطلب' },
budgetApproved: { en: 'Budget Request Approved', ar: 'تمت الموافقة على طلب الميزانية' },
budgetApprovedBody: { en: (amount) => `Your budget request for <strong>${amount}</strong> has been approved. Funds are now available.`,
ar: (amount) => `تمت الموافقة على طلب الميزانية بمبلغ <strong>${amount}</strong>. الأموال متاحة الآن.` },
budgetRejected: { en: 'Budget Request Rejected', ar: 'تم رفض طلب الميزانية' },
budgetRejectedBody: { en: (amount) => `Your budget request for <strong>${amount}</strong> has been rejected.`,
ar: (amount) => `تم رفض طلب الميزانية بمبلغ <strong>${amount}</strong>.` },
}; };
function tr(key, lang) { return t[key]?.[lang] || t[key]?.en || key; } function tr(key, lang) { return t[key]?.[lang] || t[key]?.en || key; }
@@ -111,7 +126,7 @@ function renderEmail({ heading, bodyHtml, ctaText, ctaUrl, lang = 'en' }) {
<html dir="${dir}" lang="${lang}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head> <html dir="${dir}" lang="${lang}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="margin:0;padding:0;background:#f4f5f7;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif"> <body style="margin:0;padding:0;background:#f4f5f7;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif">
<div style="max-width:600px;margin:0 auto;padding:20px;direction:${dir};text-align:${align}"> <div style="max-width:600px;margin:0 auto;padding:20px;direction:${dir};text-align:${align}">
<div style="background:#1e293b;color:#fff;padding:16px 24px;border-radius:12px 12px 0 0;font-size:16px;font-weight:600;text-align:${align}"> <div style="background:#0a1f1c;color:#fff;padding:16px 24px;border-radius:12px 12px 0 0;font-size:16px;font-weight:600;text-align:${align}">
${appName} ${appName}
</div> </div>
<div style="background:#fff;padding:32px 24px;border-radius:0 0 12px 12px;border:1px solid #e2e8f0;border-top:none"> <div style="background:#fff;padding:32px 24px;border-radius:0 0 12px 12px;border:1px solid #e2e8f0;border-top:none">
@@ -121,7 +136,7 @@ function renderEmail({ heading, bodyHtml, ctaText, ctaUrl, lang = 'en' }) {
</div> </div>
${ctaText && ctaUrl ? ` ${ctaText && ctaUrl ? `
<div style="margin:24px 0 8px"> <div style="margin:24px 0 8px">
<a href="${ctaUrl}" style="display:inline-block;background:#3b82f6;color:#fff;padding:12px 24px;border-radius:8px;text-decoration:none;font-weight:600;font-size:14px">${ctaText}</a> <a href="${ctaUrl}" style="display:inline-block;background:#0d9488;color:#fff;padding:12px 24px;border-radius:8px;text-decoration:none;font-weight:600;font-size:14px">${ctaText}</a>
</div>` : ''} </div>` : ''}
</div> </div>
<div style="text-align:center;padding:16px;color:#94a3b8;font-size:12px"> <div style="text-align:center;padding:16px;color:#94a3b8;font-size:12px">
@@ -151,8 +166,10 @@ async function getMultipleUsers(userIds) {
} }
function send({ to, subject, heading, bodyHtml, ctaText, ctaUrl, lang }) { function send({ to, subject, heading, bodyHtml, ctaText, ctaUrl, lang }) {
const appName = lang === 'ar' ? APP_NAME_AR : APP_NAME_EN;
const fullSubject = `${appName} ${subject}`;
const { html, text } = renderEmail({ heading, bodyHtml, ctaText, ctaUrl, lang }); const { html, text } = renderEmail({ heading, bodyHtml, ctaText, ctaUrl, lang });
sendMail({ to, subject, html, text }) sendMail({ to, subject: fullSubject, html, text })
.then(() => console.log(`[notifications] Sent "${subject}" to ${to}`)) .then(() => console.log(`[notifications] Sent "${subject}" to ${to}`))
.catch(err => console.error(`[notifications] FAILED "${subject}" to ${to}:`, err.message)); .catch(err => console.error(`[notifications] FAILED "${subject}" to ${to}:`, err.message));
} }
@@ -387,7 +404,52 @@ function notifyUserInvited({ email, name, password, inviterName, lang = 'en' })
}); });
} }
// 11. Budget request → email CEO
function notifyBudgetRequest({ ceoEmail, amount, requesterName, justification, earmarkedFor, approvalUrl }) {
const earmarkHtml = earmarkedFor ? `<p><strong>${tr('budgetEarmarkedFor', 'en')}:</strong> ${earmarkedFor}</p>` : '';
send({
to: ceoEmail, lang: 'en',
subject: `${tr('budgetRequest', 'en')}: ${amount}`,
heading: tr('budgetRequestHeading', 'en'),
bodyHtml: `
<p>${tr('budgetRequestBody', 'en')(requesterName, amount)}</p>
<p><strong>${tr('budgetJustification', 'en')}:</strong> ${justification}</p>
${earmarkHtml}`,
ctaText: tr('reviewRequest', 'en'),
ctaUrl: approvalUrl,
});
}
// 12. Budget approved → notify requester
function notifyBudgetApproved({ request, requesterEmail, requesterLang }) {
const l = requesterLang || 'en';
send({
to: requesterEmail, lang: l,
subject: `${tr('budgetApproved', l)}: ${request.amount}`,
heading: tr('budgetApproved', l),
bodyHtml: `
<p>${tr('budgetApprovedBody', l)(String(request.amount))}</p>
${request.response_note ? `<blockquote ${BLOCKQUOTE}>${request.response_note}</blockquote>` : ''}`,
ctaText: null, ctaUrl: null,
});
}
// 13. Budget rejected → notify requester
function notifyBudgetRejected({ request, requesterEmail, requesterLang }) {
const l = requesterLang || 'en';
send({
to: requesterEmail, lang: l,
subject: `${tr('budgetRejected', l)}: ${request.amount}`,
heading: tr('budgetRejected', l),
bodyHtml: `
<p>${tr('budgetRejectedBody', l)(String(request.amount))}</p>
${request.response_note ? `<blockquote ${BLOCKQUOTE}>${request.response_note}</blockquote>` : ''}`,
ctaText: null, ctaUrl: null,
});
}
module.exports = { module.exports = {
renderEmail,
notifyReviewSubmitted, notifyReviewSubmitted,
notifyApproved, notifyApproved,
notifyRejected, notifyRejected,
@@ -398,4 +460,7 @@ module.exports = {
notifyIssueStatusUpdate, notifyIssueStatusUpdate,
notifyCampaignCreated, notifyCampaignCreated,
notifyUserInvited, notifyUserInvited,
notifyBudgetRequest,
notifyBudgetApproved,
notifyBudgetRejected,
}; };
+457 -23
View File
@@ -13,6 +13,8 @@ const crypto = require('crypto');
const { PORT, UPLOADS_DIR, SETTINGS_PATH, DEFAULTS, QUERY_LIMITS, ALL_MODULES, TABLE_NAME_MAP, COMMENT_ENTITY_TYPES } = require('./config'); const { PORT, UPLOADS_DIR, SETTINGS_PATH, DEFAULTS, QUERY_LIMITS, ALL_MODULES, TABLE_NAME_MAP, COMMENT_ENTITY_TYPES } = require('./config');
const { getRecordName, batchResolveNames, parseApproverIds, safeJsonParse, pickBodyFields, sanitizeWhereValue, getUserModules, stripSensitiveFields, getUserTeamIds, getUserVisibilityContext } = require('./helpers'); const { getRecordName, batchResolveNames, parseApproverIds, safeJsonParse, pickBodyFields, sanitizeWhereValue, getUserModules, stripSensitiveFields, getUserTeamIds, getUserVisibilityContext } = require('./helpers');
const notify = require('./notifications'); const notify = require('./notifications');
const { acquireBudgetLock } = require('./budget-mutex');
const { getMainAvailable, getCampaignAvailable, getCampaignAllocatedFromEntries } = require('./budget-helpers');
const app = express(); const app = express();
@@ -61,7 +63,7 @@ app.use(session({
app.use('/api/uploads', express.static(uploadsDir)); app.use('/api/uploads', express.static(uploadsDir));
// ─── APP SETTINGS (persisted to JSON) ──────────────────────────── // ─── APP SETTINGS (persisted to JSON) ────────────────────────────
const defaultSettings = { uploadMaxSizeMB: DEFAULTS.uploadMaxSizeMB }; const defaultSettings = { uploadMaxSizeMB: DEFAULTS.uploadMaxSizeMB, ceoEmail: '' };
function loadSettings() { function loadSettings() {
try { return { ...defaultSettings, ...JSON.parse(fs.readFileSync(SETTINGS_PATH, 'utf8')) }; } try { return { ...defaultSettings, ...JSON.parse(fs.readFileSync(SETTINGS_PATH, 'utf8')) }; }
catch (err) { console.error('Load settings error:', err.message); return { ...defaultSettings }; } catch (err) { console.error('Load settings error:', err.message); return { ...defaultSettings }; }
@@ -313,6 +315,17 @@ const REQUIRED_TABLES = {
{ title: 'notes', uidt: 'LongText' }, { title: 'notes', uidt: 'LongText' },
{ title: 'campaign_id', uidt: 'Number' }, { title: 'campaign_id', uidt: 'Number' },
], ],
BudgetRequests: [
{ title: 'amount', uidt: 'Decimal' },
{ title: 'justification', uidt: 'LongText' },
{ title: 'status', uidt: 'SingleLineText' },
{ title: 'requested_by_user_id', uidt: 'Number' },
{ title: 'approval_token', uidt: 'SingleLineText' },
{ title: 'response_note', uidt: 'LongText' },
{ title: 'earmarked_campaign_id', uidt: 'Number' },
{ title: 'earmarked_project_id', uidt: 'Number' },
{ title: 'created_budget_entry_id', uidt: 'Number' },
],
TaskAttachments: [ TaskAttachments: [
{ title: 'filename', uidt: 'SingleLineText' }, { title: 'filename', uidt: 'SingleLineText' },
{ title: 'original_name', uidt: 'SingleLineText' }, { title: 'original_name', uidt: 'SingleLineText' },
@@ -515,6 +528,10 @@ const TEXT_COLUMNS = {
{ name: 'review_version', uidt: 'Number' }, { name: 'review_version', uidt: 'Number' },
], ],
PostAttachments: [{ name: 'version_id', uidt: 'Number' }], PostAttachments: [{ name: 'version_id', uidt: 'Number' }],
BudgetRequests: [
{ name: 'token_expires_at', uidt: 'SingleLineText' },
{ name: 'resolved_at', uidt: 'SingleLineText' },
],
}; };
async function ensureTextColumns() { async function ensureTextColumns() {
@@ -746,17 +763,21 @@ app.post('/api/auth/forgot-password', async (req, res) => {
const { sendMail } = require('./mail'); const { sendMail } = require('./mail');
await sendMail({ await sendMail({
to: email, to: email,
subject: 'Password Reset', subject: 'Rawaj — Password Reset',
html: `<div style="font-family:sans-serif;max-width:500px;margin:0 auto"> html: `<div style="font-family:sans-serif;max-width:500px;margin:0 auto;padding:20px">
<h2>Password Reset</h2> <div style="background:#0a1f1c;color:#fff;padding:16px 24px;border-radius:12px 12px 0 0;font-size:16px;font-weight:600">Rawaj</div>
<p>Hello ${user.name || ''},</p> <div style="background:#fff;padding:32px 24px;border-radius:0 0 12px 12px;border:1px solid #e2e8f0;border-top:none">
<p>Click below to reset your password:</p> <h2 style="margin:0 0 16px;color:#1e293b;font-size:20px">Password Reset</h2>
<p style="text-align:center;margin:30px 0"> <p style="color:#475569;font-size:15px;line-height:1.6">Hello ${user.name || ''},</p>
<a href="${resetUrl}" style="background:#3b82f6;color:white;padding:12px 24px;border-radius:8px;text-decoration:none;font-weight:bold">Reset Password</a> <p style="color:#475569;font-size:15px;line-height:1.6">Click below to reset your password:</p>
</p> <div style="margin:24px 0 8px">
<p style="color:#666;font-size:14px">This link expires in 1 hour. If you didn't request this, ignore this email.</p> <a href="${resetUrl}" style="display:inline-block;background:#0d9488;color:#fff;padding:12px 24px;border-radius:8px;text-decoration:none;font-weight:600;font-size:14px">Reset Password</a>
</div>
<p style="color:#94a3b8;font-size:13px">This link expires in 1 hour. If you didn't request this, ignore this email.</p>
</div>
<div style="text-align:center;padding:16px;color:#94a3b8;font-size:12px">This is an automated notification from Rawaj</div>
</div>`, </div>`,
text: `Hello ${user.name || ''},\n\nVisit this link to reset your password:\n${resetUrl}\n\nExpires in 1 hour.`, text: `Rawaj — Password Reset\n\nHello ${user.name || ''},\n\nVisit this link to reset your password:\n${resetUrl}\n\nExpires in 1 hour.`,
}); });
} }
} catch (err) { } catch (err) {
@@ -2096,8 +2117,19 @@ app.post('/api/campaigns', requireAuth, requireRole('superadmin', 'manager'), as
if (!start_date || !end_date) return res.status(400).json({ error: 'Start and end dates are required' }); if (!start_date || !end_date) return res.status(400).json({ error: 'Start and end dates are required' });
const effectiveBudget = req.session.userRole === 'superadmin' ? (budget || null) : null; const effectiveBudget = req.session.userRole === 'superadmin' ? (budget || null) : null;
const numericBudget = Number(effectiveBudget) || 0;
let releaseLock;
try { try {
// Budget validation: if allocating budget, check main availability
if (numericBudget > 0) {
releaseLock = await acquireBudgetLock();
const main = await getMainAvailable();
if (main.available < numericBudget) {
return res.status(400).json({ error: 'Insufficient budget', available: main.available });
}
}
const created = await nocodb.create('Campaigns', { const created = await nocodb.create('Campaigns', {
name, description: description || null, name, description: description || null,
start_date, end_date, start_date, end_date,
@@ -2113,6 +2145,18 @@ app.post('/api/campaigns', requireAuth, requireRole('superadmin', 'manager'), as
created_by_user_id: req.session.userId, created_by_user_id: req.session.userId,
}); });
// Auto-create BudgetEntry for campaign allocation
if (numericBudget > 0) {
await nocodb.create('BudgetEntries', {
type: 'income', amount: numericBudget,
campaign_id: created.Id,
label: 'Campaign allocation',
source: 'Campaign creation',
date_received: new Date().toISOString().slice(0, 10),
category: 'marketing',
});
}
// Auto-assign creator // Auto-assign creator
await nocodb.create('CampaignAssignments', { await nocodb.create('CampaignAssignments', {
assigned_at: new Date().toISOString(), assigned_at: new Date().toISOString(),
@@ -2131,10 +2175,13 @@ app.post('/api/campaigns', requireAuth, requireRole('superadmin', 'manager'), as
} catch (err) { } catch (err) {
console.error('Create campaign error:', err); console.error('Create campaign error:', err);
res.status(500).json({ error: 'Failed to create campaign' }); res.status(500).json({ error: 'Failed to create campaign' });
} finally {
if (releaseLock) releaseLock();
} }
}); });
app.patch('/api/campaigns/:id', requireAuth, requireOwnerOrRole('campaigns', 'superadmin', 'manager'), async (req, res) => { app.patch('/api/campaigns/:id', requireAuth, requireOwnerOrRole('campaigns', 'superadmin', 'manager'), async (req, res) => {
let releaseLock;
try { try {
const existing = await nocodb.get('Campaigns', req.params.id); const existing = await nocodb.get('Campaigns', req.params.id);
if (!existing) return res.status(404).json({ error: 'Campaign not found' }); if (!existing) return res.status(404).json({ error: 'Campaign not found' });
@@ -2153,6 +2200,51 @@ app.patch('/api/campaigns/:id', requireAuth, requireOwnerOrRole('campaigns', 'su
if (Object.keys(data).length === 0) return res.status(400).json({ error: 'No fields to update' }); if (Object.keys(data).length === 0) return res.status(400).json({ error: 'No fields to update' });
// Budget validation when budget is being updated
if (data.budget !== undefined) {
const newBudget = Number(data.budget) || 0;
const currentAllocated = await getCampaignAllocatedFromEntries(req.params.id);
const delta = newBudget - currentAllocated;
if (delta > 0) {
// Increasing: check main pool has enough
releaseLock = await acquireBudgetLock();
const main = await getMainAvailable();
if (main.available < delta) {
return res.status(400).json({ error: 'Insufficient budget', available: main.available });
}
} else if (delta < 0) {
// Decreasing: check new budget covers existing track allocations
const campAvail = await getCampaignAvailable(req.params.id);
if (newBudget < campAvail.trackAllocated) {
return res.status(400).json({
error: 'Cannot reduce below track allocations',
tracks_allocated: campAvail.trackAllocated,
});
}
}
// Update or create the BudgetEntry for this campaign
if (newBudget > 0) {
const entries = await nocodb.list('BudgetEntries', { limit: QUERY_LIMITS.max });
const existingEntry = entries.find(e =>
e.campaign_id && Number(e.campaign_id) === Number(req.params.id) && (e.type || 'income') === 'income'
);
if (existingEntry) {
await nocodb.update('BudgetEntries', existingEntry.Id, { amount: newBudget });
} else {
await nocodb.create('BudgetEntries', {
type: 'income', amount: newBudget,
campaign_id: Number(req.params.id),
label: 'Campaign allocation',
source: 'Campaign budget update',
date_received: new Date().toISOString().slice(0, 10),
category: 'marketing',
});
}
}
}
await nocodb.update('Campaigns', req.params.id, data); await nocodb.update('Campaigns', req.params.id, data);
const campaign = await nocodb.get('Campaigns', req.params.id); const campaign = await nocodb.get('Campaigns', req.params.id);
@@ -2164,6 +2256,8 @@ app.patch('/api/campaigns/:id', requireAuth, requireOwnerOrRole('campaigns', 'su
} catch (err) { } catch (err) {
console.error('Update campaign error:', err); console.error('Update campaign error:', err);
res.status(500).json({ error: 'Failed to update campaign' }); res.status(500).json({ error: 'Failed to update campaign' });
} finally {
if (releaseLock) releaseLock();
} }
}); });
@@ -2186,6 +2280,10 @@ app.delete('/api/campaigns/:id', requireAuth, requireRole('superadmin', 'manager
const assets = await nocodb.list('Assets', { where: `(campaign_id,eq,${id})`, limit: QUERY_LIMITS.max }); const assets = await nocodb.list('Assets', { where: `(campaign_id,eq,${id})`, limit: QUERY_LIMITS.max });
for (const a of assets) await nocodb.update('Assets', a.Id, { campaign_id: null }); for (const a of assets) await nocodb.update('Assets', a.Id, { campaign_id: null });
// Release budget — null out campaign_id on linked BudgetEntries
const budgetEntries = await nocodb.list('BudgetEntries', { where: `(campaign_id,eq,${id})`, limit: QUERY_LIMITS.max });
for (const e of budgetEntries) await nocodb.update('BudgetEntries', e.Id, { campaign_id: null });
await nocodb.delete('Campaigns', id); await nocodb.delete('Campaigns', id);
res.json({ success: true }); res.json({ success: true });
} catch (err) { } catch (err) {
@@ -2340,13 +2438,27 @@ app.post('/api/budget', requireAuth, requireRole('superadmin', 'manager'), async
const { label, amount, source, destination, campaign_id, project_id, category, date_received, notes, type } = req.body; const { label, amount, source, destination, campaign_id, project_id, category, date_received, notes, type } = req.body;
if (!label || !amount || !date_received) return res.status(400).json({ error: 'Label, amount, and date are required' }); if (!label || !amount || !date_received) return res.status(400).json({ error: 'Label, amount, and date are required' });
const numericAmount = Number(amount);
if (!numericAmount || numericAmount <= 0) return res.status(400).json({ error: 'Amount must be greater than 0' });
const entryType = type || 'income';
let releaseLock;
try { try {
// Validate expense against main available budget
if (entryType === 'expense') {
releaseLock = await acquireBudgetLock();
const main = await getMainAvailable();
if (main.available < numericAmount) {
return res.status(400).json({ error: 'Insufficient budget', available: main.available });
}
}
const created = await nocodb.create('BudgetEntries', { const created = await nocodb.create('BudgetEntries', {
label, amount, source: source || null, destination: destination || null, label, amount: numericAmount, source: source || null, destination: destination || null,
category: category || 'marketing', date_received, notes: notes || '', category: category || 'marketing', date_received, notes: notes || '',
campaign_id: campaign_id ? Number(campaign_id) : null, campaign_id: campaign_id ? Number(campaign_id) : null,
project_id: project_id ? Number(project_id) : null, project_id: project_id ? Number(project_id) : null,
type: type || 'income', type: entryType,
}); });
const entry = await nocodb.get('BudgetEntries', created.Id); const entry = await nocodb.get('BudgetEntries', created.Id);
res.status(201).json({ res.status(201).json({
@@ -2356,6 +2468,8 @@ app.post('/api/budget', requireAuth, requireRole('superadmin', 'manager'), async
}); });
} catch (err) { } catch (err) {
res.status(500).json({ error: 'Failed to create budget entry' }); res.status(500).json({ error: 'Failed to create budget entry' });
} finally {
if (releaseLock) releaseLock();
} }
}); });
@@ -2397,6 +2511,258 @@ app.delete('/api/budget/:id', requireAuth, requireRole('superadmin', 'manager'),
} }
}); });
// ─── BUDGET REQUESTS ────────────────────────────────────────────
// Public routes first (no auth)
app.get('/api/budget-approval/:token', async (req, res) => {
try {
const requests = await nocodb.list('BudgetRequests', {
where: `(approval_token,eq,${sanitizeWhereValue(req.params.token)})`,
limit: 1,
});
const request = requests[0];
if (!request) return res.status(404).json({ error: 'Request not found' });
// Already handled
if (request.status !== 'pending') {
return res.json({ status: request.status });
}
// Check expiry
if (request.token_expires_at && new Date(request.token_expires_at) < new Date()) {
return res.json({ status: 'expired' });
}
// Enrich with names
let requester_name = 'Unknown';
try {
const u = await nocodb.get('Users', request.requested_by_user_id);
if (u) requester_name = u.name;
} catch {}
let earmarked_campaign_name = null;
let earmarked_project_name = null;
if (request.earmarked_campaign_id) {
try {
const c = await nocodb.get('Campaigns', request.earmarked_campaign_id);
if (c) earmarked_campaign_name = c.name;
} catch {}
}
if (request.earmarked_project_id) {
try {
const p = await nocodb.get('Projects', request.earmarked_project_id);
if (p) earmarked_project_name = p.name;
} catch {}
}
res.json({
amount: request.amount,
requester_name,
justification: request.justification,
earmarked_campaign_name,
earmarked_project_name,
status: request.status,
});
} catch (err) {
console.error('Budget approval GET error:', err);
res.status(500).json({ error: 'Failed to load budget request' });
}
});
app.post('/api/budget-approval/:token/respond', async (req, res) => {
try {
const { action, note } = req.body;
if (!action || !['approve', 'reject'].includes(action)) {
return res.status(400).json({ error: 'action must be "approve" or "reject"' });
}
const requests = await nocodb.list('BudgetRequests', {
where: `(approval_token,eq,${sanitizeWhereValue(req.params.token)})`,
limit: 1,
});
const request = requests[0];
if (!request) return res.status(404).json({ error: 'Request not found' });
// Idempotent: already handled
if (request.status === 'approved' || request.status === 'rejected') {
const existing = await nocodb.get('BudgetRequests', request.Id);
return res.json(existing);
}
if (request.status !== 'pending') {
return res.status(400).json({ error: `Request is ${request.status}` });
}
// Check expiry
if (request.token_expires_at && new Date(request.token_expires_at) < new Date()) {
return res.status(400).json({ error: 'Token has expired' });
}
const now = new Date().toISOString();
// Get requester for notifications
let requester = null;
try { requester = await nocodb.get('Users', request.requested_by_user_id); } catch {}
if (action === 'approve') {
// Create income BudgetEntry
const entryData = {
type: 'income',
amount: request.amount,
source: 'CEO Approved',
label: `Budget request #${request.Id}`,
date_received: now.split('T')[0],
};
if (request.earmarked_campaign_id) entryData.campaign_id = request.earmarked_campaign_id;
if (request.earmarked_project_id) entryData.project_id = request.earmarked_project_id;
const entry = await nocodb.create('BudgetEntries', entryData);
await nocodb.update('BudgetRequests', request.Id, {
status: 'approved',
resolved_at: now,
created_budget_entry_id: entry.Id,
response_note: note || null,
});
if (requester && requester.email) {
notify.notifyBudgetApproved({
request: { ...request, status: 'approved', response_note: note || null },
requesterEmail: requester.email,
requesterLang: requester.preferred_language || 'en',
});
}
} else {
// reject
await nocodb.update('BudgetRequests', request.Id, {
status: 'rejected',
resolved_at: now,
response_note: note || null,
});
if (requester && requester.email) {
notify.notifyBudgetRejected({
request: { ...request, status: 'rejected', response_note: note || null },
requesterEmail: requester.email,
requesterLang: requester.preferred_language || 'en',
});
}
}
const updated = await nocodb.get('BudgetRequests', request.Id);
res.json(updated);
} catch (err) {
console.error('Budget approval respond error:', err);
res.status(500).json({ error: 'Failed to process budget response' });
}
});
// Authenticated budget request routes
app.get('/api/budget-requests', requireAuth, requireRole('superadmin'), async (req, res) => {
try {
const requests = await nocodb.list('BudgetRequests', { limit: 200, sort: '-CreatedAt' });
const userIds = [...new Set(requests.map(r => r.requested_by_user_id).filter(Boolean))];
const users = {};
for (const uid of userIds) {
try { const u = await nocodb.get('Users', uid); if (u) users[uid] = u.name; } catch {}
}
const enriched = requests.map(r => ({
...r,
requester_name: users[r.requested_by_user_id] || 'Unknown',
}));
await batchResolveNames(enriched, {
earmarked_campaign_id: { table: 'Campaigns', as: 'earmarked_campaign_name' },
earmarked_project_id: { table: 'Projects', as: 'earmarked_project_name' },
});
res.json(enriched);
} catch (err) {
console.error('Budget requests list error:', err);
res.status(500).json({ error: 'Failed to load budget requests' });
}
});
app.post('/api/budget-requests', requireAuth, requireRole('superadmin'), async (req, res) => {
try {
const { amount, justification, earmarked_campaign_id, earmarked_project_id } = req.body;
const numAmount = Number(amount);
if (!numAmount || numAmount <= 0) {
return res.status(400).json({ error: 'amount must be greater than 0' });
}
if (!justification || !String(justification).trim()) {
return res.status(400).json({ error: 'justification is required' });
}
if (!appSettings.ceoEmail) {
return res.status(400).json({ error: 'CEO email is not configured in settings' });
}
const token = crypto.randomUUID();
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString();
const created = await nocodb.create('BudgetRequests', {
amount: numAmount,
justification: String(justification).trim(),
status: 'pending',
requested_by_user_id: req.session.userId,
approval_token: token,
token_expires_at: expiresAt,
earmarked_campaign_id: earmarked_campaign_id || null,
earmarked_project_id: earmarked_project_id || null,
});
const appUrl = process.env.APP_URL || process.env.CORS_ORIGIN || `${req.protocol}://${req.get('host')}`;
const approvalUrl = `${appUrl}/budget-approval/${token}`;
// Build earmarked label
let earmarkedFor = null;
if (earmarked_campaign_id) {
try {
const c = await nocodb.get('Campaigns', earmarked_campaign_id);
if (c) earmarkedFor = `Campaign: ${c.name}`;
} catch {}
}
if (earmarked_project_id) {
try {
const p = await nocodb.get('Projects', earmarked_project_id);
if (p) earmarkedFor = earmarkedFor ? `${earmarkedFor}, Project: ${p.name}` : `Project: ${p.name}`;
} catch {}
}
notify.notifyBudgetRequest({
ceoEmail: appSettings.ceoEmail,
amount: numAmount,
requesterName: req.session.userName || 'Unknown',
justification: String(justification).trim(),
earmarkedFor,
approvalUrl,
});
const record = await nocodb.get('BudgetRequests', created.Id);
res.status(201).json(record);
} catch (err) {
console.error('Budget request create error:', err);
res.status(500).json({ error: 'Failed to create budget request' });
}
});
app.patch('/api/budget-requests/:id/cancel', requireAuth, requireRole('superadmin'), async (req, res) => {
try {
const existing = await nocodb.get('BudgetRequests', req.params.id);
if (!existing) return res.status(404).json({ error: 'Request not found' });
if (existing.status !== 'pending') {
return res.status(400).json({ error: `Cannot cancel — request is already ${existing.status}` });
}
await nocodb.update('BudgetRequests', req.params.id, {
status: 'cancelled',
resolved_at: new Date().toISOString(),
});
const updated = await nocodb.get('BudgetRequests', req.params.id);
res.json(updated);
} catch (err) {
console.error('Budget request cancel error:', err);
res.status(500).json({ error: 'Failed to cancel budget request' });
}
});
// Finance summary // Finance summary
app.get('/api/finance/summary', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => { app.get('/api/finance/summary', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => {
try { try {
@@ -2414,11 +2780,8 @@ app.get('/api/finance/summary', requireAuth, requireRole('superadmin', 'manager'
const incomeEntries = budgetEntries.filter(e => (e.type || 'income') === 'income'); const incomeEntries = budgetEntries.filter(e => (e.type || 'income') === 'income');
const expenseEntries = budgetEntries.filter(e => e.type === 'expense'); const expenseEntries = budgetEntries.filter(e => e.type === 'expense');
const totalIncome = isSuperadmin const totalReceived = incomeEntries.reduce((sum, e) => sum + (e.amount || 0), 0);
? incomeEntries.reduce((sum, e) => sum + (e.amount || 0), 0)
: campaigns.reduce((sum, c) => sum + (c.budget || 0), 0);
const totalExpenses = expenseEntries.reduce((sum, e) => sum + (e.amount || 0), 0); const totalExpenses = expenseEntries.reduce((sum, e) => sum + (e.amount || 0), 0);
const totalReceived = totalIncome;
const allTracks = await nocodb.list('CampaignTracks', { limit: QUERY_LIMITS.max }); const allTracks = await nocodb.list('CampaignTracks', { limit: QUERY_LIMITS.max });
const campaignStats = campaigns.map(c => { const campaignStats = campaigns.map(c => {
@@ -2447,7 +2810,10 @@ app.get('/api/finance/summary', requireAuth, requireRole('superadmin', 'manager'
conversions: acc.conversions + c.tracks_conversions, conversions: acc.conversions + c.tracks_conversions,
}), { allocated: 0, spent: 0, revenue: 0, impressions: 0, clicks: 0, conversions: 0 }); }), { allocated: 0, spent: 0, revenue: 0, impressions: 0, clicks: 0, conversions: 0 });
const totalCampaignBudget = campaignStats.reduce((s, c) => s + (c.budget || 0), 0); // Campaign budget = sum of income BudgetEntries with campaign_id set
const totalCampaignBudget = incomeEntries
.filter(e => e.campaign_id)
.reduce((s, e) => s + (e.amount || 0), 0);
// Project budget breakdown // Project budget breakdown
let projects = await nocodb.list('Projects', { limit: QUERY_LIMITS.max }); let projects = await nocodb.list('Projects', { limit: QUERY_LIMITS.max });
@@ -2465,17 +2831,20 @@ app.get('/api/finance/summary', requireAuth, requireRole('superadmin', 'manager'
}); });
const totalProjectBudget = projectStats.reduce((s, p) => s + p.budget_allocated, 0); const totalProjectBudget = projectStats.reduce((s, p) => s + p.budget_allocated, 0);
const unallocated = totalReceived - totalCampaignBudget - totalProjectBudget; // remaining = totalReceived - totalExpenses - totalCampaignBudget - totalProjectBudget
const remaining = totalReceived - totalExpenses - totalCampaignBudget - totalProjectBudget;
const mainAvailable = remaining;
res.json({ res.json({
totalReceived, ...totals, totalExpenses, totalReceived, ...totals, totalExpenses,
remaining: totalReceived - totalCampaignBudget - totalProjectBudget - totals.spent - totalExpenses, remaining,
mainAvailable,
roi: totals.spent > 0 ? ((totals.revenue - totals.spent) / totals.spent * 100) : 0, roi: totals.spent > 0 ? ((totals.revenue - totals.spent) / totals.spent * 100) : 0,
campaigns: campaignStats, campaigns: campaignStats,
projects: projectStats, projects: projectStats,
totalCampaignBudget, totalCampaignBudget,
totalProjectBudget, totalProjectBudget,
unallocated, unallocated: remaining,
}); });
} catch (err) { } catch (err) {
console.error('Finance summary error:', err); console.error('Finance summary error:', err);
@@ -2503,9 +2872,19 @@ app.post('/api/campaigns/:id/tracks', requireAuth, requireRole('superadmin', 'ma
if (!campaign) return res.status(404).json({ error: 'Campaign not found' }); if (!campaign) return res.status(404).json({ error: 'Campaign not found' });
const { name, type, platform, budget_allocated, status, notes } = req.body; const { name, type, platform, budget_allocated, status, notes } = req.body;
const numericAlloc = Number(budget_allocated) || 0;
// Validate track allocation against campaign available budget
if (numericAlloc > 0) {
const campAvail = await getCampaignAvailable(req.params.id);
if (campAvail.available < numericAlloc) {
return res.status(400).json({ error: 'Insufficient campaign budget', available: campAvail.available });
}
}
const created = await nocodb.create('CampaignTracks', { const created = await nocodb.create('CampaignTracks', {
name: name || null, type: type || 'organic_social', name: name || null, type: type || 'organic_social',
platform: platform || null, budget_allocated: budget_allocated || 0, platform: platform || null, budget_allocated: numericAlloc,
status: status || 'planned', notes: notes || '', status: status || 'planned', notes: notes || '',
budget_spent: 0, revenue: 0, impressions: 0, clicks: 0, conversions: 0, budget_spent: 0, revenue: 0, impressions: 0, clicks: 0, conversions: 0,
campaign_id: Number(req.params.id), campaign_id: Number(req.params.id),
@@ -2529,6 +2908,19 @@ app.patch('/api/tracks/:id', requireAuth, requireRole('superadmin', 'manager'),
} }
if (Object.keys(data).length === 0) return res.status(400).json({ error: 'No fields to update' }); if (Object.keys(data).length === 0) return res.status(400).json({ error: 'No fields to update' });
// Validate budget_allocated increase against campaign available budget
if (data.budget_allocated !== undefined && existing.campaign_id) {
const newAlloc = Number(data.budget_allocated) || 0;
const currentAlloc = existing.budget_allocated || 0;
const delta = newAlloc - currentAlloc;
if (delta > 0) {
const campAvail = await getCampaignAvailable(existing.campaign_id);
if (campAvail.available < delta) {
return res.status(400).json({ error: 'Insufficient campaign budget', available: campAvail.available });
}
}
}
await nocodb.update('CampaignTracks', req.params.id, data); await nocodb.update('CampaignTracks', req.params.id, data);
const track = await nocodb.get('CampaignTracks', req.params.id); const track = await nocodb.get('CampaignTracks', req.params.id);
res.json(track); res.json(track);
@@ -2729,6 +3121,12 @@ app.delete('/api/projects/:id', requireAuth, requireRole('superadmin', 'manager'
try { try {
const existing = await nocodb.get('Projects', req.params.id); const existing = await nocodb.get('Projects', req.params.id);
if (!existing) return res.status(404).json({ error: 'Project not found' }); if (!existing) return res.status(404).json({ error: 'Project not found' });
// Release budget — null out project_id on linked BudgetEntries
const projId = Number(req.params.id);
const budgetEntries = await nocodb.list('BudgetEntries', { where: `(project_id,eq,${projId})`, limit: QUERY_LIMITS.max });
for (const e of budgetEntries) await nocodb.update('BudgetEntries', e.Id, { project_id: null });
await nocodb.delete('Projects', req.params.id); await nocodb.delete('Projects', req.params.id);
res.json({ success: true }); res.json({ success: true });
} catch (err) { } catch (err) {
@@ -5351,6 +5749,9 @@ app.patch('/api/settings/app', requireAuth, requireRole('superadmin'), (req, res
} }
appSettings.uploadMaxSizeMB = val; appSettings.uploadMaxSizeMB = val;
} }
if (req.body.ceoEmail !== undefined) {
appSettings.ceoEmail = String(req.body.ceoEmail).trim();
}
saveSettings(appSettings); saveSettings(appSettings);
res.json(appSettings); res.json(appSettings);
}); });
@@ -5425,6 +5826,37 @@ async function migrateAuthToNocoDB() {
// ─── START SERVER ─────────────────────────────────────────────── // ─── START SERVER ───────────────────────────────────────────────
// Idempotent migration: create BudgetEntries for campaigns with budget > 0 that lack one
async function migrateCampaignBudgets() {
try {
const campaigns = await nocodb.list('Campaigns', { limit: QUERY_LIMITS.max });
const entries = await nocodb.list('BudgetEntries', { limit: QUERY_LIMITS.max });
const campaignIdsWithEntry = new Set(
entries
.filter(e => e.campaign_id && (e.type || 'income') === 'income')
.map(e => Number(e.campaign_id))
);
for (const c of campaigns) {
const budget = Number(c.budget) || 0;
if (budget <= 0) continue;
if (campaignIdsWithEntry.has(c.Id)) continue;
console.log(`[migrateCampaignBudgets] Creating BudgetEntry for campaign "${c.name}" (Id=${c.Id}, budget=${budget})`);
await nocodb.create('BudgetEntries', {
type: 'income', amount: budget,
campaign_id: c.Id,
label: 'Campaign allocation',
source: 'Budget migration',
date_received: new Date().toISOString().slice(0, 10),
category: 'marketing',
});
}
} catch (err) {
console.error('migrateCampaignBudgets error:', err);
}
}
async function startServer() { async function startServer() {
// Validate required env vars // Validate required env vars
const REQUIRED_ENV = { const REQUIRED_ENV = {
@@ -5461,6 +5893,8 @@ async function startServer() {
await ensureFKColumns(); await ensureFKColumns();
await ensureTextColumns(); await ensureTextColumns();
await backfillFKs(); await backfillFKs();
console.log('Running campaign budget migration...');
await migrateCampaignBudgets();
console.log('Checking auth migration...'); console.log('Checking auth migration...');
await migrateAuthToNocoDB(); await migrateAuthToNocoDB();
console.log('Migration complete.'); console.log('Migration complete.');
@@ -5547,7 +5981,7 @@ async function startServer() {
} }
app.listen(PORT, () => { app.listen(PORT, () => {
console.log(`Digital Hub API running on http://localhost:${PORT}`); console.log(`Rawaj API running on http://localhost:${PORT}`);
console.log(`Uploads directory: ${uploadsDir}`); console.log(`Uploads directory: ${uploadsDir}`);
}); });
} }
+3 -3
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env node #!/usr/bin/env node
/** /**
* setup-tables.js Creates a new "Digital Hub" base in NocoDB * setup-tables.js Creates a new "Rawaj" base in NocoDB
* with all 12 tables, fields, and links. * with all 12 tables, fields, and links.
* Run once: node setup-tables.js * Run once: node setup-tables.js
*/ */
@@ -28,9 +28,9 @@ async function request(method, url, body) {
} }
async function createBase() { async function createBase() {
console.log('Creating "Digital Hub" base...'); console.log('Creating "Rawaj" base...');
const data = await request('POST', `${NOCODB_URL}/api/v2/meta/bases/`, { const data = await request('POST', `${NOCODB_URL}/api/v2/meta/bases/`, {
title: 'Digital Hub', title: 'Rawaj',
type: 'database', type: 'database',
}); });
console.log(` Base created: ${data.id}`); console.log(` Base created: ${data.id}`);