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.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">
<title>Digital Hub</title>
<title>Rawaj</title>
</head>
<body>
<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 Translations = lazy(() => import('./pages/Translations'))
const PublicTranslationReview = lazy(() => import('./pages/PublicTranslationReview'))
const PublicBudgetApproval = lazy(() => import('./pages/PublicBudgetApproval'))
const ForgotPassword = lazy(() => import('./pages/ForgotPassword'))
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 }}>
{/* Profile completion prompt */}
{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="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="/track/:token" element={<PublicIssueTracker />} />
<Route path="/review-translation/:token" element={<PublicTranslationReview />} />
<Route path="/approve-budget/:token" element={<PublicBudgetApproval />} />
<Route path="/" element={user ? <Layout /> : <Navigate to="/login" replace />}>
<Route index element={<Dashboard />} />
{hasModule('marketing') && <>
@@ -64,7 +64,7 @@ export default function ApproverMultiSelect({ users = [], selected = [], onChang
</button>
</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>
{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'}`}>
@@ -76,7 +76,7 @@ export default function ApproverMultiSelect({ users = [], selected = [], onChang
key={uid}
type="button"
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'
}`}
>
+34 -416
View File
@@ -1,13 +1,13 @@
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 { useLanguage } from '../i18n/LanguageContext'
import { api } from '../utils/api'
import Modal from './Modal'
import TabbedModal from './TabbedModal'
import { useToast } from './ToastContainer'
import ArtefactVersionTimeline from './ArtefactVersionTimeline'
import ApproverMultiSelect from './ApproverMultiSelect'
import { ArtefactDetailVersionsTab } from './ArtefactDetailVersionsTab'
const STATUS_COLORS = {
draft: 'bg-surface-tertiary text-text-secondary',
@@ -17,13 +17,6 @@ const STATUS_COLORS = {
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 = {
copy: FileText,
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 [savingDraft, setSavingDraft] = useState(false)
const [deleting, setDeleting] = useState(false)
const [confirmDeleteLangId, setConfirmDeleteLangId] = useState(null)
const [confirmDeleteAttId, setConfirmDeleteAttId] = useState(null)
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)
const [uploading, setUploading] = useState(false)
// Video inline (Drive link input)
const [driveUrl, setDriveUrl] = useState('')
const [dragOver, setDragOver] = useState(false)
const [uploadProgress, setUploadProgress] = useState(0)
// Comments
@@ -137,57 +113,23 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
loadVersionData(version.Id)
}
const handleCreateVersion = async () => {
setCreatingVersion(true)
try {
await api.post(`/artefacts/${artefact.Id}/versions`, {
notes: newVersionNotes || `Version ${(versions[versions.length - 1]?.version_number || 0) + 1}`,
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 handleCreateVersion = async ({ notes, copy_from_previous }) => {
await api.post(`/artefacts/${artefact.Id}/versions`, { notes, copy_from_previous })
toast.success(t('artefacts.versionCreated'))
loadVersions()
onUpdate()
}
const handleAddLanguage = async () => {
if (!languageForm.language_code || !languageForm.language_label || !languageForm.content) {
toast.error(t('artefacts.allFieldsRequired'))
return
}
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 handleAddLanguage = async (languageForm) => {
await api.post(`/artefacts/${artefact.Id}/versions/${selectedVersion.Id}/texts`, languageForm)
toast.success(t('artefacts.languageAdded'))
loadVersionData(selectedVersion.Id)
}
const handleDeleteLanguage = async (textId) => {
try {
await api.delete(`/artefact-version-texts/${textId}`)
toast.success(t('artefacts.languageDeleted'))
loadVersionData(selectedVersion.Id)
} catch (err) {
toast.error(t('artefacts.failedDeleteLanguage'))
}
await api.delete(`/artefact-version-texts/${textId}`)
toast.success(t('artefacts.languageDeleted'))
loadVersionData(selectedVersion.Id)
}
const handleFileUpload = async (fileOrEvent) => {
@@ -215,16 +157,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
}
}
const handleVideoDrop = (e) => {
e.preventDefault()
setDragOver(false)
const file = e.dataTransfer.files?.[0]
if (file && file.type.startsWith('video/')) {
handleFileUpload(file)
}
}
const handleAddDriveVideo = async () => {
const handleAddDriveVideo = async (driveUrl) => {
if (!driveUrl.trim()) {
toast.error(t('artefacts.enterDriveUrl'))
return
@@ -236,7 +169,6 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
drive_url: driveUrl,
})
toast.success(t('artefacts.videoLinkAdded'))
setDriveUrl('')
loadVersionData(selectedVersion.Id)
} catch (err) {
console.error('Add Drive link failed:', err)
@@ -247,13 +179,9 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
}
const handleDeleteAttachment = async (attId) => {
try {
await api.delete(`/artefact-attachments/${attId}`)
toast.success(t('artefacts.attachmentDeleted'))
loadVersionData(selectedVersion.Id)
} catch (err) {
toast.error(t('artefacts.failedDeleteAttachment'))
}
await api.delete(`/artefact-attachments/${attId}`)
toast.success(t('artefacts.attachmentDeleted'))
loadVersionData(selectedVersion.Id)
}
const handleSubmitReview = async () => {
@@ -501,213 +429,22 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
{/* Versions Tab */}
{activeTab === 'versions' && (
<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={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>
<ArtefactDetailVersionsTab
artefact={artefact}
versions={versions}
selectedVersion={selectedVersion}
versionData={versionData}
uploading={uploading}
uploadProgress={uploadProgress}
onSelectVersion={handleSelectVersion}
onCreateVersion={handleCreateVersion}
onAddLanguage={handleAddLanguage}
onDeleteLanguage={handleDeleteLanguage}
onFileUpload={handleFileUpload}
onDeleteAttachment={handleDeleteAttachment}
onAddDriveVideo={handleAddDriveVideo}
getDriveEmbedUrl={getDriveEmbedUrl}
/>
)}
{/* Discussion Tab */}
@@ -836,125 +573,6 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
)}
</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 */}
<Modal
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}
alt={`Version ${version.version_number}`}
className="w-full h-20 object-cover rounded border border-border"
loading="lazy"
/>
</div>
)}
+2 -1
View File
@@ -24,7 +24,7 @@ export default function AssetCard({ asset, onClick }) {
return (
<div
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 */}
<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}
alt={asset.name}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
loading="lazy"
onError={(e) => {
e.target.style.display = 'none'
e.target.nextSibling.style.display = 'flex'
+3 -3
View File
@@ -41,7 +41,7 @@ export default function CampaignCalendar({ campaigns = [] }) {
}
return (
<div className="bg-white rounded-xl border border-border overflow-hidden">
<div className="bg-surface rounded-xl border border-border overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<h3 className="text-lg font-semibold text-text-primary">
@@ -109,8 +109,8 @@ export default function CampaignCalendar({ campaigns = [] }) {
<div
key={campaign._id || ci}
className={`h-5 text-[10px] font-medium text-white flex items-center px-1 truncate ${CAMPAIGN_COLORS[colorIndex]} ${
isStart ? 'rounded-l-full ml-0' : '-ml-1'
} ${isEnd ? 'rounded-r-full mr-0' : '-mr-1'}`}
isStart ? 'rounded-l-full ms-0' : '-ms-1'
} ${isEnd ? 'rounded-r-full me-0' : '-me-1'}`}
title={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 === 'completed' ? 'bg-blue-100 text-blue-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}
</span>
@@ -226,7 +226,7 @@ export default function CampaignDetailPanel({ campaign, onClose, onSave, onDelet
{/* Platforms */}
<div>
<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]) => {
const checked = (form.platforms || []).includes(k)
return (
@@ -281,7 +281,7 @@ export default function CampaignDetailPanel({ campaign, onClose, onSave, onDelet
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">
{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>
<input
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 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 ? (
<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)
)}
@@ -125,7 +125,7 @@ export default function CommentsSection({ entityType, entityId }) {
<div className="flex items-center gap-2">
<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>
<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 && (
<button
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 ${
activePreset === preset.key
? '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)}
+3 -3
View File
@@ -21,7 +21,7 @@ export default function EmptyState({
{actionLabel && (
<button
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}
</button>
@@ -44,7 +44,7 @@ export default function EmptyState({
{actionLabel && (
<button
onClick={onAction}
className="px-5 py-2.5 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm transition-all hover:-translate-y-0.5"
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}
</button>
@@ -52,7 +52,7 @@ export default function EmptyState({
{secondaryActionLabel && (
<button
onClick={onSecondaryAction}
className="px-5 py-2.5 bg-white border border-border text-text-primary rounded-lg text-sm font-medium hover:bg-surface-secondary transition-colors"
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}
</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-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}
`.trim()
@@ -39,7 +39,7 @@ export default function FormInput({
{label && (
<label className="block text-sm font-medium text-text-primary">
{label}
{required && <span className="text-red-500 ml-0.5">*</span>}
{required && <span className="text-red-500 ms-0.5">*</span>}
</label>
)}
@@ -57,7 +57,7 @@ export default function FormInput({
{/* Validation icon */}
{(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 ? (
<AlertCircle className="w-4 h-4 text-red-500" />
) : (
+10 -6
View File
@@ -22,6 +22,7 @@ const PAGE_TITLE_KEYS = {
'/issues': 'header.issues',
'/team': 'header.team',
'/settings': 'header.settings',
'/translations': 'header.translations',
}
const ROLE_INFO = {
@@ -99,7 +100,7 @@ export default function Header() {
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 */}
<div>
<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 ${
user?.role === 'superadmin'
? 'bg-gradient-to-br from-purple-500 to-pink-500'
: 'bg-gradient-to-br from-blue-500 to-indigo-500'
? 'bg-brand-primary'
: 'bg-teal-700'
}`}>
{getInitials(user?.name)}
</div>
@@ -135,7 +136,7 @@ export default function Header() {
</button>
{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 */}
<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>
@@ -174,7 +175,7 @@ export default function Header() {
setShowDropdown(false)
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" />
<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('') }}
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="••••••••"
aria-describedby={passwordError ? 'password-error' : undefined}
/>
</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"
placeholder="••••••••"
minLength={6}
aria-describedby={passwordError ? 'password-error' : undefined}
/>
</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"
placeholder="••••••••"
minLength={6}
aria-describedby={passwordError ? 'password-error' : undefined}
/>
</div>
{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" />
<p className="text-sm text-red-500">{passwordError}</p>
</div>
+12 -12
View File
@@ -237,7 +237,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
if (items.length === 0) {
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" />
<p className="text-text-secondary font-medium">{t('timeline.noItems')}</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 (
<div className="bg-white rounded-xl border border-border overflow-hidden">
<div className="bg-surface rounded-xl border border-border overflow-hidden">
{/* Toolbar */}
<div className="flex items-center justify-between px-4 py-2 border-b border-border bg-surface-secondary">
<div className="flex items-center gap-2">
@@ -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 style={{ minWidth: `${labelWidth + totalDays * pxPerDay}px` }}>
{/* Day header */}
<div className="flex sticky top-0 z-20 bg-white border-b border-border" style={{ height: headerHeight }}>
<div className="shrink-0 border-e border-border bg-surface-secondary flex items-center px-4 sticky left-0 z-30" style={{ width: labelWidth }}>
<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 start-0 z-30" style={{ width: labelWidth }}>
<span className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('timeline.item')}</span>
</div>
<div className="flex relative">
@@ -338,7 +338,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
>
{/* Label column */}
<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 }}
>
{isExpanded ? (
@@ -358,7 +358,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
)}
{item.thumbnailUrl ? (
<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>
) : 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">
@@ -394,7 +394,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
)}
{item.thumbnailUrl ? (
<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>
) : 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">
@@ -415,7 +415,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
style={{ left: `${todayOffset + pxPerDay / 2}px` }}
>
{idx === 0 && (
<div className="absolute -top-0 left-1 text-[8px] font-bold text-red-500 bg-red-50 px-1 rounded whitespace-nowrap">
<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')}
</div>
)}
@@ -459,7 +459,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
{/* Left resize handle */}
{!readOnly && onDateChange && (
<div
className="absolute left-0 top-0 bottom-0 w-2 cursor-col-resize hover:bg-white/30 z-10"
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')}
/>
)}
@@ -520,7 +520,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
{/* Right resize handle */}
{!readOnly && onDateChange && (
<div
className="absolute right-0 top-0 bottom-0 w-2 cursor-col-resize hover:bg-white/30 z-10"
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')}
/>
)}
@@ -536,7 +536,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
{colorPicker && onColorChange && (
<div
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 }}
>
<div className="grid grid-cols-4 gap-1.5 mb-2">
@@ -591,7 +591,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
)}
</div>
{!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')}
</div>
)}
+2 -2
View File
@@ -10,12 +10,12 @@ export default function KanbanCard({ title, thumbnail, brandName, tags, assignee
return (
<div
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 && (
<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>
)}
+2 -2
View File
@@ -15,7 +15,7 @@ const ROLE_BADGES = {
strategist: { bg: 'bg-rose-50', text: 'text-rose-700', label: 'Strategist' },
superadmin: { bg: 'bg-red-50', text: 'text-red-700', label: 'Super Admin' },
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 }) {
@@ -33,7 +33,7 @@ export default function MemberCard({ member, onClick }) {
return (
<div
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 */}
<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`}>
+53 -28
View File
@@ -1,15 +1,38 @@
import { useEffect } from 'react'
import { useEffect, useRef, useCallback } from 'react'
import { createPortal } from 'react-dom'
import { X, AlertTriangle } from 'lucide-react'
import { useLanguage } from '../i18n/LanguageContext'
export default function Modal({
isOpen,
onClose,
title,
children,
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({
isOpen,
onClose,
title,
children,
size = 'md',
// Confirmation mode props
isConfirm = false,
confirmText,
cancelText,
@@ -17,10 +40,11 @@ export default function Modal({
danger = false,
}) {
const { t } = useLanguage()
// Default translations
const modalRef = useRef(null)
const finalConfirmText = confirmText || (danger ? t('common.delete') : t('common.save'))
const finalCancelText = cancelText || t('common.cancel')
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden'
@@ -30,6 +54,12 @@ export default function Modal({
return () => { document.body.style.overflow = '' }
}, [isOpen])
const handleKeyDown = useCallback((e) => {
if (e.key === 'Escape') onClose()
}, [onClose])
useFocusTrap(modalRef, isOpen)
if (!isOpen) return null
const sizeClasses = {
@@ -39,25 +69,23 @@ export default function Modal({
xl: 'max-w-4xl',
}
// Confirmation dialog
if (isConfirm) {
return createPortal(
<div className="fixed inset-0 z-[9999] flex items-center justify-center px-4">
{/* Backdrop */}
<div className="fixed inset-0 z-[9999] flex items-center justify-center 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"
/>
{/* Modal content */}
<div className="relative bg-white rounded-2xl shadow-2xl w-full max-w-md animate-scale-in">
<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="p-6">
{danger && (
<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" />
</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">
{children}
</div>
@@ -74,8 +102,8 @@ export default function Modal({
onClose();
}}
className={`flex-1 px-4 py-2.5 text-sm font-medium text-white rounded-lg shadow-sm transition-colors ${
danger
? 'bg-red-600 hover:bg-red-700'
danger
? 'bg-red-600 hover:bg-red-700'
: 'bg-brand-primary hover:bg-brand-primary-light'
}`}
>
@@ -89,29 +117,26 @@ export default function Modal({
)
}
// Regular modal
return createPortal(
<div className="fixed inset-0 z-[9999] flex items-start justify-center pt-[10vh] px-4">
{/* Backdrop */}
<div className="fixed inset-0 z-[9999] flex items-start justify-center pt-[10vh] 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"
/>
{/* Modal content */}
<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={`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="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
onClick={onClose}
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" />
</button>
</div>
{/* Body */}
<div className="px-6 py-4 overflow-y-auto flex-1">
{children}
</div>
+2 -2
View File
@@ -23,11 +23,11 @@ export default function PostCard({ post, onClick, onMove, compact = false, check
return (
<div
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 && (
<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>
)}
@@ -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 { 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 { api, PLATFORMS, getBrandColor } from '../utils/api'
import ApproverMultiSelect from './ApproverMultiSelect'
import { api, getBrandColor } from '../utils/api'
import CommentsSection from './CommentsSection'
import Modal from './Modal'
import TabbedModal from './TabbedModal'
import { useToast } from './ToastContainer'
const AVAILABLE_LANGUAGES = [
{ code: 'ar', label: 'Arabic' },
{ code: 'en', label: 'English' },
{ code: 'fr', label: 'French' },
{ code: 'id', label: 'Bahasa Indonesia' },
]
import { PostDetailVersions } from './PostDetailVersions'
import { PostDetailPlatforms } from './PostDetailPlatforms'
import { PostDetailApproval } from './PostDetailApproval'
import { PostDetailAttachments } from './PostDetailAttachments'
const TABS = ['details', 'versions', 'platforms', 'approval', 'discussion']
export default function PostDetailPanel({ post, onClose, onSave, onDelete, brands, teamMembers, campaigns }) {
const { t, lang } = useLanguage()
const toast = useToast()
const imageInputRef = useRef(null)
const audioInputRef = useRef(null)
const videoInputRef = useRef(null)
const versionFileInputRef = useRef(null)
const [activeTab, setActiveTab] = useState('details')
const [form, setForm] = useState({})
@@ -38,24 +31,11 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
// Attachments state (non-versioned, legacy)
const [attachments, setAttachments] = useState([])
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
const [versions, setVersions] = useState([])
const [selectedVersion, setSelectedVersion] = 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 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) {
const { PLATFORMS } = await import('../utils/api')
const missingPlatforms = data.platforms.filter(platform => {
const link = (data.publication_links || []).find(l => l.platform === platform)
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) => {
if (!postId) return
try {
await api.post(`/posts/${postId}/attachments/from-asset`, { asset_id: assetId })
loadAttachments()
setShowAssetPicker(false)
} catch (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 ──────────────────────────
async function loadVersions() {
if (!postId) return
@@ -299,44 +263,28 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
loadVersionData(version.Id || version.id || version._id)
}
const handleCreateVersion = async () => {
setCreatingVersion(true)
const handleCreateVersion = async ({ notes, copy_from_previous }) => {
try {
await api.post(`/posts/${postId}/versions`, {
notes: newVersionNotes || undefined,
copy_from_previous: copyFromPrevious,
notes: notes || undefined,
copy_from_previous,
})
setShowNewVersionModal(false)
setNewVersionNotes('')
setCopyFromPrevious(false)
loadVersions()
} catch (err) {
console.error('Create version failed:', err)
} finally {
setCreatingVersion(false)
}
}
const handleAddLanguage = async () => {
if (!selectedVersion || !languageForm.language_code || !languageForm.content) return
setSavingLanguage(true)
try {
const vId = selectedVersion.Id || selectedVersion.id || selectedVersion._id
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 handleAddLanguage = async (languageForm) => {
if (!selectedVersion) return
const vId = selectedVersion.Id || selectedVersion.id || selectedVersion._id
await api.post(`/posts/${postId}/versions/${vId}/texts`, languageForm)
loadVersionData(vId)
}
const handleDeleteLanguage = async (textId) => {
try {
await api.delete(`/post-version-texts/${textId}`)
setConfirmDeleteLangId(null)
const vId = selectedVersion.Id || selectedVersion.id || selectedVersion._id
loadVersionData(vId)
} catch (err) {
@@ -364,7 +312,6 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
const handleDeleteVersionAttachment = async (attId) => {
try {
await api.delete(`/attachments/${attId}`)
setConfirmDeleteAttId(null)
const vId = selectedVersion.Id || selectedVersion.id || selectedVersion._id
loadVersionData(vId)
} 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 === 'in_review' ? 'bg-amber-100 text-amber-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}
</span>
@@ -498,7 +445,7 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
value={form.description}
onChange={e => update('description', e.target.value)}
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')}
/>
</div>
@@ -508,7 +455,7 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
type="text"
value={form.notes}
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')}
/>
</div>
@@ -532,7 +479,13 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
</span>
)}
</div>
{renderAttachments()}
<PostDetailAttachments
attachments={attachments}
uploading={uploading}
onFileUpload={handleFileUpload}
onDeleteAttachment={handleDeleteAttachment}
onAttachAsset={handleAttachAsset}
/>
</div>
)}
</div>
@@ -545,7 +498,7 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
<select
value={form.status}
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>)}
</select>
@@ -556,7 +509,7 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
type="date"
value={form.scheduled_date}
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>
@@ -564,7 +517,7 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
<select
value={form.assigned_to}
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>
{(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
value={form.brand_id}
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>
{(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
value={form.campaign_id}
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>
{(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 ─── */}
{activeTab === 'versions' && !isCreateMode && (
<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={() => 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>
<PostDetailVersions
versions={versions}
selectedVersion={selectedVersion}
versionData={versionData}
onSelectVersion={handleSelectVersion}
onCreateVersion={handleCreateVersion}
onAddLanguage={handleAddLanguage}
onDeleteLanguage={handleDeleteLanguage}
onVersionFileUpload={handleVersionFileUpload}
onDeleteVersionAttachment={handleDeleteVersionAttachment}
uploadingVersionFile={uploadingVersionFile}
versionFileInputRef={versionFileInputRef}
/>
)}
{/* ─── Platforms & Links Tab ─── */}
{activeTab === 'platforms' && (
<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-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>
<PostDetailPlatforms
form={form}
update={update}
updatePublicationLink={updatePublicationLink}
/>
)}
{/* ─── Approval Tab ─── */}
{activeTab === 'approval' && (
<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-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>
<PostDetailApproval
form={form}
update={update}
post={post}
isCreateMode={isCreateMode}
reviewUrl={reviewUrl}
copied={copied}
submittingReview={submittingReview}
saving={saving}
teamMembers={teamMembers}
onSubmitReview={handleSubmitReview}
onCopyReviewLink={copyReviewLink}
onStatusAction={handleStatusAction}
/>
)}
{/* ─── Discussion Tab ─── */}
@@ -1014,319 +618,6 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
>
{t('posts.deleteConfirm')}
</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 (
<div
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 ? (
<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>
) : null}
<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 === 'completed' ? 'bg-blue-100 text-blue-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}
</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>
{(project.thumbnail_url || project.thumbnailUrl) ? (
<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">
<button
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')}
</button>
+13 -4
View File
@@ -3,8 +3,17 @@ import { NavLink } from 'react-router-dom'
import {
LayoutDashboard, FileEdit, Image, Calendar, Wallet,
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'
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 { useLanguage } from '../i18n/LanguageContext'
@@ -115,8 +124,8 @@ export default function Sidebar({ collapsed, setCollapsed }) {
>
{/* Logo */}
<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">
<Sparkles className="w-5 h-5 text-white" />
<div className="w-9 h-9 rounded-lg bg-brand-primary flex items-center justify-center shrink-0">
<MarkaLogo className="w-5 h-5 text-white" />
</div>
{!collapsed && (
<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="w-8 h-8 rounded-full bg-brand-primary flex items-center justify-center shrink-0">
{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" />
)}
+6 -6
View File
@@ -2,7 +2,7 @@
export function SkeletonCard() {
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-3 bg-surface-tertiary rounded w-1/2 mb-2"></div>
<div className="h-3 bg-surface-tertiary rounded w-2/3"></div>
@@ -12,7 +12,7 @@ export function SkeletonCard() {
export function SkeletonStatCard() {
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="w-10 h-10 bg-surface-tertiary rounded-lg"></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 }) {
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="flex gap-4">
{[...Array(cols)].map((_, i) => (
@@ -60,7 +60,7 @@ export function SkeletonKanbanBoard() {
</div>
<div className="bg-surface-secondary rounded-xl p-2 space-y-2 min-h-[400px]">
{[...Array(3)].map((_, cardIdx) => (
<div key={cardIdx} className="bg-white rounded-lg border border-border p-3">
<div 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-3 bg-surface-tertiary rounded w-3/4 mb-3"></div>
<div className="flex gap-2">
@@ -78,7 +78,7 @@ export function SkeletonKanbanBoard() {
export function SkeletonCalendar() {
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="h-6 bg-surface-tertiary rounded w-40"></div>
<div className="h-8 bg-surface-tertiary rounded w-20"></div>
@@ -138,7 +138,7 @@ export function SkeletonDashboard() {
{/* Content cards */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{[...Array(2)].map((_, i) => (
<div key={i} className="bg-white rounded-xl border border-border animate-pulse">
<div key={i} className="bg-surface rounded-xl border border-border animate-pulse">
<div className="px-5 py-4 border-b border-border">
<div className="h-5 bg-surface-tertiary rounded w-32"></div>
</div>
+35 -2
View File
@@ -1,12 +1,45 @@
import { useEffect, useRef, useCallback } from 'react'
import { createPortal } from 'react-dom'
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(
<>
<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
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 }}
role="dialog"
aria-modal="true"
onKeyDown={handleKeyDown}
>
{header}
<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 = {
'brand-primary': 'bg-indigo-50 text-indigo-600 shadow-lg shadow-indigo-500/20',
'brand-secondary': 'bg-pink-50 text-pink-600 shadow-lg shadow-pink-500/20',
'brand-tertiary': 'bg-amber-50 text-amber-600 shadow-lg shadow-amber-500/20',
'brand-quaternary': 'bg-emerald-50 text-emerald-600 shadow-lg shadow-emerald-500/20',
'brand-primary': 'bg-teal-50 text-teal-700',
'brand-secondary': 'bg-pink-50 text-pink-600',
'brand-tertiary': 'bg-amber-50 text-amber-600',
'brand-quaternary': 'bg-teal-50 text-teal-600',
}
const accentClass = accentMap[color] || 'accent-primary'
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>
<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 && (
<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 { X } from 'lucide-react'
@@ -19,26 +19,55 @@ export default function TabbedModal({
footer,
children,
}) {
const modalRef = useRef(null)
useEffect(() => {
document.body.style.overflow = 'hidden'
return () => { document.body.style.overflow = '' }
}, [])
return createPortal(
<div className="fixed inset-0 z-[9999] flex items-start justify-center pt-[5vh] px-4">
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm animate-backdrop-in" onClick={onClose} />
useEffect(() => {
if (!modalRef.current) return
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 */}
<div className="shrink-0">
<div className="px-6 pt-5 pb-3">
<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}
</div>
<button
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"
aria-label="Close dialog"
>
<X className="w-5 h-5" />
</button>
@@ -47,13 +76,15 @@ export default function TabbedModal({
{/* Tabs */}
{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 => {
const TabIcon = tab.icon
return (
<button
key={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 ${
activeTab === tab.key
? 'text-brand-primary'
@@ -80,13 +111,13 @@ export default function TabbedModal({
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto">
<div className="flex-1 overflow-y-auto" role="tabpanel">
{children}
</div>
{/* 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}
</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 === 'high') return 'bg-orange-400 text-white'
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 (
@@ -124,14 +124,14 @@ export default function TaskCalendarView({ tasks, onTaskClick }) {
<div className="flex bg-surface-tertiary rounded-lg p-0.5">
<button
onClick={() => setCalView('month')}
className={`px-2.5 py-1 text-[11px] font-medium rounded-md transition-colors flex items-center gap-1 ${calView === 'month' ? 'bg-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" />
Month
</button>
<button
onClick={() => setCalView('week')}
className={`px-2.5 py-1 text-[11px] font-medium rounded-md transition-colors flex items-center gap-1 ${calView === 'week' ? 'bg-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" />
Week
@@ -162,7 +162,7 @@ export default function TaskCalendarView({ tasks, onTaskClick }) {
<div
key={i}
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 ${
@@ -175,7 +175,7 @@ export default function TaskCalendarView({ tasks, onTaskClick }) {
<button
key={task._id || task.id}
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)
}`}
title={task.title}
@@ -206,7 +206,7 @@ export default function TaskCalendarView({ tasks, onTaskClick }) {
<button
key={task._id || task.id}
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={`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
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">
{/* Priority dot */}
<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 */}
{currentThumbnail && (
<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" />
<button
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')}
>
<X className="w-3 h-3" />
@@ -218,11 +218,11 @@ export default function TaskDetailPanel({ task, onClose, onSave, onDelete, proje
placeholder={t('tasks.taskTitle')}
/>
<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}`} />
{priorityOptions.find(p => p.value === form.priority)?.label}
</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}
</span>
{isOverdue && !isCreateMode && (
@@ -401,11 +401,11 @@ export default function TaskDetailPanel({ task, onClose, onSave, onDelete, proje
const isThumbnail = currentThumbnail && attUrl === currentThumbnail
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">
{isImage ? (
<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 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>
)}
{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" />
</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 && (
<button
onClick={() => handleSetThumbnail(att)}
@@ -454,17 +454,17 @@ export default function TaskDetailPanel({ task, onClose, onSave, onDelete, proje
const previewUrl = isImage ? URL.createObjectURL(file) : null
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">
{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">
<FileText className="w-6 h-6 text-text-tertiary shrink-0" />
<span className="text-xs text-text-secondary truncate">{file.name}</span>
</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
onClick={() => setPendingFiles(prev => prev.filter((_, j) => j !== i))}
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 MODULE_LABELS = { marketing: 'Marketing', projects: 'Projects', finance: 'Finance' }
const MODULE_COLORS = {
marketing: { on: 'bg-emerald-100 text-emerald-700 border-emerald-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-gray-400 border-gray-200' },
finance: { on: 'bg-amber-100 text-amber-700 border-amber-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-text-tertiary 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 }) {
@@ -285,7 +285,7 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
<button
type="button"
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'}`}>
{(form.brands || []).length === 0
@@ -315,7 +315,7 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
{/* Dropdown */}
{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.map(brand => {
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"
key={brand.id || brand._id}
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 ${
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 ${
active
? '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}
+2 -2
View File
@@ -149,13 +149,13 @@ export default function TeamPanel({ team, onClose, onSave, onDelete, teamMembers
{activeTab === 'members' && (
<div className="p-6">
<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
type="text"
value={memberSearch}
onChange={e => setMemberSearch(e.target.value)}
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 className="space-y-1 max-h-80 overflow-y-auto">
+1 -1
View File
@@ -14,7 +14,7 @@ export default function ThemeToggle({ className = '' }) {
{darkMode ? (
<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>
)
+1 -1
View File
@@ -37,7 +37,7 @@ export function ToastProvider({ children }) {
<ToastContext.Provider value={toast}>
{children}
{/* 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">
{toasts.map(t => (
<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 === 'paused' ? 'bg-amber-100 text-amber-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)}
</span>
@@ -441,7 +441,7 @@ export default function TranslationDetailPanel({ translation, onClose, onUpdate,
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-medium text-text-tertiary">
{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>
<div className="flex items-center gap-1">
{editingTextId !== text.Id && (
@@ -520,7 +520,7 @@ export default function TranslationDetailPanel({ translation, onClose, onUpdate,
type="text"
value={currentReviewUrl}
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
onClick={copyReviewLink}
+2 -2
View File
@@ -177,7 +177,7 @@ export default function Tutorial({ onComplete }) {
{/* Tooltip card */}
<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={{
top: tooltipPosition.top,
left: tooltipPosition.left,
@@ -188,7 +188,7 @@ export default function Tutorial({ onComplete }) {
{/* Close button */}
<button
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" />
</button>
+71 -3
View File
@@ -1,6 +1,6 @@
{
"app.name": "المركز الرقمي",
"app.subtitle": "المنصة",
"app.name": "رواج",
"app.subtitle": "مركز التسويق",
"nav.dashboard": "لوحة التحكم",
"nav.campaigns": "الحملات",
"nav.finance": "المالية والعائد",
@@ -396,6 +396,16 @@
"campaigns.editCampaign": "تعديل الحملة",
"campaigns.deleteCampaign": "حذف الحملة؟",
"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.metrics": "المقاييس",
"tracks.trackName": "اسم المسار",
@@ -503,6 +513,59 @@
"budgets.dateExpensed": "التاريخ",
"dashboard.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.maxFileSize": "الحد الأقصى لحجم الملف",
"settings.maxFileSizeHint": "الحد الأقصى المسموح لحجم المرفقات (١-٥٠٠ ميجابايت)",
@@ -629,7 +692,7 @@
"review.alreadyReviewed": "تمت مراجعة هذا المحتوى بالفعل.",
"review.statusLabel": "الحالة",
"review.reviewedBy": "تمت المراجعة بواسطة",
"review.poweredBy": "مدعوم بواسطة Samaya Digital Hub",
"review.poweredBy": "مدعوم بواسطة Rawaj",
"review.loadFailed": "فشل في تحميل المحتوى",
"review.actionFailed": "فشل الإجراء",
"review.actionCompleted": "تم الإجراء بنجاح",
@@ -694,6 +757,8 @@
"team.selectRole": "اختر دوراً...",
"common.team": "الفريق",
"common.noTeam": "بدون فريق",
"common.none": "بدون",
"common.success": "تم بنجاح",
"common.error": "حدث خطأ",
"settings.roles": "الأدوار",
"settings.rolesDesc": "حدد أدوار العمل مثل مصمم، استراتيجي، إلخ. يتم تعيينها لأعضاء الفريق بشكل منفصل عن مستويات الصلاحية.",
@@ -717,6 +782,9 @@
"header.budgets": "الميزانيات",
"header.issues": "البلاغات",
"header.settings": "الإعدادات",
"header.translations": "الترجمات",
"calendar.unscheduledPosts": "منشورات غير مجدولة",
"calendar.statusLegend": "دليل الحالات",
"header.users": "إدارة المستخدمين",
"header.projectDetails": "تفاصيل المشروع",
"header.campaignDetails": "تفاصيل الحملة",
+74 -6
View File
@@ -1,6 +1,6 @@
{
"app.name": "Digital Hub",
"app.subtitle": "Platform",
"app.name": "Rawaj",
"app.subtitle": "Marketing Hub",
"nav.dashboard": "Dashboard",
"nav.campaigns": "Campaigns",
"nav.finance": "Finance & ROI",
@@ -70,7 +70,7 @@
"dashboard.noPostsYet": "No posts yet. Create your first post!",
"dashboard.upcomingDeadlines": "Upcoming Deadlines",
"dashboard.noUpcomingDeadlines": "No upcoming deadlines this week. 🎉",
"dashboard.loadingHub": "Loading Digital Hub...",
"dashboard.loadingHub": "Loading Rawaj...",
"posts.title": "Post Production",
"posts.newPost": "New Post",
"posts.editPost": "Edit Post",
@@ -271,7 +271,7 @@
"settings.english": "English",
"settings.arabic": "Arabic",
"settings.restartTutorial": "Restart Tutorial",
"settings.tutorialDesc": "Need a refresher? Restart the interactive tutorial to learn about all the features of Digital Hub.",
"settings.tutorialDesc": "Need a refresher? Restart the interactive tutorial to learn about all the features of Rawaj.",
"settings.general": "General",
"settings.onboardingTutorial": "Onboarding Tutorial",
"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.filters.title": "Filter & Focus",
"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.forgotPassword": "Forgot password?",
"login.defaultCreds": "Default credentials:",
@@ -396,6 +396,16 @@
"campaigns.editCampaign": "Edit 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.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.metrics": "Metrics",
"tracks.trackName": "Track Name",
@@ -503,6 +513,59 @@
"budgets.dateExpensed": "Date",
"dashboard.expenses": "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.maxFileSize": "Maximum File Size",
"settings.maxFileSizeHint": "Maximum allowed file size for attachments (1-500 MB)",
@@ -629,7 +692,7 @@
"review.alreadyReviewed": "This artefact has already been reviewed.",
"review.statusLabel": "Status",
"review.reviewedBy": "Reviewed by",
"review.poweredBy": "Powered by Samaya Digital Hub",
"review.poweredBy": "Powered by Rawaj",
"review.loadFailed": "Failed to load artefact",
"review.actionFailed": "Action failed",
"review.actionCompleted": "Action completed successfully",
@@ -694,6 +757,8 @@
"team.selectRole": "Select role...",
"common.team": "Team",
"common.noTeam": "No team",
"common.none": "None",
"common.success": "Success",
"common.error": "An error occurred",
"settings.roles": "Roles",
"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.issues": "Issues",
"header.settings": "Settings",
"header.translations": "Translations",
"calendar.unscheduledPosts": "Unscheduled Posts",
"calendar.statusLegend": "Status Legend",
"header.users": "User Management",
"header.projectDetails": "Project 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";
@theme {
--font-sans: 'Inter', 'IBM Plex Sans Arabic', system-ui, -apple-system, sans-serif;
--color-sidebar: #0f172a;
--color-sidebar-hover: #1e293b;
--color-sidebar-active: #020617;
--color-brand-primary: #4f46e5;
--color-brand-primary-light: #6366f1;
--font-sans: 'DM Sans', 'IBM Plex Sans Arabic', system-ui, -apple-system, sans-serif;
--color-sidebar: #0a1f1c;
--color-sidebar-hover: #123b35;
--color-sidebar-active: #061411;
--color-brand-primary: #0d9488;
--color-brand-primary-light: #14b8a6;
--color-brand-secondary: #db2777;
--color-brand-tertiary: #f59e0b;
--color-brand-quaternary: #059669;
--color-brand-quaternary: #0d9488;
--color-surface: #ffffff;
--color-surface-secondary: #f9fafb;
--color-surface-tertiary: #f3f4f6;
@@ -37,40 +37,39 @@
}
/* ═══════════════════════════════════════════════
DARK MODE — Inspired by SpaceTime
Deep layered surfaces, glass edges, ambient glow
DARK MODE — Forest teal tinted surfaces
═══════════════════════════════════════════════ */
.dark {
/* Layered depth: void → surface → surface-2surface-3 */
--color-surface: #15151e;
--color-surface-secondary: #1c1c2a;
--color-surface-tertiary: #24243a;
/* Layered depth: deep forest → surface → elevated */
--color-surface: #0f1a18;
--color-surface-secondary: #162220;
--color-surface-tertiary: #1e2e2b;
--color-border: rgba(255, 255, 255, 0.08);
--color-border-light: rgba(255, 255, 255, 0.04);
/* Text — crisp hierarchy */
--color-text-primary: #eeecf5;
--color-text-secondary: #a8a3c0;
--color-text-tertiary: #706b8a;
/* Text — warm neutrals, teal-tinted */
--color-text-primary: #e8f0ee;
--color-text-secondary: #9db5b0;
--color-text-tertiary: #637e78;
/* Sidebar */
--color-sidebar: #0e0e16;
--color-sidebar-hover: #15151e;
--color-sidebar-active: #0a0a12;
--color-sidebar: #0a1412;
--color-sidebar-hover: #0f1a18;
--color-sidebar-active: #060e0c;
/* Brand — brighter on dark */
--color-brand-primary: #8b5cf6;
--color-brand-primary-light: #a78bfa;
--color-brand-primary: #14b8a6;
--color-brand-primary-light: #2dd4bf;
color-scheme: dark;
background-color: #15151e;
color: #eeecf5;
background-color: #0f1a18;
color: #e8f0ee;
}
/* ─── Ambient background glow ────────────────── */
.dark .bg-mesh {
background-color: #15151e !important;
background-color: #0f1a18 !important;
background-image: none !important;
}
.dark .bg-mesh::before {
@@ -78,9 +77,8 @@
position: fixed;
inset: 0;
background:
radial-gradient(ellipse 70% 50% at 20% 50%, rgba(139, 92, 246, 0.045) 0%, transparent 60%),
radial-gradient(ellipse 50% 40% at 80% 30%, rgba(56, 189, 248, 0.03) 0%, transparent 60%),
radial-gradient(ellipse 60% 40% at 50% 90%, rgba(232, 168, 56, 0.02) 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(20, 184, 166, 0.025) 0%, transparent 60%);
pointer-events: none;
z-index: 0;
}
@@ -89,11 +87,11 @@
.dark .bg-white,
.dark .bg-\[\#fff\],
.dark .bg-\[\#ffffff\] {
background-color: #22223a !important;
background-color: #1a2a28 !important;
}
.dark .bg-gray-50 { background-color: #15151e !important; }
.dark .bg-gray-100 { background-color: #1c1c2a !important; }
.dark .bg-gray-200 { background-color: #24243a !important; }
.dark .bg-gray-50 { background-color: #0f1a18 !important; }
.dark .bg-gray-100 { background-color: #162220 !important; }
.dark .bg-gray-200 { background-color: #1e2e2b !important; }
/* ─── Borders ────────────────────────────────── */
.dark .border-gray-100,
@@ -104,12 +102,12 @@
.dark .divide-border-light > :not(:first-child) { border-color: rgba(255, 255, 255, 0.05) !important; }
/* ─── Text ───────────────────────────────────── */
.dark .text-gray-900 { color: #eeecf5 !important; }
.dark .text-gray-800 { color: #d8d5e8 !important; }
.dark .text-gray-700 { color: #c2bedb !important; }
.dark .text-gray-600 { color: #a8a3c0 !important; }
.dark .text-gray-500 { color: #8b85a8 !important; }
.dark .text-gray-400 { color: #706b8a !important; }
.dark .text-gray-900 { color: #e8f0ee !important; }
.dark .text-gray-800 { color: #d0ddd9 !important; }
.dark .text-gray-700 { color: #b5cac5 !important; }
.dark .text-gray-600 { color: #9db5b0 !important; }
.dark .text-gray-500 { color: #7e9a94 !important; }
.dark .text-gray-400 { color: #637e78 !important; }
/* ─── Status badges — translucent glass ──────── */
.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 select:focus,
.dark textarea:focus {
border-color: rgba(139, 92, 246, 0.5);
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.1);
border-color: rgba(20, 184, 166, 0.5);
box-shadow: 0 0 0 3px rgba(20, 184, 166, 0.1);
}
.dark input::placeholder,
.dark textarea::placeholder {
color: #706b8a;
color: #637e78;
}
.dark input:disabled,
.dark select:disabled,
.dark textarea:disabled {
background-color: rgba(255, 255, 255, 0.02) !important;
color: #706b8a !important;
color: #637e78 !important;
opacity: 0.6;
}
/* Dark select arrow */
.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 ────────────────────── */
.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 {
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 {
background: #1c1c2a;
background: #162220;
border-color: rgba(255, 255, 255, 0.06);
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.04);
}
.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 {
background: linear-gradient(180deg, rgba(36, 36, 58, 0.5) 0%, #1c1c2a 100%);
background: rgba(30, 46, 43, 0.3);
}
/* ─── 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);
}
@@ -216,22 +214,22 @@
.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; }
/* ─── Brand glow ─────────────────────────────── */
/* ─── Brand accent ────────────────────────────── */
.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 {
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 ── */
.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 ── */
.dark .bg-emerald-50.border-emerald-200 { background-color: #132a1e !important; border-color: #1a4a2e !important; }
.dark .bg-red-50.border-red-200 { background-color: #2a1318 !important; border-color: #4a1a22 !important; }
.dark .bg-blue-50.border-blue-200 { background-color: #131d2a !important; border-color: #1a2e4a !important; }
.dark .bg-amber-50.border-amber-200 { background-color: #2a2213 !important; border-color: #4a3a1a !important; }
/* ─── Toasts — solid backgrounds ────────────────── */
.dark .bg-emerald-50.border-emerald-200 { background-color: #0f2a1e !important; border-color: #154a2e !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: #0f1d2a !important; border-color: #152e4a !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-red-800 { color: #fca5a5 !important; }
.dark .text-blue-800 { color: #93c5fd !important; }
@@ -239,10 +237,19 @@
/* ─── Selection ──────────────────────────────── */
.dark ::selection {
background: rgba(139, 92, 246, 0.4);
background: rgba(20, 184, 166, 0.4);
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 */
::-webkit-scrollbar {
width: 6px;
@@ -315,15 +322,15 @@ textarea {
margin-right: 0;
}
/* Enhanced sidebar with gradient */
/* Enhanced 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);
}
/* Animation keyframes */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
@@ -347,11 +354,6 @@ textarea {
50% { opacity: 0.7; }
}
@keyframes bounce-subtle {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-4px); }
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
@@ -425,29 +427,24 @@ textarea {
overflow: visible;
}
/* Stagger children */
/* Stagger children — short, max 4 items */
.stagger-children > * {
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(2) { animation-delay: 50ms; }
.stagger-children > *:nth-child(3) { animation-delay: 100ms; }
.stagger-children > *:nth-child(4) { animation-delay: 150ms; }
.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; }
.stagger-children > *:nth-child(2) { animation-delay: 40ms; }
.stagger-children > *:nth-child(3) { animation-delay: 80ms; }
.stagger-children > *:nth-child(n+4) { animation-delay: 120ms; }
/* Card hover effect - smooth and elegant */
/* Card hover effect - refined, no lift */
.card-hover {
position: relative;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05), 0 1px 2px rgba(0, 0, 0, 0.06);
transition: box-shadow 0.2s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
}
.card-hover:hover {
transform: translateY(-3px);
box-shadow: 0 12px 28px -6px rgba(0, 0, 0, 0.12), 0 6px 16px -8px rgba(0, 0, 0, 0.08);
box-shadow: 0 4px 12px -2px rgba(0, 0, 0, 0.08);
}
/* Stat card accents - subtle colored top borders */
@@ -470,24 +467,12 @@ textarea {
opacity: 1;
}
/* Mesh background - subtle radial gradients */
/* Mesh background — flat, no gradients */
.bg-mesh {
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 */
.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 accent — subtle top border, no gradient */
.stat-card-premium {
position: relative;
overflow: hidden;
@@ -498,20 +483,20 @@ textarea {
top: 0;
left: 0;
right: 0;
height: 3px;
opacity: 1;
height: 2px;
opacity: 0.6;
}
.stat-card-premium.accent-primary::before {
background: linear-gradient(90deg, #4f46e5, #7c3aed);
background: #0d9488;
}
.stat-card-premium.accent-secondary::before {
background: linear-gradient(90deg, #db2777, #ec4899);
background: #db2777;
}
.stat-card-premium.accent-tertiary::before {
background: linear-gradient(90deg, #f59e0b, #fbbf24);
background: #f59e0b;
}
.stat-card-premium.accent-quaternary::before {
background: linear-gradient(90deg, #059669, #34d399);
background: #059669;
}
/* Section card - premium container */
@@ -524,20 +509,19 @@ textarea {
transition: box-shadow 0.3s ease;
}
.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 {
padding: 1rem 1.25rem;
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 {
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 {
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 */
@@ -594,23 +578,6 @@ select:not(:disabled):hover {
grid-template-columns: repeat(7, 1fr);
}
/* Ripple effect on buttons (optional enhancement) */
@keyframes ripple {
0% {
transform: scale(0);
opacity: 0.5;
}
100% {
transform: scale(2.5);
opacity: 0;
}
}
/* Badge pulse animation */
.badge-pulse {
animation: pulse-subtle 2s ease-in-out infinite;
}
/* Smooth height transitions */
.transition-height {
transition: height 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+17 -21
View File
@@ -199,8 +199,8 @@ export default function Artefacts() {
const SortIcon = ({ col }) => {
if (listSortBy !== col) return null
return listSortDir === 'asc'
? <ChevronUp className="w-3 h-3 inline ml-0.5" />
: <ChevronDown 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 ms-0.5" />
}
const formatDate = (dateStr) => {
@@ -211,11 +211,7 @@ export default function Artefacts() {
return (
<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">{t('artefacts.title')}</h1>
<p className="text-sm text-text-secondary mt-1">{t('artefacts.subtitle')}</p>
</div>
<div className="flex items-center justify-end">
<div className="flex items-center gap-3">
{/* View switcher */}
<div className="flex items-center bg-surface-tertiary rounded-lg p-0.5">
@@ -228,7 +224,7 @@ export default function Artefacts() {
onClick={() => setViewMode(mode)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
viewMode === mode
? 'bg-white text-text-primary shadow-sm'
? 'bg-surface text-text-primary shadow-sm'
: 'text-text-tertiary hover:text-text-secondary'
}`}
>
@@ -251,13 +247,13 @@ export default function Artefacts() {
{/* Filters */}
<div className="flex flex-wrap items-center gap-3">
<div className="relative flex-1 min-w-[200px] max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<Search className="absolute start-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<input
type="text"
placeholder={t('artefacts.searchArtefacts')}
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-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>
@@ -351,7 +347,7 @@ export default function Artefacts() {
<button
key={artefact.Id}
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="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()}>
<input type="checkbox" checked={selectedIds.size === sortedArtefacts.length && sortedArtefacts.length > 0} onChange={toggleSelectAll} className="rounded border-border" />
</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" />
</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" />
</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" />
</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-left 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-left 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-left 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">{t('artefacts.brand')}</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-start 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.creator')}</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-start 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 cursor-pointer hover:text-text-primary" onClick={() => toggleListSort('updated_at')}>
{t('artefacts.updated')} <SortIcon col="updated_at" />
</th>
</tr>
+8 -8
View File
@@ -181,20 +181,20 @@ export default function Assets() {
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-3">
<div className="relative flex-1 min-w-[200px] max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<Search className="absolute start-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<input
type="text"
placeholder="Search assets..."
value={filters.search}
onChange={e => setFilters(f => ({ ...f, search: e.target.value }))}
className="w-full pl-10 pr-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-white"
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>
<select
value={filters.brand}
onChange={e => setFilters(f => ({ ...f, brand: e.target.value }))}
className="text-sm border border-border rounded-lg px-3 py-2 bg-white text-text-secondary focus:outline-none"
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>
{brands.map(b => <option key={b} value={b}>{b}</option>)}
@@ -203,7 +203,7 @@ export default function Assets() {
<select
value={filters.tag}
onChange={e => setFilters(f => ({ ...f, tag: e.target.value }))}
className="text-sm border border-border rounded-lg px-3 py-2 bg-white text-text-secondary focus:outline-none"
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>
{allTags.map(t => <option key={t} value={t}>{t}</option>)}
@@ -211,7 +211,7 @@ export default function Assets() {
<button
onClick={() => setShowUpload(true)}
className="flex items-center gap-2 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm ml-auto"
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
@@ -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">
{filteredAssets.map(asset => (
<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" />
</div>
<AssetCard asset={asset} onClick={setSelectedAsset} />
@@ -319,7 +319,7 @@ export default function Assets() {
<div className="space-y-4">
{selectedAsset.type === 'image' && selectedAsset.url && (
<div className="rounded-lg overflow-hidden bg-surface-tertiary">
<img src={selectedAsset.url} alt={selectedAsset.name} className="w-full max-h-[400px] object-contain" />
<img src={selectedAsset.url} alt={selectedAsset.name} className="w-full max-h-[400px] object-contain" loading="lazy" />
</div>
)}
{selectedAsset.type === 'video' && selectedAsset.url && (
@@ -374,7 +374,7 @@ export default function Assets() {
download={selectedAsset.name}
target="_blank"
rel="noopener noreferrer"
className="ml-auto px-4 py-2 text-sm font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light"
className="ms-auto px-4 py-2 text-sm font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light"
>
Download
</a>
+7 -5
View File
@@ -143,7 +143,7 @@ export default function Brands() {
{/* Brand Cards Grid */}
{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" />
<p className="text-sm text-text-tertiary">{t('brands.noBrands')}</p>
</div>
@@ -154,7 +154,7 @@ export default function Brands() {
return (
<div
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)}
>
{/* Logo area */}
@@ -164,6 +164,7 @@ export default function Brands() {
src={`${API_BASE}/uploads/${brand.logo}`}
alt={displayName}
className="w-full h-full object-contain p-4"
loading="lazy"
/>
) : (
<div className="text-3xl">
@@ -171,17 +172,17 @@ export default function Brands() {
</div>
)}
{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
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')}
>
<Edit2 className="w-3 h-3" />
</button>
<button
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')}
>
<Trash2 className="w-3 h-3" />
@@ -269,6 +270,7 @@ export default function Brands() {
src={`${API_BASE}/uploads/${editingBrand.logo}`}
alt="Logo"
className="h-16 object-contain"
loading="lazy"
/>
</div>
)}
+16 -20
View File
@@ -153,11 +153,7 @@ export default function Budgets() {
return (
<div className="space-y-6 animate-fade-in">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-text-primary">{t('budgets.title')}</h1>
<p className="text-sm text-text-tertiary mt-0.5">{t('budgets.subtitle')}</p>
</div>
<div className="flex items-center justify-end">
{canManageFinance && (
<button
onClick={() => { setEditing(null); setForm(EMPTY_ENTRY); setShowModal(true) }}
@@ -171,19 +167,19 @@ export default function Budgets() {
{/* Filters */}
<div className="flex items-center gap-3 flex-wrap">
<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
type="text"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
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>
<select
value={filterCategory}
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>
{CATEGORIES.map(c => <option key={c.value} value={c.value}>{c.label}</option>)}
@@ -191,7 +187,7 @@ export default function Budgets() {
<select
value={filterDestination}
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>
{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 ${
filterType === opt.value
? 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}
@@ -215,7 +211,7 @@ export default function Budgets() {
</div>
{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 className="text-emerald-600 font-semibold">+{totalIncome.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">
<thead>
<tr className="border-b border-border bg-surface-secondary">
<th className="px-4 py-3 text-left text-xs font-medium text-text-tertiary">{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-left 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-left 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-start 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.source')}</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-start 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.date')}</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" />}
</tr>
</thead>
@@ -289,7 +285,7 @@ export default function Budgets() {
<td className="px-4 py-3 text-text-secondary whitespace-nowrap">
{entry.date_received ? format(new Date(entry.date_received), 'MMM d, yyyy') : '--'}
</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' ? '-' : '+'}{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 ${
form.type === 'income'
? '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" />
@@ -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 ${
form.type === 'expense'
? '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" />
+67 -96
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useContext } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { ArrowLeft, Plus, Edit2, Trash2, DollarSign, Eye, MousePointer, Target, TrendingUp, FileText, Megaphone, Search, Globe, Pencil, Users, X, UserPlus, 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 { AppContext } from '../App'
import { useAuth } from '../contexts/AuthContext'
@@ -26,21 +26,11 @@ const TRACK_TYPES = {
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() {
const { id } = useParams()
const navigate = useNavigate()
const { brands, getBrandName, teamMembers } = useContext(AppContext)
const { lang, currencySymbol } = useLanguage()
const { t, lang, currencySymbol } = useLanguage()
const { permissions, user } = useAuth()
const isSuperadmin = user?.role === 'superadmin'
const [campaign, setCampaign] = useState(null)
@@ -211,7 +201,7 @@ export default function CampaignDetail() {
if (!campaign) {
return (
<div className="text-center py-12 text-text-tertiary">
Campaign not found. <button onClick={() => navigate('/campaigns')} className="text-brand-primary underline">Go back</button>
{t('campaigns.notFound')} <button onClick={() => navigate('/campaigns')} className="text-brand-primary underline">{t('common.goBack')}</button>
</div>
)
}
@@ -244,9 +234,6 @@ export default function CampaignDetail() {
{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>
Budget: {campaign.budget > 0 ? `${campaign.budget.toLocaleString()} ${currencySymbol}` : 'Not set'}
</span>
{campaign.platforms && campaign.platforms.length > 0 && (
<PlatformIcons platforms={campaign.platforms} size={16} />
)}
@@ -263,109 +250,73 @@ export default function CampaignDetail() {
}`}
>
<MessageCircle className="w-4 h-4" />
Discussion
{t('campaigns.discussion')}
</button>
{canSetBudget && (
<button
onClick={() => { setBudgetValue(campaign.budget || ''); setEditingBudget(true) }}
className="flex items-center gap-1.5 px-3 py-2 bg-surface-tertiary text-text-secondary hover:bg-surface-tertiary/80 hover:text-text-primary rounded-lg text-sm font-medium transition-colors"
>
<DollarSign className="w-4 h-4" />
Budget
</button>
)}
{canManage && (
<button
onClick={() => 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"
>
<Settings className="w-4 h-4" />
Edit
{t('common.edit')}
</button>
)}
</div>
</div>
{/* Assigned Team */}
<div className="bg-white rounded-xl border border-border p-5">
{/* Budget Card */}
<div className="bg-surface rounded-xl border border-border p-5">
<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">
<Users className="w-3.5 h-3.5" /> Assigned Team
</h3>
{canAssign && (
<button
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
<h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium">{t('campaigns.budget')}</h3>
{canSetBudget && (
<button onClick={() => { setBudgetValue(campaign.budget || ''); setEditingBudget(true) }}
className="text-xs text-brand-primary hover:text-brand-primary-light font-medium">
{t('common.edit')}
</button>
)}
</div>
{assignments.length === 0 ? (
<p className="text-xs text-text-tertiary py-2">No team members assigned yet.</p>
) : (
<div className="flex flex-wrap gap-2">
{assignments.map(a => (
<div key={a.user_id} className="flex items-center gap-2 bg-surface-secondary rounded-full pl-1 pr-2 py-1">
<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">
{a.user_avatar ? (
<img src={a.user_avatar} className="w-full h-full rounded-full object-cover" alt="" />
) : (
getInitials(a.user_name)
)}
</div>
<span className="text-xs font-medium text-text-primary">{a.user_name}</span>
{canAssign && (
<button
onClick={() => removeAssignment(a.user_id)}
className="p-0.5 rounded-full hover:bg-red-100 text-text-tertiary hover:text-red-500"
>
<X className="w-3 h-3" />
</button>
)}
</div>
))}
<div className="flex items-baseline gap-2 mb-3">
<span className="text-2xl font-bold text-text-primary">
{totalAllocated.toLocaleString()} {currencySymbol}
</span>
<span className="text-sm text-text-tertiary">{t('finance.allocated')}</span>
</div>
{totalAllocated > 0 && (
<>
<BudgetBar budget={totalAllocated} spent={totalSpent} height="h-2.5" />
<div className="flex justify-between mt-2 text-xs text-text-tertiary">
<span>{totalSpent.toLocaleString()} {currencySymbol} {t('dashboard.spent')}</span>
<span>{(totalAllocated - totalSpent).toLocaleString()} {currencySymbol} {t('dashboard.remaining')}</span>
</div>
</>
)}
{(totalImpressions > 0 || totalClicks > 0) && (
<div className="flex items-center gap-4 mt-4 pt-3 border-t border-border-light text-xs text-text-secondary">
<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>
{totalConversions > 0 && <span><Target className="w-3.5 h-3.5 inline me-1" />{totalConversions.toLocaleString()}</span>}
{totalRevenue > 0 && <span><DollarSign className="w-3.5 h-3.5 inline me-1" />{totalRevenue.toLocaleString()} {currencySymbol}</span>}
</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 */}
<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">
<h3 className="font-semibold text-text-primary">Tracks</h3>
<h3 className="font-semibold text-text-primary">{t('campaigns.tracks')}</h3>
{canManage && (
<button
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"
>
<Plus className="w-3.5 h-3.5" /> Add Track
<Plus className="w-3.5 h-3.5" /> {t('campaigns.addTrack')}
</button>
)}
</div>
{tracks.length === 0 ? (
<div className="py-12 text-center text-sm text-text-tertiary">
No tracks yet. Add organic, paid, or SEO tracks to organize this campaign.
{t('campaigns.noTracks')}
</div>
) : (
<div className="divide-y divide-border-light">
@@ -403,9 +354,9 @@ export default function CampaignDetail() {
{/* Quick metrics */}
{(track.impressions > 0 || track.clicks > 0 || track.conversions > 0) && (
<div className="flex items-center gap-3 mt-1.5 text-[10px] text-text-tertiary">
{track.impressions > 0 && <span>👁 {track.impressions.toLocaleString()}</span>}
{track.clicks > 0 && <span>🖱 {track.clicks.toLocaleString()}</span>}
{track.conversions > 0 && <span>🎯 {track.conversions.toLocaleString()}</span>}
{track.impressions > 0 && <span><Eye className="w-3 h-3 inline" /> {track.impressions.toLocaleString()}</span>}
{track.clicks > 0 && <span><MousePointer className="w-3 h-3 inline" /> {track.clicks.toLocaleString()}</span>}
{track.conversions > 0 && <span><Target className="w-3 h-3 inline" /> {track.conversions.toLocaleString()}</span>}
{track.clicks > 0 && track.budget_spent > 0 && (
<span>CPC: {(track.budget_spent / track.clicks).toFixed(2)} {currencySymbol}</span>
)}
@@ -418,7 +369,7 @@ export default function CampaignDetail() {
{/* Linked posts count */}
{trackPosts.length > 0 && (
<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>
)}
@@ -461,11 +412,31 @@ export default function CampaignDetail() {
)}
</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 */}
{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">
<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 className="divide-y divide-border-light">
{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"
>
{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 items-center gap-2">
@@ -501,11 +472,11 @@ export default function CampaignDetail() {
{/* ─── DISCUSSION SIDEBAR ─── */}
{showDiscussion && (
<div className="w-[340px] shrink-0 bg-white rounded-xl border border-border flex flex-col self-start sticky top-4" style={{ maxHeight: 'calc(100vh - 6rem)' }}>
<div className="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">
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-1.5">
<MessageCircle className="w-4 h-4" />
Discussion
{t('campaigns.discussion')}
</h3>
<button onClick={() => setShowDiscussion(false)} className="p-1 hover:bg-surface-tertiary rounded-lg text-text-tertiary">
<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">
{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)
)}
+11 -11
View File
@@ -145,7 +145,7 @@ export default function Campaigns() {
<select
value={filters.brand}
onChange={e => setFilters(f => ({ ...f, brand: e.target.value }))}
className="text-sm border border-border rounded-lg px-3 py-2 bg-white text-text-secondary focus:outline-none"
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>
{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
value={filters.status}
onChange={e => setFilters(f => ({ ...f, status: e.target.value }))}
className="text-sm border border-border rounded-lg px-3 py-2 bg-white text-text-secondary focus:outline-none"
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="planning">Planning</option>
@@ -167,7 +167,7 @@ export default function Campaigns() {
{permissions?.canCreateCampaigns && (
<button
onClick={openNew}
className="flex items-center gap-2 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm ml-auto"
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" />
New Campaign
@@ -178,7 +178,7 @@ export default function Campaigns() {
{/* Summary Cards */}
{(totalBudget > 0 || totalSpent > 0) && (
<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">
<DollarSign className="w-4 h-4 text-blue-500" />
<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-[10px] text-text-tertiary">{currencySymbol} total</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">
<TrendingUp className="w-4 h-4 text-amber-500" />
<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-[10px] text-text-tertiary">{currencySymbol} spent</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">
<Eye className="w-4 h-4 text-purple-500" />
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Impressions</span>
</div>
<div className="text-lg font-bold text-text-primary">{totalImpressions.toLocaleString()}</div>
</div>
<div className="bg-white rounded-xl border border-border p-4">
<div className="bg-surface rounded-xl border border-border p-4">
<div className="flex items-center gap-2 mb-1">
<MousePointer className="w-4 h-4 text-green-500" />
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Clicks</span>
</div>
<div className="text-lg font-bold text-text-primary">{totalClicks.toLocaleString()}</div>
</div>
<div className="bg-white rounded-xl border border-border p-4">
<div className="bg-surface rounded-xl border border-border p-4">
<div className="flex items-center gap-2 mb-1">
<Target className="w-4 h-4 text-red-500" />
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Conversions</span>
</div>
<div className="text-lg font-bold text-text-primary">{totalConversions.toLocaleString()}</div>
</div>
<div className="bg-white rounded-xl border border-border p-4">
<div className="bg-surface rounded-xl border border-border p-4">
<div className="flex items-center gap-2 mb-1">
<BarChart3 className="w-4 h-4 text-emerald-500" />
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Revenue</span>
@@ -264,7 +264,7 @@ export default function Campaigns() {
/>
{/* 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">
<h3 className="font-semibold text-text-primary">All Campaigns</h3>
</div>
@@ -308,7 +308,7 @@ export default function Campaigns() {
)}
</div>
</div>
<div className="text-right shrink-0">
<div className="text-end shrink-0">
<StatusBadge status={campaign.status} size="xs" />
<div className="text-xs text-text-tertiary mt-1">
{campaign.startDate && campaign.endDate ? (
+125 -210
View File
@@ -1,12 +1,11 @@
import { useContext, useEffect, useState, useMemo } from 'react'
import { Link, useNavigate } from 'react-router-dom'
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 { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../i18n/LanguageContext'
import { api, PRIORITY_CONFIG } from '../utils/api'
import StatCard from '../components/StatCard'
import StatusBadge from '../components/StatusBadge'
import BrandBadge from '../components/BrandBadge'
import DatePresetPicker from '../components/DatePresetPicker'
@@ -18,24 +17,17 @@ function getBudgetBarColor(percentage) {
return 'bg-emerald-500'
}
function FinanceMini({ finance }) {
function BudgetSummary({ finance }) {
const { t, currencySymbol } = useLanguage()
if (!finance) return null
const totalReceived = finance.totalReceived || 0
const spent = finance.spent || 0
const remaining = finance.remaining || 0
const roi = finance.roi || 0
const 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 mainAvailable = finance.mainAvailable != null ? finance.mainAvailable : (finance.remaining || 0)
const consumed = totalReceived - mainAvailable
const pct = totalReceived > 0 ? (consumed / totalReceived) * 100 : 0
const barColor = getBudgetBarColor(pct)
const campPct = totalReceived > 0 ? (campaignBudget / totalReceived) * 100 : 0
const projPct = totalReceived > 0 ? (projectBudget / totalReceived) * 100 : 0
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">
<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">
@@ -49,58 +41,15 @@ function FinanceMini({ finance }) {
</div>
) : (
<>
{/* Spending bar */}
<div className="mb-3">
<div className="flex justify-between text-xs text-text-tertiary mb-1">
<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 className="flex justify-between text-xs text-text-tertiary mb-1">
<span>{consumed.toLocaleString()} {currencySymbol} {t('dashboard.spent')}</span>
<span>{totalReceived.toLocaleString()} {currencySymbol} {t('dashboard.received')}</span>
</div>
{/* Allocation bar */}
{(campaignBudget > 0 || projectBudget > 0) && (
<div className="mb-3">
<div className="text-[10px] text-text-tertiary mb-1 font-medium uppercase tracking-wide">Allocation</div>
<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 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 className={`mt-3 text-sm font-semibold ${mainAvailable >= 0 ? 'text-emerald-600' : 'text-red-600'}`}>
{mainAvailable.toLocaleString()} {currencySymbol} {t('dashboard.remaining')}
</div>
</>
)}
@@ -146,13 +95,6 @@ function ActiveCampaignsList({ campaigns, finance }) {
</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>
)
})}
@@ -162,12 +104,12 @@ function ActiveCampaignsList({ campaigns, finance }) {
}
function MyTasksList({ tasks, currentUserId, navigate, t }) {
const myTasks = tasks
const myTasks = useMemo(() => tasks
.filter(task => {
const assignedId = task.assigned_to_id || task.assignedTo
return assignedId === currentUserId && task.status !== 'done'
})
.slice(0, 5)
.slice(0, 5), [tasks, currentUserId])
return (
<div className="section-card">
@@ -187,10 +129,10 @@ function MyTasksList({ tasks, currentUserId, navigate, t }) {
</div>
) : (
myTasks.map(task => (
<div
<button
key={task._id || task.id}
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="flex-1 min-w-0">
@@ -203,7 +145,7 @@ function MyTasksList({ tasks, currentUserId, navigate, t }) {
{format(new Date(task.dueDate), 'MMM d')}
</div>
)}
</div>
</button>
))
)}
</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() {
const { t, currencySymbol } = useLanguage()
const navigate = useNavigate()
const { currentUser, teamMembers } = useContext(AppContext)
const { currentUser } = useContext(AppContext)
const { hasModule } = useAuth()
const [posts, setPosts] = useState([])
const [campaigns, setCampaigns] = useState([])
@@ -273,7 +289,6 @@ export default function Dashboard() {
const [finance, setFinance] = useState(null)
const [loading, setLoading] = useState(true)
// Date filtering
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
const [activePreset, setActivePreset] = useState('')
@@ -285,7 +300,6 @@ export default function Dashboard() {
const loadData = async () => {
try {
const fetches = []
// Only fetch data for modules the user has access to
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('/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(() => {
if (!dateFrom && !dateTo) return posts
return posts.filter(p => {
@@ -343,7 +356,7 @@ export default function Dashboard() {
t.dueDate && new Date(t.dueDate) < new Date() && t.status !== 'done'
).length
const upcomingDeadlines = filteredTasks
const upcomingDeadlines = useMemo(() => filteredTasks
.filter(t => {
if (!t.dueDate || t.status === 'done') return false
const due = new Date(t.dueDate)
@@ -351,60 +364,27 @@ export default function Dashboard() {
return isAfter(due, now) && isBefore(due, addDays(now, 7))
})
.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')) {
statCards.push({
icon: FileText,
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',
})
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' })
stats.push({ label: t('dashboard.activeCampaigns'), value: activeCampaigns, detail: `${campaigns.length} ${t('dashboard.total')}`, icon: Megaphone, accent: 'text-pink-600' })
}
if (hasModule('projects')) {
statCards.push({
icon: AlertTriangle,
label: t('dashboard.overdueTasks'),
value: overdueTasks,
subtitle: overdueTasks > 0 ? t('dashboard.needsAttention') : t('dashboard.allOnTrack'),
color: 'brand-quaternary',
})
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' })
}
if (loading) {
return <SkeletonDashboard />
}
if (loading) return <SkeletonDashboard />
return (
<div className="space-y-6 animate-fade-in">
{/* Welcome + Date presets */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<h1 className="text-2xl font-bold text-gradient">
{t('dashboard.welcomeBack')}, {currentUser?.name || 'there'}
</h1>
<p className="text-text-secondary mt-1">
{t('dashboard.happeningToday')}
</p>
</div>
<p className="text-lg font-medium text-text-primary">
{t('dashboard.welcomeBack')}, {currentUser?.name || 'there'}
</p>
<DatePresetPicker
activePreset={activePreset}
onSelect={(from, to, key) => { setDateFrom(from); setDateTo(to); setActivePreset(key) }}
@@ -412,11 +392,18 @@ export default function Dashboard() {
/>
</div>
{/* Stats */}
{statCards.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`}>
{statCards.map((card, i) => (
<StatCard key={i} {...card} />
{/* Stats — compact inline row, no cards */}
{stats.length > 0 && (
<div className="flex flex-wrap gap-6">
{stats.map((s, i) => (
<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>
)}
@@ -432,7 +419,7 @@ export default function Dashboard() {
{/* Budget + Active Campaigns */}
{(hasModule('finance') || hasModule('marketing')) && (
<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') && (
<div className={hasModule('finance') ? 'lg:col-span-2' : ''}>
<ActiveCampaignsList campaigns={campaigns} finance={finance} />
@@ -441,86 +428,14 @@ export default function Dashboard() {
</div>
)}
{/* Recent Posts + Upcoming Deadlines */}
{/* Activity — merged posts + deadlines */}
{(hasModule('marketing') || hasModule('projects')) && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Recent Posts */}
{hasModule('marketing') && (
<div className="section-card">
<div className="section-card-header flex items-center justify-between">
<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>
<ActivityFeed
posts={hasModule('marketing') ? filteredPosts : []}
deadlines={hasModule('projects') ? upcomingDeadlines : []}
navigate={navigate}
t={t}
/>
)}
</div>
)
+257 -41
View File
@@ -1,14 +1,16 @@
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 { AppContext } from '../App'
import { api } from '../utils/api'
import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../i18n/LanguageContext'
import StatusBadge from '../components/StatusBadge'
import Modal from '../components/Modal'
import { useToast } from '../components/ToastContainer'
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 (
<div className={`${bgColor} rounded-xl border border-border p-5`}>
<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() {
const { brands } = useContext(AppContext)
const { permissions } = useAuth()
const { currencySymbol } = useLanguage()
const { permissions, user } = useAuth()
const { t, currencySymbol } = useLanguage()
const toast = useToast()
const [summary, setSummary] = useState(null)
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() }, [])
const loadAll = async () => {
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 || {})
if (reqs) setBudgetRequests(Array.isArray(reqs) ? reqs : [])
} catch (err) {
console.error('Failed to load finance:', err)
} 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) {
return (
<div className="space-y-4">
@@ -86,18 +140,35 @@ export default function Finance() {
const projectPct = totalReceived > 0 ? (totalProjectBudget / 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 (
<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 */}
<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={TrendingUp} label="Total Spent" value={`${totalSpent.toLocaleString()} ${currencySymbol}`} sub={`${spendPct.toFixed(1)}% of budget`} color="text-amber-600" />
<FinanceStatCard icon={Wallet} label={t('finance.totalReceived')} value={`${totalReceived.toLocaleString()} ${currencySymbol}`} color="text-blue-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 && (
<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={DollarSign} label="Revenue" value={`${totalRevenue.toLocaleString()} ${currencySymbol}`} color="text-purple-600" />
<FinanceStatCard icon={roi >= 0 ? TrendingUp : TrendingDown} label="Global ROI"
<FinanceStatCard icon={Landmark} label={t('finance.remaining')} value={`${remaining.toLocaleString()} ${currencySymbol}`} color={remaining >= 0 ? 'text-emerald-600' : 'text-red-600'} />
<FinanceStatCard icon={DollarSign} label={t('finance.revenue')} value={`${totalRevenue.toLocaleString()} ${currencySymbol}`} color="text-purple-600" />
<FinanceStatCard icon={roi >= 0 ? TrendingUp : TrendingDown} label={t('finance.globalROI')}
value={`${roi.toFixed(1)}%`}
color={roi >= 0 ? 'text-emerald-600' : 'text-red-600'} />
</div>
@@ -106,9 +177,9 @@ export default function Finance() {
{totalReceived > 0 && (
<div className="section-card p-5">
<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">
Manage Budgets <ArrowRight className="w-3 h-3" />
{t('finance.manageBudgets')} <ArrowRight className="w-3 h-3" />
</Link>
</div>
<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-1.5">
<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>
</div>
<div className="flex items-center gap-1.5">
<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>
</div>
<div className="flex items-center gap-1.5">
<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>
</div>
</div>
@@ -143,7 +214,7 @@ export default function Finance() {
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{/* Utilization ring */}
<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
pct={spendPct}
size={120}
@@ -157,17 +228,17 @@ export default function Finance() {
{/* Global performance */}
<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="text-center">
<Eye className="w-5 h-5 mx-auto mb-1 text-purple-500" />
<div className="text-xl font-bold text-text-primary">{(s.impressions || 0).toLocaleString()}</div>
<div className="text-xs text-text-tertiary">Impressions</div>
<div className="text-xs text-text-tertiary">{t('finance.impressions')}</div>
</div>
<div className="text-center">
<MousePointer className="w-5 h-5 mx-auto mb-1 text-blue-500" />
<div className="text-xl font-bold text-text-primary">{(s.clicks || 0).toLocaleString()}</div>
<div className="text-xs text-text-tertiary">Clicks</div>
<div className="text-xs text-text-tertiary">{t('finance.clicks')}</div>
{s.clicks > 0 && s.spent > 0 && (
<div className="text-[10px] text-text-tertiary mt-0.5">CPC: {(s.spent / s.clicks).toFixed(2)} {currencySymbol}</div>
)}
@@ -175,7 +246,7 @@ export default function Finance() {
<div className="text-center">
<Target className="w-5 h-5 mx-auto mb-1 text-emerald-500" />
<div className="text-xl font-bold text-text-primary">{(s.conversions || 0).toLocaleString()}</div>
<div className="text-xs text-text-tertiary">Conversions</div>
<div className="text-xs text-text-tertiary">{t('finance.conversions')}</div>
{s.conversions > 0 && s.spent > 0 && (
<div className="text-[10px] text-text-tertiary mt-0.5">CPA: {(s.spent / s.conversions).toFixed(2)} {currencySymbol}</div>
)}
@@ -200,7 +271,7 @@ export default function Finance() {
<Target className="w-4 h-4 text-blue-600" />
</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>
</div>
</div>
@@ -208,13 +279,13 @@ export default function Finance() {
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-surface-secondary">
<th className="px-4 py-3 text-left text-xs font-medium text-text-tertiary">Campaign</th>
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">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-right text-xs font-medium text-text-tertiary">Spent</th>
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">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-right text-xs font-medium text-text-tertiary">ROI</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-end 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">Track Allocated</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-end text-xs font-medium text-text-tertiary">Expenses</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-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>
</tr>
</thead>
@@ -225,20 +296,20 @@ export default function Finance() {
return (
<tr key={c.id} className="hover:bg-surface-secondary">
<td className="px-4 py-3 font-medium text-text-primary">{c.name}</td>
<td className="px-4 py-3 text-right">
<td className="px-4 py-3 text-end">
{c.budget_from_entries > 0 ? (
<span className="font-semibold text-blue-600">{c.budget_from_entries.toLocaleString()}</span>
) : <span className="text-text-tertiary">{'\u2014'}</span>}
</td>
<td className="px-4 py-3 text-right text-text-secondary">{c.tracks_allocated.toLocaleString()}</td>
<td className="px-4 py-3 text-right text-text-secondary">{c.tracks_spent.toLocaleString()}</td>
<td className="px-4 py-3 text-right">
<td className="px-4 py-3 text-end text-text-secondary">{c.tracks_allocated.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-end">
{c.expenses > 0 ? (
<span className="font-semibold text-red-500">-{c.expenses.toLocaleString()}</span>
) : <span className="text-text-tertiary">{'\u2014'}</span>}
</td>
<td className="px-4 py-3 text-right text-text-secondary">{c.tracks_revenue.toLocaleString()}</td>
<td className="px-4 py-3 text-right">
<td className="px-4 py-3 text-end text-text-secondary">{c.tracks_revenue.toLocaleString()}</td>
<td className="px-4 py-3 text-end">
{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'}`}>
{cRoi.toFixed(0)}%
@@ -263,7 +334,7 @@ export default function Finance() {
<Briefcase className="w-4 h-4 text-purple-600" />
</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>
</div>
</div>
@@ -271,9 +342,9 @@ export default function Finance() {
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-surface-secondary">
<th className="px-4 py-3 text-left text-xs font-medium text-text-tertiary">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-right text-xs font-medium text-text-tertiary">Expenses</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-end 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">Expenses</th>
<th className="px-4 py-3 text-center text-xs font-medium text-text-tertiary">Status</th>
</tr>
</thead>
@@ -281,8 +352,8 @@ export default function Finance() {
{s.projects.filter(p => p.budget_allocated > 0 || p.expenses > 0).map(p => (
<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 text-right 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 text-text-secondary">{p.budget_allocated.toLocaleString()} {currencySymbol}</td>
<td className="px-4 py-3 text-end">
{p.expenses > 0 ? (
<span className="font-semibold text-red-500">-{p.expenses.toLocaleString()}</span>
) : <span className="text-text-tertiary">{'\u2014'}</span>}
@@ -295,6 +366,151 @@ export default function Finance() {
</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>
)
}
+16 -7
View File
@@ -1,9 +1,18 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
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'
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() {
const { t } = useLanguage()
const [email, setEmail] = useState('')
@@ -27,11 +36,11 @@ export default function ForgotPassword() {
}
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="text-center mb-8">
<div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-purple-600 rounded-2xl flex items-center justify-center mx-auto mb-4 shadow-lg">
<Megaphone className="w-8 h-8 text-white" />
<div className="w-16 h-16 bg-brand-primary rounded-2xl flex items-center justify-center mx-auto mb-4">
<MarkaLogo className="w-9 h-9 text-white" />
</div>
<h1 className="text-3xl font-bold text-white mb-2">{t('forgotPassword.title')}</h1>
<p className="text-slate-400">{t('forgotPassword.subtitle')}</p>
@@ -57,13 +66,13 @@ export default function ForgotPassword() {
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">{t('auth.email')}</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<Mail className="absolute start-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
dir="auto"
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
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')}
required
autoFocus
@@ -81,7 +90,7 @@ export default function ForgotPassword() {
<button
type="submit"
disabled={loading}
className="w-full py-3 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed"
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 ? (
<span className="flex items-center justify-center gap-2">
+15 -23
View File
@@ -196,8 +196,8 @@ export default function Issues() {
const SortIcon = ({ col }) => {
if (sortBy !== col) return null
return sortDir === 'asc'
? <ChevronUp className="w-3 h-3 inline ml-0.5" />
: <ChevronDown 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 ms-0.5" />
}
if (loading) {
@@ -211,15 +211,7 @@ export default function Issues() {
return (
<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 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 justify-end">
<div className="flex items-center gap-3">
<button
onClick={copyPublicLink}
@@ -241,7 +233,7 @@ export default function Issues() {
onClick={() => setViewMode(mode)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
viewMode === mode
? 'bg-white text-text-primary shadow-sm'
? 'bg-surface text-text-primary shadow-sm'
: 'text-text-tertiary hover:text-text-secondary'
}`}
>
@@ -276,13 +268,13 @@ export default function Issues() {
<div className="flex items-center gap-3 flex-wrap">
{/* Search */}
<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
type="text"
placeholder={t('issues.searchPlaceholder')}
value={searchTerm}
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>
@@ -413,21 +405,21 @@ export default function Issues() {
<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" />
</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" />
</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-left 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-left 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">{t('issues.tableSubmitter')}</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-start 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.tableType')}</th>
<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" />
</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" />
</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-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">{t('issues.tableAssignedTo')}</th>
<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" />
</th>
</tr>
+33 -21
View File
@@ -2,9 +2,18 @@ import { useState, useEffect } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
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'
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() {
const navigate = useNavigate()
const { login } = useAuth()
@@ -63,19 +72,19 @@ export default function Login() {
if (needsSetup === null) {
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>
)
}
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">
{/* Logo & Title */}
<div className="text-center mb-8">
<div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-purple-600 rounded-2xl flex items-center justify-center mx-auto mb-4 shadow-lg">
<Megaphone className="w-8 h-8 text-white" />
<div className="w-16 h-16 bg-brand-primary rounded-2xl flex items-center justify-center mx-auto mb-4">
<MarkaLogo className="w-9 h-9 text-white" />
</div>
<h1 className="text-3xl font-bold text-white mb-2">
{needsSetup ? t('login.initialSetup') : t('login.title')}
@@ -101,15 +110,16 @@ export default function Login() {
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">{t('login.fullName')}</label>
<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
type="text"
value={setupName}
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')}
required
autoFocus
aria-describedby={error ? 'setup-error' : undefined}
/>
</div>
</div>
@@ -118,13 +128,13 @@ export default function Login() {
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">{t('login.email')}</label>
<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
type="email"
value={setupEmail}
onChange={(e) => setSetupEmail(e.target.value)}
dir="auto"
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
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"
required
/>
@@ -135,12 +145,12 @@ export default function Login() {
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">{t('login.password')}</label>
<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
type="password"
value={setupPassword}
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')}
required
minLength={6}
@@ -152,12 +162,12 @@ export default function Login() {
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">{t('login.confirmPassword')}</label>
<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
type="password"
value={setupConfirm}
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')}
required
minLength={6}
@@ -167,7 +177,7 @@ export default function Login() {
{/* 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" />
<p className="text-sm text-red-400">{error}</p>
</div>
@@ -177,7 +187,7 @@ export default function Login() {
<button
type="submit"
disabled={loading}
className="w-full py-3 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed"
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 ? (
<span className="flex items-center justify-center gap-2">
@@ -197,16 +207,17 @@ export default function Login() {
{t('auth.email')}
</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<Mail className="absolute start-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
dir="auto"
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
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"
required
autoFocus
aria-describedby={error ? 'login-error' : undefined}
/>
</div>
</div>
@@ -217,21 +228,22 @@ export default function Login() {
{t('auth.password')}
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<Lock className="absolute start-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
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="••••••••"
required
aria-describedby={error ? 'login-error' : undefined}
/>
</div>
</div>
{/* 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" />
<p className="text-sm text-red-400">{error}</p>
</div>
@@ -241,7 +253,7 @@ export default function Login() {
<button
type="submit"
disabled={loading}
className="w-full py-3 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed"
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 ? (
<span className="flex items-center justify-center gap-2">
+6 -14
View File
@@ -158,14 +158,6 @@ export default function PostCalendar() {
return (
<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 */}
<div className="flex flex-wrap items-center gap-3">
<select
@@ -220,14 +212,14 @@ export default function PostCalendar() {
<div className="flex bg-surface-tertiary rounded-lg p-0.5">
<button
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" />
Month
</button>
<button
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" />
Week
@@ -271,7 +263,7 @@ export default function PostCalendar() {
<button
key={post.Id || post._id}
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'
}`}
title={post.title}
@@ -294,13 +286,13 @@ export default function PostCalendar() {
{/* Unscheduled Posts */}
{unscheduled.length > 0 && (
<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">
{unscheduled.map(post => (
<button
key={post.Id || post._id}
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">
<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 */}
<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">
{Object.entries(STATUS_COLORS).map(([status, color]) => (
<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 { AppContext } from '../App'
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.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
@@ -181,7 +181,7 @@ export default function PostProduction() {
if (filters.periodTo && d > filters.periodTo) return false
}
return true
})
}), [posts, filters, searchTerm])
if (loading) {
return view === 'kanban' ? <SkeletonKanbanBoard /> : <SkeletonTable rows={8} cols={6} />
@@ -193,20 +193,20 @@ export default function PostProduction() {
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-3">
<div className="relative flex-1 min-w-[200px] max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<Search className="absolute start-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<input
type="text"
placeholder={t('posts.searchPosts')}
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-white"
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>
<button
data-tutorial="filters"
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" />
{t('common.filter')}
@@ -215,16 +215,16 @@ export default function PostProduction() {
)}
</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
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" />
</button>
<button
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" />
</button>
@@ -245,7 +245,7 @@ export default function PostProduction() {
<select
value={filters.brand}
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>
{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
value={filters.platform}
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>
{Object.entries(PLATFORMS).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
@@ -263,7 +263,7 @@ export default function PostProduction() {
<select
value={filters.assignedTo}
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>
{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}
onChange={e => { setFilters(f => ({ ...f, periodFrom: e.target.value })); setActivePreset('') }}
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>
<input
@@ -289,7 +289,7 @@ export default function PostProduction() {
value={filters.periodTo}
onChange={e => { setFilters(f => ({ ...f, periodTo: e.target.value })); setActivePreset('') }}
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>
@@ -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 ? (
<EmptyState
icon={FileText}
@@ -361,12 +361,12 @@ export default function PostProduction() {
<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" />
</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-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.brand')}</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.status')}</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.platforms')}</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.assignTo')}</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.scheduledDate')}</th>
<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-start 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.status')}</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-start 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.scheduledDate')}</th>
</tr>
</thead>
<tbody className="divide-y divide-border-light">
+19 -19
View File
@@ -223,14 +223,14 @@ export default function ProjectDetail() {
</button>
{/* 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 */}
{(project.thumbnail_url || project.thumbnailUrl) && (
<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" />
{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
onClick={() => thumbnailInputRef.current?.click()}
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}
onClick={() => setView(v.id)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
view === v.id ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary hover:text-text-secondary'
view === v.id ? 'bg-surface shadow-sm text-text-primary' : 'text-text-tertiary hover:text-text-secondary'
}`}
>
<v.icon className="w-4 h-4" />
@@ -411,21 +411,21 @@ export default function ProjectDetail() {
{/* ─── LIST VIEW ─── */}
{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">
<thead>
<tr className="border-b border-border bg-surface-secondary">
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider w-8"></th>
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Task</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Status</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Priority</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Assignee</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Due</th>
<th className="text-start 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">Task</th>
<th className="text-start 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">Priority</th>
<th className="text-start 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">Due</th>
</tr>
</thead>
<tbody className="divide-y divide-border-light">
{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 => {
const prio = PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium
@@ -470,7 +470,7 @@ export default function ProjectDetail() {
{/* ─── DISCUSSION SIDEBAR ─── */}
{showDiscussion && (
<div className="w-[340px] shrink-0 bg-white rounded-xl border border-border flex flex-col self-start sticky top-4" style={{ maxHeight: 'calc(100vh - 6rem)' }}>
<div className="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">
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-1.5">
<MessageCircle className="w-4 h-4" />
@@ -539,7 +539,7 @@ function TaskKanbanCard({ task, canEdit, canDelete, onClick, onDelete, onStatusC
onDragStart={(e) => canEdit && onDragStart(e, task)}
onDragEnd={onDragEnd}
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={`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 && (
<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" />
</button>
)}
@@ -614,7 +614,7 @@ function GanttView({ tasks, project, onEditTask, onTaskColorChange }) {
if (tasks.length === 0) {
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" />
<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>
@@ -666,7 +666,7 @@ function GanttView({ tasks, project, onEditTask, onTaskColorChange }) {
}
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 */}
<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">
@@ -757,7 +757,7 @@ function GanttView({ tasks, project, onEditTask, onTaskColorChange }) {
)}
{!onTaskColorChange && <div className={`w-2 h-2 rounded-full ${prio.color} shrink-0`} />}
<button onClick={() => onEditTask(task)}
className="text-xs font-medium text-text-primary truncate hover:text-brand-primary text-left">
className="text-xs font-medium text-text-primary truncate hover:text-brand-primary text-start">
{task.title}
</button>
</div>
@@ -787,7 +787,7 @@ function GanttView({ tasks, project, onEditTask, onTaskColorChange }) {
{colorPicker && onTaskColorChange && (
<div
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 }}
>
<div className="grid grid-cols-4 gap-1.5 mb-2">
+4 -4
View File
@@ -80,13 +80,13 @@ export default function Projects() {
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-3">
<div className="relative flex-1 min-w-[200px] max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<Search className="absolute start-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<input
type="text"
placeholder="Search projects..."
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-white"
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>
@@ -100,7 +100,7 @@ export default function Projects() {
key={v.id}
onClick={() => setView(v.id)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
view === v.id ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary hover:text-text-secondary'
view === v.id ? 'bg-surface shadow-sm text-text-primary' : 'text-text-tertiary hover:text-text-secondary'
}`}
>
<v.icon className="w-4 h-4" />
@@ -112,7 +112,7 @@ export default function Projects() {
{permissions?.canCreateProjects && (
<button
onClick={() => setShowModal(true)}
className="flex items-center gap-2 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm ml-auto"
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" />
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 },
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 },
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 = {
low: { label: t('low'), color: 'text-gray-700' },
low: { label: t('low'), color: 'text-text-secondary' },
medium: { label: t('medium'), color: 'text-blue-700' },
high: { label: t('high'), color: 'text-orange-700' },
urgent: { label: t('urgent'), color: 'text-red-700' },
@@ -267,16 +267,16 @@ export default function PublicIssueTracker() {
<div className="flex items-start gap-3">
{issue.status === 'resolved'
? <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">
<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')}
</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}
</p>
{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)}
</p>
)}
@@ -303,7 +303,7 @@ export default function PublicIssueTracker() {
<div className="flex items-start justify-between gap-3 mb-2">
<div className="flex items-center gap-2">
<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')}
</span>
</div>
+2 -2
View File
@@ -146,7 +146,7 @@ export default function PublicPostReview() {
</div>
<div>
<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>
@@ -181,7 +181,7 @@ export default function PublicPostReview() {
{images.map((att, idx) => (
<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">
<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 && (
<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>
+3 -1
View File
@@ -184,7 +184,7 @@ export default function PublicReview() {
</div>
<div>
<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>
@@ -281,6 +281,7 @@ export default function PublicReview() {
src={att.url}
alt={att.original_name || `Design ${idx + 1}`}
className="w-full h-64 object-cover"
loading="lazy"
/>
{att.original_name && (
<div className="bg-surface-secondary px-4 py-2 border-t border-border">
@@ -354,6 +355,7 @@ export default function PublicReview() {
src={att.url}
alt={att.original_name}
className="w-full h-48 object-cover"
loading="lazy"
/>
<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>
+1 -1
View File
@@ -350,7 +350,7 @@ export default function PublicTranslationReview() {
value={suggestionContent}
onChange={e => setSuggestionContent(e.target.value)}
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">
<button
+19 -10
View File
@@ -1,9 +1,18 @@
import { useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
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'
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() {
const { t } = useLanguage()
const [searchParams] = useSearchParams()
@@ -16,7 +25,7 @@ export default function ResetPassword() {
if (!token) {
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="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" />
@@ -51,11 +60,11 @@ export default function ResetPassword() {
}
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="text-center mb-8">
<div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-purple-600 rounded-2xl flex items-center justify-center mx-auto mb-4 shadow-lg">
<Megaphone className="w-8 h-8 text-white" />
<div className="w-16 h-16 bg-brand-primary rounded-2xl flex items-center justify-center mx-auto mb-4">
<MarkaLogo className="w-9 h-9 text-white" />
</div>
<h1 className="text-3xl font-bold text-white mb-2">{t('resetPassword.title')}</h1>
<p className="text-slate-400">{t('resetPassword.subtitle')}</p>
@@ -81,12 +90,12 @@ export default function ResetPassword() {
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">{t('resetPassword.newPassword')}</label>
<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
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
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="••••••••"
required
minLength={6}
@@ -98,12 +107,12 @@ export default function ResetPassword() {
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">{t('resetPassword.confirmPassword')}</label>
<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
type="password"
value={confirm}
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="••••••••"
required
minLength={6}
@@ -121,7 +130,7 @@ export default function ResetPassword() {
<button
type="submit"
disabled={loading}
className="w-full py-3 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed"
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 ? (
<span className="flex items-center justify-center gap-2">
+71 -15
View File
@@ -1,5 +1,5 @@
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 { useLanguage } from '../i18n/LanguageContext'
import { useToast } from '../components/ToastContainer'
@@ -23,9 +23,15 @@ export default function Settings() {
const [maxSizeMB, setMaxSizeMB] = useState(50)
const [sizeSaving, setSizeSaving] = useState(false)
const [sizeSaved, setSizeSaved] = useState(false)
const [ceoEmail, setCeoEmail] = useState('')
const [ceoSaving, setCeoSaving] = useState(false)
const [ceoSaved, setCeoSaved] = useState(false)
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 () => {
@@ -65,9 +71,9 @@ export default function Settings() {
<p className="text-sm text-text-tertiary">{t('settings.preferences')}</p>
{/* 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">
<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 className="p-6 space-y-4">
{/* Language Selector */}
@@ -79,7 +85,7 @@ export default function Settings() {
<select
value={lang}
onChange={(e) => setLang(e.target.value)}
className="w-full max-w-xs px-4 py-2.5 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-white"
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="ar">{t('settings.arabic')}</option>
@@ -95,7 +101,7 @@ export default function Settings() {
<select
value={currency}
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 => (
<option key={c.code} value={c.code}>
@@ -109,12 +115,12 @@ export default function Settings() {
</div>
{/* 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">
<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" />
{t('settings.uploads')}
</h2>
</h3>
</div>
<div className="p-6 space-y-4">
<div>
@@ -128,7 +134,7 @@ export default function Settings() {
max="500"
value={maxSizeMB}
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>
<button
@@ -147,9 +153,9 @@ export default function Settings() {
</div>
{/* 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">
<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 className="p-6 space-y-4">
<p className="text-sm text-text-secondary">
@@ -180,6 +186,56 @@ export default function Settings() {
</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) */}
{user?.role === 'superadmin' && <RolesSection roles={roles} loadRoles={loadRoles} t={t} toast={toast} />}
</div>
@@ -235,12 +291,12 @@ function RolesSection({ roles, loadRoles, t, toast }) {
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">
<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" />
{t('settings.roles')}
</h2>
</h3>
<button
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"
+23 -23
View File
@@ -325,16 +325,16 @@ export default function Tasks() {
<div className="flex items-center gap-3 flex-1 min-w-0">
{/* Search */}
<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
type="text"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
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 && (
<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" />
</button>
)}
@@ -350,7 +350,7 @@ export default function Tasks() {
onClick={() => setViewMode(mode)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
viewMode === mode
? 'bg-white text-text-primary shadow-sm'
? 'bg-surface text-text-primary shadow-sm'
: 'text-text-tertiary hover:text-text-secondary'
}`}
>
@@ -399,7 +399,7 @@ export default function Tasks() {
<select
value={filterProject}
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>
{taskProjects.map(p => (
@@ -411,7 +411,7 @@ export default function Tasks() {
<select
value={filterBrand}
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>
{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 ${
active
? '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}`)}
@@ -453,7 +453,7 @@ export default function Tasks() {
<select
value={filterPriority}
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="low">{t('tasks.priority.low')}</option>
@@ -466,7 +466,7 @@ export default function Tasks() {
<select
value={filterAssignee}
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>
{(assignableUsers || []).map(m => (
@@ -479,7 +479,7 @@ export default function Tasks() {
<select
value={filterCreator}
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>
{users.map(m => (
@@ -501,7 +501,7 @@ export default function Tasks() {
type="date"
value={filterDateFrom}
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')}
/>
<span className="text-text-tertiary text-xs">-</span>
@@ -509,7 +509,7 @@ export default function Tasks() {
type="date"
value={filterDateTo}
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')}
/>
</div>
@@ -520,7 +520,7 @@ export default function Tasks() {
className={`px-2.5 py-1 text-[11px] font-medium rounded-full border transition-colors ${
filterOverdue
? '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')}
@@ -599,7 +599,7 @@ export default function Tasks() {
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">
<thead>
<tr className="border-b border-border bg-surface-secondary/50">
@@ -614,28 +614,28 @@ export default function Tasks() {
</th>
<th className="w-8 px-3 py-2.5"></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')}
>
{t('tasks.taskTitle')} {sortBy === 'title' && (sortDir === 'asc' ? '↑' : '↓')}
</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-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.project')}</th>
<th className="text-start px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase">{t('tasks.brand')}</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')}
>
{t('tasks.status')} {sortBy === 'status' && (sortDir === 'asc' ? '↑' : '↓')}
</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
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')}
>
{t('tasks.dueDate')} {sortBy === 'due_date' && (sortDir === 'asc' ? '↑' : '↓')}
</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')}
>
{t('tasks.priority')} {sortBy === 'priority' && (sortDir === 'asc' ? '↑' : '↓')}
@@ -651,7 +651,7 @@ export default function Tasks() {
const brandName = task.brand_name || task.brandName
const assignedName = task.assigned_name || task.assignedName
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 (
<tr
@@ -675,7 +675,7 @@ export default function Tasks() {
{task.title}
</span>
{(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 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 { getInitials } from '../utils/api'
import { AppContext, PERMISSION_LEVELS } from '../App'
@@ -16,9 +16,9 @@ import { useToast } from '../components/ToastContainer'
const ALL_MODULES = ['marketing', 'projects', 'finance']
const MODULE_LABELS = { marketing: 'Marketing', projects: 'Projects', finance: 'Finance' }
const MODULE_COLORS = {
marketing: { on: 'bg-emerald-100 text-emerald-700 border-emerald-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-gray-400 border-gray-200' },
finance: { on: 'bg-amber-100 text-amber-700 border-amber-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-text-tertiary 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 = {
@@ -238,9 +238,11 @@ export default function Team() {
// Member detail view
if (selectedMember) {
const todoCount = memberTasks.filter(t => t.status === 'todo').length
const inProgressCount = memberTasks.filter(t => t.status === 'in_progress').length
const doneCount = memberTasks.filter(t => t.status === 'done').length
const { todoCount, inProgressCount, doneCount } = useMemo(() => ({
todoCount: memberTasks.filter(t => t.status === 'todo').length,
inProgressCount: memberTasks.filter(t => t.status === 'in_progress').length,
doneCount: memberTasks.filter(t => t.status === 'done').length,
}), [memberTasks])
return (
<div className="space-y-6 animate-fade-in">
@@ -253,7 +255,7 @@ export default function Team() {
</button>
{/* 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={`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()}
@@ -281,19 +283,19 @@ export default function Team() {
{/* Workload stats */}
<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-xs text-text-tertiary">{t('team.totalTasks')}</p>
</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-xs text-text-tertiary">{t('team.toDo')}</p>
</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-xs text-text-tertiary">{t('team.inProgress')}</p>
</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-xs text-text-tertiary">{t('tasks.done')}</p>
</div>
@@ -302,7 +304,7 @@ export default function Team() {
{/* Tasks & Posts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Tasks */}
<div className="bg-white rounded-xl border border-border">
<div className="bg-surface rounded-xl border border-border">
<div className="px-5 py-4 border-b border-border">
<h3 className="font-semibold text-text-primary">{t('tasks.title')} ({memberTasks.length})</h3>
</div>
@@ -327,7 +329,7 @@ export default function Team() {
</div>
{/* 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">
<h3 className="font-semibold text-text-primary">{t('nav.posts')} ({memberPosts.length})</h3>
</div>
@@ -394,7 +396,7 @@ export default function Team() {
{displayedMembers.length} {displayedMembers.length !== 1 ? t('team.membersPlural') : t('team.member')}
</p>
{/* 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
onClick={() => setViewMode('grid')}
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 */}
<button
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')}
>
<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)
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" />
{t('team.myProfile')}
@@ -438,7 +440,7 @@ export default function Team() {
{canManageTeam && (
<button
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" />
{t('teams.createTeam')}
@@ -468,7 +470,7 @@ export default function Team() {
<button
onClick={() => setTeamFilter(null)}
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')}
@@ -481,7 +483,7 @@ export default function Team() {
<button
onClick={() => setTeamFilter(active ? null : tid)}
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})
@@ -531,7 +533,7 @@ export default function Team() {
const tid = team.id || team._id
const members = teamMembers.filter(m => m.teams?.some(t => t.id === tid))
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 */}
<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">
@@ -601,7 +603,7 @@ export default function Team() {
{/* Unassigned members */}
{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="w-10 h-10 rounded-lg bg-gray-400 flex items-center justify-center text-white">
<UserIcon className="w-5 h-5" />
@@ -707,7 +709,7 @@ export default function Team() {
<div ref={addBrandsRef} className="relative">
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.brands')}</label>
<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'}`}>
{addForm.brands.length === 0 ? t('team.selectBrands') : addForm.brands.join(', ')}
</span>
@@ -724,13 +726,13 @@ export default function Team() {
</div>
)}
{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 => {
const name = lang === 'ar' && brand.name_ar ? brand.name_ar : brand.name
const checked = addForm.brands.includes(name)
return (
<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'}`}>
{checked && <Check className="w-3 h-3 text-white" />}
</div>
@@ -771,7 +773,7 @@ export default function Team() {
return (
<button key={tid} type="button"
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}
</button>
)
+13 -13
View File
@@ -189,8 +189,8 @@ export default function Translations() {
const SortIcon = ({ col }) => {
if (listSortBy !== col) return null
return listSortDir === 'asc'
? <ChevronUp className="w-3 h-3 inline ml-0.5" />
: <ChevronDown 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 ms-0.5" />
}
const formatDate = (dateStr) => {
@@ -219,7 +219,7 @@ export default function Translations() {
onClick={() => setViewMode(mode)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
viewMode === mode
? 'bg-white text-text-primary shadow-sm'
? 'bg-surface text-text-primary shadow-sm'
: 'text-text-tertiary hover:text-text-secondary'
}`}
>
@@ -242,13 +242,13 @@ export default function Translations() {
{/* Filters */}
<div className="flex flex-wrap items-center gap-3">
<div className="relative flex-1 min-w-[200px] max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<Search className="absolute start-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<input
type="text"
placeholder={t('translations.searchTranslations')}
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-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>
@@ -356,22 +356,22 @@ export default function Translations() {
<table className="w-full">
<thead>
<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" />
</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" />
</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')}
</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" />
</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-left 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-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">{t('translations.brand')}</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-start 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 cursor-pointer" onClick={() => toggleListSort('updated_at')}>
{t('translations.updated')} <SortIcon col="updated_at" />
</th>
</tr>