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.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=IBM+Plex+Sans+Arabic:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=IBM+Plex+Sans+Arabic:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||||
<title>Digital Hub</title>
|
<title>Rawaj</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
+3
-1
@@ -37,6 +37,7 @@ const PublicIssueSubmit = lazy(() => import('./pages/PublicIssueSubmit'))
|
|||||||
const PublicIssueTracker = lazy(() => import('./pages/PublicIssueTracker'))
|
const PublicIssueTracker = lazy(() => import('./pages/PublicIssueTracker'))
|
||||||
const Translations = lazy(() => import('./pages/Translations'))
|
const Translations = lazy(() => import('./pages/Translations'))
|
||||||
const PublicTranslationReview = lazy(() => import('./pages/PublicTranslationReview'))
|
const PublicTranslationReview = lazy(() => import('./pages/PublicTranslationReview'))
|
||||||
|
const PublicBudgetApproval = lazy(() => import('./pages/PublicBudgetApproval'))
|
||||||
const ForgotPassword = lazy(() => import('./pages/ForgotPassword'))
|
const ForgotPassword = lazy(() => import('./pages/ForgotPassword'))
|
||||||
const ResetPassword = lazy(() => import('./pages/ResetPassword'))
|
const ResetPassword = lazy(() => import('./pages/ResetPassword'))
|
||||||
|
|
||||||
@@ -161,7 +162,7 @@ function AppContent() {
|
|||||||
<AppContext.Provider value={{ currentUser: user, teamMembers, brands, loadTeam, getBrandName, teams, loadTeams, roles, loadRoles }}>
|
<AppContext.Provider value={{ currentUser: user, teamMembers, brands, loadTeam, getBrandName, teams, loadTeams, roles, loadRoles }}>
|
||||||
{/* Profile completion prompt */}
|
{/* Profile completion prompt */}
|
||||||
{showProfilePrompt && (
|
{showProfilePrompt && (
|
||||||
<div className="fixed top-4 right-4 z-50 bg-amber-50 border-2 border-amber-400 rounded-xl shadow-lg p-4 max-w-md animate-fade-in">
|
<div className="fixed top-4 end-4 z-50 bg-amber-50 border-2 border-amber-400 rounded-xl shadow-lg p-4 max-w-md animate-fade-in">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div className="w-10 h-10 rounded-full bg-amber-400 flex items-center justify-center text-white shrink-0">
|
<div className="w-10 h-10 rounded-full bg-amber-400 flex items-center justify-center text-white shrink-0">
|
||||||
⚠️
|
⚠️
|
||||||
@@ -298,6 +299,7 @@ function AppContent() {
|
|||||||
<Route path="/submit-issue" element={<PublicIssueSubmit />} />
|
<Route path="/submit-issue" element={<PublicIssueSubmit />} />
|
||||||
<Route path="/track/:token" element={<PublicIssueTracker />} />
|
<Route path="/track/:token" element={<PublicIssueTracker />} />
|
||||||
<Route path="/review-translation/:token" element={<PublicTranslationReview />} />
|
<Route path="/review-translation/:token" element={<PublicTranslationReview />} />
|
||||||
|
<Route path="/approve-budget/:token" element={<PublicBudgetApproval />} />
|
||||||
<Route path="/" element={user ? <Layout /> : <Navigate to="/login" replace />}>
|
<Route path="/" element={user ? <Layout /> : <Navigate to="/login" replace />}>
|
||||||
<Route index element={<Dashboard />} />
|
<Route index element={<Dashboard />} />
|
||||||
{hasModule('marketing') && <>
|
{hasModule('marketing') && <>
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export default function ApproverMultiSelect({ users = [], selected = [], onChang
|
|||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
<ChevronDown className={`w-4 h-4 text-text-tertiary ml-auto shrink-0 transition-transform ${open ? 'rotate-180' : ''}`} />
|
<ChevronDown className={`w-4 h-4 text-text-tertiary ms-auto shrink-0 transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||||
</div>
|
</div>
|
||||||
{open && (
|
{open && (
|
||||||
<div className={`absolute z-50 w-full bg-surface border border-border rounded-lg shadow-lg max-h-48 overflow-y-auto ${dropUp ? 'bottom-full mb-1' : 'top-full mt-1'}`}>
|
<div className={`absolute z-50 w-full bg-surface border border-border rounded-lg shadow-lg max-h-48 overflow-y-auto ${dropUp ? 'bottom-full mb-1' : 'top-full mt-1'}`}>
|
||||||
@@ -76,7 +76,7 @@ export default function ApproverMultiSelect({ users = [], selected = [], onChang
|
|||||||
key={uid}
|
key={uid}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggle(uid)}
|
onClick={() => toggle(uid)}
|
||||||
className={`w-full text-left px-3 py-2 text-sm hover:bg-surface-secondary flex items-center justify-between ${
|
className={`w-full text-start px-3 py-2 text-sm hover:bg-surface-secondary flex items-center justify-between ${
|
||||||
isSelected ? 'text-brand-primary font-medium' : 'text-text-primary'
|
isSelected ? 'text-brand-primary font-medium' : 'text-text-primary'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { useState, useEffect, useContext } from 'react'
|
import { useState, useEffect, useContext } from 'react'
|
||||||
import { Plus, Copy, Check, ExternalLink, Upload, Globe, Trash2, FileText, Image as ImageIcon, Film, Sparkles, MessageSquare, Save, FileEdit, Layers, ShieldCheck } from 'lucide-react'
|
import { Copy, Check, ExternalLink, Trash2, FileText, Image as ImageIcon, Film, Sparkles, MessageSquare, Save, FileEdit, Layers, ShieldCheck } from 'lucide-react'
|
||||||
import { AppContext } from '../App'
|
import { AppContext } from '../App'
|
||||||
import { useLanguage } from '../i18n/LanguageContext'
|
import { useLanguage } from '../i18n/LanguageContext'
|
||||||
import { api } from '../utils/api'
|
import { api } from '../utils/api'
|
||||||
import Modal from './Modal'
|
import Modal from './Modal'
|
||||||
import TabbedModal from './TabbedModal'
|
import TabbedModal from './TabbedModal'
|
||||||
import { useToast } from './ToastContainer'
|
import { useToast } from './ToastContainer'
|
||||||
import ArtefactVersionTimeline from './ArtefactVersionTimeline'
|
|
||||||
import ApproverMultiSelect from './ApproverMultiSelect'
|
import ApproverMultiSelect from './ApproverMultiSelect'
|
||||||
|
import { ArtefactDetailVersionsTab } from './ArtefactDetailVersionsTab'
|
||||||
|
|
||||||
const STATUS_COLORS = {
|
const STATUS_COLORS = {
|
||||||
draft: 'bg-surface-tertiary text-text-secondary',
|
draft: 'bg-surface-tertiary text-text-secondary',
|
||||||
@@ -17,13 +17,6 @@ const STATUS_COLORS = {
|
|||||||
revision_requested: 'bg-orange-100 text-orange-700',
|
revision_requested: 'bg-orange-100 text-orange-700',
|
||||||
}
|
}
|
||||||
|
|
||||||
const AVAILABLE_LANGUAGES = [
|
|
||||||
{ code: 'AR', label: '\u0627\u0644\u0639\u0631\u0628\u064A\u0629' },
|
|
||||||
{ code: 'EN', label: 'English' },
|
|
||||||
{ code: 'FR', label: 'Fran\u00E7ais' },
|
|
||||||
{ code: 'ID', label: 'Bahasa Indonesia' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const TYPE_ICONS = {
|
const TYPE_ICONS = {
|
||||||
copy: FileText,
|
copy: FileText,
|
||||||
design: ImageIcon,
|
design: ImageIcon,
|
||||||
@@ -55,27 +48,10 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
|||||||
const reviewUrl = freshReviewUrl || (artefact.approval_token ? `${window.location.origin}/review/${artefact.approval_token}` : '')
|
const reviewUrl = freshReviewUrl || (artefact.approval_token ? `${window.location.origin}/review/${artefact.approval_token}` : '')
|
||||||
const [savingDraft, setSavingDraft] = useState(false)
|
const [savingDraft, setSavingDraft] = useState(false)
|
||||||
const [deleting, setDeleting] = useState(false)
|
const [deleting, setDeleting] = useState(false)
|
||||||
const [confirmDeleteLangId, setConfirmDeleteLangId] = useState(null)
|
|
||||||
const [confirmDeleteAttId, setConfirmDeleteAttId] = useState(null)
|
|
||||||
const [showDeleteArtefactConfirm, setShowDeleteArtefactConfirm] = useState(false)
|
const [showDeleteArtefactConfirm, setShowDeleteArtefactConfirm] = useState(false)
|
||||||
|
|
||||||
// Language management (for copy type)
|
|
||||||
const [showLanguageModal, setShowLanguageModal] = useState(false)
|
|
||||||
const [languageForm, setLanguageForm] = useState({ language_code: '', language_label: '', content: '' })
|
|
||||||
const [savingLanguage, setSavingLanguage] = useState(false)
|
|
||||||
|
|
||||||
// New version modal
|
|
||||||
const [showNewVersionModal, setShowNewVersionModal] = useState(false)
|
|
||||||
const [newVersionNotes, setNewVersionNotes] = useState('')
|
|
||||||
const [copyFromPrevious, setCopyFromPrevious] = useState(false)
|
|
||||||
const [creatingVersion, setCreatingVersion] = useState(false)
|
|
||||||
|
|
||||||
// File upload (for design/video)
|
// File upload (for design/video)
|
||||||
const [uploading, setUploading] = useState(false)
|
const [uploading, setUploading] = useState(false)
|
||||||
|
|
||||||
// Video inline (Drive link input)
|
|
||||||
const [driveUrl, setDriveUrl] = useState('')
|
|
||||||
const [dragOver, setDragOver] = useState(false)
|
|
||||||
const [uploadProgress, setUploadProgress] = useState(0)
|
const [uploadProgress, setUploadProgress] = useState(0)
|
||||||
|
|
||||||
// Comments
|
// Comments
|
||||||
@@ -137,57 +113,23 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
|||||||
loadVersionData(version.Id)
|
loadVersionData(version.Id)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCreateVersion = async () => {
|
const handleCreateVersion = async ({ notes, copy_from_previous }) => {
|
||||||
setCreatingVersion(true)
|
await api.post(`/artefacts/${artefact.Id}/versions`, { notes, copy_from_previous })
|
||||||
try {
|
toast.success(t('artefacts.versionCreated'))
|
||||||
await api.post(`/artefacts/${artefact.Id}/versions`, {
|
loadVersions()
|
||||||
notes: newVersionNotes || `Version ${(versions[versions.length - 1]?.version_number || 0) + 1}`,
|
onUpdate()
|
||||||
copy_from_previous: artefact.type === 'copy' ? copyFromPrevious : false,
|
|
||||||
})
|
|
||||||
|
|
||||||
toast.success(t('artefacts.versionCreated'))
|
|
||||||
setShowNewVersionModal(false)
|
|
||||||
setNewVersionNotes('')
|
|
||||||
setCopyFromPrevious(false)
|
|
||||||
loadVersions()
|
|
||||||
onUpdate()
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Create version failed:', err)
|
|
||||||
toast.error(t('artefacts.failedCreateVersion'))
|
|
||||||
} finally {
|
|
||||||
setCreatingVersion(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAddLanguage = async () => {
|
const handleAddLanguage = async (languageForm) => {
|
||||||
if (!languageForm.language_code || !languageForm.language_label || !languageForm.content) {
|
await api.post(`/artefacts/${artefact.Id}/versions/${selectedVersion.Id}/texts`, languageForm)
|
||||||
toast.error(t('artefacts.allFieldsRequired'))
|
toast.success(t('artefacts.languageAdded'))
|
||||||
return
|
loadVersionData(selectedVersion.Id)
|
||||||
}
|
|
||||||
|
|
||||||
setSavingLanguage(true)
|
|
||||||
try {
|
|
||||||
await api.post(`/artefacts/${artefact.Id}/versions/${selectedVersion.Id}/texts`, languageForm)
|
|
||||||
toast.success(t('artefacts.languageAdded'))
|
|
||||||
setShowLanguageModal(false)
|
|
||||||
setLanguageForm({ language_code: '', language_label: '', content: '' })
|
|
||||||
loadVersionData(selectedVersion.Id)
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Add language failed:', err)
|
|
||||||
toast.error(t('artefacts.failedAddLanguage'))
|
|
||||||
} finally {
|
|
||||||
setSavingLanguage(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDeleteLanguage = async (textId) => {
|
const handleDeleteLanguage = async (textId) => {
|
||||||
try {
|
await api.delete(`/artefact-version-texts/${textId}`)
|
||||||
await api.delete(`/artefact-version-texts/${textId}`)
|
toast.success(t('artefacts.languageDeleted'))
|
||||||
toast.success(t('artefacts.languageDeleted'))
|
loadVersionData(selectedVersion.Id)
|
||||||
loadVersionData(selectedVersion.Id)
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(t('artefacts.failedDeleteLanguage'))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleFileUpload = async (fileOrEvent) => {
|
const handleFileUpload = async (fileOrEvent) => {
|
||||||
@@ -215,16 +157,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleVideoDrop = (e) => {
|
const handleAddDriveVideo = async (driveUrl) => {
|
||||||
e.preventDefault()
|
|
||||||
setDragOver(false)
|
|
||||||
const file = e.dataTransfer.files?.[0]
|
|
||||||
if (file && file.type.startsWith('video/')) {
|
|
||||||
handleFileUpload(file)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAddDriveVideo = async () => {
|
|
||||||
if (!driveUrl.trim()) {
|
if (!driveUrl.trim()) {
|
||||||
toast.error(t('artefacts.enterDriveUrl'))
|
toast.error(t('artefacts.enterDriveUrl'))
|
||||||
return
|
return
|
||||||
@@ -236,7 +169,6 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
|||||||
drive_url: driveUrl,
|
drive_url: driveUrl,
|
||||||
})
|
})
|
||||||
toast.success(t('artefacts.videoLinkAdded'))
|
toast.success(t('artefacts.videoLinkAdded'))
|
||||||
setDriveUrl('')
|
|
||||||
loadVersionData(selectedVersion.Id)
|
loadVersionData(selectedVersion.Id)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Add Drive link failed:', err)
|
console.error('Add Drive link failed:', err)
|
||||||
@@ -247,13 +179,9 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleDeleteAttachment = async (attId) => {
|
const handleDeleteAttachment = async (attId) => {
|
||||||
try {
|
await api.delete(`/artefact-attachments/${attId}`)
|
||||||
await api.delete(`/artefact-attachments/${attId}`)
|
toast.success(t('artefacts.attachmentDeleted'))
|
||||||
toast.success(t('artefacts.attachmentDeleted'))
|
loadVersionData(selectedVersion.Id)
|
||||||
loadVersionData(selectedVersion.Id)
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(t('artefacts.failedDeleteAttachment'))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmitReview = async () => {
|
const handleSubmitReview = async () => {
|
||||||
@@ -501,213 +429,22 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
|||||||
|
|
||||||
{/* Versions Tab */}
|
{/* Versions Tab */}
|
||||||
{activeTab === 'versions' && (
|
{activeTab === 'versions' && (
|
||||||
<div className="p-6 space-y-5">
|
<ArtefactDetailVersionsTab
|
||||||
{/* Version Timeline */}
|
artefact={artefact}
|
||||||
<div>
|
versions={versions}
|
||||||
<div className="flex items-center justify-between mb-3">
|
selectedVersion={selectedVersion}
|
||||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase">{t('artefacts.versions')}</h4>
|
versionData={versionData}
|
||||||
<button
|
uploading={uploading}
|
||||||
onClick={() => setShowNewVersionModal(true)}
|
uploadProgress={uploadProgress}
|
||||||
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors"
|
onSelectVersion={handleSelectVersion}
|
||||||
>
|
onCreateVersion={handleCreateVersion}
|
||||||
<Plus className="w-3 h-3" />
|
onAddLanguage={handleAddLanguage}
|
||||||
{t('artefacts.newVersion')}
|
onDeleteLanguage={handleDeleteLanguage}
|
||||||
</button>
|
onFileUpload={handleFileUpload}
|
||||||
</div>
|
onDeleteAttachment={handleDeleteAttachment}
|
||||||
<ArtefactVersionTimeline
|
onAddDriveVideo={handleAddDriveVideo}
|
||||||
versions={versions}
|
getDriveEmbedUrl={getDriveEmbedUrl}
|
||||||
activeVersionId={selectedVersion?.Id}
|
/>
|
||||||
onSelectVersion={handleSelectVersion}
|
|
||||||
artefactType={artefact.type}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Type-specific content */}
|
|
||||||
{versionData && selectedVersion && (
|
|
||||||
<div className="border-t border-border pt-5">
|
|
||||||
{/* COPY TYPE: Language entries */}
|
|
||||||
{artefact.type === 'copy' && (
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase">{t('artefacts.languages')}</h4>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowLanguageModal(true)}
|
|
||||||
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors"
|
|
||||||
>
|
|
||||||
<Plus className="w-3 h-3" />
|
|
||||||
{t('artefacts.addLanguage')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{versionData.texts && versionData.texts.length > 0 ? (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{versionData.texts.map(text => (
|
|
||||||
<div key={text.Id} className="bg-surface-secondary rounded-lg p-4 border border-border">
|
|
||||||
<div className="flex items-start justify-between mb-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="px-2 py-0.5 bg-surface border border-border rounded text-xs font-mono font-medium">
|
|
||||||
{text.language_code}
|
|
||||||
</span>
|
|
||||||
<span className="text-sm font-medium text-text-primary">{text.language_label}</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setConfirmDeleteLangId(text.Id)}
|
|
||||||
className="text-red-600 hover:text-red-700"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="bg-surface rounded border border-border p-3 text-sm text-text-primary whitespace-pre-wrap font-sans">
|
|
||||||
{text.content}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-8 bg-surface-secondary rounded-lg border-2 border-dashed border-border">
|
|
||||||
<Globe className="w-8 h-8 text-text-tertiary mx-auto mb-2" />
|
|
||||||
<p className="text-sm text-text-secondary">{t('artefacts.noLanguages')}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* DESIGN TYPE: Image gallery */}
|
|
||||||
{artefact.type === 'design' && (
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase">{t('artefacts.imagesLabel')}</h4>
|
|
||||||
<label className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors cursor-pointer">
|
|
||||||
<Upload className="w-3 h-3" />
|
|
||||||
{uploading ? t('artefacts.uploading') : t('artefacts.uploadImage')}
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
className="hidden"
|
|
||||||
accept="image/*"
|
|
||||||
onChange={handleFileUpload}
|
|
||||||
disabled={uploading}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{versionData.attachments && versionData.attachments.length > 0 ? (
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
{versionData.attachments.map(att => (
|
|
||||||
<div key={att.Id} className="relative group">
|
|
||||||
<img
|
|
||||||
src={att.url}
|
|
||||||
alt={att.original_name}
|
|
||||||
className="w-full h-48 object-cover rounded-lg border border-border"
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors rounded-lg flex items-center justify-center">
|
|
||||||
<button
|
|
||||||
onClick={() => setConfirmDeleteAttId(att.Id)}
|
|
||||||
className="opacity-0 group-hover:opacity-100 transition-opacity px-3 py-2 bg-red-600 text-white rounded-lg text-xs font-medium hover:bg-red-700"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 px-2 py-1 bg-surface-secondary rounded text-xs text-text-secondary truncate">
|
|
||||||
{att.original_name}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-8 bg-surface-secondary rounded-lg border-2 border-dashed border-border">
|
|
||||||
<ImageIcon className="w-8 h-8 text-text-tertiary mx-auto mb-2" />
|
|
||||||
<p className="text-sm text-text-secondary">{t('artefacts.noImages')}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* VIDEO TYPE: Files and Drive links — all inline */}
|
|
||||||
{artefact.type === 'video' && (
|
|
||||||
<div>
|
|
||||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-3">{t('artefacts.videosLabel')}</h4>
|
|
||||||
|
|
||||||
{/* Existing attachments */}
|
|
||||||
{versionData.attachments && versionData.attachments.length > 0 && (
|
|
||||||
<div className="space-y-3 mb-4">
|
|
||||||
{versionData.attachments.map(att => (
|
|
||||||
<div key={att.Id} className="bg-surface-secondary rounded-lg p-4 border border-border">
|
|
||||||
{att.drive_url ? (
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<span className="text-sm font-medium text-text-primary">{t('artefacts.googleDriveVideo')}</span>
|
|
||||||
<button onClick={() => setConfirmDeleteAttId(att.Id)} className="text-red-600 hover:text-red-700">
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<iframe src={getDriveEmbedUrl(att.drive_url)} className="w-full h-64 rounded border border-border" allow="autoplay" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<span className="text-sm font-medium text-text-primary truncate">{att.original_name}</span>
|
|
||||||
<button onClick={() => setConfirmDeleteAttId(att.Id)} className="text-red-600 hover:text-red-700">
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<video src={att.url} controls className="w-full rounded border border-border" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Drag-and-drop / click-to-upload zone */}
|
|
||||||
<label
|
|
||||||
className={`flex flex-col items-center gap-2 px-6 py-6 border-2 border-dashed rounded-lg cursor-pointer transition-colors ${
|
|
||||||
dragOver ? 'border-brand-primary bg-brand-primary/5' : 'border-border hover:border-brand-primary/30'
|
|
||||||
} ${uploading ? 'pointer-events-none opacity-60' : ''}`}
|
|
||||||
onDragOver={(e) => { e.preventDefault(); setDragOver(true) }}
|
|
||||||
onDragLeave={() => setDragOver(false)}
|
|
||||||
onDrop={handleVideoDrop}
|
|
||||||
>
|
|
||||||
{uploading ? (
|
|
||||||
<>
|
|
||||||
<div className="w-full max-w-xs bg-surface-tertiary rounded-full h-2 overflow-hidden">
|
|
||||||
<div className="bg-brand-primary h-full rounded-full transition-all duration-300" style={{ width: `${uploadProgress}%` }} />
|
|
||||||
</div>
|
|
||||||
<span className="text-sm text-text-secondary">{t('artefacts.uploading')} {uploadProgress}%</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Upload className="w-7 h-7 text-text-tertiary" />
|
|
||||||
<span className="text-sm font-medium text-text-primary">{t('artefacts.dropOrClickVideo')}</span>
|
|
||||||
<span className="text-xs text-text-tertiary">{t('artefacts.videoFormats')}</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<input type="file" className="hidden" accept="video/*" onChange={handleFileUpload} disabled={uploading} />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{/* Google Drive URL inline input */}
|
|
||||||
<div className="flex items-center gap-2 mt-3">
|
|
||||||
<Globe className="w-4 h-4 text-text-tertiary shrink-0" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={driveUrl}
|
|
||||||
onChange={e => setDriveUrl(e.target.value)}
|
|
||||||
placeholder="https://drive.google.com/file/d/..."
|
|
||||||
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
|
||||||
onKeyDown={e => { if (e.key === 'Enter' && driveUrl.trim()) handleAddDriveVideo() }}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={handleAddDriveVideo}
|
|
||||||
disabled={uploading || !driveUrl.trim()}
|
|
||||||
className="px-3 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 shrink-0"
|
|
||||||
>
|
|
||||||
{t('artefacts.addLink')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Discussion Tab */}
|
{/* Discussion Tab */}
|
||||||
@@ -836,125 +573,6 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
|||||||
)}
|
)}
|
||||||
</TabbedModal>
|
</TabbedModal>
|
||||||
|
|
||||||
{/* Language Modal */}
|
|
||||||
<Modal isOpen={showLanguageModal} onClose={() => setShowLanguageModal(false)} title={t('artefacts.addLanguage')} size="md">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('artefacts.languageLabel')} *</label>
|
|
||||||
<select
|
|
||||||
value={languageForm.language_code}
|
|
||||||
onChange={e => {
|
|
||||||
const lang = AVAILABLE_LANGUAGES.find(l => l.code === e.target.value)
|
|
||||||
if (lang) setLanguageForm(f => ({ ...f, language_code: lang.code, language_label: lang.label }))
|
|
||||||
else setLanguageForm(f => ({ ...f, language_code: '', language_label: '' }))
|
|
||||||
}}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
|
||||||
>
|
|
||||||
<option value="">{t('artefacts.selectLanguage')}</option>
|
|
||||||
{AVAILABLE_LANGUAGES
|
|
||||||
.filter(lang => !(versionData?.texts || []).some(t => t.language_code === lang.code))
|
|
||||||
.map(lang => (
|
|
||||||
<option key={lang.code} value={lang.code}>{lang.label} ({lang.code})</option>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('artefacts.contentLabel')} *</label>
|
|
||||||
<textarea
|
|
||||||
value={languageForm.content}
|
|
||||||
onChange={e => setLanguageForm(f => ({ ...f, content: e.target.value }))}
|
|
||||||
rows={8}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 font-sans"
|
|
||||||
placeholder={t('artefacts.enterContent')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
|
|
||||||
<button
|
|
||||||
onClick={() => setShowLanguageModal(false)}
|
|
||||||
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
|
|
||||||
>
|
|
||||||
{t('common.cancel')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleAddLanguage}
|
|
||||||
disabled={savingLanguage}
|
|
||||||
className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 shadow-sm"
|
|
||||||
>
|
|
||||||
{savingLanguage ? t('header.saving') : t('common.save')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
{/* New Version Modal */}
|
|
||||||
<Modal isOpen={showNewVersionModal} onClose={() => setShowNewVersionModal(false)} title={t('artefacts.createNewVersion')} size="sm">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('artefacts.versionNotes')}</label>
|
|
||||||
<textarea
|
|
||||||
value={newVersionNotes}
|
|
||||||
onChange={e => setNewVersionNotes(e.target.value)}
|
|
||||||
rows={3}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
|
||||||
placeholder={t('artefacts.whatChanged')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{artefact.type === 'copy' && versions.length > 0 && (
|
|
||||||
<label className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={copyFromPrevious}
|
|
||||||
onChange={e => setCopyFromPrevious(e.target.checked)}
|
|
||||||
className="w-4 h-4 text-brand-primary border-border rounded focus:ring-brand-primary"
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-text-secondary">{t('artefacts.copyLanguages')}</span>
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
|
|
||||||
<button
|
|
||||||
onClick={() => setShowNewVersionModal(false)}
|
|
||||||
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
|
|
||||||
>
|
|
||||||
{t('common.cancel')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleCreateVersion}
|
|
||||||
disabled={creatingVersion}
|
|
||||||
className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 shadow-sm"
|
|
||||||
>
|
|
||||||
{creatingVersion ? t('artefacts.creatingVersion') : t('artefacts.createVersion')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
{/* Delete Language Confirmation */}
|
|
||||||
<Modal
|
|
||||||
isOpen={!!confirmDeleteLangId}
|
|
||||||
onClose={() => setConfirmDeleteLangId(null)}
|
|
||||||
title={t('artefacts.deleteLanguage')}
|
|
||||||
isConfirm
|
|
||||||
danger
|
|
||||||
onConfirm={() => handleDeleteLanguage(confirmDeleteLangId)}
|
|
||||||
confirmText={t('common.delete')}
|
|
||||||
>
|
|
||||||
{t('artefacts.deleteLanguageDesc')}
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
{/* Delete Attachment Confirmation */}
|
|
||||||
<Modal
|
|
||||||
isOpen={!!confirmDeleteAttId}
|
|
||||||
onClose={() => setConfirmDeleteAttId(null)}
|
|
||||||
title={t('artefacts.deleteAttachment')}
|
|
||||||
isConfirm
|
|
||||||
danger
|
|
||||||
onConfirm={() => handleDeleteAttachment(confirmDeleteAttId)}
|
|
||||||
confirmText={t('common.delete')}
|
|
||||||
>
|
|
||||||
{t('artefacts.deleteAttachmentDesc')}
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
{/* Delete Artefact Confirmation */}
|
{/* Delete Artefact Confirmation */}
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={showDeleteArtefactConfirm}
|
isOpen={showDeleteArtefactConfirm}
|
||||||
|
|||||||
@@ -0,0 +1,429 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Plus, Upload, Trash2, Globe, Image as ImageIcon } from 'lucide-react'
|
||||||
|
import { useLanguage } from '../i18n/LanguageContext'
|
||||||
|
import Modal from './Modal'
|
||||||
|
import ArtefactVersionTimeline from './ArtefactVersionTimeline'
|
||||||
|
|
||||||
|
const AVAILABLE_LANGUAGES = [
|
||||||
|
{ code: 'AR', label: '\u0627\u0644\u0639\u0631\u0628\u064A\u0629' },
|
||||||
|
{ code: 'EN', label: 'English' },
|
||||||
|
{ code: 'FR', label: 'Fran\u00E7ais' },
|
||||||
|
{ code: 'ID', label: 'Bahasa Indonesia' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function ArtefactDetailVersionsTab({
|
||||||
|
artefact,
|
||||||
|
versions,
|
||||||
|
selectedVersion,
|
||||||
|
versionData,
|
||||||
|
uploading,
|
||||||
|
uploadProgress,
|
||||||
|
onSelectVersion,
|
||||||
|
onCreateVersion,
|
||||||
|
onAddLanguage,
|
||||||
|
onDeleteLanguage,
|
||||||
|
onFileUpload,
|
||||||
|
onDeleteAttachment,
|
||||||
|
onAddDriveVideo,
|
||||||
|
getDriveEmbedUrl,
|
||||||
|
}) {
|
||||||
|
const { t } = useLanguage()
|
||||||
|
|
||||||
|
const [showNewVersionModal, setShowNewVersionModal] = useState(false)
|
||||||
|
const [newVersionNotes, setNewVersionNotes] = useState('')
|
||||||
|
const [copyFromPrevious, setCopyFromPrevious] = useState(false)
|
||||||
|
const [creatingVersion, setCreatingVersion] = useState(false)
|
||||||
|
|
||||||
|
const [showLanguageModal, setShowLanguageModal] = useState(false)
|
||||||
|
const [languageForm, setLanguageForm] = useState({ language_code: '', language_label: '', content: '' })
|
||||||
|
const [savingLanguage, setSavingLanguage] = useState(false)
|
||||||
|
|
||||||
|
const [confirmDeleteLangId, setConfirmDeleteLangId] = useState(null)
|
||||||
|
const [confirmDeleteAttId, setConfirmDeleteAttId] = useState(null)
|
||||||
|
|
||||||
|
const [dragOver, setDragOver] = useState(false)
|
||||||
|
const [driveUrl, setDriveUrl] = useState('')
|
||||||
|
|
||||||
|
const handleCreateVersion = async () => {
|
||||||
|
setCreatingVersion(true)
|
||||||
|
try {
|
||||||
|
await onCreateVersion({
|
||||||
|
notes: newVersionNotes || `Version ${(versions[versions.length - 1]?.version_number || 0) + 1}`,
|
||||||
|
copy_from_previous: artefact.type === 'copy' ? copyFromPrevious : false,
|
||||||
|
})
|
||||||
|
setShowNewVersionModal(false)
|
||||||
|
setNewVersionNotes('')
|
||||||
|
setCopyFromPrevious(false)
|
||||||
|
} finally {
|
||||||
|
setCreatingVersion(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddLanguage = async () => {
|
||||||
|
if (!languageForm.language_code || !languageForm.language_label || !languageForm.content) return
|
||||||
|
setSavingLanguage(true)
|
||||||
|
try {
|
||||||
|
await onAddLanguage(languageForm)
|
||||||
|
setShowLanguageModal(false)
|
||||||
|
setLanguageForm({ language_code: '', language_label: '', content: '' })
|
||||||
|
} finally {
|
||||||
|
setSavingLanguage(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteLanguage = async (textId) => {
|
||||||
|
await onDeleteLanguage(textId)
|
||||||
|
setConfirmDeleteLangId(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteAttachment = async (attId) => {
|
||||||
|
await onDeleteAttachment(attId)
|
||||||
|
setConfirmDeleteAttId(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleVideoDrop = (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setDragOver(false)
|
||||||
|
const file = e.dataTransfer.files?.[0]
|
||||||
|
if (file && file.type.startsWith('video/')) {
|
||||||
|
onFileUpload(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddDriveVideo = async () => {
|
||||||
|
if (!driveUrl.trim()) return
|
||||||
|
await onAddDriveVideo(driveUrl)
|
||||||
|
setDriveUrl('')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="p-6 space-y-5">
|
||||||
|
{/* Version Timeline */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h4 className="text-xs font-semibold text-text-tertiary uppercase">{t('artefacts.versions')}</h4>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowNewVersionModal(true)}
|
||||||
|
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="w-3 h-3" />
|
||||||
|
{t('artefacts.newVersion')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<ArtefactVersionTimeline
|
||||||
|
versions={versions}
|
||||||
|
activeVersionId={selectedVersion?.Id}
|
||||||
|
onSelectVersion={onSelectVersion}
|
||||||
|
artefactType={artefact.type}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Type-specific content */}
|
||||||
|
{versionData && selectedVersion && (
|
||||||
|
<div className="border-t border-border pt-5">
|
||||||
|
{/* COPY TYPE: Language entries */}
|
||||||
|
{artefact.type === 'copy' && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h4 className="text-xs font-semibold text-text-tertiary uppercase">{t('artefacts.languages')}</h4>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowLanguageModal(true)}
|
||||||
|
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="w-3 h-3" />
|
||||||
|
{t('artefacts.addLanguage')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{versionData.texts && versionData.texts.length > 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{versionData.texts.map(text => (
|
||||||
|
<div key={text.Id} className="bg-surface-secondary rounded-lg p-4 border border-border">
|
||||||
|
<div className="flex items-start justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="px-2 py-0.5 bg-surface border border-border rounded text-xs font-mono font-medium">
|
||||||
|
{text.language_code}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-medium text-text-primary">{text.language_label}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmDeleteLangId(text.Id)}
|
||||||
|
className="text-red-600 hover:text-red-700"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="bg-surface rounded border border-border p-3 text-sm text-text-primary whitespace-pre-wrap font-sans">
|
||||||
|
{text.content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 bg-surface-secondary rounded-lg border-2 border-dashed border-border">
|
||||||
|
<Globe className="w-8 h-8 text-text-tertiary mx-auto mb-2" />
|
||||||
|
<p className="text-sm text-text-secondary">{t('artefacts.noLanguages')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* DESIGN TYPE: Image gallery */}
|
||||||
|
{artefact.type === 'design' && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h4 className="text-xs font-semibold text-text-tertiary uppercase">{t('artefacts.imagesLabel')}</h4>
|
||||||
|
<label className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors cursor-pointer">
|
||||||
|
<Upload className="w-3 h-3" />
|
||||||
|
{uploading ? t('artefacts.uploading') : t('artefacts.uploadImage')}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
className="hidden"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={onFileUpload}
|
||||||
|
disabled={uploading}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{versionData.attachments && versionData.attachments.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{versionData.attachments.map(att => (
|
||||||
|
<div key={att.Id} className="relative group">
|
||||||
|
<img
|
||||||
|
src={att.url}
|
||||||
|
alt={att.original_name}
|
||||||
|
className="w-full h-48 object-cover rounded-lg border border-border"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors rounded-lg flex items-center justify-center">
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmDeleteAttId(att.Id)}
|
||||||
|
className="opacity-0 group-hover:opacity-100 transition-opacity px-3 py-2 bg-red-600 text-white rounded-lg text-xs font-medium hover:bg-red-700"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 px-2 py-1 bg-surface-secondary rounded text-xs text-text-secondary truncate">
|
||||||
|
{att.original_name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 bg-surface-secondary rounded-lg border-2 border-dashed border-border">
|
||||||
|
<ImageIcon className="w-8 h-8 text-text-tertiary mx-auto mb-2" />
|
||||||
|
<p className="text-sm text-text-secondary">{t('artefacts.noImages')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* VIDEO TYPE: Files and Drive links -- all inline */}
|
||||||
|
{artefact.type === 'video' && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-3">{t('artefacts.videosLabel')}</h4>
|
||||||
|
|
||||||
|
{/* Existing attachments */}
|
||||||
|
{versionData.attachments && versionData.attachments.length > 0 && (
|
||||||
|
<div className="space-y-3 mb-4">
|
||||||
|
{versionData.attachments.map(att => (
|
||||||
|
<div key={att.Id} className="bg-surface-secondary rounded-lg p-4 border border-border">
|
||||||
|
{att.drive_url ? (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-sm font-medium text-text-primary">{t('artefacts.googleDriveVideo')}</span>
|
||||||
|
<button onClick={() => setConfirmDeleteAttId(att.Id)} className="text-red-600 hover:text-red-700">
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<iframe src={getDriveEmbedUrl(att.drive_url)} className="w-full h-64 rounded border border-border" allow="autoplay" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-sm font-medium text-text-primary truncate">{att.original_name}</span>
|
||||||
|
<button onClick={() => setConfirmDeleteAttId(att.Id)} className="text-red-600 hover:text-red-700">
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<video src={att.url} controls className="w-full rounded border border-border" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Drag-and-drop / click-to-upload zone */}
|
||||||
|
<label
|
||||||
|
className={`flex flex-col items-center gap-2 px-6 py-6 border-2 border-dashed rounded-lg cursor-pointer transition-colors ${
|
||||||
|
dragOver ? 'border-brand-primary bg-brand-primary/5' : 'border-border hover:border-brand-primary/30'
|
||||||
|
} ${uploading ? 'pointer-events-none opacity-60' : ''}`}
|
||||||
|
onDragOver={(e) => { e.preventDefault(); setDragOver(true) }}
|
||||||
|
onDragLeave={() => setDragOver(false)}
|
||||||
|
onDrop={handleVideoDrop}
|
||||||
|
>
|
||||||
|
{uploading ? (
|
||||||
|
<>
|
||||||
|
<div className="w-full max-w-xs bg-surface-tertiary rounded-full h-2 overflow-hidden">
|
||||||
|
<div className="bg-brand-primary h-full rounded-full transition-all duration-300" style={{ width: `${uploadProgress}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-text-secondary">{t('artefacts.uploading')} {uploadProgress}%</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Upload className="w-7 h-7 text-text-tertiary" />
|
||||||
|
<span className="text-sm font-medium text-text-primary">{t('artefacts.dropOrClickVideo')}</span>
|
||||||
|
<span className="text-xs text-text-tertiary">{t('artefacts.videoFormats')}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<input type="file" className="hidden" accept="video/*" onChange={onFileUpload} disabled={uploading} />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* Google Drive URL inline input */}
|
||||||
|
<div className="flex items-center gap-2 mt-3">
|
||||||
|
<Globe className="w-4 h-4 text-text-tertiary shrink-0" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={driveUrl}
|
||||||
|
onChange={e => setDriveUrl(e.target.value)}
|
||||||
|
placeholder="https://drive.google.com/file/d/..."
|
||||||
|
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter' && driveUrl.trim()) handleAddDriveVideo() }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleAddDriveVideo}
|
||||||
|
disabled={uploading || !driveUrl.trim()}
|
||||||
|
className="px-3 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 shrink-0"
|
||||||
|
>
|
||||||
|
{t('artefacts.addLink')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Language Modal */}
|
||||||
|
<Modal isOpen={showLanguageModal} onClose={() => setShowLanguageModal(false)} title={t('artefacts.addLanguage')} size="md">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text-primary mb-1">{t('artefacts.languageLabel')} *</label>
|
||||||
|
<select
|
||||||
|
value={languageForm.language_code}
|
||||||
|
onChange={e => {
|
||||||
|
const lang = AVAILABLE_LANGUAGES.find(l => l.code === e.target.value)
|
||||||
|
if (lang) setLanguageForm(f => ({ ...f, language_code: lang.code, language_label: lang.label }))
|
||||||
|
else setLanguageForm(f => ({ ...f, language_code: '', language_label: '' }))
|
||||||
|
}}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||||
|
>
|
||||||
|
<option value="">{t('artefacts.selectLanguage')}</option>
|
||||||
|
{AVAILABLE_LANGUAGES
|
||||||
|
.filter(lang => !(versionData?.texts || []).some(t => t.language_code === lang.code))
|
||||||
|
.map(lang => (
|
||||||
|
<option key={lang.code} value={lang.code}>{lang.label} ({lang.code})</option>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text-primary mb-1">{t('artefacts.contentLabel')} *</label>
|
||||||
|
<textarea
|
||||||
|
value={languageForm.content}
|
||||||
|
onChange={e => setLanguageForm(f => ({ ...f, content: e.target.value }))}
|
||||||
|
rows={8}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 font-sans"
|
||||||
|
placeholder={t('artefacts.enterContent')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowLanguageModal(false)}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
|
||||||
|
>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleAddLanguage}
|
||||||
|
disabled={savingLanguage}
|
||||||
|
className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 shadow-sm"
|
||||||
|
>
|
||||||
|
{savingLanguage ? t('header.saving') : t('common.save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* New Version Modal */}
|
||||||
|
<Modal isOpen={showNewVersionModal} onClose={() => setShowNewVersionModal(false)} title={t('artefacts.createNewVersion')} size="sm">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text-primary mb-1">{t('artefacts.versionNotes')}</label>
|
||||||
|
<textarea
|
||||||
|
value={newVersionNotes}
|
||||||
|
onChange={e => setNewVersionNotes(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||||
|
placeholder={t('artefacts.whatChanged')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{artefact.type === 'copy' && versions.length > 0 && (
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={copyFromPrevious}
|
||||||
|
onChange={e => setCopyFromPrevious(e.target.checked)}
|
||||||
|
className="w-4 h-4 text-brand-primary border-border rounded focus:ring-brand-primary"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-text-secondary">{t('artefacts.copyLanguages')}</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowNewVersionModal(false)}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
|
||||||
|
>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleCreateVersion}
|
||||||
|
disabled={creatingVersion}
|
||||||
|
className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 shadow-sm"
|
||||||
|
>
|
||||||
|
{creatingVersion ? t('artefacts.creatingVersion') : t('artefacts.createVersion')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Delete Language Confirmation */}
|
||||||
|
<Modal
|
||||||
|
isOpen={!!confirmDeleteLangId}
|
||||||
|
onClose={() => setConfirmDeleteLangId(null)}
|
||||||
|
title={t('artefacts.deleteLanguage')}
|
||||||
|
isConfirm
|
||||||
|
danger
|
||||||
|
onConfirm={() => handleDeleteLanguage(confirmDeleteLangId)}
|
||||||
|
confirmText={t('common.delete')}
|
||||||
|
>
|
||||||
|
{t('artefacts.deleteLanguageDesc')}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Delete Attachment Confirmation */}
|
||||||
|
<Modal
|
||||||
|
isOpen={!!confirmDeleteAttId}
|
||||||
|
onClose={() => setConfirmDeleteAttId(null)}
|
||||||
|
title={t('artefacts.deleteAttachment')}
|
||||||
|
isConfirm
|
||||||
|
danger
|
||||||
|
onConfirm={() => handleDeleteAttachment(confirmDeleteAttId)}
|
||||||
|
confirmText={t('common.delete')}
|
||||||
|
>
|
||||||
|
{t('artefacts.deleteAttachmentDesc')}
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -85,6 +85,7 @@ export default function ArtefactVersionTimeline({ versions, activeVersionId, onS
|
|||||||
src={version.thumbnail}
|
src={version.thumbnail}
|
||||||
alt={`Version ${version.version_number}`}
|
alt={`Version ${version.version_number}`}
|
||||||
className="w-full h-20 object-cover rounded border border-border"
|
className="w-full h-20 object-cover rounded border border-border"
|
||||||
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export default function AssetCard({ asset, onClick }) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={() => onClick?.(asset)}
|
onClick={() => onClick?.(asset)}
|
||||||
className="bg-white rounded-xl border border-border overflow-hidden card-hover cursor-pointer group"
|
className="bg-surface rounded-xl border border-border overflow-hidden card-hover cursor-pointer group"
|
||||||
>
|
>
|
||||||
{/* Thumbnail */}
|
{/* Thumbnail */}
|
||||||
<div className="aspect-square bg-surface-tertiary flex items-center justify-center overflow-hidden relative">
|
<div className="aspect-square bg-surface-tertiary flex items-center justify-center overflow-hidden relative">
|
||||||
@@ -33,6 +33,7 @@ export default function AssetCard({ asset, onClick }) {
|
|||||||
src={asset.url}
|
src={asset.url}
|
||||||
alt={asset.name}
|
alt={asset.name}
|
||||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||||
|
loading="lazy"
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
e.target.style.display = 'none'
|
e.target.style.display = 'none'
|
||||||
e.target.nextSibling.style.display = 'flex'
|
e.target.nextSibling.style.display = 'flex'
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export default function CampaignCalendar({ campaigns = [] }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
<div className="bg-surface rounded-xl border border-border overflow-hidden">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||||
<h3 className="text-lg font-semibold text-text-primary">
|
<h3 className="text-lg font-semibold text-text-primary">
|
||||||
@@ -109,8 +109,8 @@ export default function CampaignCalendar({ campaigns = [] }) {
|
|||||||
<div
|
<div
|
||||||
key={campaign._id || ci}
|
key={campaign._id || ci}
|
||||||
className={`h-5 text-[10px] font-medium text-white flex items-center px-1 truncate ${CAMPAIGN_COLORS[colorIndex]} ${
|
className={`h-5 text-[10px] font-medium text-white flex items-center px-1 truncate ${CAMPAIGN_COLORS[colorIndex]} ${
|
||||||
isStart ? 'rounded-l-full ml-0' : '-ml-1'
|
isStart ? 'rounded-l-full ms-0' : '-ms-1'
|
||||||
} ${isEnd ? 'rounded-r-full mr-0' : '-mr-1'}`}
|
} ${isEnd ? 'rounded-r-full me-0' : '-me-1'}`}
|
||||||
title={campaign.name}
|
title={campaign.name}
|
||||||
>
|
>
|
||||||
{isStart ? campaign.name : ''}
|
{isStart ? campaign.name : ''}
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ export default function CampaignDetailPanel({ campaign, onClose, onSave, onDelet
|
|||||||
form.status === 'paused' ? 'bg-amber-100 text-amber-700' :
|
form.status === 'paused' ? 'bg-amber-100 text-amber-700' :
|
||||||
form.status === 'completed' ? 'bg-blue-100 text-blue-700' :
|
form.status === 'completed' ? 'bg-blue-100 text-blue-700' :
|
||||||
form.status === 'cancelled' ? 'bg-red-100 text-red-700' :
|
form.status === 'cancelled' ? 'bg-red-100 text-red-700' :
|
||||||
'bg-gray-100 text-gray-600'
|
'bg-gray-100 text-text-secondary'
|
||||||
}`}>
|
}`}>
|
||||||
{statusOptions.find(s => s.value === form.status)?.label}
|
{statusOptions.find(s => s.value === form.status)?.label}
|
||||||
</span>
|
</span>
|
||||||
@@ -226,7 +226,7 @@ export default function CampaignDetailPanel({ campaign, onClose, onSave, onDelet
|
|||||||
{/* Platforms */}
|
{/* Platforms */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.platforms')}</label>
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.platforms')}</label>
|
||||||
<div className="flex flex-wrap gap-2 p-2 border border-border rounded-lg bg-white min-h-[38px]">
|
<div className="flex flex-wrap gap-2 p-2 border border-border rounded-lg bg-surface min-h-[38px]">
|
||||||
{Object.entries(PLATFORMS).map(([k, v]) => {
|
{Object.entries(PLATFORMS).map(([k, v]) => {
|
||||||
const checked = (form.platforms || []).includes(k)
|
const checked = (form.platforms || []).includes(k)
|
||||||
return (
|
return (
|
||||||
@@ -281,7 +281,7 @@ export default function CampaignDetailPanel({ campaign, onClose, onSave, onDelet
|
|||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-text-tertiary mb-1">
|
<label className="block text-xs font-medium text-text-tertiary mb-1">
|
||||||
{t('campaigns.budget')} ({currencySymbol})
|
{t('campaigns.budget')} ({currencySymbol})
|
||||||
{!permissions?.canSetBudget && <span className="text-[10px] text-text-tertiary ml-1">(Superadmin only)</span>}
|
{!permissions?.canSetBudget && <span className="text-[10px] text-text-tertiary ms-1">(Superadmin only)</span>}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ export default function CommentsSection({ entityType, entityId }) {
|
|||||||
<div key={c.id} className="flex items-start gap-2 group">
|
<div key={c.id} className="flex items-start gap-2 group">
|
||||||
<div className="w-7 h-7 rounded-full bg-brand-primary/10 text-brand-primary flex items-center justify-center text-[10px] font-bold shrink-0 mt-0.5">
|
<div className="w-7 h-7 rounded-full bg-brand-primary/10 text-brand-primary flex items-center justify-center text-[10px] font-bold shrink-0 mt-0.5">
|
||||||
{c.user_avatar ? (
|
{c.user_avatar ? (
|
||||||
<img src={c.user_avatar} className="w-full h-full rounded-full object-cover" alt="" />
|
<img src={c.user_avatar} className="w-full h-full rounded-full object-cover" alt="" loading="lazy" />
|
||||||
) : (
|
) : (
|
||||||
getInitials(c.user_name)
|
getInitials(c.user_name)
|
||||||
)}
|
)}
|
||||||
@@ -125,7 +125,7 @@ export default function CommentsSection({ entityType, entityId }) {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs font-medium text-text-primary">{c.user_name}</span>
|
<span className="text-xs font-medium text-text-primary">{c.user_name}</span>
|
||||||
<span className="text-[10px] text-text-tertiary">{relativeTime(c.created_at, t)}</span>
|
<span className="text-[10px] text-text-tertiary">{relativeTime(c.created_at, t)}</span>
|
||||||
<div className="flex items-center gap-0.5 ml-auto opacity-0 group-hover:opacity-100 transition-opacity">
|
<div className="flex items-center gap-0.5 ms-auto opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
{canEdit(c) && editingId !== c.id && (
|
{canEdit(c) && editingId !== c.id && (
|
||||||
<button
|
<button
|
||||||
onClick={() => startEdit(c)}
|
onClick={() => startEdit(c)}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export default function DatePresetPicker({ onSelect, activePreset, onClear }) {
|
|||||||
className={`px-2.5 py-1 text-[11px] font-medium rounded-full border transition-colors ${
|
className={`px-2.5 py-1 text-[11px] font-medium rounded-full border transition-colors ${
|
||||||
activePreset === preset.key
|
activePreset === preset.key
|
||||||
? 'bg-brand-primary/10 border-brand-primary/30 text-brand-primary'
|
? 'bg-brand-primary/10 border-brand-primary/30 text-brand-primary'
|
||||||
: 'bg-white border-border text-text-tertiary hover:text-text-primary hover:border-border-dark'
|
: 'bg-surface border-border text-text-tertiary hover:text-text-primary hover:border-border-dark'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{t(preset.labelKey)}
|
{t(preset.labelKey)}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export default function EmptyState({
|
|||||||
{actionLabel && (
|
{actionLabel && (
|
||||||
<button
|
<button
|
||||||
onClick={onAction}
|
onClick={onAction}
|
||||||
className="mt-3 text-sm text-brand-primary hover:text-brand-primary-light font-medium"
|
className="mt-3 text-sm text-brand-primary hover:text-brand-primary-light font-medium transition-colors"
|
||||||
>
|
>
|
||||||
{actionLabel}
|
{actionLabel}
|
||||||
</button>
|
</button>
|
||||||
@@ -44,7 +44,7 @@ export default function EmptyState({
|
|||||||
{actionLabel && (
|
{actionLabel && (
|
||||||
<button
|
<button
|
||||||
onClick={onAction}
|
onClick={onAction}
|
||||||
className="px-5 py-2.5 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm transition-all hover:-translate-y-0.5"
|
className="px-5 py-2.5 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light transition-colors"
|
||||||
>
|
>
|
||||||
{actionLabel}
|
{actionLabel}
|
||||||
</button>
|
</button>
|
||||||
@@ -52,7 +52,7 @@ export default function EmptyState({
|
|||||||
{secondaryActionLabel && (
|
{secondaryActionLabel && (
|
||||||
<button
|
<button
|
||||||
onClick={onSecondaryAction}
|
onClick={onSecondaryAction}
|
||||||
className="px-5 py-2.5 bg-white border border-border text-text-primary rounded-lg text-sm font-medium hover:bg-surface-secondary transition-colors"
|
className="px-5 py-2.5 bg-surface border border-border text-text-primary rounded-lg text-sm font-medium hover:bg-surface-secondary transition-colors"
|
||||||
>
|
>
|
||||||
{secondaryActionLabel}
|
{secondaryActionLabel}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export default function FormInput({
|
|||||||
? 'border-emerald-300 focus:border-emerald-500 focus:ring-emerald-500/20'
|
? 'border-emerald-300 focus:border-emerald-500 focus:ring-emerald-500/20'
|
||||||
: 'border-border focus:border-brand-primary focus:ring-brand-primary/20'
|
: 'border-border focus:border-brand-primary focus:ring-brand-primary/20'
|
||||||
}
|
}
|
||||||
${disabled ? 'bg-surface-tertiary cursor-not-allowed opacity-60' : 'bg-white'}
|
${disabled ? 'bg-surface-tertiary cursor-not-allowed opacity-60' : 'bg-surface'}
|
||||||
${className}
|
${className}
|
||||||
`.trim()
|
`.trim()
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ export default function FormInput({
|
|||||||
{label && (
|
{label && (
|
||||||
<label className="block text-sm font-medium text-text-primary">
|
<label className="block text-sm font-medium text-text-primary">
|
||||||
{label}
|
{label}
|
||||||
{required && <span className="text-red-500 ml-0.5">*</span>}
|
{required && <span className="text-red-500 ms-0.5">*</span>}
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ export default function FormInput({
|
|||||||
|
|
||||||
{/* Validation icon */}
|
{/* Validation icon */}
|
||||||
{(hasError || hasSuccess) && (
|
{(hasError || hasSuccess) && (
|
||||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none">
|
<div className="absolute end-3 top-1/2 -translate-y-1/2 pointer-events-none">
|
||||||
{hasError ? (
|
{hasError ? (
|
||||||
<AlertCircle className="w-4 h-4 text-red-500" />
|
<AlertCircle className="w-4 h-4 text-red-500" />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const PAGE_TITLE_KEYS = {
|
|||||||
'/issues': 'header.issues',
|
'/issues': 'header.issues',
|
||||||
'/team': 'header.team',
|
'/team': 'header.team',
|
||||||
'/settings': 'header.settings',
|
'/settings': 'header.settings',
|
||||||
|
'/translations': 'header.translations',
|
||||||
}
|
}
|
||||||
|
|
||||||
const ROLE_INFO = {
|
const ROLE_INFO = {
|
||||||
@@ -99,7 +100,7 @@ export default function Header() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<header className="h-16 bg-white border-b border-border flex items-center justify-between px-6 shrink-0 sticky top-0 z-20">
|
<header className="h-16 bg-surface border-b border-border flex items-center justify-between px-6 shrink-0 sticky top-0 z-20">
|
||||||
{/* Page title */}
|
{/* Page title */}
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-bold text-text-primary">{pageTitle}</h2>
|
<h2 className="text-xl font-bold text-text-primary">{pageTitle}</h2>
|
||||||
@@ -118,8 +119,8 @@ export default function Header() {
|
|||||||
>
|
>
|
||||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-semibold ${
|
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-semibold ${
|
||||||
user?.role === 'superadmin'
|
user?.role === 'superadmin'
|
||||||
? 'bg-gradient-to-br from-purple-500 to-pink-500'
|
? 'bg-brand-primary'
|
||||||
: 'bg-gradient-to-br from-blue-500 to-indigo-500'
|
: 'bg-teal-700'
|
||||||
}`}>
|
}`}>
|
||||||
{getInitials(user?.name)}
|
{getInitials(user?.name)}
|
||||||
</div>
|
</div>
|
||||||
@@ -135,7 +136,7 @@ export default function Header() {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{showDropdown && (
|
{showDropdown && (
|
||||||
<div className="absolute end-0 top-full mt-2 w-64 bg-white rounded-xl shadow-lg border border-border overflow-hidden animate-scale-in">
|
<div className="absolute end-0 top-full mt-2 w-64 bg-surface rounded-xl shadow-lg border border-border overflow-hidden animate-scale-in" role="menu">
|
||||||
{/* User info */}
|
{/* User info */}
|
||||||
<div className="px-4 py-3 border-b border-border-light bg-surface-secondary">
|
<div className="px-4 py-3 border-b border-border-light bg-surface-secondary">
|
||||||
<p className="text-sm font-semibold text-text-primary">{user?.name}</p>
|
<p className="text-sm font-semibold text-text-primary">{user?.name}</p>
|
||||||
@@ -174,7 +175,7 @@ export default function Header() {
|
|||||||
setShowDropdown(false)
|
setShowDropdown(false)
|
||||||
logout()
|
logout()
|
||||||
}}
|
}}
|
||||||
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-red-50 transition-colors text-left group"
|
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-red-50 transition-colors text-start group"
|
||||||
>
|
>
|
||||||
<LogOut className="w-4 h-4 text-text-tertiary group-hover:text-red-500" />
|
<LogOut className="w-4 h-4 text-text-tertiary group-hover:text-red-500" />
|
||||||
<span className="text-sm text-text-primary group-hover:text-red-500">{t('header.signOut')}</span>
|
<span className="text-sm text-text-primary group-hover:text-red-500">{t('header.signOut')}</span>
|
||||||
@@ -197,6 +198,7 @@ export default function Header() {
|
|||||||
onChange={e => { setPasswordForm(f => ({ ...f, currentPassword: e.target.value })); setPasswordError('') }}
|
onChange={e => { setPasswordForm(f => ({ ...f, currentPassword: e.target.value })); setPasswordError('') }}
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
|
aria-describedby={passwordError ? 'password-error' : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -208,6 +210,7 @@ export default function Header() {
|
|||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
minLength={6}
|
minLength={6}
|
||||||
|
aria-describedby={passwordError ? 'password-error' : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -219,11 +222,12 @@ export default function Header() {
|
|||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
minLength={6}
|
minLength={6}
|
||||||
|
aria-describedby={passwordError ? 'password-error' : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{passwordError && (
|
{passwordError && (
|
||||||
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
|
<div id="password-error" className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg" role="alert">
|
||||||
<AlertCircle className="w-4 h-4 text-red-500 shrink-0" />
|
<AlertCircle className="w-4 h-4 text-red-500 shrink-0" />
|
||||||
<p className="text-sm text-red-500">{passwordError}</p>
|
<p className="text-sm text-red-500">{passwordError}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -237,7 +237,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
|
|||||||
|
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-xl border border-border py-16 text-center">
|
<div className="bg-surface rounded-xl border border-border py-16 text-center">
|
||||||
<Calendar className="w-12 h-12 text-text-tertiary mx-auto mb-3" />
|
<Calendar className="w-12 h-12 text-text-tertiary mx-auto mb-3" />
|
||||||
<p className="text-text-secondary font-medium">{t('timeline.noItems')}</p>
|
<p className="text-text-secondary font-medium">{t('timeline.noItems')}</p>
|
||||||
<p className="text-sm text-text-tertiary mt-1">{t('timeline.addItems')}</p>
|
<p className="text-sm text-text-tertiary mt-1">{t('timeline.addItems')}</p>
|
||||||
@@ -246,7 +246,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
<div className="bg-surface rounded-xl border border-border overflow-hidden">
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div className="flex items-center justify-between px-4 py-2 border-b border-border bg-surface-secondary">
|
<div className="flex items-center justify-between px-4 py-2 border-b border-border bg-surface-secondary">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -287,8 +287,8 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
|
|||||||
<div ref={containerRef} dir="ltr" className="overflow-x-auto relative" style={{ cursor: dragState ? 'grabbing' : undefined }}>
|
<div ref={containerRef} dir="ltr" className="overflow-x-auto relative" style={{ cursor: dragState ? 'grabbing' : undefined }}>
|
||||||
<div style={{ minWidth: `${labelWidth + totalDays * pxPerDay}px` }}>
|
<div style={{ minWidth: `${labelWidth + totalDays * pxPerDay}px` }}>
|
||||||
{/* Day header */}
|
{/* Day header */}
|
||||||
<div className="flex sticky top-0 z-20 bg-white border-b border-border" style={{ height: headerHeight }}>
|
<div className="flex sticky top-0 z-20 bg-surface border-b border-border" style={{ height: headerHeight }}>
|
||||||
<div className="shrink-0 border-e border-border bg-surface-secondary flex items-center px-4 sticky left-0 z-30" style={{ width: labelWidth }}>
|
<div className="shrink-0 border-e border-border bg-surface-secondary flex items-center px-4 sticky start-0 z-30" style={{ width: labelWidth }}>
|
||||||
<span className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('timeline.item')}</span>
|
<span className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('timeline.item')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex relative">
|
<div className="flex relative">
|
||||||
@@ -338,7 +338,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
|
|||||||
>
|
>
|
||||||
{/* Label column */}
|
{/* Label column */}
|
||||||
<div
|
<div
|
||||||
className={`shrink-0 border-e border-border flex ${isExpanded ? 'flex-col justify-center gap-1' : 'items-center gap-2'} px-3 overflow-hidden sticky left-0 z-10 bg-white group-hover/row:bg-surface-secondary/50`}
|
className={`shrink-0 border-e border-border flex ${isExpanded ? 'flex-col justify-center gap-1' : 'items-center gap-2'} px-3 overflow-hidden sticky start-0 z-10 bg-surface group-hover/row:bg-surface-secondary/50`}
|
||||||
style={{ width: labelWidth }}
|
style={{ width: labelWidth }}
|
||||||
>
|
>
|
||||||
{isExpanded ? (
|
{isExpanded ? (
|
||||||
@@ -358,7 +358,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
|
|||||||
)}
|
)}
|
||||||
{item.thumbnailUrl ? (
|
{item.thumbnailUrl ? (
|
||||||
<div className="w-8 h-8 rounded overflow-hidden shrink-0">
|
<div className="w-8 h-8 rounded overflow-hidden shrink-0">
|
||||||
<img src={item.thumbnailUrl} alt="" className="w-full h-full object-cover" />
|
<img src={item.thumbnailUrl} alt="" className="w-full h-full object-cover" loading="lazy" />
|
||||||
</div>
|
</div>
|
||||||
) : item.assigneeName ? (
|
) : item.assigneeName ? (
|
||||||
<div className="w-6 h-6 rounded-full bg-brand-primary/10 text-brand-primary flex items-center justify-center text-[9px] font-bold shrink-0">
|
<div className="w-6 h-6 rounded-full bg-brand-primary/10 text-brand-primary flex items-center justify-center text-[9px] font-bold shrink-0">
|
||||||
@@ -394,7 +394,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
|
|||||||
)}
|
)}
|
||||||
{item.thumbnailUrl ? (
|
{item.thumbnailUrl ? (
|
||||||
<div className="w-6 h-6 rounded overflow-hidden shrink-0">
|
<div className="w-6 h-6 rounded overflow-hidden shrink-0">
|
||||||
<img src={item.thumbnailUrl} alt="" className="w-full h-full object-cover" />
|
<img src={item.thumbnailUrl} alt="" className="w-full h-full object-cover" loading="lazy" />
|
||||||
</div>
|
</div>
|
||||||
) : item.assigneeName ? (
|
) : item.assigneeName ? (
|
||||||
<div className="w-6 h-6 rounded-full bg-brand-primary/10 text-brand-primary flex items-center justify-center text-[9px] font-bold shrink-0">
|
<div className="w-6 h-6 rounded-full bg-brand-primary/10 text-brand-primary flex items-center justify-center text-[9px] font-bold shrink-0">
|
||||||
@@ -415,7 +415,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
|
|||||||
style={{ left: `${todayOffset + pxPerDay / 2}px` }}
|
style={{ left: `${todayOffset + pxPerDay / 2}px` }}
|
||||||
>
|
>
|
||||||
{idx === 0 && (
|
{idx === 0 && (
|
||||||
<div className="absolute -top-0 left-1 text-[8px] font-bold text-red-500 bg-red-50 px-1 rounded whitespace-nowrap">
|
<div className="absolute -top-0 start-1 text-[8px] font-bold text-red-500 bg-red-50 px-1 rounded whitespace-nowrap">
|
||||||
{t('timeline.today')}
|
{t('timeline.today')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -459,7 +459,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
|
|||||||
{/* Left resize handle */}
|
{/* Left resize handle */}
|
||||||
{!readOnly && onDateChange && (
|
{!readOnly && onDateChange && (
|
||||||
<div
|
<div
|
||||||
className="absolute left-0 top-0 bottom-0 w-2 cursor-col-resize hover:bg-white/30 z-10"
|
className="absolute start-0 top-0 bottom-0 w-2 cursor-col-resize hover:bg-white/30 z-10"
|
||||||
onMouseDown={(e) => handleMouseDown(e, item, 'resize-left')}
|
onMouseDown={(e) => handleMouseDown(e, item, 'resize-left')}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -520,7 +520,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
|
|||||||
{/* Right resize handle */}
|
{/* Right resize handle */}
|
||||||
{!readOnly && onDateChange && (
|
{!readOnly && onDateChange && (
|
||||||
<div
|
<div
|
||||||
className="absolute right-0 top-0 bottom-0 w-2 cursor-col-resize hover:bg-white/30 z-10"
|
className="absolute end-0 top-0 bottom-0 w-2 cursor-col-resize hover:bg-white/30 z-10"
|
||||||
onMouseDown={(e) => handleMouseDown(e, item, 'resize-right')}
|
onMouseDown={(e) => handleMouseDown(e, item, 'resize-right')}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -536,7 +536,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
|
|||||||
{colorPicker && onColorChange && (
|
{colorPicker && onColorChange && (
|
||||||
<div
|
<div
|
||||||
ref={colorPickerRef}
|
ref={colorPickerRef}
|
||||||
className="fixed z-50 bg-white rounded-lg shadow-xl border border-border p-2"
|
className="fixed z-50 bg-surface rounded-lg shadow-xl border border-border p-2"
|
||||||
style={{ left: colorPicker.x, top: colorPicker.y }}
|
style={{ left: colorPicker.x, top: colorPicker.y }}
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-4 gap-1.5 mb-2">
|
<div className="grid grid-cols-4 gap-1.5 mb-2">
|
||||||
@@ -591,7 +591,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!readOnly && onDateChange && (
|
{!readOnly && onDateChange && (
|
||||||
<div className="text-gray-400 mt-1 text-[10px] italic">
|
<div className="text-text-tertiary mt-1 text-[10px] italic">
|
||||||
{t('timeline.dragToMove')} · {t('timeline.dragToResize')}
|
{t('timeline.dragToMove')} · {t('timeline.dragToResize')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -10,12 +10,12 @@ export default function KanbanCard({ title, thumbnail, brandName, tags, assignee
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className="bg-white rounded-lg p-3 border border-border hover:border-brand-primary/30 cursor-pointer transition-all group overflow-hidden"
|
className="bg-surface rounded-lg p-3 border border-border hover:border-brand-primary/30 cursor-pointer transition-all group overflow-hidden"
|
||||||
>
|
>
|
||||||
{/* Thumbnail */}
|
{/* Thumbnail */}
|
||||||
{thumbnail && (
|
{thumbnail && (
|
||||||
<div className="w-[calc(100%+1.5rem)] h-32 -mx-3 -mt-3 mb-2 rounded-t-lg overflow-hidden">
|
<div className="w-[calc(100%+1.5rem)] h-32 -mx-3 -mt-3 mb-2 rounded-t-lg overflow-hidden">
|
||||||
<img src={thumbnail} alt="" className="w-full h-full object-cover" />
|
<img src={thumbnail} alt="" className="w-full h-full object-cover" loading="lazy" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const ROLE_BADGES = {
|
|||||||
strategist: { bg: 'bg-rose-50', text: 'text-rose-700', label: 'Strategist' },
|
strategist: { bg: 'bg-rose-50', text: 'text-rose-700', label: 'Strategist' },
|
||||||
superadmin: { bg: 'bg-red-50', text: 'text-red-700', label: 'Super Admin' },
|
superadmin: { bg: 'bg-red-50', text: 'text-red-700', label: 'Super Admin' },
|
||||||
contributor: { bg: 'bg-slate-50', text: 'text-slate-700', label: 'Contributor' },
|
contributor: { bg: 'bg-slate-50', text: 'text-slate-700', label: 'Contributor' },
|
||||||
default: { bg: 'bg-gray-50', text: 'text-gray-700', label: 'Team Member' },
|
default: { bg: 'bg-gray-50', text: 'text-text-secondary', label: 'Team Member' },
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MemberCard({ member, onClick }) {
|
export default function MemberCard({ member, onClick }) {
|
||||||
@@ -33,7 +33,7 @@ export default function MemberCard({ member, onClick }) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={() => onClick?.(member)}
|
onClick={() => onClick?.(member)}
|
||||||
className="bg-white rounded-xl border border-border p-5 card-hover cursor-pointer text-center"
|
className="bg-surface rounded-xl border border-border p-5 card-hover cursor-pointer text-center"
|
||||||
>
|
>
|
||||||
{/* Avatar */}
|
{/* Avatar */}
|
||||||
<div className={`w-16 h-16 rounded-full bg-gradient-to-br ${avatarColors[colorIndex]} flex items-center justify-center text-white text-xl font-bold mx-auto mb-3`}>
|
<div className={`w-16 h-16 rounded-full bg-gradient-to-br ${avatarColors[colorIndex]} flex items-center justify-center text-white text-xl font-bold mx-auto mb-3`}>
|
||||||
|
|||||||
@@ -1,15 +1,38 @@
|
|||||||
import { useEffect } from 'react'
|
import { useEffect, useRef, useCallback } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { X, AlertTriangle } from 'lucide-react'
|
import { X, AlertTriangle } from 'lucide-react'
|
||||||
import { useLanguage } from '../i18n/LanguageContext'
|
import { useLanguage } from '../i18n/LanguageContext'
|
||||||
|
|
||||||
export default function Modal({
|
function useFocusTrap(ref, isOpen) {
|
||||||
isOpen,
|
useEffect(() => {
|
||||||
onClose,
|
if (!isOpen || !ref.current) return
|
||||||
title,
|
const el = ref.current
|
||||||
children,
|
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',
|
size = 'md',
|
||||||
// Confirmation mode props
|
|
||||||
isConfirm = false,
|
isConfirm = false,
|
||||||
confirmText,
|
confirmText,
|
||||||
cancelText,
|
cancelText,
|
||||||
@@ -17,10 +40,11 @@ export default function Modal({
|
|||||||
danger = false,
|
danger = false,
|
||||||
}) {
|
}) {
|
||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
|
const modalRef = useRef(null)
|
||||||
// Default translations
|
|
||||||
const finalConfirmText = confirmText || (danger ? t('common.delete') : t('common.save'))
|
const finalConfirmText = confirmText || (danger ? t('common.delete') : t('common.save'))
|
||||||
const finalCancelText = cancelText || t('common.cancel')
|
const finalCancelText = cancelText || t('common.cancel')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
document.body.style.overflow = 'hidden'
|
document.body.style.overflow = 'hidden'
|
||||||
@@ -30,6 +54,12 @@ export default function Modal({
|
|||||||
return () => { document.body.style.overflow = '' }
|
return () => { document.body.style.overflow = '' }
|
||||||
}, [isOpen])
|
}, [isOpen])
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback((e) => {
|
||||||
|
if (e.key === 'Escape') onClose()
|
||||||
|
}, [onClose])
|
||||||
|
|
||||||
|
useFocusTrap(modalRef, isOpen)
|
||||||
|
|
||||||
if (!isOpen) return null
|
if (!isOpen) return null
|
||||||
|
|
||||||
const sizeClasses = {
|
const sizeClasses = {
|
||||||
@@ -39,25 +69,23 @@ export default function Modal({
|
|||||||
xl: 'max-w-4xl',
|
xl: 'max-w-4xl',
|
||||||
}
|
}
|
||||||
|
|
||||||
// Confirmation dialog
|
|
||||||
if (isConfirm) {
|
if (isConfirm) {
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center px-4">
|
<div className="fixed inset-0 z-[9999] flex items-center justify-center px-4" onKeyDown={handleKeyDown} ref={modalRef}>
|
||||||
{/* Backdrop */}
|
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 bg-black/40 backdrop-blur-sm animate-backdrop-in"
|
className="fixed inset-0 bg-black/40 backdrop-blur-sm animate-backdrop-in"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
|
aria-label="Close dialog"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Modal content */}
|
<div className="relative bg-surface rounded-2xl shadow-2xl w-full max-w-md animate-scale-in" role="dialog" aria-modal="true" aria-labelledby="modal-title">
|
||||||
<div className="relative bg-white rounded-2xl shadow-2xl w-full max-w-md animate-scale-in">
|
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
{danger && (
|
{danger && (
|
||||||
<div className="w-12 h-12 rounded-full bg-red-100 flex items-center justify-center mx-auto mb-4">
|
<div className="w-12 h-12 rounded-full bg-red-100 flex items-center justify-center mx-auto mb-4">
|
||||||
<AlertTriangle className="w-6 h-6 text-red-600" />
|
<AlertTriangle className="w-6 h-6 text-red-600" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<h3 className="text-lg font-semibold text-text-primary text-center mb-2">{title}</h3>
|
<h3 id="modal-title" className="text-lg font-semibold text-text-primary text-center mb-2">{title}</h3>
|
||||||
<div className="text-sm text-text-secondary text-center mb-6">
|
<div className="text-sm text-text-secondary text-center mb-6">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
@@ -74,8 +102,8 @@ export default function Modal({
|
|||||||
onClose();
|
onClose();
|
||||||
}}
|
}}
|
||||||
className={`flex-1 px-4 py-2.5 text-sm font-medium text-white rounded-lg shadow-sm transition-colors ${
|
className={`flex-1 px-4 py-2.5 text-sm font-medium text-white rounded-lg shadow-sm transition-colors ${
|
||||||
danger
|
danger
|
||||||
? 'bg-red-600 hover:bg-red-700'
|
? 'bg-red-600 hover:bg-red-700'
|
||||||
: 'bg-brand-primary hover:bg-brand-primary-light'
|
: 'bg-brand-primary hover:bg-brand-primary-light'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -89,29 +117,26 @@ export default function Modal({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Regular modal
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div className="fixed inset-0 z-[9999] flex items-start justify-center pt-[10vh] px-4">
|
<div className="fixed inset-0 z-[9999] flex items-start justify-center pt-[10vh] px-4" onKeyDown={handleKeyDown} ref={modalRef}>
|
||||||
{/* Backdrop */}
|
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 bg-black/40 backdrop-blur-sm animate-backdrop-in"
|
className="fixed inset-0 bg-black/40 backdrop-blur-sm animate-backdrop-in"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
|
aria-label="Close dialog"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Modal content */}
|
<div className={`relative bg-surface rounded-2xl shadow-2xl w-full ${sizeClasses[size]} max-h-[80vh] flex flex-col animate-scale-in`} role="dialog" aria-modal="true" aria-labelledby="modal-title">
|
||||||
<div className={`relative bg-white rounded-2xl shadow-2xl w-full ${sizeClasses[size]} max-h-[80vh] flex flex-col animate-scale-in`}>
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border shrink-0">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-border shrink-0">
|
||||||
<h3 className="text-lg font-semibold text-text-primary">{title}</h3>
|
<h3 id="modal-title" className="text-lg font-semibold text-text-primary">{title}</h3>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors"
|
className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors"
|
||||||
|
aria-label="Close dialog"
|
||||||
>
|
>
|
||||||
<X className="w-5 h-5" />
|
<X className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body */}
|
|
||||||
<div className="px-6 py-4 overflow-y-auto flex-1">
|
<div className="px-6 py-4 overflow-y-auto flex-1">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,11 +23,11 @@ export default function PostCard({ post, onClick, onMove, compact = false, check
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className="bg-white rounded-lg p-3 border border-border hover:border-brand-primary/30 cursor-pointer transition-all group overflow-hidden card-hover"
|
className="bg-surface rounded-lg p-3 border border-border hover:border-brand-primary/30 cursor-pointer transition-all group overflow-hidden card-hover"
|
||||||
>
|
>
|
||||||
{post.thumbnail_url && (
|
{post.thumbnail_url && (
|
||||||
<div className="w-[calc(100%+1.5rem)] h-32 -mx-3 -mt-3 mb-2 rounded-t-lg overflow-hidden">
|
<div className="w-[calc(100%+1.5rem)] h-32 -mx-3 -mt-3 mb-2 rounded-t-lg overflow-hidden">
|
||||||
<img src={post.thumbnail_url} alt="" className="w-full h-full object-cover" />
|
<img src={post.thumbnail_url} alt="" className="w-full h-full object-cover" loading="lazy" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { Send, CheckCircle2, XCircle, Copy, Check, Clock } from 'lucide-react'
|
||||||
|
import { useLanguage } from '../i18n/LanguageContext'
|
||||||
|
import ApproverMultiSelect from './ApproverMultiSelect'
|
||||||
|
|
||||||
|
export function PostDetailApproval({
|
||||||
|
form,
|
||||||
|
update,
|
||||||
|
post,
|
||||||
|
isCreateMode,
|
||||||
|
reviewUrl,
|
||||||
|
copied,
|
||||||
|
submittingReview,
|
||||||
|
saving,
|
||||||
|
teamMembers,
|
||||||
|
onSubmitReview,
|
||||||
|
onCopyReviewLink,
|
||||||
|
onStatusAction,
|
||||||
|
}) {
|
||||||
|
const { t } = useLanguage()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-5 w-full">
|
||||||
|
<div className="bg-surface-secondary rounded-xl p-4">
|
||||||
|
<label className="block text-xs font-medium text-text-primary mb-2">{t('posts.approvers')}</label>
|
||||||
|
<ApproverMultiSelect
|
||||||
|
users={teamMembers || []}
|
||||||
|
selected={form.approver_ids || []}
|
||||||
|
onChange={ids => update('approver_ids', ids)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isCreateMode && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Approval status cards */}
|
||||||
|
{form.status === 'approved' && post.approved_by_name && (
|
||||||
|
<div className="bg-emerald-50 border border-emerald-200 rounded-xl p-4 flex items-start gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-emerald-100 flex items-center justify-center shrink-0 mt-0.5">
|
||||||
|
<CheckCircle2 className="w-4 h-4 text-emerald-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-emerald-800">{t('posts.approvedBy')} {post.approved_by_name}</p>
|
||||||
|
{post.feedback && <p className="text-xs text-emerald-700 mt-1.5 leading-relaxed">{post.feedback}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{form.status === 'rejected' && post.approved_by_name && (
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-xl p-4 flex items-start gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-red-100 flex items-center justify-center shrink-0 mt-0.5">
|
||||||
|
<XCircle className="w-4 h-4 text-red-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-red-800">{t('posts.rejectedBy')} {post.approved_by_name}</p>
|
||||||
|
{post.feedback && <p className="text-xs text-red-700 mt-1.5 leading-relaxed">{post.feedback}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{form.status === 'in_review' && (
|
||||||
|
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4 text-center">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-amber-100 flex items-center justify-center mx-auto mb-2">
|
||||||
|
<Clock className="w-5 h-5 text-amber-600" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-semibold text-amber-800">{t('posts.awaitingReview')}</p>
|
||||||
|
<p className="text-xs text-amber-600 mt-1">{t('posts.awaitingReviewDesc')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Review link */}
|
||||||
|
{reviewUrl && (
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
|
||||||
|
<div className="text-xs font-semibold text-blue-900 mb-2.5">{t('posts.reviewLinkTitle')}</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input type="text" value={reviewUrl} readOnly className="flex-1 px-3 py-2 text-xs bg-surface border border-blue-200 rounded-lg font-mono" />
|
||||||
|
<button onClick={onCopyReviewLink} className="p-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors shadow-sm">
|
||||||
|
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{!reviewUrl && (
|
||||||
|
<button
|
||||||
|
onClick={onSubmitReview}
|
||||||
|
disabled={submittingReview}
|
||||||
|
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-amber-500 text-white rounded-xl hover:bg-amber-600 transition-colors font-medium text-sm disabled:opacity-50 shadow-sm"
|
||||||
|
>
|
||||||
|
<Send className="w-4 h-4" />
|
||||||
|
{submittingReview ? t('posts.submitting') : t('posts.sendToReview')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{form.status === 'approved' && (
|
||||||
|
<button
|
||||||
|
onClick={() => onStatusAction('scheduled')}
|
||||||
|
disabled={saving}
|
||||||
|
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-purple-600 text-white rounded-xl hover:bg-purple-700 transition-colors font-medium text-sm disabled:opacity-50 shadow-sm"
|
||||||
|
>
|
||||||
|
{t('posts.schedule')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
import { useState, useRef } from 'react'
|
||||||
|
import { X, Upload, FileText, FolderOpen, Image as ImageIcon, Music, Film } from 'lucide-react'
|
||||||
|
import { useLanguage } from '../i18n/LanguageContext'
|
||||||
|
|
||||||
|
export function PostDetailAttachments({
|
||||||
|
attachments,
|
||||||
|
uploading,
|
||||||
|
onFileUpload,
|
||||||
|
onDeleteAttachment,
|
||||||
|
onAttachAsset,
|
||||||
|
}) {
|
||||||
|
const { t } = useLanguage()
|
||||||
|
const imageInputRef = useRef(null)
|
||||||
|
const [dragActive, setDragActive] = useState(false)
|
||||||
|
const [showAssetPicker, setShowAssetPicker] = useState(false)
|
||||||
|
const [availableAssets, setAvailableAssets] = useState([])
|
||||||
|
const [assetSearch, setAssetSearch] = useState('')
|
||||||
|
|
||||||
|
const handleDrop = (e) => {
|
||||||
|
e.preventDefault(); e.stopPropagation(); setDragActive(false)
|
||||||
|
if (e.dataTransfer.files?.length) onFileUpload(e.dataTransfer.files)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openAssetPicker = async () => {
|
||||||
|
const { api } = await import('../utils/api')
|
||||||
|
try {
|
||||||
|
const data = await api.get('/assets')
|
||||||
|
setAvailableAssets(Array.isArray(data) ? data : [])
|
||||||
|
} catch {
|
||||||
|
setAvailableAssets([])
|
||||||
|
}
|
||||||
|
setAssetSearch('')
|
||||||
|
setShowAssetPicker(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAttachAsset = async (assetId) => {
|
||||||
|
await onAttachAsset(assetId)
|
||||||
|
setShowAssetPicker(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const images = attachments.filter(a => (a.mime_type || a.mimeType || '').startsWith('image/'))
|
||||||
|
const audio = attachments.filter(a => (a.mime_type || a.mimeType || '').startsWith('audio/'))
|
||||||
|
const videos = attachments.filter(a => (a.mime_type || a.mimeType || '').startsWith('video/'))
|
||||||
|
const others = attachments.filter(a => {
|
||||||
|
const mime = a.mime_type || a.mimeType || ''
|
||||||
|
return !mime.startsWith('image/') && !mime.startsWith('audio/') && !mime.startsWith('video/')
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Images */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs font-medium text-text-secondary">
|
||||||
|
<ImageIcon className="w-3.5 h-3.5" />
|
||||||
|
{t('posts.images')}
|
||||||
|
{images.length > 0 && <span className="text-text-tertiary">({images.length})</span>}
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-1 px-2 py-1 text-[11px] font-medium text-brand-primary hover:bg-brand-primary/5 rounded cursor-pointer transition-colors">
|
||||||
|
<Upload className="w-3 h-3" />
|
||||||
|
{t('posts.addImage')}
|
||||||
|
<input ref={imageInputRef} type="file" multiple accept="image/*" className="hidden"
|
||||||
|
onChange={e => { onFileUpload(e.target.files); e.target.value = '' }} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{images.length > 0 && (
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{images.map(att => {
|
||||||
|
const attUrl = att.url || `/api/uploads/${att.filename}`
|
||||||
|
const name = att.original_name || att.originalName || att.filename
|
||||||
|
const attId = att.id || att._id
|
||||||
|
return (
|
||||||
|
<div key={attId} className="relative group/att border border-border rounded-lg overflow-hidden bg-surface">
|
||||||
|
<div className="h-20 relative">
|
||||||
|
<a href={attUrl} target="_blank" rel="noopener noreferrer" className="block h-full">
|
||||||
|
<img src={attUrl} alt={name} className="absolute inset-0 w-full h-full object-cover" />
|
||||||
|
</a>
|
||||||
|
<button onClick={() => onDeleteAttachment(attId)}
|
||||||
|
className="absolute top-1 end-1 p-1 bg-black/50 hover:bg-red-500 rounded-full text-white opacity-0 group-hover/att:opacity-100 transition-opacity"
|
||||||
|
title={t('common.delete')}><X className="w-2.5 h-2.5" /></button>
|
||||||
|
</div>
|
||||||
|
<div className="px-2 py-1 text-[10px] text-text-tertiary truncate border-t border-border-light">{name}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Audio */}
|
||||||
|
{audio.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-1.5 text-xs font-medium text-text-secondary mb-2">
|
||||||
|
<Music className="w-3.5 h-3.5" />
|
||||||
|
{t('posts.audio')} <span className="text-text-tertiary">({audio.length})</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{audio.map(att => {
|
||||||
|
const attUrl = att.url || `/api/uploads/${att.filename}`
|
||||||
|
const name = att.original_name || att.originalName || att.filename
|
||||||
|
const attId = att.id || att._id
|
||||||
|
return (
|
||||||
|
<div key={attId} className="flex items-center gap-2 border border-border rounded-lg p-2 bg-surface group/att">
|
||||||
|
<Music className="w-4 h-4 text-text-tertiary shrink-0" />
|
||||||
|
<span className="text-xs text-text-secondary truncate flex-1">{name}</span>
|
||||||
|
<audio src={attUrl} controls className="h-7 max-w-[160px]" />
|
||||||
|
<button onClick={() => onDeleteAttachment(attId)}
|
||||||
|
className="p-1 text-text-tertiary hover:text-red-500 opacity-0 group-hover/att:opacity-100 transition-opacity"
|
||||||
|
title={t('common.delete')}><X className="w-3 h-3" /></button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Videos */}
|
||||||
|
{videos.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-1.5 text-xs font-medium text-text-secondary mb-2">
|
||||||
|
<Film className="w-3.5 h-3.5" />
|
||||||
|
{t('posts.videos')} <span className="text-text-tertiary">({videos.length})</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{videos.map(att => {
|
||||||
|
const attUrl = att.url || `/api/uploads/${att.filename}`
|
||||||
|
const name = att.original_name || att.originalName || att.filename
|
||||||
|
const attId = att.id || att._id
|
||||||
|
return (
|
||||||
|
<div key={attId} className="border border-border rounded-lg overflow-hidden bg-surface group/att">
|
||||||
|
<video src={attUrl} controls className="w-full max-h-40" />
|
||||||
|
<div className="flex items-center justify-between px-2 py-1 border-t border-border-light">
|
||||||
|
<span className="text-[10px] text-text-tertiary truncate">{name}</span>
|
||||||
|
<button onClick={() => onDeleteAttachment(attId)}
|
||||||
|
className="p-1 text-text-tertiary hover:text-red-500 opacity-0 group-hover/att:opacity-100 transition-opacity"
|
||||||
|
title={t('common.delete')}><X className="w-3 h-3" /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Other files */}
|
||||||
|
{others.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-1.5 text-xs font-medium text-text-secondary mb-2">
|
||||||
|
<FileText className="w-3.5 h-3.5" />
|
||||||
|
{t('posts.otherFiles')} <span className="text-text-tertiary">({others.length})</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{others.map(att => {
|
||||||
|
const attUrl = att.url || `/api/uploads/${att.filename}`
|
||||||
|
const name = att.original_name || att.originalName || att.filename
|
||||||
|
const attId = att.id || att._id
|
||||||
|
return (
|
||||||
|
<div key={attId} className="relative group/att border border-border rounded-lg overflow-hidden bg-surface">
|
||||||
|
<a href={attUrl} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 p-3 h-16">
|
||||||
|
<FileText className="w-6 h-6 text-text-tertiary shrink-0" />
|
||||||
|
<span className="text-xs text-text-secondary truncate">{name}</span>
|
||||||
|
</a>
|
||||||
|
<button onClick={() => onDeleteAttachment(attId)}
|
||||||
|
className="absolute top-1 end-1 p-1 bg-black/50 hover:bg-red-500 rounded-full text-white opacity-0 group-hover/att:opacity-100 transition-opacity"
|
||||||
|
title={t('common.delete')}><X className="w-2.5 h-2.5" /></button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Drag and drop zone */}
|
||||||
|
<div
|
||||||
|
className={`border-2 border-dashed rounded-lg p-3 text-center cursor-pointer transition-colors ${
|
||||||
|
dragActive ? 'border-brand-primary bg-brand-primary/5' : 'border-border hover:border-brand-primary/40'
|
||||||
|
}`}
|
||||||
|
onDragEnter={e => { e.preventDefault(); setDragActive(true) }}
|
||||||
|
onDragLeave={e => { e.preventDefault(); setDragActive(false) }}
|
||||||
|
onDragOver={e => e.preventDefault()}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
>
|
||||||
|
<Upload className={`w-4 h-4 text-text-tertiary mx-auto mb-1 ${uploading ? 'animate-pulse' : ''}`} />
|
||||||
|
<p className="text-[11px] text-text-secondary">
|
||||||
|
{dragActive ? t('posts.dropFiles') : t('posts.dragToUpload')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={openAssetPicker}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 text-sm text-text-secondary border border-border rounded-lg hover:bg-surface-tertiary transition-colors w-full justify-center"
|
||||||
|
>
|
||||||
|
<FolderOpen className="w-4 h-4" />
|
||||||
|
{t('posts.attachFromAssets')}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showAssetPicker && (
|
||||||
|
<div className="border border-border rounded-lg p-3 bg-surface-secondary">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<p className="text-xs font-medium text-text-secondary">{t('posts.selectAssets')}</p>
|
||||||
|
<button onClick={() => setShowAssetPicker(false)} className="p-1 text-text-tertiary hover:text-text-primary">
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={assetSearch}
|
||||||
|
onChange={e => setAssetSearch(e.target.value)}
|
||||||
|
placeholder={t('common.search')}
|
||||||
|
className="w-full px-3 py-1.5 text-xs border border-border rounded-lg mb-2 focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-4 gap-2 max-h-48 overflow-y-auto">
|
||||||
|
{availableAssets
|
||||||
|
.filter(a => !assetSearch || (a.original_name || a.filename || '').toLowerCase().includes(assetSearch.toLowerCase()))
|
||||||
|
.map(asset => {
|
||||||
|
const isImage = asset.mime_type?.startsWith('image/')
|
||||||
|
const assetUrl = `/api/uploads/${asset.filename}`
|
||||||
|
const name = asset.original_name || asset.filename
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={asset.id || asset._id}
|
||||||
|
onClick={() => handleAttachAsset(asset.id || asset._id)}
|
||||||
|
className="block w-full border border-border rounded-lg overflow-hidden bg-surface hover:border-brand-primary hover:ring-2 hover:ring-brand-primary/20 transition-all text-start"
|
||||||
|
>
|
||||||
|
<div className="aspect-square relative">
|
||||||
|
{isImage ? (
|
||||||
|
<img src={assetUrl} alt={name} className="absolute inset-0 w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-surface-tertiary">
|
||||||
|
<FileText className="w-6 h-6 text-text-tertiary" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="px-1.5 py-1 text-[10px] text-text-tertiary truncate border-t border-border-light">{name}</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{availableAssets.filter(a => !assetSearch || (a.original_name || a.filename || '').toLowerCase().includes(assetSearch.toLowerCase())).length === 0 && (
|
||||||
|
<p className="text-xs text-text-tertiary text-center py-4">{t('posts.noAssetsFound')}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,28 +1,21 @@
|
|||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { X, Trash2, Upload, FileText, Link2, ExternalLink, FolderOpen, Image as ImageIcon, Music, Film, Send, CheckCircle2, XCircle, Copy, Check, Plus, Globe, Clock, User, FileEdit, Layers, Share2, ShieldCheck, MessageSquare } from 'lucide-react'
|
import { Trash2, XCircle, FileEdit, Layers, Share2, ShieldCheck, MessageSquare } from 'lucide-react'
|
||||||
import { useLanguage } from '../i18n/LanguageContext'
|
import { useLanguage } from '../i18n/LanguageContext'
|
||||||
import { api, PLATFORMS, getBrandColor } from '../utils/api'
|
import { api, getBrandColor } from '../utils/api'
|
||||||
import ApproverMultiSelect from './ApproverMultiSelect'
|
|
||||||
import CommentsSection from './CommentsSection'
|
import CommentsSection from './CommentsSection'
|
||||||
import Modal from './Modal'
|
import Modal from './Modal'
|
||||||
import TabbedModal from './TabbedModal'
|
import TabbedModal from './TabbedModal'
|
||||||
import { useToast } from './ToastContainer'
|
import { useToast } from './ToastContainer'
|
||||||
|
import { PostDetailVersions } from './PostDetailVersions'
|
||||||
const AVAILABLE_LANGUAGES = [
|
import { PostDetailPlatforms } from './PostDetailPlatforms'
|
||||||
{ code: 'ar', label: 'Arabic' },
|
import { PostDetailApproval } from './PostDetailApproval'
|
||||||
{ code: 'en', label: 'English' },
|
import { PostDetailAttachments } from './PostDetailAttachments'
|
||||||
{ code: 'fr', label: 'French' },
|
|
||||||
{ code: 'id', label: 'Bahasa Indonesia' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const TABS = ['details', 'versions', 'platforms', 'approval', 'discussion']
|
const TABS = ['details', 'versions', 'platforms', 'approval', 'discussion']
|
||||||
|
|
||||||
export default function PostDetailPanel({ post, onClose, onSave, onDelete, brands, teamMembers, campaigns }) {
|
export default function PostDetailPanel({ post, onClose, onSave, onDelete, brands, teamMembers, campaigns }) {
|
||||||
const { t, lang } = useLanguage()
|
const { t, lang } = useLanguage()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const imageInputRef = useRef(null)
|
|
||||||
const audioInputRef = useRef(null)
|
|
||||||
const videoInputRef = useRef(null)
|
|
||||||
const versionFileInputRef = useRef(null)
|
const versionFileInputRef = useRef(null)
|
||||||
const [activeTab, setActiveTab] = useState('details')
|
const [activeTab, setActiveTab] = useState('details')
|
||||||
const [form, setForm] = useState({})
|
const [form, setForm] = useState({})
|
||||||
@@ -38,24 +31,11 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
|
|||||||
// Attachments state (non-versioned, legacy)
|
// Attachments state (non-versioned, legacy)
|
||||||
const [attachments, setAttachments] = useState([])
|
const [attachments, setAttachments] = useState([])
|
||||||
const [uploading, setUploading] = useState(false)
|
const [uploading, setUploading] = useState(false)
|
||||||
const [dragActive, setDragActive] = useState(false)
|
|
||||||
const [showAssetPicker, setShowAssetPicker] = useState(false)
|
|
||||||
const [availableAssets, setAvailableAssets] = useState([])
|
|
||||||
const [assetSearch, setAssetSearch] = useState('')
|
|
||||||
|
|
||||||
// Versions state
|
// Versions state
|
||||||
const [versions, setVersions] = useState([])
|
const [versions, setVersions] = useState([])
|
||||||
const [selectedVersion, setSelectedVersion] = useState(null)
|
const [selectedVersion, setSelectedVersion] = useState(null)
|
||||||
const [versionData, setVersionData] = useState(null)
|
const [versionData, setVersionData] = useState(null)
|
||||||
const [showNewVersionModal, setShowNewVersionModal] = useState(false)
|
|
||||||
const [newVersionNotes, setNewVersionNotes] = useState('')
|
|
||||||
const [copyFromPrevious, setCopyFromPrevious] = useState(false)
|
|
||||||
const [creatingVersion, setCreatingVersion] = useState(false)
|
|
||||||
const [showLanguageModal, setShowLanguageModal] = useState(false)
|
|
||||||
const [languageForm, setLanguageForm] = useState({ language_code: '', language_label: '', content: '' })
|
|
||||||
const [savingLanguage, setSavingLanguage] = useState(false)
|
|
||||||
const [confirmDeleteLangId, setConfirmDeleteLangId] = useState(null)
|
|
||||||
const [confirmDeleteAttId, setConfirmDeleteAttId] = useState(null)
|
|
||||||
const [uploadingVersionFile, setUploadingVersionFile] = useState(false)
|
const [uploadingVersionFile, setUploadingVersionFile] = useState(false)
|
||||||
|
|
||||||
const postId = post?._id || post?.id
|
const postId = post?._id || post?.id
|
||||||
@@ -136,6 +116,7 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (data.status === 'published' && data.platforms.length > 0) {
|
if (data.status === 'published' && data.platforms.length > 0) {
|
||||||
|
const { PLATFORMS } = await import('../utils/api')
|
||||||
const missingPlatforms = data.platforms.filter(platform => {
|
const missingPlatforms = data.platforms.filter(platform => {
|
||||||
const link = (data.publication_links || []).find(l => l.platform === platform)
|
const link = (data.publication_links || []).find(l => l.platform === platform)
|
||||||
return !link || !link.url || !link.url.trim()
|
return !link || !link.url || !link.url.trim()
|
||||||
@@ -237,33 +218,16 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const openAssetPicker = async () => {
|
|
||||||
try {
|
|
||||||
const data = await api.get('/assets')
|
|
||||||
setAvailableAssets(Array.isArray(data) ? data : [])
|
|
||||||
} catch {
|
|
||||||
setAvailableAssets([])
|
|
||||||
}
|
|
||||||
setAssetSearch('')
|
|
||||||
setShowAssetPicker(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAttachAsset = async (assetId) => {
|
const handleAttachAsset = async (assetId) => {
|
||||||
if (!postId) return
|
if (!postId) return
|
||||||
try {
|
try {
|
||||||
await api.post(`/posts/${postId}/attachments/from-asset`, { asset_id: assetId })
|
await api.post(`/posts/${postId}/attachments/from-asset`, { asset_id: assetId })
|
||||||
loadAttachments()
|
loadAttachments()
|
||||||
setShowAssetPicker(false)
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Attach asset failed:', err)
|
console.error('Attach asset failed:', err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDrop = (e) => {
|
|
||||||
e.preventDefault(); e.stopPropagation(); setDragActive(false)
|
|
||||||
if (e.dataTransfer.files?.length) handleFileUpload(e.dataTransfer.files)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Versions ──────────────────────────
|
// ─── Versions ──────────────────────────
|
||||||
async function loadVersions() {
|
async function loadVersions() {
|
||||||
if (!postId) return
|
if (!postId) return
|
||||||
@@ -299,44 +263,28 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
|
|||||||
loadVersionData(version.Id || version.id || version._id)
|
loadVersionData(version.Id || version.id || version._id)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCreateVersion = async () => {
|
const handleCreateVersion = async ({ notes, copy_from_previous }) => {
|
||||||
setCreatingVersion(true)
|
|
||||||
try {
|
try {
|
||||||
await api.post(`/posts/${postId}/versions`, {
|
await api.post(`/posts/${postId}/versions`, {
|
||||||
notes: newVersionNotes || undefined,
|
notes: notes || undefined,
|
||||||
copy_from_previous: copyFromPrevious,
|
copy_from_previous,
|
||||||
})
|
})
|
||||||
setShowNewVersionModal(false)
|
|
||||||
setNewVersionNotes('')
|
|
||||||
setCopyFromPrevious(false)
|
|
||||||
loadVersions()
|
loadVersions()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Create version failed:', err)
|
console.error('Create version failed:', err)
|
||||||
} finally {
|
|
||||||
setCreatingVersion(false)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAddLanguage = async () => {
|
const handleAddLanguage = async (languageForm) => {
|
||||||
if (!selectedVersion || !languageForm.language_code || !languageForm.content) return
|
if (!selectedVersion) return
|
||||||
setSavingLanguage(true)
|
const vId = selectedVersion.Id || selectedVersion.id || selectedVersion._id
|
||||||
try {
|
await api.post(`/posts/${postId}/versions/${vId}/texts`, languageForm)
|
||||||
const vId = selectedVersion.Id || selectedVersion.id || selectedVersion._id
|
loadVersionData(vId)
|
||||||
await api.post(`/posts/${postId}/versions/${vId}/texts`, languageForm)
|
|
||||||
setShowLanguageModal(false)
|
|
||||||
setLanguageForm({ language_code: '', language_label: '', content: '' })
|
|
||||||
loadVersionData(vId)
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Add language failed:', err)
|
|
||||||
} finally {
|
|
||||||
setSavingLanguage(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDeleteLanguage = async (textId) => {
|
const handleDeleteLanguage = async (textId) => {
|
||||||
try {
|
try {
|
||||||
await api.delete(`/post-version-texts/${textId}`)
|
await api.delete(`/post-version-texts/${textId}`)
|
||||||
setConfirmDeleteLangId(null)
|
|
||||||
const vId = selectedVersion.Id || selectedVersion.id || selectedVersion._id
|
const vId = selectedVersion.Id || selectedVersion.id || selectedVersion._id
|
||||||
loadVersionData(vId)
|
loadVersionData(vId)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -364,7 +312,6 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
|
|||||||
const handleDeleteVersionAttachment = async (attId) => {
|
const handleDeleteVersionAttachment = async (attId) => {
|
||||||
try {
|
try {
|
||||||
await api.delete(`/attachments/${attId}`)
|
await api.delete(`/attachments/${attId}`)
|
||||||
setConfirmDeleteAttId(null)
|
|
||||||
const vId = selectedVersion.Id || selectedVersion.id || selectedVersion._id
|
const vId = selectedVersion.Id || selectedVersion.id || selectedVersion._id
|
||||||
loadVersionData(vId)
|
loadVersionData(vId)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -409,7 +356,7 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
|
|||||||
form.status === 'approved' ? 'bg-blue-100 text-blue-700' :
|
form.status === 'approved' ? 'bg-blue-100 text-blue-700' :
|
||||||
form.status === 'in_review' ? 'bg-amber-100 text-amber-700' :
|
form.status === 'in_review' ? 'bg-amber-100 text-amber-700' :
|
||||||
form.status === 'rejected' ? 'bg-red-100 text-red-700' :
|
form.status === 'rejected' ? 'bg-red-100 text-red-700' :
|
||||||
'bg-gray-100 text-gray-600'
|
'bg-gray-100 text-text-secondary'
|
||||||
}`}>
|
}`}>
|
||||||
{statusOptions.find(s => s.value === form.status)?.label}
|
{statusOptions.find(s => s.value === form.status)?.label}
|
||||||
</span>
|
</span>
|
||||||
@@ -498,7 +445,7 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
|
|||||||
value={form.description}
|
value={form.description}
|
||||||
onChange={e => update('description', e.target.value)}
|
onChange={e => update('description', e.target.value)}
|
||||||
rows={4}
|
rows={4}
|
||||||
className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary resize-none"
|
className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary resize-none"
|
||||||
placeholder={t('posts.postDescPlaceholder')}
|
placeholder={t('posts.postDescPlaceholder')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -508,7 +455,7 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
|
|||||||
type="text"
|
type="text"
|
||||||
value={form.notes}
|
value={form.notes}
|
||||||
onChange={e => update('notes', e.target.value)}
|
onChange={e => update('notes', e.target.value)}
|
||||||
className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||||
placeholder={t('posts.additionalNotes')}
|
placeholder={t('posts.additionalNotes')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -532,7 +479,13 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{renderAttachments()}
|
<PostDetailAttachments
|
||||||
|
attachments={attachments}
|
||||||
|
uploading={uploading}
|
||||||
|
onFileUpload={handleFileUpload}
|
||||||
|
onDeleteAttachment={handleDeleteAttachment}
|
||||||
|
onAttachAsset={handleAttachAsset}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -545,7 +498,7 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
|
|||||||
<select
|
<select
|
||||||
value={form.status}
|
value={form.status}
|
||||||
onChange={e => update('status', e.target.value)}
|
onChange={e => update('status', e.target.value)}
|
||||||
className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||||
>
|
>
|
||||||
{statusOptions.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
|
{statusOptions.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
|
||||||
</select>
|
</select>
|
||||||
@@ -556,7 +509,7 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
|
|||||||
type="date"
|
type="date"
|
||||||
value={form.scheduled_date}
|
value={form.scheduled_date}
|
||||||
onChange={e => update('scheduled_date', e.target.value)}
|
onChange={e => update('scheduled_date', e.target.value)}
|
||||||
className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -564,7 +517,7 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
|
|||||||
<select
|
<select
|
||||||
value={form.assigned_to}
|
value={form.assigned_to}
|
||||||
onChange={e => update('assigned_to', e.target.value)}
|
onChange={e => update('assigned_to', e.target.value)}
|
||||||
className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||||
>
|
>
|
||||||
<option value="">{t('common.unassigned')}</option>
|
<option value="">{t('common.unassigned')}</option>
|
||||||
{(teamMembers || []).map(m => <option key={m._id || m.id} value={m._id || m.id}>{m.name}</option>)}
|
{(teamMembers || []).map(m => <option key={m._id || m.id} value={m._id || m.id}>{m.name}</option>)}
|
||||||
@@ -578,7 +531,7 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
|
|||||||
<select
|
<select
|
||||||
value={form.brand_id}
|
value={form.brand_id}
|
||||||
onChange={e => update('brand_id', e.target.value)}
|
onChange={e => update('brand_id', e.target.value)}
|
||||||
className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||||
>
|
>
|
||||||
<option value="">{t('posts.selectBrand')}</option>
|
<option value="">{t('posts.selectBrand')}</option>
|
||||||
{(brands || []).map(b => <option key={b._id || b.id} value={b._id || b.id}>{b.icon} {lang === 'ar' && b.name_ar ? b.name_ar : b.name}</option>)}
|
{(brands || []).map(b => <option key={b._id || b.id} value={b._id || b.id}>{b.icon} {lang === 'ar' && b.name_ar ? b.name_ar : b.name}</option>)}
|
||||||
@@ -589,7 +542,7 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
|
|||||||
<select
|
<select
|
||||||
value={form.campaign_id}
|
value={form.campaign_id}
|
||||||
onChange={e => update('campaign_id', e.target.value)}
|
onChange={e => update('campaign_id', e.target.value)}
|
||||||
className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||||
>
|
>
|
||||||
<option value="">{t('posts.noCampaign')}</option>
|
<option value="">{t('posts.noCampaign')}</option>
|
||||||
{(campaigns || []).map(c => <option key={c._id || c.id} value={c._id || c.id}>{c.name}</option>)}
|
{(campaigns || []).map(c => <option key={c._id || c.id} value={c._id || c.id}>{c.name}</option>)}
|
||||||
@@ -603,395 +556,46 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
|
|||||||
|
|
||||||
{/* ─── Versions Tab ─── */}
|
{/* ─── Versions Tab ─── */}
|
||||||
{activeTab === 'versions' && !isCreateMode && (
|
{activeTab === 'versions' && !isCreateMode && (
|
||||||
<div className="flex h-full">
|
<PostDetailVersions
|
||||||
{/* Version Timeline (left sidebar) */}
|
versions={versions}
|
||||||
<div className="w-64 shrink-0 border-e border-border p-4 overflow-y-auto bg-surface-secondary/50">
|
selectedVersion={selectedVersion}
|
||||||
<div className="flex items-center justify-between mb-4">
|
versionData={versionData}
|
||||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wide">{t('posts.versions')}</h4>
|
onSelectVersion={handleSelectVersion}
|
||||||
<button
|
onCreateVersion={handleCreateVersion}
|
||||||
onClick={() => setShowNewVersionModal(true)}
|
onAddLanguage={handleAddLanguage}
|
||||||
className="flex items-center gap-1 px-2.5 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors shadow-sm"
|
onDeleteLanguage={handleDeleteLanguage}
|
||||||
>
|
onVersionFileUpload={handleVersionFileUpload}
|
||||||
<Plus className="w-3 h-3" />
|
onDeleteVersionAttachment={handleDeleteVersionAttachment}
|
||||||
{t('posts.newVersion')}
|
uploadingVersionFile={uploadingVersionFile}
|
||||||
</button>
|
versionFileInputRef={versionFileInputRef}
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
{versions.length === 0 ? (
|
|
||||||
<div className="text-center py-10">
|
|
||||||
<div className="w-12 h-12 rounded-full bg-surface-tertiary flex items-center justify-center mx-auto mb-3">
|
|
||||||
<Layers className="w-6 h-6 text-text-quaternary" />
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-text-tertiary leading-relaxed px-2">{t('posts.noVersions')}</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
{versions.map((version, idx) => {
|
|
||||||
const vId = version.Id || version.id || version._id
|
|
||||||
const isActive = vId === (selectedVersion?.Id || selectedVersion?.id || selectedVersion?._id)
|
|
||||||
const isLatest = idx === versions.length - 1
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={vId}
|
|
||||||
onClick={() => handleSelectVersion(version)}
|
|
||||||
className={`w-full text-start p-3 rounded-xl border transition-all ${
|
|
||||||
isActive
|
|
||||||
? 'border-brand-primary bg-white shadow-sm ring-1 ring-brand-primary/20'
|
|
||||||
: 'border-transparent hover:bg-white hover:border-border'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2.5">
|
|
||||||
<div className={`flex-shrink-0 w-7 h-7 rounded-lg flex items-center justify-center text-[11px] font-bold ${
|
|
||||||
isActive ? 'bg-brand-primary text-white' : 'bg-surface-tertiary text-text-secondary'
|
|
||||||
}`}>
|
|
||||||
{version.version_number}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<span className={`text-sm font-medium ${isActive ? 'text-brand-primary' : 'text-text-primary'}`}>
|
|
||||||
v{version.version_number}
|
|
||||||
</span>
|
|
||||||
{isLatest && (
|
|
||||||
<span className="text-[9px] px-1.5 py-px bg-emerald-100 text-emerald-700 rounded font-semibold uppercase">
|
|
||||||
Latest
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{version.notes && (
|
|
||||||
<p className="text-[11px] text-text-tertiary line-clamp-1 mt-0.5">{version.notes}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{(version.creator_name || version.created_at) && (
|
|
||||||
<div className="flex items-center gap-2 mt-2 ms-[38px] text-[10px] text-text-quaternary">
|
|
||||||
{version.creator_name && <span>{version.creator_name}</span>}
|
|
||||||
{version.creator_name && version.created_at && <span>·</span>}
|
|
||||||
{version.created_at && <span>{new Date(version.created_at).toLocaleDateString()}</span>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Version Content (right side) */}
|
|
||||||
<div className="flex-1 min-w-0 overflow-y-auto p-6">
|
|
||||||
{selectedVersion && versionData ? (
|
|
||||||
<div className="space-y-6 w-full">
|
|
||||||
{/* Languages */}
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Globe className="w-4 h-4 text-text-tertiary" />
|
|
||||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wide">{t('posts.languages')}</h4>
|
|
||||||
{versionData.texts?.length > 0 && (
|
|
||||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-surface-tertiary text-text-tertiary font-medium">
|
|
||||||
{versionData.texts.length}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowLanguageModal(true)}
|
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-brand-primary border border-brand-primary/30 hover:bg-brand-primary/5 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<Plus className="w-3 h-3" />
|
|
||||||
{t('posts.addLanguage')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{versionData.texts && versionData.texts.length > 0 ? (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{versionData.texts.map(text => {
|
|
||||||
const tId = text.Id || text.id || text._id
|
|
||||||
return (
|
|
||||||
<div key={tId} className="rounded-xl border border-border overflow-hidden">
|
|
||||||
<div className="flex items-center justify-between px-4 py-2.5 bg-surface-secondary">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="px-2 py-0.5 bg-white border border-border rounded text-[11px] font-semibold uppercase text-text-secondary">{text.language_code}</span>
|
|
||||||
<span className="text-sm font-medium text-text-primary">{text.language_label}</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setConfirmDeleteLangId(tId)}
|
|
||||||
className="p-1.5 text-text-quaternary hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="px-4 py-3 text-sm whitespace-pre-wrap text-text-primary leading-relaxed" dir={text.language_code === 'ar' ? 'rtl' : 'ltr'}>
|
|
||||||
{text.content}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-8 rounded-xl border-2 border-dashed border-border">
|
|
||||||
<Globe className="w-8 h-8 text-text-quaternary mx-auto mb-2" />
|
|
||||||
<p className="text-sm text-text-tertiary">{t('posts.noLanguages')}</p>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowLanguageModal(true)}
|
|
||||||
className="mt-3 text-xs font-medium text-brand-primary hover:underline"
|
|
||||||
>
|
|
||||||
{t('posts.addLanguage')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Media / Attachments for this version */}
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<ImageIcon className="w-4 h-4 text-text-tertiary" />
|
|
||||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wide">{t('posts.media')}</h4>
|
|
||||||
{versionData.attachments?.length > 0 && (
|
|
||||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-surface-tertiary text-text-tertiary font-medium">
|
|
||||||
{versionData.attachments.length}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<label className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-brand-primary border border-brand-primary/30 hover:bg-brand-primary/5 rounded-lg cursor-pointer transition-colors">
|
|
||||||
<Upload className="w-3 h-3" />
|
|
||||||
{uploadingVersionFile ? t('posts.uploading') : t('posts.addImage')}
|
|
||||||
<input
|
|
||||||
ref={versionFileInputRef}
|
|
||||||
type="file"
|
|
||||||
multiple
|
|
||||||
className="hidden"
|
|
||||||
onChange={e => { handleVersionFileUpload(e.target.files); e.target.value = '' }}
|
|
||||||
disabled={uploadingVersionFile}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{versionData.attachments && versionData.attachments.length > 0 ? (
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
{versionData.attachments.map(att => {
|
|
||||||
const attId = att.Id || att.id || att._id
|
|
||||||
const attUrl = att.url || `/api/uploads/${att.filename}`
|
|
||||||
const name = att.original_name || att.filename
|
|
||||||
const mime = att.mime_type || ''
|
|
||||||
const isImage = mime.startsWith('image/')
|
|
||||||
const isVideo = mime.startsWith('video/')
|
|
||||||
return (
|
|
||||||
<div key={attId} className="relative group rounded-xl border border-border overflow-hidden bg-white hover:shadow-md transition-shadow">
|
|
||||||
{isImage ? (
|
|
||||||
<a href={attUrl} target="_blank" rel="noopener noreferrer">
|
|
||||||
<img src={attUrl} alt={name} className="w-full h-44 object-cover" />
|
|
||||||
</a>
|
|
||||||
) : isVideo ? (
|
|
||||||
<video src={attUrl} controls className="w-full h-44 object-cover" />
|
|
||||||
) : (
|
|
||||||
<a href={attUrl} target="_blank" rel="noopener noreferrer" className="flex items-center justify-center h-44 bg-surface-tertiary">
|
|
||||||
<FileText className="w-10 h-10 text-text-quaternary" />
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
<div className="flex items-center justify-between px-3 py-2 border-t border-border bg-surface-secondary/50">
|
|
||||||
<span className="text-[11px] text-text-secondary truncate">{name}</span>
|
|
||||||
<button
|
|
||||||
onClick={() => setConfirmDeleteAttId(attId)}
|
|
||||||
className="p-1 text-text-quaternary hover:text-red-500 opacity-0 group-hover:opacity-100 transition-all"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-8 rounded-xl border-2 border-dashed border-border">
|
|
||||||
<ImageIcon className="w-8 h-8 text-text-quaternary mx-auto mb-2" />
|
|
||||||
<p className="text-sm text-text-tertiary">{t('posts.noMedia')}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : versions.length > 0 ? (
|
|
||||||
<div className="flex items-center justify-center h-40">
|
|
||||||
<div className="w-6 h-6 border-2 border-brand-primary border-t-transparent rounded-full animate-spin" />
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ─── Platforms & Links Tab ─── */}
|
{/* ─── Platforms & Links Tab ─── */}
|
||||||
{activeTab === 'platforms' && (
|
{activeTab === 'platforms' && (
|
||||||
<div className="p-6 space-y-6 w-full">
|
<PostDetailPlatforms
|
||||||
<div>
|
form={form}
|
||||||
<div className="flex items-center gap-2 mb-3">
|
update={update}
|
||||||
<Share2 className="w-4 h-4 text-text-tertiary" />
|
updatePublicationLink={updatePublicationLink}
|
||||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wide">{t('posts.platforms')}</h4>
|
/>
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-2 p-3 rounded-xl bg-surface-secondary min-h-[44px]">
|
|
||||||
{Object.entries(PLATFORMS).map(([k, v]) => {
|
|
||||||
const checked = (form.platforms || []).includes(k)
|
|
||||||
return (
|
|
||||||
<label
|
|
||||||
key={k}
|
|
||||||
className={`flex items-center gap-1.5 text-xs px-3 py-2 rounded-lg cursor-pointer border transition-all ${
|
|
||||||
checked
|
|
||||||
? 'bg-white border-brand-primary/30 text-brand-primary font-medium shadow-sm'
|
|
||||||
: 'bg-white/50 border-transparent text-text-secondary hover:bg-white hover:shadow-sm'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={checked}
|
|
||||||
onChange={() => {
|
|
||||||
update('platforms', checked
|
|
||||||
? form.platforms.filter(p => p !== k)
|
|
||||||
: [...(form.platforms || []), k]
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
className="sr-only"
|
|
||||||
/>
|
|
||||||
<span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: v.color || '#888' }} />
|
|
||||||
{v.label}
|
|
||||||
</label>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{(form.platforms || []).length > 0 && (
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2 mb-3">
|
|
||||||
<Link2 className="w-4 h-4 text-text-tertiary" />
|
|
||||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wide">{t('posts.publicationLinks')}</h4>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2.5">
|
|
||||||
{(form.platforms || []).map(platformKey => {
|
|
||||||
const platformInfo = PLATFORMS[platformKey] || { label: platformKey }
|
|
||||||
const existingLink = (form.publication_links || []).find(l => l.platform === platformKey)
|
|
||||||
const linkUrl = existingLink?.url || ''
|
|
||||||
return (
|
|
||||||
<div key={platformKey} className="flex items-center gap-3 p-3 rounded-xl bg-surface-secondary">
|
|
||||||
<span className="text-xs font-medium text-text-primary w-28 shrink-0 flex items-center gap-2">
|
|
||||||
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: platformInfo.color || '#888' }} />
|
|
||||||
{platformInfo.label}
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
type="url"
|
|
||||||
value={linkUrl}
|
|
||||||
onChange={e => updatePublicationLink(platformKey, e.target.value)}
|
|
||||||
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
|
||||||
placeholder="https://..."
|
|
||||||
/>
|
|
||||||
{linkUrl && (
|
|
||||||
<a href={linkUrl} target="_blank" rel="noopener noreferrer" className="p-2 text-text-tertiary hover:text-brand-primary hover:bg-white rounded-lg transition-colors">
|
|
||||||
<ExternalLink className="w-4 h-4" />
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
{form.status === 'published' && (form.platforms || []).some(p => {
|
|
||||||
const link = (form.publication_links || []).find(l => l.platform === p)
|
|
||||||
return !link || !link.url?.trim()
|
|
||||||
}) && (
|
|
||||||
<p className="text-xs text-amber-600 mt-3 flex items-center gap-1.5">
|
|
||||||
<XCircle className="w-3.5 h-3.5" />
|
|
||||||
{t('posts.publishRequired')}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ─── Approval Tab ─── */}
|
{/* ─── Approval Tab ─── */}
|
||||||
{activeTab === 'approval' && (
|
{activeTab === 'approval' && (
|
||||||
<div className="p-6 space-y-5 w-full">
|
<PostDetailApproval
|
||||||
<div className="bg-surface-secondary rounded-xl p-4">
|
form={form}
|
||||||
<label className="block text-xs font-medium text-text-primary mb-2">{t('posts.approvers')}</label>
|
update={update}
|
||||||
<ApproverMultiSelect
|
post={post}
|
||||||
users={teamMembers || []}
|
isCreateMode={isCreateMode}
|
||||||
selected={form.approver_ids || []}
|
reviewUrl={reviewUrl}
|
||||||
onChange={ids => update('approver_ids', ids)}
|
copied={copied}
|
||||||
/>
|
submittingReview={submittingReview}
|
||||||
</div>
|
saving={saving}
|
||||||
|
teamMembers={teamMembers}
|
||||||
{!isCreateMode && (
|
onSubmitReview={handleSubmitReview}
|
||||||
<div className="space-y-4">
|
onCopyReviewLink={copyReviewLink}
|
||||||
{/* Approval status cards */}
|
onStatusAction={handleStatusAction}
|
||||||
{form.status === 'approved' && post.approved_by_name && (
|
/>
|
||||||
<div className="bg-emerald-50 border border-emerald-200 rounded-xl p-4 flex items-start gap-3">
|
|
||||||
<div className="w-8 h-8 rounded-full bg-emerald-100 flex items-center justify-center shrink-0 mt-0.5">
|
|
||||||
<CheckCircle2 className="w-4 h-4 text-emerald-600" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-semibold text-emerald-800">{t('posts.approvedBy')} {post.approved_by_name}</p>
|
|
||||||
{post.feedback && <p className="text-xs text-emerald-700 mt-1.5 leading-relaxed">{post.feedback}</p>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{form.status === 'rejected' && post.approved_by_name && (
|
|
||||||
<div className="bg-red-50 border border-red-200 rounded-xl p-4 flex items-start gap-3">
|
|
||||||
<div className="w-8 h-8 rounded-full bg-red-100 flex items-center justify-center shrink-0 mt-0.5">
|
|
||||||
<XCircle className="w-4 h-4 text-red-600" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-semibold text-red-800">{t('posts.rejectedBy')} {post.approved_by_name}</p>
|
|
||||||
{post.feedback && <p className="text-xs text-red-700 mt-1.5 leading-relaxed">{post.feedback}</p>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{form.status === 'in_review' && (
|
|
||||||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4 text-center">
|
|
||||||
<div className="w-10 h-10 rounded-full bg-amber-100 flex items-center justify-center mx-auto mb-2">
|
|
||||||
<Clock className="w-5 h-5 text-amber-600" />
|
|
||||||
</div>
|
|
||||||
<p className="text-sm font-semibold text-amber-800">{t('posts.awaitingReview')}</p>
|
|
||||||
<p className="text-xs text-amber-600 mt-1">{t('posts.awaitingReviewDesc')}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Review link */}
|
|
||||||
{reviewUrl && (
|
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
|
|
||||||
<div className="text-xs font-semibold text-blue-900 mb-2.5">{t('posts.reviewLinkTitle')}</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input type="text" value={reviewUrl} readOnly className="flex-1 px-3 py-2 text-xs bg-white border border-blue-200 rounded-lg font-mono" />
|
|
||||||
<button onClick={copyReviewLink} className="p-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors shadow-sm">
|
|
||||||
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Action buttons */}
|
|
||||||
<div className="flex gap-3">
|
|
||||||
{!reviewUrl && (
|
|
||||||
<button
|
|
||||||
onClick={handleSubmitReview}
|
|
||||||
disabled={submittingReview}
|
|
||||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-amber-500 text-white rounded-xl hover:bg-amber-600 transition-colors font-medium text-sm disabled:opacity-50 shadow-sm"
|
|
||||||
>
|
|
||||||
<Send className="w-4 h-4" />
|
|
||||||
{submittingReview ? t('posts.submitting') : t('posts.sendToReview')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{form.status === 'approved' && (
|
|
||||||
<button
|
|
||||||
onClick={() => handleStatusAction('scheduled')}
|
|
||||||
disabled={saving}
|
|
||||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-purple-600 text-white rounded-xl hover:bg-purple-700 transition-colors font-medium text-sm disabled:opacity-50 shadow-sm"
|
|
||||||
>
|
|
||||||
{t('posts.schedule')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ─── Discussion Tab ─── */}
|
{/* ─── Discussion Tab ─── */}
|
||||||
@@ -1014,319 +618,6 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
|
|||||||
>
|
>
|
||||||
{t('posts.deleteConfirm')}
|
{t('posts.deleteConfirm')}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* New Version Modal */}
|
|
||||||
<Modal
|
|
||||||
isOpen={showNewVersionModal}
|
|
||||||
onClose={() => { setShowNewVersionModal(false); setNewVersionNotes(''); setCopyFromPrevious(false) }}
|
|
||||||
title={t('posts.createNewVersion')}
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<textarea
|
|
||||||
value={newVersionNotes}
|
|
||||||
onChange={e => setNewVersionNotes(e.target.value)}
|
|
||||||
rows={3}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary resize-none"
|
|
||||||
placeholder={t('posts.whatChanged')}
|
|
||||||
/>
|
|
||||||
{versions.length > 0 && (
|
|
||||||
<label className="flex items-center gap-2 text-sm text-text-secondary cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={copyFromPrevious}
|
|
||||||
onChange={e => setCopyFromPrevious(e.target.checked)}
|
|
||||||
className="rounded border-border text-brand-primary focus:ring-brand-primary/20"
|
|
||||||
/>
|
|
||||||
{t('posts.copyLanguages')}
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={handleCreateVersion}
|
|
||||||
disabled={creatingVersion}
|
|
||||||
className="w-full px-4 py-2.5 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 shadow-sm"
|
|
||||||
>
|
|
||||||
{creatingVersion ? t('posts.creatingVersion') : t('posts.createVersion')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
{/* Add Language Modal */}
|
|
||||||
<Modal
|
|
||||||
isOpen={showLanguageModal}
|
|
||||||
onClose={() => { setShowLanguageModal(false); setLanguageForm({ language_code: '', language_label: '', content: '' }) }}
|
|
||||||
title={t('posts.addLanguage')}
|
|
||||||
size="md"
|
|
||||||
>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<select
|
|
||||||
value={languageForm.language_code}
|
|
||||||
onChange={e => {
|
|
||||||
const lang = AVAILABLE_LANGUAGES.find(l => l.code === e.target.value)
|
|
||||||
setLanguageForm(f => ({ ...f, language_code: e.target.value, language_label: lang?.label || e.target.value }))
|
|
||||||
}}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
|
||||||
>
|
|
||||||
<option value="">{t('posts.selectLanguage')}</option>
|
|
||||||
{AVAILABLE_LANGUAGES
|
|
||||||
.filter(lang => !(versionData?.texts || []).some(t => t.language_code === lang.code))
|
|
||||||
.map(lang => (
|
|
||||||
<option key={lang.code} value={lang.code}>{lang.label} ({lang.code.toUpperCase()})</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<textarea
|
|
||||||
value={languageForm.content}
|
|
||||||
onChange={e => setLanguageForm(f => ({ ...f, content: e.target.value }))}
|
|
||||||
rows={8}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary resize-none"
|
|
||||||
placeholder={t('posts.enterContent')}
|
|
||||||
dir={languageForm.language_code === 'ar' ? 'rtl' : 'ltr'}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={handleAddLanguage}
|
|
||||||
disabled={savingLanguage || !languageForm.language_code || !languageForm.content}
|
|
||||||
className="w-full px-4 py-2.5 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 shadow-sm"
|
|
||||||
>
|
|
||||||
{savingLanguage ? t('common.loading') : t('common.save')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
{/* Delete Language Confirmation */}
|
|
||||||
<Modal
|
|
||||||
isOpen={!!confirmDeleteLangId}
|
|
||||||
onClose={() => setConfirmDeleteLangId(null)}
|
|
||||||
title={t('posts.deleteLanguage')}
|
|
||||||
isConfirm
|
|
||||||
danger
|
|
||||||
confirmText={t('common.delete')}
|
|
||||||
onConfirm={() => handleDeleteLanguage(confirmDeleteLangId)}
|
|
||||||
>
|
|
||||||
{t('posts.deleteLanguageConfirm')}
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
{/* Delete Version Attachment Confirmation */}
|
|
||||||
<Modal
|
|
||||||
isOpen={!!confirmDeleteAttId}
|
|
||||||
onClose={() => setConfirmDeleteAttId(null)}
|
|
||||||
title={t('posts.deleteAttachment')}
|
|
||||||
isConfirm
|
|
||||||
danger
|
|
||||||
confirmText={t('common.delete')}
|
|
||||||
onConfirm={() => handleDeleteVersionAttachment(confirmDeleteAttId)}
|
|
||||||
>
|
|
||||||
{t('posts.deleteConfirm')}
|
|
||||||
</Modal>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
||||||
// ─── Render legacy attachments helper ──────────────────────────
|
|
||||||
function renderAttachments() {
|
|
||||||
const images = attachments.filter(a => (a.mime_type || a.mimeType || '').startsWith('image/'))
|
|
||||||
const audio = attachments.filter(a => (a.mime_type || a.mimeType || '').startsWith('audio/'))
|
|
||||||
const videos = attachments.filter(a => (a.mime_type || a.mimeType || '').startsWith('video/'))
|
|
||||||
const others = attachments.filter(a => {
|
|
||||||
const mime = a.mime_type || a.mimeType || ''
|
|
||||||
return !mime.startsWith('image/') && !mime.startsWith('audio/') && !mime.startsWith('video/')
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Images */}
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<div className="flex items-center gap-1.5 text-xs font-medium text-text-secondary">
|
|
||||||
<ImageIcon className="w-3.5 h-3.5" />
|
|
||||||
{t('posts.images')}
|
|
||||||
{images.length > 0 && <span className="text-text-tertiary">({images.length})</span>}
|
|
||||||
</div>
|
|
||||||
<label className="flex items-center gap-1 px-2 py-1 text-[11px] font-medium text-brand-primary hover:bg-brand-primary/5 rounded cursor-pointer transition-colors">
|
|
||||||
<Upload className="w-3 h-3" />
|
|
||||||
{t('posts.addImage')}
|
|
||||||
<input ref={imageInputRef} type="file" multiple accept="image/*" className="hidden"
|
|
||||||
onChange={e => { handleFileUpload(e.target.files); e.target.value = '' }} />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
{images.length > 0 && (
|
|
||||||
<div className="grid grid-cols-3 gap-2">
|
|
||||||
{images.map(att => {
|
|
||||||
const attUrl = att.url || `/api/uploads/${att.filename}`
|
|
||||||
const name = att.original_name || att.originalName || att.filename
|
|
||||||
const attId = att.id || att._id
|
|
||||||
return (
|
|
||||||
<div key={attId} className="relative group/att border border-border rounded-lg overflow-hidden bg-white">
|
|
||||||
<div className="h-20 relative">
|
|
||||||
<a href={attUrl} target="_blank" rel="noopener noreferrer" className="block h-full">
|
|
||||||
<img src={attUrl} alt={name} className="absolute inset-0 w-full h-full object-cover" />
|
|
||||||
</a>
|
|
||||||
<button onClick={() => handleDeleteAttachment(attId)}
|
|
||||||
className="absolute top-1 right-1 p-1 bg-black/50 hover:bg-red-500 rounded-full text-white opacity-0 group-hover/att:opacity-100 transition-opacity"
|
|
||||||
title={t('common.delete')}><X className="w-2.5 h-2.5" /></button>
|
|
||||||
</div>
|
|
||||||
<div className="px-2 py-1 text-[10px] text-text-tertiary truncate border-t border-border-light">{name}</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Audio */}
|
|
||||||
{audio.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-1.5 text-xs font-medium text-text-secondary mb-2">
|
|
||||||
<Music className="w-3.5 h-3.5" />
|
|
||||||
{t('posts.audio')} <span className="text-text-tertiary">({audio.length})</span>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{audio.map(att => {
|
|
||||||
const attUrl = att.url || `/api/uploads/${att.filename}`
|
|
||||||
const name = att.original_name || att.originalName || att.filename
|
|
||||||
const attId = att.id || att._id
|
|
||||||
return (
|
|
||||||
<div key={attId} className="flex items-center gap-2 border border-border rounded-lg p-2 bg-white group/att">
|
|
||||||
<Music className="w-4 h-4 text-text-tertiary shrink-0" />
|
|
||||||
<span className="text-xs text-text-secondary truncate flex-1">{name}</span>
|
|
||||||
<audio src={attUrl} controls className="h-7 max-w-[160px]" />
|
|
||||||
<button onClick={() => handleDeleteAttachment(attId)}
|
|
||||||
className="p-1 text-text-tertiary hover:text-red-500 opacity-0 group-hover/att:opacity-100 transition-opacity"
|
|
||||||
title={t('common.delete')}><X className="w-3 h-3" /></button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Videos */}
|
|
||||||
{videos.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-1.5 text-xs font-medium text-text-secondary mb-2">
|
|
||||||
<Film className="w-3.5 h-3.5" />
|
|
||||||
{t('posts.videos')} <span className="text-text-tertiary">({videos.length})</span>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{videos.map(att => {
|
|
||||||
const attUrl = att.url || `/api/uploads/${att.filename}`
|
|
||||||
const name = att.original_name || att.originalName || att.filename
|
|
||||||
const attId = att.id || att._id
|
|
||||||
return (
|
|
||||||
<div key={attId} className="border border-border rounded-lg overflow-hidden bg-white group/att">
|
|
||||||
<video src={attUrl} controls className="w-full max-h-40" />
|
|
||||||
<div className="flex items-center justify-between px-2 py-1 border-t border-border-light">
|
|
||||||
<span className="text-[10px] text-text-tertiary truncate">{name}</span>
|
|
||||||
<button onClick={() => handleDeleteAttachment(attId)}
|
|
||||||
className="p-1 text-text-tertiary hover:text-red-500 opacity-0 group-hover/att:opacity-100 transition-opacity"
|
|
||||||
title={t('common.delete')}><X className="w-3 h-3" /></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Other files */}
|
|
||||||
{others.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-1.5 text-xs font-medium text-text-secondary mb-2">
|
|
||||||
<FileText className="w-3.5 h-3.5" />
|
|
||||||
{t('posts.otherFiles')} <span className="text-text-tertiary">({others.length})</span>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-3 gap-2">
|
|
||||||
{others.map(att => {
|
|
||||||
const attUrl = att.url || `/api/uploads/${att.filename}`
|
|
||||||
const name = att.original_name || att.originalName || att.filename
|
|
||||||
const attId = att.id || att._id
|
|
||||||
return (
|
|
||||||
<div key={attId} className="relative group/att border border-border rounded-lg overflow-hidden bg-white">
|
|
||||||
<a href={attUrl} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 p-3 h-16">
|
|
||||||
<FileText className="w-6 h-6 text-text-tertiary shrink-0" />
|
|
||||||
<span className="text-xs text-text-secondary truncate">{name}</span>
|
|
||||||
</a>
|
|
||||||
<button onClick={() => handleDeleteAttachment(attId)}
|
|
||||||
className="absolute top-1 right-1 p-1 bg-black/50 hover:bg-red-500 rounded-full text-white opacity-0 group-hover/att:opacity-100 transition-opacity"
|
|
||||||
title={t('common.delete')}><X className="w-2.5 h-2.5" /></button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Drag and drop zone */}
|
|
||||||
<div
|
|
||||||
className={`border-2 border-dashed rounded-lg p-3 text-center cursor-pointer transition-colors ${
|
|
||||||
dragActive ? 'border-brand-primary bg-brand-primary/5' : 'border-border hover:border-brand-primary/40'
|
|
||||||
}`}
|
|
||||||
onDragEnter={e => { e.preventDefault(); setDragActive(true) }}
|
|
||||||
onDragLeave={e => { e.preventDefault(); setDragActive(false) }}
|
|
||||||
onDragOver={e => e.preventDefault()}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
>
|
|
||||||
<Upload className={`w-4 h-4 text-text-tertiary mx-auto mb-1 ${uploading ? 'animate-pulse' : ''}`} />
|
|
||||||
<p className="text-[11px] text-text-secondary">
|
|
||||||
{dragActive ? t('posts.dropFiles') : t('posts.dragToUpload')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={openAssetPicker}
|
|
||||||
className="flex items-center gap-2 px-3 py-2 text-sm text-text-secondary border border-border rounded-lg hover:bg-surface-tertiary transition-colors w-full justify-center"
|
|
||||||
>
|
|
||||||
<FolderOpen className="w-4 h-4" />
|
|
||||||
{t('posts.attachFromAssets')}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{showAssetPicker && (
|
|
||||||
<div className="border border-border rounded-lg p-3 bg-surface-secondary">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<p className="text-xs font-medium text-text-secondary">{t('posts.selectAssets')}</p>
|
|
||||||
<button onClick={() => setShowAssetPicker(false)} className="p-1 text-text-tertiary hover:text-text-primary">
|
|
||||||
<X className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={assetSearch}
|
|
||||||
onChange={e => setAssetSearch(e.target.value)}
|
|
||||||
placeholder={t('common.search')}
|
|
||||||
className="w-full px-3 py-1.5 text-xs border border-border rounded-lg mb-2 focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
|
||||||
/>
|
|
||||||
<div className="grid grid-cols-4 gap-2 max-h-48 overflow-y-auto">
|
|
||||||
{availableAssets
|
|
||||||
.filter(a => !assetSearch || (a.original_name || a.filename || '').toLowerCase().includes(assetSearch.toLowerCase()))
|
|
||||||
.map(asset => {
|
|
||||||
const isImage = asset.mime_type?.startsWith('image/')
|
|
||||||
const assetUrl = `/api/uploads/${asset.filename}`
|
|
||||||
const name = asset.original_name || asset.filename
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={asset.id || asset._id}
|
|
||||||
onClick={() => handleAttachAsset(asset.id || asset._id)}
|
|
||||||
className="block w-full border border-border rounded-lg overflow-hidden bg-white hover:border-brand-primary hover:ring-2 hover:ring-brand-primary/20 transition-all text-left"
|
|
||||||
>
|
|
||||||
<div className="aspect-square relative">
|
|
||||||
{isImage ? (
|
|
||||||
<img src={assetUrl} alt={name} className="absolute inset-0 w-full h-full object-cover" />
|
|
||||||
) : (
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-surface-tertiary">
|
|
||||||
<FileText className="w-6 h-6 text-text-tertiary" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="px-1.5 py-1 text-[10px] text-text-tertiary truncate border-t border-border-light">{name}</div>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
{availableAssets.filter(a => !assetSearch || (a.original_name || a.filename || '').toLowerCase().includes(assetSearch.toLowerCase())).length === 0 && (
|
|
||||||
<p className="text-xs text-text-tertiary text-center py-4">{t('posts.noAssetsFound')}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { Link2, ExternalLink, XCircle, Share2 } from 'lucide-react'
|
||||||
|
import { useLanguage } from '../i18n/LanguageContext'
|
||||||
|
import { PLATFORMS } from '../utils/api'
|
||||||
|
|
||||||
|
export function PostDetailPlatforms({ form, update, updatePublicationLink }) {
|
||||||
|
const { t } = useLanguage()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6 w-full">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Share2 className="w-4 h-4 text-text-tertiary" />
|
||||||
|
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wide">{t('posts.platforms')}</h4>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2 p-3 rounded-xl bg-surface-secondary min-h-[44px]">
|
||||||
|
{Object.entries(PLATFORMS).map(([k, v]) => {
|
||||||
|
const checked = (form.platforms || []).includes(k)
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={k}
|
||||||
|
className={`flex items-center gap-1.5 text-xs px-3 py-2 rounded-lg cursor-pointer border transition-all ${
|
||||||
|
checked
|
||||||
|
? 'bg-surface border-brand-primary/30 text-brand-primary font-medium shadow-sm'
|
||||||
|
: 'bg-white/50 border-transparent text-text-secondary hover:bg-surface hover:shadow-sm'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={() => {
|
||||||
|
update('platforms', checked
|
||||||
|
? form.platforms.filter(p => p !== k)
|
||||||
|
: [...(form.platforms || []), k]
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
className="sr-only"
|
||||||
|
/>
|
||||||
|
<span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: v.color || '#888' }} />
|
||||||
|
{v.label}
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(form.platforms || []).length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Link2 className="w-4 h-4 text-text-tertiary" />
|
||||||
|
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wide">{t('posts.publicationLinks')}</h4>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
{(form.platforms || []).map(platformKey => {
|
||||||
|
const platformInfo = PLATFORMS[platformKey] || { label: platformKey }
|
||||||
|
const existingLink = (form.publication_links || []).find(l => l.platform === platformKey)
|
||||||
|
const linkUrl = existingLink?.url || ''
|
||||||
|
return (
|
||||||
|
<div key={platformKey} className="flex items-center gap-3 p-3 rounded-xl bg-surface-secondary">
|
||||||
|
<span className="text-xs font-medium text-text-primary w-28 shrink-0 flex items-center gap-2">
|
||||||
|
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: platformInfo.color || '#888' }} />
|
||||||
|
{platformInfo.label}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={linkUrl}
|
||||||
|
onChange={e => updatePublicationLink(platformKey, e.target.value)}
|
||||||
|
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||||
|
placeholder="https://..."
|
||||||
|
/>
|
||||||
|
{linkUrl && (
|
||||||
|
<a href={linkUrl} target="_blank" rel="noopener noreferrer" className="p-2 text-text-tertiary hover:text-brand-primary hover:bg-surface rounded-lg transition-colors">
|
||||||
|
<ExternalLink className="w-4 h-4" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{form.status === 'published' && (form.platforms || []).some(p => {
|
||||||
|
const link = (form.publication_links || []).find(l => l.platform === p)
|
||||||
|
return !link || !link.url?.trim()
|
||||||
|
}) && (
|
||||||
|
<p className="text-xs text-amber-600 mt-3 flex items-center gap-1.5">
|
||||||
|
<XCircle className="w-3.5 h-3.5" />
|
||||||
|
{t('posts.publishRequired')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,391 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Trash2, Upload, FileText, Image as ImageIcon, Plus, Globe, Layers } from 'lucide-react'
|
||||||
|
import { useLanguage } from '../i18n/LanguageContext'
|
||||||
|
import Modal from './Modal'
|
||||||
|
|
||||||
|
const AVAILABLE_LANGUAGES = [
|
||||||
|
{ code: 'ar', label: 'Arabic' },
|
||||||
|
{ code: 'en', label: 'English' },
|
||||||
|
{ code: 'fr', label: 'French' },
|
||||||
|
{ code: 'id', label: 'Bahasa Indonesia' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function PostDetailVersions({
|
||||||
|
versions,
|
||||||
|
selectedVersion,
|
||||||
|
versionData,
|
||||||
|
onSelectVersion,
|
||||||
|
onCreateVersion,
|
||||||
|
onAddLanguage,
|
||||||
|
onDeleteLanguage,
|
||||||
|
onVersionFileUpload,
|
||||||
|
onDeleteVersionAttachment,
|
||||||
|
uploadingVersionFile,
|
||||||
|
versionFileInputRef,
|
||||||
|
}) {
|
||||||
|
const { t } = useLanguage()
|
||||||
|
const [showNewVersionModal, setShowNewVersionModal] = useState(false)
|
||||||
|
const [newVersionNotes, setNewVersionNotes] = useState('')
|
||||||
|
const [copyFromPrevious, setCopyFromPrevious] = useState(false)
|
||||||
|
const [creatingVersion, setCreatingVersion] = useState(false)
|
||||||
|
const [showLanguageModal, setShowLanguageModal] = useState(false)
|
||||||
|
const [languageForm, setLanguageForm] = useState({ language_code: '', language_label: '', content: '' })
|
||||||
|
const [savingLanguage, setSavingLanguage] = useState(false)
|
||||||
|
const [confirmDeleteLangId, setConfirmDeleteLangId] = useState(null)
|
||||||
|
const [confirmDeleteAttId, setConfirmDeleteAttId] = useState(null)
|
||||||
|
|
||||||
|
const handleCreateVersion = async () => {
|
||||||
|
setCreatingVersion(true)
|
||||||
|
try {
|
||||||
|
await onCreateVersion({ notes: newVersionNotes || undefined, copy_from_previous: copyFromPrevious })
|
||||||
|
setShowNewVersionModal(false)
|
||||||
|
setNewVersionNotes('')
|
||||||
|
setCopyFromPrevious(false)
|
||||||
|
} finally {
|
||||||
|
setCreatingVersion(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddLanguage = async () => {
|
||||||
|
if (!selectedVersion || !languageForm.language_code || !languageForm.content) return
|
||||||
|
setSavingLanguage(true)
|
||||||
|
try {
|
||||||
|
await onAddLanguage(languageForm)
|
||||||
|
setShowLanguageModal(false)
|
||||||
|
setLanguageForm({ language_code: '', language_label: '', content: '' })
|
||||||
|
} finally {
|
||||||
|
setSavingLanguage(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteLanguage = async (textId) => {
|
||||||
|
await onDeleteLanguage(textId)
|
||||||
|
setConfirmDeleteLangId(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteAttachment = async (attId) => {
|
||||||
|
await onDeleteVersionAttachment(attId)
|
||||||
|
setConfirmDeleteAttId(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex h-full">
|
||||||
|
{/* Version Timeline (left sidebar) */}
|
||||||
|
<div className="w-64 shrink-0 border-e border-border p-4 overflow-y-auto bg-surface-secondary/50">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wide">{t('posts.versions')}</h4>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowNewVersionModal(true)}
|
||||||
|
className="flex items-center gap-1 px-2.5 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors shadow-sm"
|
||||||
|
>
|
||||||
|
<Plus className="w-3 h-3" />
|
||||||
|
{t('posts.newVersion')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{versions.length === 0 ? (
|
||||||
|
<div className="text-center py-10">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-surface-tertiary flex items-center justify-center mx-auto mb-3">
|
||||||
|
<Layers className="w-6 h-6 text-text-quaternary" />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-text-tertiary leading-relaxed px-2">{t('posts.noVersions')}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{versions.map((version, idx) => {
|
||||||
|
const vId = version.Id || version.id || version._id
|
||||||
|
const isActive = vId === (selectedVersion?.Id || selectedVersion?.id || selectedVersion?._id)
|
||||||
|
const isLatest = idx === versions.length - 1
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={vId}
|
||||||
|
onClick={() => onSelectVersion(version)}
|
||||||
|
className={`w-full text-start p-3 rounded-xl border transition-all ${
|
||||||
|
isActive
|
||||||
|
? 'border-brand-primary bg-surface shadow-sm ring-1 ring-brand-primary/20'
|
||||||
|
: 'border-transparent hover:bg-surface hover:border-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className={`flex-shrink-0 w-7 h-7 rounded-lg flex items-center justify-center text-[11px] font-bold ${
|
||||||
|
isActive ? 'bg-brand-primary text-white' : 'bg-surface-tertiary text-text-secondary'
|
||||||
|
}`}>
|
||||||
|
{version.version_number}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className={`text-sm font-medium ${isActive ? 'text-brand-primary' : 'text-text-primary'}`}>
|
||||||
|
v{version.version_number}
|
||||||
|
</span>
|
||||||
|
{isLatest && (
|
||||||
|
<span className="text-[9px] px-1.5 py-px bg-emerald-100 text-emerald-700 rounded font-semibold uppercase">
|
||||||
|
Latest
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{version.notes && (
|
||||||
|
<p className="text-[11px] text-text-tertiary line-clamp-1 mt-0.5">{version.notes}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{(version.creator_name || version.created_at) && (
|
||||||
|
<div className="flex items-center gap-2 mt-2 ms-[38px] text-[10px] text-text-quaternary">
|
||||||
|
{version.creator_name && <span>{version.creator_name}</span>}
|
||||||
|
{version.creator_name && version.created_at && <span>·</span>}
|
||||||
|
{version.created_at && <span>{new Date(version.created_at).toLocaleDateString()}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Version Content (right side) */}
|
||||||
|
<div className="flex-1 min-w-0 overflow-y-auto p-6">
|
||||||
|
{selectedVersion && versionData ? (
|
||||||
|
<div className="space-y-6 w-full">
|
||||||
|
{/* Languages */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Globe className="w-4 h-4 text-text-tertiary" />
|
||||||
|
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wide">{t('posts.languages')}</h4>
|
||||||
|
{versionData.texts?.length > 0 && (
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-surface-tertiary text-text-tertiary font-medium">
|
||||||
|
{versionData.texts.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowLanguageModal(true)}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-brand-primary border border-brand-primary/30 hover:bg-brand-primary/5 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="w-3 h-3" />
|
||||||
|
{t('posts.addLanguage')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{versionData.texts && versionData.texts.length > 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{versionData.texts.map(text => {
|
||||||
|
const tId = text.Id || text.id || text._id
|
||||||
|
return (
|
||||||
|
<div key={tId} className="rounded-xl border border-border overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between px-4 py-2.5 bg-surface-secondary">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="px-2 py-0.5 bg-surface border border-border rounded text-[11px] font-semibold uppercase text-text-secondary">{text.language_code}</span>
|
||||||
|
<span className="text-sm font-medium text-text-primary">{text.language_label}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmDeleteLangId(tId)}
|
||||||
|
className="p-1.5 text-text-quaternary hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-3 text-sm whitespace-pre-wrap text-text-primary leading-relaxed" dir={text.language_code === 'ar' ? 'rtl' : 'ltr'}>
|
||||||
|
{text.content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 rounded-xl border-2 border-dashed border-border">
|
||||||
|
<Globe className="w-8 h-8 text-text-quaternary mx-auto mb-2" />
|
||||||
|
<p className="text-sm text-text-tertiary">{t('posts.noLanguages')}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowLanguageModal(true)}
|
||||||
|
className="mt-3 text-xs font-medium text-brand-primary hover:underline"
|
||||||
|
>
|
||||||
|
{t('posts.addLanguage')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Media / Attachments for this version */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ImageIcon className="w-4 h-4 text-text-tertiary" />
|
||||||
|
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wide">{t('posts.media')}</h4>
|
||||||
|
{versionData.attachments?.length > 0 && (
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-surface-tertiary text-text-tertiary font-medium">
|
||||||
|
{versionData.attachments.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-brand-primary border border-brand-primary/30 hover:bg-brand-primary/5 rounded-lg cursor-pointer transition-colors">
|
||||||
|
<Upload className="w-3 h-3" />
|
||||||
|
{uploadingVersionFile ? t('posts.uploading') : t('posts.addImage')}
|
||||||
|
<input
|
||||||
|
ref={versionFileInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={e => { onVersionFileUpload(e.target.files); e.target.value = '' }}
|
||||||
|
disabled={uploadingVersionFile}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{versionData.attachments && versionData.attachments.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{versionData.attachments.map(att => {
|
||||||
|
const attId = att.Id || att.id || att._id
|
||||||
|
const attUrl = att.url || `/api/uploads/${att.filename}`
|
||||||
|
const name = att.original_name || att.filename
|
||||||
|
const mime = att.mime_type || ''
|
||||||
|
const isImage = mime.startsWith('image/')
|
||||||
|
const isVideo = mime.startsWith('video/')
|
||||||
|
return (
|
||||||
|
<div key={attId} className="relative group rounded-xl border border-border overflow-hidden bg-surface hover:shadow-md transition-shadow">
|
||||||
|
{isImage ? (
|
||||||
|
<a href={attUrl} target="_blank" rel="noopener noreferrer">
|
||||||
|
<img src={attUrl} alt={name} className="w-full h-44 object-cover" loading="lazy" />
|
||||||
|
</a>
|
||||||
|
) : isVideo ? (
|
||||||
|
<video src={attUrl} controls className="w-full h-44 object-cover" />
|
||||||
|
) : (
|
||||||
|
<a href={attUrl} target="_blank" rel="noopener noreferrer" className="flex items-center justify-center h-44 bg-surface-tertiary">
|
||||||
|
<FileText className="w-10 h-10 text-text-quaternary" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center justify-between px-3 py-2 border-t border-border bg-surface-secondary/50">
|
||||||
|
<span className="text-[11px] text-text-secondary truncate">{name}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmDeleteAttId(attId)}
|
||||||
|
className="p-1 text-text-quaternary hover:text-red-500 opacity-0 group-hover:opacity-100 transition-all"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 rounded-xl border-2 border-dashed border-border">
|
||||||
|
<ImageIcon className="w-8 h-8 text-text-quaternary mx-auto mb-2" />
|
||||||
|
<p className="text-sm text-text-tertiary">{t('posts.noMedia')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : versions.length > 0 ? (
|
||||||
|
<div className="flex items-center justify-center h-40">
|
||||||
|
<div className="w-6 h-6 border-2 border-brand-primary border-t-transparent rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* New Version Modal */}
|
||||||
|
<Modal
|
||||||
|
isOpen={showNewVersionModal}
|
||||||
|
onClose={() => { setShowNewVersionModal(false); setNewVersionNotes(''); setCopyFromPrevious(false) }}
|
||||||
|
title={t('posts.createNewVersion')}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<textarea
|
||||||
|
value={newVersionNotes}
|
||||||
|
onChange={e => setNewVersionNotes(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary resize-none"
|
||||||
|
placeholder={t('posts.whatChanged')}
|
||||||
|
/>
|
||||||
|
{versions.length > 0 && (
|
||||||
|
<label className="flex items-center gap-2 text-sm text-text-secondary cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={copyFromPrevious}
|
||||||
|
onChange={e => setCopyFromPrevious(e.target.checked)}
|
||||||
|
className="rounded border-border text-brand-primary focus:ring-brand-primary/20"
|
||||||
|
/>
|
||||||
|
{t('posts.copyLanguages')}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleCreateVersion}
|
||||||
|
disabled={creatingVersion}
|
||||||
|
className="w-full px-4 py-2.5 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 shadow-sm"
|
||||||
|
>
|
||||||
|
{creatingVersion ? t('posts.creatingVersion') : t('posts.createVersion')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Add Language Modal */}
|
||||||
|
<Modal
|
||||||
|
isOpen={showLanguageModal}
|
||||||
|
onClose={() => { setShowLanguageModal(false); setLanguageForm({ language_code: '', language_label: '', content: '' }) }}
|
||||||
|
title={t('posts.addLanguage')}
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<select
|
||||||
|
value={languageForm.language_code}
|
||||||
|
onChange={e => {
|
||||||
|
const lang = AVAILABLE_LANGUAGES.find(l => l.code === e.target.value)
|
||||||
|
setLanguageForm(f => ({ ...f, language_code: e.target.value, language_label: lang?.label || e.target.value }))
|
||||||
|
}}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||||
|
>
|
||||||
|
<option value="">{t('posts.selectLanguage')}</option>
|
||||||
|
{AVAILABLE_LANGUAGES
|
||||||
|
.filter(lang => !(versionData?.texts || []).some(t => t.language_code === lang.code))
|
||||||
|
.map(lang => (
|
||||||
|
<option key={lang.code} value={lang.code}>{lang.label} ({lang.code.toUpperCase()})</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<textarea
|
||||||
|
value={languageForm.content}
|
||||||
|
onChange={e => setLanguageForm(f => ({ ...f, content: e.target.value }))}
|
||||||
|
rows={8}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary resize-none"
|
||||||
|
placeholder={t('posts.enterContent')}
|
||||||
|
dir={languageForm.language_code === 'ar' ? 'rtl' : 'ltr'}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleAddLanguage}
|
||||||
|
disabled={savingLanguage || !languageForm.language_code || !languageForm.content}
|
||||||
|
className="w-full px-4 py-2.5 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 shadow-sm"
|
||||||
|
>
|
||||||
|
{savingLanguage ? t('common.loading') : t('common.save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Delete Language Confirmation */}
|
||||||
|
<Modal
|
||||||
|
isOpen={!!confirmDeleteLangId}
|
||||||
|
onClose={() => setConfirmDeleteLangId(null)}
|
||||||
|
title={t('posts.deleteLanguage')}
|
||||||
|
isConfirm
|
||||||
|
danger
|
||||||
|
confirmText={t('common.delete')}
|
||||||
|
onConfirm={() => handleDeleteLanguage(confirmDeleteLangId)}
|
||||||
|
>
|
||||||
|
{t('posts.deleteLanguageConfirm')}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Delete Version Attachment Confirmation */}
|
||||||
|
<Modal
|
||||||
|
isOpen={!!confirmDeleteAttId}
|
||||||
|
onClose={() => setConfirmDeleteAttId(null)}
|
||||||
|
title={t('posts.deleteAttachment')}
|
||||||
|
isConfirm
|
||||||
|
danger
|
||||||
|
confirmText={t('common.delete')}
|
||||||
|
onConfirm={() => handleDeleteAttachment(confirmDeleteAttId)}
|
||||||
|
>
|
||||||
|
{t('posts.deleteConfirm')}
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -21,11 +21,11 @@ export default function ProjectCard({ project }) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={() => navigate(`/projects/${project._id}`)}
|
onClick={() => navigate(`/projects/${project._id}`)}
|
||||||
className="bg-white rounded-xl border border-border card-hover cursor-pointer overflow-hidden"
|
className="bg-surface rounded-xl border border-border card-hover cursor-pointer overflow-hidden"
|
||||||
>
|
>
|
||||||
{thumbnailUrl ? (
|
{thumbnailUrl ? (
|
||||||
<div className="w-full h-32 overflow-hidden">
|
<div className="w-full h-32 overflow-hidden">
|
||||||
<img src={thumbnailUrl} alt="" className="w-full h-full object-cover" />
|
<img src={thumbnailUrl} alt="" className="w-full h-full object-cover" loading="lazy" />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="p-5">
|
<div className="p-5">
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ export default function ProjectEditPanel({ project, onClose, onSave, onDelete, b
|
|||||||
form.status === 'paused' ? 'bg-amber-100 text-amber-700' :
|
form.status === 'paused' ? 'bg-amber-100 text-amber-700' :
|
||||||
form.status === 'completed' ? 'bg-blue-100 text-blue-700' :
|
form.status === 'completed' ? 'bg-blue-100 text-blue-700' :
|
||||||
form.status === 'cancelled' ? 'bg-red-100 text-red-700' :
|
form.status === 'cancelled' ? 'bg-red-100 text-red-700' :
|
||||||
'bg-gray-100 text-gray-600'
|
'bg-gray-100 text-text-secondary'
|
||||||
}`}>
|
}`}>
|
||||||
{statusOptions.find(s => s.value === form.status)?.label}
|
{statusOptions.find(s => s.value === form.status)?.label}
|
||||||
</span>
|
</span>
|
||||||
@@ -257,11 +257,11 @@ export default function ProjectEditPanel({ project, onClose, onSave, onDelete, b
|
|||||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('projects.thumbnail')}</label>
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('projects.thumbnail')}</label>
|
||||||
{(project.thumbnail_url || project.thumbnailUrl) ? (
|
{(project.thumbnail_url || project.thumbnailUrl) ? (
|
||||||
<div className="relative group rounded-lg overflow-hidden border border-border">
|
<div className="relative group rounded-lg overflow-hidden border border-border">
|
||||||
<img src={project.thumbnail_url || project.thumbnailUrl} alt="" className="w-full h-24 object-cover" />
|
<img src={project.thumbnail_url || project.thumbnailUrl} alt="" className="w-full h-24 object-cover" loading="lazy" />
|
||||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/30 transition-colors flex items-center justify-center gap-2 opacity-0 group-hover:opacity-100">
|
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/30 transition-colors flex items-center justify-center gap-2 opacity-0 group-hover:opacity-100">
|
||||||
<button
|
<button
|
||||||
onClick={() => thumbnailInputRef.current?.click()}
|
onClick={() => thumbnailInputRef.current?.click()}
|
||||||
className="px-3 py-1.5 text-xs bg-white/90 hover:bg-white rounded-lg font-medium text-text-primary transition-colors"
|
className="px-3 py-1.5 text-xs bg-white/90 hover:bg-surface rounded-lg font-medium text-text-primary transition-colors"
|
||||||
>
|
>
|
||||||
{t('projects.changeThumbnail')}
|
{t('projects.changeThumbnail')}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -3,8 +3,17 @@ import { NavLink } from 'react-router-dom'
|
|||||||
import {
|
import {
|
||||||
LayoutDashboard, FileEdit, Image, Calendar, Wallet,
|
LayoutDashboard, FileEdit, Image, Calendar, Wallet,
|
||||||
FolderKanban, CheckSquare, Users, ChevronLeft, ChevronRight, ChevronDown,
|
FolderKanban, CheckSquare, Users, ChevronLeft, ChevronRight, ChevronDown,
|
||||||
Sparkles, LogOut, User, Settings, Languages, Tag, LayoutList, Receipt, BarChart3, Palette, CalendarDays, AlertCircle
|
LogOut, User, Settings, Languages, Tag, LayoutList, Receipt, BarChart3, Palette, CalendarDays, AlertCircle
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
function MarkaLogo({ className = '' }) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 32 32" fill="none" className={className}>
|
||||||
|
<path d="M4 26V6l10 10L4 26z" fill="currentColor" opacity="0.85" />
|
||||||
|
<path d="M18 26V6l10 10-10 10z" fill="currentColor" opacity="0.5" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
import { useLanguage } from '../i18n/LanguageContext'
|
import { useLanguage } from '../i18n/LanguageContext'
|
||||||
|
|
||||||
@@ -115,8 +124,8 @@ export default function Sidebar({ collapsed, setCollapsed }) {
|
|||||||
>
|
>
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="flex items-center gap-3 px-4 h-16 border-b border-white/10 shrink-0">
|
<div className="flex items-center gap-3 px-4 h-16 border-b border-white/10 shrink-0">
|
||||||
<div className="w-9 h-9 rounded-lg bg-gradient-to-br from-indigo-400 via-purple-500 to-pink-500 flex items-center justify-center shrink-0 shadow-lg shadow-indigo-500/30">
|
<div className="w-9 h-9 rounded-lg bg-brand-primary flex items-center justify-center shrink-0">
|
||||||
<Sparkles className="w-5 h-5 text-white" />
|
<MarkaLogo className="w-5 h-5 text-white" />
|
||||||
</div>
|
</div>
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<div className="animate-fade-in overflow-hidden">
|
<div className="animate-fade-in overflow-hidden">
|
||||||
@@ -191,7 +200,7 @@ export default function Sidebar({ collapsed, setCollapsed }) {
|
|||||||
<div className="flex items-center gap-3 px-3 py-2 rounded-lg bg-white/5">
|
<div className="flex items-center gap-3 px-3 py-2 rounded-lg bg-white/5">
|
||||||
<div className="w-8 h-8 rounded-full bg-brand-primary flex items-center justify-center shrink-0">
|
<div className="w-8 h-8 rounded-full bg-brand-primary flex items-center justify-center shrink-0">
|
||||||
{currentUser.avatar ? (
|
{currentUser.avatar ? (
|
||||||
<img src={currentUser.avatar} alt={currentUser.name} className="w-full h-full rounded-full object-cover" />
|
<img src={currentUser.avatar} alt={currentUser.name} className="w-full h-full rounded-full object-cover" loading="lazy" />
|
||||||
) : (
|
) : (
|
||||||
<User className="w-4 h-4 text-white" />
|
<User className="w-4 h-4 text-white" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
export function SkeletonCard() {
|
export function SkeletonCard() {
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-xl border border-border p-5 animate-pulse">
|
<div className="bg-surface rounded-xl border border-border p-5 animate-pulse">
|
||||||
<div className="h-4 bg-surface-tertiary rounded w-3/4 mb-3"></div>
|
<div className="h-4 bg-surface-tertiary rounded w-3/4 mb-3"></div>
|
||||||
<div className="h-3 bg-surface-tertiary rounded w-1/2 mb-2"></div>
|
<div className="h-3 bg-surface-tertiary rounded w-1/2 mb-2"></div>
|
||||||
<div className="h-3 bg-surface-tertiary rounded w-2/3"></div>
|
<div className="h-3 bg-surface-tertiary rounded w-2/3"></div>
|
||||||
@@ -12,7 +12,7 @@ export function SkeletonCard() {
|
|||||||
|
|
||||||
export function SkeletonStatCard() {
|
export function SkeletonStatCard() {
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-xl border border-border p-5 animate-pulse">
|
<div className="bg-surface rounded-xl border border-border p-5 animate-pulse">
|
||||||
<div className="flex items-start justify-between mb-4">
|
<div className="flex items-start justify-between mb-4">
|
||||||
<div className="w-10 h-10 bg-surface-tertiary rounded-lg"></div>
|
<div className="w-10 h-10 bg-surface-tertiary rounded-lg"></div>
|
||||||
<div className="h-3 bg-surface-tertiary rounded w-16"></div>
|
<div className="h-3 bg-surface-tertiary rounded w-16"></div>
|
||||||
@@ -25,7 +25,7 @@ export function SkeletonStatCard() {
|
|||||||
|
|
||||||
export function SkeletonTable({ rows = 5, cols = 6 }) {
|
export function SkeletonTable({ rows = 5, cols = 6 }) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-xl border border-border overflow-hidden animate-pulse">
|
<div className="bg-surface rounded-xl border border-border overflow-hidden animate-pulse">
|
||||||
<div className="border-b border-border bg-surface-secondary p-4">
|
<div className="border-b border-border bg-surface-secondary p-4">
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
{[...Array(cols)].map((_, i) => (
|
{[...Array(cols)].map((_, i) => (
|
||||||
@@ -60,7 +60,7 @@ export function SkeletonKanbanBoard() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="bg-surface-secondary rounded-xl p-2 space-y-2 min-h-[400px]">
|
<div className="bg-surface-secondary rounded-xl p-2 space-y-2 min-h-[400px]">
|
||||||
{[...Array(3)].map((_, cardIdx) => (
|
{[...Array(3)].map((_, cardIdx) => (
|
||||||
<div key={cardIdx} className="bg-white rounded-lg border border-border p-3">
|
<div key={cardIdx} className="bg-surface rounded-lg border border-border p-3">
|
||||||
<div className="h-4 bg-surface-tertiary rounded w-full mb-2"></div>
|
<div className="h-4 bg-surface-tertiary rounded w-full mb-2"></div>
|
||||||
<div className="h-3 bg-surface-tertiary rounded w-3/4 mb-3"></div>
|
<div className="h-3 bg-surface-tertiary rounded w-3/4 mb-3"></div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -78,7 +78,7 @@ export function SkeletonKanbanBoard() {
|
|||||||
|
|
||||||
export function SkeletonCalendar() {
|
export function SkeletonCalendar() {
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-xl border border-border overflow-hidden animate-pulse">
|
<div className="bg-surface rounded-xl border border-border overflow-hidden animate-pulse">
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||||
<div className="h-6 bg-surface-tertiary rounded w-40"></div>
|
<div className="h-6 bg-surface-tertiary rounded w-40"></div>
|
||||||
<div className="h-8 bg-surface-tertiary rounded w-20"></div>
|
<div className="h-8 bg-surface-tertiary rounded w-20"></div>
|
||||||
@@ -138,7 +138,7 @@ export function SkeletonDashboard() {
|
|||||||
{/* Content cards */}
|
{/* Content cards */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
{[...Array(2)].map((_, i) => (
|
{[...Array(2)].map((_, i) => (
|
||||||
<div key={i} className="bg-white rounded-xl border border-border animate-pulse">
|
<div key={i} className="bg-surface rounded-xl border border-border animate-pulse">
|
||||||
<div className="px-5 py-4 border-b border-border">
|
<div className="px-5 py-4 border-b border-border">
|
||||||
<div className="h-5 bg-surface-tertiary rounded w-32"></div>
|
<div className="h-5 bg-surface-tertiary rounded w-32"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,45 @@
|
|||||||
|
import { useEffect, useRef, useCallback } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
|
|
||||||
export default function SlidePanel({ onClose, maxWidth = '420px', header, footer, children }) {
|
export default function SlidePanel({ onClose, maxWidth = '420px', header, footer, children }) {
|
||||||
|
const panelRef = useRef(null)
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback((e) => {
|
||||||
|
if (e.key === 'Escape') onClose()
|
||||||
|
}, [onClose])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!panelRef.current) return
|
||||||
|
const el = panelRef.current
|
||||||
|
const focusable = el.querySelectorAll(
|
||||||
|
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||||
|
)
|
||||||
|
if (focusable.length > 0) focusable[0].focus()
|
||||||
|
|
||||||
|
const handleTab = (e) => {
|
||||||
|
if (e.key !== 'Tab' || focusable.length === 0) return
|
||||||
|
const first = focusable[0]
|
||||||
|
const last = focusable[focusable.length - 1]
|
||||||
|
if (e.shiftKey) {
|
||||||
|
if (document.activeElement === first) { e.preventDefault(); last.focus() }
|
||||||
|
} else {
|
||||||
|
if (document.activeElement === last) { e.preventDefault(); first.focus() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
el.addEventListener('keydown', handleTab)
|
||||||
|
return () => el.removeEventListener('keydown', handleTab)
|
||||||
|
}, [])
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<>
|
<>
|
||||||
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm animate-backdrop-in z-[9998]" onClick={onClose} />
|
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm animate-backdrop-in z-[9998]" onClick={onClose} aria-label="Close panel" />
|
||||||
<div
|
<div
|
||||||
className="fixed top-0 right-0 h-full w-full bg-white shadow-2xl z-[9998] flex flex-col animate-slide-in-right overflow-hidden"
|
ref={panelRef}
|
||||||
|
className="fixed top-0 right-0 h-full w-full bg-surface shadow-2xl z-[9998] flex flex-col animate-slide-in-right overflow-hidden"
|
||||||
style={{ maxWidth }}
|
style={{ maxWidth }}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
>
|
>
|
||||||
{header}
|
{header}
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
|||||||
@@ -7,20 +7,20 @@ export default function StatCard({ icon: Icon, label, value, subtitle, color = '
|
|||||||
}
|
}
|
||||||
|
|
||||||
const iconBgMap = {
|
const iconBgMap = {
|
||||||
'brand-primary': 'bg-indigo-50 text-indigo-600 shadow-lg shadow-indigo-500/20',
|
'brand-primary': 'bg-teal-50 text-teal-700',
|
||||||
'brand-secondary': 'bg-pink-50 text-pink-600 shadow-lg shadow-pink-500/20',
|
'brand-secondary': 'bg-pink-50 text-pink-600',
|
||||||
'brand-tertiary': 'bg-amber-50 text-amber-600 shadow-lg shadow-amber-500/20',
|
'brand-tertiary': 'bg-amber-50 text-amber-600',
|
||||||
'brand-quaternary': 'bg-emerald-50 text-emerald-600 shadow-lg shadow-emerald-500/20',
|
'brand-quaternary': 'bg-teal-50 text-teal-600',
|
||||||
}
|
}
|
||||||
|
|
||||||
const accentClass = accentMap[color] || 'accent-primary'
|
const accentClass = accentMap[color] || 'accent-primary'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`stat-card-premium ${accentClass} bg-white rounded-xl border border-border p-5 card-hover`}>
|
<div className={`stat-card-premium ${accentClass} bg-surface rounded-xl border border-border p-5 card-hover`}>
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-text-tertiary">{label}</p>
|
<p className="text-sm font-medium text-text-tertiary">{label}</p>
|
||||||
<p className="text-3xl font-bold text-text-primary mt-1">{value}</p>
|
<p className="text-2xl font-bold text-text-primary mt-1">{value}</p>
|
||||||
{subtitle && (
|
{subtitle && (
|
||||||
<p className="text-xs text-text-tertiary mt-1">{subtitle}</p>
|
<p className="text-xs text-text-tertiary mt-1">{subtitle}</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect } from 'react'
|
import { useEffect, useRef, useCallback } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { X } from 'lucide-react'
|
import { X } from 'lucide-react'
|
||||||
|
|
||||||
@@ -19,26 +19,55 @@ export default function TabbedModal({
|
|||||||
footer,
|
footer,
|
||||||
children,
|
children,
|
||||||
}) {
|
}) {
|
||||||
|
const modalRef = useRef(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.body.style.overflow = 'hidden'
|
document.body.style.overflow = 'hidden'
|
||||||
return () => { document.body.style.overflow = '' }
|
return () => { document.body.style.overflow = '' }
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return createPortal(
|
useEffect(() => {
|
||||||
<div className="fixed inset-0 z-[9999] flex items-start justify-center pt-[5vh] px-4">
|
if (!modalRef.current) return
|
||||||
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm animate-backdrop-in" onClick={onClose} />
|
const el = modalRef.current
|
||||||
|
const focusable = el.querySelectorAll(
|
||||||
|
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||||
|
)
|
||||||
|
if (focusable.length > 0) focusable[0].focus()
|
||||||
|
|
||||||
<div className={`relative bg-white rounded-2xl shadow-2xl w-full ${SIZE_CLASSES[size] || SIZE_CLASSES.md} max-h-[90vh] flex flex-col animate-scale-in`}>
|
const handleTab = (e) => {
|
||||||
|
if (e.key !== 'Tab' || focusable.length === 0) return
|
||||||
|
const first = focusable[0]
|
||||||
|
const last = focusable[focusable.length - 1]
|
||||||
|
if (e.shiftKey) {
|
||||||
|
if (document.activeElement === first) { e.preventDefault(); last.focus() }
|
||||||
|
} else {
|
||||||
|
if (document.activeElement === last) { e.preventDefault(); first.focus() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
el.addEventListener('keydown', handleTab)
|
||||||
|
return () => el.removeEventListener('keydown', handleTab)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback((e) => {
|
||||||
|
if (e.key === 'Escape') onClose()
|
||||||
|
}, [onClose])
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className="fixed inset-0 z-[9999] flex items-start justify-center pt-[5vh] px-4" onKeyDown={handleKeyDown} ref={modalRef}>
|
||||||
|
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm animate-backdrop-in" onClick={onClose} aria-label="Close dialog" />
|
||||||
|
|
||||||
|
<div className={`relative bg-surface rounded-2xl shadow-2xl w-full ${SIZE_CLASSES[size] || SIZE_CLASSES.md} max-h-[90vh] flex flex-col animate-scale-in`} role="dialog" aria-modal="true" aria-labelledby="tabbed-modal-title">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="shrink-0">
|
<div className="shrink-0">
|
||||||
<div className="px-6 pt-5 pb-3">
|
<div className="px-6 pt-5 pb-3">
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div className="flex-1 min-w-0">
|
<div id="tabbed-modal-title" className="flex-1 min-w-0">
|
||||||
{header}
|
{header}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="p-2 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors shrink-0 -mt-1 -me-1"
|
className="p-2 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors shrink-0 -mt-1 -me-1"
|
||||||
|
aria-label="Close dialog"
|
||||||
>
|
>
|
||||||
<X className="w-5 h-5" />
|
<X className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -47,13 +76,15 @@ export default function TabbedModal({
|
|||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
{tabs.length > 0 && (
|
{tabs.length > 0 && (
|
||||||
<div className="flex gap-0 px-6 border-b border-border overflow-x-auto">
|
<div className="flex gap-0 px-6 border-b border-border overflow-x-auto" role="tablist">
|
||||||
{tabs.map(tab => {
|
{tabs.map(tab => {
|
||||||
const TabIcon = tab.icon
|
const TabIcon = tab.icon
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={tab.key}
|
key={tab.key}
|
||||||
onClick={() => onTabChange(tab.key)}
|
onClick={() => onTabChange(tab.key)}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === tab.key}
|
||||||
className={`relative flex items-center gap-2 px-4 py-3 text-[13px] font-medium whitespace-nowrap transition-colors ${
|
className={`relative flex items-center gap-2 px-4 py-3 text-[13px] font-medium whitespace-nowrap transition-colors ${
|
||||||
activeTab === tab.key
|
activeTab === tab.key
|
||||||
? 'text-brand-primary'
|
? 'text-brand-primary'
|
||||||
@@ -80,13 +111,13 @@ export default function TabbedModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto" role="tabpanel">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
{footer && (
|
{footer && (
|
||||||
<div className="border-t border-border px-6 py-3.5 flex items-center justify-between shrink-0 rounded-b-2xl bg-white">
|
<div className="border-t border-border px-6 py-3.5 flex items-center justify-between shrink-0 rounded-b-2xl bg-surface">
|
||||||
{footer}
|
{footer}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ export default function TaskCalendarView({ tasks, onTaskClick }) {
|
|||||||
if (p === 'urgent') return 'bg-red-500 text-white'
|
if (p === 'urgent') return 'bg-red-500 text-white'
|
||||||
if (p === 'high') return 'bg-orange-400 text-white'
|
if (p === 'high') return 'bg-orange-400 text-white'
|
||||||
if (p === 'medium') return 'bg-amber-400 text-amber-900'
|
if (p === 'medium') return 'bg-amber-400 text-amber-900'
|
||||||
return 'bg-gray-300 text-gray-700'
|
return 'bg-gray-300 text-text-secondary'
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -124,14 +124,14 @@ export default function TaskCalendarView({ tasks, onTaskClick }) {
|
|||||||
<div className="flex bg-surface-tertiary rounded-lg p-0.5">
|
<div className="flex bg-surface-tertiary rounded-lg p-0.5">
|
||||||
<button
|
<button
|
||||||
onClick={() => setCalView('month')}
|
onClick={() => setCalView('month')}
|
||||||
className={`px-2.5 py-1 text-[11px] font-medium rounded-md transition-colors flex items-center gap-1 ${calView === 'month' ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary'}`}
|
className={`px-2.5 py-1 text-[11px] font-medium rounded-md transition-colors flex items-center gap-1 ${calView === 'month' ? 'bg-surface shadow-sm text-text-primary' : 'text-text-tertiary'}`}
|
||||||
>
|
>
|
||||||
<CalendarIcon className="w-3 h-3" />
|
<CalendarIcon className="w-3 h-3" />
|
||||||
Month
|
Month
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setCalView('week')}
|
onClick={() => setCalView('week')}
|
||||||
className={`px-2.5 py-1 text-[11px] font-medium rounded-md transition-colors flex items-center gap-1 ${calView === 'week' ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary'}`}
|
className={`px-2.5 py-1 text-[11px] font-medium rounded-md transition-colors flex items-center gap-1 ${calView === 'week' ? 'bg-surface shadow-sm text-text-primary' : 'text-text-tertiary'}`}
|
||||||
>
|
>
|
||||||
<CalendarDays className="w-3 h-3" />
|
<CalendarDays className="w-3 h-3" />
|
||||||
Week
|
Week
|
||||||
@@ -162,7 +162,7 @@ export default function TaskCalendarView({ tasks, onTaskClick }) {
|
|||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className={`border-r border-b border-border ${calView === 'week' ? 'min-h-[300px]' : 'min-h-[90px]'} p-1 ${
|
className={`border-r border-b border-border ${calView === 'week' ? 'min-h-[300px]' : 'min-h-[90px]'} p-1 ${
|
||||||
cell.current ? 'bg-white' : 'bg-surface-secondary/50'
|
cell.current ? 'bg-surface' : 'bg-surface-secondary/50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className={`text-[11px] font-medium mb-0.5 w-6 h-6 flex items-center justify-center rounded-full ${
|
<div className={`text-[11px] font-medium mb-0.5 w-6 h-6 flex items-center justify-center rounded-full ${
|
||||||
@@ -175,7 +175,7 @@ export default function TaskCalendarView({ tasks, onTaskClick }) {
|
|||||||
<button
|
<button
|
||||||
key={task._id || task.id}
|
key={task._id || task.id}
|
||||||
onClick={() => onTaskClick(task)}
|
onClick={() => onTaskClick(task)}
|
||||||
className={`w-full text-left text-[10px] px-1.5 py-0.5 rounded truncate font-medium hover:opacity-80 transition-opacity ${
|
className={`w-full text-start text-[10px] px-1.5 py-0.5 rounded truncate font-medium hover:opacity-80 transition-opacity ${
|
||||||
task.status === 'done' ? 'bg-emerald-100 text-emerald-700 line-through' : getPillColor(task)
|
task.status === 'done' ? 'bg-emerald-100 text-emerald-700 line-through' : getPillColor(task)
|
||||||
}`}
|
}`}
|
||||||
title={task.title}
|
title={task.title}
|
||||||
@@ -206,7 +206,7 @@ export default function TaskCalendarView({ tasks, onTaskClick }) {
|
|||||||
<button
|
<button
|
||||||
key={task._id || task.id}
|
key={task._id || task.id}
|
||||||
onClick={() => onTaskClick(task)}
|
onClick={() => onTaskClick(task)}
|
||||||
className="w-full text-left bg-white border border-border rounded-lg p-2 hover:border-brand-primary/30 transition-colors"
|
className="w-full text-start bg-surface border border-border rounded-lg p-2 hover:border-brand-primary/30 transition-colors"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<div className={`w-2 h-2 rounded-full ${priority.color} shrink-0`} />
|
<div className={`w-2 h-2 rounded-full ${priority.color} shrink-0`} />
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export default function TaskCard({ task, onMove, showProject = true }) {
|
|||||||
const assignedName = task.assigned_name || task.assignedName
|
const assignedName = task.assigned_name || task.assignedName
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`bg-white rounded-lg border border-border p-3 card-hover group cursor-pointer ${isExternallyAssigned ? 'border-l-[3px] border-l-blue-400' : ''}`}>
|
<div className={`bg-surface rounded-lg border border-border p-3 card-hover group cursor-pointer ${isExternallyAssigned ? 'border-l-[3px] border-l-blue-400' : ''}`}>
|
||||||
<div className="flex items-start gap-2.5">
|
<div className="flex items-start gap-2.5">
|
||||||
{/* Priority dot */}
|
{/* Priority dot */}
|
||||||
<div className={`w-2.5 h-2.5 rounded-full ${priority.color} mt-1.5 shrink-0`} title={priority.label} />
|
<div className={`w-2.5 h-2.5 rounded-full ${priority.color} mt-1.5 shrink-0`} title={priority.label} />
|
||||||
|
|||||||
@@ -199,11 +199,11 @@ export default function TaskDetailPanel({ task, onClose, onSave, onDelete, proje
|
|||||||
{/* Thumbnail banner */}
|
{/* Thumbnail banner */}
|
||||||
{currentThumbnail && (
|
{currentThumbnail && (
|
||||||
<div className="relative -mx-6 -mt-5 mb-3 h-32 overflow-hidden rounded-t-2xl">
|
<div className="relative -mx-6 -mt-5 mb-3 h-32 overflow-hidden rounded-t-2xl">
|
||||||
<img src={currentThumbnail} alt="" className="w-full h-full object-cover" />
|
<img src={currentThumbnail} alt="" className="w-full h-full object-cover" loading="lazy" />
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-white/80 to-transparent" />
|
<div className="absolute inset-0 bg-gradient-to-t from-white/80 to-transparent" />
|
||||||
<button
|
<button
|
||||||
onClick={handleRemoveThumbnail}
|
onClick={handleRemoveThumbnail}
|
||||||
className="absolute top-2 right-2 p-1 bg-black/40 hover:bg-black/60 rounded-full text-white transition-colors"
|
className="absolute top-2 end-2 p-1 bg-black/40 hover:bg-black/60 rounded-full text-white transition-colors"
|
||||||
title={t('tasks.removeThumbnail')}
|
title={t('tasks.removeThumbnail')}
|
||||||
>
|
>
|
||||||
<X className="w-3 h-3" />
|
<X className="w-3 h-3" />
|
||||||
@@ -218,11 +218,11 @@ export default function TaskDetailPanel({ task, onClose, onSave, onDelete, proje
|
|||||||
placeholder={t('tasks.taskTitle')}
|
placeholder={t('tasks.taskTitle')}
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center gap-2 mt-2">
|
<div className="flex items-center gap-2 mt-2">
|
||||||
<span className={`inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full font-medium ${priority.color === 'bg-gray-400' ? 'bg-gray-100 text-gray-600' : priority.color === 'bg-amber-400' ? 'bg-amber-100 text-amber-700' : priority.color === 'bg-orange-500' ? 'bg-orange-100 text-orange-700' : 'bg-red-100 text-red-700'}`}>
|
<span className={`inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full font-medium ${priority.color === 'bg-gray-400' ? 'bg-gray-100 text-text-secondary' : priority.color === 'bg-amber-400' ? 'bg-amber-100 text-amber-700' : priority.color === 'bg-orange-500' ? 'bg-orange-100 text-orange-700' : 'bg-red-100 text-red-700'}`}>
|
||||||
<div className={`w-1.5 h-1.5 rounded-full ${priority.color}`} />
|
<div className={`w-1.5 h-1.5 rounded-full ${priority.color}`} />
|
||||||
{priorityOptions.find(p => p.value === form.priority)?.label}
|
{priorityOptions.find(p => p.value === form.priority)?.label}
|
||||||
</span>
|
</span>
|
||||||
<span className={`text-[11px] px-2 py-0.5 rounded-full font-medium ${form.status === 'done' ? 'bg-emerald-100 text-emerald-700' : form.status === 'in_progress' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600'}`}>
|
<span className={`text-[11px] px-2 py-0.5 rounded-full font-medium ${form.status === 'done' ? 'bg-emerald-100 text-emerald-700' : form.status === 'in_progress' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-text-secondary'}`}>
|
||||||
{statusOptions.find(s => s.value === form.status)?.label}
|
{statusOptions.find(s => s.value === form.status)?.label}
|
||||||
</span>
|
</span>
|
||||||
{isOverdue && !isCreateMode && (
|
{isOverdue && !isCreateMode && (
|
||||||
@@ -401,11 +401,11 @@ export default function TaskDetailPanel({ task, onClose, onSave, onDelete, proje
|
|||||||
const isThumbnail = currentThumbnail && attUrl === currentThumbnail
|
const isThumbnail = currentThumbnail && attUrl === currentThumbnail
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={attId} className="relative group/att border border-border rounded-lg overflow-hidden bg-white">
|
<div key={attId} className="relative group/att border border-border rounded-lg overflow-hidden bg-surface">
|
||||||
<div className="h-20 relative">
|
<div className="h-20 relative">
|
||||||
{isImage ? (
|
{isImage ? (
|
||||||
<a href={attUrl} target="_blank" rel="noopener noreferrer" className="block h-full">
|
<a href={attUrl} target="_blank" rel="noopener noreferrer" className="block h-full">
|
||||||
<img src={attUrl} alt={name} className="absolute inset-0 w-full h-full object-cover" />
|
<img src={attUrl} alt={name} className="absolute inset-0 w-full h-full object-cover" loading="lazy" />
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
<a href={attUrl} target="_blank" rel="noopener noreferrer" className="absolute inset-0 flex items-center gap-2 p-3">
|
<a href={attUrl} target="_blank" rel="noopener noreferrer" className="absolute inset-0 flex items-center gap-2 p-3">
|
||||||
@@ -414,11 +414,11 @@ export default function TaskDetailPanel({ task, onClose, onSave, onDelete, proje
|
|||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
{isThumbnail && (
|
{isThumbnail && (
|
||||||
<div className="absolute top-1 left-1 p-0.5 bg-amber-400 rounded-full text-white">
|
<div className="absolute top-1 start-1 p-0.5 bg-amber-400 rounded-full text-white">
|
||||||
<Star className="w-2.5 h-2.5 fill-current" />
|
<Star className="w-2.5 h-2.5 fill-current" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="absolute top-1 right-1 flex items-center gap-0.5 opacity-0 group-hover/att:opacity-100 transition-opacity">
|
<div className="absolute top-1 end-1 flex items-center gap-0.5 opacity-0 group-hover/att:opacity-100 transition-opacity">
|
||||||
{isImage && !isThumbnail && (
|
{isImage && !isThumbnail && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleSetThumbnail(att)}
|
onClick={() => handleSetThumbnail(att)}
|
||||||
@@ -454,17 +454,17 @@ export default function TaskDetailPanel({ task, onClose, onSave, onDelete, proje
|
|||||||
const previewUrl = isImage ? URL.createObjectURL(file) : null
|
const previewUrl = isImage ? URL.createObjectURL(file) : null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={i} className="relative group/att border border-border rounded-lg overflow-hidden bg-white">
|
<div key={i} className="relative group/att border border-border rounded-lg overflow-hidden bg-surface">
|
||||||
<div className="h-20 relative">
|
<div className="h-20 relative">
|
||||||
{isImage ? (
|
{isImage ? (
|
||||||
<img src={previewUrl} alt={file.name} className="absolute inset-0 w-full h-full object-cover" />
|
<img src={previewUrl} alt={file.name} className="absolute inset-0 w-full h-full object-cover" loading="lazy" />
|
||||||
) : (
|
) : (
|
||||||
<div className="absolute inset-0 flex items-center gap-2 p-3">
|
<div className="absolute inset-0 flex items-center gap-2 p-3">
|
||||||
<FileText className="w-6 h-6 text-text-tertiary shrink-0" />
|
<FileText className="w-6 h-6 text-text-tertiary shrink-0" />
|
||||||
<span className="text-xs text-text-secondary truncate">{file.name}</span>
|
<span className="text-xs text-text-secondary truncate">{file.name}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="absolute top-1 right-1 flex items-center gap-0.5 opacity-0 group-hover/att:opacity-100 transition-opacity">
|
<div className="absolute top-1 end-1 flex items-center gap-0.5 opacity-0 group-hover/att:opacity-100 transition-opacity">
|
||||||
<button
|
<button
|
||||||
onClick={() => setPendingFiles(prev => prev.filter((_, j) => j !== i))}
|
onClick={() => setPendingFiles(prev => prev.filter((_, j) => j !== i))}
|
||||||
className="p-1 bg-black/50 hover:bg-red-500 rounded-full text-white transition-colors"
|
className="p-1 bg-black/50 hover:bg-red-500 rounded-full text-white transition-colors"
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ import { AppContext, PERMISSION_LEVELS } from '../App'
|
|||||||
const ALL_MODULES = ['marketing', 'projects', 'finance']
|
const ALL_MODULES = ['marketing', 'projects', 'finance']
|
||||||
const MODULE_LABELS = { marketing: 'Marketing', projects: 'Projects', finance: 'Finance' }
|
const MODULE_LABELS = { marketing: 'Marketing', projects: 'Projects', finance: 'Finance' }
|
||||||
const MODULE_COLORS = {
|
const MODULE_COLORS = {
|
||||||
marketing: { on: 'bg-emerald-100 text-emerald-700 border-emerald-300', off: 'bg-gray-100 text-gray-400 border-gray-200' },
|
marketing: { on: 'bg-emerald-100 text-emerald-700 border-emerald-300', off: 'bg-gray-100 text-text-tertiary border-gray-200' },
|
||||||
projects: { on: 'bg-blue-100 text-blue-700 border-blue-300', off: 'bg-gray-100 text-gray-400 border-gray-200' },
|
projects: { on: 'bg-blue-100 text-blue-700 border-blue-300', off: 'bg-gray-100 text-text-tertiary border-gray-200' },
|
||||||
finance: { on: 'bg-amber-100 text-amber-700 border-amber-300', off: 'bg-gray-100 text-gray-400 border-gray-200' },
|
finance: { on: 'bg-amber-100 text-amber-700 border-amber-300', off: 'bg-gray-100 text-text-tertiary border-gray-200' },
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave, onDelete, canManageTeam, userRole, teams, brands: brandsList }) {
|
export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave, onDelete, canManageTeam, userRole, teams, brands: brandsList }) {
|
||||||
@@ -285,7 +285,7 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowBrandsDropdown(prev => !prev)}
|
onClick={() => setShowBrandsDropdown(prev => !prev)}
|
||||||
className="w-full flex items-center justify-between px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-white text-left"
|
className="w-full flex items-center justify-between px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface text-start"
|
||||||
>
|
>
|
||||||
<span className={`flex-1 truncate ${(form.brands || []).length === 0 ? 'text-text-tertiary' : 'text-text-primary'}`}>
|
<span className={`flex-1 truncate ${(form.brands || []).length === 0 ? 'text-text-tertiary' : 'text-text-primary'}`}>
|
||||||
{(form.brands || []).length === 0
|
{(form.brands || []).length === 0
|
||||||
@@ -315,7 +315,7 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
|
|||||||
|
|
||||||
{/* Dropdown */}
|
{/* Dropdown */}
|
||||||
{showBrandsDropdown && (
|
{showBrandsDropdown && (
|
||||||
<div className="absolute z-20 mt-1 w-full bg-white border border-border rounded-lg shadow-lg max-h-48 overflow-y-auto">
|
<div className="absolute z-20 mt-1 w-full bg-surface border border-border rounded-lg shadow-lg max-h-48 overflow-y-auto">
|
||||||
{brandsList && brandsList.length > 0 ? (
|
{brandsList && brandsList.length > 0 ? (
|
||||||
brandsList.map(brand => {
|
brandsList.map(brand => {
|
||||||
const name = lang === 'ar' && brand.name_ar ? brand.name_ar : brand.name
|
const name = lang === 'ar' && brand.name_ar ? brand.name_ar : brand.name
|
||||||
@@ -325,7 +325,7 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
|
|||||||
type="button"
|
type="button"
|
||||||
key={brand.id || brand._id}
|
key={brand.id || brand._id}
|
||||||
onClick={() => toggleBrand(name)}
|
onClick={() => toggleBrand(name)}
|
||||||
className={`w-full flex items-center gap-2.5 px-3 py-2 cursor-pointer hover:bg-surface-secondary transition-colors text-left ${checked ? 'bg-brand-primary/5' : ''}`}
|
className={`w-full flex items-center gap-2.5 px-3 py-2 cursor-pointer hover:bg-surface-secondary transition-colors text-start ${checked ? 'bg-brand-primary/5' : ''}`}
|
||||||
>
|
>
|
||||||
<div className={`w-4 h-4 rounded border flex items-center justify-center shrink-0 transition-colors ${
|
<div className={`w-4 h-4 rounded border flex items-center justify-center shrink-0 transition-colors ${
|
||||||
checked ? 'bg-brand-primary border-brand-primary' : 'border-border'
|
checked ? 'bg-brand-primary border-brand-primary' : 'border-border'
|
||||||
@@ -393,7 +393,7 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
|
|||||||
className={`text-xs px-3 py-1.5 rounded-full border font-medium transition-colors ${
|
className={`text-xs px-3 py-1.5 rounded-full border font-medium transition-colors ${
|
||||||
active
|
active
|
||||||
? 'bg-blue-100 text-blue-700 border-blue-300'
|
? 'bg-blue-100 text-blue-700 border-blue-300'
|
||||||
: 'bg-gray-100 text-gray-400 border-gray-200'
|
: 'bg-gray-100 text-text-tertiary border-gray-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{team.name}
|
{team.name}
|
||||||
|
|||||||
@@ -149,13 +149,13 @@ export default function TeamPanel({ team, onClose, onSave, onDelete, teamMembers
|
|||||||
{activeTab === 'members' && (
|
{activeTab === 'members' && (
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="relative mb-3">
|
<div className="relative mb-3">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-text-tertiary" />
|
<Search className="absolute start-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-text-tertiary" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={memberSearch}
|
value={memberSearch}
|
||||||
onChange={e => setMemberSearch(e.target.value)}
|
onChange={e => setMemberSearch(e.target.value)}
|
||||||
placeholder={t('teams.selectMembers')}
|
placeholder={t('teams.selectMembers')}
|
||||||
className="w-full pl-9 pr-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
className="w-full ps-9 pe-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 max-h-80 overflow-y-auto">
|
<div className="space-y-1 max-h-80 overflow-y-auto">
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export default function ThemeToggle({ className = '' }) {
|
|||||||
{darkMode ? (
|
{darkMode ? (
|
||||||
<Sun className="w-5 h-5 text-yellow-500" />
|
<Sun className="w-5 h-5 text-yellow-500" />
|
||||||
) : (
|
) : (
|
||||||
<Moon className="w-5 h-5 text-gray-600" />
|
<Moon className="w-5 h-5 text-text-secondary" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export function ToastProvider({ children }) {
|
|||||||
<ToastContext.Provider value={toast}>
|
<ToastContext.Provider value={toast}>
|
||||||
{children}
|
{children}
|
||||||
{/* Toast container - fixed position */}
|
{/* Toast container - fixed position */}
|
||||||
<div className="fixed top-4 right-4 z-[10000] flex flex-col gap-2 pointer-events-none">
|
<div className="fixed top-4 end-4 z-[10000] flex flex-col gap-2 pointer-events-none">
|
||||||
<div className="flex flex-col gap-2 pointer-events-auto">
|
<div className="flex flex-col gap-2 pointer-events-auto">
|
||||||
{toasts.map(t => (
|
{toasts.map(t => (
|
||||||
<Toast
|
<Toast
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ export default function TrackDetailPanel({ track, campaignId, onClose, onSave, o
|
|||||||
form.status === 'active' ? 'bg-emerald-100 text-emerald-700' :
|
form.status === 'active' ? 'bg-emerald-100 text-emerald-700' :
|
||||||
form.status === 'paused' ? 'bg-amber-100 text-amber-700' :
|
form.status === 'paused' ? 'bg-amber-100 text-amber-700' :
|
||||||
form.status === 'completed' ? 'bg-blue-100 text-blue-700' :
|
form.status === 'completed' ? 'bg-blue-100 text-blue-700' :
|
||||||
'bg-gray-100 text-gray-600'
|
'bg-gray-100 text-text-secondary'
|
||||||
}`}>
|
}`}>
|
||||||
{form.status?.charAt(0).toUpperCase() + form.status?.slice(1)}
|
{form.status?.charAt(0).toUpperCase() + form.status?.slice(1)}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -441,7 +441,7 @@ export default function TranslationDetailPanel({ translation, onClose, onUpdate,
|
|||||||
<div className="flex items-center justify-between mb-1.5">
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
<span className="text-xs font-medium text-text-tertiary">
|
<span className="text-xs font-medium text-text-tertiary">
|
||||||
{t('translations.optionLabel')} {text.option_number || idx + 1}
|
{t('translations.optionLabel')} {text.option_number || idx + 1}
|
||||||
{selected && <span className="ml-2 text-emerald-600 font-semibold">{t('translations.selected')}</span>}
|
{selected && <span className="ms-2 text-emerald-600 font-semibold">{t('translations.selected')}</span>}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{editingTextId !== text.Id && (
|
{editingTextId !== text.Id && (
|
||||||
@@ -520,7 +520,7 @@ export default function TranslationDetailPanel({ translation, onClose, onUpdate,
|
|||||||
type="text"
|
type="text"
|
||||||
value={currentReviewUrl}
|
value={currentReviewUrl}
|
||||||
readOnly
|
readOnly
|
||||||
className="flex-1 px-3 py-2 text-sm bg-white border border-blue-200 rounded-lg text-blue-800"
|
className="flex-1 px-3 py-2 text-sm bg-surface border border-blue-200 rounded-lg text-blue-800"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={copyReviewLink}
|
onClick={copyReviewLink}
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ export default function Tutorial({ onComplete }) {
|
|||||||
|
|
||||||
{/* Tooltip card */}
|
{/* Tooltip card */}
|
||||||
<div
|
<div
|
||||||
className="absolute bg-white rounded-xl shadow-2xl border border-border p-6 animate-fade-in pointer-events-auto"
|
className="absolute bg-surface rounded-xl shadow-2xl border border-border p-6 animate-fade-in pointer-events-auto"
|
||||||
style={{
|
style={{
|
||||||
top: tooltipPosition.top,
|
top: tooltipPosition.top,
|
||||||
left: tooltipPosition.left,
|
left: tooltipPosition.left,
|
||||||
@@ -188,7 +188,7 @@ export default function Tutorial({ onComplete }) {
|
|||||||
{/* Close button */}
|
{/* Close button */}
|
||||||
<button
|
<button
|
||||||
onClick={handleSkip}
|
onClick={handleSkip}
|
||||||
className="absolute top-4 right-4 text-text-tertiary hover:text-text-primary transition-colors"
|
className="absolute top-4 end-4 text-text-tertiary hover:text-text-primary transition-colors"
|
||||||
>
|
>
|
||||||
<X className="w-5 h-5" />
|
<X className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
+71
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"app.name": "المركز الرقمي",
|
"app.name": "رواج",
|
||||||
"app.subtitle": "المنصة",
|
"app.subtitle": "مركز التسويق",
|
||||||
"nav.dashboard": "لوحة التحكم",
|
"nav.dashboard": "لوحة التحكم",
|
||||||
"nav.campaigns": "الحملات",
|
"nav.campaigns": "الحملات",
|
||||||
"nav.finance": "المالية والعائد",
|
"nav.finance": "المالية والعائد",
|
||||||
@@ -396,6 +396,16 @@
|
|||||||
"campaigns.editCampaign": "تعديل الحملة",
|
"campaigns.editCampaign": "تعديل الحملة",
|
||||||
"campaigns.deleteCampaign": "حذف الحملة؟",
|
"campaigns.deleteCampaign": "حذف الحملة؟",
|
||||||
"campaigns.deleteConfirm": "هل أنت متأكد من حذف هذه الحملة؟ سيتم حذف جميع البيانات المرتبطة. لا يمكن التراجع.",
|
"campaigns.deleteConfirm": "هل أنت متأكد من حذف هذه الحملة؟ سيتم حذف جميع البيانات المرتبطة. لا يمكن التراجع.",
|
||||||
|
"campaigns.tracks": "المسارات",
|
||||||
|
"campaigns.addTrack": "إضافة مسار",
|
||||||
|
"campaigns.noTracks": "لا توجد مسارات بعد. أضف مسارات عضوية أو مدفوعة أو SEO لتنظيم هذه الحملة.",
|
||||||
|
"campaigns.postsLinked": "منشورات مرتبطة",
|
||||||
|
"campaigns.team": "الفريق",
|
||||||
|
"campaigns.assignMembers": "تعيين أعضاء",
|
||||||
|
"campaigns.linkedPosts": "المنشورات المرتبطة",
|
||||||
|
"campaigns.notFound": "الحملة غير موجودة.",
|
||||||
|
"common.goBack": "رجوع",
|
||||||
|
"finance.allocated": "مخصص",
|
||||||
"tracks.details": "التفاصيل",
|
"tracks.details": "التفاصيل",
|
||||||
"tracks.metrics": "المقاييس",
|
"tracks.metrics": "المقاييس",
|
||||||
"tracks.trackName": "اسم المسار",
|
"tracks.trackName": "اسم المسار",
|
||||||
@@ -503,6 +513,59 @@
|
|||||||
"budgets.dateExpensed": "التاريخ",
|
"budgets.dateExpensed": "التاريخ",
|
||||||
"dashboard.expenses": "المصروفات",
|
"dashboard.expenses": "المصروفات",
|
||||||
"finance.expenses": "إجمالي المصروفات",
|
"finance.expenses": "إجمالي المصروفات",
|
||||||
|
"finance.totalReceived": "إجمالي المستلم",
|
||||||
|
"finance.totalSpent": "إجمالي المنفق",
|
||||||
|
"finance.remaining": "المتبقي",
|
||||||
|
"finance.revenue": "الإيرادات",
|
||||||
|
"finance.globalROI": "العائد الإجمالي",
|
||||||
|
"finance.budgetAllocation": "توزيع الميزانية",
|
||||||
|
"finance.manageBudgets": "إدارة الميزانيات",
|
||||||
|
"finance.campaigns": "الحملات",
|
||||||
|
"finance.projects": "المشاريع",
|
||||||
|
"finance.unallocated": "غير مخصص",
|
||||||
|
"finance.budgetUtilization": "استخدام الميزانية",
|
||||||
|
"finance.globalPerformance": "الأداء العام",
|
||||||
|
"finance.impressions": "مرات الظهور",
|
||||||
|
"finance.clicks": "النقرات",
|
||||||
|
"finance.conversions": "التحويلات",
|
||||||
|
"finance.campaignBreakdown": "توزيع الحملات",
|
||||||
|
"finance.allocatedFunds": "الأموال المخصصة",
|
||||||
|
"finance.requestBudget": "طلب ميزانية",
|
||||||
|
"finance.budgetRequests": "طلبات الميزانية",
|
||||||
|
"finance.pendingApproval": "بانتظار موافقة المدير التنفيذي",
|
||||||
|
"finance.justification": "المبرر",
|
||||||
|
"finance.earmarkFor": "تخصيص لـ",
|
||||||
|
"finance.submitRequest": "إرسال الطلب",
|
||||||
|
"finance.cancelRequest": "إلغاء الطلب",
|
||||||
|
"finance.approved": "تمت الموافقة",
|
||||||
|
"finance.rejected": "مرفوض",
|
||||||
|
"finance.cancelled": "ملغي",
|
||||||
|
"finance.pending": "قيد الانتظار",
|
||||||
|
"finance.ceoNote": "ملاحظة المدير",
|
||||||
|
"finance.requestPending": "طلب(ات) ميزانية بانتظار الموافقة",
|
||||||
|
"finance.insufficientBudget": "ميزانية غير كافية",
|
||||||
|
"finance.availableBudget": "المتاح",
|
||||||
|
"finance.requestMore": "طلب المزيد من الأموال",
|
||||||
|
"finance.noCeoEmail": "لم يتم تكوين بريد المدير التنفيذي. اذهب إلى الإعدادات.",
|
||||||
|
"finance.amount": "المبلغ",
|
||||||
|
"finance.justificationPlaceholder": "لماذا هذه الميزانية مطلوبة؟",
|
||||||
|
"finance.optional": "اختياري",
|
||||||
|
"settings.budgetApproval": "موافقة الميزانية",
|
||||||
|
"settings.ceoEmail": "بريد المدير التنفيذي / المعتمد",
|
||||||
|
"settings.ceoEmailHint": "عنوان البريد الإلكتروني الذي يستلم طلبات الموافقة على الميزانية",
|
||||||
|
"budgetApproval.title": "موافقة الميزانية",
|
||||||
|
"budgetApproval.amount": "المبلغ المطلوب",
|
||||||
|
"budgetApproval.requestedBy": "مقدم الطلب",
|
||||||
|
"budgetApproval.justification": "المبرر",
|
||||||
|
"budgetApproval.earmarkedFor": "مخصص لـ",
|
||||||
|
"budgetApproval.approve": "موافقة",
|
||||||
|
"budgetApproval.reject": "رفض",
|
||||||
|
"budgetApproval.addNote": "أضف ملاحظة (اختياري)",
|
||||||
|
"budgetApproval.approved": "تمت الموافقة على هذا الطلب.",
|
||||||
|
"budgetApproval.rejected": "تم رفض هذا الطلب.",
|
||||||
|
"budgetApproval.expired": "انتهت صلاحية هذا الطلب.",
|
||||||
|
"budgetApproval.alreadyHandled": "تمت معالجة هذا الطلب بالفعل.",
|
||||||
|
"finance.ofBudget": "من الميزانية",
|
||||||
"settings.uploads": "الرفع",
|
"settings.uploads": "الرفع",
|
||||||
"settings.maxFileSize": "الحد الأقصى لحجم الملف",
|
"settings.maxFileSize": "الحد الأقصى لحجم الملف",
|
||||||
"settings.maxFileSizeHint": "الحد الأقصى المسموح لحجم المرفقات (١-٥٠٠ ميجابايت)",
|
"settings.maxFileSizeHint": "الحد الأقصى المسموح لحجم المرفقات (١-٥٠٠ ميجابايت)",
|
||||||
@@ -629,7 +692,7 @@
|
|||||||
"review.alreadyReviewed": "تمت مراجعة هذا المحتوى بالفعل.",
|
"review.alreadyReviewed": "تمت مراجعة هذا المحتوى بالفعل.",
|
||||||
"review.statusLabel": "الحالة",
|
"review.statusLabel": "الحالة",
|
||||||
"review.reviewedBy": "تمت المراجعة بواسطة",
|
"review.reviewedBy": "تمت المراجعة بواسطة",
|
||||||
"review.poweredBy": "مدعوم بواسطة Samaya Digital Hub",
|
"review.poweredBy": "مدعوم بواسطة Rawaj",
|
||||||
"review.loadFailed": "فشل في تحميل المحتوى",
|
"review.loadFailed": "فشل في تحميل المحتوى",
|
||||||
"review.actionFailed": "فشل الإجراء",
|
"review.actionFailed": "فشل الإجراء",
|
||||||
"review.actionCompleted": "تم الإجراء بنجاح",
|
"review.actionCompleted": "تم الإجراء بنجاح",
|
||||||
@@ -694,6 +757,8 @@
|
|||||||
"team.selectRole": "اختر دوراً...",
|
"team.selectRole": "اختر دوراً...",
|
||||||
"common.team": "الفريق",
|
"common.team": "الفريق",
|
||||||
"common.noTeam": "بدون فريق",
|
"common.noTeam": "بدون فريق",
|
||||||
|
"common.none": "بدون",
|
||||||
|
"common.success": "تم بنجاح",
|
||||||
"common.error": "حدث خطأ",
|
"common.error": "حدث خطأ",
|
||||||
"settings.roles": "الأدوار",
|
"settings.roles": "الأدوار",
|
||||||
"settings.rolesDesc": "حدد أدوار العمل مثل مصمم، استراتيجي، إلخ. يتم تعيينها لأعضاء الفريق بشكل منفصل عن مستويات الصلاحية.",
|
"settings.rolesDesc": "حدد أدوار العمل مثل مصمم، استراتيجي، إلخ. يتم تعيينها لأعضاء الفريق بشكل منفصل عن مستويات الصلاحية.",
|
||||||
@@ -717,6 +782,9 @@
|
|||||||
"header.budgets": "الميزانيات",
|
"header.budgets": "الميزانيات",
|
||||||
"header.issues": "البلاغات",
|
"header.issues": "البلاغات",
|
||||||
"header.settings": "الإعدادات",
|
"header.settings": "الإعدادات",
|
||||||
|
"header.translations": "الترجمات",
|
||||||
|
"calendar.unscheduledPosts": "منشورات غير مجدولة",
|
||||||
|
"calendar.statusLegend": "دليل الحالات",
|
||||||
"header.users": "إدارة المستخدمين",
|
"header.users": "إدارة المستخدمين",
|
||||||
"header.projectDetails": "تفاصيل المشروع",
|
"header.projectDetails": "تفاصيل المشروع",
|
||||||
"header.campaignDetails": "تفاصيل الحملة",
|
"header.campaignDetails": "تفاصيل الحملة",
|
||||||
|
|||||||
+74
-6
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"app.name": "Digital Hub",
|
"app.name": "Rawaj",
|
||||||
"app.subtitle": "Platform",
|
"app.subtitle": "Marketing Hub",
|
||||||
"nav.dashboard": "Dashboard",
|
"nav.dashboard": "Dashboard",
|
||||||
"nav.campaigns": "Campaigns",
|
"nav.campaigns": "Campaigns",
|
||||||
"nav.finance": "Finance & ROI",
|
"nav.finance": "Finance & ROI",
|
||||||
@@ -70,7 +70,7 @@
|
|||||||
"dashboard.noPostsYet": "No posts yet. Create your first post!",
|
"dashboard.noPostsYet": "No posts yet. Create your first post!",
|
||||||
"dashboard.upcomingDeadlines": "Upcoming Deadlines",
|
"dashboard.upcomingDeadlines": "Upcoming Deadlines",
|
||||||
"dashboard.noUpcomingDeadlines": "No upcoming deadlines this week. 🎉",
|
"dashboard.noUpcomingDeadlines": "No upcoming deadlines this week. 🎉",
|
||||||
"dashboard.loadingHub": "Loading Digital Hub...",
|
"dashboard.loadingHub": "Loading Rawaj...",
|
||||||
"posts.title": "Post Production",
|
"posts.title": "Post Production",
|
||||||
"posts.newPost": "New Post",
|
"posts.newPost": "New Post",
|
||||||
"posts.editPost": "Edit Post",
|
"posts.editPost": "Edit Post",
|
||||||
@@ -271,7 +271,7 @@
|
|||||||
"settings.english": "English",
|
"settings.english": "English",
|
||||||
"settings.arabic": "Arabic",
|
"settings.arabic": "Arabic",
|
||||||
"settings.restartTutorial": "Restart Tutorial",
|
"settings.restartTutorial": "Restart Tutorial",
|
||||||
"settings.tutorialDesc": "Need a refresher? Restart the interactive tutorial to learn about all the features of Digital Hub.",
|
"settings.tutorialDesc": "Need a refresher? Restart the interactive tutorial to learn about all the features of Rawaj.",
|
||||||
"settings.general": "General",
|
"settings.general": "General",
|
||||||
"settings.onboardingTutorial": "Onboarding Tutorial",
|
"settings.onboardingTutorial": "Onboarding Tutorial",
|
||||||
"settings.tutorialRestarted": "Tutorial Restarted!",
|
"settings.tutorialRestarted": "Tutorial Restarted!",
|
||||||
@@ -315,7 +315,7 @@
|
|||||||
"tutorial.newPost.desc": "Start creating content here. Pick your brand, platforms, and assign it to a team member.",
|
"tutorial.newPost.desc": "Start creating content here. Pick your brand, platforms, and assign it to a team member.",
|
||||||
"tutorial.filters.title": "Filter & Focus",
|
"tutorial.filters.title": "Filter & Focus",
|
||||||
"tutorial.filters.desc": "Use filters to focus on specific brands, platforms, or team members.",
|
"tutorial.filters.desc": "Use filters to focus on specific brands, platforms, or team members.",
|
||||||
"login.title": "Digital Hub",
|
"login.title": "Rawaj",
|
||||||
"login.subtitle": "Sign in to continue",
|
"login.subtitle": "Sign in to continue",
|
||||||
"login.forgotPassword": "Forgot password?",
|
"login.forgotPassword": "Forgot password?",
|
||||||
"login.defaultCreds": "Default credentials:",
|
"login.defaultCreds": "Default credentials:",
|
||||||
@@ -396,6 +396,16 @@
|
|||||||
"campaigns.editCampaign": "Edit Campaign",
|
"campaigns.editCampaign": "Edit Campaign",
|
||||||
"campaigns.deleteCampaign": "Delete Campaign?",
|
"campaigns.deleteCampaign": "Delete Campaign?",
|
||||||
"campaigns.deleteConfirm": "Are you sure you want to delete this campaign? All associated data will be removed. This action cannot be undone.",
|
"campaigns.deleteConfirm": "Are you sure you want to delete this campaign? All associated data will be removed. This action cannot be undone.",
|
||||||
|
"campaigns.tracks": "Tracks",
|
||||||
|
"campaigns.addTrack": "Add Track",
|
||||||
|
"campaigns.noTracks": "No tracks yet. Add organic, paid, or SEO tracks to organize this campaign.",
|
||||||
|
"campaigns.postsLinked": "posts linked",
|
||||||
|
"campaigns.team": "Team",
|
||||||
|
"campaigns.assignMembers": "Assign Members",
|
||||||
|
"campaigns.linkedPosts": "Linked Posts",
|
||||||
|
"campaigns.notFound": "Campaign not found.",
|
||||||
|
"common.goBack": "Go back",
|
||||||
|
"finance.allocated": "allocated",
|
||||||
"tracks.details": "Details",
|
"tracks.details": "Details",
|
||||||
"tracks.metrics": "Metrics",
|
"tracks.metrics": "Metrics",
|
||||||
"tracks.trackName": "Track Name",
|
"tracks.trackName": "Track Name",
|
||||||
@@ -503,6 +513,59 @@
|
|||||||
"budgets.dateExpensed": "Date",
|
"budgets.dateExpensed": "Date",
|
||||||
"dashboard.expenses": "Expenses",
|
"dashboard.expenses": "Expenses",
|
||||||
"finance.expenses": "Total Expenses",
|
"finance.expenses": "Total Expenses",
|
||||||
|
"finance.totalReceived": "Total Received",
|
||||||
|
"finance.totalSpent": "Total Spent",
|
||||||
|
"finance.remaining": "Remaining",
|
||||||
|
"finance.revenue": "Revenue",
|
||||||
|
"finance.globalROI": "Global ROI",
|
||||||
|
"finance.budgetAllocation": "Budget Allocation",
|
||||||
|
"finance.manageBudgets": "Manage Budgets",
|
||||||
|
"finance.campaigns": "Campaigns",
|
||||||
|
"finance.projects": "Projects",
|
||||||
|
"finance.unallocated": "Unallocated",
|
||||||
|
"finance.budgetUtilization": "Budget Utilization",
|
||||||
|
"finance.globalPerformance": "Global Performance",
|
||||||
|
"finance.impressions": "Impressions",
|
||||||
|
"finance.clicks": "Clicks",
|
||||||
|
"finance.conversions": "Conversions",
|
||||||
|
"finance.campaignBreakdown": "Campaign Breakdown",
|
||||||
|
"finance.allocatedFunds": "Allocated Funds",
|
||||||
|
"finance.requestBudget": "Request Budget",
|
||||||
|
"finance.budgetRequests": "Budget Requests",
|
||||||
|
"finance.pendingApproval": "pending CEO approval",
|
||||||
|
"finance.justification": "Justification",
|
||||||
|
"finance.earmarkFor": "Earmark for",
|
||||||
|
"finance.submitRequest": "Submit Request",
|
||||||
|
"finance.cancelRequest": "Cancel Request",
|
||||||
|
"finance.approved": "Approved",
|
||||||
|
"finance.rejected": "Rejected",
|
||||||
|
"finance.cancelled": "Cancelled",
|
||||||
|
"finance.pending": "Pending",
|
||||||
|
"finance.ceoNote": "CEO Note",
|
||||||
|
"finance.requestPending": "budget request(s) pending CEO approval",
|
||||||
|
"finance.insufficientBudget": "Insufficient budget",
|
||||||
|
"finance.availableBudget": "Available",
|
||||||
|
"finance.requestMore": "Request more funds",
|
||||||
|
"finance.noCeoEmail": "CEO email not configured. Go to Settings.",
|
||||||
|
"finance.amount": "Amount",
|
||||||
|
"finance.justificationPlaceholder": "Why is this budget needed?",
|
||||||
|
"finance.optional": "Optional",
|
||||||
|
"settings.budgetApproval": "Budget Approval",
|
||||||
|
"settings.ceoEmail": "CEO / Budget Approver Email",
|
||||||
|
"settings.ceoEmailHint": "Email address that receives budget approval requests",
|
||||||
|
"budgetApproval.title": "Budget Approval",
|
||||||
|
"budgetApproval.amount": "Requested Amount",
|
||||||
|
"budgetApproval.requestedBy": "Requested by",
|
||||||
|
"budgetApproval.justification": "Justification",
|
||||||
|
"budgetApproval.earmarkedFor": "Earmarked for",
|
||||||
|
"budgetApproval.approve": "Approve",
|
||||||
|
"budgetApproval.reject": "Reject",
|
||||||
|
"budgetApproval.addNote": "Add a note (optional)",
|
||||||
|
"budgetApproval.approved": "This request has been approved.",
|
||||||
|
"budgetApproval.rejected": "This request has been rejected.",
|
||||||
|
"budgetApproval.expired": "This request has expired.",
|
||||||
|
"budgetApproval.alreadyHandled": "This request has already been processed.",
|
||||||
|
"finance.ofBudget": "of budget",
|
||||||
"settings.uploads": "Uploads",
|
"settings.uploads": "Uploads",
|
||||||
"settings.maxFileSize": "Maximum File Size",
|
"settings.maxFileSize": "Maximum File Size",
|
||||||
"settings.maxFileSizeHint": "Maximum allowed file size for attachments (1-500 MB)",
|
"settings.maxFileSizeHint": "Maximum allowed file size for attachments (1-500 MB)",
|
||||||
@@ -629,7 +692,7 @@
|
|||||||
"review.alreadyReviewed": "This artefact has already been reviewed.",
|
"review.alreadyReviewed": "This artefact has already been reviewed.",
|
||||||
"review.statusLabel": "Status",
|
"review.statusLabel": "Status",
|
||||||
"review.reviewedBy": "Reviewed by",
|
"review.reviewedBy": "Reviewed by",
|
||||||
"review.poweredBy": "Powered by Samaya Digital Hub",
|
"review.poweredBy": "Powered by Rawaj",
|
||||||
"review.loadFailed": "Failed to load artefact",
|
"review.loadFailed": "Failed to load artefact",
|
||||||
"review.actionFailed": "Action failed",
|
"review.actionFailed": "Action failed",
|
||||||
"review.actionCompleted": "Action completed successfully",
|
"review.actionCompleted": "Action completed successfully",
|
||||||
@@ -694,6 +757,8 @@
|
|||||||
"team.selectRole": "Select role...",
|
"team.selectRole": "Select role...",
|
||||||
"common.team": "Team",
|
"common.team": "Team",
|
||||||
"common.noTeam": "No team",
|
"common.noTeam": "No team",
|
||||||
|
"common.none": "None",
|
||||||
|
"common.success": "Success",
|
||||||
"common.error": "An error occurred",
|
"common.error": "An error occurred",
|
||||||
"settings.roles": "Roles",
|
"settings.roles": "Roles",
|
||||||
"settings.rolesDesc": "Define job roles like Designer, Strategist, etc. These are assigned to team members separately from permission levels.",
|
"settings.rolesDesc": "Define job roles like Designer, Strategist, etc. These are assigned to team members separately from permission levels.",
|
||||||
@@ -717,6 +782,9 @@
|
|||||||
"header.budgets": "Budgets",
|
"header.budgets": "Budgets",
|
||||||
"header.issues": "Issues",
|
"header.issues": "Issues",
|
||||||
"header.settings": "Settings",
|
"header.settings": "Settings",
|
||||||
|
"header.translations": "Translations",
|
||||||
|
"calendar.unscheduledPosts": "Unscheduled Posts",
|
||||||
|
"calendar.statusLegend": "Status Legend",
|
||||||
"header.users": "User Management",
|
"header.users": "User Management",
|
||||||
"header.projectDetails": "Project Details",
|
"header.projectDetails": "Project Details",
|
||||||
"header.campaignDetails": "Campaign Details",
|
"header.campaignDetails": "Campaign Details",
|
||||||
|
|||||||
+90
-123
@@ -1,16 +1,16 @@
|
|||||||
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+Arabic:wght@300;400;500;600;700&family=Inter:wght@300;400;500;600;700&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500;600;700&family=IBM+Plex+Sans+Arabic:wght@300;400;500;600;700&display=swap');
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--font-sans: 'Inter', 'IBM Plex Sans Arabic', system-ui, -apple-system, sans-serif;
|
--font-sans: 'DM Sans', 'IBM Plex Sans Arabic', system-ui, -apple-system, sans-serif;
|
||||||
--color-sidebar: #0f172a;
|
--color-sidebar: #0a1f1c;
|
||||||
--color-sidebar-hover: #1e293b;
|
--color-sidebar-hover: #123b35;
|
||||||
--color-sidebar-active: #020617;
|
--color-sidebar-active: #061411;
|
||||||
--color-brand-primary: #4f46e5;
|
--color-brand-primary: #0d9488;
|
||||||
--color-brand-primary-light: #6366f1;
|
--color-brand-primary-light: #14b8a6;
|
||||||
--color-brand-secondary: #db2777;
|
--color-brand-secondary: #db2777;
|
||||||
--color-brand-tertiary: #f59e0b;
|
--color-brand-tertiary: #f59e0b;
|
||||||
--color-brand-quaternary: #059669;
|
--color-brand-quaternary: #0d9488;
|
||||||
--color-surface: #ffffff;
|
--color-surface: #ffffff;
|
||||||
--color-surface-secondary: #f9fafb;
|
--color-surface-secondary: #f9fafb;
|
||||||
--color-surface-tertiary: #f3f4f6;
|
--color-surface-tertiary: #f3f4f6;
|
||||||
@@ -37,40 +37,39 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════════
|
/* ═══════════════════════════════════════════════
|
||||||
DARK MODE — Inspired by SpaceTime
|
DARK MODE — Forest teal tinted surfaces
|
||||||
Deep layered surfaces, glass edges, ambient glow
|
|
||||||
═══════════════════════════════════════════════ */
|
═══════════════════════════════════════════════ */
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
/* Layered depth: void → surface → surface-2 → surface-3 */
|
/* Layered depth: deep forest → surface → elevated */
|
||||||
--color-surface: #15151e;
|
--color-surface: #0f1a18;
|
||||||
--color-surface-secondary: #1c1c2a;
|
--color-surface-secondary: #162220;
|
||||||
--color-surface-tertiary: #24243a;
|
--color-surface-tertiary: #1e2e2b;
|
||||||
--color-border: rgba(255, 255, 255, 0.08);
|
--color-border: rgba(255, 255, 255, 0.08);
|
||||||
--color-border-light: rgba(255, 255, 255, 0.04);
|
--color-border-light: rgba(255, 255, 255, 0.04);
|
||||||
|
|
||||||
/* Text — crisp hierarchy */
|
/* Text — warm neutrals, teal-tinted */
|
||||||
--color-text-primary: #eeecf5;
|
--color-text-primary: #e8f0ee;
|
||||||
--color-text-secondary: #a8a3c0;
|
--color-text-secondary: #9db5b0;
|
||||||
--color-text-tertiary: #706b8a;
|
--color-text-tertiary: #637e78;
|
||||||
|
|
||||||
/* Sidebar */
|
/* Sidebar */
|
||||||
--color-sidebar: #0e0e16;
|
--color-sidebar: #0a1412;
|
||||||
--color-sidebar-hover: #15151e;
|
--color-sidebar-hover: #0f1a18;
|
||||||
--color-sidebar-active: #0a0a12;
|
--color-sidebar-active: #060e0c;
|
||||||
|
|
||||||
/* Brand — brighter on dark */
|
/* Brand — brighter on dark */
|
||||||
--color-brand-primary: #8b5cf6;
|
--color-brand-primary: #14b8a6;
|
||||||
--color-brand-primary-light: #a78bfa;
|
--color-brand-primary-light: #2dd4bf;
|
||||||
|
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
background-color: #15151e;
|
background-color: #0f1a18;
|
||||||
color: #eeecf5;
|
color: #e8f0ee;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Ambient background glow ────────────────── */
|
/* ─── Ambient background glow ────────────────── */
|
||||||
.dark .bg-mesh {
|
.dark .bg-mesh {
|
||||||
background-color: #15151e !important;
|
background-color: #0f1a18 !important;
|
||||||
background-image: none !important;
|
background-image: none !important;
|
||||||
}
|
}
|
||||||
.dark .bg-mesh::before {
|
.dark .bg-mesh::before {
|
||||||
@@ -78,9 +77,8 @@
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background:
|
background:
|
||||||
radial-gradient(ellipse 70% 50% at 20% 50%, rgba(139, 92, 246, 0.045) 0%, transparent 60%),
|
radial-gradient(ellipse 70% 50% at 20% 50%, rgba(13, 148, 136, 0.04) 0%, transparent 60%),
|
||||||
radial-gradient(ellipse 50% 40% at 80% 30%, rgba(56, 189, 248, 0.03) 0%, transparent 60%),
|
radial-gradient(ellipse 50% 40% at 80% 30%, rgba(20, 184, 166, 0.025) 0%, transparent 60%);
|
||||||
radial-gradient(ellipse 60% 40% at 50% 90%, rgba(232, 168, 56, 0.02) 0%, transparent 60%);
|
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
}
|
}
|
||||||
@@ -89,11 +87,11 @@
|
|||||||
.dark .bg-white,
|
.dark .bg-white,
|
||||||
.dark .bg-\[\#fff\],
|
.dark .bg-\[\#fff\],
|
||||||
.dark .bg-\[\#ffffff\] {
|
.dark .bg-\[\#ffffff\] {
|
||||||
background-color: #22223a !important;
|
background-color: #1a2a28 !important;
|
||||||
}
|
}
|
||||||
.dark .bg-gray-50 { background-color: #15151e !important; }
|
.dark .bg-gray-50 { background-color: #0f1a18 !important; }
|
||||||
.dark .bg-gray-100 { background-color: #1c1c2a !important; }
|
.dark .bg-gray-100 { background-color: #162220 !important; }
|
||||||
.dark .bg-gray-200 { background-color: #24243a !important; }
|
.dark .bg-gray-200 { background-color: #1e2e2b !important; }
|
||||||
|
|
||||||
/* ─── Borders ────────────────────────────────── */
|
/* ─── Borders ────────────────────────────────── */
|
||||||
.dark .border-gray-100,
|
.dark .border-gray-100,
|
||||||
@@ -104,12 +102,12 @@
|
|||||||
.dark .divide-border-light > :not(:first-child) { border-color: rgba(255, 255, 255, 0.05) !important; }
|
.dark .divide-border-light > :not(:first-child) { border-color: rgba(255, 255, 255, 0.05) !important; }
|
||||||
|
|
||||||
/* ─── Text ───────────────────────────────────── */
|
/* ─── Text ───────────────────────────────────── */
|
||||||
.dark .text-gray-900 { color: #eeecf5 !important; }
|
.dark .text-gray-900 { color: #e8f0ee !important; }
|
||||||
.dark .text-gray-800 { color: #d8d5e8 !important; }
|
.dark .text-gray-800 { color: #d0ddd9 !important; }
|
||||||
.dark .text-gray-700 { color: #c2bedb !important; }
|
.dark .text-gray-700 { color: #b5cac5 !important; }
|
||||||
.dark .text-gray-600 { color: #a8a3c0 !important; }
|
.dark .text-gray-600 { color: #9db5b0 !important; }
|
||||||
.dark .text-gray-500 { color: #8b85a8 !important; }
|
.dark .text-gray-500 { color: #7e9a94 !important; }
|
||||||
.dark .text-gray-400 { color: #706b8a !important; }
|
.dark .text-gray-400 { color: #637e78 !important; }
|
||||||
|
|
||||||
/* ─── Status badges — translucent glass ──────── */
|
/* ─── Status badges — translucent glass ──────── */
|
||||||
.dark .bg-emerald-100, .dark .bg-emerald-50 { background-color: rgba(74, 222, 128, 0.12) !important; }
|
.dark .bg-emerald-100, .dark .bg-emerald-50 { background-color: rgba(74, 222, 128, 0.12) !important; }
|
||||||
@@ -150,49 +148,49 @@
|
|||||||
.dark input:focus,
|
.dark input:focus,
|
||||||
.dark select:focus,
|
.dark select:focus,
|
||||||
.dark textarea:focus {
|
.dark textarea:focus {
|
||||||
border-color: rgba(139, 92, 246, 0.5);
|
border-color: rgba(20, 184, 166, 0.5);
|
||||||
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.1);
|
box-shadow: 0 0 0 3px rgba(20, 184, 166, 0.1);
|
||||||
}
|
}
|
||||||
.dark input::placeholder,
|
.dark input::placeholder,
|
||||||
.dark textarea::placeholder {
|
.dark textarea::placeholder {
|
||||||
color: #706b8a;
|
color: #637e78;
|
||||||
}
|
}
|
||||||
.dark input:disabled,
|
.dark input:disabled,
|
||||||
.dark select:disabled,
|
.dark select:disabled,
|
||||||
.dark textarea:disabled {
|
.dark textarea:disabled {
|
||||||
background-color: rgba(255, 255, 255, 0.02) !important;
|
background-color: rgba(255, 255, 255, 0.02) !important;
|
||||||
color: #706b8a !important;
|
color: #637e78 !important;
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark select arrow */
|
/* Dark select arrow */
|
||||||
.dark select {
|
.dark select {
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23706b8a' d='M6 9L1 4h10z'/%3E%3C/svg%3E");
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23637e78' d='M6 9L1 4h10z'/%3E%3C/svg%3E");
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Cards — glass edges ────────────────────── */
|
/* ─── Cards — glass edges ────────────────────── */
|
||||||
.dark .card-hover {
|
.dark .card-hover {
|
||||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.04), 0 2px 8px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.04);
|
||||||
}
|
}
|
||||||
.dark .card-hover:hover {
|
.dark .card-hover:hover {
|
||||||
box-shadow: 0 0 0 1px rgba(139, 92, 246, 0.15), 0 16px 48px -12px rgba(0, 0, 0, 0.5);
|
box-shadow: 0 0 0 1px rgba(20, 184, 166, 0.12), 0 4px 16px -4px rgba(0, 0, 0, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .section-card {
|
.dark .section-card {
|
||||||
background: #1c1c2a;
|
background: #162220;
|
||||||
border-color: rgba(255, 255, 255, 0.06);
|
border-color: rgba(255, 255, 255, 0.06);
|
||||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.04);
|
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.04);
|
||||||
}
|
}
|
||||||
.dark .section-card:hover {
|
.dark .section-card:hover {
|
||||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.06), 0 8px 32px -8px rgba(0, 0, 0, 0.4);
|
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.06), 0 4px 16px -4px rgba(0, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
.dark .section-card-header {
|
.dark .section-card-header {
|
||||||
background: linear-gradient(180deg, rgba(36, 36, 58, 0.5) 0%, #1c1c2a 100%);
|
background: rgba(30, 46, 43, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Sidebar ────────────────────────────────── */
|
/* ─── Sidebar ────────────────────────────────── */
|
||||||
.dark .sidebar {
|
.dark .sidebar {
|
||||||
background: linear-gradient(180deg, #0e0e16 0%, #0a0a12 100%);
|
background: linear-gradient(180deg, #0a1412 0%, #060e0c 100%);
|
||||||
box-shadow: 2px 0 24px rgba(0, 0, 0, 0.5);
|
box-shadow: 2px 0 24px rgba(0, 0, 0, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,22 +214,22 @@
|
|||||||
.dark .hover\:bg-red-50:hover { background-color: rgba(251, 113, 133, 0.08) !important; }
|
.dark .hover\:bg-red-50:hover { background-color: rgba(251, 113, 133, 0.08) !important; }
|
||||||
.dark .hover\:bg-blue-100:hover { background-color: rgba(96, 165, 250, 0.08) !important; }
|
.dark .hover\:bg-blue-100:hover { background-color: rgba(96, 165, 250, 0.08) !important; }
|
||||||
|
|
||||||
/* ─── Brand glow ─────────────────────────────── */
|
/* ─── Brand accent ────────────────────────────── */
|
||||||
.dark .bg-brand-primary {
|
.dark .bg-brand-primary {
|
||||||
box-shadow: 0 0 24px -4px rgba(139, 92, 246, 0.35);
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
.dark .bg-brand-primary:hover {
|
.dark .bg-brand-primary:hover {
|
||||||
box-shadow: 0 0 32px -4px rgba(139, 92, 246, 0.45);
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── White/light text overrides on colored badges ── */
|
/* ─── White/light text overrides on colored badges ── */
|
||||||
.dark .bg-white\/90 { background-color: rgba(28, 28, 42, 0.9) !important; }
|
.dark .bg-white\/90 { background-color: rgba(22, 34, 32, 0.9) !important; }
|
||||||
|
|
||||||
/* ─── Toasts — solid backgrounds, no transparency ── */
|
/* ─── Toasts — solid backgrounds ────────────────── */
|
||||||
.dark .bg-emerald-50.border-emerald-200 { background-color: #132a1e !important; border-color: #1a4a2e !important; }
|
.dark .bg-emerald-50.border-emerald-200 { background-color: #0f2a1e !important; border-color: #154a2e !important; }
|
||||||
.dark .bg-red-50.border-red-200 { background-color: #2a1318 !important; border-color: #4a1a22 !important; }
|
.dark .bg-red-50.border-red-200 { background-color: #2a1315 !important; border-color: #4a1a20 !important; }
|
||||||
.dark .bg-blue-50.border-blue-200 { background-color: #131d2a !important; border-color: #1a2e4a !important; }
|
.dark .bg-blue-50.border-blue-200 { background-color: #0f1d2a !important; border-color: #152e4a !important; }
|
||||||
.dark .bg-amber-50.border-amber-200 { background-color: #2a2213 !important; border-color: #4a3a1a !important; }
|
.dark .bg-amber-50.border-amber-200 { background-color: #2a2210 !important; border-color: #4a3a15 !important; }
|
||||||
.dark .text-emerald-800 { color: #6ee7b7 !important; }
|
.dark .text-emerald-800 { color: #6ee7b7 !important; }
|
||||||
.dark .text-red-800 { color: #fca5a5 !important; }
|
.dark .text-red-800 { color: #fca5a5 !important; }
|
||||||
.dark .text-blue-800 { color: #93c5fd !important; }
|
.dark .text-blue-800 { color: #93c5fd !important; }
|
||||||
@@ -239,10 +237,19 @@
|
|||||||
|
|
||||||
/* ─── Selection ──────────────────────────────── */
|
/* ─── Selection ──────────────────────────────── */
|
||||||
.dark ::selection {
|
.dark ::selection {
|
||||||
background: rgba(139, 92, 246, 0.4);
|
background: rgba(20, 184, 166, 0.4);
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Reduced motion — disable animations for accessibility */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*, *::before, *::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Custom scrollbar */
|
/* Custom scrollbar */
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
@@ -315,15 +322,15 @@ textarea {
|
|||||||
margin-right: 0;
|
margin-right: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Enhanced sidebar with gradient */
|
/* Enhanced sidebar */
|
||||||
.sidebar {
|
.sidebar {
|
||||||
background: linear-gradient(180deg, #0f172a 0%, #020617 100%);
|
background: linear-gradient(180deg, #0a1f1c 0%, #061411 100%);
|
||||||
box-shadow: 2px 0 12px rgba(0, 0, 0, 0.08);
|
box-shadow: 2px 0 12px rgba(0, 0, 0, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Animation keyframes */
|
/* Animation keyframes */
|
||||||
@keyframes fadeIn {
|
@keyframes fadeIn {
|
||||||
from { opacity: 0; transform: translateY(8px); }
|
from { opacity: 0; transform: translateY(4px); }
|
||||||
to { opacity: 1; transform: translateY(0); }
|
to { opacity: 1; transform: translateY(0); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,11 +354,6 @@ textarea {
|
|||||||
50% { opacity: 0.7; }
|
50% { opacity: 0.7; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes bounce-subtle {
|
|
||||||
0%, 100% { transform: translateY(0); }
|
|
||||||
50% { transform: translateY(-4px); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
from { transform: rotate(0deg); }
|
from { transform: rotate(0deg); }
|
||||||
to { transform: rotate(360deg); }
|
to { transform: rotate(360deg); }
|
||||||
@@ -425,29 +427,24 @@ textarea {
|
|||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Stagger children */
|
/* Stagger children — short, max 4 items */
|
||||||
.stagger-children > * {
|
.stagger-children > * {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
animation: fadeIn 0.3s ease-out forwards;
|
animation: fadeIn 0.2s ease-out forwards;
|
||||||
}
|
}
|
||||||
.stagger-children > *:nth-child(1) { animation-delay: 0ms; }
|
.stagger-children > *:nth-child(1) { animation-delay: 0ms; }
|
||||||
.stagger-children > *:nth-child(2) { animation-delay: 50ms; }
|
.stagger-children > *:nth-child(2) { animation-delay: 40ms; }
|
||||||
.stagger-children > *:nth-child(3) { animation-delay: 100ms; }
|
.stagger-children > *:nth-child(3) { animation-delay: 80ms; }
|
||||||
.stagger-children > *:nth-child(4) { animation-delay: 150ms; }
|
.stagger-children > *:nth-child(n+4) { animation-delay: 120ms; }
|
||||||
.stagger-children > *:nth-child(5) { animation-delay: 200ms; }
|
|
||||||
.stagger-children > *:nth-child(6) { animation-delay: 250ms; }
|
|
||||||
.stagger-children > *:nth-child(7) { animation-delay: 300ms; }
|
|
||||||
.stagger-children > *:nth-child(8) { animation-delay: 350ms; }
|
|
||||||
|
|
||||||
/* Card hover effect - smooth and elegant */
|
/* Card hover effect - refined, no lift */
|
||||||
.card-hover {
|
.card-hover {
|
||||||
position: relative;
|
position: relative;
|
||||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: box-shadow 0.2s ease;
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05), 0 1px 2px rgba(0, 0, 0, 0.06);
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||||
}
|
}
|
||||||
.card-hover:hover {
|
.card-hover:hover {
|
||||||
transform: translateY(-3px);
|
box-shadow: 0 4px 12px -2px rgba(0, 0, 0, 0.08);
|
||||||
box-shadow: 0 12px 28px -6px rgba(0, 0, 0, 0.12), 0 6px 16px -8px rgba(0, 0, 0, 0.08);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Stat card accents - subtle colored top borders */
|
/* Stat card accents - subtle colored top borders */
|
||||||
@@ -470,24 +467,12 @@ textarea {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mesh background - subtle radial gradients */
|
/* Mesh background — flat, no gradients */
|
||||||
.bg-mesh {
|
.bg-mesh {
|
||||||
background-color: #f8fafc;
|
background-color: #f8fafc;
|
||||||
background-image:
|
|
||||||
radial-gradient(at 20% 20%, rgba(79, 70, 229, 0.04) 0, transparent 50%),
|
|
||||||
radial-gradient(at 80% 40%, rgba(219, 39, 119, 0.03) 0, transparent 50%),
|
|
||||||
radial-gradient(at 40% 80%, rgba(5, 150, 105, 0.03) 0, transparent 50%);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Gradient text */
|
/* Stat card accent — subtle top border, no gradient */
|
||||||
.text-gradient {
|
|
||||||
background: linear-gradient(135deg, var(--color-brand-primary) 0%, #7c3aed 100%);
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
background-clip: text;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Premium stat card - always-visible gradient top bar */
|
|
||||||
.stat-card-premium {
|
.stat-card-premium {
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -498,20 +483,20 @@ textarea {
|
|||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
height: 3px;
|
height: 2px;
|
||||||
opacity: 1;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
.stat-card-premium.accent-primary::before {
|
.stat-card-premium.accent-primary::before {
|
||||||
background: linear-gradient(90deg, #4f46e5, #7c3aed);
|
background: #0d9488;
|
||||||
}
|
}
|
||||||
.stat-card-premium.accent-secondary::before {
|
.stat-card-premium.accent-secondary::before {
|
||||||
background: linear-gradient(90deg, #db2777, #ec4899);
|
background: #db2777;
|
||||||
}
|
}
|
||||||
.stat-card-premium.accent-tertiary::before {
|
.stat-card-premium.accent-tertiary::before {
|
||||||
background: linear-gradient(90deg, #f59e0b, #fbbf24);
|
background: #f59e0b;
|
||||||
}
|
}
|
||||||
.stat-card-premium.accent-quaternary::before {
|
.stat-card-premium.accent-quaternary::before {
|
||||||
background: linear-gradient(90deg, #059669, #34d399);
|
background: #059669;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Section card - premium container */
|
/* Section card - premium container */
|
||||||
@@ -524,20 +509,19 @@ textarea {
|
|||||||
transition: box-shadow 0.3s ease;
|
transition: box-shadow 0.3s ease;
|
||||||
}
|
}
|
||||||
.section-card:hover {
|
.section-card:hover {
|
||||||
box-shadow: 0 4px 20px -4px rgba(0, 0, 0, 0.08);
|
box-shadow: 0 2px 8px -2px rgba(0, 0, 0, 0.06);
|
||||||
}
|
}
|
||||||
.section-card-header {
|
.section-card-header {
|
||||||
padding: 1rem 1.25rem;
|
padding: 1rem 1.25rem;
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--color-border);
|
||||||
background: linear-gradient(180deg, rgba(249, 250, 251, 0.5) 0%, white 100%);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Sidebar active glow */
|
/* Sidebar active glow */
|
||||||
.sidebar-active-glow {
|
.sidebar-active-glow {
|
||||||
box-shadow: inset 3px 0 0 rgba(129, 140, 248, 0.8);
|
box-shadow: inset 3px 0 0 rgba(20, 184, 166, 0.8);
|
||||||
}
|
}
|
||||||
[dir="rtl"] .sidebar-active-glow {
|
[dir="rtl"] .sidebar-active-glow {
|
||||||
box-shadow: inset -3px 0 0 rgba(129, 140, 248, 0.8);
|
box-shadow: inset -3px 0 0 rgba(20, 184, 166, 0.8);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Refined button styles */
|
/* Refined button styles */
|
||||||
@@ -594,23 +578,6 @@ select:not(:disabled):hover {
|
|||||||
grid-template-columns: repeat(7, 1fr);
|
grid-template-columns: repeat(7, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ripple effect on buttons (optional enhancement) */
|
|
||||||
@keyframes ripple {
|
|
||||||
0% {
|
|
||||||
transform: scale(0);
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: scale(2.5);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Badge pulse animation */
|
|
||||||
.badge-pulse {
|
|
||||||
animation: pulse-subtle 2s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Smooth height transitions */
|
/* Smooth height transitions */
|
||||||
.transition-height {
|
.transition-height {
|
||||||
transition: height 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: height 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
|||||||
@@ -199,8 +199,8 @@ export default function Artefacts() {
|
|||||||
const SortIcon = ({ col }) => {
|
const SortIcon = ({ col }) => {
|
||||||
if (listSortBy !== col) return null
|
if (listSortBy !== col) return null
|
||||||
return listSortDir === 'asc'
|
return listSortDir === 'asc'
|
||||||
? <ChevronUp className="w-3 h-3 inline ml-0.5" />
|
? <ChevronUp className="w-3 h-3 inline ms-0.5" />
|
||||||
: <ChevronDown className="w-3 h-3 inline ml-0.5" />
|
: <ChevronDown className="w-3 h-3 inline ms-0.5" />
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatDate = (dateStr) => {
|
const formatDate = (dateStr) => {
|
||||||
@@ -211,11 +211,7 @@ export default function Artefacts() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4 animate-fade-in">
|
<div className="space-y-4 animate-fade-in">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-end">
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-text-primary">{t('artefacts.title')}</h1>
|
|
||||||
<p className="text-sm text-text-secondary mt-1">{t('artefacts.subtitle')}</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{/* View switcher */}
|
{/* View switcher */}
|
||||||
<div className="flex items-center bg-surface-tertiary rounded-lg p-0.5">
|
<div className="flex items-center bg-surface-tertiary rounded-lg p-0.5">
|
||||||
@@ -228,7 +224,7 @@ export default function Artefacts() {
|
|||||||
onClick={() => setViewMode(mode)}
|
onClick={() => setViewMode(mode)}
|
||||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
|
||||||
viewMode === mode
|
viewMode === mode
|
||||||
? 'bg-white text-text-primary shadow-sm'
|
? 'bg-surface text-text-primary shadow-sm'
|
||||||
: 'text-text-tertiary hover:text-text-secondary'
|
: 'text-text-tertiary hover:text-text-secondary'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -251,13 +247,13 @@ export default function Artefacts() {
|
|||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<div className="relative flex-1 min-w-[200px] max-w-md">
|
<div className="relative flex-1 min-w-[200px] max-w-md">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
|
<Search className="absolute start-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={t('artefacts.searchArtefacts')}
|
placeholder={t('artefacts.searchArtefacts')}
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={e => setSearchTerm(e.target.value)}
|
onChange={e => setSearchTerm(e.target.value)}
|
||||||
className="w-full pl-10 pr-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface transition-colors"
|
className="w-full ps-10 pe-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface transition-colors"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -351,7 +347,7 @@ export default function Artefacts() {
|
|||||||
<button
|
<button
|
||||||
key={artefact.Id}
|
key={artefact.Id}
|
||||||
onClick={() => setSelectedArtefact(artefact)}
|
onClick={() => setSelectedArtefact(artefact)}
|
||||||
className="bg-surface border border-border rounded-xl p-4 hover:border-brand-primary/30 hover:shadow-sm transition-colors text-left"
|
className="bg-surface border border-border rounded-xl p-4 hover:border-brand-primary/30 hover:shadow-sm transition-colors text-start"
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-3 mb-2">
|
<div className="flex items-start gap-3 mb-2">
|
||||||
<div className="w-10 h-10 rounded-lg bg-brand-primary/10 flex items-center justify-center shrink-0">
|
<div className="w-10 h-10 rounded-lg bg-brand-primary/10 flex items-center justify-center shrink-0">
|
||||||
@@ -418,22 +414,22 @@ export default function Artefacts() {
|
|||||||
<th className="w-10 px-3 py-3" onClick={e => e.stopPropagation()}>
|
<th className="w-10 px-3 py-3" onClick={e => e.stopPropagation()}>
|
||||||
<input type="checkbox" checked={selectedIds.size === sortedArtefacts.length && sortedArtefacts.length > 0} onChange={toggleSelectAll} className="rounded border-border" />
|
<input type="checkbox" checked={selectedIds.size === sortedArtefacts.length && sortedArtefacts.length > 0} onChange={toggleSelectAll} className="rounded border-border" />
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleListSort('title')}>
|
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleListSort('title')}>
|
||||||
{t('artefacts.titleLabel')} <SortIcon col="title" />
|
{t('artefacts.titleLabel')} <SortIcon col="title" />
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleListSort('type')}>
|
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleListSort('type')}>
|
||||||
{t('artefacts.type')} <SortIcon col="type" />
|
{t('artefacts.type')} <SortIcon col="type" />
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleListSort('status')}>
|
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleListSort('status')}>
|
||||||
{t('artefacts.status')} <SortIcon col="status" />
|
{t('artefacts.status')} <SortIcon col="status" />
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('artefacts.brand')}</th>
|
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('artefacts.brand')}</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('artefacts.project')}</th>
|
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('artefacts.project')}</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('artefacts.campaign')}</th>
|
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('artefacts.campaign')}</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('artefacts.creator')}</th>
|
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('artefacts.creator')}</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('artefacts.approvers')}</th>
|
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('artefacts.approvers')}</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('artefacts.version')}</th>
|
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('artefacts.version')}</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleListSort('updated_at')}>
|
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleListSort('updated_at')}>
|
||||||
{t('artefacts.updated')} <SortIcon col="updated_at" />
|
{t('artefacts.updated')} <SortIcon col="updated_at" />
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -181,20 +181,20 @@ export default function Assets() {
|
|||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<div className="relative flex-1 min-w-[200px] max-w-md">
|
<div className="relative flex-1 min-w-[200px] max-w-md">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
|
<Search className="absolute start-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search assets..."
|
placeholder="Search assets..."
|
||||||
value={filters.search}
|
value={filters.search}
|
||||||
onChange={e => setFilters(f => ({ ...f, search: e.target.value }))}
|
onChange={e => setFilters(f => ({ ...f, search: e.target.value }))}
|
||||||
className="w-full pl-10 pr-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-white"
|
className="w-full ps-10 pe-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<select
|
<select
|
||||||
value={filters.brand}
|
value={filters.brand}
|
||||||
onChange={e => setFilters(f => ({ ...f, brand: e.target.value }))}
|
onChange={e => setFilters(f => ({ ...f, brand: e.target.value }))}
|
||||||
className="text-sm border border-border rounded-lg px-3 py-2 bg-white text-text-secondary focus:outline-none"
|
className="text-sm border border-border rounded-lg px-3 py-2 bg-surface text-text-secondary focus:outline-none"
|
||||||
>
|
>
|
||||||
<option value="">All Brands</option>
|
<option value="">All Brands</option>
|
||||||
{brands.map(b => <option key={b} value={b}>{b}</option>)}
|
{brands.map(b => <option key={b} value={b}>{b}</option>)}
|
||||||
@@ -203,7 +203,7 @@ export default function Assets() {
|
|||||||
<select
|
<select
|
||||||
value={filters.tag}
|
value={filters.tag}
|
||||||
onChange={e => setFilters(f => ({ ...f, tag: e.target.value }))}
|
onChange={e => setFilters(f => ({ ...f, tag: e.target.value }))}
|
||||||
className="text-sm border border-border rounded-lg px-3 py-2 bg-white text-text-secondary focus:outline-none"
|
className="text-sm border border-border rounded-lg px-3 py-2 bg-surface text-text-secondary focus:outline-none"
|
||||||
>
|
>
|
||||||
<option value="">All Tags</option>
|
<option value="">All Tags</option>
|
||||||
{allTags.map(t => <option key={t} value={t}>{t}</option>)}
|
{allTags.map(t => <option key={t} value={t}>{t}</option>)}
|
||||||
@@ -211,7 +211,7 @@ export default function Assets() {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowUpload(true)}
|
onClick={() => setShowUpload(true)}
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm ml-auto"
|
className="flex items-center gap-2 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm ms-auto"
|
||||||
>
|
>
|
||||||
<Upload className="w-4 h-4" />
|
<Upload className="w-4 h-4" />
|
||||||
Upload
|
Upload
|
||||||
@@ -260,7 +260,7 @@ export default function Assets() {
|
|||||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4 stagger-children">
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4 stagger-children">
|
||||||
{filteredAssets.map(asset => (
|
{filteredAssets.map(asset => (
|
||||||
<div key={asset._id || asset.id} className="relative">
|
<div key={asset._id || asset.id} className="relative">
|
||||||
<div className="absolute top-2 left-2 z-10" onClick={e => e.stopPropagation()}>
|
<div className="absolute top-2 start-2 z-10" onClick={e => e.stopPropagation()}>
|
||||||
<input type="checkbox" checked={selectedIds.has(asset._id || asset.id)} onChange={() => toggleSelect(asset._id || asset.id)} className="rounded border-border" />
|
<input type="checkbox" checked={selectedIds.has(asset._id || asset.id)} onChange={() => toggleSelect(asset._id || asset.id)} className="rounded border-border" />
|
||||||
</div>
|
</div>
|
||||||
<AssetCard asset={asset} onClick={setSelectedAsset} />
|
<AssetCard asset={asset} onClick={setSelectedAsset} />
|
||||||
@@ -319,7 +319,7 @@ export default function Assets() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{selectedAsset.type === 'image' && selectedAsset.url && (
|
{selectedAsset.type === 'image' && selectedAsset.url && (
|
||||||
<div className="rounded-lg overflow-hidden bg-surface-tertiary">
|
<div className="rounded-lg overflow-hidden bg-surface-tertiary">
|
||||||
<img src={selectedAsset.url} alt={selectedAsset.name} className="w-full max-h-[400px] object-contain" />
|
<img src={selectedAsset.url} alt={selectedAsset.name} className="w-full max-h-[400px] object-contain" loading="lazy" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{selectedAsset.type === 'video' && selectedAsset.url && (
|
{selectedAsset.type === 'video' && selectedAsset.url && (
|
||||||
@@ -374,7 +374,7 @@ export default function Assets() {
|
|||||||
download={selectedAsset.name}
|
download={selectedAsset.name}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="ml-auto px-4 py-2 text-sm font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light"
|
className="ms-auto px-4 py-2 text-sm font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light"
|
||||||
>
|
>
|
||||||
Download
|
Download
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ export default function Brands() {
|
|||||||
|
|
||||||
{/* Brand Cards Grid */}
|
{/* Brand Cards Grid */}
|
||||||
{brands.length === 0 ? (
|
{brands.length === 0 ? (
|
||||||
<div className="bg-white rounded-xl border border-border py-16 text-center">
|
<div className="bg-surface rounded-xl border border-border py-16 text-center">
|
||||||
<Tag className="w-12 h-12 text-text-quaternary mx-auto mb-3" />
|
<Tag className="w-12 h-12 text-text-quaternary mx-auto mb-3" />
|
||||||
<p className="text-sm text-text-tertiary">{t('brands.noBrands')}</p>
|
<p className="text-sm text-text-tertiary">{t('brands.noBrands')}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -154,7 +154,7 @@ export default function Brands() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={getBrandId(brand)}
|
key={getBrandId(brand)}
|
||||||
className={`bg-white rounded-xl border border-border overflow-hidden hover:shadow-md transition-all aspect-square flex flex-col ${isSuperadminOrManager ? 'cursor-pointer' : ''}`}
|
className={`bg-surface rounded-xl border border-border overflow-hidden hover:shadow-md transition-all aspect-square flex flex-col ${isSuperadminOrManager ? 'cursor-pointer' : ''}`}
|
||||||
onClick={() => isSuperadminOrManager && openEditBrand(brand)}
|
onClick={() => isSuperadminOrManager && openEditBrand(brand)}
|
||||||
>
|
>
|
||||||
{/* Logo area */}
|
{/* Logo area */}
|
||||||
@@ -164,6 +164,7 @@ export default function Brands() {
|
|||||||
src={`${API_BASE}/uploads/${brand.logo}`}
|
src={`${API_BASE}/uploads/${brand.logo}`}
|
||||||
alt={displayName}
|
alt={displayName}
|
||||||
className="w-full h-full object-contain p-4"
|
className="w-full h-full object-contain p-4"
|
||||||
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-3xl">
|
<div className="text-3xl">
|
||||||
@@ -171,17 +172,17 @@ export default function Brands() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isSuperadminOrManager && (
|
{isSuperadminOrManager && (
|
||||||
<div className="absolute top-1.5 right-1.5 flex gap-1 opacity-0 group-hover:opacity-100" onClick={e => e.stopPropagation()}>
|
<div className="absolute top-1.5 end-1.5 flex gap-1 opacity-0 group-hover:opacity-100" onClick={e => e.stopPropagation()}>
|
||||||
<button
|
<button
|
||||||
onClick={() => openEditBrand(brand)}
|
onClick={() => openEditBrand(brand)}
|
||||||
className="p-1 bg-white/90 hover:bg-white rounded-md text-text-tertiary hover:text-text-primary shadow-sm"
|
className="p-1 bg-white/90 hover:bg-surface rounded-md text-text-tertiary hover:text-text-primary shadow-sm"
|
||||||
title={t('common.edit')}
|
title={t('common.edit')}
|
||||||
>
|
>
|
||||||
<Edit2 className="w-3 h-3" />
|
<Edit2 className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setBrandToDelete(brand); setShowDeleteModal(true) }}
|
onClick={() => { setBrandToDelete(brand); setShowDeleteModal(true) }}
|
||||||
className="p-1 bg-white/90 hover:bg-white rounded-md text-text-tertiary hover:text-red-500 shadow-sm"
|
className="p-1 bg-white/90 hover:bg-surface rounded-md text-text-tertiary hover:text-red-500 shadow-sm"
|
||||||
title={t('common.delete')}
|
title={t('common.delete')}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3 h-3" />
|
<Trash2 className="w-3 h-3" />
|
||||||
@@ -269,6 +270,7 @@ export default function Brands() {
|
|||||||
src={`${API_BASE}/uploads/${editingBrand.logo}`}
|
src={`${API_BASE}/uploads/${editingBrand.logo}`}
|
||||||
alt="Logo"
|
alt="Logo"
|
||||||
className="h-16 object-contain"
|
className="h-16 object-contain"
|
||||||
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -153,11 +153,7 @@ export default function Budgets() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6 animate-fade-in">
|
<div className="space-y-6 animate-fade-in">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-end">
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-text-primary">{t('budgets.title')}</h1>
|
|
||||||
<p className="text-sm text-text-tertiary mt-0.5">{t('budgets.subtitle')}</p>
|
|
||||||
</div>
|
|
||||||
{canManageFinance && (
|
{canManageFinance && (
|
||||||
<button
|
<button
|
||||||
onClick={() => { setEditing(null); setForm(EMPTY_ENTRY); setShowModal(true) }}
|
onClick={() => { setEditing(null); setForm(EMPTY_ENTRY); setShowModal(true) }}
|
||||||
@@ -171,19 +167,19 @@ export default function Budgets() {
|
|||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
<div className="relative flex-1 min-w-[200px] max-w-xs">
|
<div className="relative flex-1 min-w-[200px] max-w-xs">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
|
<Search className="absolute start-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={e => setSearchQuery(e.target.value)}
|
onChange={e => setSearchQuery(e.target.value)}
|
||||||
placeholder={t('budgets.searchEntries')}
|
placeholder={t('budgets.searchEntries')}
|
||||||
className="w-full pl-9 pr-3 py-2 text-sm border border-border rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
className="w-full ps-9 pe-3 py-2 text-sm border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<select
|
<select
|
||||||
value={filterCategory}
|
value={filterCategory}
|
||||||
onChange={e => setFilterCategory(e.target.value)}
|
onChange={e => setFilterCategory(e.target.value)}
|
||||||
className="px-3 py-2 text-sm border border-border rounded-lg bg-white focus:outline-none"
|
className="px-3 py-2 text-sm border border-border rounded-lg bg-surface focus:outline-none"
|
||||||
>
|
>
|
||||||
<option value="">{t('budgets.allCategories')}</option>
|
<option value="">{t('budgets.allCategories')}</option>
|
||||||
{CATEGORIES.map(c => <option key={c.value} value={c.value}>{c.label}</option>)}
|
{CATEGORIES.map(c => <option key={c.value} value={c.value}>{c.label}</option>)}
|
||||||
@@ -191,7 +187,7 @@ export default function Budgets() {
|
|||||||
<select
|
<select
|
||||||
value={filterDestination}
|
value={filterDestination}
|
||||||
onChange={e => setFilterDestination(e.target.value)}
|
onChange={e => setFilterDestination(e.target.value)}
|
||||||
className="px-3 py-2 text-sm border border-border rounded-lg bg-white focus:outline-none"
|
className="px-3 py-2 text-sm border border-border rounded-lg bg-surface focus:outline-none"
|
||||||
>
|
>
|
||||||
<option value="">{t('budgets.allDestinations')}</option>
|
<option value="">{t('budgets.allDestinations')}</option>
|
||||||
{DESTINATIONS.map(d => <option key={d.value} value={d.value}>{t(d.labelKey)}</option>)}
|
{DESTINATIONS.map(d => <option key={d.value} value={d.value}>{t(d.labelKey)}</option>)}
|
||||||
@@ -206,7 +202,7 @@ export default function Budgets() {
|
|||||||
className={`px-3 py-1.5 text-xs font-medium transition-colors ${
|
className={`px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||||
filterType === opt.value
|
filterType === opt.value
|
||||||
? opt.value === 'expense' ? 'bg-red-500 text-white' : opt.value === 'income' ? 'bg-emerald-500 text-white' : 'bg-brand-primary text-white'
|
? opt.value === 'expense' ? 'bg-red-500 text-white' : opt.value === 'income' ? 'bg-emerald-500 text-white' : 'bg-brand-primary text-white'
|
||||||
: 'bg-white text-text-secondary hover:bg-surface-secondary'
|
: 'bg-surface text-text-secondary hover:bg-surface-secondary'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{opt.label}
|
{opt.label}
|
||||||
@@ -215,7 +211,7 @@ export default function Budgets() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{filteredEntries.length > 0 && (
|
{filteredEntries.length > 0 && (
|
||||||
<div className="ml-auto flex items-center gap-3 text-sm text-text-tertiary">
|
<div className="ms-auto flex items-center gap-3 text-sm text-text-tertiary">
|
||||||
<span>{filteredEntries.length} {filteredEntries.length === 1 ? 'entry' : 'entries'}</span>
|
<span>{filteredEntries.length} {filteredEntries.length === 1 ? 'entry' : 'entries'}</span>
|
||||||
<span className="text-emerald-600 font-semibold">+{totalIncome.toLocaleString()}</span>
|
<span className="text-emerald-600 font-semibold">+{totalIncome.toLocaleString()}</span>
|
||||||
{totalExpenseAmt > 0 && <span className="text-red-500 font-semibold">-{totalExpenseAmt.toLocaleString()}</span>}
|
{totalExpenseAmt > 0 && <span className="text-red-500 font-semibold">-{totalExpenseAmt.toLocaleString()}</span>}
|
||||||
@@ -235,12 +231,12 @@ export default function Budgets() {
|
|||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-border bg-surface-secondary">
|
<tr className="border-b border-border bg-surface-secondary">
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-text-tertiary">{t('budgets.label')}</th>
|
<th className="px-4 py-3 text-start text-xs font-medium text-text-tertiary">{t('budgets.label')}</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-text-tertiary">{t('budgets.source')}</th>
|
<th className="px-4 py-3 text-start text-xs font-medium text-text-tertiary">{t('budgets.source')}</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-text-tertiary">{t('budgets.destination')}</th>
|
<th className="px-4 py-3 text-start text-xs font-medium text-text-tertiary">{t('budgets.destination')}</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-text-tertiary">{t('budgets.linkedTo')}</th>
|
<th className="px-4 py-3 text-start text-xs font-medium text-text-tertiary">{t('budgets.linkedTo')}</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-text-tertiary">{t('budgets.date')}</th>
|
<th className="px-4 py-3 text-start text-xs font-medium text-text-tertiary">{t('budgets.date')}</th>
|
||||||
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">{t('budgets.amount')}</th>
|
<th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">{t('budgets.amount')}</th>
|
||||||
{canManageFinance && <th className="px-4 py-3 w-20" />}
|
{canManageFinance && <th className="px-4 py-3 w-20" />}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -289,7 +285,7 @@ export default function Budgets() {
|
|||||||
<td className="px-4 py-3 text-text-secondary whitespace-nowrap">
|
<td className="px-4 py-3 text-text-secondary whitespace-nowrap">
|
||||||
{entry.date_received ? format(new Date(entry.date_received), 'MMM d, yyyy') : '--'}
|
{entry.date_received ? format(new Date(entry.date_received), 'MMM d, yyyy') : '--'}
|
||||||
</td>
|
</td>
|
||||||
<td className={`px-4 py-3 text-right font-semibold whitespace-nowrap ${
|
<td className={`px-4 py-3 text-end font-semibold whitespace-nowrap ${
|
||||||
(entry.type || 'income') === 'expense' ? 'text-red-500' : 'text-emerald-600'
|
(entry.type || 'income') === 'expense' ? 'text-red-500' : 'text-emerald-600'
|
||||||
}`}>
|
}`}>
|
||||||
{(entry.type || 'income') === 'expense' ? '-' : '+'}{Number(entry.amount).toLocaleString()} {currencySymbol}
|
{(entry.type || 'income') === 'expense' ? '-' : '+'}{Number(entry.amount).toLocaleString()} {currencySymbol}
|
||||||
@@ -332,7 +328,7 @@ export default function Budgets() {
|
|||||||
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium border-2 transition-all ${
|
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium border-2 transition-all ${
|
||||||
form.type === 'income'
|
form.type === 'income'
|
||||||
? 'border-emerald-500 bg-emerald-50 text-emerald-700'
|
? 'border-emerald-500 bg-emerald-50 text-emerald-700'
|
||||||
: 'border-border bg-white text-text-secondary hover:bg-surface-secondary'
|
: 'border-border bg-surface text-text-secondary hover:bg-surface-secondary'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<TrendingUp className="w-4 h-4" />
|
<TrendingUp className="w-4 h-4" />
|
||||||
@@ -344,7 +340,7 @@ export default function Budgets() {
|
|||||||
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium border-2 transition-all ${
|
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium border-2 transition-all ${
|
||||||
form.type === 'expense'
|
form.type === 'expense'
|
||||||
? 'border-red-500 bg-red-50 text-red-700'
|
? 'border-red-500 bg-red-50 text-red-700'
|
||||||
: 'border-border bg-white text-text-secondary hover:bg-surface-secondary'
|
: 'border-border bg-surface text-text-secondary hover:bg-surface-secondary'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<TrendingDown className="w-4 h-4" />
|
<TrendingDown className="w-4 h-4" />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useContext } from 'react'
|
import { useState, useEffect, useContext } from 'react'
|
||||||
import { useParams, useNavigate } from 'react-router-dom'
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
import { ArrowLeft, Plus, Edit2, Trash2, DollarSign, Eye, MousePointer, Target, TrendingUp, FileText, Megaphone, Search, Globe, Pencil, Users, X, UserPlus, MessageCircle, Settings } from 'lucide-react'
|
import { ArrowLeft, Plus, Edit2, Trash2, DollarSign, Eye, MousePointer, Target, TrendingUp, FileText, Megaphone, Search, Globe, Users, X, MessageCircle, Settings } from 'lucide-react'
|
||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
import { AppContext } from '../App'
|
import { AppContext } from '../App'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
@@ -26,21 +26,11 @@ const TRACK_TYPES = {
|
|||||||
|
|
||||||
const TRACK_STATUSES = ['planned', 'active', 'paused', 'completed']
|
const TRACK_STATUSES = ['planned', 'active', 'paused', 'completed']
|
||||||
|
|
||||||
function MetricBox({ label, value, icon: Icon, color = 'text-text-primary' }) {
|
|
||||||
return (
|
|
||||||
<div className="text-center">
|
|
||||||
<Icon className={`w-4 h-4 mx-auto mb-0.5 ${color}`} />
|
|
||||||
<div className={`text-base font-bold ${color}`}>{value ?? '—'}</div>
|
|
||||||
<div className="text-[10px] text-text-tertiary">{label}</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CampaignDetail() {
|
export default function CampaignDetail() {
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { brands, getBrandName, teamMembers } = useContext(AppContext)
|
const { brands, getBrandName, teamMembers } = useContext(AppContext)
|
||||||
const { lang, currencySymbol } = useLanguage()
|
const { t, lang, currencySymbol } = useLanguage()
|
||||||
const { permissions, user } = useAuth()
|
const { permissions, user } = useAuth()
|
||||||
const isSuperadmin = user?.role === 'superadmin'
|
const isSuperadmin = user?.role === 'superadmin'
|
||||||
const [campaign, setCampaign] = useState(null)
|
const [campaign, setCampaign] = useState(null)
|
||||||
@@ -211,7 +201,7 @@ export default function CampaignDetail() {
|
|||||||
if (!campaign) {
|
if (!campaign) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center py-12 text-text-tertiary">
|
<div className="text-center py-12 text-text-tertiary">
|
||||||
Campaign not found. <button onClick={() => navigate('/campaigns')} className="text-brand-primary underline">Go back</button>
|
{t('campaigns.notFound')} <button onClick={() => navigate('/campaigns')} className="text-brand-primary underline">{t('common.goBack')}</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -244,9 +234,6 @@ export default function CampaignDetail() {
|
|||||||
{campaign.start_date && campaign.end_date && (
|
{campaign.start_date && campaign.end_date && (
|
||||||
<span>{format(new Date(campaign.start_date), 'MMM d')} – {format(new Date(campaign.end_date), 'MMM d, yyyy')}</span>
|
<span>{format(new Date(campaign.start_date), 'MMM d')} – {format(new Date(campaign.end_date), 'MMM d, yyyy')}</span>
|
||||||
)}
|
)}
|
||||||
<span>
|
|
||||||
Budget: {campaign.budget > 0 ? `${campaign.budget.toLocaleString()} ${currencySymbol}` : 'Not set'}
|
|
||||||
</span>
|
|
||||||
{campaign.platforms && campaign.platforms.length > 0 && (
|
{campaign.platforms && campaign.platforms.length > 0 && (
|
||||||
<PlatformIcons platforms={campaign.platforms} size={16} />
|
<PlatformIcons platforms={campaign.platforms} size={16} />
|
||||||
)}
|
)}
|
||||||
@@ -263,109 +250,73 @@ export default function CampaignDetail() {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<MessageCircle className="w-4 h-4" />
|
<MessageCircle className="w-4 h-4" />
|
||||||
Discussion
|
{t('campaigns.discussion')}
|
||||||
</button>
|
</button>
|
||||||
{canSetBudget && (
|
|
||||||
<button
|
|
||||||
onClick={() => { setBudgetValue(campaign.budget || ''); setEditingBudget(true) }}
|
|
||||||
className="flex items-center gap-1.5 px-3 py-2 bg-surface-tertiary text-text-secondary hover:bg-surface-tertiary/80 hover:text-text-primary rounded-lg text-sm font-medium transition-colors"
|
|
||||||
>
|
|
||||||
<DollarSign className="w-4 h-4" />
|
|
||||||
Budget
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{canManage && (
|
{canManage && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setPanelCampaign(campaign)}
|
onClick={() => setPanelCampaign(campaign)}
|
||||||
className="flex items-center gap-1.5 px-3 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm transition-colors"
|
className="flex items-center gap-1.5 px-3 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm transition-colors"
|
||||||
>
|
>
|
||||||
<Settings className="w-4 h-4" />
|
<Settings className="w-4 h-4" />
|
||||||
Edit
|
{t('common.edit')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Assigned Team */}
|
{/* Budget Card */}
|
||||||
<div className="bg-white rounded-xl border border-border p-5">
|
<div className="bg-surface rounded-xl border border-border p-5">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium flex items-center gap-1.5">
|
<h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium">{t('campaigns.budget')}</h3>
|
||||||
<Users className="w-3.5 h-3.5" /> Assigned Team
|
{canSetBudget && (
|
||||||
</h3>
|
<button onClick={() => { setBudgetValue(campaign.budget || ''); setEditingBudget(true) }}
|
||||||
{canAssign && (
|
className="text-xs text-brand-primary hover:text-brand-primary-light font-medium">
|
||||||
<button
|
{t('common.edit')}
|
||||||
onClick={openAssignModal}
|
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-brand-primary text-white rounded-lg text-xs font-medium hover:bg-brand-primary-light"
|
|
||||||
>
|
|
||||||
<UserPlus className="w-3.5 h-3.5" /> Assign Members
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{assignments.length === 0 ? (
|
<div className="flex items-baseline gap-2 mb-3">
|
||||||
<p className="text-xs text-text-tertiary py-2">No team members assigned yet.</p>
|
<span className="text-2xl font-bold text-text-primary">
|
||||||
) : (
|
{totalAllocated.toLocaleString()} {currencySymbol}
|
||||||
<div className="flex flex-wrap gap-2">
|
</span>
|
||||||
{assignments.map(a => (
|
<span className="text-sm text-text-tertiary">{t('finance.allocated')}</span>
|
||||||
<div key={a.user_id} className="flex items-center gap-2 bg-surface-secondary rounded-full pl-1 pr-2 py-1">
|
</div>
|
||||||
<div className="w-6 h-6 rounded-full bg-brand-primary/10 text-brand-primary flex items-center justify-center text-[10px] font-bold shrink-0">
|
{totalAllocated > 0 && (
|
||||||
{a.user_avatar ? (
|
<>
|
||||||
<img src={a.user_avatar} className="w-full h-full rounded-full object-cover" alt="" />
|
<BudgetBar budget={totalAllocated} spent={totalSpent} height="h-2.5" />
|
||||||
) : (
|
<div className="flex justify-between mt-2 text-xs text-text-tertiary">
|
||||||
getInitials(a.user_name)
|
<span>{totalSpent.toLocaleString()} {currencySymbol} {t('dashboard.spent')}</span>
|
||||||
)}
|
<span>{(totalAllocated - totalSpent).toLocaleString()} {currencySymbol} {t('dashboard.remaining')}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs font-medium text-text-primary">{a.user_name}</span>
|
</>
|
||||||
{canAssign && (
|
)}
|
||||||
<button
|
{(totalImpressions > 0 || totalClicks > 0) && (
|
||||||
onClick={() => removeAssignment(a.user_id)}
|
<div className="flex items-center gap-4 mt-4 pt-3 border-t border-border-light text-xs text-text-secondary">
|
||||||
className="p-0.5 rounded-full hover:bg-red-100 text-text-tertiary hover:text-red-500"
|
<span><Eye className="w-3.5 h-3.5 inline me-1" />{totalImpressions.toLocaleString()}</span>
|
||||||
>
|
<span><MousePointer className="w-3.5 h-3.5 inline me-1" />{totalClicks.toLocaleString()}</span>
|
||||||
<X className="w-3 h-3" />
|
{totalConversions > 0 && <span><Target className="w-3.5 h-3.5 inline me-1" />{totalConversions.toLocaleString()}</span>}
|
||||||
</button>
|
{totalRevenue > 0 && <span><DollarSign className="w-3.5 h-3.5 inline me-1" />{totalRevenue.toLocaleString()} {currencySymbol}</span>}
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Aggregate Metrics */}
|
|
||||||
{tracks.length > 0 && (
|
|
||||||
<div className="bg-white rounded-xl border border-border p-5">
|
|
||||||
<h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium mb-4">Campaign Totals (from tracks)</h3>
|
|
||||||
<div className="grid grid-cols-3 md:grid-cols-6 gap-4">
|
|
||||||
<MetricBox icon={DollarSign} label="Allocated" value={`${totalAllocated.toLocaleString()}`} color="text-blue-600" />
|
|
||||||
<MetricBox icon={TrendingUp} label="Spent" value={`${totalSpent.toLocaleString()}`} color="text-amber-600" />
|
|
||||||
<MetricBox icon={Eye} label="Impressions" value={totalImpressions.toLocaleString()} color="text-purple-600" />
|
|
||||||
<MetricBox icon={MousePointer} label="Clicks" value={totalClicks.toLocaleString()} color="text-green-600" />
|
|
||||||
<MetricBox icon={Target} label="Conversions" value={totalConversions.toLocaleString()} color="text-red-600" />
|
|
||||||
<MetricBox icon={DollarSign} label="Revenue" value={`${totalRevenue.toLocaleString()}`} color="text-emerald-600" />
|
|
||||||
</div>
|
|
||||||
{totalAllocated > 0 && (
|
|
||||||
<div className="mt-4">
|
|
||||||
<BudgetBar budget={totalAllocated} spent={totalSpent} height="h-2" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Tracks */}
|
{/* Tracks */}
|
||||||
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
<div className="bg-surface rounded-xl border border-border overflow-hidden">
|
||||||
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
|
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
|
||||||
<h3 className="font-semibold text-text-primary">Tracks</h3>
|
<h3 className="font-semibold text-text-primary">{t('campaigns.tracks')}</h3>
|
||||||
{canManage && (
|
{canManage && (
|
||||||
<button
|
<button
|
||||||
onClick={() => { setPanelTrack({}); setTrackScrollToMetrics(false) }}
|
onClick={() => { setPanelTrack({}); setTrackScrollToMetrics(false) }}
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-brand-primary text-white rounded-lg text-xs font-medium hover:bg-brand-primary-light"
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-brand-primary text-white rounded-lg text-xs font-medium hover:bg-brand-primary-light"
|
||||||
>
|
>
|
||||||
<Plus className="w-3.5 h-3.5" /> Add Track
|
<Plus className="w-3.5 h-3.5" /> {t('campaigns.addTrack')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tracks.length === 0 ? (
|
{tracks.length === 0 ? (
|
||||||
<div className="py-12 text-center text-sm text-text-tertiary">
|
<div className="py-12 text-center text-sm text-text-tertiary">
|
||||||
No tracks yet. Add organic, paid, or SEO tracks to organize this campaign.
|
{t('campaigns.noTracks')}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y divide-border-light">
|
<div className="divide-y divide-border-light">
|
||||||
@@ -403,9 +354,9 @@ export default function CampaignDetail() {
|
|||||||
{/* Quick metrics */}
|
{/* Quick metrics */}
|
||||||
{(track.impressions > 0 || track.clicks > 0 || track.conversions > 0) && (
|
{(track.impressions > 0 || track.clicks > 0 || track.conversions > 0) && (
|
||||||
<div className="flex items-center gap-3 mt-1.5 text-[10px] text-text-tertiary">
|
<div className="flex items-center gap-3 mt-1.5 text-[10px] text-text-tertiary">
|
||||||
{track.impressions > 0 && <span>👁 {track.impressions.toLocaleString()}</span>}
|
{track.impressions > 0 && <span><Eye className="w-3 h-3 inline" /> {track.impressions.toLocaleString()}</span>}
|
||||||
{track.clicks > 0 && <span>🖱 {track.clicks.toLocaleString()}</span>}
|
{track.clicks > 0 && <span><MousePointer className="w-3 h-3 inline" /> {track.clicks.toLocaleString()}</span>}
|
||||||
{track.conversions > 0 && <span>🎯 {track.conversions.toLocaleString()}</span>}
|
{track.conversions > 0 && <span><Target className="w-3 h-3 inline" /> {track.conversions.toLocaleString()}</span>}
|
||||||
{track.clicks > 0 && track.budget_spent > 0 && (
|
{track.clicks > 0 && track.budget_spent > 0 && (
|
||||||
<span>CPC: {(track.budget_spent / track.clicks).toFixed(2)} {currencySymbol}</span>
|
<span>CPC: {(track.budget_spent / track.clicks).toFixed(2)} {currencySymbol}</span>
|
||||||
)}
|
)}
|
||||||
@@ -418,7 +369,7 @@ export default function CampaignDetail() {
|
|||||||
{/* Linked posts count */}
|
{/* Linked posts count */}
|
||||||
{trackPosts.length > 0 && (
|
{trackPosts.length > 0 && (
|
||||||
<div className="text-[10px] text-text-tertiary mt-1">
|
<div className="text-[10px] text-text-tertiary mt-1">
|
||||||
📝 {trackPosts.length} post{trackPosts.length !== 1 ? 's' : ''} linked
|
<FileText className="w-3 h-3 inline" /> {trackPosts.length} {t('campaigns.postsLinked')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -461,11 +412,31 @@ export default function CampaignDetail() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Team */}
|
||||||
|
{(assignments.length > 0 || canAssign) && (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xs text-text-tertiary font-medium">{t('campaigns.team')}:</span>
|
||||||
|
<div className="flex -space-x-1.5">
|
||||||
|
{assignments.slice(0, 6).map(a => (
|
||||||
|
<div key={a.user_id} className="w-7 h-7 rounded-full bg-brand-primary/10 text-brand-primary flex items-center justify-center text-[10px] font-bold border-2 border-surface" title={a.user_name}>
|
||||||
|
{a.user_avatar ? <img src={a.user_avatar} className="w-full h-full rounded-full object-cover" alt="" loading="lazy" /> : getInitials(a.user_name)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{assignments.length > 6 && <div className="w-7 h-7 rounded-full bg-surface-tertiary flex items-center justify-center text-[10px] text-text-tertiary font-medium border-2 border-surface">+{assignments.length - 6}</div>}
|
||||||
|
</div>
|
||||||
|
{canAssign && (
|
||||||
|
<button onClick={openAssignModal} className="text-xs text-brand-primary hover:text-brand-primary-light font-medium">
|
||||||
|
{t('campaigns.assignMembers')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Linked Posts */}
|
{/* Linked Posts */}
|
||||||
{posts.length > 0 && (
|
{posts.length > 0 && (
|
||||||
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
<div className="bg-surface rounded-xl border border-border overflow-hidden">
|
||||||
<div className="px-5 py-4 border-b border-border">
|
<div className="px-5 py-4 border-b border-border">
|
||||||
<h3 className="font-semibold text-text-primary">Linked Posts ({posts.length})</h3>
|
<h3 className="font-semibold text-text-primary">{t('campaigns.linkedPosts')} ({posts.length})</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="divide-y divide-border-light">
|
<div className="divide-y divide-border-light">
|
||||||
{posts.map(post => (
|
{posts.map(post => (
|
||||||
@@ -475,7 +446,7 @@ export default function CampaignDetail() {
|
|||||||
className="flex items-center gap-3 px-5 py-3 hover:bg-surface-secondary cursor-pointer transition-colors"
|
className="flex items-center gap-3 px-5 py-3 hover:bg-surface-secondary cursor-pointer transition-colors"
|
||||||
>
|
>
|
||||||
{post.thumbnail_url && (
|
{post.thumbnail_url && (
|
||||||
<img src={post.thumbnail_url} alt="" className="w-10 h-10 rounded-lg object-cover shrink-0" />
|
<img src={post.thumbnail_url} alt="" className="w-10 h-10 rounded-lg object-cover shrink-0" loading="lazy" />
|
||||||
)}
|
)}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -501,11 +472,11 @@ export default function CampaignDetail() {
|
|||||||
|
|
||||||
{/* ─── DISCUSSION SIDEBAR ─── */}
|
{/* ─── DISCUSSION SIDEBAR ─── */}
|
||||||
{showDiscussion && (
|
{showDiscussion && (
|
||||||
<div className="w-[340px] shrink-0 bg-white rounded-xl border border-border flex flex-col self-start sticky top-4" style={{ maxHeight: 'calc(100vh - 6rem)' }}>
|
<div className="w-[340px] shrink-0 bg-surface rounded-xl border border-border flex flex-col self-start sticky top-4" style={{ maxHeight: 'calc(100vh - 6rem)' }}>
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||||
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-1.5">
|
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-1.5">
|
||||||
<MessageCircle className="w-4 h-4" />
|
<MessageCircle className="w-4 h-4" />
|
||||||
Discussion
|
{t('campaigns.discussion')}
|
||||||
</h3>
|
</h3>
|
||||||
<button onClick={() => setShowDiscussion(false)} className="p-1 hover:bg-surface-tertiary rounded-lg text-text-tertiary">
|
<button onClick={() => setShowDiscussion(false)} className="p-1 hover:bg-surface-tertiary rounded-lg text-text-tertiary">
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
@@ -557,7 +528,7 @@ export default function CampaignDetail() {
|
|||||||
/>
|
/>
|
||||||
<div className="w-7 h-7 rounded-full bg-brand-primary/10 text-brand-primary flex items-center justify-center text-[10px] font-bold shrink-0">
|
<div className="w-7 h-7 rounded-full bg-brand-primary/10 text-brand-primary flex items-center justify-center text-[10px] font-bold shrink-0">
|
||||||
{u.avatar ? (
|
{u.avatar ? (
|
||||||
<img src={u.avatar} className="w-full h-full rounded-full object-cover" alt="" />
|
<img src={u.avatar} className="w-full h-full rounded-full object-cover" alt="" loading="lazy" />
|
||||||
) : (
|
) : (
|
||||||
getInitials(u.name)
|
getInitials(u.name)
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ export default function Campaigns() {
|
|||||||
<select
|
<select
|
||||||
value={filters.brand}
|
value={filters.brand}
|
||||||
onChange={e => setFilters(f => ({ ...f, brand: e.target.value }))}
|
onChange={e => setFilters(f => ({ ...f, brand: e.target.value }))}
|
||||||
className="text-sm border border-border rounded-lg px-3 py-2 bg-white text-text-secondary focus:outline-none"
|
className="text-sm border border-border rounded-lg px-3 py-2 bg-surface text-text-secondary focus:outline-none"
|
||||||
>
|
>
|
||||||
<option value="">All Brands</option>
|
<option value="">All Brands</option>
|
||||||
{brands.map(b => <option key={b.id || b._id} value={b.id || b._id}>{lang === 'ar' && b.name_ar ? b.name_ar : b.name}</option>)}
|
{brands.map(b => <option key={b.id || b._id} value={b.id || b._id}>{lang === 'ar' && b.name_ar ? b.name_ar : b.name}</option>)}
|
||||||
@@ -154,7 +154,7 @@ export default function Campaigns() {
|
|||||||
<select
|
<select
|
||||||
value={filters.status}
|
value={filters.status}
|
||||||
onChange={e => setFilters(f => ({ ...f, status: e.target.value }))}
|
onChange={e => setFilters(f => ({ ...f, status: e.target.value }))}
|
||||||
className="text-sm border border-border rounded-lg px-3 py-2 bg-white text-text-secondary focus:outline-none"
|
className="text-sm border border-border rounded-lg px-3 py-2 bg-surface text-text-secondary focus:outline-none"
|
||||||
>
|
>
|
||||||
<option value="">All Statuses</option>
|
<option value="">All Statuses</option>
|
||||||
<option value="planning">Planning</option>
|
<option value="planning">Planning</option>
|
||||||
@@ -167,7 +167,7 @@ export default function Campaigns() {
|
|||||||
{permissions?.canCreateCampaigns && (
|
{permissions?.canCreateCampaigns && (
|
||||||
<button
|
<button
|
||||||
onClick={openNew}
|
onClick={openNew}
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm ml-auto"
|
className="flex items-center gap-2 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm ms-auto"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
New Campaign
|
New Campaign
|
||||||
@@ -178,7 +178,7 @@ export default function Campaigns() {
|
|||||||
{/* Summary Cards */}
|
{/* Summary Cards */}
|
||||||
{(totalBudget > 0 || totalSpent > 0) && (
|
{(totalBudget > 0 || totalSpent > 0) && (
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3 stagger-children">
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3 stagger-children">
|
||||||
<div className="bg-white rounded-xl border border-border p-4">
|
<div className="bg-surface rounded-xl border border-border p-4">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<DollarSign className="w-4 h-4 text-blue-500" />
|
<DollarSign className="w-4 h-4 text-blue-500" />
|
||||||
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Budget</span>
|
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Budget</span>
|
||||||
@@ -186,7 +186,7 @@ export default function Campaigns() {
|
|||||||
<div className="text-lg font-bold text-text-primary">{totalBudget.toLocaleString()}</div>
|
<div className="text-lg font-bold text-text-primary">{totalBudget.toLocaleString()}</div>
|
||||||
<div className="text-[10px] text-text-tertiary">{currencySymbol} total</div>
|
<div className="text-[10px] text-text-tertiary">{currencySymbol} total</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white rounded-xl border border-border p-4">
|
<div className="bg-surface rounded-xl border border-border p-4">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<TrendingUp className="w-4 h-4 text-amber-500" />
|
<TrendingUp className="w-4 h-4 text-amber-500" />
|
||||||
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Spent</span>
|
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Spent</span>
|
||||||
@@ -194,28 +194,28 @@ export default function Campaigns() {
|
|||||||
<div className="text-lg font-bold text-text-primary">{totalSpent.toLocaleString()}</div>
|
<div className="text-lg font-bold text-text-primary">{totalSpent.toLocaleString()}</div>
|
||||||
<div className="text-[10px] text-text-tertiary">{currencySymbol} spent</div>
|
<div className="text-[10px] text-text-tertiary">{currencySymbol} spent</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white rounded-xl border border-border p-4">
|
<div className="bg-surface rounded-xl border border-border p-4">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<Eye className="w-4 h-4 text-purple-500" />
|
<Eye className="w-4 h-4 text-purple-500" />
|
||||||
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Impressions</span>
|
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Impressions</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-lg font-bold text-text-primary">{totalImpressions.toLocaleString()}</div>
|
<div className="text-lg font-bold text-text-primary">{totalImpressions.toLocaleString()}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white rounded-xl border border-border p-4">
|
<div className="bg-surface rounded-xl border border-border p-4">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<MousePointer className="w-4 h-4 text-green-500" />
|
<MousePointer className="w-4 h-4 text-green-500" />
|
||||||
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Clicks</span>
|
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Clicks</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-lg font-bold text-text-primary">{totalClicks.toLocaleString()}</div>
|
<div className="text-lg font-bold text-text-primary">{totalClicks.toLocaleString()}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white rounded-xl border border-border p-4">
|
<div className="bg-surface rounded-xl border border-border p-4">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<Target className="w-4 h-4 text-red-500" />
|
<Target className="w-4 h-4 text-red-500" />
|
||||||
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Conversions</span>
|
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Conversions</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-lg font-bold text-text-primary">{totalConversions.toLocaleString()}</div>
|
<div className="text-lg font-bold text-text-primary">{totalConversions.toLocaleString()}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white rounded-xl border border-border p-4">
|
<div className="bg-surface rounded-xl border border-border p-4">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<BarChart3 className="w-4 h-4 text-emerald-500" />
|
<BarChart3 className="w-4 h-4 text-emerald-500" />
|
||||||
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Revenue</span>
|
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Revenue</span>
|
||||||
@@ -264,7 +264,7 @@ export default function Campaigns() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Campaign list */}
|
{/* Campaign list */}
|
||||||
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
<div className="bg-surface rounded-xl border border-border overflow-hidden">
|
||||||
<div className="px-5 py-4 border-b border-border">
|
<div className="px-5 py-4 border-b border-border">
|
||||||
<h3 className="font-semibold text-text-primary">All Campaigns</h3>
|
<h3 className="font-semibold text-text-primary">All Campaigns</h3>
|
||||||
</div>
|
</div>
|
||||||
@@ -308,7 +308,7 @@ export default function Campaigns() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right shrink-0">
|
<div className="text-end shrink-0">
|
||||||
<StatusBadge status={campaign.status} size="xs" />
|
<StatusBadge status={campaign.status} size="xs" />
|
||||||
<div className="text-xs text-text-tertiary mt-1">
|
<div className="text-xs text-text-tertiary mt-1">
|
||||||
{campaign.startDate && campaign.endDate ? (
|
{campaign.startDate && campaign.endDate ? (
|
||||||
|
|||||||
+125
-210
@@ -1,12 +1,11 @@
|
|||||||
import { useContext, useEffect, useState, useMemo } from 'react'
|
import { useContext, useEffect, useState, useMemo } from 'react'
|
||||||
import { Link, useNavigate } from 'react-router-dom'
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
import { format, isAfter, isBefore, addDays } from 'date-fns'
|
import { format, isAfter, isBefore, addDays } from 'date-fns'
|
||||||
import { FileText, Megaphone, AlertTriangle, ArrowRight, Clock, Wallet, TrendingUp, TrendingDown, DollarSign, Landmark, CheckSquare, FolderKanban } from 'lucide-react'
|
import { FileText, Megaphone, AlertTriangle, ArrowRight, Clock, Landmark, CheckSquare, FolderKanban } from 'lucide-react'
|
||||||
import { AppContext } from '../App'
|
import { AppContext } from '../App'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
import { useLanguage } from '../i18n/LanguageContext'
|
import { useLanguage } from '../i18n/LanguageContext'
|
||||||
import { api, PRIORITY_CONFIG } from '../utils/api'
|
import { api, PRIORITY_CONFIG } from '../utils/api'
|
||||||
import StatCard from '../components/StatCard'
|
|
||||||
import StatusBadge from '../components/StatusBadge'
|
import StatusBadge from '../components/StatusBadge'
|
||||||
import BrandBadge from '../components/BrandBadge'
|
import BrandBadge from '../components/BrandBadge'
|
||||||
import DatePresetPicker from '../components/DatePresetPicker'
|
import DatePresetPicker from '../components/DatePresetPicker'
|
||||||
@@ -18,24 +17,17 @@ function getBudgetBarColor(percentage) {
|
|||||||
return 'bg-emerald-500'
|
return 'bg-emerald-500'
|
||||||
}
|
}
|
||||||
|
|
||||||
function FinanceMini({ finance }) {
|
function BudgetSummary({ finance }) {
|
||||||
const { t, currencySymbol } = useLanguage()
|
const { t, currencySymbol } = useLanguage()
|
||||||
if (!finance) return null
|
if (!finance) return null
|
||||||
const totalReceived = finance.totalReceived || 0
|
const totalReceived = finance.totalReceived || 0
|
||||||
const spent = finance.spent || 0
|
const mainAvailable = finance.mainAvailable != null ? finance.mainAvailable : (finance.remaining || 0)
|
||||||
const remaining = finance.remaining || 0
|
const consumed = totalReceived - mainAvailable
|
||||||
const roi = finance.roi || 0
|
const pct = totalReceived > 0 ? (consumed / totalReceived) * 100 : 0
|
||||||
const totalExpenses = finance.totalExpenses || 0
|
|
||||||
const campaignBudget = finance.totalCampaignBudget || 0
|
|
||||||
const projectBudget = finance.totalProjectBudget || 0
|
|
||||||
const unallocated = finance.unallocated ?? (totalReceived - campaignBudget - projectBudget)
|
|
||||||
const pct = totalReceived > 0 ? (spent / totalReceived) * 100 : 0
|
|
||||||
const barColor = getBudgetBarColor(pct)
|
const barColor = getBudgetBarColor(pct)
|
||||||
const campPct = totalReceived > 0 ? (campaignBudget / totalReceived) * 100 : 0
|
|
||||||
const projPct = totalReceived > 0 ? (projectBudget / totalReceived) * 100 : 0
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-xl border border-border p-5">
|
<div className="bg-surface rounded-xl border border-border p-5">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h3 className="font-semibold text-text-primary">{t('dashboard.budgetOverview')}</h3>
|
<h3 className="font-semibold text-text-primary">{t('dashboard.budgetOverview')}</h3>
|
||||||
<Link to="/finance" className="text-sm text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1">
|
<Link to="/finance" className="text-sm text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1">
|
||||||
@@ -49,58 +41,15 @@ function FinanceMini({ finance }) {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Spending bar */}
|
<div className="flex justify-between text-xs text-text-tertiary mb-1">
|
||||||
<div className="mb-3">
|
<span>{consumed.toLocaleString()} {currencySymbol} {t('dashboard.spent')}</span>
|
||||||
<div className="flex justify-between text-xs text-text-tertiary mb-1">
|
<span>{totalReceived.toLocaleString()} {currencySymbol} {t('dashboard.received')}</span>
|
||||||
<span>{spent.toLocaleString()} {currencySymbol} {t('dashboard.spent')}</span>
|
|
||||||
<span>{totalReceived.toLocaleString()} {currencySymbol} {t('dashboard.received')}</span>
|
|
||||||
</div>
|
|
||||||
<div className="h-2.5 bg-surface-tertiary rounded-full overflow-hidden">
|
|
||||||
<div className={`h-full ${barColor} rounded-full transition-all`} style={{ width: `${Math.min(pct, 100)}%` }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="h-2.5 bg-surface-tertiary rounded-full overflow-hidden">
|
||||||
{/* Allocation bar */}
|
<div className={`h-full ${barColor} rounded-full transition-all`} style={{ width: `${Math.min(pct, 100)}%` }} />
|
||||||
{(campaignBudget > 0 || projectBudget > 0) && (
|
</div>
|
||||||
<div className="mb-3">
|
<div className={`mt-3 text-sm font-semibold ${mainAvailable >= 0 ? 'text-emerald-600' : 'text-red-600'}`}>
|
||||||
<div className="text-[10px] text-text-tertiary mb-1 font-medium uppercase tracking-wide">Allocation</div>
|
{mainAvailable.toLocaleString()} {currencySymbol} {t('dashboard.remaining')}
|
||||||
<div className="h-2 bg-surface-tertiary rounded-full overflow-hidden flex">
|
|
||||||
{campPct > 0 && <div className="h-full bg-blue-500" style={{ width: `${campPct}%` }} />}
|
|
||||||
{projPct > 0 && <div className="h-full bg-purple-500" style={{ width: `${projPct}%` }} />}
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-3 mt-1 text-[10px] text-text-tertiary">
|
|
||||||
{campaignBudget > 0 && <span><span className="inline-block w-1.5 h-1.5 rounded-full bg-blue-500 mr-1" />{campaignBudget.toLocaleString()}</span>}
|
|
||||||
{projectBudget > 0 && <span><span className="inline-block w-1.5 h-1.5 rounded-full bg-purple-500 mr-1" />{projectBudget.toLocaleString()}</span>}
|
|
||||||
{unallocated > 0 && <span><span className="inline-block w-1.5 h-1.5 rounded-full bg-gray-300 mr-1" />{unallocated.toLocaleString()} free</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Key numbers */}
|
|
||||||
<div className={`grid ${totalExpenses > 0 ? 'grid-cols-3' : 'grid-cols-2'} gap-3`}>
|
|
||||||
<div className="text-center p-2 bg-surface-secondary rounded-lg">
|
|
||||||
<Landmark className="w-4 h-4 mx-auto mb-1 text-emerald-500" />
|
|
||||||
<div className={`text-sm font-bold ${remaining >= 0 ? 'text-emerald-600' : 'text-red-600'}`}>
|
|
||||||
{remaining.toLocaleString()}
|
|
||||||
</div>
|
|
||||||
<div className="text-[10px] text-text-tertiary">{t('dashboard.remaining')}</div>
|
|
||||||
</div>
|
|
||||||
{totalExpenses > 0 && (
|
|
||||||
<div className="text-center p-2 bg-surface-secondary rounded-lg">
|
|
||||||
<TrendingDown className="w-4 h-4 mx-auto mb-1 text-red-500" />
|
|
||||||
<div className="text-sm font-bold text-red-600">
|
|
||||||
{totalExpenses.toLocaleString()}
|
|
||||||
</div>
|
|
||||||
<div className="text-[10px] text-text-tertiary">{t('dashboard.expenses')}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="text-center p-2 bg-surface-secondary rounded-lg">
|
|
||||||
<TrendingUp className="w-4 h-4 mx-auto mb-1 text-blue-500" />
|
|
||||||
<div className={`text-sm font-bold ${roi >= 0 ? 'text-emerald-600' : 'text-red-600'}`}>
|
|
||||||
{roi.toFixed(0)}%
|
|
||||||
</div>
|
|
||||||
<div className="text-[10px] text-text-tertiary">{t('dashboard.roi')}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -146,13 +95,6 @@ function ActiveCampaignsList({ campaigns, finance }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right shrink-0">
|
|
||||||
{cd.tracks_impressions > 0 && (
|
|
||||||
<div className="text-[10px] text-text-tertiary">
|
|
||||||
{cd.tracks_impressions.toLocaleString()} imp. / {cd.tracks_clicks.toLocaleString()} clicks
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -162,12 +104,12 @@ function ActiveCampaignsList({ campaigns, finance }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function MyTasksList({ tasks, currentUserId, navigate, t }) {
|
function MyTasksList({ tasks, currentUserId, navigate, t }) {
|
||||||
const myTasks = tasks
|
const myTasks = useMemo(() => tasks
|
||||||
.filter(task => {
|
.filter(task => {
|
||||||
const assignedId = task.assigned_to_id || task.assignedTo
|
const assignedId = task.assigned_to_id || task.assignedTo
|
||||||
return assignedId === currentUserId && task.status !== 'done'
|
return assignedId === currentUserId && task.status !== 'done'
|
||||||
})
|
})
|
||||||
.slice(0, 5)
|
.slice(0, 5), [tasks, currentUserId])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="section-card">
|
<div className="section-card">
|
||||||
@@ -187,10 +129,10 @@ function MyTasksList({ tasks, currentUserId, navigate, t }) {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
myTasks.map(task => (
|
myTasks.map(task => (
|
||||||
<div
|
<button
|
||||||
key={task._id || task.id}
|
key={task._id || task.id}
|
||||||
onClick={() => navigate('/tasks')}
|
onClick={() => navigate('/tasks')}
|
||||||
className="flex items-center gap-3 px-5 py-3 hover:bg-surface-secondary transition-colors cursor-pointer"
|
className="w-full flex items-center gap-3 px-5 py-3 hover:bg-surface-secondary transition-colors text-start"
|
||||||
>
|
>
|
||||||
<div className={`w-2 h-2 rounded-full shrink-0 ${(PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium).color}`} />
|
<div className={`w-2 h-2 rounded-full shrink-0 ${(PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium).color}`} />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -203,7 +145,7 @@ function MyTasksList({ tasks, currentUserId, navigate, t }) {
|
|||||||
{format(new Date(task.dueDate), 'MMM d')}
|
{format(new Date(task.dueDate), 'MMM d')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</button>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -261,10 +203,84 @@ function ProjectProgress({ projects, tasks, t }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ActivityFeed({ posts, deadlines, navigate, t }) {
|
||||||
|
const [tab, setTab] = useState('posts')
|
||||||
|
const hasPosts = posts.length > 0
|
||||||
|
const hasDeadlines = deadlines.length > 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="section-card">
|
||||||
|
<div className="section-card-header flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setTab('posts')}
|
||||||
|
className={`px-3 py-1 text-sm font-medium rounded-md transition-colors ${
|
||||||
|
tab === 'posts' ? 'bg-brand-primary/10 text-brand-primary' : 'text-text-tertiary hover:text-text-secondary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t('dashboard.recentPosts')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setTab('deadlines')}
|
||||||
|
className={`px-3 py-1 text-sm font-medium rounded-md transition-colors ${
|
||||||
|
tab === 'deadlines' ? 'bg-brand-primary/10 text-brand-primary' : 'text-text-tertiary hover:text-text-secondary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t('dashboard.upcomingDeadlines')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<Link to={tab === 'posts' ? '/posts' : '/tasks'} className="text-sm text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1">
|
||||||
|
{t('dashboard.viewAll')} <ArrowRight className="w-3.5 h-3.5" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-border-light">
|
||||||
|
{tab === 'posts' ? (
|
||||||
|
!hasPosts ? (
|
||||||
|
<div className="py-10 text-center text-sm text-text-tertiary">{t('dashboard.noPostsYet')}</div>
|
||||||
|
) : (
|
||||||
|
posts.slice(0, 6).map(post => (
|
||||||
|
<button key={post._id} onClick={() => navigate('/posts')} className="w-full flex items-center gap-3 px-5 py-3 hover:bg-surface-secondary transition-colors text-start">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-text-primary truncate">{post.title}</p>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
{post.brand && <BrandBadge brand={post.brand} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<StatusBadge status={post.status} size="xs" />
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
!hasDeadlines ? (
|
||||||
|
<div className="py-10 text-center text-sm text-text-tertiary">{t('dashboard.noUpcomingDeadlines')}</div>
|
||||||
|
) : (
|
||||||
|
deadlines.map(task => (
|
||||||
|
<button key={task._id} onClick={() => navigate('/tasks')} className="w-full flex items-center gap-3 px-5 py-3 hover:bg-surface-secondary transition-colors text-start">
|
||||||
|
<div className={`w-2 h-2 rounded-full ${(PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium).color}`} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-text-primary truncate">{task.title}</p>
|
||||||
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
|
<StatusBadge status={task.status} size="xs" />
|
||||||
|
{task.assignedName && <span className="text-xs text-text-tertiary truncate">{task.assignedName}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 text-xs text-text-tertiary shrink-0">
|
||||||
|
<Clock className="w-3.5 h-3.5" />
|
||||||
|
{format(new Date(task.dueDate), 'MMM d')}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const { t, currencySymbol } = useLanguage()
|
const { t, currencySymbol } = useLanguage()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { currentUser, teamMembers } = useContext(AppContext)
|
const { currentUser } = useContext(AppContext)
|
||||||
const { hasModule } = useAuth()
|
const { hasModule } = useAuth()
|
||||||
const [posts, setPosts] = useState([])
|
const [posts, setPosts] = useState([])
|
||||||
const [campaigns, setCampaigns] = useState([])
|
const [campaigns, setCampaigns] = useState([])
|
||||||
@@ -273,7 +289,6 @@ export default function Dashboard() {
|
|||||||
const [finance, setFinance] = useState(null)
|
const [finance, setFinance] = useState(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
// Date filtering
|
|
||||||
const [dateFrom, setDateFrom] = useState('')
|
const [dateFrom, setDateFrom] = useState('')
|
||||||
const [dateTo, setDateTo] = useState('')
|
const [dateTo, setDateTo] = useState('')
|
||||||
const [activePreset, setActivePreset] = useState('')
|
const [activePreset, setActivePreset] = useState('')
|
||||||
@@ -285,7 +300,6 @@ export default function Dashboard() {
|
|||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
const fetches = []
|
const fetches = []
|
||||||
// Only fetch data for modules the user has access to
|
|
||||||
if (hasModule('marketing')) {
|
if (hasModule('marketing')) {
|
||||||
fetches.push(api.get('/posts?limit=50&sort=-createdAt').then(r => ({ key: 'posts', data: Array.isArray(r) ? r : [] })))
|
fetches.push(api.get('/posts?limit=50&sort=-createdAt').then(r => ({ key: 'posts', data: Array.isArray(r) ? r : [] })))
|
||||||
fetches.push(api.get('/campaigns').then(r => ({ key: 'campaigns', data: Array.isArray(r) ? r : [] })))
|
fetches.push(api.get('/campaigns').then(r => ({ key: 'campaigns', data: Array.isArray(r) ? r : [] })))
|
||||||
@@ -315,7 +329,6 @@ export default function Dashboard() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filtered data based on date range
|
|
||||||
const filteredPosts = useMemo(() => {
|
const filteredPosts = useMemo(() => {
|
||||||
if (!dateFrom && !dateTo) return posts
|
if (!dateFrom && !dateTo) return posts
|
||||||
return posts.filter(p => {
|
return posts.filter(p => {
|
||||||
@@ -343,7 +356,7 @@ export default function Dashboard() {
|
|||||||
t.dueDate && new Date(t.dueDate) < new Date() && t.status !== 'done'
|
t.dueDate && new Date(t.dueDate) < new Date() && t.status !== 'done'
|
||||||
).length
|
).length
|
||||||
|
|
||||||
const upcomingDeadlines = filteredTasks
|
const upcomingDeadlines = useMemo(() => filteredTasks
|
||||||
.filter(t => {
|
.filter(t => {
|
||||||
if (!t.dueDate || t.status === 'done') return false
|
if (!t.dueDate || t.status === 'done') return false
|
||||||
const due = new Date(t.dueDate)
|
const due = new Date(t.dueDate)
|
||||||
@@ -351,60 +364,27 @@ export default function Dashboard() {
|
|||||||
return isAfter(due, now) && isBefore(due, addDays(now, 7))
|
return isAfter(due, now) && isBefore(due, addDays(now, 7))
|
||||||
})
|
})
|
||||||
.sort((a, b) => new Date(a.dueDate) - new Date(b.dueDate))
|
.sort((a, b) => new Date(a.dueDate) - new Date(b.dueDate))
|
||||||
.slice(0, 8)
|
.slice(0, 6), [filteredTasks])
|
||||||
|
|
||||||
const statCards = []
|
// Inline stat values — no card component needed
|
||||||
|
const stats = []
|
||||||
if (hasModule('marketing')) {
|
if (hasModule('marketing')) {
|
||||||
statCards.push({
|
stats.push({ label: t('dashboard.totalPosts'), value: filteredPosts.length, detail: `${filteredPosts.filter(p => p.status === 'published').length} ${t('dashboard.published')}`, icon: FileText, accent: 'text-indigo-600' })
|
||||||
icon: FileText,
|
stats.push({ label: t('dashboard.activeCampaigns'), value: activeCampaigns, detail: `${campaigns.length} ${t('dashboard.total')}`, icon: Megaphone, accent: 'text-pink-600' })
|
||||||
label: t('dashboard.totalPosts'),
|
|
||||||
value: filteredPosts.length || 0,
|
|
||||||
subtitle: `${filteredPosts.filter(p => p.status === 'published').length} ${t('dashboard.published')}`,
|
|
||||||
color: 'brand-primary',
|
|
||||||
})
|
|
||||||
statCards.push({
|
|
||||||
icon: Megaphone,
|
|
||||||
label: t('dashboard.activeCampaigns'),
|
|
||||||
value: activeCampaigns,
|
|
||||||
subtitle: `${campaigns.length} ${t('dashboard.total')}`,
|
|
||||||
color: 'brand-secondary',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (hasModule('finance')) {
|
|
||||||
statCards.push({
|
|
||||||
icon: Landmark,
|
|
||||||
label: t('dashboard.budgetRemaining'),
|
|
||||||
value: `${(finance?.remaining ?? 0).toLocaleString()}`,
|
|
||||||
subtitle: finance?.totalReceived ? `${(finance.spent || 0).toLocaleString()} ${t('dashboard.spent')} ${t('dashboard.of')} ${finance.totalReceived.toLocaleString()} ${currencySymbol}` : t('dashboard.noBudget'),
|
|
||||||
color: 'brand-tertiary',
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
if (hasModule('projects')) {
|
if (hasModule('projects')) {
|
||||||
statCards.push({
|
stats.push({ label: t('dashboard.overdueTasks'), value: overdueTasks, detail: overdueTasks > 0 ? t('dashboard.needsAttention') : t('dashboard.allOnTrack'), icon: AlertTriangle, accent: overdueTasks > 0 ? 'text-red-600' : 'text-emerald-600' })
|
||||||
icon: AlertTriangle,
|
|
||||||
label: t('dashboard.overdueTasks'),
|
|
||||||
value: overdueTasks,
|
|
||||||
subtitle: overdueTasks > 0 ? t('dashboard.needsAttention') : t('dashboard.allOnTrack'),
|
|
||||||
color: 'brand-quaternary',
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) return <SkeletonDashboard />
|
||||||
return <SkeletonDashboard />
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 animate-fade-in">
|
<div className="space-y-6 animate-fade-in">
|
||||||
{/* Welcome + Date presets */}
|
{/* Welcome + Date presets */}
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||||
<div>
|
<p className="text-lg font-medium text-text-primary">
|
||||||
<h1 className="text-2xl font-bold text-gradient">
|
{t('dashboard.welcomeBack')}, {currentUser?.name || 'there'}
|
||||||
{t('dashboard.welcomeBack')}, {currentUser?.name || 'there'}
|
</p>
|
||||||
</h1>
|
|
||||||
<p className="text-text-secondary mt-1">
|
|
||||||
{t('dashboard.happeningToday')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<DatePresetPicker
|
<DatePresetPicker
|
||||||
activePreset={activePreset}
|
activePreset={activePreset}
|
||||||
onSelect={(from, to, key) => { setDateFrom(from); setDateTo(to); setActivePreset(key) }}
|
onSelect={(from, to, key) => { setDateFrom(from); setDateTo(to); setActivePreset(key) }}
|
||||||
@@ -412,11 +392,18 @@ export default function Dashboard() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats — compact inline row, no cards */}
|
||||||
{statCards.length > 0 && (
|
{stats.length > 0 && (
|
||||||
<div className={`grid grid-cols-1 sm:grid-cols-2 ${statCards.length >= 4 ? 'lg:grid-cols-4' : statCards.length === 3 ? 'lg:grid-cols-3' : 'lg:grid-cols-2'} gap-4 stagger-children`}>
|
<div className="flex flex-wrap gap-6">
|
||||||
{statCards.map((card, i) => (
|
{stats.map((s, i) => (
|
||||||
<StatCard key={i} {...card} />
|
<div key={i} className="flex items-center gap-3">
|
||||||
|
<s.icon className={`w-5 h-5 ${s.accent}`} />
|
||||||
|
<div>
|
||||||
|
<span className="text-2xl font-bold text-text-primary">{s.value}</span>
|
||||||
|
<span className="text-sm text-text-tertiary ms-1.5">{s.label}</span>
|
||||||
|
<p className="text-xs text-text-tertiary">{s.detail}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -432,7 +419,7 @@ export default function Dashboard() {
|
|||||||
{/* Budget + Active Campaigns */}
|
{/* Budget + Active Campaigns */}
|
||||||
{(hasModule('finance') || hasModule('marketing')) && (
|
{(hasModule('finance') || hasModule('marketing')) && (
|
||||||
<div className={`grid grid-cols-1 ${hasModule('finance') && hasModule('marketing') ? 'lg:grid-cols-3' : ''} gap-6`}>
|
<div className={`grid grid-cols-1 ${hasModule('finance') && hasModule('marketing') ? 'lg:grid-cols-3' : ''} gap-6`}>
|
||||||
{hasModule('finance') && <FinanceMini finance={finance} />}
|
{hasModule('finance') && <BudgetSummary finance={finance} />}
|
||||||
{hasModule('marketing') && (
|
{hasModule('marketing') && (
|
||||||
<div className={hasModule('finance') ? 'lg:col-span-2' : ''}>
|
<div className={hasModule('finance') ? 'lg:col-span-2' : ''}>
|
||||||
<ActiveCampaignsList campaigns={campaigns} finance={finance} />
|
<ActiveCampaignsList campaigns={campaigns} finance={finance} />
|
||||||
@@ -441,86 +428,14 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Recent Posts + Upcoming Deadlines */}
|
{/* Activity — merged posts + deadlines */}
|
||||||
{(hasModule('marketing') || hasModule('projects')) && (
|
{(hasModule('marketing') || hasModule('projects')) && (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<ActivityFeed
|
||||||
{/* Recent Posts */}
|
posts={hasModule('marketing') ? filteredPosts : []}
|
||||||
{hasModule('marketing') && (
|
deadlines={hasModule('projects') ? upcomingDeadlines : []}
|
||||||
<div className="section-card">
|
navigate={navigate}
|
||||||
<div className="section-card-header flex items-center justify-between">
|
t={t}
|
||||||
<h3 className="font-semibold text-text-primary">{t('dashboard.recentPosts')}</h3>
|
/>
|
||||||
<Link to="/posts" className="text-sm text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1">
|
|
||||||
{t('dashboard.viewAll')} <ArrowRight className="w-3.5 h-3.5" />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<div className="divide-y divide-border-light">
|
|
||||||
{filteredPosts.length === 0 ? (
|
|
||||||
<div className="py-12 text-center text-sm text-text-tertiary">
|
|
||||||
{t('dashboard.noPostsYet')}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
filteredPosts.slice(0, 8).map((post) => (
|
|
||||||
<div
|
|
||||||
key={post._id}
|
|
||||||
onClick={() => navigate('/posts')}
|
|
||||||
className="flex items-center gap-3 px-5 py-3 hover:bg-surface-secondary transition-colors cursor-pointer"
|
|
||||||
>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="text-sm font-medium text-text-primary truncate">{post.title}</p>
|
|
||||||
<div className="flex items-center gap-2 mt-1">
|
|
||||||
{post.brand && <BrandBadge brand={post.brand} />}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<StatusBadge status={post.status} size="xs" />
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Upcoming Deadlines */}
|
|
||||||
{hasModule('projects') && (
|
|
||||||
<div className="section-card">
|
|
||||||
<div className="section-card-header flex items-center justify-between">
|
|
||||||
<h3 className="font-semibold text-text-primary">{t('dashboard.upcomingDeadlines')}</h3>
|
|
||||||
<Link to="/tasks" className="text-sm text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1">
|
|
||||||
{t('dashboard.viewAll')} <ArrowRight className="w-3.5 h-3.5" />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<div className="divide-y divide-border-light">
|
|
||||||
{upcomingDeadlines.length === 0 ? (
|
|
||||||
<div className="py-12 text-center text-sm text-text-tertiary">
|
|
||||||
{t('dashboard.noUpcomingDeadlines')}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
upcomingDeadlines.map((task) => (
|
|
||||||
<div
|
|
||||||
key={task._id}
|
|
||||||
onClick={() => navigate('/tasks')}
|
|
||||||
className="flex items-center gap-3 px-5 py-3 hover:bg-surface-secondary transition-colors cursor-pointer"
|
|
||||||
>
|
|
||||||
<div className={`w-2 h-2 rounded-full ${(PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium).color}`} />
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="text-sm font-medium text-text-primary truncate">{task.title}</p>
|
|
||||||
<div className="flex items-center gap-2 mt-0.5">
|
|
||||||
<StatusBadge status={task.status} size="xs" />
|
|
||||||
{task.assignedName && (
|
|
||||||
<span className="text-xs text-text-tertiary truncate">{task.assignedName}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1 text-xs text-text-tertiary shrink-0">
|
|
||||||
<Clock className="w-3.5 h-3.5" />
|
|
||||||
{format(new Date(task.dueDate), 'MMM d')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
+257
-41
@@ -1,14 +1,16 @@
|
|||||||
import { useState, useEffect, useContext } from 'react'
|
import { useState, useEffect, useContext } from 'react'
|
||||||
import { DollarSign, TrendingUp, TrendingDown, Wallet, Landmark, Eye, MousePointer, Target, Briefcase, ArrowRight, Receipt } from 'lucide-react'
|
import { DollarSign, TrendingUp, TrendingDown, Wallet, Landmark, Eye, MousePointer, Target, Briefcase, ArrowRight, Receipt, Plus, X } from 'lucide-react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { AppContext } from '../App'
|
import { AppContext } from '../App'
|
||||||
import { api } from '../utils/api'
|
import { api } from '../utils/api'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
import { useLanguage } from '../i18n/LanguageContext'
|
import { useLanguage } from '../i18n/LanguageContext'
|
||||||
import StatusBadge from '../components/StatusBadge'
|
import StatusBadge from '../components/StatusBadge'
|
||||||
|
import Modal from '../components/Modal'
|
||||||
|
import { useToast } from '../components/ToastContainer'
|
||||||
import { SkeletonStatCard, SkeletonTable } from '../components/SkeletonLoader'
|
import { SkeletonStatCard, SkeletonTable } from '../components/SkeletonLoader'
|
||||||
|
|
||||||
function FinanceStatCard({ icon: Icon, label, value, sub, color = 'text-text-primary', bgColor = 'bg-white' }) {
|
function FinanceStatCard({ icon: Icon, label, value, sub, color = 'text-text-primary', bgColor = 'bg-surface' }) {
|
||||||
return (
|
return (
|
||||||
<div className={`${bgColor} rounded-xl border border-border p-5`}>
|
<div className={`${bgColor} rounded-xl border border-border p-5`}>
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
@@ -40,19 +42,36 @@ function ProgressRing({ pct, size = 80, stroke = 8, color = '#10b981' }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const BUDGET_REQUEST_STATUS_COLORS = {
|
||||||
|
pending: 'bg-amber-100 text-amber-800',
|
||||||
|
approved: 'bg-emerald-100 text-emerald-800',
|
||||||
|
rejected: 'bg-red-100 text-red-800',
|
||||||
|
cancelled: 'bg-gray-100 text-gray-600',
|
||||||
|
}
|
||||||
|
|
||||||
export default function Finance() {
|
export default function Finance() {
|
||||||
const { brands } = useContext(AppContext)
|
const { brands } = useContext(AppContext)
|
||||||
const { permissions } = useAuth()
|
const { permissions, user } = useAuth()
|
||||||
const { currencySymbol } = useLanguage()
|
const { t, currencySymbol } = useLanguage()
|
||||||
|
const toast = useToast()
|
||||||
const [summary, setSummary] = useState(null)
|
const [summary, setSummary] = useState(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [budgetRequests, setBudgetRequests] = useState([])
|
||||||
|
const [showRequestModal, setShowRequestModal] = useState(false)
|
||||||
|
const [requestForm, setRequestForm] = useState({ amount: '', justification: '', earmark_type: '', earmark_id: '' })
|
||||||
|
const [submittingRequest, setSubmittingRequest] = useState(false)
|
||||||
|
|
||||||
|
const isSuperadmin = user?.role === 'superadmin'
|
||||||
|
|
||||||
useEffect(() => { loadAll() }, [])
|
useEffect(() => { loadAll() }, [])
|
||||||
|
|
||||||
const loadAll = async () => {
|
const loadAll = async () => {
|
||||||
try {
|
try {
|
||||||
const sum = await api.get('/finance/summary')
|
const fetches = [api.get('/finance/summary')]
|
||||||
|
if (isSuperadmin) fetches.push(api.get('/budget-requests').catch(() => []))
|
||||||
|
const [sum, reqs] = await Promise.all(fetches)
|
||||||
setSummary(sum.data || sum || {})
|
setSummary(sum.data || sum || {})
|
||||||
|
if (reqs) setBudgetRequests(Array.isArray(reqs) ? reqs : [])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load finance:', err)
|
console.error('Failed to load finance:', err)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -60,6 +79,41 @@ export default function Finance() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleSubmitRequest = async () => {
|
||||||
|
if (!requestForm.amount || !requestForm.justification.trim()) return
|
||||||
|
setSubmittingRequest(true)
|
||||||
|
try {
|
||||||
|
const body = {
|
||||||
|
amount: Number(requestForm.amount),
|
||||||
|
justification: requestForm.justification.trim(),
|
||||||
|
}
|
||||||
|
if (requestForm.earmark_type === 'campaign' && requestForm.earmark_id) {
|
||||||
|
body.earmarked_campaign_id = Number(requestForm.earmark_id)
|
||||||
|
} else if (requestForm.earmark_type === 'project' && requestForm.earmark_id) {
|
||||||
|
body.earmarked_project_id = Number(requestForm.earmark_id)
|
||||||
|
}
|
||||||
|
await api.post('/budget-requests', body)
|
||||||
|
toast.success(t('finance.requestBudget') + ' — ' + t('common.success'))
|
||||||
|
setShowRequestModal(false)
|
||||||
|
setRequestForm({ amount: '', justification: '', earmark_type: '', earmark_id: '' })
|
||||||
|
loadAll()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.message || t('common.error'))
|
||||||
|
} finally {
|
||||||
|
setSubmittingRequest(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancelRequest = async (id) => {
|
||||||
|
try {
|
||||||
|
await api.patch(`/budget-requests/${id}/cancel`)
|
||||||
|
toast.success(t('common.success'))
|
||||||
|
loadAll()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.message || t('common.error'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -86,18 +140,35 @@ export default function Finance() {
|
|||||||
const projectPct = totalReceived > 0 ? (totalProjectBudget / totalReceived) * 100 : 0
|
const projectPct = totalReceived > 0 ? (totalProjectBudget / totalReceived) * 100 : 0
|
||||||
const unallocatedPct = totalReceived > 0 ? (Math.max(0, unallocated) / totalReceived) * 100 : 0
|
const unallocatedPct = totalReceived > 0 ? (Math.max(0, unallocated) / totalReceived) * 100 : 0
|
||||||
|
|
||||||
|
const campaigns = s.campaigns || []
|
||||||
|
const projects = s.projects || []
|
||||||
|
const pendingCount = budgetRequests.filter(r => r.status === 'pending').length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 animate-fade-in">
|
<div className="space-y-6 animate-fade-in">
|
||||||
|
{/* Request Budget button (superadmin) */}
|
||||||
|
{isSuperadmin && (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowRequestModal(true)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2.5 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light transition-colors shadow-sm"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
{t('finance.requestBudget')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Top metrics */}
|
{/* Top metrics */}
|
||||||
<div className={`grid grid-cols-2 ${totalExpenses > 0 ? 'lg:grid-cols-6' : 'lg:grid-cols-5'} gap-4 stagger-children`}>
|
<div className={`grid grid-cols-2 ${totalExpenses > 0 ? 'lg:grid-cols-6' : 'lg:grid-cols-5'} gap-4 stagger-children`}>
|
||||||
<FinanceStatCard icon={Wallet} label="Total Received" value={`${totalReceived.toLocaleString()} ${currencySymbol}`} color="text-blue-600" />
|
<FinanceStatCard icon={Wallet} label={t('finance.totalReceived')} value={`${totalReceived.toLocaleString()} ${currencySymbol}`} color="text-blue-600" />
|
||||||
<FinanceStatCard icon={TrendingUp} label="Total Spent" value={`${totalSpent.toLocaleString()} ${currencySymbol}`} sub={`${spendPct.toFixed(1)}% of budget`} color="text-amber-600" />
|
<FinanceStatCard icon={TrendingUp} label={t('finance.totalSpent')} value={`${totalSpent.toLocaleString()} ${currencySymbol}`} sub={`${spendPct.toFixed(1)}% ${t('finance.ofBudget')}`} color="text-amber-600" />
|
||||||
{totalExpenses > 0 && (
|
{totalExpenses > 0 && (
|
||||||
<FinanceStatCard icon={Receipt} label="Expenses" value={`${totalExpenses.toLocaleString()} ${currencySymbol}`} color="text-red-600" />
|
<FinanceStatCard icon={Receipt} label={t('finance.expenses')} value={`${totalExpenses.toLocaleString()} ${currencySymbol}`} color="text-red-600" />
|
||||||
)}
|
)}
|
||||||
<FinanceStatCard icon={Landmark} label="Remaining" value={`${remaining.toLocaleString()} ${currencySymbol}`} color={remaining >= 0 ? 'text-emerald-600' : 'text-red-600'} />
|
<FinanceStatCard icon={Landmark} label={t('finance.remaining')} value={`${remaining.toLocaleString()} ${currencySymbol}`} color={remaining >= 0 ? 'text-emerald-600' : 'text-red-600'} />
|
||||||
<FinanceStatCard icon={DollarSign} label="Revenue" value={`${totalRevenue.toLocaleString()} ${currencySymbol}`} color="text-purple-600" />
|
<FinanceStatCard icon={DollarSign} label={t('finance.revenue')} value={`${totalRevenue.toLocaleString()} ${currencySymbol}`} color="text-purple-600" />
|
||||||
<FinanceStatCard icon={roi >= 0 ? TrendingUp : TrendingDown} label="Global ROI"
|
<FinanceStatCard icon={roi >= 0 ? TrendingUp : TrendingDown} label={t('finance.globalROI')}
|
||||||
value={`${roi.toFixed(1)}%`}
|
value={`${roi.toFixed(1)}%`}
|
||||||
color={roi >= 0 ? 'text-emerald-600' : 'text-red-600'} />
|
color={roi >= 0 ? 'text-emerald-600' : 'text-red-600'} />
|
||||||
</div>
|
</div>
|
||||||
@@ -106,9 +177,9 @@ export default function Finance() {
|
|||||||
{totalReceived > 0 && (
|
{totalReceived > 0 && (
|
||||||
<div className="section-card p-5">
|
<div className="section-card p-5">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium">Budget Allocation</h3>
|
<h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium">{t('finance.budgetAllocation')}</h3>
|
||||||
<Link to="/budgets" className="text-xs text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1">
|
<Link to="/budgets" className="text-xs text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1">
|
||||||
Manage Budgets <ArrowRight className="w-3 h-3" />
|
{t('finance.manageBudgets')} <ArrowRight className="w-3 h-3" />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-4 bg-surface-tertiary rounded-full overflow-hidden flex">
|
<div className="h-4 bg-surface-tertiary rounded-full overflow-hidden flex">
|
||||||
@@ -122,17 +193,17 @@ export default function Finance() {
|
|||||||
<div className="flex items-center gap-4 mt-2.5 text-xs">
|
<div className="flex items-center gap-4 mt-2.5 text-xs">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<div className="w-2.5 h-2.5 rounded-full bg-blue-500" />
|
<div className="w-2.5 h-2.5 rounded-full bg-blue-500" />
|
||||||
<span className="text-text-secondary">Campaigns: <span className="font-semibold text-text-primary">{totalCampaignBudget.toLocaleString()} {currencySymbol}</span></span>
|
<span className="text-text-secondary">{t('finance.campaigns')}: <span className="font-semibold text-text-primary">{totalCampaignBudget.toLocaleString()} {currencySymbol}</span></span>
|
||||||
<span className="text-text-tertiary">({campaignPct.toFixed(0)}%)</span>
|
<span className="text-text-tertiary">({campaignPct.toFixed(0)}%)</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<div className="w-2.5 h-2.5 rounded-full bg-purple-500" />
|
<div className="w-2.5 h-2.5 rounded-full bg-purple-500" />
|
||||||
<span className="text-text-secondary">Projects: <span className="font-semibold text-text-primary">{totalProjectBudget.toLocaleString()} {currencySymbol}</span></span>
|
<span className="text-text-secondary">{t('finance.projects')}: <span className="font-semibold text-text-primary">{totalProjectBudget.toLocaleString()} {currencySymbol}</span></span>
|
||||||
<span className="text-text-tertiary">({projectPct.toFixed(0)}%)</span>
|
<span className="text-text-tertiary">({projectPct.toFixed(0)}%)</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<div className="w-2.5 h-2.5 rounded-full bg-gray-300" />
|
<div className="w-2.5 h-2.5 rounded-full bg-gray-300" />
|
||||||
<span className="text-text-secondary">Unallocated: <span className="font-semibold text-text-primary">{Math.max(0, unallocated).toLocaleString()} {currencySymbol}</span></span>
|
<span className="text-text-secondary">{t('finance.unallocated')}: <span className="font-semibold text-text-primary">{Math.max(0, unallocated).toLocaleString()} {currencySymbol}</span></span>
|
||||||
<span className="text-text-tertiary">({unallocatedPct.toFixed(0)}%)</span>
|
<span className="text-text-tertiary">({unallocatedPct.toFixed(0)}%)</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -143,7 +214,7 @@ export default function Finance() {
|
|||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||||
{/* Utilization ring */}
|
{/* Utilization ring */}
|
||||||
<div className="section-card p-5 flex flex-col items-center justify-center">
|
<div className="section-card p-5 flex flex-col items-center justify-center">
|
||||||
<h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium mb-4">Budget Utilization</h3>
|
<h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium mb-4">{t('finance.budgetUtilization')}</h3>
|
||||||
<ProgressRing
|
<ProgressRing
|
||||||
pct={spendPct}
|
pct={spendPct}
|
||||||
size={120}
|
size={120}
|
||||||
@@ -157,17 +228,17 @@ export default function Finance() {
|
|||||||
|
|
||||||
{/* Global performance */}
|
{/* Global performance */}
|
||||||
<div className="section-card p-5 lg:col-span-2">
|
<div className="section-card p-5 lg:col-span-2">
|
||||||
<h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium mb-4">Global Performance</h3>
|
<h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium mb-4">{t('finance.globalPerformance')}</h3>
|
||||||
<div className="grid grid-cols-3 gap-6">
|
<div className="grid grid-cols-3 gap-6">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<Eye className="w-5 h-5 mx-auto mb-1 text-purple-500" />
|
<Eye className="w-5 h-5 mx-auto mb-1 text-purple-500" />
|
||||||
<div className="text-xl font-bold text-text-primary">{(s.impressions || 0).toLocaleString()}</div>
|
<div className="text-xl font-bold text-text-primary">{(s.impressions || 0).toLocaleString()}</div>
|
||||||
<div className="text-xs text-text-tertiary">Impressions</div>
|
<div className="text-xs text-text-tertiary">{t('finance.impressions')}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<MousePointer className="w-5 h-5 mx-auto mb-1 text-blue-500" />
|
<MousePointer className="w-5 h-5 mx-auto mb-1 text-blue-500" />
|
||||||
<div className="text-xl font-bold text-text-primary">{(s.clicks || 0).toLocaleString()}</div>
|
<div className="text-xl font-bold text-text-primary">{(s.clicks || 0).toLocaleString()}</div>
|
||||||
<div className="text-xs text-text-tertiary">Clicks</div>
|
<div className="text-xs text-text-tertiary">{t('finance.clicks')}</div>
|
||||||
{s.clicks > 0 && s.spent > 0 && (
|
{s.clicks > 0 && s.spent > 0 && (
|
||||||
<div className="text-[10px] text-text-tertiary mt-0.5">CPC: {(s.spent / s.clicks).toFixed(2)} {currencySymbol}</div>
|
<div className="text-[10px] text-text-tertiary mt-0.5">CPC: {(s.spent / s.clicks).toFixed(2)} {currencySymbol}</div>
|
||||||
)}
|
)}
|
||||||
@@ -175,7 +246,7 @@ export default function Finance() {
|
|||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<Target className="w-5 h-5 mx-auto mb-1 text-emerald-500" />
|
<Target className="w-5 h-5 mx-auto mb-1 text-emerald-500" />
|
||||||
<div className="text-xl font-bold text-text-primary">{(s.conversions || 0).toLocaleString()}</div>
|
<div className="text-xl font-bold text-text-primary">{(s.conversions || 0).toLocaleString()}</div>
|
||||||
<div className="text-xs text-text-tertiary">Conversions</div>
|
<div className="text-xs text-text-tertiary">{t('finance.conversions')}</div>
|
||||||
{s.conversions > 0 && s.spent > 0 && (
|
{s.conversions > 0 && s.spent > 0 && (
|
||||||
<div className="text-[10px] text-text-tertiary mt-0.5">CPA: {(s.spent / s.conversions).toFixed(2)} {currencySymbol}</div>
|
<div className="text-[10px] text-text-tertiary mt-0.5">CPA: {(s.spent / s.conversions).toFixed(2)} {currencySymbol}</div>
|
||||||
)}
|
)}
|
||||||
@@ -200,7 +271,7 @@ export default function Finance() {
|
|||||||
<Target className="w-4 h-4 text-blue-600" />
|
<Target className="w-4 h-4 text-blue-600" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold text-text-primary text-base">Campaign Breakdown</h3>
|
<h3 className="font-semibold text-text-primary">{t('finance.campaignBreakdown')}</h3>
|
||||||
<p className="text-xs text-text-tertiary mt-0.5">{s.campaigns.length} campaigns · Track-level budget allocation</p>
|
<p className="text-xs text-text-tertiary mt-0.5">{s.campaigns.length} campaigns · Track-level budget allocation</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -208,13 +279,13 @@ export default function Finance() {
|
|||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-border bg-surface-secondary">
|
<tr className="border-b border-border bg-surface-secondary">
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-text-tertiary">Campaign</th>
|
<th className="px-4 py-3 text-start text-xs font-medium text-text-tertiary">Campaign</th>
|
||||||
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">Budget Assigned</th>
|
<th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">Budget Assigned</th>
|
||||||
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">Track Allocated</th>
|
<th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">Track Allocated</th>
|
||||||
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">Spent</th>
|
<th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">Spent</th>
|
||||||
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">Expenses</th>
|
<th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">Expenses</th>
|
||||||
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">Revenue</th>
|
<th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">Revenue</th>
|
||||||
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">ROI</th>
|
<th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">ROI</th>
|
||||||
<th className="px-4 py-3 text-center text-xs font-medium text-text-tertiary">Status</th>
|
<th className="px-4 py-3 text-center text-xs font-medium text-text-tertiary">Status</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -225,20 +296,20 @@ export default function Finance() {
|
|||||||
return (
|
return (
|
||||||
<tr key={c.id} className="hover:bg-surface-secondary">
|
<tr key={c.id} className="hover:bg-surface-secondary">
|
||||||
<td className="px-4 py-3 font-medium text-text-primary">{c.name}</td>
|
<td className="px-4 py-3 font-medium text-text-primary">{c.name}</td>
|
||||||
<td className="px-4 py-3 text-right">
|
<td className="px-4 py-3 text-end">
|
||||||
{c.budget_from_entries > 0 ? (
|
{c.budget_from_entries > 0 ? (
|
||||||
<span className="font-semibold text-blue-600">{c.budget_from_entries.toLocaleString()}</span>
|
<span className="font-semibold text-blue-600">{c.budget_from_entries.toLocaleString()}</span>
|
||||||
) : <span className="text-text-tertiary">{'\u2014'}</span>}
|
) : <span className="text-text-tertiary">{'\u2014'}</span>}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-right text-text-secondary">{c.tracks_allocated.toLocaleString()}</td>
|
<td className="px-4 py-3 text-end text-text-secondary">{c.tracks_allocated.toLocaleString()}</td>
|
||||||
<td className="px-4 py-3 text-right text-text-secondary">{c.tracks_spent.toLocaleString()}</td>
|
<td className="px-4 py-3 text-end text-text-secondary">{c.tracks_spent.toLocaleString()}</td>
|
||||||
<td className="px-4 py-3 text-right">
|
<td className="px-4 py-3 text-end">
|
||||||
{c.expenses > 0 ? (
|
{c.expenses > 0 ? (
|
||||||
<span className="font-semibold text-red-500">-{c.expenses.toLocaleString()}</span>
|
<span className="font-semibold text-red-500">-{c.expenses.toLocaleString()}</span>
|
||||||
) : <span className="text-text-tertiary">{'\u2014'}</span>}
|
) : <span className="text-text-tertiary">{'\u2014'}</span>}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-right text-text-secondary">{c.tracks_revenue.toLocaleString()}</td>
|
<td className="px-4 py-3 text-end text-text-secondary">{c.tracks_revenue.toLocaleString()}</td>
|
||||||
<td className="px-4 py-3 text-right">
|
<td className="px-4 py-3 text-end">
|
||||||
{totalCampaignConsumed > 0 ? (
|
{totalCampaignConsumed > 0 ? (
|
||||||
<span className={`text-xs font-semibold px-1.5 py-0.5 rounded ${cRoi >= 0 ? 'text-emerald-600 bg-emerald-50' : 'text-red-600 bg-red-50'}`}>
|
<span className={`text-xs font-semibold px-1.5 py-0.5 rounded ${cRoi >= 0 ? 'text-emerald-600 bg-emerald-50' : 'text-red-600 bg-red-50'}`}>
|
||||||
{cRoi.toFixed(0)}%
|
{cRoi.toFixed(0)}%
|
||||||
@@ -263,7 +334,7 @@ export default function Finance() {
|
|||||||
<Briefcase className="w-4 h-4 text-purple-600" />
|
<Briefcase className="w-4 h-4 text-purple-600" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold text-text-primary text-base">Allocated Funds</h3>
|
<h3 className="font-semibold text-text-primary">{t('finance.allocatedFunds')}</h3>
|
||||||
<p className="text-xs text-text-tertiary mt-0.5">{s.projects.filter(p => p.budget_allocated > 0 || p.expenses > 0).length} work orders with assigned budget</p>
|
<p className="text-xs text-text-tertiary mt-0.5">{s.projects.filter(p => p.budget_allocated > 0 || p.expenses > 0).length} work orders with assigned budget</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -271,9 +342,9 @@ export default function Finance() {
|
|||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-border bg-surface-secondary">
|
<tr className="border-b border-border bg-surface-secondary">
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-text-tertiary">Work Order</th>
|
<th className="px-4 py-3 text-start text-xs font-medium text-text-tertiary">Work Order</th>
|
||||||
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">Budget Allocated</th>
|
<th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">Budget Allocated</th>
|
||||||
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">Expenses</th>
|
<th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">Expenses</th>
|
||||||
<th className="px-4 py-3 text-center text-xs font-medium text-text-tertiary">Status</th>
|
<th className="px-4 py-3 text-center text-xs font-medium text-text-tertiary">Status</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -281,8 +352,8 @@ export default function Finance() {
|
|||||||
{s.projects.filter(p => p.budget_allocated > 0 || p.expenses > 0).map(p => (
|
{s.projects.filter(p => p.budget_allocated > 0 || p.expenses > 0).map(p => (
|
||||||
<tr key={p.id} className="hover:bg-surface-secondary">
|
<tr key={p.id} className="hover:bg-surface-secondary">
|
||||||
<td className="px-4 py-3 font-medium text-text-primary">{p.name}</td>
|
<td className="px-4 py-3 font-medium text-text-primary">{p.name}</td>
|
||||||
<td className="px-4 py-3 text-right text-text-secondary">{p.budget_allocated.toLocaleString()} {currencySymbol}</td>
|
<td className="px-4 py-3 text-end text-text-secondary">{p.budget_allocated.toLocaleString()} {currencySymbol}</td>
|
||||||
<td className="px-4 py-3 text-right">
|
<td className="px-4 py-3 text-end">
|
||||||
{p.expenses > 0 ? (
|
{p.expenses > 0 ? (
|
||||||
<span className="font-semibold text-red-500">-{p.expenses.toLocaleString()}</span>
|
<span className="font-semibold text-red-500">-{p.expenses.toLocaleString()}</span>
|
||||||
) : <span className="text-text-tertiary">{'\u2014'}</span>}
|
) : <span className="text-text-tertiary">{'\u2014'}</span>}
|
||||||
@@ -295,6 +366,151 @@ export default function Finance() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Budget Requests (superadmin) */}
|
||||||
|
{isSuperadmin && (
|
||||||
|
<div className="section-card">
|
||||||
|
<div className="section-card-header flex items-center gap-3">
|
||||||
|
<div className="p-2 rounded-lg bg-amber-50">
|
||||||
|
<Wallet className="w-4 h-4 text-amber-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-text-primary">{t('finance.budgetRequests')}</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pendingCount > 0 && (
|
||||||
|
<div className="mx-5 mt-4 px-4 py-2.5 bg-amber-50 border border-amber-200 rounded-lg text-sm text-amber-800 font-medium">
|
||||||
|
{pendingCount} {t('finance.requestPending')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{budgetRequests.length === 0 ? (
|
||||||
|
<div className="px-5 py-8 text-center text-sm text-text-tertiary">
|
||||||
|
{t('common.noData')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border bg-surface-secondary">
|
||||||
|
<th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">{t('finance.amount')}</th>
|
||||||
|
<th className="px-4 py-3 text-start text-xs font-medium text-text-tertiary">{t('budgetApproval.justification')}</th>
|
||||||
|
<th className="px-4 py-3 text-center text-xs font-medium text-text-tertiary">{t('common.status')}</th>
|
||||||
|
<th className="px-4 py-3 text-start text-xs font-medium text-text-tertiary">{t('budgetApproval.earmarkedFor')}</th>
|
||||||
|
<th className="px-4 py-3 text-start text-xs font-medium text-text-tertiary">{t('common.date')}</th>
|
||||||
|
<th className="px-4 py-3 text-center text-xs font-medium text-text-tertiary"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border-light">
|
||||||
|
{budgetRequests.map(req => (
|
||||||
|
<tr key={req.id || req.Id} className="hover:bg-surface-secondary">
|
||||||
|
<td className="px-4 py-3 text-end font-semibold text-text-primary">
|
||||||
|
{Number(req.amount).toLocaleString()} {currencySymbol}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-text-secondary max-w-[200px]">
|
||||||
|
<span title={req.justification}>
|
||||||
|
{req.justification?.length > 60 ? req.justification.slice(0, 60) + '...' : req.justification}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-center">
|
||||||
|
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${BUDGET_REQUEST_STATUS_COLORS[req.status] || 'bg-gray-100 text-gray-600'}`}>
|
||||||
|
{req.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-text-secondary text-xs">
|
||||||
|
{req.earmark_name || '\u2014'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-text-tertiary text-xs">
|
||||||
|
{req.created_at ? new Date(req.created_at).toLocaleDateString() : '\u2014'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-center">
|
||||||
|
{req.status === 'pending' && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleCancelRequest(req.id || req.Id)}
|
||||||
|
className="text-xs text-red-600 hover:text-red-700 font-medium hover:bg-red-50 px-2 py-1 rounded transition-colors"
|
||||||
|
>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Budget Request Modal */}
|
||||||
|
<Modal isOpen={showRequestModal} onClose={() => setShowRequestModal(false)} title={t('finance.requestBudget')} size="md">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('finance.amount')}</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={requestForm.amount}
|
||||||
|
onChange={e => setRequestForm(f => ({ ...f, amount: e.target.value }))}
|
||||||
|
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface"
|
||||||
|
placeholder="0"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-text-tertiary">{currencySymbol}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('budgetApproval.justification')}</label>
|
||||||
|
<textarea
|
||||||
|
value={requestForm.justification}
|
||||||
|
onChange={e => setRequestForm(f => ({ ...f, justification: e.target.value }))}
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface"
|
||||||
|
placeholder={t('budgetApproval.justification')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('budgetApproval.earmarkedFor')}</label>
|
||||||
|
<select
|
||||||
|
value={requestForm.earmark_type ? `${requestForm.earmark_type}:${requestForm.earmark_id}` : ''}
|
||||||
|
onChange={e => {
|
||||||
|
if (!e.target.value) {
|
||||||
|
setRequestForm(f => ({ ...f, earmark_type: '', earmark_id: '' }))
|
||||||
|
} else {
|
||||||
|
const [type, id] = e.target.value.split(':')
|
||||||
|
setRequestForm(f => ({ ...f, earmark_type: type, earmark_id: id }))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface"
|
||||||
|
>
|
||||||
|
<option value="">{t('common.none')}</option>
|
||||||
|
{campaigns.length > 0 && (
|
||||||
|
<optgroup label={t('finance.campaigns')}>
|
||||||
|
{campaigns.map(c => (
|
||||||
|
<option key={`campaign:${c.id}`} value={`campaign:${c.id}`}>{c.name}</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
)}
|
||||||
|
{projects.length > 0 && (
|
||||||
|
<optgroup label={t('finance.projects')}>
|
||||||
|
{projects.map(p => (
|
||||||
|
<option key={`project:${p.id}`} value={`project:${p.id}`}>{p.name}</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleSubmitRequest}
|
||||||
|
disabled={!requestForm.amount || !requestForm.justification.trim() || submittingRequest}
|
||||||
|
className={`w-full px-4 py-2.5 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm transition-colors ${submittingRequest ? 'btn-loading' : ''}`}
|
||||||
|
>
|
||||||
|
{t('finance.requestBudget')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { useLanguage } from '../i18n/LanguageContext'
|
import { useLanguage } from '../i18n/LanguageContext'
|
||||||
import { Megaphone, Mail, AlertCircle, CheckCircle, ArrowLeft } from 'lucide-react'
|
import { Mail, AlertCircle, CheckCircle, ArrowLeft } from 'lucide-react'
|
||||||
import { api } from '../utils/api'
|
import { api } from '../utils/api'
|
||||||
|
|
||||||
|
function MarkaLogo({ className = '' }) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 32 32" fill="none" className={className}>
|
||||||
|
<path d="M4 26V6l10 10L4 26z" fill="currentColor" opacity="0.85" />
|
||||||
|
<path d="M18 26V6l10 10-10 10z" fill="currentColor" opacity="0.5" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function ForgotPassword() {
|
export default function ForgotPassword() {
|
||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
@@ -27,11 +36,11 @@ export default function ForgotPassword() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-blue-900 to-slate-900 flex items-center justify-center px-4">
|
<div className="min-h-screen bg-slate-900 flex items-center justify-center px-4">
|
||||||
<div className="w-full max-w-md">
|
<div className="w-full max-w-md">
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
<div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-purple-600 rounded-2xl flex items-center justify-center mx-auto mb-4 shadow-lg">
|
<div className="w-16 h-16 bg-brand-primary rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||||
<Megaphone className="w-8 h-8 text-white" />
|
<MarkaLogo className="w-9 h-9 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-3xl font-bold text-white mb-2">{t('forgotPassword.title')}</h1>
|
<h1 className="text-3xl font-bold text-white mb-2">{t('forgotPassword.title')}</h1>
|
||||||
<p className="text-slate-400">{t('forgotPassword.subtitle')}</p>
|
<p className="text-slate-400">{t('forgotPassword.subtitle')}</p>
|
||||||
@@ -57,13 +66,13 @@ export default function ForgotPassword() {
|
|||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-300 mb-2">{t('auth.email')}</label>
|
<label className="block text-sm font-medium text-slate-300 mb-2">{t('auth.email')}</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
<Mail className="absolute start-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
dir="auto"
|
dir="auto"
|
||||||
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
className="w-full ps-11 pe-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||||
placeholder={t('forgotPassword.emailPlaceholder')}
|
placeholder={t('forgotPassword.emailPlaceholder')}
|
||||||
required
|
required
|
||||||
autoFocus
|
autoFocus
|
||||||
@@ -81,7 +90,7 @@ export default function ForgotPassword() {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="w-full py-3 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
className="w-full py-3 bg-brand-primary hover:bg-brand-primary-light text-white font-semibold rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<span className="flex items-center justify-center gap-2">
|
<span className="flex items-center justify-center gap-2">
|
||||||
|
|||||||
+15
-23
@@ -196,8 +196,8 @@ export default function Issues() {
|
|||||||
const SortIcon = ({ col }) => {
|
const SortIcon = ({ col }) => {
|
||||||
if (sortBy !== col) return null
|
if (sortBy !== col) return null
|
||||||
return sortDir === 'asc'
|
return sortDir === 'asc'
|
||||||
? <ChevronUp className="w-3 h-3 inline ml-0.5" />
|
? <ChevronUp className="w-3 h-3 inline ms-0.5" />
|
||||||
: <ChevronDown className="w-3 h-3 inline ml-0.5" />
|
: <ChevronDown className="w-3 h-3 inline ms-0.5" />
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -211,15 +211,7 @@ export default function Issues() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4 animate-fade-in">
|
<div className="space-y-4 animate-fade-in">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-end">
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-text-primary flex items-center gap-2">
|
|
||||||
<AlertCircle className="w-7 h-7" />
|
|
||||||
{t('issues.title')}
|
|
||||||
</h1>
|
|
||||||
<p className="text-text-secondary mt-1">{t('issues.subtitle')}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={copyPublicLink}
|
onClick={copyPublicLink}
|
||||||
@@ -241,7 +233,7 @@ export default function Issues() {
|
|||||||
onClick={() => setViewMode(mode)}
|
onClick={() => setViewMode(mode)}
|
||||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
|
||||||
viewMode === mode
|
viewMode === mode
|
||||||
? 'bg-white text-text-primary shadow-sm'
|
? 'bg-surface text-text-primary shadow-sm'
|
||||||
: 'text-text-tertiary hover:text-text-secondary'
|
: 'text-text-tertiary hover:text-text-secondary'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -276,13 +268,13 @@ export default function Issues() {
|
|||||||
<div className="flex items-center gap-3 flex-wrap">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
<div className="relative flex-1 min-w-[200px] max-w-xs">
|
<div className="relative flex-1 min-w-[200px] max-w-xs">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
|
<Search className="absolute start-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={t('issues.searchPlaceholder')}
|
placeholder={t('issues.searchPlaceholder')}
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={e => setSearchTerm(e.target.value)}
|
onChange={e => setSearchTerm(e.target.value)}
|
||||||
className="w-full pl-10 pr-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 bg-surface"
|
className="w-full ps-10 pe-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 bg-surface"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -413,21 +405,21 @@ export default function Issues() {
|
|||||||
<th className="w-10 px-3 py-3" onClick={e => e.stopPropagation()}>
|
<th className="w-10 px-3 py-3" onClick={e => e.stopPropagation()}>
|
||||||
<input type="checkbox" checked={selectedIds.size === sortedIssues.length && sortedIssues.length > 0} onChange={toggleSelectAll} className="rounded border-border" />
|
<input type="checkbox" checked={selectedIds.size === sortedIssues.length && sortedIssues.length > 0} onChange={toggleSelectAll} className="rounded border-border" />
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleSort('title')}>
|
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleSort('title')}>
|
||||||
{t('issues.tableTitle')} <SortIcon col="title" />
|
{t('issues.tableTitle')} <SortIcon col="title" />
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('issues.tableSubmitter')}</th>
|
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('issues.tableSubmitter')}</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('issues.tableBrand')}</th>
|
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('issues.tableBrand')}</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('issues.tableCategory')}</th>
|
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('issues.tableCategory')}</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('issues.tableType')}</th>
|
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('issues.tableType')}</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleSort('priority')}>
|
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleSort('priority')}>
|
||||||
{t('issues.tablePriority')} <SortIcon col="priority" />
|
{t('issues.tablePriority')} <SortIcon col="priority" />
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleSort('status')}>
|
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleSort('status')}>
|
||||||
{t('issues.tableStatus')} <SortIcon col="status" />
|
{t('issues.tableStatus')} <SortIcon col="status" />
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('issues.tableAssignedTo')}</th>
|
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('issues.tableAssignedTo')}</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleSort('created_at')}>
|
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleSort('created_at')}>
|
||||||
{t('issues.tableCreated')} <SortIcon col="created_at" />
|
{t('issues.tableCreated')} <SortIcon col="created_at" />
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
+33
-21
@@ -2,9 +2,18 @@ import { useState, useEffect } from 'react'
|
|||||||
import { useNavigate, Link } from 'react-router-dom'
|
import { useNavigate, Link } from 'react-router-dom'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
import { useLanguage } from '../i18n/LanguageContext'
|
import { useLanguage } from '../i18n/LanguageContext'
|
||||||
import { Megaphone, Lock, Mail, AlertCircle, User, CheckCircle } from 'lucide-react'
|
import { Lock, Mail, AlertCircle, User, CheckCircle } from 'lucide-react'
|
||||||
import { api } from '../utils/api'
|
import { api } from '../utils/api'
|
||||||
|
|
||||||
|
function MarkaLogo({ className = '' }) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 32 32" fill="none" className={className}>
|
||||||
|
<path d="M4 26V6l10 10L4 26z" fill="currentColor" opacity="0.85" />
|
||||||
|
<path d="M18 26V6l10 10-10 10z" fill="currentColor" opacity="0.5" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { login } = useAuth()
|
const { login } = useAuth()
|
||||||
@@ -63,19 +72,19 @@ export default function Login() {
|
|||||||
|
|
||||||
if (needsSetup === null) {
|
if (needsSetup === null) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-blue-900 to-slate-900 flex items-center justify-center">
|
<div className="min-h-screen bg-slate-900 flex items-center justify-center">
|
||||||
<div className="w-8 h-8 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
<div className="w-8 h-8 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-blue-900 to-slate-900 flex items-center justify-center px-4">
|
<div className="min-h-screen bg-slate-900 flex items-center justify-center px-4">
|
||||||
<div className="w-full max-w-md">
|
<div className="w-full max-w-md">
|
||||||
{/* Logo & Title */}
|
{/* Logo & Title */}
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
<div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-purple-600 rounded-2xl flex items-center justify-center mx-auto mb-4 shadow-lg">
|
<div className="w-16 h-16 bg-brand-primary rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||||
<Megaphone className="w-8 h-8 text-white" />
|
<MarkaLogo className="w-9 h-9 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-3xl font-bold text-white mb-2">
|
<h1 className="text-3xl font-bold text-white mb-2">
|
||||||
{needsSetup ? t('login.initialSetup') : t('login.title')}
|
{needsSetup ? t('login.initialSetup') : t('login.title')}
|
||||||
@@ -101,15 +110,16 @@ export default function Login() {
|
|||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-300 mb-2">{t('login.fullName')}</label>
|
<label className="block text-sm font-medium text-slate-300 mb-2">{t('login.fullName')}</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
<User className="absolute start-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={setupName}
|
value={setupName}
|
||||||
onChange={(e) => setSetupName(e.target.value)}
|
onChange={(e) => setSetupName(e.target.value)}
|
||||||
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
className="w-full ps-11 pe-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||||
placeholder={t('login.fullNamePlaceholder')}
|
placeholder={t('login.fullNamePlaceholder')}
|
||||||
required
|
required
|
||||||
autoFocus
|
autoFocus
|
||||||
|
aria-describedby={error ? 'setup-error' : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -118,13 +128,13 @@ export default function Login() {
|
|||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-300 mb-2">{t('login.email')}</label>
|
<label className="block text-sm font-medium text-slate-300 mb-2">{t('login.email')}</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
<Mail className="absolute start-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
value={setupEmail}
|
value={setupEmail}
|
||||||
onChange={(e) => setSetupEmail(e.target.value)}
|
onChange={(e) => setSetupEmail(e.target.value)}
|
||||||
dir="auto"
|
dir="auto"
|
||||||
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
className="w-full ps-11 pe-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||||
placeholder="admin@company.com"
|
placeholder="admin@company.com"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@@ -135,12 +145,12 @@ export default function Login() {
|
|||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-300 mb-2">{t('login.password')}</label>
|
<label className="block text-sm font-medium text-slate-300 mb-2">{t('login.password')}</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
<Lock className="absolute start-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={setupPassword}
|
value={setupPassword}
|
||||||
onChange={(e) => setSetupPassword(e.target.value)}
|
onChange={(e) => setSetupPassword(e.target.value)}
|
||||||
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
className="w-full ps-11 pe-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||||
placeholder={t('login.passwordPlaceholder')}
|
placeholder={t('login.passwordPlaceholder')}
|
||||||
required
|
required
|
||||||
minLength={6}
|
minLength={6}
|
||||||
@@ -152,12 +162,12 @@ export default function Login() {
|
|||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-300 mb-2">{t('login.confirmPassword')}</label>
|
<label className="block text-sm font-medium text-slate-300 mb-2">{t('login.confirmPassword')}</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
<Lock className="absolute start-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={setupConfirm}
|
value={setupConfirm}
|
||||||
onChange={(e) => setSetupConfirm(e.target.value)}
|
onChange={(e) => setSetupConfirm(e.target.value)}
|
||||||
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
className="w-full ps-11 pe-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||||
placeholder={t('login.confirmPasswordPlaceholder')}
|
placeholder={t('login.confirmPasswordPlaceholder')}
|
||||||
required
|
required
|
||||||
minLength={6}
|
minLength={6}
|
||||||
@@ -167,7 +177,7 @@ export default function Login() {
|
|||||||
|
|
||||||
{/* Error */}
|
{/* Error */}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
|
<div id="setup-error" className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg" role="alert">
|
||||||
<AlertCircle className="w-5 h-5 text-red-400 shrink-0" />
|
<AlertCircle className="w-5 h-5 text-red-400 shrink-0" />
|
||||||
<p className="text-sm text-red-400">{error}</p>
|
<p className="text-sm text-red-400">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -177,7 +187,7 @@ export default function Login() {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="w-full py-3 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
className="w-full py-3 bg-brand-primary hover:bg-brand-primary-light text-white font-semibold rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<span className="flex items-center justify-center gap-2">
|
<span className="flex items-center justify-center gap-2">
|
||||||
@@ -197,16 +207,17 @@ export default function Login() {
|
|||||||
{t('auth.email')}
|
{t('auth.email')}
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
<Mail className="absolute start-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
dir="auto"
|
dir="auto"
|
||||||
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
className="w-full ps-11 pe-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||||
placeholder="user@company.com"
|
placeholder="user@company.com"
|
||||||
required
|
required
|
||||||
autoFocus
|
autoFocus
|
||||||
|
aria-describedby={error ? 'login-error' : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -217,21 +228,22 @@ export default function Login() {
|
|||||||
{t('auth.password')}
|
{t('auth.password')}
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
<Lock className="absolute start-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
className="w-full ps-11 pe-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
required
|
required
|
||||||
|
aria-describedby={error ? 'login-error' : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Error */}
|
{/* Error */}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
|
<div id="login-error" className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg" role="alert">
|
||||||
<AlertCircle className="w-5 h-5 text-red-400 shrink-0" />
|
<AlertCircle className="w-5 h-5 text-red-400 shrink-0" />
|
||||||
<p className="text-sm text-red-400">{error}</p>
|
<p className="text-sm text-red-400">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -241,7 +253,7 @@ export default function Login() {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="w-full py-3 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
className="w-full py-3 bg-brand-primary hover:bg-brand-primary-light text-white font-semibold rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<span className="flex items-center justify-center gap-2">
|
<span className="flex items-center justify-center gap-2">
|
||||||
|
|||||||
@@ -158,14 +158,6 @@ export default function PostCalendar() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 animate-fade-in">
|
<div className="space-y-4 animate-fade-in">
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-text-primary">Content Calendar</h1>
|
|
||||||
<p className="text-sm text-text-secondary mt-1">Schedule and plan your posts</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<select
|
<select
|
||||||
@@ -220,14 +212,14 @@ export default function PostCalendar() {
|
|||||||
<div className="flex bg-surface-tertiary rounded-lg p-0.5">
|
<div className="flex bg-surface-tertiary rounded-lg p-0.5">
|
||||||
<button
|
<button
|
||||||
onClick={() => setCalView('month')}
|
onClick={() => setCalView('month')}
|
||||||
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors flex items-center gap-1.5 ${calView === 'month' ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary'}`}
|
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors flex items-center gap-1.5 ${calView === 'month' ? 'bg-surface shadow-sm text-text-primary' : 'text-text-tertiary'}`}
|
||||||
>
|
>
|
||||||
<CalendarIcon className="w-3.5 h-3.5" />
|
<CalendarIcon className="w-3.5 h-3.5" />
|
||||||
Month
|
Month
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setCalView('week')}
|
onClick={() => setCalView('week')}
|
||||||
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors flex items-center gap-1.5 ${calView === 'week' ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary'}`}
|
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors flex items-center gap-1.5 ${calView === 'week' ? 'bg-surface shadow-sm text-text-primary' : 'text-text-tertiary'}`}
|
||||||
>
|
>
|
||||||
<CalendarDays className="w-3.5 h-3.5" />
|
<CalendarDays className="w-3.5 h-3.5" />
|
||||||
Week
|
Week
|
||||||
@@ -271,7 +263,7 @@ export default function PostCalendar() {
|
|||||||
<button
|
<button
|
||||||
key={post.Id || post._id}
|
key={post.Id || post._id}
|
||||||
onClick={() => handlePostClick(post)}
|
onClick={() => handlePostClick(post)}
|
||||||
className={`w-full text-left text-[10px] px-2 py-1 rounded font-medium hover:opacity-80 transition-opacity truncate ${
|
className={`w-full text-start text-[10px] px-2 py-1 rounded font-medium hover:opacity-80 transition-opacity truncate ${
|
||||||
STATUS_COLORS[post.status] || 'bg-surface-tertiary text-text-secondary'
|
STATUS_COLORS[post.status] || 'bg-surface-tertiary text-text-secondary'
|
||||||
}`}
|
}`}
|
||||||
title={post.title}
|
title={post.title}
|
||||||
@@ -294,13 +286,13 @@ export default function PostCalendar() {
|
|||||||
{/* Unscheduled Posts */}
|
{/* Unscheduled Posts */}
|
||||||
{unscheduled.length > 0 && (
|
{unscheduled.length > 0 && (
|
||||||
<div className="bg-surface rounded-xl border border-border p-6">
|
<div className="bg-surface rounded-xl border border-border p-6">
|
||||||
<h3 className="text-sm font-semibold text-text-tertiary uppercase mb-3">Unscheduled Posts</h3>
|
<h3 className="text-sm font-semibold text-text-tertiary uppercase mb-3">{t('calendar.unscheduledPosts')}</h3>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
{unscheduled.map(post => (
|
{unscheduled.map(post => (
|
||||||
<button
|
<button
|
||||||
key={post.Id || post._id}
|
key={post.Id || post._id}
|
||||||
onClick={() => handlePostClick(post)}
|
onClick={() => handlePostClick(post)}
|
||||||
className="text-left bg-surface-secondary border border-border rounded-lg p-3 hover:border-brand-primary/30 transition-colors"
|
className="text-start bg-surface-secondary border border-border rounded-lg p-3 hover:border-brand-primary/30 transition-colors"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<span className={`text-xs px-2 py-0.5 rounded-full ${STATUS_COLORS[post.status] || 'bg-surface-tertiary text-text-secondary'}`}>
|
<span className={`text-xs px-2 py-0.5 rounded-full ${STATUS_COLORS[post.status] || 'bg-surface-tertiary text-text-secondary'}`}>
|
||||||
@@ -319,7 +311,7 @@ export default function PostCalendar() {
|
|||||||
|
|
||||||
{/* Legend */}
|
{/* Legend */}
|
||||||
<div className="bg-surface rounded-xl border border-border p-4">
|
<div className="bg-surface rounded-xl border border-border p-4">
|
||||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-3">Status Legend</h4>
|
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-3">{t('calendar.statusLegend')}</h4>
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
{Object.entries(STATUS_COLORS).map(([status, color]) => (
|
{Object.entries(STATUS_COLORS).map(([status, color]) => (
|
||||||
<div key={status} className="flex items-center gap-2">
|
<div key={status} className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useContext } from 'react'
|
import { useState, useEffect, useContext, useMemo } from 'react'
|
||||||
import { Plus, LayoutGrid, List, Search, X, FileText, Filter } from 'lucide-react'
|
import { Plus, LayoutGrid, List, Search, X, FileText, Filter } from 'lucide-react'
|
||||||
import { AppContext } from '../App'
|
import { AppContext } from '../App'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
@@ -167,7 +167,7 @@ export default function PostProduction() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredPosts = posts.filter(p => {
|
const filteredPosts = useMemo(() => posts.filter(p => {
|
||||||
if (filters.brand && String(p.brandId || p.brand_id) !== filters.brand) return false
|
if (filters.brand && String(p.brandId || p.brand_id) !== filters.brand) return false
|
||||||
if (filters.platform && !(p.platforms || []).includes(filters.platform) && p.platform !== filters.platform) return false
|
if (filters.platform && !(p.platforms || []).includes(filters.platform) && p.platform !== filters.platform) return false
|
||||||
if (filters.assignedTo && String(p.assignedTo || p.assigned_to) !== filters.assignedTo) return false
|
if (filters.assignedTo && String(p.assignedTo || p.assigned_to) !== filters.assignedTo) return false
|
||||||
@@ -181,7 +181,7 @@ export default function PostProduction() {
|
|||||||
if (filters.periodTo && d > filters.periodTo) return false
|
if (filters.periodTo && d > filters.periodTo) return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
})
|
}), [posts, filters, searchTerm])
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return view === 'kanban' ? <SkeletonKanbanBoard /> : <SkeletonTable rows={8} cols={6} />
|
return view === 'kanban' ? <SkeletonKanbanBoard /> : <SkeletonTable rows={8} cols={6} />
|
||||||
@@ -193,20 +193,20 @@ export default function PostProduction() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<div className="relative flex-1 min-w-[200px] max-w-md">
|
<div className="relative flex-1 min-w-[200px] max-w-md">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
|
<Search className="absolute start-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={t('posts.searchPosts')}
|
placeholder={t('posts.searchPosts')}
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={e => setSearchTerm(e.target.value)}
|
onChange={e => setSearchTerm(e.target.value)}
|
||||||
className="w-full pl-10 pr-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-white"
|
className="w-full ps-10 pe-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
data-tutorial="filters"
|
data-tutorial="filters"
|
||||||
onClick={() => setShowFilters(f => !f)}
|
onClick={() => setShowFilters(f => !f)}
|
||||||
className={`relative flex items-center gap-1.5 px-3 py-2 text-sm border rounded-lg transition-colors ${showFilters ? 'border-brand-primary bg-brand-primary/5 text-brand-primary' : 'border-border bg-white text-text-secondary hover:border-brand-primary/40'}`}
|
className={`relative flex items-center gap-1.5 px-3 py-2 text-sm border rounded-lg transition-colors ${showFilters ? 'border-brand-primary bg-brand-primary/5 text-brand-primary' : 'border-border bg-surface text-text-secondary hover:border-brand-primary/40'}`}
|
||||||
>
|
>
|
||||||
<Filter className="w-4 h-4" />
|
<Filter className="w-4 h-4" />
|
||||||
{t('common.filter')}
|
{t('common.filter')}
|
||||||
@@ -215,16 +215,16 @@ export default function PostProduction() {
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="flex bg-surface-tertiary rounded-lg p-0.5 ml-auto">
|
<div className="flex bg-surface-tertiary rounded-lg p-0.5 ms-auto">
|
||||||
<button
|
<button
|
||||||
onClick={() => setView('kanban')}
|
onClick={() => setView('kanban')}
|
||||||
className={`p-2 rounded-md ${view === 'kanban' ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary'}`}
|
className={`p-2 rounded-md ${view === 'kanban' ? 'bg-surface shadow-sm text-text-primary' : 'text-text-tertiary'}`}
|
||||||
>
|
>
|
||||||
<LayoutGrid className="w-4 h-4" />
|
<LayoutGrid className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setView('list')}
|
onClick={() => setView('list')}
|
||||||
className={`p-2 rounded-md ${view === 'list' ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary'}`}
|
className={`p-2 rounded-md ${view === 'list' ? 'bg-surface shadow-sm text-text-primary' : 'text-text-tertiary'}`}
|
||||||
>
|
>
|
||||||
<List className="w-4 h-4" />
|
<List className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -245,7 +245,7 @@ export default function PostProduction() {
|
|||||||
<select
|
<select
|
||||||
value={filters.brand}
|
value={filters.brand}
|
||||||
onChange={e => setFilters(f => ({ ...f, brand: e.target.value }))}
|
onChange={e => setFilters(f => ({ ...f, brand: e.target.value }))}
|
||||||
className="text-xs border border-border rounded-lg px-2.5 py-1.5 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
className="text-xs border border-border rounded-lg px-2.5 py-1.5 bg-surface text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||||
>
|
>
|
||||||
<option value="">{t('posts.allBrands')}</option>
|
<option value="">{t('posts.allBrands')}</option>
|
||||||
{brands.map(b => <option key={b._id} value={b._id}>{lang === 'ar' && b.name_ar ? b.name_ar : b.name}</option>)}
|
{brands.map(b => <option key={b._id} value={b._id}>{lang === 'ar' && b.name_ar ? b.name_ar : b.name}</option>)}
|
||||||
@@ -254,7 +254,7 @@ export default function PostProduction() {
|
|||||||
<select
|
<select
|
||||||
value={filters.platform}
|
value={filters.platform}
|
||||||
onChange={e => setFilters(f => ({ ...f, platform: e.target.value }))}
|
onChange={e => setFilters(f => ({ ...f, platform: e.target.value }))}
|
||||||
className="text-xs border border-border rounded-lg px-2.5 py-1.5 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
className="text-xs border border-border rounded-lg px-2.5 py-1.5 bg-surface text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||||
>
|
>
|
||||||
<option value="">{t('posts.allPlatforms')}</option>
|
<option value="">{t('posts.allPlatforms')}</option>
|
||||||
{Object.entries(PLATFORMS).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
|
{Object.entries(PLATFORMS).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
|
||||||
@@ -263,7 +263,7 @@ export default function PostProduction() {
|
|||||||
<select
|
<select
|
||||||
value={filters.assignedTo}
|
value={filters.assignedTo}
|
||||||
onChange={e => setFilters(f => ({ ...f, assignedTo: e.target.value }))}
|
onChange={e => setFilters(f => ({ ...f, assignedTo: e.target.value }))}
|
||||||
className="text-xs border border-border rounded-lg px-2.5 py-1.5 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
className="text-xs border border-border rounded-lg px-2.5 py-1.5 bg-surface text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||||
>
|
>
|
||||||
<option value="">{t('posts.allPeople')}</option>
|
<option value="">{t('posts.allPeople')}</option>
|
||||||
{teamMembers.map(m => <option key={m._id} value={String(m._id)}>{m.name}</option>)}
|
{teamMembers.map(m => <option key={m._id} value={String(m._id)}>{m.name}</option>)}
|
||||||
@@ -281,7 +281,7 @@ export default function PostProduction() {
|
|||||||
value={filters.periodFrom}
|
value={filters.periodFrom}
|
||||||
onChange={e => { setFilters(f => ({ ...f, periodFrom: e.target.value })); setActivePreset('') }}
|
onChange={e => { setFilters(f => ({ ...f, periodFrom: e.target.value })); setActivePreset('') }}
|
||||||
title={t('posts.periodFrom')}
|
title={t('posts.periodFrom')}
|
||||||
className="text-xs border border-border rounded-lg px-2 py-1.5 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
className="text-xs border border-border rounded-lg px-2 py-1.5 bg-surface text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||||
/>
|
/>
|
||||||
<span className="text-xs text-text-tertiary">–</span>
|
<span className="text-xs text-text-tertiary">–</span>
|
||||||
<input
|
<input
|
||||||
@@ -289,7 +289,7 @@ export default function PostProduction() {
|
|||||||
value={filters.periodTo}
|
value={filters.periodTo}
|
||||||
onChange={e => { setFilters(f => ({ ...f, periodTo: e.target.value })); setActivePreset('') }}
|
onChange={e => { setFilters(f => ({ ...f, periodTo: e.target.value })); setActivePreset('') }}
|
||||||
title={t('posts.periodTo')}
|
title={t('posts.periodTo')}
|
||||||
className="text-xs border border-border rounded-lg px-2 py-1.5 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
className="text-xs border border-border rounded-lg px-2 py-1.5 bg-surface text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -334,7 +334,7 @@ export default function PostProduction() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
<div className="bg-surface rounded-xl border border-border overflow-hidden">
|
||||||
{filteredPosts.length === 0 ? (
|
{filteredPosts.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={FileText}
|
icon={FileText}
|
||||||
@@ -361,12 +361,12 @@ export default function PostProduction() {
|
|||||||
<th className="w-10 px-3 py-3" onClick={e => e.stopPropagation()}>
|
<th className="w-10 px-3 py-3" onClick={e => e.stopPropagation()}>
|
||||||
<input type="checkbox" checked={selectedIds.size === filteredPosts.length && filteredPosts.length > 0} onChange={toggleSelectAll} className="rounded border-border" />
|
<input type="checkbox" checked={selectedIds.size === filteredPosts.length && filteredPosts.length > 0} onChange={toggleSelectAll} className="rounded border-border" />
|
||||||
</th>
|
</th>
|
||||||
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.postTitle')}</th>
|
<th className="text-start px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.postTitle')}</th>
|
||||||
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.brand')}</th>
|
<th className="text-start px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.brand')}</th>
|
||||||
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.status')}</th>
|
<th className="text-start px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.status')}</th>
|
||||||
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.platforms')}</th>
|
<th className="text-start px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.platforms')}</th>
|
||||||
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.assignTo')}</th>
|
<th className="text-start px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.assignTo')}</th>
|
||||||
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.scheduledDate')}</th>
|
<th className="text-start px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.scheduledDate')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-border-light">
|
<tbody className="divide-y divide-border-light">
|
||||||
|
|||||||
@@ -223,14 +223,14 @@ export default function ProjectDetail() {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Project header */}
|
{/* Project header */}
|
||||||
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
<div className="bg-surface rounded-xl border border-border overflow-hidden">
|
||||||
{/* Thumbnail banner */}
|
{/* Thumbnail banner */}
|
||||||
{(project.thumbnail_url || project.thumbnailUrl) && (
|
{(project.thumbnail_url || project.thumbnailUrl) && (
|
||||||
<div className="relative w-full h-40 overflow-hidden">
|
<div className="relative w-full h-40 overflow-hidden">
|
||||||
<img src={project.thumbnail_url || project.thumbnailUrl} alt="" className="w-full h-full object-cover" />
|
<img src={project.thumbnail_url || project.thumbnailUrl} alt="" className="w-full h-full object-cover" loading="lazy" />
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/30 to-transparent" />
|
<div className="absolute inset-0 bg-gradient-to-t from-black/30 to-transparent" />
|
||||||
{canEditProject && (
|
{canEditProject && (
|
||||||
<div className="absolute top-2 right-2 flex items-center gap-1">
|
<div className="absolute top-2 end-2 flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => thumbnailInputRef.current?.click()}
|
onClick={() => thumbnailInputRef.current?.click()}
|
||||||
className="px-2 py-1 text-xs bg-black/40 hover:bg-black/60 rounded text-white transition-colors"
|
className="px-2 py-1 text-xs bg-black/40 hover:bg-black/60 rounded text-white transition-colors"
|
||||||
@@ -341,7 +341,7 @@ export default function ProjectDetail() {
|
|||||||
key={v.id}
|
key={v.id}
|
||||||
onClick={() => setView(v.id)}
|
onClick={() => setView(v.id)}
|
||||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
||||||
view === v.id ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary hover:text-text-secondary'
|
view === v.id ? 'bg-surface shadow-sm text-text-primary' : 'text-text-tertiary hover:text-text-secondary'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<v.icon className="w-4 h-4" />
|
<v.icon className="w-4 h-4" />
|
||||||
@@ -411,21 +411,21 @@ export default function ProjectDetail() {
|
|||||||
|
|
||||||
{/* ─── LIST VIEW ─── */}
|
{/* ─── LIST VIEW ─── */}
|
||||||
{view === 'list' && (
|
{view === 'list' && (
|
||||||
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
<div className="bg-surface rounded-xl border border-border overflow-hidden">
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-border bg-surface-secondary">
|
<tr className="border-b border-border bg-surface-secondary">
|
||||||
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider w-8"></th>
|
<th className="text-start px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider w-8"></th>
|
||||||
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Task</th>
|
<th className="text-start px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Task</th>
|
||||||
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Status</th>
|
<th className="text-start px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Status</th>
|
||||||
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Priority</th>
|
<th className="text-start px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Priority</th>
|
||||||
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Assignee</th>
|
<th className="text-start px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Assignee</th>
|
||||||
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Due</th>
|
<th className="text-start px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Due</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-border-light">
|
<tbody className="divide-y divide-border-light">
|
||||||
{tasks.length === 0 ? (
|
{tasks.length === 0 ? (
|
||||||
<tr><td colSpan={6} className="py-12 text-center text-sm text-text-tertiary">No tasks yet</td></tr>
|
<tr><td colSpan={6} className="py-12 text-center text-sm text-text-tertiary">{t('tasks.noTasks')}</td></tr>
|
||||||
) : (
|
) : (
|
||||||
tasks.map(task => {
|
tasks.map(task => {
|
||||||
const prio = PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium
|
const prio = PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium
|
||||||
@@ -470,7 +470,7 @@ export default function ProjectDetail() {
|
|||||||
|
|
||||||
{/* ─── DISCUSSION SIDEBAR ─── */}
|
{/* ─── DISCUSSION SIDEBAR ─── */}
|
||||||
{showDiscussion && (
|
{showDiscussion && (
|
||||||
<div className="w-[340px] shrink-0 bg-white rounded-xl border border-border flex flex-col self-start sticky top-4" style={{ maxHeight: 'calc(100vh - 6rem)' }}>
|
<div className="w-[340px] shrink-0 bg-surface rounded-xl border border-border flex flex-col self-start sticky top-4" style={{ maxHeight: 'calc(100vh - 6rem)' }}>
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||||
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-1.5">
|
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-1.5">
|
||||||
<MessageCircle className="w-4 h-4" />
|
<MessageCircle className="w-4 h-4" />
|
||||||
@@ -539,7 +539,7 @@ function TaskKanbanCard({ task, canEdit, canDelete, onClick, onDelete, onStatusC
|
|||||||
onDragStart={(e) => canEdit && onDragStart(e, task)}
|
onDragStart={(e) => canEdit && onDragStart(e, task)}
|
||||||
onDragEnd={onDragEnd}
|
onDragEnd={onDragEnd}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={`bg-white rounded-lg border border-border p-3 group hover:border-brand-primary/30 hover:shadow-sm transition-all cursor-pointer ${canEdit ? 'active:cursor-grabbing' : ''}`}
|
className={`bg-surface rounded-lg border border-border p-3 group hover:border-brand-primary/30 hover:shadow-sm transition-all cursor-pointer ${canEdit ? 'active:cursor-grabbing' : ''}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<div className={`w-2 h-2 rounded-full ${priority.color} mt-1.5 shrink-0`} title={priority.label} />
|
<div className={`w-2 h-2 rounded-full ${priority.color} mt-1.5 shrink-0`} title={priority.label} />
|
||||||
@@ -572,7 +572,7 @@ function TaskKanbanCard({ task, canEdit, canDelete, onClick, onDelete, onStatusC
|
|||||||
)}
|
)}
|
||||||
{canDelete && (
|
{canDelete && (
|
||||||
<button onClick={(e) => { e.stopPropagation(); onDelete() }}
|
<button onClick={(e) => { e.stopPropagation(); onDelete() }}
|
||||||
className="text-[10px] text-red-400 hover:bg-red-50 px-2 py-0.5 rounded-full flex items-center gap-1 ml-auto">
|
className="text-[10px] text-red-400 hover:bg-red-50 px-2 py-0.5 rounded-full flex items-center gap-1 ms-auto">
|
||||||
<Trash2 className="w-3 h-3" />
|
<Trash2 className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -614,7 +614,7 @@ function GanttView({ tasks, project, onEditTask, onTaskColorChange }) {
|
|||||||
|
|
||||||
if (tasks.length === 0) {
|
if (tasks.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-xl border border-border py-16 text-center">
|
<div className="bg-surface rounded-xl border border-border py-16 text-center">
|
||||||
<GanttChart className="w-12 h-12 text-text-tertiary mx-auto mb-3" />
|
<GanttChart className="w-12 h-12 text-text-tertiary mx-auto mb-3" />
|
||||||
<p className="text-text-secondary font-medium">No tasks to display</p>
|
<p className="text-text-secondary font-medium">No tasks to display</p>
|
||||||
<p className="text-sm text-text-tertiary mt-1">Add tasks with due dates to see the timeline</p>
|
<p className="text-sm text-text-tertiary mt-1">Add tasks with due dates to see the timeline</p>
|
||||||
@@ -666,7 +666,7 @@ function GanttView({ tasks, project, onEditTask, onTaskColorChange }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
<div className="bg-surface rounded-xl border border-border overflow-hidden">
|
||||||
{/* Zoom toolbar */}
|
{/* Zoom toolbar */}
|
||||||
<div className="flex items-center justify-between px-4 py-2 border-b border-border bg-surface-secondary">
|
<div className="flex items-center justify-between px-4 py-2 border-b border-border bg-surface-secondary">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -757,7 +757,7 @@ function GanttView({ tasks, project, onEditTask, onTaskColorChange }) {
|
|||||||
)}
|
)}
|
||||||
{!onTaskColorChange && <div className={`w-2 h-2 rounded-full ${prio.color} shrink-0`} />}
|
{!onTaskColorChange && <div className={`w-2 h-2 rounded-full ${prio.color} shrink-0`} />}
|
||||||
<button onClick={() => onEditTask(task)}
|
<button onClick={() => onEditTask(task)}
|
||||||
className="text-xs font-medium text-text-primary truncate hover:text-brand-primary text-left">
|
className="text-xs font-medium text-text-primary truncate hover:text-brand-primary text-start">
|
||||||
{task.title}
|
{task.title}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -787,7 +787,7 @@ function GanttView({ tasks, project, onEditTask, onTaskColorChange }) {
|
|||||||
{colorPicker && onTaskColorChange && (
|
{colorPicker && onTaskColorChange && (
|
||||||
<div
|
<div
|
||||||
ref={colorPickerRef}
|
ref={colorPickerRef}
|
||||||
className="fixed z-50 bg-white rounded-lg shadow-xl border border-border p-2"
|
className="fixed z-50 bg-surface rounded-lg shadow-xl border border-border p-2"
|
||||||
style={{ left: colorPicker.x, top: colorPicker.y }}
|
style={{ left: colorPicker.x, top: colorPicker.y }}
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-4 gap-1.5 mb-2">
|
<div className="grid grid-cols-4 gap-1.5 mb-2">
|
||||||
|
|||||||
@@ -80,13 +80,13 @@ export default function Projects() {
|
|||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<div className="relative flex-1 min-w-[200px] max-w-md">
|
<div className="relative flex-1 min-w-[200px] max-w-md">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
|
<Search className="absolute start-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search projects..."
|
placeholder="Search projects..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={e => setSearchTerm(e.target.value)}
|
onChange={e => setSearchTerm(e.target.value)}
|
||||||
className="w-full pl-10 pr-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-white"
|
className="w-full ps-10 pe-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -100,7 +100,7 @@ export default function Projects() {
|
|||||||
key={v.id}
|
key={v.id}
|
||||||
onClick={() => setView(v.id)}
|
onClick={() => setView(v.id)}
|
||||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
||||||
view === v.id ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary hover:text-text-secondary'
|
view === v.id ? 'bg-surface shadow-sm text-text-primary' : 'text-text-tertiary hover:text-text-secondary'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<v.icon className="w-4 h-4" />
|
<v.icon className="w-4 h-4" />
|
||||||
@@ -112,7 +112,7 @@ export default function Projects() {
|
|||||||
{permissions?.canCreateProjects && (
|
{permissions?.canCreateProjects && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowModal(true)}
|
onClick={() => setShowModal(true)}
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm ml-auto"
|
className="flex items-center gap-2 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm ms-auto"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
New Project
|
New Project
|
||||||
|
|||||||
@@ -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 },
|
acknowledged: { label: t('acknowledged'), bg: 'bg-blue-100', text: 'text-blue-700', dot: 'bg-blue-500', icon: CheckCircle2 },
|
||||||
in_progress: { label: t('in_progress'), bg: 'bg-amber-100', text: 'text-amber-700', dot: 'bg-amber-500', icon: Clock },
|
in_progress: { label: t('in_progress'), bg: 'bg-amber-100', text: 'text-amber-700', dot: 'bg-amber-500', icon: Clock },
|
||||||
resolved: { label: t('resolved'), bg: 'bg-emerald-100', text: 'text-emerald-700', dot: 'bg-emerald-500', icon: CheckCircle2 },
|
resolved: { label: t('resolved'), bg: 'bg-emerald-100', text: 'text-emerald-700', dot: 'bg-emerald-500', icon: CheckCircle2 },
|
||||||
declined: { label: t('declined_status'), bg: 'bg-gray-100', text: 'text-gray-700', dot: 'bg-gray-500', icon: XCircle },
|
declined: { label: t('declined_status'), bg: 'bg-gray-100', text: 'text-text-secondary', dot: 'bg-gray-500', icon: XCircle },
|
||||||
}
|
}
|
||||||
|
|
||||||
const PRIORITY_CONFIG = {
|
const PRIORITY_CONFIG = {
|
||||||
low: { label: t('low'), color: 'text-gray-700' },
|
low: { label: t('low'), color: 'text-text-secondary' },
|
||||||
medium: { label: t('medium'), color: 'text-blue-700' },
|
medium: { label: t('medium'), color: 'text-blue-700' },
|
||||||
high: { label: t('high'), color: 'text-orange-700' },
|
high: { label: t('high'), color: 'text-orange-700' },
|
||||||
urgent: { label: t('urgent'), color: 'text-red-700' },
|
urgent: { label: t('urgent'), color: 'text-red-700' },
|
||||||
@@ -267,16 +267,16 @@ export default function PublicIssueTracker() {
|
|||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
{issue.status === 'resolved'
|
{issue.status === 'resolved'
|
||||||
? <CheckCircle2 className="w-6 h-6 text-emerald-600 shrink-0 mt-1" />
|
? <CheckCircle2 className="w-6 h-6 text-emerald-600 shrink-0 mt-1" />
|
||||||
: <XCircle className="w-6 h-6 text-gray-600 shrink-0 mt-1" />}
|
: <XCircle className="w-6 h-6 text-text-secondary shrink-0 mt-1" />}
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h2 className={`text-lg font-bold mb-2 ${issue.status === 'resolved' ? 'text-emerald-900' : 'text-gray-900'}`}>
|
<h2 className={`text-lg font-bold mb-2 ${issue.status === 'resolved' ? 'text-emerald-900' : 'text-text-primary'}`}>
|
||||||
{issue.status === 'resolved' ? t('resolution') : t('declined')}
|
{issue.status === 'resolved' ? t('resolution') : t('declined')}
|
||||||
</h2>
|
</h2>
|
||||||
<p className={`${issue.status === 'resolved' ? 'text-emerald-800' : 'text-gray-800'} whitespace-pre-wrap`}>
|
<p className={`${issue.status === 'resolved' ? 'text-emerald-800' : 'text-text-primary'} whitespace-pre-wrap`}>
|
||||||
{issue.resolution_summary}
|
{issue.resolution_summary}
|
||||||
</p>
|
</p>
|
||||||
{issue.resolved_at && (
|
{issue.resolved_at && (
|
||||||
<p className={`text-sm mt-2 ${issue.status === 'resolved' ? 'text-emerald-600' : 'text-gray-600'}`}>
|
<p className={`text-sm mt-2 ${issue.status === 'resolved' ? 'text-emerald-600' : 'text-text-secondary'}`}>
|
||||||
{dateFmt(issue.resolved_at)}
|
{dateFmt(issue.resolved_at)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -303,7 +303,7 @@ export default function PublicIssueTracker() {
|
|||||||
<div className="flex items-start justify-between gap-3 mb-2">
|
<div className="flex items-start justify-between gap-3 mb-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-semibold text-text-primary">{update.author_name}</span>
|
<span className="font-semibold text-text-primary">{update.author_name}</span>
|
||||||
<span className={`text-xs px-2 py-0.5 rounded-full ${update.author_type === 'staff' ? 'bg-purple-100 text-purple-700' : 'bg-gray-200 text-gray-700'}`}>
|
<span className={`text-xs px-2 py-0.5 rounded-full ${update.author_type === 'staff' ? 'bg-purple-100 text-purple-700' : 'bg-gray-200 text-text-secondary'}`}>
|
||||||
{update.author_type === 'staff' ? t('team') : t('you')}
|
{update.author_type === 'staff' ? t('team') : t('you')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ export default function PublicPostReview() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-white">{t('review.postReview')}</h1>
|
<h1 className="text-2xl font-bold text-white">{t('review.postReview')}</h1>
|
||||||
<p className="text-white/80 text-sm">Samaya Digital Hub</p>
|
<p className="text-white/80 text-sm">Rawaj</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -181,7 +181,7 @@ export default function PublicPostReview() {
|
|||||||
{images.map((att, idx) => (
|
{images.map((att, idx) => (
|
||||||
<a key={idx} href={att.url} target="_blank" rel="noopener noreferrer"
|
<a key={idx} href={att.url} target="_blank" rel="noopener noreferrer"
|
||||||
className="block rounded-xl overflow-hidden border-2 border-border hover:border-brand-primary transition-colors shadow-sm">
|
className="block rounded-xl overflow-hidden border-2 border-border hover:border-brand-primary transition-colors shadow-sm">
|
||||||
<img src={att.url} alt={att.original_name || `Image ${idx + 1}`} className="w-full h-64 object-cover" />
|
<img src={att.url} alt={att.original_name || `Image ${idx + 1}`} className="w-full h-64 object-cover" loading="lazy" />
|
||||||
{att.original_name && (
|
{att.original_name && (
|
||||||
<div className="bg-surface-secondary px-4 py-2 border-t border-border">
|
<div className="bg-surface-secondary px-4 py-2 border-t border-border">
|
||||||
<p className="text-sm text-text-secondary truncate">{att.original_name}</p>
|
<p className="text-sm text-text-secondary truncate">{att.original_name}</p>
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ export default function PublicReview() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-white">{t('review.contentReview')}</h1>
|
<h1 className="text-2xl font-bold text-white">{t('review.contentReview')}</h1>
|
||||||
<p className="text-white/80 text-sm">Samaya Digital Hub</p>
|
<p className="text-white/80 text-sm">Rawaj</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -281,6 +281,7 @@ export default function PublicReview() {
|
|||||||
src={att.url}
|
src={att.url}
|
||||||
alt={att.original_name || `Design ${idx + 1}`}
|
alt={att.original_name || `Design ${idx + 1}`}
|
||||||
className="w-full h-64 object-cover"
|
className="w-full h-64 object-cover"
|
||||||
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
{att.original_name && (
|
{att.original_name && (
|
||||||
<div className="bg-surface-secondary px-4 py-2 border-t border-border">
|
<div className="bg-surface-secondary px-4 py-2 border-t border-border">
|
||||||
@@ -354,6 +355,7 @@ export default function PublicReview() {
|
|||||||
src={att.url}
|
src={att.url}
|
||||||
alt={att.original_name}
|
alt={att.original_name}
|
||||||
className="w-full h-48 object-cover"
|
className="w-full h-48 object-cover"
|
||||||
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
<div className="bg-surface-secondary px-3 py-2 border-t border-border">
|
<div className="bg-surface-secondary px-3 py-2 border-t border-border">
|
||||||
<p className="text-xs text-text-secondary truncate">{att.original_name}</p>
|
<p className="text-xs text-text-secondary truncate">{att.original_name}</p>
|
||||||
|
|||||||
@@ -350,7 +350,7 @@ export default function PublicTranslationReview() {
|
|||||||
value={suggestionContent}
|
value={suggestionContent}
|
||||||
onChange={e => setSuggestionContent(e.target.value)}
|
onChange={e => setSuggestionContent(e.target.value)}
|
||||||
placeholder={t('translations.enterSuggestion')}
|
placeholder={t('translations.enterSuggestion')}
|
||||||
className="w-full px-3 py-2 text-sm border border-amber-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-amber-400/30 min-h-[80px] resize-y bg-white"
|
className="w-full px-3 py-2 text-sm border border-amber-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-amber-400/30 min-h-[80px] resize-y bg-surface"
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center gap-2 mt-2">
|
<div className="flex items-center gap-2 mt-2">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Link, useSearchParams } from 'react-router-dom'
|
import { Link, useSearchParams } from 'react-router-dom'
|
||||||
import { useLanguage } from '../i18n/LanguageContext'
|
import { useLanguage } from '../i18n/LanguageContext'
|
||||||
import { Megaphone, Lock, AlertCircle, CheckCircle, ArrowLeft } from 'lucide-react'
|
import { Lock, AlertCircle, CheckCircle, ArrowLeft } from 'lucide-react'
|
||||||
import { api } from '../utils/api'
|
import { api } from '../utils/api'
|
||||||
|
|
||||||
|
function MarkaLogo({ className = '' }) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 32 32" fill="none" className={className}>
|
||||||
|
<path d="M4 26V6l10 10L4 26z" fill="currentColor" opacity="0.85" />
|
||||||
|
<path d="M18 26V6l10 10-10 10z" fill="currentColor" opacity="0.5" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function ResetPassword() {
|
export default function ResetPassword() {
|
||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
const [searchParams] = useSearchParams()
|
const [searchParams] = useSearchParams()
|
||||||
@@ -16,7 +25,7 @@ export default function ResetPassword() {
|
|||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-blue-900 to-slate-900 flex items-center justify-center px-4">
|
<div className="min-h-screen bg-slate-900 flex items-center justify-center px-4">
|
||||||
<div className="w-full max-w-md text-center">
|
<div className="w-full max-w-md text-center">
|
||||||
<div className="bg-slate-800/50 backdrop-blur-sm rounded-2xl border border-slate-700/50 p-8 shadow-2xl">
|
<div className="bg-slate-800/50 backdrop-blur-sm rounded-2xl border border-slate-700/50 p-8 shadow-2xl">
|
||||||
<AlertCircle className="w-12 h-12 text-red-400 mx-auto mb-4" />
|
<AlertCircle className="w-12 h-12 text-red-400 mx-auto mb-4" />
|
||||||
@@ -51,11 +60,11 @@ export default function ResetPassword() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-blue-900 to-slate-900 flex items-center justify-center px-4">
|
<div className="min-h-screen bg-slate-900 flex items-center justify-center px-4">
|
||||||
<div className="w-full max-w-md">
|
<div className="w-full max-w-md">
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
<div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-purple-600 rounded-2xl flex items-center justify-center mx-auto mb-4 shadow-lg">
|
<div className="w-16 h-16 bg-brand-primary rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||||
<Megaphone className="w-8 h-8 text-white" />
|
<MarkaLogo className="w-9 h-9 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-3xl font-bold text-white mb-2">{t('resetPassword.title')}</h1>
|
<h1 className="text-3xl font-bold text-white mb-2">{t('resetPassword.title')}</h1>
|
||||||
<p className="text-slate-400">{t('resetPassword.subtitle')}</p>
|
<p className="text-slate-400">{t('resetPassword.subtitle')}</p>
|
||||||
@@ -81,12 +90,12 @@ export default function ResetPassword() {
|
|||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-300 mb-2">{t('resetPassword.newPassword')}</label>
|
<label className="block text-sm font-medium text-slate-300 mb-2">{t('resetPassword.newPassword')}</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
<Lock className="absolute start-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
className="w-full ps-11 pe-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
required
|
required
|
||||||
minLength={6}
|
minLength={6}
|
||||||
@@ -98,12 +107,12 @@ export default function ResetPassword() {
|
|||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-300 mb-2">{t('resetPassword.confirmPassword')}</label>
|
<label className="block text-sm font-medium text-slate-300 mb-2">{t('resetPassword.confirmPassword')}</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
<Lock className="absolute start-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={confirm}
|
value={confirm}
|
||||||
onChange={(e) => setConfirm(e.target.value)}
|
onChange={(e) => setConfirm(e.target.value)}
|
||||||
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
className="w-full ps-11 pe-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
required
|
required
|
||||||
minLength={6}
|
minLength={6}
|
||||||
@@ -121,7 +130,7 @@ export default function ResetPassword() {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="w-full py-3 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
className="w-full py-3 bg-brand-primary hover:bg-brand-primary-light text-white font-semibold rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<span className="flex items-center justify-center gap-2">
|
<span className="flex items-center justify-center gap-2">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useContext } from 'react'
|
import { useState, useEffect, useContext } from 'react'
|
||||||
import { Settings as SettingsIcon, Play, CheckCircle, Languages, Coins, Upload, Tag, Plus, Pencil, Trash2, X } from 'lucide-react'
|
import { Settings as SettingsIcon, Play, CheckCircle, Languages, Coins, Upload, Tag, Plus, Pencil, Trash2, X, Mail } from 'lucide-react'
|
||||||
import { api } from '../utils/api'
|
import { api } from '../utils/api'
|
||||||
import { useLanguage } from '../i18n/LanguageContext'
|
import { useLanguage } from '../i18n/LanguageContext'
|
||||||
import { useToast } from '../components/ToastContainer'
|
import { useToast } from '../components/ToastContainer'
|
||||||
@@ -23,9 +23,15 @@ export default function Settings() {
|
|||||||
const [maxSizeMB, setMaxSizeMB] = useState(50)
|
const [maxSizeMB, setMaxSizeMB] = useState(50)
|
||||||
const [sizeSaving, setSizeSaving] = useState(false)
|
const [sizeSaving, setSizeSaving] = useState(false)
|
||||||
const [sizeSaved, setSizeSaved] = useState(false)
|
const [sizeSaved, setSizeSaved] = useState(false)
|
||||||
|
const [ceoEmail, setCeoEmail] = useState('')
|
||||||
|
const [ceoSaving, setCeoSaving] = useState(false)
|
||||||
|
const [ceoSaved, setCeoSaved] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.get('/settings/app').then(s => setMaxSizeMB(s.uploadMaxSizeMB || 50)).catch(() => {})
|
api.get('/settings/app').then(s => {
|
||||||
|
setMaxSizeMB(s.uploadMaxSizeMB || 50)
|
||||||
|
if (s.ceoEmail) setCeoEmail(s.ceoEmail)
|
||||||
|
}).catch(() => {})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleSaveMaxSize = async () => {
|
const handleSaveMaxSize = async () => {
|
||||||
@@ -65,9 +71,9 @@ export default function Settings() {
|
|||||||
<p className="text-sm text-text-tertiary">{t('settings.preferences')}</p>
|
<p className="text-sm text-text-tertiary">{t('settings.preferences')}</p>
|
||||||
|
|
||||||
{/* General Settings */}
|
{/* General Settings */}
|
||||||
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
<div className="bg-surface rounded-xl border border-border overflow-hidden">
|
||||||
<div className="px-6 py-4 border-b border-border">
|
<div className="px-6 py-4 border-b border-border">
|
||||||
<h2 className="text-lg font-semibold text-text-primary">{t('settings.general')}</h2>
|
<h3 className="font-semibold text-text-primary">{t('settings.general')}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-6 space-y-4">
|
<div className="p-6 space-y-4">
|
||||||
{/* Language Selector */}
|
{/* Language Selector */}
|
||||||
@@ -79,7 +85,7 @@ export default function Settings() {
|
|||||||
<select
|
<select
|
||||||
value={lang}
|
value={lang}
|
||||||
onChange={(e) => setLang(e.target.value)}
|
onChange={(e) => setLang(e.target.value)}
|
||||||
className="w-full max-w-xs px-4 py-2.5 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-white"
|
className="w-full max-w-xs px-4 py-2.5 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface"
|
||||||
>
|
>
|
||||||
<option value="en">{t('settings.english')}</option>
|
<option value="en">{t('settings.english')}</option>
|
||||||
<option value="ar">{t('settings.arabic')}</option>
|
<option value="ar">{t('settings.arabic')}</option>
|
||||||
@@ -95,7 +101,7 @@ export default function Settings() {
|
|||||||
<select
|
<select
|
||||||
value={currency}
|
value={currency}
|
||||||
onChange={(e) => setCurrency(e.target.value)}
|
onChange={(e) => setCurrency(e.target.value)}
|
||||||
className="w-full max-w-xs px-4 py-2.5 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-white"
|
className="w-full max-w-xs px-4 py-2.5 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface"
|
||||||
>
|
>
|
||||||
{CURRENCIES.map(c => (
|
{CURRENCIES.map(c => (
|
||||||
<option key={c.code} value={c.code}>
|
<option key={c.code} value={c.code}>
|
||||||
@@ -109,12 +115,12 @@ export default function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Uploads Section */}
|
{/* Uploads Section */}
|
||||||
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
<div className="bg-surface rounded-xl border border-border overflow-hidden">
|
||||||
<div className="px-6 py-4 border-b border-border">
|
<div className="px-6 py-4 border-b border-border">
|
||||||
<h2 className="text-lg font-semibold text-text-primary flex items-center gap-2">
|
<h3 className="font-semibold text-text-primary flex items-center gap-2">
|
||||||
<Upload className="w-5 h-5 text-brand-primary" />
|
<Upload className="w-5 h-5 text-brand-primary" />
|
||||||
{t('settings.uploads')}
|
{t('settings.uploads')}
|
||||||
</h2>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-6 space-y-4">
|
<div className="p-6 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -128,7 +134,7 @@ export default function Settings() {
|
|||||||
max="500"
|
max="500"
|
||||||
value={maxSizeMB}
|
value={maxSizeMB}
|
||||||
onChange={(e) => setMaxSizeMB(Number(e.target.value))}
|
onChange={(e) => setMaxSizeMB(Number(e.target.value))}
|
||||||
className="w-24 px-3 py-2.5 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-white"
|
className="w-24 px-3 py-2.5 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-text-secondary">{t('settings.mb')}</span>
|
<span className="text-sm text-text-secondary">{t('settings.mb')}</span>
|
||||||
<button
|
<button
|
||||||
@@ -147,9 +153,9 @@ export default function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tutorial Section */}
|
{/* Tutorial Section */}
|
||||||
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
<div className="bg-surface rounded-xl border border-border overflow-hidden">
|
||||||
<div className="px-6 py-4 border-b border-border">
|
<div className="px-6 py-4 border-b border-border">
|
||||||
<h2 className="text-lg font-semibold text-text-primary">{t('settings.onboardingTutorial')}</h2>
|
<h3 className="font-semibold text-text-primary">{t('settings.onboardingTutorial')}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-6 space-y-4">
|
<div className="p-6 space-y-4">
|
||||||
<p className="text-sm text-text-secondary">
|
<p className="text-sm text-text-secondary">
|
||||||
@@ -180,6 +186,56 @@ export default function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Budget Approval (Superadmin only) */}
|
||||||
|
{user?.role === 'superadmin' && (
|
||||||
|
<div className="bg-surface rounded-xl border border-border overflow-hidden">
|
||||||
|
<div className="px-6 py-4 border-b border-border">
|
||||||
|
<h3 className="font-semibold text-text-primary flex items-center gap-2">
|
||||||
|
<Mail className="w-5 h-5 text-brand-primary" />
|
||||||
|
{t('settings.budgetApproval') || 'Budget Approval'}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text-primary mb-2">
|
||||||
|
{t('settings.ceoEmail')}
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={ceoEmail}
|
||||||
|
onChange={(e) => setCeoEmail(e.target.value)}
|
||||||
|
placeholder="ceo@company.com"
|
||||||
|
className="flex-1 max-w-sm px-4 py-2.5 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
setCeoSaving(true)
|
||||||
|
setCeoSaved(false)
|
||||||
|
try {
|
||||||
|
await api.patch('/settings/app', { ceoEmail })
|
||||||
|
setCeoSaved(true)
|
||||||
|
setTimeout(() => setCeoSaved(false), 2000)
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.message || t('settings.saveFailed'))
|
||||||
|
} finally {
|
||||||
|
setCeoSaving(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={ceoSaving}
|
||||||
|
className="px-4 py-2.5 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{ceoSaved ? (
|
||||||
|
<span className="flex items-center gap-1.5"><CheckCircle className="w-4 h-4" />{t('settings.saved')}</span>
|
||||||
|
) : ceoSaving ? '...' : t('common.save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-text-tertiary mt-1.5">{t('settings.ceoEmailHint')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Roles Management (Superadmin only) */}
|
{/* Roles Management (Superadmin only) */}
|
||||||
{user?.role === 'superadmin' && <RolesSection roles={roles} loadRoles={loadRoles} t={t} toast={toast} />}
|
{user?.role === 'superadmin' && <RolesSection roles={roles} loadRoles={loadRoles} t={t} toast={toast} />}
|
||||||
</div>
|
</div>
|
||||||
@@ -235,12 +291,12 @@ function RolesSection({ roles, loadRoles, t, toast }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="bg-white dark:bg-surface-primary rounded-xl border border-border overflow-hidden">
|
<div className="bg-surface dark:bg-surface-primary rounded-xl border border-border overflow-hidden">
|
||||||
<div className="px-6 py-4 border-b border-border flex items-center justify-between">
|
<div className="px-6 py-4 border-b border-border flex items-center justify-between">
|
||||||
<h2 className="text-lg font-semibold text-text-primary flex items-center gap-2">
|
<h3 className="font-semibold text-text-primary flex items-center gap-2">
|
||||||
<Tag className="w-5 h-5 text-brand-primary" />
|
<Tag className="w-5 h-5 text-brand-primary" />
|
||||||
{t('settings.roles')}
|
{t('settings.roles')}
|
||||||
</h2>
|
</h3>
|
||||||
<button
|
<button
|
||||||
onClick={openAddModal}
|
onClick={openAddModal}
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors"
|
className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors"
|
||||||
|
|||||||
+23
-23
@@ -325,16 +325,16 @@ export default function Tasks() {
|
|||||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
<div className="relative flex-1 max-w-xs">
|
<div className="relative flex-1 max-w-xs">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
|
<Search className="absolute start-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={e => setSearchQuery(e.target.value)}
|
onChange={e => setSearchQuery(e.target.value)}
|
||||||
placeholder={t('tasks.search')}
|
placeholder={t('tasks.search')}
|
||||||
className="w-full pl-9 pr-3 py-1.5 text-sm border border-border rounded-lg bg-surface-primary text-text-primary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
className="w-full ps-9 pe-3 py-1.5 text-sm border border-border rounded-lg bg-surface-primary text-text-primary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||||
/>
|
/>
|
||||||
{searchQuery && (
|
{searchQuery && (
|
||||||
<button onClick={() => setSearchQuery('')} className="absolute right-2 top-1/2 -translate-y-1/2 p-0.5 rounded text-text-tertiary hover:text-text-primary">
|
<button onClick={() => setSearchQuery('')} className="absolute end-2 top-1/2 -translate-y-1/2 p-0.5 rounded text-text-tertiary hover:text-text-primary">
|
||||||
<X className="w-3.5 h-3.5" />
|
<X className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -350,7 +350,7 @@ export default function Tasks() {
|
|||||||
onClick={() => setViewMode(mode)}
|
onClick={() => setViewMode(mode)}
|
||||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
|
||||||
viewMode === mode
|
viewMode === mode
|
||||||
? 'bg-white text-text-primary shadow-sm'
|
? 'bg-surface text-text-primary shadow-sm'
|
||||||
: 'text-text-tertiary hover:text-text-secondary'
|
: 'text-text-tertiary hover:text-text-secondary'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -399,7 +399,7 @@ export default function Tasks() {
|
|||||||
<select
|
<select
|
||||||
value={filterProject}
|
value={filterProject}
|
||||||
onChange={e => setFilterProject(e.target.value)}
|
onChange={e => setFilterProject(e.target.value)}
|
||||||
className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-white text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
|
className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
|
||||||
>
|
>
|
||||||
<option value="">{t('tasks.allProjects')}</option>
|
<option value="">{t('tasks.allProjects')}</option>
|
||||||
{taskProjects.map(p => (
|
{taskProjects.map(p => (
|
||||||
@@ -411,7 +411,7 @@ export default function Tasks() {
|
|||||||
<select
|
<select
|
||||||
value={filterBrand}
|
value={filterBrand}
|
||||||
onChange={e => setFilterBrand(e.target.value)}
|
onChange={e => setFilterBrand(e.target.value)}
|
||||||
className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-white text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
|
className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
|
||||||
>
|
>
|
||||||
<option value="">{t('tasks.allBrands')}</option>
|
<option value="">{t('tasks.allBrands')}</option>
|
||||||
{taskBrands.map(b => (
|
{taskBrands.map(b => (
|
||||||
@@ -440,7 +440,7 @@ export default function Tasks() {
|
|||||||
className={`px-2.5 py-1 text-[11px] font-medium rounded-full border transition-colors ${
|
className={`px-2.5 py-1 text-[11px] font-medium rounded-full border transition-colors ${
|
||||||
active
|
active
|
||||||
? 'bg-brand-primary/10 border-brand-primary/20 text-brand-primary'
|
? 'bg-brand-primary/10 border-brand-primary/20 text-brand-primary'
|
||||||
: 'bg-white border-border text-text-tertiary'
|
: 'bg-surface border-border text-text-tertiary'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{t(`tasks.${s}`)}
|
{t(`tasks.${s}`)}
|
||||||
@@ -453,7 +453,7 @@ export default function Tasks() {
|
|||||||
<select
|
<select
|
||||||
value={filterPriority}
|
value={filterPriority}
|
||||||
onChange={e => setFilterPriority(e.target.value)}
|
onChange={e => setFilterPriority(e.target.value)}
|
||||||
className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-white text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
|
className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
|
||||||
>
|
>
|
||||||
<option value="">{t('tasks.allPriorities')}</option>
|
<option value="">{t('tasks.allPriorities')}</option>
|
||||||
<option value="low">{t('tasks.priority.low')}</option>
|
<option value="low">{t('tasks.priority.low')}</option>
|
||||||
@@ -466,7 +466,7 @@ export default function Tasks() {
|
|||||||
<select
|
<select
|
||||||
value={filterAssignee}
|
value={filterAssignee}
|
||||||
onChange={e => setFilterAssignee(e.target.value)}
|
onChange={e => setFilterAssignee(e.target.value)}
|
||||||
className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-white text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
|
className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
|
||||||
>
|
>
|
||||||
<option value="">{t('tasks.allAssignees')}</option>
|
<option value="">{t('tasks.allAssignees')}</option>
|
||||||
{(assignableUsers || []).map(m => (
|
{(assignableUsers || []).map(m => (
|
||||||
@@ -479,7 +479,7 @@ export default function Tasks() {
|
|||||||
<select
|
<select
|
||||||
value={filterCreator}
|
value={filterCreator}
|
||||||
onChange={e => setFilterCreator(e.target.value)}
|
onChange={e => setFilterCreator(e.target.value)}
|
||||||
className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-white text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
|
className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
|
||||||
>
|
>
|
||||||
<option value="">{t('tasks.allCreators')}</option>
|
<option value="">{t('tasks.allCreators')}</option>
|
||||||
{users.map(m => (
|
{users.map(m => (
|
||||||
@@ -501,7 +501,7 @@ export default function Tasks() {
|
|||||||
type="date"
|
type="date"
|
||||||
value={filterDateFrom}
|
value={filterDateFrom}
|
||||||
onChange={e => { setFilterDateFrom(e.target.value); setActivePreset('') }}
|
onChange={e => { setFilterDateFrom(e.target.value); setActivePreset('') }}
|
||||||
className="px-2 py-1.5 text-xs border border-border rounded-lg bg-white text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
|
className="px-2 py-1.5 text-xs border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
|
||||||
title={t('posts.periodFrom')}
|
title={t('posts.periodFrom')}
|
||||||
/>
|
/>
|
||||||
<span className="text-text-tertiary text-xs">-</span>
|
<span className="text-text-tertiary text-xs">-</span>
|
||||||
@@ -509,7 +509,7 @@ export default function Tasks() {
|
|||||||
type="date"
|
type="date"
|
||||||
value={filterDateTo}
|
value={filterDateTo}
|
||||||
onChange={e => { setFilterDateTo(e.target.value); setActivePreset('') }}
|
onChange={e => { setFilterDateTo(e.target.value); setActivePreset('') }}
|
||||||
className="px-2 py-1.5 text-xs border border-border rounded-lg bg-white text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
|
className="px-2 py-1.5 text-xs border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
|
||||||
title={t('posts.periodTo')}
|
title={t('posts.periodTo')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -520,7 +520,7 @@ export default function Tasks() {
|
|||||||
className={`px-2.5 py-1 text-[11px] font-medium rounded-full border transition-colors ${
|
className={`px-2.5 py-1 text-[11px] font-medium rounded-full border transition-colors ${
|
||||||
filterOverdue
|
filterOverdue
|
||||||
? 'bg-red-50 border-red-200 text-red-600'
|
? 'bg-red-50 border-red-200 text-red-600'
|
||||||
: 'bg-white border-border text-text-tertiary'
|
: 'bg-surface border-border text-text-tertiary'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{t('tasks.overdue')}
|
{t('tasks.overdue')}
|
||||||
@@ -599,7 +599,7 @@ export default function Tasks() {
|
|||||||
onDelete={() => setShowBulkDeleteConfirm(true)}
|
onDelete={() => setShowBulkDeleteConfirm(true)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
<div className="bg-surface rounded-xl border border-border overflow-hidden">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-border bg-surface-secondary/50">
|
<tr className="border-b border-border bg-surface-secondary/50">
|
||||||
@@ -614,28 +614,28 @@ export default function Tasks() {
|
|||||||
</th>
|
</th>
|
||||||
<th className="w-8 px-3 py-2.5"></th>
|
<th className="w-8 px-3 py-2.5"></th>
|
||||||
<th
|
<th
|
||||||
className="text-left px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase cursor-pointer hover:text-text-primary"
|
className="text-start px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase cursor-pointer hover:text-text-primary"
|
||||||
onClick={() => toggleSort('title')}
|
onClick={() => toggleSort('title')}
|
||||||
>
|
>
|
||||||
{t('tasks.taskTitle')} {sortBy === 'title' && (sortDir === 'asc' ? '↑' : '↓')}
|
{t('tasks.taskTitle')} {sortBy === 'title' && (sortDir === 'asc' ? '↑' : '↓')}
|
||||||
</th>
|
</th>
|
||||||
<th className="text-left px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase">{t('tasks.project')}</th>
|
<th className="text-start px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase">{t('tasks.project')}</th>
|
||||||
<th className="text-left px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase">{t('tasks.brand')}</th>
|
<th className="text-start px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase">{t('tasks.brand')}</th>
|
||||||
<th
|
<th
|
||||||
className="text-left px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase cursor-pointer hover:text-text-primary"
|
className="text-start px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase cursor-pointer hover:text-text-primary"
|
||||||
onClick={() => toggleSort('status')}
|
onClick={() => toggleSort('status')}
|
||||||
>
|
>
|
||||||
{t('tasks.status')} {sortBy === 'status' && (sortDir === 'asc' ? '↑' : '↓')}
|
{t('tasks.status')} {sortBy === 'status' && (sortDir === 'asc' ? '↑' : '↓')}
|
||||||
</th>
|
</th>
|
||||||
<th className="text-left px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase">{t('tasks.assignee')}</th>
|
<th className="text-start px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase">{t('tasks.assignee')}</th>
|
||||||
<th
|
<th
|
||||||
className="text-left px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase cursor-pointer hover:text-text-primary"
|
className="text-start px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase cursor-pointer hover:text-text-primary"
|
||||||
onClick={() => toggleSort('due_date')}
|
onClick={() => toggleSort('due_date')}
|
||||||
>
|
>
|
||||||
{t('tasks.dueDate')} {sortBy === 'due_date' && (sortDir === 'asc' ? '↑' : '↓')}
|
{t('tasks.dueDate')} {sortBy === 'due_date' && (sortDir === 'asc' ? '↑' : '↓')}
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
className="text-left px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase cursor-pointer hover:text-text-primary"
|
className="text-start px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase cursor-pointer hover:text-text-primary"
|
||||||
onClick={() => toggleSort('priority')}
|
onClick={() => toggleSort('priority')}
|
||||||
>
|
>
|
||||||
{t('tasks.priority')} {sortBy === 'priority' && (sortDir === 'asc' ? '↑' : '↓')}
|
{t('tasks.priority')} {sortBy === 'priority' && (sortDir === 'asc' ? '↑' : '↓')}
|
||||||
@@ -651,7 +651,7 @@ export default function Tasks() {
|
|||||||
const brandName = task.brand_name || task.brandName
|
const brandName = task.brand_name || task.brandName
|
||||||
const assignedName = task.assigned_name || task.assignedName
|
const assignedName = task.assigned_name || task.assignedName
|
||||||
const statusLabels = { todo: t('tasks.todo'), in_progress: t('tasks.in_progress'), done: t('tasks.done') }
|
const statusLabels = { todo: t('tasks.todo'), in_progress: t('tasks.in_progress'), done: t('tasks.done') }
|
||||||
const statusColors = { todo: 'bg-gray-100 text-gray-600', in_progress: 'bg-blue-100 text-blue-700', done: 'bg-emerald-100 text-emerald-700' }
|
const statusColors = { todo: 'bg-gray-100 text-text-secondary', in_progress: 'bg-blue-100 text-blue-700', done: 'bg-emerald-100 text-emerald-700' }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
@@ -675,7 +675,7 @@ export default function Tasks() {
|
|||||||
{task.title}
|
{task.title}
|
||||||
</span>
|
</span>
|
||||||
{(task.comment_count || task.commentCount) > 0 && (
|
{(task.comment_count || task.commentCount) > 0 && (
|
||||||
<span className="ml-2 text-[10px] text-text-tertiary">💬 {task.comment_count || task.commentCount}</span>
|
<span className="ms-2 text-[10px] text-text-tertiary">💬 {task.comment_count || task.commentCount}</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2.5 text-text-tertiary text-xs">{projectName || '—'}</td>
|
<td className="px-3 py-2.5 text-text-tertiary text-xs">{projectName || '—'}</td>
|
||||||
|
|||||||
+28
-26
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useContext, useRef } from 'react'
|
import { useState, useEffect, useContext, useRef, useMemo } from 'react'
|
||||||
import { Plus, Users, ArrowLeft, User as UserIcon, Edit2, LayoutGrid, Network, Link2, ChevronDown, Check, X } from 'lucide-react'
|
import { Plus, Users, ArrowLeft, User as UserIcon, Edit2, LayoutGrid, Network, Link2, ChevronDown, Check, X } from 'lucide-react'
|
||||||
import { getInitials } from '../utils/api'
|
import { getInitials } from '../utils/api'
|
||||||
import { AppContext, PERMISSION_LEVELS } from '../App'
|
import { AppContext, PERMISSION_LEVELS } from '../App'
|
||||||
@@ -16,9 +16,9 @@ import { useToast } from '../components/ToastContainer'
|
|||||||
const ALL_MODULES = ['marketing', 'projects', 'finance']
|
const ALL_MODULES = ['marketing', 'projects', 'finance']
|
||||||
const MODULE_LABELS = { marketing: 'Marketing', projects: 'Projects', finance: 'Finance' }
|
const MODULE_LABELS = { marketing: 'Marketing', projects: 'Projects', finance: 'Finance' }
|
||||||
const MODULE_COLORS = {
|
const MODULE_COLORS = {
|
||||||
marketing: { on: 'bg-emerald-100 text-emerald-700 border-emerald-300', off: 'bg-gray-100 text-gray-400 border-gray-200' },
|
marketing: { on: 'bg-emerald-100 text-emerald-700 border-emerald-300', off: 'bg-gray-100 text-text-tertiary border-gray-200' },
|
||||||
projects: { on: 'bg-blue-100 text-blue-700 border-blue-300', off: 'bg-gray-100 text-gray-400 border-gray-200' },
|
projects: { on: 'bg-blue-100 text-blue-700 border-blue-300', off: 'bg-gray-100 text-text-tertiary border-gray-200' },
|
||||||
finance: { on: 'bg-amber-100 text-amber-700 border-amber-300', off: 'bg-gray-100 text-gray-400 border-gray-200' },
|
finance: { on: 'bg-amber-100 text-amber-700 border-amber-300', off: 'bg-gray-100 text-text-tertiary border-gray-200' },
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY_MEMBER = {
|
const EMPTY_MEMBER = {
|
||||||
@@ -238,9 +238,11 @@ export default function Team() {
|
|||||||
|
|
||||||
// Member detail view
|
// Member detail view
|
||||||
if (selectedMember) {
|
if (selectedMember) {
|
||||||
const todoCount = memberTasks.filter(t => t.status === 'todo').length
|
const { todoCount, inProgressCount, doneCount } = useMemo(() => ({
|
||||||
const inProgressCount = memberTasks.filter(t => t.status === 'in_progress').length
|
todoCount: memberTasks.filter(t => t.status === 'todo').length,
|
||||||
const doneCount = memberTasks.filter(t => t.status === 'done').length
|
inProgressCount: memberTasks.filter(t => t.status === 'in_progress').length,
|
||||||
|
doneCount: memberTasks.filter(t => t.status === 'done').length,
|
||||||
|
}), [memberTasks])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 animate-fade-in">
|
<div className="space-y-6 animate-fade-in">
|
||||||
@@ -253,7 +255,7 @@ export default function Team() {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Member profile */}
|
{/* Member profile */}
|
||||||
<div className="bg-white rounded-xl border border-border p-6">
|
<div className="bg-surface rounded-xl border border-border p-6">
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<div className={`w-16 h-16 rounded-full bg-gradient-to-br from-indigo-400 to-purple-500 flex items-center justify-center text-white text-xl font-bold`}>
|
<div className={`w-16 h-16 rounded-full bg-gradient-to-br from-indigo-400 to-purple-500 flex items-center justify-center text-white text-xl font-bold`}>
|
||||||
{selectedMember.name?.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase()}
|
{selectedMember.name?.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase()}
|
||||||
@@ -281,19 +283,19 @@ export default function Team() {
|
|||||||
|
|
||||||
{/* Workload stats */}
|
{/* Workload stats */}
|
||||||
<div className="grid grid-cols-4 gap-4">
|
<div className="grid grid-cols-4 gap-4">
|
||||||
<div className="bg-white rounded-xl border border-border p-4 text-center">
|
<div className="bg-surface rounded-xl border border-border p-4 text-center">
|
||||||
<p className="text-2xl font-bold text-text-primary">{memberTasks.length}</p>
|
<p className="text-2xl font-bold text-text-primary">{memberTasks.length}</p>
|
||||||
<p className="text-xs text-text-tertiary">{t('team.totalTasks')}</p>
|
<p className="text-xs text-text-tertiary">{t('team.totalTasks')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white rounded-xl border border-border p-4 text-center">
|
<div className="bg-surface rounded-xl border border-border p-4 text-center">
|
||||||
<p className="text-2xl font-bold text-amber-500">{todoCount}</p>
|
<p className="text-2xl font-bold text-amber-500">{todoCount}</p>
|
||||||
<p className="text-xs text-text-tertiary">{t('team.toDo')}</p>
|
<p className="text-xs text-text-tertiary">{t('team.toDo')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white rounded-xl border border-border p-4 text-center">
|
<div className="bg-surface rounded-xl border border-border p-4 text-center">
|
||||||
<p className="text-2xl font-bold text-blue-500">{inProgressCount}</p>
|
<p className="text-2xl font-bold text-blue-500">{inProgressCount}</p>
|
||||||
<p className="text-xs text-text-tertiary">{t('team.inProgress')}</p>
|
<p className="text-xs text-text-tertiary">{t('team.inProgress')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white rounded-xl border border-border p-4 text-center">
|
<div className="bg-surface rounded-xl border border-border p-4 text-center">
|
||||||
<p className="text-2xl font-bold text-emerald-500">{doneCount}</p>
|
<p className="text-2xl font-bold text-emerald-500">{doneCount}</p>
|
||||||
<p className="text-xs text-text-tertiary">{t('tasks.done')}</p>
|
<p className="text-xs text-text-tertiary">{t('tasks.done')}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -302,7 +304,7 @@ export default function Team() {
|
|||||||
{/* Tasks & Posts */}
|
{/* Tasks & Posts */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
{/* Tasks */}
|
{/* Tasks */}
|
||||||
<div className="bg-white rounded-xl border border-border">
|
<div className="bg-surface rounded-xl border border-border">
|
||||||
<div className="px-5 py-4 border-b border-border">
|
<div className="px-5 py-4 border-b border-border">
|
||||||
<h3 className="font-semibold text-text-primary">{t('tasks.title')} ({memberTasks.length})</h3>
|
<h3 className="font-semibold text-text-primary">{t('tasks.title')} ({memberTasks.length})</h3>
|
||||||
</div>
|
</div>
|
||||||
@@ -327,7 +329,7 @@ export default function Team() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Posts */}
|
{/* Posts */}
|
||||||
<div className="bg-white rounded-xl border border-border">
|
<div className="bg-surface rounded-xl border border-border">
|
||||||
<div className="px-5 py-4 border-b border-border">
|
<div className="px-5 py-4 border-b border-border">
|
||||||
<h3 className="font-semibold text-text-primary">{t('nav.posts')} ({memberPosts.length})</h3>
|
<h3 className="font-semibold text-text-primary">{t('nav.posts')} ({memberPosts.length})</h3>
|
||||||
</div>
|
</div>
|
||||||
@@ -394,7 +396,7 @@ export default function Team() {
|
|||||||
{displayedMembers.length} {displayedMembers.length !== 1 ? t('team.membersPlural') : t('team.member')}
|
{displayedMembers.length} {displayedMembers.length !== 1 ? t('team.membersPlural') : t('team.member')}
|
||||||
</p>
|
</p>
|
||||||
{/* View toggle */}
|
{/* View toggle */}
|
||||||
<div className="flex items-center bg-white border border-border rounded-lg overflow-hidden">
|
<div className="flex items-center bg-surface border border-border rounded-lg overflow-hidden">
|
||||||
<button
|
<button
|
||||||
onClick={() => setViewMode('grid')}
|
onClick={() => setViewMode('grid')}
|
||||||
className={`p-2 transition-colors ${viewMode === 'grid' ? 'bg-brand-primary text-white' : 'text-text-tertiary hover:text-text-primary'}`}
|
className={`p-2 transition-colors ${viewMode === 'grid' ? 'bg-brand-primary text-white' : 'text-text-tertiary hover:text-text-primary'}`}
|
||||||
@@ -415,7 +417,7 @@ export default function Team() {
|
|||||||
{/* Copy generic issue link */}
|
{/* Copy generic issue link */}
|
||||||
<button
|
<button
|
||||||
onClick={() => copyIssueLink()}
|
onClick={() => copyIssueLink()}
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-white border border-border text-text-primary rounded-lg text-sm font-medium hover:bg-surface-secondary transition-colors"
|
className="flex items-center gap-2 px-4 py-2 bg-surface border border-border text-text-primary rounded-lg text-sm font-medium hover:bg-surface-secondary transition-colors"
|
||||||
title={t('team.copyGenericIssueLink')}
|
title={t('team.copyGenericIssueLink')}
|
||||||
>
|
>
|
||||||
<Link2 className="w-4 h-4" />
|
<Link2 className="w-4 h-4" />
|
||||||
@@ -428,7 +430,7 @@ export default function Team() {
|
|||||||
const self = teamMembers.find(m => m._id === user?.id || m.id === user?.id)
|
const self = teamMembers.find(m => m._id === user?.id || m.id === user?.id)
|
||||||
if (self) openEdit(self)
|
if (self) openEdit(self)
|
||||||
}}
|
}}
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-white border border-border text-text-primary rounded-lg text-sm font-medium hover:bg-surface-secondary transition-colors"
|
className="flex items-center gap-2 px-4 py-2 bg-surface border border-border text-text-primary rounded-lg text-sm font-medium hover:bg-surface-secondary transition-colors"
|
||||||
>
|
>
|
||||||
<UserIcon className="w-4 h-4" />
|
<UserIcon className="w-4 h-4" />
|
||||||
{t('team.myProfile')}
|
{t('team.myProfile')}
|
||||||
@@ -438,7 +440,7 @@ export default function Team() {
|
|||||||
{canManageTeam && (
|
{canManageTeam && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setPanelTeam({})}
|
onClick={() => setPanelTeam({})}
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-white border border-border text-text-primary rounded-lg text-sm font-medium hover:bg-surface-secondary transition-colors"
|
className="flex items-center gap-2 px-4 py-2 bg-surface border border-border text-text-primary rounded-lg text-sm font-medium hover:bg-surface-secondary transition-colors"
|
||||||
>
|
>
|
||||||
<Users className="w-4 h-4" />
|
<Users className="w-4 h-4" />
|
||||||
{t('teams.createTeam')}
|
{t('teams.createTeam')}
|
||||||
@@ -468,7 +470,7 @@ export default function Team() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setTeamFilter(null)}
|
onClick={() => setTeamFilter(null)}
|
||||||
className={`text-xs px-3 py-1.5 rounded-full border font-medium transition-colors ${
|
className={`text-xs px-3 py-1.5 rounded-full border font-medium transition-colors ${
|
||||||
!teamFilter ? 'bg-brand-primary text-white border-brand-primary' : 'bg-white text-text-secondary border-border hover:bg-surface-tertiary'
|
!teamFilter ? 'bg-brand-primary text-white border-brand-primary' : 'bg-surface text-text-secondary border-border hover:bg-surface-tertiary'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{t('common.all')}
|
{t('common.all')}
|
||||||
@@ -481,7 +483,7 @@ export default function Team() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setTeamFilter(active ? null : tid)}
|
onClick={() => setTeamFilter(active ? null : tid)}
|
||||||
className={`text-xs px-3 py-1.5 rounded-full border font-medium transition-colors ${
|
className={`text-xs px-3 py-1.5 rounded-full border font-medium transition-colors ${
|
||||||
active ? 'bg-blue-600 text-white border-blue-600' : 'bg-white text-text-secondary border-border hover:bg-surface-tertiary'
|
active ? 'bg-blue-600 text-white border-blue-600' : 'bg-surface text-text-secondary border-border hover:bg-surface-tertiary'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{team.name} ({team.member_count || 0})
|
{team.name} ({team.member_count || 0})
|
||||||
@@ -531,7 +533,7 @@ export default function Team() {
|
|||||||
const tid = team.id || team._id
|
const tid = team.id || team._id
|
||||||
const members = teamMembers.filter(m => m.teams?.some(t => t.id === tid))
|
const members = teamMembers.filter(m => m.teams?.some(t => t.id === tid))
|
||||||
return (
|
return (
|
||||||
<div key={tid} className="bg-white rounded-xl border border-border overflow-hidden">
|
<div key={tid} className="bg-surface rounded-xl border border-border overflow-hidden">
|
||||||
{/* Team header */}
|
{/* Team header */}
|
||||||
<div className="flex items-center justify-between px-5 py-4 bg-gradient-to-r from-blue-50 to-indigo-50 border-b border-border">
|
<div className="flex items-center justify-between px-5 py-4 bg-gradient-to-r from-blue-50 to-indigo-50 border-b border-border">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -601,7 +603,7 @@ export default function Team() {
|
|||||||
|
|
||||||
{/* Unassigned members */}
|
{/* Unassigned members */}
|
||||||
{unassignedMembers.length > 0 && (
|
{unassignedMembers.length > 0 && (
|
||||||
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
<div className="bg-surface rounded-xl border border-border overflow-hidden">
|
||||||
<div className="flex items-center gap-3 px-5 py-4 bg-gray-50 border-b border-border">
|
<div className="flex items-center gap-3 px-5 py-4 bg-gray-50 border-b border-border">
|
||||||
<div className="w-10 h-10 rounded-lg bg-gray-400 flex items-center justify-center text-white">
|
<div className="w-10 h-10 rounded-lg bg-gray-400 flex items-center justify-center text-white">
|
||||||
<UserIcon className="w-5 h-5" />
|
<UserIcon className="w-5 h-5" />
|
||||||
@@ -707,7 +709,7 @@ export default function Team() {
|
|||||||
<div ref={addBrandsRef} className="relative">
|
<div ref={addBrandsRef} className="relative">
|
||||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.brands')}</label>
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.brands')}</label>
|
||||||
<button type="button" onClick={() => setShowAddBrandsDropdown(p => !p)}
|
<button type="button" onClick={() => setShowAddBrandsDropdown(p => !p)}
|
||||||
className="w-full flex items-center justify-between px-3 py-2 text-sm border border-border rounded-lg bg-white text-left focus:outline-none focus:ring-2 focus:ring-brand-primary/20">
|
className="w-full flex items-center justify-between px-3 py-2 text-sm border border-border rounded-lg bg-surface text-start focus:outline-none focus:ring-2 focus:ring-brand-primary/20">
|
||||||
<span className={`flex-1 truncate ${addForm.brands.length === 0 ? 'text-text-tertiary' : 'text-text-primary'}`}>
|
<span className={`flex-1 truncate ${addForm.brands.length === 0 ? 'text-text-tertiary' : 'text-text-primary'}`}>
|
||||||
{addForm.brands.length === 0 ? t('team.selectBrands') : addForm.brands.join(', ')}
|
{addForm.brands.length === 0 ? t('team.selectBrands') : addForm.brands.join(', ')}
|
||||||
</span>
|
</span>
|
||||||
@@ -724,13 +726,13 @@ export default function Team() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{showAddBrandsDropdown && (
|
{showAddBrandsDropdown && (
|
||||||
<div className="absolute z-20 mt-1 w-full bg-white border border-border rounded-lg shadow-lg max-h-48 overflow-y-auto">
|
<div className="absolute z-20 mt-1 w-full bg-surface border border-border rounded-lg shadow-lg max-h-48 overflow-y-auto">
|
||||||
{brands.map(brand => {
|
{brands.map(brand => {
|
||||||
const name = lang === 'ar' && brand.name_ar ? brand.name_ar : brand.name
|
const name = lang === 'ar' && brand.name_ar ? brand.name_ar : brand.name
|
||||||
const checked = addForm.brands.includes(name)
|
const checked = addForm.brands.includes(name)
|
||||||
return (
|
return (
|
||||||
<button key={brand.id || brand._id} type="button" onClick={() => toggleAddBrand(name)}
|
<button key={brand.id || brand._id} type="button" onClick={() => toggleAddBrand(name)}
|
||||||
className={`w-full flex items-center gap-2.5 px-3 py-2 hover:bg-surface-secondary transition-colors text-left ${checked ? 'bg-brand-primary/5' : ''}`}>
|
className={`w-full flex items-center gap-2.5 px-3 py-2 hover:bg-surface-secondary transition-colors text-start ${checked ? 'bg-brand-primary/5' : ''}`}>
|
||||||
<div className={`w-4 h-4 rounded border flex items-center justify-center shrink-0 ${checked ? 'bg-brand-primary border-brand-primary' : 'border-border'}`}>
|
<div className={`w-4 h-4 rounded border flex items-center justify-center shrink-0 ${checked ? 'bg-brand-primary border-brand-primary' : 'border-border'}`}>
|
||||||
{checked && <Check className="w-3 h-3 text-white" />}
|
{checked && <Check className="w-3 h-3 text-white" />}
|
||||||
</div>
|
</div>
|
||||||
@@ -771,7 +773,7 @@ export default function Team() {
|
|||||||
return (
|
return (
|
||||||
<button key={tid} type="button"
|
<button key={tid} type="button"
|
||||||
onClick={() => updateAdd('team_ids', active ? addForm.team_ids.filter(id => id !== tid) : [...addForm.team_ids, tid])}
|
onClick={() => updateAdd('team_ids', active ? addForm.team_ids.filter(id => id !== tid) : [...addForm.team_ids, tid])}
|
||||||
className={`text-xs px-3 py-1.5 rounded-full border font-medium transition-colors ${active ? 'bg-blue-100 text-blue-700 border-blue-300' : 'bg-gray-100 text-gray-400 border-gray-200'}`}>
|
className={`text-xs px-3 py-1.5 rounded-full border font-medium transition-colors ${active ? 'bg-blue-100 text-blue-700 border-blue-300' : 'bg-gray-100 text-text-tertiary border-gray-200'}`}>
|
||||||
{team.name}
|
{team.name}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -189,8 +189,8 @@ export default function Translations() {
|
|||||||
const SortIcon = ({ col }) => {
|
const SortIcon = ({ col }) => {
|
||||||
if (listSortBy !== col) return null
|
if (listSortBy !== col) return null
|
||||||
return listSortDir === 'asc'
|
return listSortDir === 'asc'
|
||||||
? <ChevronUp className="w-3 h-3 inline ml-0.5" />
|
? <ChevronUp className="w-3 h-3 inline ms-0.5" />
|
||||||
: <ChevronDown className="w-3 h-3 inline ml-0.5" />
|
: <ChevronDown className="w-3 h-3 inline ms-0.5" />
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatDate = (dateStr) => {
|
const formatDate = (dateStr) => {
|
||||||
@@ -219,7 +219,7 @@ export default function Translations() {
|
|||||||
onClick={() => setViewMode(mode)}
|
onClick={() => setViewMode(mode)}
|
||||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
|
||||||
viewMode === mode
|
viewMode === mode
|
||||||
? 'bg-white text-text-primary shadow-sm'
|
? 'bg-surface text-text-primary shadow-sm'
|
||||||
: 'text-text-tertiary hover:text-text-secondary'
|
: 'text-text-tertiary hover:text-text-secondary'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -242,13 +242,13 @@ export default function Translations() {
|
|||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<div className="relative flex-1 min-w-[200px] max-w-md">
|
<div className="relative flex-1 min-w-[200px] max-w-md">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
|
<Search className="absolute start-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={t('translations.searchTranslations')}
|
placeholder={t('translations.searchTranslations')}
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={e => setSearchTerm(e.target.value)}
|
onChange={e => setSearchTerm(e.target.value)}
|
||||||
className="w-full pl-10 pr-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface transition-colors"
|
className="w-full ps-10 pe-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface transition-colors"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -356,22 +356,22 @@ export default function Translations() {
|
|||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-border bg-surface-secondary">
|
<tr className="border-b border-border bg-surface-secondary">
|
||||||
<th className="px-4 py-3 text-left w-10">
|
<th className="px-4 py-3 text-start w-10">
|
||||||
<input type="checkbox" checked={selectedIds.size === sortedTranslations.length && sortedTranslations.length > 0} onChange={toggleSelectAll} className="rounded border-border" />
|
<input type="checkbox" checked={selectedIds.size === sortedTranslations.length && sortedTranslations.length > 0} onChange={toggleSelectAll} className="rounded border-border" />
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer" onClick={() => toggleListSort('title')}>
|
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase cursor-pointer" onClick={() => toggleListSort('title')}>
|
||||||
{t('translations.titleLabel')} <SortIcon col="title" />
|
{t('translations.titleLabel')} <SortIcon col="title" />
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">
|
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">
|
||||||
{t('translations.sourceLanguage')}
|
{t('translations.sourceLanguage')}
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer" onClick={() => toggleListSort('status')}>
|
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase cursor-pointer" onClick={() => toggleListSort('status')}>
|
||||||
{t('translations.status')} <SortIcon col="status" />
|
{t('translations.status')} <SortIcon col="status" />
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('translations.brand')}</th>
|
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('translations.brand')}</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('translations.creator')}</th>
|
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('translations.creator')}</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('translations.languagesLabel')}</th>
|
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('translations.languagesLabel')}</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer" onClick={() => toggleListSort('updated_at')}>
|
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase cursor-pointer" onClick={() => toggleListSort('updated_at')}>
|
||||||
{t('translations.updated')} <SortIcon col="updated_at" />
|
{t('translations.updated')} <SortIcon col="updated_at" />
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -0,0 +1,635 @@
|
|||||||
|
# Budget Allocation Redesign — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Replace the dual budget system with a single source of truth (BudgetEntries), add validation at all levels, and implement a CEO approval workflow for new income.
|
||||||
|
|
||||||
|
**Architecture:** BudgetEntries table is the only source for all budget calculations. Campaign/project allocations are income entries with a FK set. A new BudgetRequests table + public approval page handles CEO approval for new income. Budget mutex prevents race conditions.
|
||||||
|
|
||||||
|
**Tech Stack:** Express.js (server), React (client), NocoDB (database), nodemailer (emails)
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-03-15-budget-allocation-redesign.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chunk 1: Server — Budget Model Fix + Validation
|
||||||
|
|
||||||
|
### Task 1: Add budget mutex utility
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `server/budget-mutex.js`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the mutex module**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// server/budget-mutex.js
|
||||||
|
let _lock = null;
|
||||||
|
|
||||||
|
async function acquireBudgetLock() {
|
||||||
|
while (_lock) await _lock;
|
||||||
|
let resolve;
|
||||||
|
_lock = new Promise(r => { resolve = r; });
|
||||||
|
return () => { _lock = null; resolve(); };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { acquireBudgetLock };
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add server/budget-mutex.js
|
||||||
|
git commit -m "feat: add budget mutex for race condition prevention"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 2: Add budget availability helper
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `server/budget-helpers.js`
|
||||||
|
|
||||||
|
This module computes `mainAvailable` and `campaignAvailable` from BudgetEntries — the single source of truth. Every route that modifies budget will call these.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the helper module**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// server/budget-helpers.js
|
||||||
|
const nocodb = require('./nocodb');
|
||||||
|
const QUERY_LIMITS = { max: 10000 };
|
||||||
|
|
||||||
|
async function getMainAvailable() {
|
||||||
|
const entries = await nocodb.list('BudgetEntries', { limit: QUERY_LIMITS.max });
|
||||||
|
const income = entries.filter(e => (e.type || 'income') === 'income');
|
||||||
|
const expenses = entries.filter(e => e.type === 'expense');
|
||||||
|
|
||||||
|
const totalReceived = income.reduce((s, e) => s + (e.amount || 0), 0);
|
||||||
|
const totalExpenses = expenses.reduce((s, e) => s + (e.amount || 0), 0);
|
||||||
|
const totalCampaignBudget = income.filter(e => e.campaign_id).reduce((s, e) => s + (e.amount || 0), 0);
|
||||||
|
const totalProjectBudget = income.filter(e => e.project_id).reduce((s, e) => s + (e.amount || 0), 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalReceived,
|
||||||
|
totalExpenses,
|
||||||
|
totalCampaignBudget,
|
||||||
|
totalProjectBudget,
|
||||||
|
available: totalReceived - totalExpenses - totalCampaignBudget - totalProjectBudget,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCampaignAvailable(campaignId) {
|
||||||
|
const entries = await nocodb.list('BudgetEntries', {
|
||||||
|
where: `(campaign_id,eq,${campaignId})~and(type,neq,expense)`,
|
||||||
|
limit: QUERY_LIMITS.max,
|
||||||
|
});
|
||||||
|
const allocated = entries.reduce((s, e) => s + (e.amount || 0), 0);
|
||||||
|
|
||||||
|
const tracks = await nocodb.list('CampaignTracks', {
|
||||||
|
where: `(campaign_id,eq,${campaignId})`,
|
||||||
|
limit: QUERY_LIMITS.max,
|
||||||
|
});
|
||||||
|
const trackAllocated = tracks.reduce((s, t) => s + (t.budget_allocated || 0), 0);
|
||||||
|
|
||||||
|
return { allocated, trackAllocated, available: allocated - trackAllocated };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCampaignAllocatedFromEntries(campaignId) {
|
||||||
|
const entries = await nocodb.list('BudgetEntries', {
|
||||||
|
where: `(campaign_id,eq,${campaignId})~and(type,neq,expense)`,
|
||||||
|
limit: QUERY_LIMITS.max,
|
||||||
|
});
|
||||||
|
return entries.reduce((s, e) => s + (e.amount || 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getMainAvailable, getCampaignAvailable, getCampaignAllocatedFromEntries };
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add server/budget-helpers.js
|
||||||
|
git commit -m "feat: add budget availability helpers (single source of truth)"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 3: Fix finance summary endpoint
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `server/server.js` — the `GET /api/finance/summary` handler (~lines 2405-2488)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Rewrite the finance summary to use BudgetEntries only**
|
||||||
|
|
||||||
|
Replace the entire handler body. Key changes:
|
||||||
|
- `totalReceived` = sum of ALL income entries (same for all roles — remove the superadmin/manager fork)
|
||||||
|
- `totalCampaignBudget` = sum of income entries with `campaign_id` set (not `Campaign.budget`)
|
||||||
|
- `remaining` = mainAvailable (no track double-counting)
|
||||||
|
- Keep track aggregations (spent, revenue, impressions) for the campaign breakdown table
|
||||||
|
- Add `mainAvailable` to the response
|
||||||
|
|
||||||
|
The handler still filters by user's campaign access for managers. Managers see a subset of campaigns but the SAME calculation logic.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify build**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd client && npx vite build --logLevel error
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add server/server.js
|
||||||
|
git commit -m "fix: finance summary uses BudgetEntries as single source of truth"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 4: Add budget validation to campaign creation
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `server/server.js` — `POST /api/campaigns` (~line 2097)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add validation + auto-create BudgetEntry**
|
||||||
|
|
||||||
|
In the campaign creation handler, after creating the campaign:
|
||||||
|
1. Import `{ acquireBudgetLock }` from `./budget-mutex`
|
||||||
|
2. Import `{ getMainAvailable }` from `./budget-helpers`
|
||||||
|
3. If `budget > 0`:
|
||||||
|
- Acquire lock
|
||||||
|
- Check `mainAvailable >= budget`
|
||||||
|
- If insufficient: delete the just-created campaign, release lock, return 400
|
||||||
|
- If OK: create BudgetEntry `{ type: 'income', amount: budget, campaign_id: created.Id, label: 'Campaign allocation', source: 'Campaign creation', date_received: new Date().toISOString().slice(0,10) }`
|
||||||
|
- Release lock
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add validation to campaign PATCH for budget changes**
|
||||||
|
|
||||||
|
Modify: `server/server.js` — `PATCH /api/campaigns/:id`
|
||||||
|
|
||||||
|
If `budget` field is being updated:
|
||||||
|
1. Get current allocated = sum of income BudgetEntries for this campaign
|
||||||
|
2. If increasing: check `mainAvailable >= (newBudget - currentAllocated)`
|
||||||
|
3. If decreasing: check `newBudget >= sum(tracks.budget_allocated)` for this campaign
|
||||||
|
4. Update (or create) the BudgetEntry to match new budget amount
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add server/server.js
|
||||||
|
git commit -m "feat: validate campaign budget against main available, auto-create BudgetEntry"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 5: Add budget validation to track creation/edit
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `server/server.js` — `POST /api/campaigns/:id/tracks` (~line 2504) and `PATCH /api/campaigns/:id/tracks/:trackId`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add campaignAvailable check to track POST**
|
||||||
|
|
||||||
|
Before creating the track:
|
||||||
|
1. Import `{ getCampaignAvailable }` from `./budget-helpers`
|
||||||
|
2. If `budget_allocated > 0`: check `campaignAvailable >= budget_allocated`
|
||||||
|
3. If insufficient: return 400 `{ error: 'Insufficient campaign budget', available: campaignAvailable }`
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add same check to track PATCH**
|
||||||
|
|
||||||
|
If `budget_allocated` is being updated:
|
||||||
|
1. Get current track's `budget_allocated`
|
||||||
|
2. Delta = newAmount - currentAmount
|
||||||
|
3. If delta > 0: check `campaignAvailable >= delta`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add server/server.js
|
||||||
|
git commit -m "feat: validate track budget against campaign available"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 6: Add budget validation to expense creation
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `server/server.js` — `POST /api/budget` (~line 2343)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add mainAvailable check for expenses**
|
||||||
|
|
||||||
|
In the budget entry creation handler:
|
||||||
|
1. Validate `amount > 0`
|
||||||
|
2. If `type === 'expense'`: acquire lock, check `mainAvailable >= amount`, release
|
||||||
|
3. If insufficient: return 400
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add server/server.js
|
||||||
|
git commit -m "feat: validate expense entries against available budget"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 7: Handle campaign/project deletion — release budget
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `server/server.js` — `DELETE /api/campaigns/:id` (~line 2174) and `DELETE /api/projects/:id`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Null out BudgetEntry FKs on campaign delete**
|
||||||
|
|
||||||
|
In the campaign delete handler, before deleting the campaign:
|
||||||
|
```javascript
|
||||||
|
// Release budget entries back to main
|
||||||
|
const budgetEntries = await nocodb.list('BudgetEntries', { where: `(campaign_id,eq,${id})`, limit: QUERY_LIMITS.max });
|
||||||
|
for (const e of budgetEntries) await nocodb.update('BudgetEntries', e.Id, { campaign_id: null });
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Same for project delete**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const budgetEntries = await nocodb.list('BudgetEntries', { where: `(project_id,eq,${id})`, limit: QUERY_LIMITS.max });
|
||||||
|
for (const e of budgetEntries) await nocodb.update('BudgetEntries', e.Id, { project_id: null });
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add server/server.js
|
||||||
|
git commit -m "feat: release budget on campaign/project deletion"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 8: Migration — create BudgetEntries for existing campaigns
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `server/server.js` — add migration in `startServer()` function
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add migration after ensureTextColumns**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Migrate Campaign.budget → BudgetEntries (one-time, idempotent)
|
||||||
|
async function migrateCampaignBudgets() {
|
||||||
|
const campaigns = await nocodb.list('Campaigns', { limit: 10000 });
|
||||||
|
const entries = await nocodb.list('BudgetEntries', { limit: 10000 });
|
||||||
|
|
||||||
|
for (const c of campaigns) {
|
||||||
|
if (!c.budget || c.budget <= 0) continue;
|
||||||
|
const existing = entries.find(e => e.campaign_id && Number(e.campaign_id) === c.Id && (e.type || 'income') === 'income');
|
||||||
|
if (existing) continue;
|
||||||
|
|
||||||
|
await nocodb.create('BudgetEntries', {
|
||||||
|
label: `Campaign allocation: ${c.name}`,
|
||||||
|
amount: c.budget,
|
||||||
|
type: 'income',
|
||||||
|
campaign_id: c.Id,
|
||||||
|
source: 'Migrated from Campaign.budget',
|
||||||
|
date_received: c.CreatedAt ? c.CreatedAt.slice(0, 10) : new Date().toISOString().slice(0, 10),
|
||||||
|
category: 'marketing',
|
||||||
|
});
|
||||||
|
console.log(` ✓ Migrated budget $${c.budget} for campaign "${c.name}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Call `await migrateCampaignBudgets()` in `startServer()` after table creation.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add server/server.js
|
||||||
|
git commit -m "feat: migrate Campaign.budget to BudgetEntries (idempotent)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chunk 2: Server — Budget Request Workflow + CEO Approval
|
||||||
|
|
||||||
|
### Task 9: Add BudgetRequests table schema + CEO email setting
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `server/server.js` — REQUIRED_TABLES, TEXT_COLUMNS, appSettings
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add BudgetRequests to REQUIRED_TABLES**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
BudgetRequests: [
|
||||||
|
{ title: 'amount', uidt: 'Decimal' },
|
||||||
|
{ title: 'justification', uidt: 'LongText' },
|
||||||
|
{ title: 'status', uidt: 'SingleLineText' },
|
||||||
|
{ title: 'requested_by_user_id', uidt: 'Number' },
|
||||||
|
{ title: 'approval_token', uidt: 'SingleLineText' },
|
||||||
|
{ title: 'response_note', uidt: 'LongText' },
|
||||||
|
{ title: 'earmarked_campaign_id', uidt: 'Number' },
|
||||||
|
{ title: 'earmarked_project_id', uidt: 'Number' },
|
||||||
|
{ title: 'created_budget_entry_id', uidt: 'Number' },
|
||||||
|
],
|
||||||
|
```
|
||||||
|
|
||||||
|
Add to TEXT_COLUMNS:
|
||||||
|
```javascript
|
||||||
|
BudgetRequests: [
|
||||||
|
{ name: 'token_expires_at', uidt: 'SingleLineText' },
|
||||||
|
{ name: 'resolved_at', uidt: 'SingleLineText' },
|
||||||
|
],
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add ceoEmail to appSettings default**
|
||||||
|
|
||||||
|
In the `defaultSettings` object (wherever `uploadMaxSizeMB` is initialized), add:
|
||||||
|
```javascript
|
||||||
|
ceoEmail: ''
|
||||||
|
```
|
||||||
|
|
||||||
|
In `PATCH /api/settings/app`, add handling for `ceoEmail`:
|
||||||
|
```javascript
|
||||||
|
if (ceoEmail !== undefined) {
|
||||||
|
appSettings.ceoEmail = String(ceoEmail).trim();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add server/server.js
|
||||||
|
git commit -m "feat: add BudgetRequests table schema + ceoEmail setting"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 10: Add budget request CRUD routes
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `server/server.js`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add GET /api/budget-requests**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
app.get('/api/budget-requests', requireAuth, requireRole('superadmin'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const requests = await nocodb.list('BudgetRequests', { limit: 200, sort: '-CreatedAt' });
|
||||||
|
// Enrich with requester name, campaign/project names
|
||||||
|
res.json(requests);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: 'Failed to load budget requests' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add POST /api/budget-requests**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
app.post('/api/budget-requests', requireAuth, requireRole('superadmin'), async (req, res) => {
|
||||||
|
const { amount, justification, earmarked_campaign_id, earmarked_project_id } = req.body;
|
||||||
|
if (!amount || amount <= 0) return res.status(400).json({ error: 'Amount must be positive' });
|
||||||
|
if (!justification?.trim()) return res.status(400).json({ error: 'Justification is required' });
|
||||||
|
|
||||||
|
const ceoEmail = appSettings.ceoEmail;
|
||||||
|
if (!ceoEmail) return res.status(400).json({ error: 'CEO email not configured. Go to Settings.' });
|
||||||
|
|
||||||
|
const token = require('crypto').randomUUID();
|
||||||
|
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const created = await nocodb.create('BudgetRequests', {
|
||||||
|
amount,
|
||||||
|
justification: justification.trim(),
|
||||||
|
status: 'pending',
|
||||||
|
requested_by_user_id: req.session.userId,
|
||||||
|
approval_token: token,
|
||||||
|
token_expires_at: expiresAt,
|
||||||
|
earmarked_campaign_id: earmarked_campaign_id ? Number(earmarked_campaign_id) : null,
|
||||||
|
earmarked_project_id: earmarked_project_id ? Number(earmarked_project_id) : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send email to CEO
|
||||||
|
const appUrl = process.env.APP_URL || process.env.CORS_ORIGIN || 'http://localhost:3001';
|
||||||
|
const approvalUrl = `${appUrl}/approve-budget/${token}`;
|
||||||
|
const requesterName = req.session.userName || 'Team member';
|
||||||
|
// Use notifications.js pattern for email
|
||||||
|
const { sendMail } = require('./mail');
|
||||||
|
await sendMail({
|
||||||
|
to: ceoEmail,
|
||||||
|
subject: `Rawaj — Budget Request: ${amount}`,
|
||||||
|
html: renderBudgetRequestEmail({ amount, requesterName, justification: justification.trim(), approvalUrl }),
|
||||||
|
text: `${requesterName} is requesting ${amount}. Justification: ${justification.trim()}\n\nReview: ${approvalUrl}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json(created);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Budget request error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to create budget request' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `renderBudgetRequestEmail` helper near the route (uses the same branded template pattern as notifications.js).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add PATCH /api/budget-requests/:id/cancel**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
app.patch('/api/budget-requests/:id/cancel', requireAuth, requireRole('superadmin'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const request = await nocodb.get('BudgetRequests', req.params.id);
|
||||||
|
if (!request) return res.status(404).json({ error: 'Not found' });
|
||||||
|
if (request.status !== 'pending') return res.status(400).json({ error: 'Can only cancel pending requests' });
|
||||||
|
await nocodb.update('BudgetRequests', request.Id, { status: 'cancelled', resolved_at: new Date().toISOString() });
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: 'Failed to cancel request' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add server/server.js
|
||||||
|
git commit -m "feat: add budget request CRUD routes"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 11: Add public approval endpoints
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `server/server.js`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add GET /api/budget-approval/:token (public, no auth)**
|
||||||
|
|
||||||
|
Returns request details for the approval page. Validates token exists and hasn't expired.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add POST /api/budget-approval/:token/respond (public, no auth)**
|
||||||
|
|
||||||
|
Body: `{ action: 'approve' | 'reject', note?: string }`
|
||||||
|
|
||||||
|
On approve:
|
||||||
|
1. Check status === 'pending' and token not expired (idempotent: if already approved, return 200 with existing result)
|
||||||
|
2. Auto-create income BudgetEntry with campaign_id/project_id from earmarked fields
|
||||||
|
3. Update request: status='approved', resolved_at=now, created_budget_entry_id=entry.Id
|
||||||
|
4. Send notification email to requester (superadmin)
|
||||||
|
|
||||||
|
On reject:
|
||||||
|
1. Update request: status='rejected', response_note=note, resolved_at=now
|
||||||
|
2. Send notification email to requester
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add notification helpers for budget approval/rejection**
|
||||||
|
|
||||||
|
Add to `server/notifications.js`:
|
||||||
|
- `notifyBudgetApproved({ request, entryId })` — emails the requesting superadmin
|
||||||
|
- `notifyBudgetRejected({ request, note })` — emails the requesting superadmin
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add server/server.js server/notifications.js
|
||||||
|
git commit -m "feat: add public budget approval endpoints + notifications"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chunk 3: Client — Finance Page + Budget Request UI
|
||||||
|
|
||||||
|
### Task 12: Update Finance page to show budget requests
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `client/src/pages/Finance.jsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add budget requests fetch and state**
|
||||||
|
|
||||||
|
Add `budgetRequests` state, fetch from `GET /api/budget-requests` alongside the finance summary.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add "Request Budget" button in header (superadmin only)**
|
||||||
|
|
||||||
|
Next to the page title, show a teal button that opens a modal.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add budget request modal**
|
||||||
|
|
||||||
|
Modal with fields: amount (number input), justification (textarea), optional earmark dropdown (campaign or project). Submit calls `POST /api/budget-requests`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Add budget requests section**
|
||||||
|
|
||||||
|
Below the existing finance sections, add a "Budget Requests" list:
|
||||||
|
- Pending: amber badge, shows cancel button
|
||||||
|
- Approved: green badge, shows linked entry amount
|
||||||
|
- Rejected: red badge, shows CEO's note
|
||||||
|
- Cancelled: gray badge
|
||||||
|
|
||||||
|
If any pending requests exist, show a banner at the top: "N budget request(s) pending CEO approval"
|
||||||
|
|
||||||
|
- [ ] **Step 5: Add i18n keys for budget requests**
|
||||||
|
|
||||||
|
Add to both `en.json` and `ar.json`:
|
||||||
|
- `finance.requestBudget`, `finance.budgetRequests`, `finance.pendingApproval`, `finance.justification`, `finance.earmarkFor`, `finance.submitRequest`, `finance.cancelRequest`, `finance.approved`, `finance.rejected`, `finance.cancelled`, `finance.pending`, `finance.ceoNote`, `finance.requestPending`
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add client/src/pages/Finance.jsx client/src/i18n/en.json client/src/i18n/ar.json
|
||||||
|
git commit -m "feat: budget requests UI on Finance page"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 13: Update Settings page — CEO email field
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `client/src/pages/Settings.jsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add CEO email field in settings (superadmin only)**
|
||||||
|
|
||||||
|
In the settings form, add a section "Budget Approval":
|
||||||
|
- Label: "CEO / Budget Approver Email"
|
||||||
|
- Input: email type, bound to `appSettings.ceoEmail`
|
||||||
|
- Save alongside existing settings via `PATCH /api/settings/app`
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add i18n keys**
|
||||||
|
|
||||||
|
`settings.ceoEmail`, `settings.ceoEmailHint`, `settings.budgetApproval`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add client/src/pages/Settings.jsx client/src/i18n/en.json client/src/i18n/ar.json
|
||||||
|
git commit -m "feat: CEO email setting for budget approval"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 14: Update Dashboard BudgetSummary
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `client/src/pages/Dashboard.jsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update BudgetSummary to use new response shape**
|
||||||
|
|
||||||
|
The finance summary response now has `mainAvailable` instead of computing `remaining` from the old formula. Update the component to use the new field. The `spent` field from tracks is no longer subtracted from main — it lives within campaign allocations.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add client/src/pages/Dashboard.jsx
|
||||||
|
git commit -m "fix: dashboard budget uses new single-source response"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chunk 4: Client — Public Approval Page + Campaign Budget Validation UI
|
||||||
|
|
||||||
|
### Task 15: Create public budget approval page
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `client/src/pages/PublicBudgetApproval.jsx`
|
||||||
|
- Modify: `client/src/App.jsx` — add route `/approve-budget/:token`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the page component**
|
||||||
|
|
||||||
|
Follow the same pattern as `PublicReview.jsx`:
|
||||||
|
1. Fetch request via `GET /api/budget-approval/:token`
|
||||||
|
2. Show: amount, requester name, justification, earmarked for (if set)
|
||||||
|
3. Approve / Reject buttons + optional note textarea
|
||||||
|
4. Submit via `POST /api/budget-approval/:token/respond`
|
||||||
|
5. States: loading, active, success (with approved/rejected message), already-handled, expired, error
|
||||||
|
|
||||||
|
Use the teal brand color for the approve button, red for reject.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add route in App.jsx**
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<Route path="/approve-budget/:token" element={<PublicBudgetApproval />} />
|
||||||
|
```
|
||||||
|
|
||||||
|
Add this alongside other public routes (before the auth-protected layout).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add i18n keys**
|
||||||
|
|
||||||
|
`budgetApproval.title`, `budgetApproval.amount`, `budgetApproval.requestedBy`, `budgetApproval.justification`, `budgetApproval.earmarkedFor`, `budgetApproval.approve`, `budgetApproval.reject`, `budgetApproval.addNote`, `budgetApproval.approved`, `budgetApproval.rejected`, `budgetApproval.expired`, `budgetApproval.alreadyHandled`
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add client/src/pages/PublicBudgetApproval.jsx client/src/App.jsx client/src/i18n/en.json client/src/i18n/ar.json
|
||||||
|
git commit -m "feat: public budget approval page"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 16: Add budget validation feedback to campaign creation UI
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `client/src/pages/Campaigns.jsx` (or wherever campaign creation modal lives)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Show available budget near the budget input**
|
||||||
|
|
||||||
|
When user enters a budget amount for a new campaign, fetch `mainAvailable` from the finance summary (or a lightweight endpoint) and show: "Available: $X". If the entered amount exceeds available, show error inline and disable the submit button.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Handle 400 error from server**
|
||||||
|
|
||||||
|
If campaign creation returns 400 with `{ error: 'Insufficient budget', available: X }`, show a toast or inline error with the available amount and a suggestion to request more.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Same for track creation in CampaignDetail**
|
||||||
|
|
||||||
|
When adding a track, show campaign available budget. Handle 400 insufficient errors.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add client/src/pages/Campaigns.jsx client/src/pages/CampaignDetail.jsx
|
||||||
|
git commit -m "feat: budget validation UI for campaigns and tracks"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 17: Final verification
|
||||||
|
|
||||||
|
- [ ] **Step 1: Build check**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd client && npx vite build --logLevel error
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Manual test checklist**
|
||||||
|
|
||||||
|
1. Create income via budget request → CEO approves → funds appear
|
||||||
|
2. Create campaign with budget > available → blocked
|
||||||
|
3. Create campaign with budget ≤ available → succeeds, BudgetEntry created
|
||||||
|
4. Create track exceeding campaign budget → blocked
|
||||||
|
5. Delete campaign → funds return to main
|
||||||
|
6. Create expense > available → blocked
|
||||||
|
7. Reduce campaign budget below track allocations → blocked
|
||||||
|
8. Finance summary shows correct numbers (same for superadmin and manager)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit any final fixes**
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
# Budget Allocation Redesign — Single Source of Truth + CEO Approval Workflow
|
||||||
|
|
||||||
|
**Date:** 2026-03-15
|
||||||
|
**Status:** Draft
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The current budget system has two parallel sources of truth:
|
||||||
|
- `Campaign.budget` field (set directly on campaigns)
|
||||||
|
- `BudgetEntries` table (income/expense records linked to campaigns/projects)
|
||||||
|
|
||||||
|
These don't sync. The finance summary uses `Campaign.budget` for `totalCampaignBudget` but `BudgetEntries` for `totalReceived` (superadmin only). Managers see a completely different `totalReceived` calculation. There's no validation preventing over-allocation, and no approval workflow for incoming funds.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Budget Hierarchy
|
||||||
|
|
||||||
|
```
|
||||||
|
Main Budget (sum of approved income BudgetEntries)
|
||||||
|
│
|
||||||
|
├─ Expenses (BudgetEntries with type='expense', deducted from main)
|
||||||
|
├─ Campaign allocations (income BudgetEntries with campaign_id set)
|
||||||
|
├─ Project allocations (income BudgetEntries with project_id set)
|
||||||
|
│
|
||||||
|
└─ Available = Main Budget - expenses - campaign allocations - project allocations
|
||||||
|
│
|
||||||
|
Campaign "Summer Sale" ($10K allocated)
|
||||||
|
├─ Track "Facebook Ads" ($3K from campaign)
|
||||||
|
├─ Track "Google Ads" ($5K from campaign)
|
||||||
|
└─ Campaign Available = $2K
|
||||||
|
```
|
||||||
|
|
||||||
|
Campaign allocation entries are a **subset** of income entries — they are income entries that happen to have a `campaign_id` set. An earmarked CEO-approved income entry counts as both `totalReceived` and `totalCampaignBudget` (which is correct — the money enters the system AND is allocated).
|
||||||
|
|
||||||
|
### Single Source of Truth
|
||||||
|
|
||||||
|
**BudgetEntries is the only source.** `Campaign.budget` field is deprecated — kept in schema but ignored in all calculations.
|
||||||
|
|
||||||
|
All calculations:
|
||||||
|
- `totalReceived` = sum of all income BudgetEntries (same for all roles)
|
||||||
|
- `totalExpenses` = sum of all expense BudgetEntries
|
||||||
|
- `totalCampaignBudget` = sum of income BudgetEntries where `campaign_id` is set
|
||||||
|
- `totalProjectBudget` = sum of income BudgetEntries where `project_id` is set
|
||||||
|
- `mainAvailable` = totalReceived - totalExpenses - totalCampaignBudget - totalProjectBudget
|
||||||
|
- `campaignAvailable(id)` = campaign's allocated budget - sum of its tracks' `budget_allocated`
|
||||||
|
- `remaining` = mainAvailable (same thing — no more double-counting tracks)
|
||||||
|
|
||||||
|
### Validation Rules
|
||||||
|
|
||||||
|
| Action | Guard | Error message |
|
||||||
|
|--------|-------|---------------|
|
||||||
|
| All amounts | amount > 0 | "Amount must be positive" |
|
||||||
|
| Create expense entry | mainAvailable >= amount | "Insufficient budget. Available: $X" |
|
||||||
|
| Set campaign budget (at creation or edit) | mainAvailable >= amount (or >= increase delta) | "Insufficient budget. Available: $X. Request more funds." |
|
||||||
|
| Decrease campaign budget | newBudget >= sum(tracks.budget_allocated) | "Cannot reduce below track allocations ($X assigned to tracks)" |
|
||||||
|
| Set project budget | mainAvailable >= amount (or >= increase delta) | "Insufficient budget. Available: $X. Request more funds." |
|
||||||
|
| Set track budget_allocated | campaignAvailable >= amount (or >= increase delta) | "Insufficient campaign budget. Available: $X of $Y allocated" |
|
||||||
|
| Create income entry | Must go through budget request workflow (superadmin only) | N/A — handled by request workflow |
|
||||||
|
|
||||||
|
**Race condition mitigation:** Budget-modifying operations (campaign/project/expense creation, budget changes) acquire an in-memory mutex before reading availability and releasing after the write. Single-server app — no distributed lock needed.
|
||||||
|
|
||||||
|
### Campaign/Project Deletion
|
||||||
|
|
||||||
|
When a campaign or project is deleted:
|
||||||
|
- All linked BudgetEntries have their `campaign_id` / `project_id` set to null
|
||||||
|
- This returns the allocated funds to the main available balance
|
||||||
|
- Tracks under the campaign are already deleted by the existing cascade logic
|
||||||
|
|
||||||
|
### Budget Request Workflow (CEO Approval)
|
||||||
|
|
||||||
|
**Who can request:** Superadmin only.
|
||||||
|
**Who approves:** CEO — external email address configured in Settings page.
|
||||||
|
|
||||||
|
#### Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Superadmin opens Finance page → clicks "Request Budget"
|
||||||
|
2. Fills form: amount, justification note
|
||||||
|
Optional: earmarked for specific campaign or project
|
||||||
|
3. System creates BudgetRequest (status: pending)
|
||||||
|
4. System generates approval token + public URL (expires in 7 days)
|
||||||
|
5. System emails CEO: amount, requester name, justification, approve/reject links
|
||||||
|
(no internal budget details exposed)
|
||||||
|
|
||||||
|
6a. CEO clicks Approve:
|
||||||
|
→ BudgetRequest.status = 'approved', resolved_at = now
|
||||||
|
→ Auto-creates income BudgetEntry (amount, source: "CEO Approved — {justification}")
|
||||||
|
→ If earmarked: BudgetEntry gets campaign_id or project_id
|
||||||
|
→ Email notification to superadmin: "Your budget request for $X has been approved"
|
||||||
|
|
||||||
|
6b. CEO clicks Reject:
|
||||||
|
→ BudgetRequest.status = 'rejected', resolved_at = now
|
||||||
|
→ CEO can add a response note
|
||||||
|
→ Email notification to superadmin: "Your budget request for $X has been rejected"
|
||||||
|
```
|
||||||
|
|
||||||
|
Idempotent: if CEO clicks approve/reject twice, return 200 with existing result — no duplicate entries.
|
||||||
|
|
||||||
|
#### New Table: BudgetRequests
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| amount | Decimal | Requested amount (must be > 0) |
|
||||||
|
| justification | LongText | Why the money is needed |
|
||||||
|
| status | SingleLineText | pending / approved / rejected / cancelled |
|
||||||
|
| requested_by_user_id | Number | FK to Users |
|
||||||
|
| approval_token | SingleLineText | UUID for public approval URL |
|
||||||
|
| token_expires_at | DateTime | Token expiry (7 days from creation) |
|
||||||
|
| response_note | LongText | CEO's note on approval/rejection |
|
||||||
|
| resolved_at | DateTime | When CEO acted (null while pending) |
|
||||||
|
| earmarked_campaign_id | Number | Optional FK — intended campaign |
|
||||||
|
| earmarked_project_id | Number | Optional FK — intended project |
|
||||||
|
| created_budget_entry_id | Number | FK to the auto-created BudgetEntry (set on approval) |
|
||||||
|
|
||||||
|
#### API Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Auth | Description |
|
||||||
|
|--------|------|------|-------------|
|
||||||
|
| GET | /api/budget-requests | Superadmin | List all requests (sorted by CreatedAt desc) |
|
||||||
|
| POST | /api/budget-requests | Superadmin | Create new request, sends email to CEO |
|
||||||
|
| PATCH | /api/budget-requests/:id/cancel | Superadmin | Cancel a pending request |
|
||||||
|
| GET | /api/budget-approval/:token | Public | Get request details for approval page |
|
||||||
|
| POST | /api/budget-approval/:token/respond | Public | Approve or reject (body: `{ action: 'approve'|'reject', note?: string }`) |
|
||||||
|
|
||||||
|
#### Public Approval Page: `/approve-budget/:token`
|
||||||
|
|
||||||
|
Minimal page showing:
|
||||||
|
- Requested amount
|
||||||
|
- Requester name
|
||||||
|
- Justification note
|
||||||
|
- Earmarked for (campaign/project name, if set)
|
||||||
|
- Two buttons: Approve / Reject
|
||||||
|
- Optional text field for response note
|
||||||
|
- States: loading, active, success, already-handled, expired
|
||||||
|
|
||||||
|
Same pattern as existing public review pages (`PublicReview.jsx`, `PublicPostReview.jsx`).
|
||||||
|
|
||||||
|
Token validation: check `token_expires_at > now` and `status === 'pending'`. Expired tokens show "This request has expired" with no action buttons.
|
||||||
|
|
||||||
|
### Settings: CEO Email
|
||||||
|
|
||||||
|
Add to Settings page (superadmin only):
|
||||||
|
- Field: "CEO / Budget Approver Email"
|
||||||
|
- Stored in `AppSettings` table (key-value: `{ key: 'ceo_email', value: 'ceo@company.com' }`)
|
||||||
|
|
||||||
|
**AppSettings table schema:**
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| key | SingleLineText | Setting key (unique) |
|
||||||
|
| value | LongText | Setting value |
|
||||||
|
|
||||||
|
Add to `REQUIRED_TABLES`. Read via `GET /api/settings/:key` (superadmin), write via `PATCH /api/settings/:key` (superadmin).
|
||||||
|
|
||||||
|
### Finance Page Changes
|
||||||
|
|
||||||
|
Add a "Budget Requests" section to the Finance page:
|
||||||
|
- Shows all requests with status badge (pending/approved/rejected/cancelled)
|
||||||
|
- Pending requests show a subtle banner at top: "1 budget request pending CEO approval"
|
||||||
|
- Each row: amount, justification (truncated), status, date, earmarked for, resolved_at
|
||||||
|
- Superadmin sees "Request Budget" button in the page header
|
||||||
|
|
||||||
|
### Campaign Creation Change
|
||||||
|
|
||||||
|
When creating/editing a campaign with a budget:
|
||||||
|
1. Acquire budget mutex
|
||||||
|
2. Server calculates `mainAvailable`
|
||||||
|
3. If `budget > mainAvailable`: return 400 with `{ error: 'Insufficient budget', available: mainAvailable }`
|
||||||
|
4. If OK: create campaign, then auto-create BudgetEntry (type=income, campaign_id=new campaign ID, amount=budget)
|
||||||
|
5. `Campaign.budget` field is still written for backward compat but NOT used in calculations
|
||||||
|
6. Release mutex
|
||||||
|
|
||||||
|
When increasing a campaign's budget:
|
||||||
|
- Delta = newBudget - currentAllocated (where currentAllocated = sum of income BudgetEntries with this campaign_id)
|
||||||
|
- Acquire mutex, check `mainAvailable >= delta`, update BudgetEntry amount, release
|
||||||
|
|
||||||
|
When decreasing a campaign's budget:
|
||||||
|
- Check `newBudget >= sum(tracks.budget_allocated for this campaign)`
|
||||||
|
- If not: return 400 "Cannot reduce below track allocations"
|
||||||
|
- Update BudgetEntry amount (freed funds return to main automatically)
|
||||||
|
|
||||||
|
### Track Creation/Edit Change
|
||||||
|
|
||||||
|
When creating/editing a track with `budget_allocated`:
|
||||||
|
1. Calculate `campaignAllocated` = sum of income BudgetEntries with this campaign_id
|
||||||
|
2. Calculate `tracksTotalAllocated` = sum of all tracks' `budget_allocated` for this campaign (excluding current track if editing)
|
||||||
|
3. `campaignAvailable = campaignAllocated - tracksTotalAllocated`
|
||||||
|
4. If `budget_allocated > campaignAvailable`: return 400
|
||||||
|
5. If OK: save normally
|
||||||
|
|
||||||
|
### Finance Summary Endpoint Fix
|
||||||
|
|
||||||
|
`GET /api/finance/summary` — rewrite calculation:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Single source of truth — BudgetEntries only
|
||||||
|
const incomeEntries = budgetEntries.filter(e => (e.type || 'income') === 'income');
|
||||||
|
const expenseEntries = budgetEntries.filter(e => e.type === 'expense');
|
||||||
|
|
||||||
|
const totalReceived = incomeEntries.reduce((s, e) => s + (e.amount || 0), 0); // SAME for all roles
|
||||||
|
const totalExpenses = expenseEntries.reduce((s, e) => s + (e.amount || 0), 0);
|
||||||
|
|
||||||
|
const totalCampaignBudget = incomeEntries
|
||||||
|
.filter(e => e.campaign_id)
|
||||||
|
.reduce((s, e) => s + (e.amount || 0), 0); // FROM ENTRIES, not Campaign.budget
|
||||||
|
|
||||||
|
const totalProjectBudget = incomeEntries
|
||||||
|
.filter(e => e.project_id)
|
||||||
|
.reduce((s, e) => s + (e.amount || 0), 0);
|
||||||
|
|
||||||
|
const mainAvailable = totalReceived - totalExpenses - totalCampaignBudget - totalProjectBudget;
|
||||||
|
|
||||||
|
// Track spending stays within campaign allocation — not subtracted from main
|
||||||
|
const remaining = mainAvailable; // Simple. No double-counting.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Migration
|
||||||
|
|
||||||
|
Existing data:
|
||||||
|
1. For each campaign with `budget > 0` that has NO corresponding income BudgetEntry with that campaign_id: auto-create an income BudgetEntry linked to that campaign
|
||||||
|
2. Skip campaigns with `budget = 0` or `budget = null`
|
||||||
|
3. Log migrations to console for audit
|
||||||
|
4. Run once on server startup (idempotent — skip if matching entry already exists)
|
||||||
|
|
||||||
|
### Email Templates
|
||||||
|
|
||||||
|
Budget request email to CEO:
|
||||||
|
- Subject: `Rawaj — Budget Request: $X`
|
||||||
|
- Header: Rawaj branded (dark forest `#0a1f1c`)
|
||||||
|
- Body: "{requester} is requesting $X. Justification: {note}"
|
||||||
|
- CTA: "Review Request" button → public approval page
|
||||||
|
- No internal budget details
|
||||||
|
|
||||||
|
Approval/rejection notification to superadmin:
|
||||||
|
- Subject: `Rawaj — Budget Request Approved/Rejected: $X`
|
||||||
|
- Body: result + CEO's response note if any
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- Multi-currency support
|
||||||
|
- Budget periods/fiscal years
|
||||||
|
- Partial approval (CEO can't approve a different amount)
|
||||||
|
- Delegation (CEO can't forward approval to someone else)
|
||||||
|
- Audit log (beyond the BudgetRequests table itself)
|
||||||
|
- Currency precision (uses NocoDB Decimal as-is)
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
{
|
{
|
||||||
"uploadMaxSizeMB": 500
|
"uploadMaxSizeMB": 500,
|
||||||
|
"ceoEmail": "fahed@softhouse.io"
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// server/budget-helpers.js — Budget availability calculations
|
||||||
|
// Single source of truth: BudgetEntries table
|
||||||
|
|
||||||
|
const nocodb = require('./nocodb');
|
||||||
|
|
||||||
|
async function getMainAvailable() {
|
||||||
|
const entries = await nocodb.list('BudgetEntries', { limit: 10000 });
|
||||||
|
const income = entries.filter(e => (e.type || 'income') === 'income');
|
||||||
|
const expenses = entries.filter(e => e.type === 'expense');
|
||||||
|
|
||||||
|
const totalReceived = income.reduce((s, e) => s + (e.amount || 0), 0);
|
||||||
|
const totalExpenses = expenses.reduce((s, e) => s + (e.amount || 0), 0);
|
||||||
|
const totalCampaignBudget = income.filter(e => e.campaign_id).reduce((s, e) => s + (e.amount || 0), 0);
|
||||||
|
const totalProjectBudget = income.filter(e => e.project_id).reduce((s, e) => s + (e.amount || 0), 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalReceived,
|
||||||
|
totalExpenses,
|
||||||
|
totalCampaignBudget,
|
||||||
|
totalProjectBudget,
|
||||||
|
available: totalReceived - totalExpenses - totalCampaignBudget - totalProjectBudget,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCampaignAvailable(campaignId) {
|
||||||
|
const entries = await nocodb.list('BudgetEntries', { limit: 10000 });
|
||||||
|
const campaignIncome = entries.filter(e =>
|
||||||
|
e.campaign_id && Number(e.campaign_id) === Number(campaignId) && (e.type || 'income') === 'income'
|
||||||
|
);
|
||||||
|
const allocated = campaignIncome.reduce((s, e) => s + (e.amount || 0), 0);
|
||||||
|
|
||||||
|
const tracks = await nocodb.list('CampaignTracks', {
|
||||||
|
where: `(campaign_id,eq,${campaignId})`,
|
||||||
|
limit: 10000,
|
||||||
|
});
|
||||||
|
const trackAllocated = tracks.reduce((s, t) => s + (t.budget_allocated || 0), 0);
|
||||||
|
|
||||||
|
return { allocated, trackAllocated, available: allocated - trackAllocated };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCampaignAllocatedFromEntries(campaignId) {
|
||||||
|
const entries = await nocodb.list('BudgetEntries', { limit: 10000 });
|
||||||
|
return entries
|
||||||
|
.filter(e => e.campaign_id && Number(e.campaign_id) === Number(campaignId) && (e.type || 'income') === 'income')
|
||||||
|
.reduce((s, e) => s + (e.amount || 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getMainAvailable, getCampaignAvailable, getCampaignAllocatedFromEntries };
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
// server/budget-mutex.js — In-memory mutex for budget-modifying operations
|
||||||
|
// Prevents race conditions when multiple requests check availability simultaneously
|
||||||
|
|
||||||
|
let _lock = null;
|
||||||
|
|
||||||
|
async function acquireBudgetLock() {
|
||||||
|
while (_lock) await _lock;
|
||||||
|
let resolve;
|
||||||
|
_lock = new Promise(r => { resolve = r; });
|
||||||
|
return () => { _lock = null; resolve(); };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { acquireBudgetLock };
|
||||||
+70
-5
@@ -4,8 +4,8 @@ const nocodb = require('./nocodb');
|
|||||||
const { parseApproverIds } = require('./helpers');
|
const { parseApproverIds } = require('./helpers');
|
||||||
|
|
||||||
const APP_URL = process.env.APP_URL || process.env.CORS_ORIGIN || 'http://localhost:3001';
|
const APP_URL = process.env.APP_URL || process.env.CORS_ORIGIN || 'http://localhost:3001';
|
||||||
const APP_NAME_EN = "Samaya's Digital Hub";
|
const APP_NAME_EN = 'Rawaj';
|
||||||
const APP_NAME_AR = 'المركز الرقمي لسمايا';
|
const APP_NAME_AR = 'رواج';
|
||||||
|
|
||||||
// ─── TRANSLATIONS ───────────────────────────────────────────────
|
// ─── TRANSLATIONS ───────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -94,6 +94,21 @@ const t = {
|
|||||||
view: { en: 'View', ar: 'عرض' },
|
view: { en: 'View', ar: 'عرض' },
|
||||||
viewTask: { en: 'View Task', ar: 'عرض المهمة' },
|
viewTask: { en: 'View Task', ar: 'عرض المهمة' },
|
||||||
viewIssue:{ en: 'View Issue', ar: 'عرض المشكلة' },
|
viewIssue:{ en: 'View Issue', ar: 'عرض المشكلة' },
|
||||||
|
|
||||||
|
// Budget
|
||||||
|
budgetRequest: { en: 'Budget Request', ar: 'طلب ميزانية' },
|
||||||
|
budgetRequestHeading: { en: 'Budget Request', ar: 'طلب ميزانية' },
|
||||||
|
budgetRequestBody: { en: (name, amount) => `<strong>${name}</strong> is requesting <strong>${amount}</strong>.`,
|
||||||
|
ar: (name, amount) => `يطلب <strong>${name}</strong> مبلغ <strong>${amount}</strong>.` },
|
||||||
|
budgetJustification: { en: 'Justification', ar: 'المبرر' },
|
||||||
|
budgetEarmarkedFor: { en: 'Earmarked for', ar: 'مخصص لـ' },
|
||||||
|
reviewRequest: { en: 'Review Request', ar: 'مراجعة الطلب' },
|
||||||
|
budgetApproved: { en: 'Budget Request Approved', ar: 'تمت الموافقة على طلب الميزانية' },
|
||||||
|
budgetApprovedBody: { en: (amount) => `Your budget request for <strong>${amount}</strong> has been approved. Funds are now available.`,
|
||||||
|
ar: (amount) => `تمت الموافقة على طلب الميزانية بمبلغ <strong>${amount}</strong>. الأموال متاحة الآن.` },
|
||||||
|
budgetRejected: { en: 'Budget Request Rejected', ar: 'تم رفض طلب الميزانية' },
|
||||||
|
budgetRejectedBody: { en: (amount) => `Your budget request for <strong>${amount}</strong> has been rejected.`,
|
||||||
|
ar: (amount) => `تم رفض طلب الميزانية بمبلغ <strong>${amount}</strong>.` },
|
||||||
};
|
};
|
||||||
|
|
||||||
function tr(key, lang) { return t[key]?.[lang] || t[key]?.en || key; }
|
function tr(key, lang) { return t[key]?.[lang] || t[key]?.en || key; }
|
||||||
@@ -111,7 +126,7 @@ function renderEmail({ heading, bodyHtml, ctaText, ctaUrl, lang = 'en' }) {
|
|||||||
<html dir="${dir}" lang="${lang}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
|
<html dir="${dir}" lang="${lang}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
|
||||||
<body style="margin:0;padding:0;background:#f4f5f7;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif">
|
<body style="margin:0;padding:0;background:#f4f5f7;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif">
|
||||||
<div style="max-width:600px;margin:0 auto;padding:20px;direction:${dir};text-align:${align}">
|
<div style="max-width:600px;margin:0 auto;padding:20px;direction:${dir};text-align:${align}">
|
||||||
<div style="background:#1e293b;color:#fff;padding:16px 24px;border-radius:12px 12px 0 0;font-size:16px;font-weight:600;text-align:${align}">
|
<div style="background:#0a1f1c;color:#fff;padding:16px 24px;border-radius:12px 12px 0 0;font-size:16px;font-weight:600;text-align:${align}">
|
||||||
${appName}
|
${appName}
|
||||||
</div>
|
</div>
|
||||||
<div style="background:#fff;padding:32px 24px;border-radius:0 0 12px 12px;border:1px solid #e2e8f0;border-top:none">
|
<div style="background:#fff;padding:32px 24px;border-radius:0 0 12px 12px;border:1px solid #e2e8f0;border-top:none">
|
||||||
@@ -121,7 +136,7 @@ function renderEmail({ heading, bodyHtml, ctaText, ctaUrl, lang = 'en' }) {
|
|||||||
</div>
|
</div>
|
||||||
${ctaText && ctaUrl ? `
|
${ctaText && ctaUrl ? `
|
||||||
<div style="margin:24px 0 8px">
|
<div style="margin:24px 0 8px">
|
||||||
<a href="${ctaUrl}" style="display:inline-block;background:#3b82f6;color:#fff;padding:12px 24px;border-radius:8px;text-decoration:none;font-weight:600;font-size:14px">${ctaText}</a>
|
<a href="${ctaUrl}" style="display:inline-block;background:#0d9488;color:#fff;padding:12px 24px;border-radius:8px;text-decoration:none;font-weight:600;font-size:14px">${ctaText}</a>
|
||||||
</div>` : ''}
|
</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div style="text-align:center;padding:16px;color:#94a3b8;font-size:12px">
|
<div style="text-align:center;padding:16px;color:#94a3b8;font-size:12px">
|
||||||
@@ -151,8 +166,10 @@ async function getMultipleUsers(userIds) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function send({ to, subject, heading, bodyHtml, ctaText, ctaUrl, lang }) {
|
function send({ to, subject, heading, bodyHtml, ctaText, ctaUrl, lang }) {
|
||||||
|
const appName = lang === 'ar' ? APP_NAME_AR : APP_NAME_EN;
|
||||||
|
const fullSubject = `${appName} — ${subject}`;
|
||||||
const { html, text } = renderEmail({ heading, bodyHtml, ctaText, ctaUrl, lang });
|
const { html, text } = renderEmail({ heading, bodyHtml, ctaText, ctaUrl, lang });
|
||||||
sendMail({ to, subject, html, text })
|
sendMail({ to, subject: fullSubject, html, text })
|
||||||
.then(() => console.log(`[notifications] Sent "${subject}" to ${to}`))
|
.then(() => console.log(`[notifications] Sent "${subject}" to ${to}`))
|
||||||
.catch(err => console.error(`[notifications] FAILED "${subject}" to ${to}:`, err.message));
|
.catch(err => console.error(`[notifications] FAILED "${subject}" to ${to}:`, err.message));
|
||||||
}
|
}
|
||||||
@@ -387,7 +404,52 @@ function notifyUserInvited({ email, name, password, inviterName, lang = 'en' })
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 11. Budget request → email CEO
|
||||||
|
function notifyBudgetRequest({ ceoEmail, amount, requesterName, justification, earmarkedFor, approvalUrl }) {
|
||||||
|
const earmarkHtml = earmarkedFor ? `<p><strong>${tr('budgetEarmarkedFor', 'en')}:</strong> ${earmarkedFor}</p>` : '';
|
||||||
|
send({
|
||||||
|
to: ceoEmail, lang: 'en',
|
||||||
|
subject: `${tr('budgetRequest', 'en')}: ${amount}`,
|
||||||
|
heading: tr('budgetRequestHeading', 'en'),
|
||||||
|
bodyHtml: `
|
||||||
|
<p>${tr('budgetRequestBody', 'en')(requesterName, amount)}</p>
|
||||||
|
<p><strong>${tr('budgetJustification', 'en')}:</strong> ${justification}</p>
|
||||||
|
${earmarkHtml}`,
|
||||||
|
ctaText: tr('reviewRequest', 'en'),
|
||||||
|
ctaUrl: approvalUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 12. Budget approved → notify requester
|
||||||
|
function notifyBudgetApproved({ request, requesterEmail, requesterLang }) {
|
||||||
|
const l = requesterLang || 'en';
|
||||||
|
send({
|
||||||
|
to: requesterEmail, lang: l,
|
||||||
|
subject: `${tr('budgetApproved', l)}: ${request.amount}`,
|
||||||
|
heading: tr('budgetApproved', l),
|
||||||
|
bodyHtml: `
|
||||||
|
<p>${tr('budgetApprovedBody', l)(String(request.amount))}</p>
|
||||||
|
${request.response_note ? `<blockquote ${BLOCKQUOTE}>${request.response_note}</blockquote>` : ''}`,
|
||||||
|
ctaText: null, ctaUrl: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 13. Budget rejected → notify requester
|
||||||
|
function notifyBudgetRejected({ request, requesterEmail, requesterLang }) {
|
||||||
|
const l = requesterLang || 'en';
|
||||||
|
send({
|
||||||
|
to: requesterEmail, lang: l,
|
||||||
|
subject: `${tr('budgetRejected', l)}: ${request.amount}`,
|
||||||
|
heading: tr('budgetRejected', l),
|
||||||
|
bodyHtml: `
|
||||||
|
<p>${tr('budgetRejectedBody', l)(String(request.amount))}</p>
|
||||||
|
${request.response_note ? `<blockquote ${BLOCKQUOTE}>${request.response_note}</blockquote>` : ''}`,
|
||||||
|
ctaText: null, ctaUrl: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
renderEmail,
|
||||||
notifyReviewSubmitted,
|
notifyReviewSubmitted,
|
||||||
notifyApproved,
|
notifyApproved,
|
||||||
notifyRejected,
|
notifyRejected,
|
||||||
@@ -398,4 +460,7 @@ module.exports = {
|
|||||||
notifyIssueStatusUpdate,
|
notifyIssueStatusUpdate,
|
||||||
notifyCampaignCreated,
|
notifyCampaignCreated,
|
||||||
notifyUserInvited,
|
notifyUserInvited,
|
||||||
|
notifyBudgetRequest,
|
||||||
|
notifyBudgetApproved,
|
||||||
|
notifyBudgetRejected,
|
||||||
};
|
};
|
||||||
|
|||||||
+457
-23
@@ -13,6 +13,8 @@ const crypto = require('crypto');
|
|||||||
const { PORT, UPLOADS_DIR, SETTINGS_PATH, DEFAULTS, QUERY_LIMITS, ALL_MODULES, TABLE_NAME_MAP, COMMENT_ENTITY_TYPES } = require('./config');
|
const { PORT, UPLOADS_DIR, SETTINGS_PATH, DEFAULTS, QUERY_LIMITS, ALL_MODULES, TABLE_NAME_MAP, COMMENT_ENTITY_TYPES } = require('./config');
|
||||||
const { getRecordName, batchResolveNames, parseApproverIds, safeJsonParse, pickBodyFields, sanitizeWhereValue, getUserModules, stripSensitiveFields, getUserTeamIds, getUserVisibilityContext } = require('./helpers');
|
const { getRecordName, batchResolveNames, parseApproverIds, safeJsonParse, pickBodyFields, sanitizeWhereValue, getUserModules, stripSensitiveFields, getUserTeamIds, getUserVisibilityContext } = require('./helpers');
|
||||||
const notify = require('./notifications');
|
const notify = require('./notifications');
|
||||||
|
const { acquireBudgetLock } = require('./budget-mutex');
|
||||||
|
const { getMainAvailable, getCampaignAvailable, getCampaignAllocatedFromEntries } = require('./budget-helpers');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
@@ -61,7 +63,7 @@ app.use(session({
|
|||||||
app.use('/api/uploads', express.static(uploadsDir));
|
app.use('/api/uploads', express.static(uploadsDir));
|
||||||
|
|
||||||
// ─── APP SETTINGS (persisted to JSON) ────────────────────────────
|
// ─── APP SETTINGS (persisted to JSON) ────────────────────────────
|
||||||
const defaultSettings = { uploadMaxSizeMB: DEFAULTS.uploadMaxSizeMB };
|
const defaultSettings = { uploadMaxSizeMB: DEFAULTS.uploadMaxSizeMB, ceoEmail: '' };
|
||||||
function loadSettings() {
|
function loadSettings() {
|
||||||
try { return { ...defaultSettings, ...JSON.parse(fs.readFileSync(SETTINGS_PATH, 'utf8')) }; }
|
try { return { ...defaultSettings, ...JSON.parse(fs.readFileSync(SETTINGS_PATH, 'utf8')) }; }
|
||||||
catch (err) { console.error('Load settings error:', err.message); return { ...defaultSettings }; }
|
catch (err) { console.error('Load settings error:', err.message); return { ...defaultSettings }; }
|
||||||
@@ -313,6 +315,17 @@ const REQUIRED_TABLES = {
|
|||||||
{ title: 'notes', uidt: 'LongText' },
|
{ title: 'notes', uidt: 'LongText' },
|
||||||
{ title: 'campaign_id', uidt: 'Number' },
|
{ title: 'campaign_id', uidt: 'Number' },
|
||||||
],
|
],
|
||||||
|
BudgetRequests: [
|
||||||
|
{ title: 'amount', uidt: 'Decimal' },
|
||||||
|
{ title: 'justification', uidt: 'LongText' },
|
||||||
|
{ title: 'status', uidt: 'SingleLineText' },
|
||||||
|
{ title: 'requested_by_user_id', uidt: 'Number' },
|
||||||
|
{ title: 'approval_token', uidt: 'SingleLineText' },
|
||||||
|
{ title: 'response_note', uidt: 'LongText' },
|
||||||
|
{ title: 'earmarked_campaign_id', uidt: 'Number' },
|
||||||
|
{ title: 'earmarked_project_id', uidt: 'Number' },
|
||||||
|
{ title: 'created_budget_entry_id', uidt: 'Number' },
|
||||||
|
],
|
||||||
TaskAttachments: [
|
TaskAttachments: [
|
||||||
{ title: 'filename', uidt: 'SingleLineText' },
|
{ title: 'filename', uidt: 'SingleLineText' },
|
||||||
{ title: 'original_name', uidt: 'SingleLineText' },
|
{ title: 'original_name', uidt: 'SingleLineText' },
|
||||||
@@ -515,6 +528,10 @@ const TEXT_COLUMNS = {
|
|||||||
{ name: 'review_version', uidt: 'Number' },
|
{ name: 'review_version', uidt: 'Number' },
|
||||||
],
|
],
|
||||||
PostAttachments: [{ name: 'version_id', uidt: 'Number' }],
|
PostAttachments: [{ name: 'version_id', uidt: 'Number' }],
|
||||||
|
BudgetRequests: [
|
||||||
|
{ name: 'token_expires_at', uidt: 'SingleLineText' },
|
||||||
|
{ name: 'resolved_at', uidt: 'SingleLineText' },
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
async function ensureTextColumns() {
|
async function ensureTextColumns() {
|
||||||
@@ -746,17 +763,21 @@ app.post('/api/auth/forgot-password', async (req, res) => {
|
|||||||
const { sendMail } = require('./mail');
|
const { sendMail } = require('./mail');
|
||||||
await sendMail({
|
await sendMail({
|
||||||
to: email,
|
to: email,
|
||||||
subject: 'Password Reset',
|
subject: 'Rawaj — Password Reset',
|
||||||
html: `<div style="font-family:sans-serif;max-width:500px;margin:0 auto">
|
html: `<div style="font-family:sans-serif;max-width:500px;margin:0 auto;padding:20px">
|
||||||
<h2>Password Reset</h2>
|
<div style="background:#0a1f1c;color:#fff;padding:16px 24px;border-radius:12px 12px 0 0;font-size:16px;font-weight:600">Rawaj</div>
|
||||||
<p>Hello ${user.name || ''},</p>
|
<div style="background:#fff;padding:32px 24px;border-radius:0 0 12px 12px;border:1px solid #e2e8f0;border-top:none">
|
||||||
<p>Click below to reset your password:</p>
|
<h2 style="margin:0 0 16px;color:#1e293b;font-size:20px">Password Reset</h2>
|
||||||
<p style="text-align:center;margin:30px 0">
|
<p style="color:#475569;font-size:15px;line-height:1.6">Hello ${user.name || ''},</p>
|
||||||
<a href="${resetUrl}" style="background:#3b82f6;color:white;padding:12px 24px;border-radius:8px;text-decoration:none;font-weight:bold">Reset Password</a>
|
<p style="color:#475569;font-size:15px;line-height:1.6">Click below to reset your password:</p>
|
||||||
</p>
|
<div style="margin:24px 0 8px">
|
||||||
<p style="color:#666;font-size:14px">This link expires in 1 hour. If you didn't request this, ignore this email.</p>
|
<a href="${resetUrl}" style="display:inline-block;background:#0d9488;color:#fff;padding:12px 24px;border-radius:8px;text-decoration:none;font-weight:600;font-size:14px">Reset Password</a>
|
||||||
|
</div>
|
||||||
|
<p style="color:#94a3b8;font-size:13px">This link expires in 1 hour. If you didn't request this, ignore this email.</p>
|
||||||
|
</div>
|
||||||
|
<div style="text-align:center;padding:16px;color:#94a3b8;font-size:12px">This is an automated notification from Rawaj</div>
|
||||||
</div>`,
|
</div>`,
|
||||||
text: `Hello ${user.name || ''},\n\nVisit this link to reset your password:\n${resetUrl}\n\nExpires in 1 hour.`,
|
text: `Rawaj — Password Reset\n\nHello ${user.name || ''},\n\nVisit this link to reset your password:\n${resetUrl}\n\nExpires in 1 hour.`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -2096,8 +2117,19 @@ app.post('/api/campaigns', requireAuth, requireRole('superadmin', 'manager'), as
|
|||||||
if (!start_date || !end_date) return res.status(400).json({ error: 'Start and end dates are required' });
|
if (!start_date || !end_date) return res.status(400).json({ error: 'Start and end dates are required' });
|
||||||
|
|
||||||
const effectiveBudget = req.session.userRole === 'superadmin' ? (budget || null) : null;
|
const effectiveBudget = req.session.userRole === 'superadmin' ? (budget || null) : null;
|
||||||
|
const numericBudget = Number(effectiveBudget) || 0;
|
||||||
|
|
||||||
|
let releaseLock;
|
||||||
try {
|
try {
|
||||||
|
// Budget validation: if allocating budget, check main availability
|
||||||
|
if (numericBudget > 0) {
|
||||||
|
releaseLock = await acquireBudgetLock();
|
||||||
|
const main = await getMainAvailable();
|
||||||
|
if (main.available < numericBudget) {
|
||||||
|
return res.status(400).json({ error: 'Insufficient budget', available: main.available });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const created = await nocodb.create('Campaigns', {
|
const created = await nocodb.create('Campaigns', {
|
||||||
name, description: description || null,
|
name, description: description || null,
|
||||||
start_date, end_date,
|
start_date, end_date,
|
||||||
@@ -2113,6 +2145,18 @@ app.post('/api/campaigns', requireAuth, requireRole('superadmin', 'manager'), as
|
|||||||
created_by_user_id: req.session.userId,
|
created_by_user_id: req.session.userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Auto-create BudgetEntry for campaign allocation
|
||||||
|
if (numericBudget > 0) {
|
||||||
|
await nocodb.create('BudgetEntries', {
|
||||||
|
type: 'income', amount: numericBudget,
|
||||||
|
campaign_id: created.Id,
|
||||||
|
label: 'Campaign allocation',
|
||||||
|
source: 'Campaign creation',
|
||||||
|
date_received: new Date().toISOString().slice(0, 10),
|
||||||
|
category: 'marketing',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-assign creator
|
// Auto-assign creator
|
||||||
await nocodb.create('CampaignAssignments', {
|
await nocodb.create('CampaignAssignments', {
|
||||||
assigned_at: new Date().toISOString(),
|
assigned_at: new Date().toISOString(),
|
||||||
@@ -2131,10 +2175,13 @@ app.post('/api/campaigns', requireAuth, requireRole('superadmin', 'manager'), as
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Create campaign error:', err);
|
console.error('Create campaign error:', err);
|
||||||
res.status(500).json({ error: 'Failed to create campaign' });
|
res.status(500).json({ error: 'Failed to create campaign' });
|
||||||
|
} finally {
|
||||||
|
if (releaseLock) releaseLock();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.patch('/api/campaigns/:id', requireAuth, requireOwnerOrRole('campaigns', 'superadmin', 'manager'), async (req, res) => {
|
app.patch('/api/campaigns/:id', requireAuth, requireOwnerOrRole('campaigns', 'superadmin', 'manager'), async (req, res) => {
|
||||||
|
let releaseLock;
|
||||||
try {
|
try {
|
||||||
const existing = await nocodb.get('Campaigns', req.params.id);
|
const existing = await nocodb.get('Campaigns', req.params.id);
|
||||||
if (!existing) return res.status(404).json({ error: 'Campaign not found' });
|
if (!existing) return res.status(404).json({ error: 'Campaign not found' });
|
||||||
@@ -2153,6 +2200,51 @@ app.patch('/api/campaigns/:id', requireAuth, requireOwnerOrRole('campaigns', 'su
|
|||||||
|
|
||||||
if (Object.keys(data).length === 0) return res.status(400).json({ error: 'No fields to update' });
|
if (Object.keys(data).length === 0) return res.status(400).json({ error: 'No fields to update' });
|
||||||
|
|
||||||
|
// Budget validation when budget is being updated
|
||||||
|
if (data.budget !== undefined) {
|
||||||
|
const newBudget = Number(data.budget) || 0;
|
||||||
|
const currentAllocated = await getCampaignAllocatedFromEntries(req.params.id);
|
||||||
|
const delta = newBudget - currentAllocated;
|
||||||
|
|
||||||
|
if (delta > 0) {
|
||||||
|
// Increasing: check main pool has enough
|
||||||
|
releaseLock = await acquireBudgetLock();
|
||||||
|
const main = await getMainAvailable();
|
||||||
|
if (main.available < delta) {
|
||||||
|
return res.status(400).json({ error: 'Insufficient budget', available: main.available });
|
||||||
|
}
|
||||||
|
} else if (delta < 0) {
|
||||||
|
// Decreasing: check new budget covers existing track allocations
|
||||||
|
const campAvail = await getCampaignAvailable(req.params.id);
|
||||||
|
if (newBudget < campAvail.trackAllocated) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Cannot reduce below track allocations',
|
||||||
|
tracks_allocated: campAvail.trackAllocated,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update or create the BudgetEntry for this campaign
|
||||||
|
if (newBudget > 0) {
|
||||||
|
const entries = await nocodb.list('BudgetEntries', { limit: QUERY_LIMITS.max });
|
||||||
|
const existingEntry = entries.find(e =>
|
||||||
|
e.campaign_id && Number(e.campaign_id) === Number(req.params.id) && (e.type || 'income') === 'income'
|
||||||
|
);
|
||||||
|
if (existingEntry) {
|
||||||
|
await nocodb.update('BudgetEntries', existingEntry.Id, { amount: newBudget });
|
||||||
|
} else {
|
||||||
|
await nocodb.create('BudgetEntries', {
|
||||||
|
type: 'income', amount: newBudget,
|
||||||
|
campaign_id: Number(req.params.id),
|
||||||
|
label: 'Campaign allocation',
|
||||||
|
source: 'Campaign budget update',
|
||||||
|
date_received: new Date().toISOString().slice(0, 10),
|
||||||
|
category: 'marketing',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await nocodb.update('Campaigns', req.params.id, data);
|
await nocodb.update('Campaigns', req.params.id, data);
|
||||||
|
|
||||||
const campaign = await nocodb.get('Campaigns', req.params.id);
|
const campaign = await nocodb.get('Campaigns', req.params.id);
|
||||||
@@ -2164,6 +2256,8 @@ app.patch('/api/campaigns/:id', requireAuth, requireOwnerOrRole('campaigns', 'su
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Update campaign error:', err);
|
console.error('Update campaign error:', err);
|
||||||
res.status(500).json({ error: 'Failed to update campaign' });
|
res.status(500).json({ error: 'Failed to update campaign' });
|
||||||
|
} finally {
|
||||||
|
if (releaseLock) releaseLock();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2186,6 +2280,10 @@ app.delete('/api/campaigns/:id', requireAuth, requireRole('superadmin', 'manager
|
|||||||
const assets = await nocodb.list('Assets', { where: `(campaign_id,eq,${id})`, limit: QUERY_LIMITS.max });
|
const assets = await nocodb.list('Assets', { where: `(campaign_id,eq,${id})`, limit: QUERY_LIMITS.max });
|
||||||
for (const a of assets) await nocodb.update('Assets', a.Id, { campaign_id: null });
|
for (const a of assets) await nocodb.update('Assets', a.Id, { campaign_id: null });
|
||||||
|
|
||||||
|
// Release budget — null out campaign_id on linked BudgetEntries
|
||||||
|
const budgetEntries = await nocodb.list('BudgetEntries', { where: `(campaign_id,eq,${id})`, limit: QUERY_LIMITS.max });
|
||||||
|
for (const e of budgetEntries) await nocodb.update('BudgetEntries', e.Id, { campaign_id: null });
|
||||||
|
|
||||||
await nocodb.delete('Campaigns', id);
|
await nocodb.delete('Campaigns', id);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -2340,13 +2438,27 @@ app.post('/api/budget', requireAuth, requireRole('superadmin', 'manager'), async
|
|||||||
const { label, amount, source, destination, campaign_id, project_id, category, date_received, notes, type } = req.body;
|
const { label, amount, source, destination, campaign_id, project_id, category, date_received, notes, type } = req.body;
|
||||||
if (!label || !amount || !date_received) return res.status(400).json({ error: 'Label, amount, and date are required' });
|
if (!label || !amount || !date_received) return res.status(400).json({ error: 'Label, amount, and date are required' });
|
||||||
|
|
||||||
|
const numericAmount = Number(amount);
|
||||||
|
if (!numericAmount || numericAmount <= 0) return res.status(400).json({ error: 'Amount must be greater than 0' });
|
||||||
|
|
||||||
|
const entryType = type || 'income';
|
||||||
|
let releaseLock;
|
||||||
try {
|
try {
|
||||||
|
// Validate expense against main available budget
|
||||||
|
if (entryType === 'expense') {
|
||||||
|
releaseLock = await acquireBudgetLock();
|
||||||
|
const main = await getMainAvailable();
|
||||||
|
if (main.available < numericAmount) {
|
||||||
|
return res.status(400).json({ error: 'Insufficient budget', available: main.available });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const created = await nocodb.create('BudgetEntries', {
|
const created = await nocodb.create('BudgetEntries', {
|
||||||
label, amount, source: source || null, destination: destination || null,
|
label, amount: numericAmount, source: source || null, destination: destination || null,
|
||||||
category: category || 'marketing', date_received, notes: notes || '',
|
category: category || 'marketing', date_received, notes: notes || '',
|
||||||
campaign_id: campaign_id ? Number(campaign_id) : null,
|
campaign_id: campaign_id ? Number(campaign_id) : null,
|
||||||
project_id: project_id ? Number(project_id) : null,
|
project_id: project_id ? Number(project_id) : null,
|
||||||
type: type || 'income',
|
type: entryType,
|
||||||
});
|
});
|
||||||
const entry = await nocodb.get('BudgetEntries', created.Id);
|
const entry = await nocodb.get('BudgetEntries', created.Id);
|
||||||
res.status(201).json({
|
res.status(201).json({
|
||||||
@@ -2356,6 +2468,8 @@ app.post('/api/budget', requireAuth, requireRole('superadmin', 'manager'), async
|
|||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: 'Failed to create budget entry' });
|
res.status(500).json({ error: 'Failed to create budget entry' });
|
||||||
|
} finally {
|
||||||
|
if (releaseLock) releaseLock();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2397,6 +2511,258 @@ app.delete('/api/budget/:id', requireAuth, requireRole('superadmin', 'manager'),
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── BUDGET REQUESTS ────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Public routes first (no auth)
|
||||||
|
app.get('/api/budget-approval/:token', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const requests = await nocodb.list('BudgetRequests', {
|
||||||
|
where: `(approval_token,eq,${sanitizeWhereValue(req.params.token)})`,
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
const request = requests[0];
|
||||||
|
if (!request) return res.status(404).json({ error: 'Request not found' });
|
||||||
|
|
||||||
|
// Already handled
|
||||||
|
if (request.status !== 'pending') {
|
||||||
|
return res.json({ status: request.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check expiry
|
||||||
|
if (request.token_expires_at && new Date(request.token_expires_at) < new Date()) {
|
||||||
|
return res.json({ status: 'expired' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enrich with names
|
||||||
|
let requester_name = 'Unknown';
|
||||||
|
try {
|
||||||
|
const u = await nocodb.get('Users', request.requested_by_user_id);
|
||||||
|
if (u) requester_name = u.name;
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
let earmarked_campaign_name = null;
|
||||||
|
let earmarked_project_name = null;
|
||||||
|
if (request.earmarked_campaign_id) {
|
||||||
|
try {
|
||||||
|
const c = await nocodb.get('Campaigns', request.earmarked_campaign_id);
|
||||||
|
if (c) earmarked_campaign_name = c.name;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
if (request.earmarked_project_id) {
|
||||||
|
try {
|
||||||
|
const p = await nocodb.get('Projects', request.earmarked_project_id);
|
||||||
|
if (p) earmarked_project_name = p.name;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
amount: request.amount,
|
||||||
|
requester_name,
|
||||||
|
justification: request.justification,
|
||||||
|
earmarked_campaign_name,
|
||||||
|
earmarked_project_name,
|
||||||
|
status: request.status,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Budget approval GET error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to load budget request' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/budget-approval/:token/respond', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { action, note } = req.body;
|
||||||
|
if (!action || !['approve', 'reject'].includes(action)) {
|
||||||
|
return res.status(400).json({ error: 'action must be "approve" or "reject"' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const requests = await nocodb.list('BudgetRequests', {
|
||||||
|
where: `(approval_token,eq,${sanitizeWhereValue(req.params.token)})`,
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
const request = requests[0];
|
||||||
|
if (!request) return res.status(404).json({ error: 'Request not found' });
|
||||||
|
|
||||||
|
// Idempotent: already handled
|
||||||
|
if (request.status === 'approved' || request.status === 'rejected') {
|
||||||
|
const existing = await nocodb.get('BudgetRequests', request.Id);
|
||||||
|
return res.json(existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.status !== 'pending') {
|
||||||
|
return res.status(400).json({ error: `Request is ${request.status}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check expiry
|
||||||
|
if (request.token_expires_at && new Date(request.token_expires_at) < new Date()) {
|
||||||
|
return res.status(400).json({ error: 'Token has expired' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
// Get requester for notifications
|
||||||
|
let requester = null;
|
||||||
|
try { requester = await nocodb.get('Users', request.requested_by_user_id); } catch {}
|
||||||
|
|
||||||
|
if (action === 'approve') {
|
||||||
|
// Create income BudgetEntry
|
||||||
|
const entryData = {
|
||||||
|
type: 'income',
|
||||||
|
amount: request.amount,
|
||||||
|
source: 'CEO Approved',
|
||||||
|
label: `Budget request #${request.Id}`,
|
||||||
|
date_received: now.split('T')[0],
|
||||||
|
};
|
||||||
|
if (request.earmarked_campaign_id) entryData.campaign_id = request.earmarked_campaign_id;
|
||||||
|
if (request.earmarked_project_id) entryData.project_id = request.earmarked_project_id;
|
||||||
|
|
||||||
|
const entry = await nocodb.create('BudgetEntries', entryData);
|
||||||
|
|
||||||
|
await nocodb.update('BudgetRequests', request.Id, {
|
||||||
|
status: 'approved',
|
||||||
|
resolved_at: now,
|
||||||
|
created_budget_entry_id: entry.Id,
|
||||||
|
response_note: note || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (requester && requester.email) {
|
||||||
|
notify.notifyBudgetApproved({
|
||||||
|
request: { ...request, status: 'approved', response_note: note || null },
|
||||||
|
requesterEmail: requester.email,
|
||||||
|
requesterLang: requester.preferred_language || 'en',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// reject
|
||||||
|
await nocodb.update('BudgetRequests', request.Id, {
|
||||||
|
status: 'rejected',
|
||||||
|
resolved_at: now,
|
||||||
|
response_note: note || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (requester && requester.email) {
|
||||||
|
notify.notifyBudgetRejected({
|
||||||
|
request: { ...request, status: 'rejected', response_note: note || null },
|
||||||
|
requesterEmail: requester.email,
|
||||||
|
requesterLang: requester.preferred_language || 'en',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await nocodb.get('BudgetRequests', request.Id);
|
||||||
|
res.json(updated);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Budget approval respond error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to process budget response' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Authenticated budget request routes
|
||||||
|
app.get('/api/budget-requests', requireAuth, requireRole('superadmin'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const requests = await nocodb.list('BudgetRequests', { limit: 200, sort: '-CreatedAt' });
|
||||||
|
const userIds = [...new Set(requests.map(r => r.requested_by_user_id).filter(Boolean))];
|
||||||
|
const users = {};
|
||||||
|
for (const uid of userIds) {
|
||||||
|
try { const u = await nocodb.get('Users', uid); if (u) users[uid] = u.name; } catch {}
|
||||||
|
}
|
||||||
|
const enriched = requests.map(r => ({
|
||||||
|
...r,
|
||||||
|
requester_name: users[r.requested_by_user_id] || 'Unknown',
|
||||||
|
}));
|
||||||
|
await batchResolveNames(enriched, {
|
||||||
|
earmarked_campaign_id: { table: 'Campaigns', as: 'earmarked_campaign_name' },
|
||||||
|
earmarked_project_id: { table: 'Projects', as: 'earmarked_project_name' },
|
||||||
|
});
|
||||||
|
res.json(enriched);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Budget requests list error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to load budget requests' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/budget-requests', requireAuth, requireRole('superadmin'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { amount, justification, earmarked_campaign_id, earmarked_project_id } = req.body;
|
||||||
|
const numAmount = Number(amount);
|
||||||
|
if (!numAmount || numAmount <= 0) {
|
||||||
|
return res.status(400).json({ error: 'amount must be greater than 0' });
|
||||||
|
}
|
||||||
|
if (!justification || !String(justification).trim()) {
|
||||||
|
return res.status(400).json({ error: 'justification is required' });
|
||||||
|
}
|
||||||
|
if (!appSettings.ceoEmail) {
|
||||||
|
return res.status(400).json({ error: 'CEO email is not configured in settings' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = crypto.randomUUID();
|
||||||
|
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
|
||||||
|
const created = await nocodb.create('BudgetRequests', {
|
||||||
|
amount: numAmount,
|
||||||
|
justification: String(justification).trim(),
|
||||||
|
status: 'pending',
|
||||||
|
requested_by_user_id: req.session.userId,
|
||||||
|
approval_token: token,
|
||||||
|
token_expires_at: expiresAt,
|
||||||
|
earmarked_campaign_id: earmarked_campaign_id || null,
|
||||||
|
earmarked_project_id: earmarked_project_id || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const appUrl = process.env.APP_URL || process.env.CORS_ORIGIN || `${req.protocol}://${req.get('host')}`;
|
||||||
|
const approvalUrl = `${appUrl}/budget-approval/${token}`;
|
||||||
|
|
||||||
|
// Build earmarked label
|
||||||
|
let earmarkedFor = null;
|
||||||
|
if (earmarked_campaign_id) {
|
||||||
|
try {
|
||||||
|
const c = await nocodb.get('Campaigns', earmarked_campaign_id);
|
||||||
|
if (c) earmarkedFor = `Campaign: ${c.name}`;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
if (earmarked_project_id) {
|
||||||
|
try {
|
||||||
|
const p = await nocodb.get('Projects', earmarked_project_id);
|
||||||
|
if (p) earmarkedFor = earmarkedFor ? `${earmarkedFor}, Project: ${p.name}` : `Project: ${p.name}`;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
notify.notifyBudgetRequest({
|
||||||
|
ceoEmail: appSettings.ceoEmail,
|
||||||
|
amount: numAmount,
|
||||||
|
requesterName: req.session.userName || 'Unknown',
|
||||||
|
justification: String(justification).trim(),
|
||||||
|
earmarkedFor,
|
||||||
|
approvalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
const record = await nocodb.get('BudgetRequests', created.Id);
|
||||||
|
res.status(201).json(record);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Budget request create error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to create budget request' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch('/api/budget-requests/:id/cancel', requireAuth, requireRole('superadmin'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const existing = await nocodb.get('BudgetRequests', req.params.id);
|
||||||
|
if (!existing) return res.status(404).json({ error: 'Request not found' });
|
||||||
|
if (existing.status !== 'pending') {
|
||||||
|
return res.status(400).json({ error: `Cannot cancel — request is already ${existing.status}` });
|
||||||
|
}
|
||||||
|
await nocodb.update('BudgetRequests', req.params.id, {
|
||||||
|
status: 'cancelled',
|
||||||
|
resolved_at: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
const updated = await nocodb.get('BudgetRequests', req.params.id);
|
||||||
|
res.json(updated);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Budget request cancel error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to cancel budget request' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Finance summary
|
// Finance summary
|
||||||
app.get('/api/finance/summary', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => {
|
app.get('/api/finance/summary', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -2414,11 +2780,8 @@ app.get('/api/finance/summary', requireAuth, requireRole('superadmin', 'manager'
|
|||||||
|
|
||||||
const incomeEntries = budgetEntries.filter(e => (e.type || 'income') === 'income');
|
const incomeEntries = budgetEntries.filter(e => (e.type || 'income') === 'income');
|
||||||
const expenseEntries = budgetEntries.filter(e => e.type === 'expense');
|
const expenseEntries = budgetEntries.filter(e => e.type === 'expense');
|
||||||
const totalIncome = isSuperadmin
|
const totalReceived = incomeEntries.reduce((sum, e) => sum + (e.amount || 0), 0);
|
||||||
? incomeEntries.reduce((sum, e) => sum + (e.amount || 0), 0)
|
|
||||||
: campaigns.reduce((sum, c) => sum + (c.budget || 0), 0);
|
|
||||||
const totalExpenses = expenseEntries.reduce((sum, e) => sum + (e.amount || 0), 0);
|
const totalExpenses = expenseEntries.reduce((sum, e) => sum + (e.amount || 0), 0);
|
||||||
const totalReceived = totalIncome;
|
|
||||||
|
|
||||||
const allTracks = await nocodb.list('CampaignTracks', { limit: QUERY_LIMITS.max });
|
const allTracks = await nocodb.list('CampaignTracks', { limit: QUERY_LIMITS.max });
|
||||||
const campaignStats = campaigns.map(c => {
|
const campaignStats = campaigns.map(c => {
|
||||||
@@ -2447,7 +2810,10 @@ app.get('/api/finance/summary', requireAuth, requireRole('superadmin', 'manager'
|
|||||||
conversions: acc.conversions + c.tracks_conversions,
|
conversions: acc.conversions + c.tracks_conversions,
|
||||||
}), { allocated: 0, spent: 0, revenue: 0, impressions: 0, clicks: 0, conversions: 0 });
|
}), { allocated: 0, spent: 0, revenue: 0, impressions: 0, clicks: 0, conversions: 0 });
|
||||||
|
|
||||||
const totalCampaignBudget = campaignStats.reduce((s, c) => s + (c.budget || 0), 0);
|
// Campaign budget = sum of income BudgetEntries with campaign_id set
|
||||||
|
const totalCampaignBudget = incomeEntries
|
||||||
|
.filter(e => e.campaign_id)
|
||||||
|
.reduce((s, e) => s + (e.amount || 0), 0);
|
||||||
|
|
||||||
// Project budget breakdown
|
// Project budget breakdown
|
||||||
let projects = await nocodb.list('Projects', { limit: QUERY_LIMITS.max });
|
let projects = await nocodb.list('Projects', { limit: QUERY_LIMITS.max });
|
||||||
@@ -2465,17 +2831,20 @@ app.get('/api/finance/summary', requireAuth, requireRole('superadmin', 'manager'
|
|||||||
});
|
});
|
||||||
const totalProjectBudget = projectStats.reduce((s, p) => s + p.budget_allocated, 0);
|
const totalProjectBudget = projectStats.reduce((s, p) => s + p.budget_allocated, 0);
|
||||||
|
|
||||||
const unallocated = totalReceived - totalCampaignBudget - totalProjectBudget;
|
// remaining = totalReceived - totalExpenses - totalCampaignBudget - totalProjectBudget
|
||||||
|
const remaining = totalReceived - totalExpenses - totalCampaignBudget - totalProjectBudget;
|
||||||
|
const mainAvailable = remaining;
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
totalReceived, ...totals, totalExpenses,
|
totalReceived, ...totals, totalExpenses,
|
||||||
remaining: totalReceived - totalCampaignBudget - totalProjectBudget - totals.spent - totalExpenses,
|
remaining,
|
||||||
|
mainAvailable,
|
||||||
roi: totals.spent > 0 ? ((totals.revenue - totals.spent) / totals.spent * 100) : 0,
|
roi: totals.spent > 0 ? ((totals.revenue - totals.spent) / totals.spent * 100) : 0,
|
||||||
campaigns: campaignStats,
|
campaigns: campaignStats,
|
||||||
projects: projectStats,
|
projects: projectStats,
|
||||||
totalCampaignBudget,
|
totalCampaignBudget,
|
||||||
totalProjectBudget,
|
totalProjectBudget,
|
||||||
unallocated,
|
unallocated: remaining,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Finance summary error:', err);
|
console.error('Finance summary error:', err);
|
||||||
@@ -2503,9 +2872,19 @@ app.post('/api/campaigns/:id/tracks', requireAuth, requireRole('superadmin', 'ma
|
|||||||
if (!campaign) return res.status(404).json({ error: 'Campaign not found' });
|
if (!campaign) return res.status(404).json({ error: 'Campaign not found' });
|
||||||
|
|
||||||
const { name, type, platform, budget_allocated, status, notes } = req.body;
|
const { name, type, platform, budget_allocated, status, notes } = req.body;
|
||||||
|
const numericAlloc = Number(budget_allocated) || 0;
|
||||||
|
|
||||||
|
// Validate track allocation against campaign available budget
|
||||||
|
if (numericAlloc > 0) {
|
||||||
|
const campAvail = await getCampaignAvailable(req.params.id);
|
||||||
|
if (campAvail.available < numericAlloc) {
|
||||||
|
return res.status(400).json({ error: 'Insufficient campaign budget', available: campAvail.available });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const created = await nocodb.create('CampaignTracks', {
|
const created = await nocodb.create('CampaignTracks', {
|
||||||
name: name || null, type: type || 'organic_social',
|
name: name || null, type: type || 'organic_social',
|
||||||
platform: platform || null, budget_allocated: budget_allocated || 0,
|
platform: platform || null, budget_allocated: numericAlloc,
|
||||||
status: status || 'planned', notes: notes || '',
|
status: status || 'planned', notes: notes || '',
|
||||||
budget_spent: 0, revenue: 0, impressions: 0, clicks: 0, conversions: 0,
|
budget_spent: 0, revenue: 0, impressions: 0, clicks: 0, conversions: 0,
|
||||||
campaign_id: Number(req.params.id),
|
campaign_id: Number(req.params.id),
|
||||||
@@ -2529,6 +2908,19 @@ app.patch('/api/tracks/:id', requireAuth, requireRole('superadmin', 'manager'),
|
|||||||
}
|
}
|
||||||
if (Object.keys(data).length === 0) return res.status(400).json({ error: 'No fields to update' });
|
if (Object.keys(data).length === 0) return res.status(400).json({ error: 'No fields to update' });
|
||||||
|
|
||||||
|
// Validate budget_allocated increase against campaign available budget
|
||||||
|
if (data.budget_allocated !== undefined && existing.campaign_id) {
|
||||||
|
const newAlloc = Number(data.budget_allocated) || 0;
|
||||||
|
const currentAlloc = existing.budget_allocated || 0;
|
||||||
|
const delta = newAlloc - currentAlloc;
|
||||||
|
if (delta > 0) {
|
||||||
|
const campAvail = await getCampaignAvailable(existing.campaign_id);
|
||||||
|
if (campAvail.available < delta) {
|
||||||
|
return res.status(400).json({ error: 'Insufficient campaign budget', available: campAvail.available });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await nocodb.update('CampaignTracks', req.params.id, data);
|
await nocodb.update('CampaignTracks', req.params.id, data);
|
||||||
const track = await nocodb.get('CampaignTracks', req.params.id);
|
const track = await nocodb.get('CampaignTracks', req.params.id);
|
||||||
res.json(track);
|
res.json(track);
|
||||||
@@ -2729,6 +3121,12 @@ app.delete('/api/projects/:id', requireAuth, requireRole('superadmin', 'manager'
|
|||||||
try {
|
try {
|
||||||
const existing = await nocodb.get('Projects', req.params.id);
|
const existing = await nocodb.get('Projects', req.params.id);
|
||||||
if (!existing) return res.status(404).json({ error: 'Project not found' });
|
if (!existing) return res.status(404).json({ error: 'Project not found' });
|
||||||
|
|
||||||
|
// Release budget — null out project_id on linked BudgetEntries
|
||||||
|
const projId = Number(req.params.id);
|
||||||
|
const budgetEntries = await nocodb.list('BudgetEntries', { where: `(project_id,eq,${projId})`, limit: QUERY_LIMITS.max });
|
||||||
|
for (const e of budgetEntries) await nocodb.update('BudgetEntries', e.Id, { project_id: null });
|
||||||
|
|
||||||
await nocodb.delete('Projects', req.params.id);
|
await nocodb.delete('Projects', req.params.id);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -5351,6 +5749,9 @@ app.patch('/api/settings/app', requireAuth, requireRole('superadmin'), (req, res
|
|||||||
}
|
}
|
||||||
appSettings.uploadMaxSizeMB = val;
|
appSettings.uploadMaxSizeMB = val;
|
||||||
}
|
}
|
||||||
|
if (req.body.ceoEmail !== undefined) {
|
||||||
|
appSettings.ceoEmail = String(req.body.ceoEmail).trim();
|
||||||
|
}
|
||||||
saveSettings(appSettings);
|
saveSettings(appSettings);
|
||||||
res.json(appSettings);
|
res.json(appSettings);
|
||||||
});
|
});
|
||||||
@@ -5425,6 +5826,37 @@ async function migrateAuthToNocoDB() {
|
|||||||
|
|
||||||
// ─── START SERVER ───────────────────────────────────────────────
|
// ─── START SERVER ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Idempotent migration: create BudgetEntries for campaigns with budget > 0 that lack one
|
||||||
|
async function migrateCampaignBudgets() {
|
||||||
|
try {
|
||||||
|
const campaigns = await nocodb.list('Campaigns', { limit: QUERY_LIMITS.max });
|
||||||
|
const entries = await nocodb.list('BudgetEntries', { limit: QUERY_LIMITS.max });
|
||||||
|
const campaignIdsWithEntry = new Set(
|
||||||
|
entries
|
||||||
|
.filter(e => e.campaign_id && (e.type || 'income') === 'income')
|
||||||
|
.map(e => Number(e.campaign_id))
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const c of campaigns) {
|
||||||
|
const budget = Number(c.budget) || 0;
|
||||||
|
if (budget <= 0) continue;
|
||||||
|
if (campaignIdsWithEntry.has(c.Id)) continue;
|
||||||
|
|
||||||
|
console.log(`[migrateCampaignBudgets] Creating BudgetEntry for campaign "${c.name}" (Id=${c.Id}, budget=${budget})`);
|
||||||
|
await nocodb.create('BudgetEntries', {
|
||||||
|
type: 'income', amount: budget,
|
||||||
|
campaign_id: c.Id,
|
||||||
|
label: 'Campaign allocation',
|
||||||
|
source: 'Budget migration',
|
||||||
|
date_received: new Date().toISOString().slice(0, 10),
|
||||||
|
category: 'marketing',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('migrateCampaignBudgets error:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function startServer() {
|
async function startServer() {
|
||||||
// Validate required env vars
|
// Validate required env vars
|
||||||
const REQUIRED_ENV = {
|
const REQUIRED_ENV = {
|
||||||
@@ -5461,6 +5893,8 @@ async function startServer() {
|
|||||||
await ensureFKColumns();
|
await ensureFKColumns();
|
||||||
await ensureTextColumns();
|
await ensureTextColumns();
|
||||||
await backfillFKs();
|
await backfillFKs();
|
||||||
|
console.log('Running campaign budget migration...');
|
||||||
|
await migrateCampaignBudgets();
|
||||||
console.log('Checking auth migration...');
|
console.log('Checking auth migration...');
|
||||||
await migrateAuthToNocoDB();
|
await migrateAuthToNocoDB();
|
||||||
console.log('Migration complete.');
|
console.log('Migration complete.');
|
||||||
@@ -5547,7 +5981,7 @@ async function startServer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`Digital Hub API running on http://localhost:${PORT}`);
|
console.log(`Rawaj API running on http://localhost:${PORT}`);
|
||||||
console.log(`Uploads directory: ${uploadsDir}`);
|
console.log(`Uploads directory: ${uploadsDir}`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
/**
|
/**
|
||||||
* setup-tables.js — Creates a new "Digital Hub" base in NocoDB
|
* setup-tables.js — Creates a new "Rawaj" base in NocoDB
|
||||||
* with all 12 tables, fields, and links.
|
* with all 12 tables, fields, and links.
|
||||||
* Run once: node setup-tables.js
|
* Run once: node setup-tables.js
|
||||||
*/
|
*/
|
||||||
@@ -28,9 +28,9 @@ async function request(method, url, body) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function createBase() {
|
async function createBase() {
|
||||||
console.log('Creating "Digital Hub" base...');
|
console.log('Creating "Rawaj" base...');
|
||||||
const data = await request('POST', `${NOCODB_URL}/api/v2/meta/bases/`, {
|
const data = await request('POST', `${NOCODB_URL}/api/v2/meta/bases/`, {
|
||||||
title: 'Digital Hub',
|
title: 'Rawaj',
|
||||||
type: 'database',
|
type: 'database',
|
||||||
});
|
});
|
||||||
console.log(` Base created: ${data.id}`);
|
console.log(` Base created: ${data.id}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user