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:
+1
-1
@@ -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
@@ -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'
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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`}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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`} />
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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-2 → surface-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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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)
|
||||
)}
|
||||
|
||||
@@ -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
@@ -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
@@ -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 · 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user