Compare commits
14 Commits
b17108b321
...
v2-rawaj
| Author | SHA1 | Date | |
|---|---|---|---|
| af91dba268 | |||
| 94ce012837 | |||
| a67b2afb0d | |||
| 378d91648b | |||
| 16a94a2f19 | |||
| eb23931ce0 | |||
| 49e1a796ed | |||
| ce4d6025d7 | |||
| e1d1c392eb | |||
| 3c857856c5 | |||
| 94f448344e | |||
| ba3900bc33 | |||
| 7ace32a070 | |||
| 18785ed901 |
@@ -8,3 +8,4 @@ dist/
|
||||
.env
|
||||
.env.*
|
||||
server/uploads/
|
||||
.superpowers/
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
[ 433ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:5173/api/auth/me:0
|
||||
[ 434ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:5173/api/auth/me:0
|
||||
[ 516ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:5173/api/auth/me:0
|
||||
[ 520ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:5173/api/auth/me:0
|
||||
@@ -0,0 +1,2 @@
|
||||
[ 101ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:5173/api/auth/me:0
|
||||
[ 107ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:5173/api/auth/me:0
|
||||
@@ -0,0 +1,145 @@
|
||||
[ 3110815ms] [ERROR] %o
|
||||
|
||||
%s
|
||||
|
||||
%s
|
||||
ReferenceError: Upload is not defined
|
||||
at ArtefactDetailVersionsTab (http://localhost:5173/src/components/ArtefactDetailVersionsTab.jsx?t=1773656331074:286:42)
|
||||
at Object.react_stack_bottom_frame (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:18509:20)
|
||||
at renderWithHooks (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:5654:24)
|
||||
at updateFunctionComponent (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:7475:21)
|
||||
at beginWork (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:8525:20)
|
||||
at runWithFiberInDEV (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:997:72)
|
||||
at performUnitOfWork (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:12561:98)
|
||||
at workLoopSync (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:12424:43)
|
||||
at renderRootSync (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:12408:13)
|
||||
at performWorkOnRoot (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:11827:37) The above error occurred in the <ArtefactDetailVersionsTab> component. React will try to recreate this component tree from scratch using the error boundary you provided, ErrorBoundary. @ http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:7000
|
||||
[ 3110816ms] [ERROR] ErrorBoundary caught: ReferenceError: Upload is not defined
|
||||
at ArtefactDetailVersionsTab (http://localhost:5173/src/components/ArtefactDetailVersionsTab.jsx?t=1773656331074:286:42)
|
||||
at Object.react_stack_bottom_frame (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:18509:20)
|
||||
at renderWithHooks (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:5654:24)
|
||||
at updateFunctionComponent (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:7475:21)
|
||||
at beginWork (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:8525:20)
|
||||
at runWithFiberInDEV (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:997:72)
|
||||
at performUnitOfWork (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:12561:98)
|
||||
at workLoopSync (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:12424:43)
|
||||
at renderRootSync (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:12408:13)
|
||||
at performWorkOnRoot (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:11827:37) {componentStack:
|
||||
at ArtefactDetailVersionsTab (http://localhos…vite/deps/react-router-dom.js?v=50a373cd:10250:3)} @ http://localhost:5173/src/components/ErrorBoundary.jsx:12
|
||||
[ 7975521ms] [ERROR] Failed to load team: TypeError: Failed to fetch
|
||||
at Object.get (http://localhost:5173/src/utils/api.js:59:18)
|
||||
at loadTeam (http://localhost:5173/src/App.jsx?t=1773661195572:114:30)
|
||||
at loadInitialData (http://localhost:5173/src/App.jsx?t=1773661195572:143:11)
|
||||
at http://localhost:5173/src/App.jsx?t=1773661195572:93:7
|
||||
at Object.react_stack_bottom_frame (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:18567:20)
|
||||
at runWithFiberInDEV (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:997:72)
|
||||
at commitHookEffectListMount (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:9411:163)
|
||||
at commitHookPassiveMountEffects (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:9465:60)
|
||||
at commitPassiveMountOnFiber (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:11040:29)
|
||||
at recursivelyTraversePassiveMountEffects (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:11010:13) @ http://localhost:5173/src/App.jsx?t=1773661195572:118
|
||||
[ 7975522ms] [ERROR] Failed to load teams: TypeError: Failed to fetch
|
||||
at Object.get (http://localhost:5173/src/utils/api.js:59:18)
|
||||
at loadTeams (http://localhost:5173/src/App.jsx?t=1773661195572:125:30)
|
||||
at loadInitialData (http://localhost:5173/src/App.jsx?t=1773661195572:145:11)
|
||||
at http://localhost:5173/src/App.jsx?t=1773661195572:93:7
|
||||
at Object.react_stack_bottom_frame (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:18567:20)
|
||||
at runWithFiberInDEV (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:997:72)
|
||||
at commitHookEffectListMount (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:9411:163)
|
||||
at commitHookPassiveMountEffects (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:9465:60)
|
||||
at commitPassiveMountOnFiber (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:11040:29)
|
||||
at recursivelyTraversePassiveMountEffects (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:11010:13) @ http://localhost:5173/src/App.jsx?t=1773661195572:127
|
||||
[ 7975522ms] [ERROR] Failed to load roles: TypeError: Failed to fetch
|
||||
at Object.get (http://localhost:5173/src/utils/api.js:59:18)
|
||||
at loadRoles (http://localhost:5173/src/App.jsx?t=1773661195572:133:30)
|
||||
at loadInitialData (http://localhost:5173/src/App.jsx?t=1773661195572:146:11)
|
||||
at http://localhost:5173/src/App.jsx?t=1773661195572:93:7
|
||||
at Object.react_stack_bottom_frame (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:18567:20)
|
||||
at runWithFiberInDEV (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:997:72)
|
||||
at commitHookEffectListMount (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:9411:163)
|
||||
at commitHookPassiveMountEffects (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:9465:60)
|
||||
at commitPassiveMountOnFiber (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:11040:29)
|
||||
at recursivelyTraversePassiveMountEffects (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:11010:13) @ http://localhost:5173/src/App.jsx?t=1773661195572:135
|
||||
[11275011ms] [ERROR] %o
|
||||
|
||||
%s
|
||||
|
||||
%s
|
||||
ReferenceError: PortalSelect is not defined
|
||||
at Artefacts (http://localhost:5173/src/pages/Artefacts.jsx?t=1773664494925:936:11)
|
||||
at Object.react_stack_bottom_frame (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:18509:20)
|
||||
at renderWithHooks (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:5654:24)
|
||||
at updateFunctionComponent (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:7475:21)
|
||||
at beginWork (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:8484:199)
|
||||
at runWithFiberInDEV (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:997:72)
|
||||
at performUnitOfWork (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:12561:98)
|
||||
at workLoopSync (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:12424:43)
|
||||
at renderRootSync (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:12408:13)
|
||||
at performWorkOnRoot (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:11827:37) The above error occurred in the <Artefacts> component. React will try to recreate this component tree from scratch using the error boundary you provided, ErrorBoundary. @ http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:7000
|
||||
[11275012ms] [ERROR] ErrorBoundary caught: ReferenceError: PortalSelect is not defined
|
||||
at Artefacts (http://localhost:5173/src/pages/Artefacts.jsx?t=1773664494925:936:11)
|
||||
at Object.react_stack_bottom_frame (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:18509:20)
|
||||
at renderWithHooks (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:5654:24)
|
||||
at updateFunctionComponent (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:7475:21)
|
||||
at beginWork (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:8484:199)
|
||||
at runWithFiberInDEV (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:997:72)
|
||||
at performUnitOfWork (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:12561:98)
|
||||
at workLoopSync (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:12424:43)
|
||||
at renderRootSync (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:12408:13)
|
||||
at performWorkOnRoot (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:11827:37) {componentStack:
|
||||
at Artefacts (http://localhost:5173/src/pages…vite/deps/react-router-dom.js?v=50a373cd:10250:3)} @ http://localhost:5173/src/components/ErrorBoundary.jsx:12
|
||||
[11282373ms] [ERROR] %o
|
||||
|
||||
%s
|
||||
|
||||
%s
|
||||
ReferenceError: PortalSelect is not defined
|
||||
at Artefacts (http://localhost:5173/src/pages/Artefacts.jsx?t=1773664502312:936:11)
|
||||
at Object.react_stack_bottom_frame (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:18509:20)
|
||||
at renderWithHooks (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:5654:24)
|
||||
at updateFunctionComponent (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:7475:21)
|
||||
at beginWork (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:8484:199)
|
||||
at runWithFiberInDEV (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:997:72)
|
||||
at performUnitOfWork (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:12561:98)
|
||||
at workLoopSync (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:12424:43)
|
||||
at renderRootSync (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:12408:13)
|
||||
at performWorkOnRoot (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:11827:37) The above error occurred in the <Artefacts> component. React will try to recreate this component tree from scratch using the error boundary you provided, ErrorBoundary. @ http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:7000
|
||||
[11282374ms] [ERROR] ErrorBoundary caught: ReferenceError: PortalSelect is not defined
|
||||
at Artefacts (http://localhost:5173/src/pages/Artefacts.jsx?t=1773664502312:936:11)
|
||||
at Object.react_stack_bottom_frame (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:18509:20)
|
||||
at renderWithHooks (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:5654:24)
|
||||
at updateFunctionComponent (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:7475:21)
|
||||
at beginWork (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:8484:199)
|
||||
at runWithFiberInDEV (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:997:72)
|
||||
at performUnitOfWork (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:12561:98)
|
||||
at workLoopSync (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:12424:43)
|
||||
at renderRootSync (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:12408:13)
|
||||
at performWorkOnRoot (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:11827:37) {componentStack:
|
||||
at Artefacts (http://localhost:5173/src/pages…vite/deps/react-router-dom.js?v=50a373cd:10250:3)} @ http://localhost:5173/src/components/ErrorBoundary.jsx:12
|
||||
[11301530ms] [ERROR] %o
|
||||
|
||||
%s
|
||||
|
||||
%s
|
||||
ReferenceError: PortalSelect is not defined
|
||||
at Artefacts (http://localhost:5173/src/pages/Artefacts.jsx?t=1773664521350:936:11)
|
||||
at Object.react_stack_bottom_frame (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:18509:20)
|
||||
at renderWithHooks (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:5654:24)
|
||||
at updateFunctionComponent (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:7475:21)
|
||||
at beginWork (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:8484:199)
|
||||
at runWithFiberInDEV (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:997:72)
|
||||
at performUnitOfWork (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:12561:98)
|
||||
at workLoopSync (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:12424:43)
|
||||
at renderRootSync (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:12408:13)
|
||||
at performWorkOnRoot (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:11827:37) The above error occurred in the <Artefacts> component. React will try to recreate this component tree from scratch using the error boundary you provided, ErrorBoundary. @ http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:7000
|
||||
[11301531ms] [ERROR] ErrorBoundary caught: ReferenceError: PortalSelect is not defined
|
||||
at Artefacts (http://localhost:5173/src/pages/Artefacts.jsx?t=1773664521350:936:11)
|
||||
at Object.react_stack_bottom_frame (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:18509:20)
|
||||
at renderWithHooks (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:5654:24)
|
||||
at updateFunctionComponent (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:7475:21)
|
||||
at beginWork (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:8484:199)
|
||||
at runWithFiberInDEV (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:997:72)
|
||||
at performUnitOfWork (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:12561:98)
|
||||
at workLoopSync (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:12424:43)
|
||||
at renderRootSync (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:12408:13)
|
||||
at performWorkOnRoot (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:11827:37) {componentStack:
|
||||
at Artefacts (http://localhost:5173/src/pages…vite/deps/react-router-dom.js?v=50a373cd:10250:3)} @ http://localhost:5173/src/components/ErrorBoundary.jsx:12
|
||||
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 227 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 190 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 207 KiB |
@@ -7,7 +7,7 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=IBM+Plex+Sans+Arabic:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<title>Digital Hub</title>
|
||||
<title>Rawaj</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useKeyboardShortcuts, DEFAULT_SHORTCUTS } from './hooks/useKeyboardShor
|
||||
// Lazy-loaded page components
|
||||
const Dashboard = lazy(() => import('./pages/Dashboard'))
|
||||
const PostProduction = lazy(() => import('./pages/PostProduction'))
|
||||
const PostDetail = lazy(() => import('./pages/PostDetail'))
|
||||
const Assets = lazy(() => import('./pages/Assets'))
|
||||
const Campaigns = lazy(() => import('./pages/Campaigns'))
|
||||
const CampaignDetail = lazy(() => import('./pages/CampaignDetail'))
|
||||
@@ -37,6 +38,7 @@ const PublicIssueSubmit = lazy(() => import('./pages/PublicIssueSubmit'))
|
||||
const PublicIssueTracker = lazy(() => import('./pages/PublicIssueTracker'))
|
||||
const Translations = lazy(() => import('./pages/Translations'))
|
||||
const PublicTranslationReview = lazy(() => import('./pages/PublicTranslationReview'))
|
||||
const PublicBudgetApproval = lazy(() => import('./pages/PublicBudgetApproval'))
|
||||
const ForgotPassword = lazy(() => import('./pages/ForgotPassword'))
|
||||
const ResetPassword = lazy(() => import('./pages/ResetPassword'))
|
||||
|
||||
@@ -161,7 +163,7 @@ function AppContent() {
|
||||
<AppContext.Provider value={{ currentUser: user, teamMembers, brands, loadTeam, getBrandName, teams, loadTeams, roles, loadRoles }}>
|
||||
{/* Profile completion prompt */}
|
||||
{showProfilePrompt && (
|
||||
<div className="fixed top-4 right-4 z-50 bg-amber-50 border-2 border-amber-400 rounded-xl shadow-lg p-4 max-w-md animate-fade-in">
|
||||
<div className="fixed top-4 end-4 z-50 bg-amber-50 border-2 border-amber-400 rounded-xl shadow-lg p-4 max-w-md animate-fade-in">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-amber-400 flex items-center justify-center text-white shrink-0">
|
||||
⚠️
|
||||
@@ -288,7 +290,7 @@ function AppContent() {
|
||||
{showTutorial && <Tutorial onComplete={handleTutorialComplete} />}
|
||||
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={<div className="min-h-screen bg-surface-secondary flex items-center justify-center"><div className="animate-pulse text-text-tertiary">Loading...</div></div>}>
|
||||
<Suspense fallback={<div className="min-h-screen flex items-center justify-center"><div className="w-8 h-8 border-2 border-brand-primary/30 border-t-brand-primary rounded-full animate-spin" /></div>}>
|
||||
<Routes>
|
||||
<Route path="/login" element={user ? <Navigate to="/" replace /> : <Login />} />
|
||||
<Route path="/forgot-password" element={user ? <Navigate to="/" replace /> : <ForgotPassword />} />
|
||||
@@ -298,9 +300,11 @@ function AppContent() {
|
||||
<Route path="/submit-issue" element={<PublicIssueSubmit />} />
|
||||
<Route path="/track/:token" element={<PublicIssueTracker />} />
|
||||
<Route path="/review-translation/:token" element={<PublicTranslationReview />} />
|
||||
<Route path="/approve-budget/:token" element={<PublicBudgetApproval />} />
|
||||
<Route path="/" element={user ? <Layout /> : <Navigate to="/login" replace />}>
|
||||
<Route index element={<Dashboard />} />
|
||||
{hasModule('marketing') && <>
|
||||
<Route path="posts/:id" element={<PostDetail />} />
|
||||
<Route path="posts" element={<PostProduction />} />
|
||||
<Route path="calendar" element={<PostCalendar />} />
|
||||
<Route path="artefacts" element={<Artefacts />} />
|
||||
|
||||
@@ -1,30 +1,51 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Check, ChevronDown, X } from 'lucide-react'
|
||||
|
||||
export default function ApproverMultiSelect({ users = [], selected = [], onChange }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [dropUp, setDropUp] = useState(false)
|
||||
const wrapperRef = useRef(null)
|
||||
const triggerRef = useRef(null)
|
||||
const dropdownRef = useRef(null)
|
||||
const [pos, setPos] = useState({ top: 0, left: 0, width: 0 })
|
||||
|
||||
const updatePosition = useCallback(() => {
|
||||
if (!triggerRef.current) return
|
||||
const rect = triggerRef.current.getBoundingClientRect()
|
||||
const spaceBelow = window.innerHeight - rect.bottom
|
||||
const dropdownHeight = Math.min(users.length * 40 + 8, 220)
|
||||
const showAbove = spaceBelow < dropdownHeight && rect.top > dropdownHeight
|
||||
|
||||
setPos({
|
||||
top: showAbove ? rect.top - dropdownHeight - 4 : rect.bottom + 4,
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
})
|
||||
}, [users.length])
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const handleClick = (e) => {
|
||||
if (wrapperRef.current && !wrapperRef.current.contains(e.target)) {
|
||||
if (triggerRef.current?.contains(e.target)) return
|
||||
if (dropdownRef.current?.contains(e.target)) return
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
return () => document.removeEventListener('mousedown', handleClick)
|
||||
}, [open])
|
||||
const handleEsc = (e) => { if (e.key === 'Escape') setOpen(false) }
|
||||
const handleScroll = () => updatePosition()
|
||||
|
||||
// Detect if dropdown should open upward
|
||||
useEffect(() => {
|
||||
if (!open || !wrapperRef.current) return
|
||||
const rect = wrapperRef.current.getBoundingClientRect()
|
||||
const spaceBelow = window.innerHeight - rect.bottom
|
||||
setDropUp(spaceBelow < 220)
|
||||
}, [open])
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
document.addEventListener('keydown', handleEsc)
|
||||
window.addEventListener('scroll', handleScroll, true)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClick)
|
||||
document.removeEventListener('keydown', handleEsc)
|
||||
window.removeEventListener('scroll', handleScroll, true)
|
||||
}
|
||||
}, [open, updatePosition])
|
||||
|
||||
const handleOpen = () => {
|
||||
updatePosition()
|
||||
setOpen(!open)
|
||||
}
|
||||
|
||||
const toggle = (userId) => {
|
||||
const id = String(userId)
|
||||
@@ -39,9 +60,10 @@ export default function ApproverMultiSelect({ users = [], selected = [], onChang
|
||||
const selectedUsers = selected.map(id => users.find(u => String(u._id || u.id || u.Id) === String(id))).filter(Boolean)
|
||||
|
||||
return (
|
||||
<div className="relative" ref={wrapperRef}>
|
||||
<>
|
||||
<div
|
||||
onClick={() => setOpen(!open)}
|
||||
ref={triggerRef}
|
||||
onClick={handleOpen}
|
||||
className={`w-full min-h-[38px] px-3 py-1.5 text-sm border rounded-lg bg-surface cursor-pointer flex items-center flex-wrap gap-1.5 transition-colors ${
|
||||
open ? 'border-brand-primary ring-2 ring-brand-primary/20' : 'border-border'
|
||||
}`}
|
||||
@@ -58,16 +80,21 @@ export default function ApproverMultiSelect({ users = [], selected = [], onChang
|
||||
<button
|
||||
type="button"
|
||||
onClick={e => { e.stopPropagation(); remove(u._id || u.id || u.Id) }}
|
||||
className="hover:text-amber-950"
|
||||
className="hover:text-amber-950 transition-colors"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<ChevronDown className={`w-4 h-4 text-text-tertiary ml-auto shrink-0 transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||
<ChevronDown className={`w-4 h-4 text-text-tertiary ms-auto shrink-0 transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||
</div>
|
||||
{open && (
|
||||
<div className={`absolute z-50 w-full bg-surface border border-border rounded-lg shadow-lg max-h-48 overflow-y-auto ${dropUp ? 'bottom-full mb-1' : 'top-full mt-1'}`}>
|
||||
|
||||
{open && createPortal(
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="fixed z-[99999] bg-surface border border-border rounded-lg shadow-lg max-h-[220px] overflow-y-auto"
|
||||
style={{ top: pos.top, left: pos.left, width: pos.width }}
|
||||
>
|
||||
{users.map(u => {
|
||||
const uid = String(u._id || u.id || u.Id)
|
||||
const isSelected = selected.includes(uid)
|
||||
@@ -76,7 +103,7 @@ export default function ApproverMultiSelect({ users = [], selected = [], onChang
|
||||
key={uid}
|
||||
type="button"
|
||||
onClick={() => toggle(uid)}
|
||||
className={`w-full text-left px-3 py-2 text-sm hover:bg-surface-secondary flex items-center justify-between ${
|
||||
className={`w-full text-start px-3 py-2 text-sm hover:bg-surface-secondary flex items-center justify-between transition-colors ${
|
||||
isSelected ? 'text-brand-primary font-medium' : 'text-text-primary'
|
||||
}`}
|
||||
>
|
||||
@@ -88,8 +115,9 @@ export default function ApproverMultiSelect({ users = [], selected = [], onChang
|
||||
{users.length === 0 && (
|
||||
<div className="px-3 py-4 text-sm text-text-tertiary text-center">No users available</div>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useState, useEffect, useContext } from 'react'
|
||||
import { Plus, Copy, Check, ExternalLink, Upload, Globe, Trash2, FileText, Image as ImageIcon, Film, Sparkles, MessageSquare, Save, FileEdit, Layers, ShieldCheck } from 'lucide-react'
|
||||
import { Copy, Check, ExternalLink, Trash2, FileText, Image as ImageIcon, Film, Sparkles, MessageSquare, Save, FileEdit, Layers, ShieldCheck } from 'lucide-react'
|
||||
import { AppContext } from '../App'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import { api } from '../utils/api'
|
||||
import Modal from './Modal'
|
||||
import TabbedModal from './TabbedModal'
|
||||
import { useToast } from './ToastContainer'
|
||||
import ArtefactVersionTimeline from './ArtefactVersionTimeline'
|
||||
import ApproverMultiSelect from './ApproverMultiSelect'
|
||||
import PortalSelect from './PortalSelect'
|
||||
import { ArtefactDetailVersionsTab } from './ArtefactDetailVersionsTab'
|
||||
|
||||
const STATUS_COLORS = {
|
||||
draft: 'bg-surface-tertiary text-text-secondary',
|
||||
@@ -17,13 +17,6 @@ const STATUS_COLORS = {
|
||||
revision_requested: 'bg-orange-100 text-orange-700',
|
||||
}
|
||||
|
||||
const AVAILABLE_LANGUAGES = [
|
||||
{ code: 'AR', label: '\u0627\u0644\u0639\u0631\u0628\u064A\u0629' },
|
||||
{ code: 'EN', label: 'English' },
|
||||
{ code: 'FR', label: 'Fran\u00E7ais' },
|
||||
{ code: 'ID', label: 'Bahasa Indonesia' },
|
||||
]
|
||||
|
||||
const TYPE_ICONS = {
|
||||
copy: FileText,
|
||||
design: ImageIcon,
|
||||
@@ -31,7 +24,11 @@ const TYPE_ICONS = {
|
||||
other: Sparkles,
|
||||
}
|
||||
|
||||
export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDelete, projects = [], campaigns = [], assignableUsers = [] }) {
|
||||
const parseApproverIds = (a) =>
|
||||
a.approvers?.map(u => String(u.id)) ||
|
||||
(a.approver_ids ? a.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : [])
|
||||
|
||||
export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDelete, assignableUsers = [] }) {
|
||||
const { t } = useLanguage()
|
||||
const { brands } = useContext(AppContext)
|
||||
const toast = useToast()
|
||||
@@ -42,40 +39,19 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [freshReviewUrl, setFreshReviewUrl] = useState('')
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState('details')
|
||||
const [activeTab, setActiveTab] = useState(artefact.type === 'copy' ? 'versions' : 'details')
|
||||
|
||||
// Editable fields
|
||||
// Editable fields — seeded from artefact prop; component is keyed by artefact._id at call site
|
||||
const [editTitle, setEditTitle] = useState(artefact.title || '')
|
||||
const [editDescription, setEditDescription] = useState(artefact.description || '')
|
||||
const [editProjectId, setEditProjectId] = useState(artefact.project_id || '')
|
||||
const [editCampaignId, setEditCampaignId] = useState(artefact.campaign_id || '')
|
||||
const [editApproverIds, setEditApproverIds] = useState(
|
||||
artefact.approvers?.map(a => String(a.id)) || (artefact.approver_ids ? artefact.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : [])
|
||||
)
|
||||
const [editApproverIds, setEditApproverIds] = useState(() => parseApproverIds(artefact))
|
||||
const reviewUrl = freshReviewUrl || (artefact.approval_token ? `${window.location.origin}/review/${artefact.approval_token}` : '')
|
||||
const [savingDraft, setSavingDraft] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [confirmDeleteLangId, setConfirmDeleteLangId] = useState(null)
|
||||
const [confirmDeleteAttId, setConfirmDeleteAttId] = useState(null)
|
||||
const [showDeleteArtefactConfirm, setShowDeleteArtefactConfirm] = useState(false)
|
||||
|
||||
// Language management (for copy type)
|
||||
const [showLanguageModal, setShowLanguageModal] = useState(false)
|
||||
const [languageForm, setLanguageForm] = useState({ language_code: '', language_label: '', content: '' })
|
||||
const [savingLanguage, setSavingLanguage] = useState(false)
|
||||
|
||||
// New version modal
|
||||
const [showNewVersionModal, setShowNewVersionModal] = useState(false)
|
||||
const [newVersionNotes, setNewVersionNotes] = useState('')
|
||||
const [copyFromPrevious, setCopyFromPrevious] = useState(false)
|
||||
const [creatingVersion, setCreatingVersion] = useState(false)
|
||||
|
||||
// File upload (for design/video)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
|
||||
// Video inline (Drive link input)
|
||||
const [driveUrl, setDriveUrl] = useState('')
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
const [uploadProgress, setUploadProgress] = useState(0)
|
||||
|
||||
// Comments
|
||||
@@ -87,16 +63,6 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
loadVersions()
|
||||
}, [artefact.Id])
|
||||
|
||||
useEffect(() => {
|
||||
setEditTitle(artefact.title || '')
|
||||
setEditDescription(artefact.description || '')
|
||||
setEditProjectId(artefact.project_id || '')
|
||||
setEditCampaignId(artefact.campaign_id || '')
|
||||
setEditApproverIds(
|
||||
artefact.approvers?.map(a => String(a.id)) || (artefact.approver_ids ? artefact.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : [])
|
||||
)
|
||||
}, [artefact.Id])
|
||||
|
||||
const loadVersions = async () => {
|
||||
try {
|
||||
const res = await api.get(`/artefacts/${artefact.Id}/versions`)
|
||||
@@ -137,57 +103,16 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
loadVersionData(version.Id)
|
||||
}
|
||||
|
||||
const handleCreateVersion = async () => {
|
||||
setCreatingVersion(true)
|
||||
try {
|
||||
await api.post(`/artefacts/${artefact.Id}/versions`, {
|
||||
notes: newVersionNotes || `Version ${(versions[versions.length - 1]?.version_number || 0) + 1}`,
|
||||
copy_from_previous: artefact.type === 'copy' ? copyFromPrevious : false,
|
||||
})
|
||||
|
||||
toast.success(t('artefacts.versionCreated'))
|
||||
setShowNewVersionModal(false)
|
||||
setNewVersionNotes('')
|
||||
setCopyFromPrevious(false)
|
||||
loadVersions()
|
||||
onUpdate()
|
||||
} catch (err) {
|
||||
console.error('Create version failed:', err)
|
||||
toast.error(t('artefacts.failedCreateVersion'))
|
||||
} finally {
|
||||
setCreatingVersion(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddLanguage = async () => {
|
||||
if (!languageForm.language_code || !languageForm.language_label || !languageForm.content) {
|
||||
toast.error(t('artefacts.allFieldsRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
setSavingLanguage(true)
|
||||
try {
|
||||
const handleAddLanguage = async (languageForm) => {
|
||||
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) => {
|
||||
try {
|
||||
await api.delete(`/artefact-version-texts/${textId}`)
|
||||
toast.success(t('artefacts.languageDeleted'))
|
||||
loadVersionData(selectedVersion.Id)
|
||||
} catch (err) {
|
||||
toast.error(t('artefacts.failedDeleteLanguage'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileUpload = async (fileOrEvent) => {
|
||||
@@ -215,16 +140,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
}
|
||||
}
|
||||
|
||||
const handleVideoDrop = (e) => {
|
||||
e.preventDefault()
|
||||
setDragOver(false)
|
||||
const file = e.dataTransfer.files?.[0]
|
||||
if (file && file.type.startsWith('video/')) {
|
||||
handleFileUpload(file)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddDriveVideo = async () => {
|
||||
const handleAddDriveVideo = async (driveUrl) => {
|
||||
if (!driveUrl.trim()) {
|
||||
toast.error(t('artefacts.enterDriveUrl'))
|
||||
return
|
||||
@@ -236,7 +152,6 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
drive_url: driveUrl,
|
||||
})
|
||||
toast.success(t('artefacts.videoLinkAdded'))
|
||||
setDriveUrl('')
|
||||
loadVersionData(selectedVersion.Id)
|
||||
} catch (err) {
|
||||
console.error('Add Drive link failed:', err)
|
||||
@@ -247,13 +162,9 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
}
|
||||
|
||||
const handleDeleteAttachment = async (attId) => {
|
||||
try {
|
||||
await api.delete(`/artefact-attachments/${attId}`)
|
||||
toast.success(t('artefacts.attachmentDeleted'))
|
||||
loadVersionData(selectedVersion.Id)
|
||||
} catch (err) {
|
||||
toast.error(t('artefacts.failedDeleteAttachment'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmitReview = async () => {
|
||||
@@ -325,6 +236,12 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateLanguage = async (textId, content) => {
|
||||
await api.patch(`/artefact-version-texts/${textId}`, { content })
|
||||
toast.success(t('artefacts.languageAdded'))
|
||||
loadVersionData(selectedVersion.Id)
|
||||
}
|
||||
|
||||
const handleDeleteArtefact = async () => {
|
||||
setDeleting(true)
|
||||
try {
|
||||
@@ -358,10 +275,10 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
const TypeIcon = TYPE_ICONS[artefact.type] || Sparkles
|
||||
|
||||
const tabs = [
|
||||
{ key: 'details', label: t('artefacts.details') || 'Details', icon: FileEdit },
|
||||
{ key: 'versions', label: t('artefacts.versions') || 'Versions', icon: Layers, badge: versions.length },
|
||||
{ key: 'discussion', label: t('artefacts.comments') || 'Discussion', icon: MessageSquare, badge: comments.length },
|
||||
{ key: 'review', label: t('artefacts.review') || 'Review', icon: ShieldCheck },
|
||||
{ key: 'details', label: t('artefacts.details'), icon: FileEdit },
|
||||
{ key: 'versions', label: t('artefacts.versions'), icon: Layers, badge: versions.length },
|
||||
{ key: 'discussion', label: t('artefacts.comments'), icon: MessageSquare, badge: comments.length },
|
||||
{ key: 'review', label: t('artefacts.review'), icon: ShieldCheck },
|
||||
]
|
||||
|
||||
if (loading) {
|
||||
@@ -380,7 +297,6 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
onClose={onClose}
|
||||
size="xl"
|
||||
header={
|
||||
<>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-brand-primary/10 flex items-center justify-center shrink-0">
|
||||
<TypeIcon className="w-5 h-5 text-brand-primary" />
|
||||
@@ -405,7 +321,6 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
@@ -425,6 +340,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{activeTab === 'details' && (
|
||||
<button
|
||||
onClick={handleSaveDraft}
|
||||
disabled={savingDraft}
|
||||
@@ -434,6 +350,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
<Save className="w-3.5 h-3.5" />
|
||||
{savingDraft ? t('artefacts.savingDraft') : t('artefacts.saveDraft')}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
@@ -452,262 +369,53 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Project & Campaign dropdowns */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Metadata row */}
|
||||
<div className="grid grid-cols-2 gap-4 pt-1">
|
||||
{/* Brand */}
|
||||
{(artefact.brand_id || artefact.brandId) && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1.5">{t('artefacts.project')}</h4>
|
||||
<select
|
||||
value={editProjectId}
|
||||
onChange={e => {
|
||||
setEditProjectId(e.target.value)
|
||||
handleUpdateField('project_id', e.target.value)
|
||||
}}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 bg-surface"
|
||||
>
|
||||
<option value="">—</option>
|
||||
{projects.map(p => <option key={p.Id || p._id || p.id} value={p.Id || p._id || p.id}>{p.name || p.title}</option>)}
|
||||
</select>
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1">{t('posts.brand')}</h4>
|
||||
<p className="text-sm text-text-primary">
|
||||
{brands.find(b => String(b._id) === String(artefact.brand_id || artefact.brandId))?.name || `#${artefact.brand_id || artefact.brandId}`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{/* Created date */}
|
||||
{artefact.CreatedAt && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1.5">{t('artefacts.campaign')}</h4>
|
||||
<select
|
||||
value={editCampaignId}
|
||||
onChange={e => {
|
||||
setEditCampaignId(e.target.value)
|
||||
handleUpdateField('campaign_id', e.target.value)
|
||||
}}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 bg-surface"
|
||||
>
|
||||
<option value="">—</option>
|
||||
{campaigns.map(c => <option key={c.Id || c._id || c.id} value={c.Id || c._id || c.id}>{c.name || c.title}</option>)}
|
||||
</select>
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1">{t('common.created')}</h4>
|
||||
<p className="text-sm text-text-secondary">{new Date(artefact.CreatedAt).toLocaleDateString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Approvers */}
|
||||
)}
|
||||
{/* Linked post */}
|
||||
{(artefact.post_id || artefact.postId) && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1.5">{t('artefacts.approversLabel')}</h4>
|
||||
<ApproverMultiSelect
|
||||
users={assignableUsers}
|
||||
selected={editApproverIds}
|
||||
onChange={ids => {
|
||||
setEditApproverIds(ids)
|
||||
handleUpdateField('approver_ids', ids.length > 0 ? ids.join(',') : '')
|
||||
}}
|
||||
/>
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1">{t('artefacts.linkedPost')}</h4>
|
||||
<p className="text-sm text-text-secondary">{t('artefacts.post')} #{artefact.post_id || artefact.postId}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Versions Tab */}
|
||||
{activeTab === 'versions' && (
|
||||
<div className="p-6 space-y-5">
|
||||
{/* Version Timeline */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase">{t('artefacts.versions')}</h4>
|
||||
<button
|
||||
onClick={() => setShowNewVersionModal(true)}
|
||||
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
{t('artefacts.newVersion')}
|
||||
</button>
|
||||
</div>
|
||||
<ArtefactVersionTimeline
|
||||
<ArtefactDetailVersionsTab
|
||||
artefact={artefact}
|
||||
versions={versions}
|
||||
activeVersionId={selectedVersion?.Id}
|
||||
selectedVersion={selectedVersion}
|
||||
versionData={versionData}
|
||||
uploading={uploading}
|
||||
uploadProgress={uploadProgress}
|
||||
onSelectVersion={handleSelectVersion}
|
||||
artefactType={artefact.type}
|
||||
onAddLanguage={handleAddLanguage}
|
||||
onUpdateLanguage={handleUpdateLanguage}
|
||||
onDeleteLanguage={handleDeleteLanguage}
|
||||
onFileUpload={handleFileUpload}
|
||||
onDeleteAttachment={handleDeleteAttachment}
|
||||
onAddDriveVideo={handleAddDriveVideo}
|
||||
getDriveEmbedUrl={getDriveEmbedUrl}
|
||||
/>
|
||||
</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 */}
|
||||
@@ -762,7 +470,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-8 text-sm text-text-tertiary">
|
||||
{t('artefacts.selectVersionFirst') || 'Select a version first to view comments.'}
|
||||
{t('artefacts.selectVersionFirst')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -771,11 +479,28 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
{/* Review Tab */}
|
||||
{activeTab === 'review' && (
|
||||
<div className="p-6 space-y-5">
|
||||
{/* Reviewer Selection (single) */}
|
||||
{['draft', 'revision_requested', 'rejected'].includes(artefact.status) && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1.5">{t('artefacts.reviewer')}</h4>
|
||||
<PortalSelect
|
||||
value={editApproverIds[0] || ''}
|
||||
onChange={val => {
|
||||
const ids = val ? [val] : []
|
||||
setEditApproverIds(ids)
|
||||
handleUpdateField('approver_ids', val || '')
|
||||
}}
|
||||
options={[{ value: '', label: t('artefacts.selectReviewer') }, ...assignableUsers.map(u => ({ value: u.id || u.Id, label: u.name }))]}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit for Review */}
|
||||
{['draft', 'revision_requested', 'rejected'].includes(artefact.status) && (
|
||||
<button
|
||||
onClick={handleSubmitReview}
|
||||
disabled={submitting}
|
||||
disabled={submitting || editApproverIds.length === 0}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-amber-500 text-white rounded-lg hover:bg-amber-600 transition-colors font-medium disabled:opacity-50"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
@@ -824,137 +549,16 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state when no review actions available */}
|
||||
{!['draft', 'revision_requested', 'rejected'].includes(artefact.status) && !reviewUrl && !artefact.feedback && !(artefact.status === 'approved' && artefact.approved_by_name) && (
|
||||
{/* Empty state: pending_review or unknown status with no review info */}
|
||||
{artefact.status === 'pending_review' && !reviewUrl && !artefact.feedback && (
|
||||
<div className="text-center py-8 text-sm text-text-tertiary">
|
||||
{artefact.status === 'pending_review'
|
||||
? t('artefacts.pendingReviewInfo') || 'This artefact is currently pending review.'
|
||||
: t('artefacts.noReviewInfo') || 'No review information available.'}
|
||||
{t('artefacts.pendingReviewInfo')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TabbedModal>
|
||||
|
||||
{/* Language Modal */}
|
||||
<Modal isOpen={showLanguageModal} onClose={() => setShowLanguageModal(false)} title={t('artefacts.addLanguage')} size="md">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('artefacts.languageLabel')} *</label>
|
||||
<select
|
||||
value={languageForm.language_code}
|
||||
onChange={e => {
|
||||
const lang = AVAILABLE_LANGUAGES.find(l => l.code === e.target.value)
|
||||
if (lang) setLanguageForm(f => ({ ...f, language_code: lang.code, language_label: lang.label }))
|
||||
else setLanguageForm(f => ({ ...f, language_code: '', language_label: '' }))
|
||||
}}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||
>
|
||||
<option value="">{t('artefacts.selectLanguage')}</option>
|
||||
{AVAILABLE_LANGUAGES
|
||||
.filter(lang => !(versionData?.texts || []).some(t => t.language_code === lang.code))
|
||||
.map(lang => (
|
||||
<option key={lang.code} value={lang.code}>{lang.label} ({lang.code})</option>
|
||||
))
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('artefacts.contentLabel')} *</label>
|
||||
<textarea
|
||||
value={languageForm.content}
|
||||
onChange={e => setLanguageForm(f => ({ ...f, content: e.target.value }))}
|
||||
rows={8}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 font-sans"
|
||||
placeholder={t('artefacts.enterContent')}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
|
||||
<button
|
||||
onClick={() => setShowLanguageModal(false)}
|
||||
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAddLanguage}
|
||||
disabled={savingLanguage}
|
||||
className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 shadow-sm"
|
||||
>
|
||||
{savingLanguage ? t('header.saving') : t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* New Version Modal */}
|
||||
<Modal isOpen={showNewVersionModal} onClose={() => setShowNewVersionModal(false)} title={t('artefacts.createNewVersion')} size="sm">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('artefacts.versionNotes')}</label>
|
||||
<textarea
|
||||
value={newVersionNotes}
|
||||
onChange={e => setNewVersionNotes(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||
placeholder={t('artefacts.whatChanged')}
|
||||
/>
|
||||
</div>
|
||||
{artefact.type === 'copy' && versions.length > 0 && (
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={copyFromPrevious}
|
||||
onChange={e => setCopyFromPrevious(e.target.checked)}
|
||||
className="w-4 h-4 text-brand-primary border-border rounded focus:ring-brand-primary"
|
||||
/>
|
||||
<span className="text-sm text-text-secondary">{t('artefacts.copyLanguages')}</span>
|
||||
</label>
|
||||
)}
|
||||
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
|
||||
<button
|
||||
onClick={() => setShowNewVersionModal(false)}
|
||||
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreateVersion}
|
||||
disabled={creatingVersion}
|
||||
className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 shadow-sm"
|
||||
>
|
||||
{creatingVersion ? t('artefacts.creatingVersion') : t('artefacts.createVersion')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Delete Language Confirmation */}
|
||||
<Modal
|
||||
isOpen={!!confirmDeleteLangId}
|
||||
onClose={() => setConfirmDeleteLangId(null)}
|
||||
title={t('artefacts.deleteLanguage')}
|
||||
isConfirm
|
||||
danger
|
||||
onConfirm={() => handleDeleteLanguage(confirmDeleteLangId)}
|
||||
confirmText={t('common.delete')}
|
||||
>
|
||||
{t('artefacts.deleteLanguageDesc')}
|
||||
</Modal>
|
||||
|
||||
{/* Delete Attachment Confirmation */}
|
||||
<Modal
|
||||
isOpen={!!confirmDeleteAttId}
|
||||
onClose={() => setConfirmDeleteAttId(null)}
|
||||
title={t('artefacts.deleteAttachment')}
|
||||
isConfirm
|
||||
danger
|
||||
onConfirm={() => handleDeleteAttachment(confirmDeleteAttId)}
|
||||
confirmText={t('common.delete')}
|
||||
>
|
||||
{t('artefacts.deleteAttachmentDesc')}
|
||||
</Modal>
|
||||
|
||||
{/* Delete Artefact Confirmation */}
|
||||
<Modal
|
||||
isOpen={showDeleteArtefactConfirm}
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
import { useState } from 'react'
|
||||
import { Trash2, Globe, Image as ImageIcon, Pencil } from 'lucide-react'
|
||||
import PortalSelect from './PortalSelect'
|
||||
import UploadZone from './UploadZone'
|
||||
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,
|
||||
onAddLanguage,
|
||||
onUpdateLanguage,
|
||||
onDeleteLanguage,
|
||||
onFileUpload,
|
||||
onDeleteAttachment,
|
||||
onAddDriveVideo,
|
||||
getDriveEmbedUrl,
|
||||
}) {
|
||||
const { t } = useLanguage()
|
||||
|
||||
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 [editingLangText, setEditingLangText] = useState(null) // { Id, language_code, language_label, content }
|
||||
const [editLangContent, setEditLangContent] = useState('')
|
||||
const [savingEditLang, setSavingEditLang] = useState(false)
|
||||
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
const [driveUrl, setDriveUrl] = useState('')
|
||||
|
||||
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 handleSaveEditLang = async () => {
|
||||
if (!editingLangText || !editLangContent.trim()) return
|
||||
setSavingEditLang(true)
|
||||
try {
|
||||
await onUpdateLanguage(editingLangText.Id, editLangContent)
|
||||
setEditingLangText(null)
|
||||
} finally {
|
||||
setSavingEditLang(false)
|
||||
}
|
||||
}
|
||||
|
||||
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 — only shown when there are multiple rounds */}
|
||||
{versions.length > 1 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-3">{t('artefacts.versions')}</h4>
|
||||
<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"
|
||||
>
|
||||
<Globe 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>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => { setEditingLangText(text); setEditLangContent(text.content || '') }}
|
||||
className="p-1 text-text-tertiary hover:text-brand-primary rounded transition-colors"
|
||||
title={t('common.edit')}
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmDeleteLangId(text.Id)}
|
||||
className="p-1 text-red-500 hover:text-red-700 rounded transition-colors"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</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>
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-3">{t('artefacts.imagesLabel')}</h4>
|
||||
|
||||
{versionData.attachments && versionData.attachments.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-3 mb-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"
|
||||
loading="lazy"
|
||||
/>
|
||||
<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>
|
||||
)}
|
||||
<UploadZone
|
||||
onUpload={onFileUpload}
|
||||
accept="image/*"
|
||||
uploading={uploading}
|
||||
progress={uploadProgress}
|
||||
label={t('artefacts.dropOrClickImage') || 'Drop images here or click to upload'}
|
||||
hint={t('artefacts.imageFormats') || 'PNG, JPG, WebP'}
|
||||
compact={versionData.attachments?.length > 0}
|
||||
/>
|
||||
</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 */}
|
||||
<UploadZone
|
||||
onUpload={onFileUpload}
|
||||
accept="video/*"
|
||||
uploading={uploading}
|
||||
progress={uploadProgress}
|
||||
label={t('artefacts.dropOrClickVideo')}
|
||||
hint={t('artefacts.videoFormats')}
|
||||
/>
|
||||
|
||||
{/* 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>
|
||||
<PortalSelect
|
||||
value={languageForm.language_code}
|
||||
onChange={val => {
|
||||
const lang = AVAILABLE_LANGUAGES.find(l => l.code === val)
|
||||
if (lang) setLanguageForm(f => ({ ...f, language_code: lang.code, language_label: lang.label }))
|
||||
else setLanguageForm(f => ({ ...f, language_code: '', language_label: '' }))
|
||||
}}
|
||||
options={[
|
||||
{ value: '', label: t('artefacts.selectLanguage') },
|
||||
...AVAILABLE_LANGUAGES
|
||||
.filter(lang => !(versionData?.texts || []).some(t => t.language_code === lang.code))
|
||||
.map(lang => ({ value: lang.code, label: `${lang.label} (${lang.code})` }))
|
||||
]}
|
||||
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"
|
||||
/>
|
||||
</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>
|
||||
|
||||
{/* Edit Language Modal */}
|
||||
<Modal isOpen={!!editingLangText} onClose={() => setEditingLangText(null)} title={t('artefacts.editLanguage')} size="md">
|
||||
<div className="space-y-4">
|
||||
{editingLangText && (
|
||||
<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">
|
||||
{editingLangText.language_code}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-text-primary">{editingLangText.language_label}</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('artefacts.contentLabel')}</label>
|
||||
<textarea
|
||||
value={editLangContent}
|
||||
onChange={e => setEditLangContent(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"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
|
||||
<button
|
||||
onClick={() => setEditingLangText(null)}
|
||||
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveEditLang}
|
||||
disabled={savingEditLang}
|
||||
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"
|
||||
>
|
||||
{savingEditLang ? t('header.saving') : t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Delete Language Confirmation */}
|
||||
<Modal
|
||||
isOpen={!!confirmDeleteLangId}
|
||||
onClose={() => setConfirmDeleteLangId(null)}
|
||||
title={t('artefacts.deleteLanguage')}
|
||||
isConfirm
|
||||
danger
|
||||
onConfirm={() => handleDeleteLanguage(confirmDeleteLangId)}
|
||||
confirmText={t('common.delete')}
|
||||
>
|
||||
{t('artefacts.deleteLanguageDesc')}
|
||||
</Modal>
|
||||
|
||||
{/* Delete Attachment Confirmation */}
|
||||
<Modal
|
||||
isOpen={!!confirmDeleteAttId}
|
||||
onClose={() => setConfirmDeleteAttId(null)}
|
||||
title={t('artefacts.deleteAttachment')}
|
||||
isConfirm
|
||||
danger
|
||||
onConfirm={() => handleDeleteAttachment(confirmDeleteAttId)}
|
||||
confirmText={t('common.delete')}
|
||||
>
|
||||
{t('artefacts.deleteAttachmentDesc')}
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -85,6 +85,7 @@ export default function ArtefactVersionTimeline({ versions, activeVersionId, onS
|
||||
src={version.thumbnail}
|
||||
alt={`Version ${version.version_number}`}
|
||||
className="w-full h-20 object-cover rounded border border-border"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function AssetCard({ asset, onClick }) {
|
||||
return (
|
||||
<div
|
||||
onClick={() => onClick?.(asset)}
|
||||
className="bg-white rounded-xl border border-border overflow-hidden card-hover cursor-pointer group"
|
||||
className="bg-surface rounded-xl border border-border overflow-clip card-hover cursor-pointer group"
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
<div className="aspect-square bg-surface-tertiary flex items-center justify-center overflow-hidden relative">
|
||||
@@ -33,6 +33,7 @@ export default function AssetCard({ asset, onClick }) {
|
||||
src={asset.url}
|
||||
alt={asset.name}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
e.target.style.display = 'none'
|
||||
e.target.nextSibling.style.display = 'flex'
|
||||
|
||||
@@ -41,7 +41,7 @@ export default function CampaignCalendar({ campaigns = [] }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
||||
<div className="bg-surface rounded-xl border border-border overflow-clip">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||
<h3 className="text-lg font-semibold text-text-primary">
|
||||
@@ -109,8 +109,8 @@ export default function CampaignCalendar({ campaigns = [] }) {
|
||||
<div
|
||||
key={campaign._id || ci}
|
||||
className={`h-5 text-[10px] font-medium text-white flex items-center px-1 truncate ${CAMPAIGN_COLORS[colorIndex]} ${
|
||||
isStart ? 'rounded-l-full ml-0' : '-ml-1'
|
||||
} ${isEnd ? 'rounded-r-full mr-0' : '-mr-1'}`}
|
||||
isStart ? 'rounded-l-full ms-0' : '-ms-1'
|
||||
} ${isEnd ? 'rounded-r-full me-0' : '-me-1'}`}
|
||||
title={campaign.name}
|
||||
>
|
||||
{isStart ? campaign.name : ''}
|
||||
|
||||
@@ -6,6 +6,7 @@ import CommentsSection from './CommentsSection'
|
||||
import Modal from './Modal'
|
||||
import TabbedModal from './TabbedModal'
|
||||
import BudgetBar from './BudgetBar'
|
||||
import PortalSelect from './PortalSelect'
|
||||
import { AppContext } from '../App'
|
||||
|
||||
export default function CampaignDetailPanel({ campaign, onClose, onSave, onDelete, brands, permissions }) {
|
||||
@@ -130,7 +131,7 @@ export default function CampaignDetailPanel({ campaign, onClose, onSave, onDelet
|
||||
form.status === 'paused' ? 'bg-amber-100 text-amber-700' :
|
||||
form.status === 'completed' ? 'bg-blue-100 text-blue-700' :
|
||||
form.status === 'cancelled' ? 'bg-red-100 text-red-700' :
|
||||
'bg-gray-100 text-gray-600'
|
||||
'bg-gray-100 text-text-secondary'
|
||||
}`}>
|
||||
{statusOptions.find(s => s.value === form.status)?.label}
|
||||
</span>
|
||||
@@ -189,44 +190,39 @@ export default function CampaignDetailPanel({ campaign, onClose, onSave, onDelet
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.brand')}</label>
|
||||
<select
|
||||
<PortalSelect
|
||||
value={form.brand_id}
|
||||
onChange={e => update('brand_id', e.target.value)}
|
||||
onChange={val => update('brand_id', val)}
|
||||
options={[{ value: '', label: 'Select brand' }, ...(brands || []).map(b => ({ value: b.id || b._id, label: `${b.icon || ''} ${lang === 'ar' && b.name_ar ? b.name_ar : b.name}`.trim() }))]}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
>
|
||||
<option value="">Select brand</option>
|
||||
{(brands || []).map(b => <option key={b.id || b._id} value={b.id || b._id}>{b.icon} {lang === 'ar' && b.name_ar ? b.name_ar : b.name}</option>)}
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.status')}</label>
|
||||
<select
|
||||
<PortalSelect
|
||||
value={form.status}
|
||||
onChange={e => update('status', e.target.value)}
|
||||
onChange={val => update('status', val)}
|
||||
options={statusOptions}
|
||||
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"
|
||||
>
|
||||
{statusOptions.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Team */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('common.team')}</label>
|
||||
<select
|
||||
<PortalSelect
|
||||
value={form.team_id}
|
||||
onChange={e => update('team_id', e.target.value)}
|
||||
onChange={val => update('team_id', val)}
|
||||
options={[{ value: '', label: t('common.noTeam') }, ...(teams || []).map(tm => ({ value: tm.id || tm._id, label: tm.name }))]}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
>
|
||||
<option value="">{t('common.noTeam')}</option>
|
||||
{(teams || []).map(t => <option key={t.id || t._id} value={t.id || t._id}>{t.name}</option>)}
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Platforms */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.platforms')}</label>
|
||||
<div className="flex flex-wrap gap-2 p-2 border border-border rounded-lg bg-white min-h-[38px]">
|
||||
<div className="flex flex-wrap gap-2 p-2 border border-border rounded-lg bg-surface min-h-[38px]">
|
||||
{Object.entries(PLATFORMS).map(([k, v]) => {
|
||||
const checked = (form.platforms || []).includes(k)
|
||||
return (
|
||||
@@ -281,7 +277,7 @@ export default function CampaignDetailPanel({ campaign, onClose, onSave, onDelet
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">
|
||||
{t('campaigns.budget')} ({currencySymbol})
|
||||
{!permissions?.canSetBudget && <span className="text-[10px] text-text-tertiary ml-1">(Superadmin only)</span>}
|
||||
{!permissions?.canSetBudget && <span className="text-[10px] text-text-tertiary ms-1">(Superadmin only)</span>}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
|
||||
@@ -116,7 +116,7 @@ export default function CommentsSection({ entityType, entityId }) {
|
||||
<div key={c.id} className="flex items-start gap-2 group">
|
||||
<div className="w-7 h-7 rounded-full bg-brand-primary/10 text-brand-primary flex items-center justify-center text-[10px] font-bold shrink-0 mt-0.5">
|
||||
{c.user_avatar ? (
|
||||
<img src={c.user_avatar} className="w-full h-full rounded-full object-cover" alt="" />
|
||||
<img src={c.user_avatar} className="w-full h-full rounded-full object-cover" alt="" loading="lazy" />
|
||||
) : (
|
||||
getInitials(c.user_name)
|
||||
)}
|
||||
@@ -125,7 +125,7 @@ export default function CommentsSection({ entityType, entityId }) {
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium text-text-primary">{c.user_name}</span>
|
||||
<span className="text-[10px] text-text-tertiary">{relativeTime(c.created_at, t)}</span>
|
||||
<div className="flex items-center gap-0.5 ml-auto opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<div className="flex items-center gap-0.5 ms-auto opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{canEdit(c) && editingId !== c.id && (
|
||||
<button
|
||||
onClick={() => startEdit(c)}
|
||||
|
||||
@@ -17,7 +17,7 @@ export default function DatePresetPicker({ onSelect, activePreset, onClear }) {
|
||||
className={`px-2.5 py-1 text-[11px] font-medium rounded-full border transition-colors ${
|
||||
activePreset === preset.key
|
||||
? 'bg-brand-primary/10 border-brand-primary/30 text-brand-primary'
|
||||
: 'bg-white border-border text-text-tertiary hover:text-text-primary hover:border-border-dark'
|
||||
: 'bg-surface border-border text-text-tertiary hover:text-text-primary hover:border-border-dark'
|
||||
}`}
|
||||
>
|
||||
{t(preset.labelKey)}
|
||||
|
||||
@@ -21,7 +21,7 @@ export default function EmptyState({
|
||||
{actionLabel && (
|
||||
<button
|
||||
onClick={onAction}
|
||||
className="mt-3 text-sm text-brand-primary hover:text-brand-primary-light font-medium"
|
||||
className="mt-3 text-sm text-brand-primary hover:text-brand-primary-light font-medium transition-colors"
|
||||
>
|
||||
{actionLabel}
|
||||
</button>
|
||||
@@ -44,7 +44,7 @@ export default function EmptyState({
|
||||
{actionLabel && (
|
||||
<button
|
||||
onClick={onAction}
|
||||
className="px-5 py-2.5 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm transition-all hover:-translate-y-0.5"
|
||||
className="px-5 py-2.5 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light transition-colors"
|
||||
>
|
||||
{actionLabel}
|
||||
</button>
|
||||
@@ -52,7 +52,7 @@ export default function EmptyState({
|
||||
{secondaryActionLabel && (
|
||||
<button
|
||||
onClick={onSecondaryAction}
|
||||
className="px-5 py-2.5 bg-white border border-border text-text-primary rounded-lg text-sm font-medium hover:bg-surface-secondary transition-colors"
|
||||
className="px-5 py-2.5 bg-surface border border-border text-text-primary rounded-lg text-sm font-medium hover:bg-surface-secondary transition-colors"
|
||||
>
|
||||
{secondaryActionLabel}
|
||||
</button>
|
||||
|
||||
@@ -28,7 +28,7 @@ export default function FormInput({
|
||||
? 'border-emerald-300 focus:border-emerald-500 focus:ring-emerald-500/20'
|
||||
: 'border-border focus:border-brand-primary focus:ring-brand-primary/20'
|
||||
}
|
||||
${disabled ? 'bg-surface-tertiary cursor-not-allowed opacity-60' : 'bg-white'}
|
||||
${disabled ? 'bg-surface-tertiary cursor-not-allowed opacity-60' : 'bg-surface'}
|
||||
${className}
|
||||
`.trim()
|
||||
|
||||
@@ -39,7 +39,7 @@ export default function FormInput({
|
||||
{label && (
|
||||
<label className="block text-sm font-medium text-text-primary">
|
||||
{label}
|
||||
{required && <span className="text-red-500 ml-0.5">*</span>}
|
||||
{required && <span className="text-red-500 ms-0.5">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
@@ -57,7 +57,7 @@ export default function FormInput({
|
||||
|
||||
{/* Validation icon */}
|
||||
{(hasError || hasSuccess) && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none">
|
||||
<div className="absolute end-3 top-1/2 -translate-y-1/2 pointer-events-none">
|
||||
{hasError ? (
|
||||
<AlertCircle className="w-4 h-4 text-red-500" />
|
||||
) : (
|
||||
|
||||
@@ -22,6 +22,7 @@ const PAGE_TITLE_KEYS = {
|
||||
'/issues': 'header.issues',
|
||||
'/team': 'header.team',
|
||||
'/settings': 'header.settings',
|
||||
'/translations': 'header.copy',
|
||||
}
|
||||
|
||||
const ROLE_INFO = {
|
||||
@@ -44,6 +45,7 @@ export default function Header() {
|
||||
|
||||
function getPageTitle(pathname) {
|
||||
if (PAGE_TITLE_KEYS[pathname]) return t(PAGE_TITLE_KEYS[pathname])
|
||||
if (pathname.startsWith('/posts/')) return t('header.postDetails')
|
||||
if (pathname.startsWith('/projects/')) return t('header.projectDetails')
|
||||
if (pathname.startsWith('/campaigns/')) return t('header.campaignDetails')
|
||||
return t('header.page')
|
||||
@@ -99,7 +101,7 @@ export default function Header() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="h-16 bg-white border-b border-border flex items-center justify-between px-6 shrink-0 sticky top-0 z-20">
|
||||
<header className="h-16 bg-surface border-b border-border flex items-center justify-between px-6 shrink-0 sticky top-0 z-20">
|
||||
{/* Page title */}
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-text-primary">{pageTitle}</h2>
|
||||
@@ -118,8 +120,8 @@ export default function Header() {
|
||||
>
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-semibold ${
|
||||
user?.role === 'superadmin'
|
||||
? 'bg-gradient-to-br from-purple-500 to-pink-500'
|
||||
: 'bg-gradient-to-br from-blue-500 to-indigo-500'
|
||||
? 'bg-brand-primary'
|
||||
: 'bg-teal-700'
|
||||
}`}>
|
||||
{getInitials(user?.name)}
|
||||
</div>
|
||||
@@ -135,7 +137,7 @@ export default function Header() {
|
||||
</button>
|
||||
|
||||
{showDropdown && (
|
||||
<div className="absolute end-0 top-full mt-2 w-64 bg-white rounded-xl shadow-lg border border-border overflow-hidden animate-scale-in">
|
||||
<div className="absolute end-0 top-full mt-2 w-64 bg-surface rounded-xl shadow-lg border border-border overflow-hidden animate-scale-in" role="menu">
|
||||
{/* User info */}
|
||||
<div className="px-4 py-3 border-b border-border-light bg-surface-secondary">
|
||||
<p className="text-sm font-semibold text-text-primary">{user?.name}</p>
|
||||
@@ -174,7 +176,7 @@ export default function Header() {
|
||||
setShowDropdown(false)
|
||||
logout()
|
||||
}}
|
||||
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-red-50 transition-colors text-left group"
|
||||
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-red-50 transition-colors text-start group"
|
||||
>
|
||||
<LogOut className="w-4 h-4 text-text-tertiary group-hover:text-red-500" />
|
||||
<span className="text-sm text-text-primary group-hover:text-red-500">{t('header.signOut')}</span>
|
||||
@@ -197,6 +199,7 @@ export default function Header() {
|
||||
onChange={e => { setPasswordForm(f => ({ ...f, currentPassword: e.target.value })); setPasswordError('') }}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
placeholder="••••••••"
|
||||
aria-describedby={passwordError ? 'password-error' : undefined}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -208,6 +211,7 @@ export default function Header() {
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
placeholder="••••••••"
|
||||
minLength={6}
|
||||
aria-describedby={passwordError ? 'password-error' : undefined}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -219,11 +223,12 @@ export default function Header() {
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
placeholder="••••••••"
|
||||
minLength={6}
|
||||
aria-describedby={passwordError ? 'password-error' : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{passwordError && (
|
||||
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
|
||||
<div id="password-error" className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg" role="alert">
|
||||
<AlertCircle className="w-4 h-4 text-red-500 shrink-0" />
|
||||
<p className="text-sm text-red-500">{passwordError}</p>
|
||||
</div>
|
||||
|
||||
@@ -237,7 +237,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-border py-16 text-center">
|
||||
<div className="bg-surface rounded-xl border border-border py-16 text-center">
|
||||
<Calendar className="w-12 h-12 text-text-tertiary mx-auto mb-3" />
|
||||
<p className="text-text-secondary font-medium">{t('timeline.noItems')}</p>
|
||||
<p className="text-sm text-text-tertiary mt-1">{t('timeline.addItems')}</p>
|
||||
@@ -246,7 +246,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
||||
<div className="bg-surface rounded-xl border border-border overflow-clip">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-border bg-surface-secondary">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -287,8 +287,8 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
|
||||
<div ref={containerRef} dir="ltr" className="overflow-x-auto relative" style={{ cursor: dragState ? 'grabbing' : undefined }}>
|
||||
<div style={{ minWidth: `${labelWidth + totalDays * pxPerDay}px` }}>
|
||||
{/* Day header */}
|
||||
<div className="flex sticky top-0 z-20 bg-white border-b border-border" style={{ height: headerHeight }}>
|
||||
<div className="shrink-0 border-e border-border bg-surface-secondary flex items-center px-4 sticky left-0 z-30" style={{ width: labelWidth }}>
|
||||
<div className="flex sticky top-0 z-20 bg-surface border-b border-border" style={{ height: headerHeight }}>
|
||||
<div className="shrink-0 border-e border-border bg-surface-secondary flex items-center px-4 sticky start-0 z-30" style={{ width: labelWidth }}>
|
||||
<span className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('timeline.item')}</span>
|
||||
</div>
|
||||
<div className="flex relative">
|
||||
@@ -338,7 +338,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
|
||||
>
|
||||
{/* Label column */}
|
||||
<div
|
||||
className={`shrink-0 border-e border-border flex ${isExpanded ? 'flex-col justify-center gap-1' : 'items-center gap-2'} px-3 overflow-hidden sticky left-0 z-10 bg-white group-hover/row:bg-surface-secondary/50`}
|
||||
className={`shrink-0 border-e border-border flex ${isExpanded ? 'flex-col justify-center gap-1' : 'items-center gap-2'} px-3 overflow-hidden sticky start-0 z-10 bg-surface group-hover/row:bg-surface-secondary/50`}
|
||||
style={{ width: labelWidth }}
|
||||
>
|
||||
{isExpanded ? (
|
||||
@@ -358,7 +358,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
|
||||
)}
|
||||
{item.thumbnailUrl ? (
|
||||
<div className="w-8 h-8 rounded overflow-hidden shrink-0">
|
||||
<img src={item.thumbnailUrl} alt="" className="w-full h-full object-cover" />
|
||||
<img src={item.thumbnailUrl} alt="" className="w-full h-full object-cover" loading="lazy" />
|
||||
</div>
|
||||
) : item.assigneeName ? (
|
||||
<div className="w-6 h-6 rounded-full bg-brand-primary/10 text-brand-primary flex items-center justify-center text-[9px] font-bold shrink-0">
|
||||
@@ -394,7 +394,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
|
||||
)}
|
||||
{item.thumbnailUrl ? (
|
||||
<div className="w-6 h-6 rounded overflow-hidden shrink-0">
|
||||
<img src={item.thumbnailUrl} alt="" className="w-full h-full object-cover" />
|
||||
<img src={item.thumbnailUrl} alt="" className="w-full h-full object-cover" loading="lazy" />
|
||||
</div>
|
||||
) : item.assigneeName ? (
|
||||
<div className="w-6 h-6 rounded-full bg-brand-primary/10 text-brand-primary flex items-center justify-center text-[9px] font-bold shrink-0">
|
||||
@@ -415,7 +415,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
|
||||
style={{ left: `${todayOffset + pxPerDay / 2}px` }}
|
||||
>
|
||||
{idx === 0 && (
|
||||
<div className="absolute -top-0 left-1 text-[8px] font-bold text-red-500 bg-red-50 px-1 rounded whitespace-nowrap">
|
||||
<div className="absolute -top-0 start-1 text-[8px] font-bold text-red-500 bg-red-50 px-1 rounded whitespace-nowrap">
|
||||
{t('timeline.today')}
|
||||
</div>
|
||||
)}
|
||||
@@ -459,7 +459,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
|
||||
{/* Left resize handle */}
|
||||
{!readOnly && onDateChange && (
|
||||
<div
|
||||
className="absolute left-0 top-0 bottom-0 w-2 cursor-col-resize hover:bg-white/30 z-10"
|
||||
className="absolute start-0 top-0 bottom-0 w-2 cursor-col-resize hover:bg-white/30 z-10"
|
||||
onMouseDown={(e) => handleMouseDown(e, item, 'resize-left')}
|
||||
/>
|
||||
)}
|
||||
@@ -520,7 +520,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
|
||||
{/* Right resize handle */}
|
||||
{!readOnly && onDateChange && (
|
||||
<div
|
||||
className="absolute right-0 top-0 bottom-0 w-2 cursor-col-resize hover:bg-white/30 z-10"
|
||||
className="absolute end-0 top-0 bottom-0 w-2 cursor-col-resize hover:bg-white/30 z-10"
|
||||
onMouseDown={(e) => handleMouseDown(e, item, 'resize-right')}
|
||||
/>
|
||||
)}
|
||||
@@ -536,7 +536,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
|
||||
{colorPicker && onColorChange && (
|
||||
<div
|
||||
ref={colorPickerRef}
|
||||
className="fixed z-50 bg-white rounded-lg shadow-xl border border-border p-2"
|
||||
className="fixed z-50 bg-surface rounded-lg shadow-xl border border-border p-2"
|
||||
style={{ left: colorPicker.x, top: colorPicker.y }}
|
||||
>
|
||||
<div className="grid grid-cols-4 gap-1.5 mb-2">
|
||||
@@ -591,7 +591,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
|
||||
)}
|
||||
</div>
|
||||
{!readOnly && onDateChange && (
|
||||
<div className="text-gray-400 mt-1 text-[10px] italic">
|
||||
<div className="text-text-tertiary mt-1 text-[10px] italic">
|
||||
{t('timeline.dragToMove')} · {t('timeline.dragToResize')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useState, useEffect, useContext } from 'react'
|
||||
import { Copy, Eye, Lock, Send, Upload, FileText, Trash2, Check, Clock, CheckCircle2, XCircle, FileEdit, Wrench, MessageSquare, Paperclip } from 'lucide-react'
|
||||
import { Copy, Eye, Lock, Send, FileText, Trash2, Check, Clock, CheckCircle2, XCircle, FileEdit, Wrench, MessageSquare, Paperclip } from 'lucide-react'
|
||||
import UploadZone from './UploadZone'
|
||||
import { api, STATUS_CONFIG, PRIORITY_CONFIG } from '../utils/api'
|
||||
import TabbedModal from './TabbedModal'
|
||||
import Modal from './Modal'
|
||||
import { useToast } from './ToastContainer'
|
||||
import { AppContext } from '../App'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import PortalSelect from './PortalSelect'
|
||||
|
||||
export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers, teams = [] }) {
|
||||
const { brands } = useContext(AppContext)
|
||||
@@ -284,67 +286,53 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
{/* Assigned To */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-text-primary mb-2">{t('issues.assignedTo')}</label>
|
||||
<select
|
||||
<PortalSelect
|
||||
value={assignedTo}
|
||||
onChange={(e) => handleAssignmentChange(e.target.value)}
|
||||
onChange={val => handleAssignmentChange(val)}
|
||||
options={[{ value: '', label: t('issues.unassigned') }, ...teamMembers.map(member => ({ value: member.id || member._id, label: member.name }))]}
|
||||
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"
|
||||
>
|
||||
<option value="">{t('issues.unassigned')}</option>
|
||||
{teamMembers.map((member) => (
|
||||
<option key={member.id || member._id} value={member.id || member._id}>
|
||||
{member.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Team */}
|
||||
{teams.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-text-primary mb-2">{t('issues.team')}</label>
|
||||
<select
|
||||
<PortalSelect
|
||||
value={teamId}
|
||||
onChange={async (e) => {
|
||||
const val = e.target.value || null
|
||||
setTeamId(val || '')
|
||||
onChange={async (val) => {
|
||||
const resolvedVal = val || null
|
||||
setTeamId(resolvedVal || '')
|
||||
try {
|
||||
await api.patch(`/issues/${issueId}`, { team_id: val })
|
||||
await api.patch(`/issues/${issueId}`, { team_id: resolvedVal })
|
||||
await onUpdate()
|
||||
await loadIssueDetails()
|
||||
} catch (err) {
|
||||
console.error('Failed to update team:', err)
|
||||
}
|
||||
}}
|
||||
options={[{ value: '', label: t('issues.allTeams') }, ...teams.map(team => ({ value: team.id || team._id, label: team.name }))]}
|
||||
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"
|
||||
>
|
||||
<option value="">{t('issues.allTeams')}</option>
|
||||
{teams.map((team) => (
|
||||
<option key={team.id || team._id} value={team.id || team._id}>{team.name}</option>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Brand */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-text-primary mb-2">{t('issues.brandLabel')}</label>
|
||||
<select
|
||||
<PortalSelect
|
||||
value={issueData.brand_id || ''}
|
||||
onChange={async (e) => {
|
||||
const val = e.target.value || null;
|
||||
onChange={async (val) => {
|
||||
const resolvedVal = val || null;
|
||||
try {
|
||||
await api.patch(`/issues/${issueId}`, { brand_id: val });
|
||||
await api.patch(`/issues/${issueId}`, { brand_id: resolvedVal });
|
||||
loadIssueDetails();
|
||||
onUpdate();
|
||||
} catch {}
|
||||
}}
|
||||
options={[{ value: '', label: t('issues.noBrand') }, ...(brands || []).map(b => ({ value: b._id || b.Id, label: b.name }))]}
|
||||
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"
|
||||
>
|
||||
<option value="">{t('issues.noBrand')}</option>
|
||||
{(brands || []).map((b) => (
|
||||
<option key={b._id || b.Id} value={b._id || b.Id}>{b.name}</option>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Internal Notes */}
|
||||
@@ -501,15 +489,12 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
{activeTab === 'attachments' && (
|
||||
<div className="p-6 space-y-5">
|
||||
{/* Upload */}
|
||||
<label className="block">
|
||||
<input type="file" onChange={handleFileUpload} disabled={uploadingFile} className="hidden" />
|
||||
<div className="border-2 border-dashed border-border rounded-lg p-4 text-center cursor-pointer hover:bg-surface-secondary transition-colors">
|
||||
<Upload className="w-6 h-6 mx-auto mb-2 text-text-tertiary" />
|
||||
<p className="text-sm text-text-secondary">
|
||||
{uploadingFile ? t('issues.uploading') : t('issues.clickToUpload')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
<UploadZone
|
||||
onUpload={handleFileUpload}
|
||||
uploading={uploadingFile}
|
||||
label={t('issues.clickToUpload')}
|
||||
compact
|
||||
/>
|
||||
|
||||
{/* Attachments List */}
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -10,12 +10,12 @@ export default function KanbanCard({ title, thumbnail, brandName, tags, assignee
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className="bg-white rounded-lg p-3 border border-border hover:border-brand-primary/30 cursor-pointer transition-all group overflow-hidden"
|
||||
className="bg-surface rounded-lg p-3 border border-border hover:border-brand-primary/30 cursor-pointer transition-all group overflow-hidden"
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
{thumbnail && (
|
||||
<div className="w-[calc(100%+1.5rem)] h-32 -mx-3 -mt-3 mb-2 rounded-t-lg overflow-hidden">
|
||||
<img src={thumbnail} alt="" className="w-full h-full object-cover" />
|
||||
<img src={thumbnail} alt="" className="w-full h-full object-cover" loading="lazy" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ const ROLE_BADGES = {
|
||||
strategist: { bg: 'bg-rose-50', text: 'text-rose-700', label: 'Strategist' },
|
||||
superadmin: { bg: 'bg-red-50', text: 'text-red-700', label: 'Super Admin' },
|
||||
contributor: { bg: 'bg-slate-50', text: 'text-slate-700', label: 'Contributor' },
|
||||
default: { bg: 'bg-gray-50', text: 'text-gray-700', label: 'Team Member' },
|
||||
default: { bg: 'bg-gray-50', text: 'text-text-secondary', label: 'Team Member' },
|
||||
}
|
||||
|
||||
export default function MemberCard({ member, onClick }) {
|
||||
@@ -33,7 +33,7 @@ export default function MemberCard({ member, onClick }) {
|
||||
return (
|
||||
<div
|
||||
onClick={() => onClick?.(member)}
|
||||
className="bg-white rounded-xl border border-border p-5 card-hover cursor-pointer text-center"
|
||||
className="bg-surface rounded-xl border border-border p-5 card-hover cursor-pointer text-center"
|
||||
>
|
||||
{/* Avatar */}
|
||||
<div className={`w-16 h-16 rounded-full bg-gradient-to-br ${avatarColors[colorIndex]} flex items-center justify-center text-white text-xl font-bold mx-auto mb-3`}>
|
||||
|
||||
@@ -1,15 +1,38 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useRef, useCallback } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X, AlertTriangle } from 'lucide-react'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
|
||||
function useFocusTrap(ref, isOpen) {
|
||||
useEffect(() => {
|
||||
if (!isOpen || !ref.current) return
|
||||
const el = ref.current
|
||||
const focusable = el.querySelectorAll(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
)
|
||||
if (focusable.length > 0) focusable[0].focus()
|
||||
|
||||
const handleTab = (e) => {
|
||||
if (e.key !== 'Tab' || focusable.length === 0) return
|
||||
const first = focusable[0]
|
||||
const last = focusable[focusable.length - 1]
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === first) { e.preventDefault(); last.focus() }
|
||||
} else {
|
||||
if (document.activeElement === last) { e.preventDefault(); first.focus() }
|
||||
}
|
||||
}
|
||||
el.addEventListener('keydown', handleTab)
|
||||
return () => el.removeEventListener('keydown', handleTab)
|
||||
}, [isOpen, ref])
|
||||
}
|
||||
|
||||
export default function Modal({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
size = 'md',
|
||||
// Confirmation mode props
|
||||
isConfirm = false,
|
||||
confirmText,
|
||||
cancelText,
|
||||
@@ -17,10 +40,11 @@ export default function Modal({
|
||||
danger = false,
|
||||
}) {
|
||||
const { t } = useLanguage()
|
||||
const modalRef = useRef(null)
|
||||
|
||||
// Default translations
|
||||
const finalConfirmText = confirmText || (danger ? t('common.delete') : t('common.save'))
|
||||
const finalCancelText = cancelText || t('common.cancel')
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden'
|
||||
@@ -30,6 +54,12 @@ export default function Modal({
|
||||
return () => { document.body.style.overflow = '' }
|
||||
}, [isOpen])
|
||||
|
||||
const handleKeyDown = useCallback((e) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}, [onClose])
|
||||
|
||||
useFocusTrap(modalRef, isOpen)
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const sizeClasses = {
|
||||
@@ -39,25 +69,23 @@ export default function Modal({
|
||||
xl: 'max-w-4xl',
|
||||
}
|
||||
|
||||
// Confirmation dialog
|
||||
if (isConfirm) {
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center px-4">
|
||||
{/* Backdrop */}
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center px-4" onKeyDown={handleKeyDown} ref={modalRef}>
|
||||
<div
|
||||
className="fixed inset-0 bg-black/40 backdrop-blur-sm animate-backdrop-in"
|
||||
onClick={onClose}
|
||||
aria-label="Close dialog"
|
||||
/>
|
||||
|
||||
{/* Modal content */}
|
||||
<div className="relative bg-white rounded-2xl shadow-2xl w-full max-w-md animate-scale-in">
|
||||
<div className="relative bg-surface rounded-2xl shadow-2xl w-full max-w-md animate-scale-in" role="dialog" aria-modal="true" aria-labelledby="modal-title">
|
||||
<div className="p-6">
|
||||
{danger && (
|
||||
<div className="w-12 h-12 rounded-full bg-red-100 flex items-center justify-center mx-auto mb-4">
|
||||
<AlertTriangle className="w-6 h-6 text-red-600" />
|
||||
</div>
|
||||
)}
|
||||
<h3 className="text-lg font-semibold text-text-primary text-center mb-2">{title}</h3>
|
||||
<h3 id="modal-title" className="text-lg font-semibold text-text-primary text-center mb-2">{title}</h3>
|
||||
<div className="text-sm text-text-secondary text-center mb-6">
|
||||
{children}
|
||||
</div>
|
||||
@@ -89,30 +117,27 @@ export default function Modal({
|
||||
)
|
||||
}
|
||||
|
||||
// Regular modal
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[9999] flex items-start justify-center pt-[10vh] px-4">
|
||||
{/* Backdrop */}
|
||||
<div className="fixed inset-0 z-[9999] flex items-start justify-center pt-[10vh] px-4" onKeyDown={handleKeyDown} ref={modalRef}>
|
||||
<div
|
||||
className="fixed inset-0 bg-black/40 backdrop-blur-sm animate-backdrop-in"
|
||||
onClick={onClose}
|
||||
aria-label="Close dialog"
|
||||
/>
|
||||
|
||||
{/* Modal content */}
|
||||
<div className={`relative bg-white rounded-2xl shadow-2xl w-full ${sizeClasses[size]} max-h-[80vh] flex flex-col animate-scale-in`}>
|
||||
{/* Header */}
|
||||
<div className="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>
|
||||
<div className={`relative bg-surface rounded-2xl shadow-2xl w-full ${sizeClasses[size]} max-h-[80vh] overflow-y-auto animate-scale-in`} role="dialog" aria-modal="true" aria-labelledby="modal-title">
|
||||
<div className="sticky top-0 z-10 bg-surface rounded-t-2xl flex items-center justify-between px-6 py-4 border-b border-border">
|
||||
<h3 id="modal-title" className="text-lg font-semibold text-text-primary">{title}</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors"
|
||||
aria-label="Close dialog"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-6 py-4 overflow-y-auto flex-1">
|
||||
<div className="px-6 py-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { ChevronDown, Check } from 'lucide-react'
|
||||
|
||||
/**
|
||||
* Portal-based select dropdown that renders options outside any overflow/stacking context.
|
||||
* Drop-in replacement for <select> inside SlidePanel/TabbedModal/Modal.
|
||||
*
|
||||
* Props:
|
||||
* value - current value
|
||||
* onChange - (value) => void
|
||||
* options - [{ value, label }] or children-based (fallback to native if no options)
|
||||
* placeholder - text when no value selected
|
||||
* className - additional classes on the trigger button
|
||||
* disabled - boolean
|
||||
*/
|
||||
export default function PortalSelect({ value, onChange, options = [], placeholder = '—', className = '', disabled = false }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const triggerRef = useRef(null)
|
||||
const dropdownRef = useRef(null)
|
||||
const [pos, setPos] = useState({ top: 0, left: 0, width: 0 })
|
||||
|
||||
const selectedOption = options.find(o => String(o.value) === String(value))
|
||||
const displayText = selectedOption?.label || placeholder
|
||||
|
||||
const updatePosition = useCallback(() => {
|
||||
if (!triggerRef.current) return
|
||||
const rect = triggerRef.current.getBoundingClientRect()
|
||||
const spaceBelow = window.innerHeight - rect.bottom
|
||||
const dropdownHeight = Math.min(options.length * 32 + 8, 240)
|
||||
const showAbove = spaceBelow < dropdownHeight && rect.top > dropdownHeight
|
||||
|
||||
setPos({
|
||||
top: showAbove ? rect.top - dropdownHeight - 4 : rect.bottom + 4,
|
||||
left: rect.left,
|
||||
width: Math.max(rect.width, 160),
|
||||
})
|
||||
}, [options.length])
|
||||
|
||||
const handleOpen = () => {
|
||||
if (disabled) return
|
||||
updatePosition()
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
const handleSelect = (val) => {
|
||||
onChange(val)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const handleClick = (e) => {
|
||||
if (triggerRef.current?.contains(e.target)) return
|
||||
if (dropdownRef.current?.contains(e.target)) return
|
||||
setOpen(false)
|
||||
}
|
||||
const handleEsc = (e) => { if (e.key === 'Escape') setOpen(false) }
|
||||
const handleScroll = () => updatePosition()
|
||||
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
document.addEventListener('keydown', handleEsc)
|
||||
window.addEventListener('scroll', handleScroll, true)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClick)
|
||||
document.removeEventListener('keydown', handleEsc)
|
||||
window.removeEventListener('scroll', handleScroll, true)
|
||||
}
|
||||
}, [open, updatePosition])
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
onClick={handleOpen}
|
||||
disabled={disabled}
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
className={`flex items-center justify-between gap-1 text-start ${className} ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
|
||||
>
|
||||
<span className={`truncate ${selectedOption ? '' : 'text-text-tertiary'}`}>{displayText}</span>
|
||||
<ChevronDown className={`w-3 h-3 shrink-0 text-text-tertiary transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{open && createPortal(
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
role="listbox"
|
||||
className="fixed z-[99999] bg-surface border border-border rounded-lg shadow-lg overflow-y-auto animate-scale-in"
|
||||
style={{ top: pos.top, left: pos.left, width: pos.width, maxHeight: 240 }}
|
||||
>
|
||||
{options.map(opt => {
|
||||
const isSelected = String(opt.value) === String(value)
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={String(opt.value) === String(value)}
|
||||
onClick={() => handleSelect(opt.value)}
|
||||
className={`w-full flex items-center gap-2 px-3 py-1.5 text-xs text-start transition-colors ${
|
||||
isSelected
|
||||
? 'bg-brand-primary/10 text-brand-primary font-medium'
|
||||
: 'text-text-primary hover:bg-surface-secondary'
|
||||
}`}
|
||||
>
|
||||
<span className="flex-1 truncate">{opt.label}</span>
|
||||
{isSelected && <Check className="w-3 h-3 shrink-0" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{options.length === 0 && (
|
||||
<div className="px-3 py-2 text-xs text-text-tertiary text-center">—</div>
|
||||
)}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -23,11 +23,11 @@ export default function PostCard({ post, onClick, onMove, compact = false, check
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className="bg-white rounded-lg p-3 border border-border hover:border-brand-primary/30 cursor-pointer transition-all group overflow-hidden card-hover"
|
||||
className="bg-surface rounded-lg p-3 border border-border hover:border-brand-primary/30 cursor-pointer transition-all group overflow-hidden card-hover"
|
||||
>
|
||||
{post.thumbnail_url && (
|
||||
<div className="w-[calc(100%+1.5rem)] h-32 -mx-3 -mt-3 mb-2 rounded-t-lg overflow-hidden">
|
||||
<img src={post.thumbnail_url} alt="" className="w-full h-full object-cover" />
|
||||
<img src={post.thumbnail_url} alt="" className="w-full h-full object-cover" loading="lazy" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { Send, CheckCircle2, XCircle, Copy, Check, Clock } from 'lucide-react'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import ApproverMultiSelect from './ApproverMultiSelect'
|
||||
|
||||
export function PostDetailApproval({
|
||||
form,
|
||||
update,
|
||||
post,
|
||||
isCreateMode,
|
||||
reviewUrl,
|
||||
copied,
|
||||
submittingReview,
|
||||
saving,
|
||||
teamMembers,
|
||||
onSubmitReview,
|
||||
onCopyReviewLink,
|
||||
onStatusAction,
|
||||
}) {
|
||||
const { t } = useLanguage()
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-5 w-full">
|
||||
<div className="bg-surface-secondary rounded-xl p-4">
|
||||
<label className="block text-xs font-medium text-text-primary mb-2">{t('posts.approvers')}</label>
|
||||
<ApproverMultiSelect
|
||||
users={teamMembers || []}
|
||||
selected={form.approver_ids || []}
|
||||
onChange={ids => update('approver_ids', ids)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isCreateMode && (
|
||||
<div className="space-y-4">
|
||||
{/* Approval status cards */}
|
||||
{form.status === 'approved' && post.approved_by_name && (
|
||||
<div className="bg-emerald-50 border border-emerald-200 rounded-xl p-4 flex items-start gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-emerald-100 flex items-center justify-center shrink-0 mt-0.5">
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-emerald-800">{t('posts.approvedBy')} {post.approved_by_name}</p>
|
||||
{post.feedback && <p className="text-xs text-emerald-700 mt-1.5 leading-relaxed">{post.feedback}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{form.status === 'rejected' && post.approved_by_name && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-4 flex items-start gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-red-100 flex items-center justify-center shrink-0 mt-0.5">
|
||||
<XCircle className="w-4 h-4 text-red-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-red-800">{t('posts.rejectedBy')} {post.approved_by_name}</p>
|
||||
{post.feedback && <p className="text-xs text-red-700 mt-1.5 leading-relaxed">{post.feedback}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{form.status === 'in_review' && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4 text-center">
|
||||
<div className="w-10 h-10 rounded-full bg-amber-100 flex items-center justify-center mx-auto mb-2">
|
||||
<Clock className="w-5 h-5 text-amber-600" />
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-amber-800">{t('posts.awaitingReview')}</p>
|
||||
<p className="text-xs text-amber-600 mt-1">{t('posts.awaitingReviewDesc')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Review link */}
|
||||
{reviewUrl && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
|
||||
<div className="text-xs font-semibold text-blue-900 mb-2.5">{t('posts.reviewLinkTitle')}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="text" value={reviewUrl} readOnly className="flex-1 px-3 py-2 text-xs bg-surface border border-blue-200 rounded-lg font-mono" />
|
||||
<button onClick={onCopyReviewLink} className="p-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors shadow-sm">
|
||||
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex gap-3">
|
||||
{!reviewUrl && (
|
||||
<button
|
||||
onClick={onSubmitReview}
|
||||
disabled={submittingReview}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-amber-500 text-white rounded-xl hover:bg-amber-600 transition-colors font-medium text-sm disabled:opacity-50 shadow-sm"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
{submittingReview ? t('posts.submitting') : t('posts.sendToReview')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{form.status === 'approved' && (
|
||||
<button
|
||||
onClick={() => onStatusAction('scheduled')}
|
||||
disabled={saving}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-purple-600 text-white rounded-xl hover:bg-purple-700 transition-colors font-medium text-sm disabled:opacity-50 shadow-sm"
|
||||
>
|
||||
{t('posts.schedule')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { X, Upload, FileText, FolderOpen, Image as ImageIcon, Music, Film } from 'lucide-react'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
|
||||
export function PostDetailAttachments({
|
||||
attachments,
|
||||
uploading,
|
||||
onFileUpload,
|
||||
onDeleteAttachment,
|
||||
onAttachAsset,
|
||||
}) {
|
||||
const { t } = useLanguage()
|
||||
const imageInputRef = useRef(null)
|
||||
const [dragActive, setDragActive] = useState(false)
|
||||
const [showAssetPicker, setShowAssetPicker] = useState(false)
|
||||
const [availableAssets, setAvailableAssets] = useState([])
|
||||
const [assetSearch, setAssetSearch] = useState('')
|
||||
|
||||
const handleDrop = (e) => {
|
||||
e.preventDefault(); e.stopPropagation(); setDragActive(false)
|
||||
if (e.dataTransfer.files?.length) onFileUpload(e.dataTransfer.files)
|
||||
}
|
||||
|
||||
const openAssetPicker = async () => {
|
||||
const { api } = await import('../utils/api')
|
||||
try {
|
||||
const data = await api.get('/assets')
|
||||
setAvailableAssets(Array.isArray(data) ? data : [])
|
||||
} catch {
|
||||
setAvailableAssets([])
|
||||
}
|
||||
setAssetSearch('')
|
||||
setShowAssetPicker(true)
|
||||
}
|
||||
|
||||
const handleAttachAsset = async (assetId) => {
|
||||
await onAttachAsset(assetId)
|
||||
setShowAssetPicker(false)
|
||||
}
|
||||
|
||||
const images = attachments.filter(a => (a.mime_type || a.mimeType || '').startsWith('image/'))
|
||||
const audio = attachments.filter(a => (a.mime_type || a.mimeType || '').startsWith('audio/'))
|
||||
const videos = attachments.filter(a => (a.mime_type || a.mimeType || '').startsWith('video/'))
|
||||
const others = attachments.filter(a => {
|
||||
const mime = a.mime_type || a.mimeType || ''
|
||||
return !mime.startsWith('image/') && !mime.startsWith('audio/') && !mime.startsWith('video/')
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Images */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-text-secondary">
|
||||
<ImageIcon className="w-3.5 h-3.5" />
|
||||
{t('posts.images')}
|
||||
{images.length > 0 && <span className="text-text-tertiary">({images.length})</span>}
|
||||
</div>
|
||||
<label className="flex items-center gap-1 px-2 py-1 text-[11px] font-medium text-brand-primary hover:bg-brand-primary/5 rounded cursor-pointer transition-colors">
|
||||
<Upload className="w-3 h-3" />
|
||||
{t('posts.addImage')}
|
||||
<input ref={imageInputRef} type="file" multiple accept="image/*" className="hidden"
|
||||
onChange={e => { onFileUpload(e.target.files); e.target.value = '' }} />
|
||||
</label>
|
||||
</div>
|
||||
{images.length > 0 && (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{images.map(att => {
|
||||
const attUrl = att.url || `/api/uploads/${att.filename}`
|
||||
const name = att.original_name || att.originalName || att.filename
|
||||
const attId = att.id || att._id
|
||||
return (
|
||||
<div key={attId} className="relative group/att border border-border rounded-lg overflow-hidden bg-surface">
|
||||
<div className="h-20 relative">
|
||||
<a href={attUrl} target="_blank" rel="noopener noreferrer" className="block h-full">
|
||||
<img src={attUrl} alt={name} className="absolute inset-0 w-full h-full object-cover" />
|
||||
</a>
|
||||
<button onClick={() => onDeleteAttachment(attId)}
|
||||
className="absolute top-1 end-1 p-1 bg-black/50 hover:bg-red-500 rounded-full text-white opacity-0 group-hover/att:opacity-100 transition-opacity"
|
||||
title={t('common.delete')}><X className="w-2.5 h-2.5" /></button>
|
||||
</div>
|
||||
<div className="px-2 py-1 text-[10px] text-text-tertiary truncate border-t border-border-light">{name}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Audio */}
|
||||
{audio.length > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-text-secondary mb-2">
|
||||
<Music className="w-3.5 h-3.5" />
|
||||
{t('posts.audio')} <span className="text-text-tertiary">({audio.length})</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{audio.map(att => {
|
||||
const attUrl = att.url || `/api/uploads/${att.filename}`
|
||||
const name = att.original_name || att.originalName || att.filename
|
||||
const attId = att.id || att._id
|
||||
return (
|
||||
<div key={attId} className="flex items-center gap-2 border border-border rounded-lg p-2 bg-surface group/att">
|
||||
<Music className="w-4 h-4 text-text-tertiary shrink-0" />
|
||||
<span className="text-xs text-text-secondary truncate flex-1">{name}</span>
|
||||
<audio src={attUrl} controls className="h-7 max-w-[160px]" />
|
||||
<button onClick={() => onDeleteAttachment(attId)}
|
||||
className="p-1 text-text-tertiary hover:text-red-500 opacity-0 group-hover/att:opacity-100 transition-opacity"
|
||||
title={t('common.delete')}><X className="w-3 h-3" /></button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Videos */}
|
||||
{videos.length > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-text-secondary mb-2">
|
||||
<Film className="w-3.5 h-3.5" />
|
||||
{t('posts.videos')} <span className="text-text-tertiary">({videos.length})</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{videos.map(att => {
|
||||
const attUrl = att.url || `/api/uploads/${att.filename}`
|
||||
const name = att.original_name || att.originalName || att.filename
|
||||
const attId = att.id || att._id
|
||||
return (
|
||||
<div key={attId} className="border border-border rounded-lg overflow-hidden bg-surface group/att">
|
||||
<video src={attUrl} controls className="w-full max-h-40" />
|
||||
<div className="flex items-center justify-between px-2 py-1 border-t border-border-light">
|
||||
<span className="text-[10px] text-text-tertiary truncate">{name}</span>
|
||||
<button onClick={() => onDeleteAttachment(attId)}
|
||||
className="p-1 text-text-tertiary hover:text-red-500 opacity-0 group-hover/att:opacity-100 transition-opacity"
|
||||
title={t('common.delete')}><X className="w-3 h-3" /></button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Other files */}
|
||||
{others.length > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-text-secondary mb-2">
|
||||
<FileText className="w-3.5 h-3.5" />
|
||||
{t('posts.otherFiles')} <span className="text-text-tertiary">({others.length})</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{others.map(att => {
|
||||
const attUrl = att.url || `/api/uploads/${att.filename}`
|
||||
const name = att.original_name || att.originalName || att.filename
|
||||
const attId = att.id || att._id
|
||||
return (
|
||||
<div key={attId} className="relative group/att border border-border rounded-lg overflow-hidden bg-surface">
|
||||
<a href={attUrl} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 p-3 h-16">
|
||||
<FileText className="w-6 h-6 text-text-tertiary shrink-0" />
|
||||
<span className="text-xs text-text-secondary truncate">{name}</span>
|
||||
</a>
|
||||
<button onClick={() => onDeleteAttachment(attId)}
|
||||
className="absolute top-1 end-1 p-1 bg-black/50 hover:bg-red-500 rounded-full text-white opacity-0 group-hover/att:opacity-100 transition-opacity"
|
||||
title={t('common.delete')}><X className="w-2.5 h-2.5" /></button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Drag and drop zone */}
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-3 text-center cursor-pointer transition-colors ${
|
||||
dragActive ? 'border-brand-primary bg-brand-primary/5' : 'border-border hover:border-brand-primary/40'
|
||||
}`}
|
||||
onDragEnter={e => { e.preventDefault(); setDragActive(true) }}
|
||||
onDragLeave={e => { e.preventDefault(); setDragActive(false) }}
|
||||
onDragOver={e => e.preventDefault()}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<Upload className={`w-4 h-4 text-text-tertiary mx-auto mb-1 ${uploading ? 'animate-pulse' : ''}`} />
|
||||
<p className="text-[11px] text-text-secondary">
|
||||
{dragActive ? t('posts.dropFiles') : t('posts.dragToUpload')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={openAssetPicker}
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm text-text-secondary border border-border rounded-lg hover:bg-surface-tertiary transition-colors w-full justify-center"
|
||||
>
|
||||
<FolderOpen className="w-4 h-4" />
|
||||
{t('posts.attachFromAssets')}
|
||||
</button>
|
||||
|
||||
{showAssetPicker && (
|
||||
<div className="border border-border rounded-lg p-3 bg-surface-secondary">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-xs font-medium text-text-secondary">{t('posts.selectAssets')}</p>
|
||||
<button onClick={() => setShowAssetPicker(false)} className="p-1 text-text-tertiary hover:text-text-primary">
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={assetSearch}
|
||||
onChange={e => setAssetSearch(e.target.value)}
|
||||
placeholder={t('common.search')}
|
||||
className="w-full px-3 py-1.5 text-xs border border-border rounded-lg mb-2 focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
/>
|
||||
<div className="grid grid-cols-4 gap-2 max-h-48 overflow-y-auto">
|
||||
{availableAssets
|
||||
.filter(a => !assetSearch || (a.original_name || a.filename || '').toLowerCase().includes(assetSearch.toLowerCase()))
|
||||
.map(asset => {
|
||||
const isImage = asset.mime_type?.startsWith('image/')
|
||||
const assetUrl = `/api/uploads/${asset.filename}`
|
||||
const name = asset.original_name || asset.filename
|
||||
return (
|
||||
<button
|
||||
key={asset.id || asset._id}
|
||||
onClick={() => handleAttachAsset(asset.id || asset._id)}
|
||||
className="block w-full border border-border rounded-lg overflow-hidden bg-surface hover:border-brand-primary hover:ring-2 hover:ring-brand-primary/20 transition-all text-start"
|
||||
>
|
||||
<div className="aspect-square relative">
|
||||
{isImage ? (
|
||||
<img src={assetUrl} alt={name} className="absolute inset-0 w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-surface-tertiary">
|
||||
<FileText className="w-6 h-6 text-text-tertiary" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-1.5 py-1 text-[10px] text-text-tertiary truncate border-t border-border-light">{name}</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{availableAssets.filter(a => !assetSearch || (a.original_name || a.filename || '').toLowerCase().includes(assetSearch.toLowerCase())).length === 0 && (
|
||||
<p className="text-xs text-text-tertiary text-center py-4">{t('posts.noAssetsFound')}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,28 +1,21 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { X, Trash2, Upload, FileText, Link2, ExternalLink, FolderOpen, Image as ImageIcon, Music, Film, Send, CheckCircle2, XCircle, Copy, Check, Plus, Globe, Clock, User, FileEdit, Layers, Share2, ShieldCheck, MessageSquare } from 'lucide-react'
|
||||
import { Trash2, XCircle, FileEdit, Layers, Share2, ShieldCheck, MessageSquare } from 'lucide-react'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import { api, PLATFORMS, getBrandColor } from '../utils/api'
|
||||
import ApproverMultiSelect from './ApproverMultiSelect'
|
||||
import { api, getBrandColor } from '../utils/api'
|
||||
import CommentsSection from './CommentsSection'
|
||||
import Modal from './Modal'
|
||||
import TabbedModal from './TabbedModal'
|
||||
import { useToast } from './ToastContainer'
|
||||
|
||||
const AVAILABLE_LANGUAGES = [
|
||||
{ code: 'ar', label: 'Arabic' },
|
||||
{ code: 'en', label: 'English' },
|
||||
{ code: 'fr', label: 'French' },
|
||||
{ code: 'id', label: 'Bahasa Indonesia' },
|
||||
]
|
||||
import { PostDetailVersions } from './PostDetailVersions'
|
||||
import { PostDetailPlatforms } from './PostDetailPlatforms'
|
||||
import { PostDetailApproval } from './PostDetailApproval'
|
||||
import { PostDetailAttachments } from './PostDetailAttachments'
|
||||
|
||||
const TABS = ['details', 'versions', 'platforms', 'approval', 'discussion']
|
||||
|
||||
export default function PostDetailPanel({ post, onClose, onSave, onDelete, brands, teamMembers, campaigns }) {
|
||||
const { t, lang } = useLanguage()
|
||||
const toast = useToast()
|
||||
const imageInputRef = useRef(null)
|
||||
const audioInputRef = useRef(null)
|
||||
const videoInputRef = useRef(null)
|
||||
const versionFileInputRef = useRef(null)
|
||||
const [activeTab, setActiveTab] = useState('details')
|
||||
const [form, setForm] = useState({})
|
||||
@@ -38,24 +31,11 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
|
||||
// Attachments state (non-versioned, legacy)
|
||||
const [attachments, setAttachments] = useState([])
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [dragActive, setDragActive] = useState(false)
|
||||
const [showAssetPicker, setShowAssetPicker] = useState(false)
|
||||
const [availableAssets, setAvailableAssets] = useState([])
|
||||
const [assetSearch, setAssetSearch] = useState('')
|
||||
|
||||
// Versions state
|
||||
const [versions, setVersions] = useState([])
|
||||
const [selectedVersion, setSelectedVersion] = useState(null)
|
||||
const [versionData, setVersionData] = useState(null)
|
||||
const [showNewVersionModal, setShowNewVersionModal] = useState(false)
|
||||
const [newVersionNotes, setNewVersionNotes] = useState('')
|
||||
const [copyFromPrevious, setCopyFromPrevious] = useState(false)
|
||||
const [creatingVersion, setCreatingVersion] = useState(false)
|
||||
const [showLanguageModal, setShowLanguageModal] = useState(false)
|
||||
const [languageForm, setLanguageForm] = useState({ language_code: '', language_label: '', content: '' })
|
||||
const [savingLanguage, setSavingLanguage] = useState(false)
|
||||
const [confirmDeleteLangId, setConfirmDeleteLangId] = useState(null)
|
||||
const [confirmDeleteAttId, setConfirmDeleteAttId] = useState(null)
|
||||
const [uploadingVersionFile, setUploadingVersionFile] = useState(false)
|
||||
|
||||
const postId = post?._id || post?.id
|
||||
@@ -136,6 +116,7 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
|
||||
}
|
||||
|
||||
if (data.status === 'published' && data.platforms.length > 0) {
|
||||
const { PLATFORMS } = await import('../utils/api')
|
||||
const missingPlatforms = data.platforms.filter(platform => {
|
||||
const link = (data.publication_links || []).find(l => l.platform === platform)
|
||||
return !link || !link.url || !link.url.trim()
|
||||
@@ -237,33 +218,16 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
|
||||
}
|
||||
}
|
||||
|
||||
const openAssetPicker = async () => {
|
||||
try {
|
||||
const data = await api.get('/assets')
|
||||
setAvailableAssets(Array.isArray(data) ? data : [])
|
||||
} catch {
|
||||
setAvailableAssets([])
|
||||
}
|
||||
setAssetSearch('')
|
||||
setShowAssetPicker(true)
|
||||
}
|
||||
|
||||
const handleAttachAsset = async (assetId) => {
|
||||
if (!postId) return
|
||||
try {
|
||||
await api.post(`/posts/${postId}/attachments/from-asset`, { asset_id: assetId })
|
||||
loadAttachments()
|
||||
setShowAssetPicker(false)
|
||||
} catch (err) {
|
||||
console.error('Attach asset failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDrop = (e) => {
|
||||
e.preventDefault(); e.stopPropagation(); setDragActive(false)
|
||||
if (e.dataTransfer.files?.length) handleFileUpload(e.dataTransfer.files)
|
||||
}
|
||||
|
||||
// ─── Versions ──────────────────────────
|
||||
async function loadVersions() {
|
||||
if (!postId) return
|
||||
@@ -299,44 +263,28 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
|
||||
loadVersionData(version.Id || version.id || version._id)
|
||||
}
|
||||
|
||||
const handleCreateVersion = async () => {
|
||||
setCreatingVersion(true)
|
||||
const handleCreateVersion = async ({ notes, copy_from_previous }) => {
|
||||
try {
|
||||
await api.post(`/posts/${postId}/versions`, {
|
||||
notes: newVersionNotes || undefined,
|
||||
copy_from_previous: copyFromPrevious,
|
||||
notes: notes || undefined,
|
||||
copy_from_previous,
|
||||
})
|
||||
setShowNewVersionModal(false)
|
||||
setNewVersionNotes('')
|
||||
setCopyFromPrevious(false)
|
||||
loadVersions()
|
||||
} catch (err) {
|
||||
console.error('Create version failed:', err)
|
||||
} finally {
|
||||
setCreatingVersion(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddLanguage = async () => {
|
||||
if (!selectedVersion || !languageForm.language_code || !languageForm.content) return
|
||||
setSavingLanguage(true)
|
||||
try {
|
||||
const handleAddLanguage = async (languageForm) => {
|
||||
if (!selectedVersion) return
|
||||
const vId = selectedVersion.Id || selectedVersion.id || selectedVersion._id
|
||||
await api.post(`/posts/${postId}/versions/${vId}/texts`, languageForm)
|
||||
setShowLanguageModal(false)
|
||||
setLanguageForm({ language_code: '', language_label: '', content: '' })
|
||||
loadVersionData(vId)
|
||||
} catch (err) {
|
||||
console.error('Add language failed:', err)
|
||||
} finally {
|
||||
setSavingLanguage(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteLanguage = async (textId) => {
|
||||
try {
|
||||
await api.delete(`/post-version-texts/${textId}`)
|
||||
setConfirmDeleteLangId(null)
|
||||
const vId = selectedVersion.Id || selectedVersion.id || selectedVersion._id
|
||||
loadVersionData(vId)
|
||||
} catch (err) {
|
||||
@@ -364,7 +312,6 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
|
||||
const handleDeleteVersionAttachment = async (attId) => {
|
||||
try {
|
||||
await api.delete(`/attachments/${attId}`)
|
||||
setConfirmDeleteAttId(null)
|
||||
const vId = selectedVersion.Id || selectedVersion.id || selectedVersion._id
|
||||
loadVersionData(vId)
|
||||
} catch (err) {
|
||||
@@ -409,7 +356,7 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
|
||||
form.status === 'approved' ? 'bg-blue-100 text-blue-700' :
|
||||
form.status === 'in_review' ? 'bg-amber-100 text-amber-700' :
|
||||
form.status === 'rejected' ? 'bg-red-100 text-red-700' :
|
||||
'bg-gray-100 text-gray-600'
|
||||
'bg-gray-100 text-text-secondary'
|
||||
}`}>
|
||||
{statusOptions.find(s => s.value === form.status)?.label}
|
||||
</span>
|
||||
@@ -498,7 +445,7 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
|
||||
value={form.description}
|
||||
onChange={e => update('description', e.target.value)}
|
||||
rows={4}
|
||||
className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary resize-none"
|
||||
className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary resize-none"
|
||||
placeholder={t('posts.postDescPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
@@ -508,7 +455,7 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
|
||||
type="text"
|
||||
value={form.notes}
|
||||
onChange={e => update('notes', e.target.value)}
|
||||
className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
placeholder={t('posts.additionalNotes')}
|
||||
/>
|
||||
</div>
|
||||
@@ -532,7 +479,13 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{renderAttachments()}
|
||||
<PostDetailAttachments
|
||||
attachments={attachments}
|
||||
uploading={uploading}
|
||||
onFileUpload={handleFileUpload}
|
||||
onDeleteAttachment={handleDeleteAttachment}
|
||||
onAttachAsset={handleAttachAsset}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -545,7 +498,7 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
|
||||
<select
|
||||
value={form.status}
|
||||
onChange={e => update('status', e.target.value)}
|
||||
className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
>
|
||||
{statusOptions.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
|
||||
</select>
|
||||
@@ -556,7 +509,7 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
|
||||
type="date"
|
||||
value={form.scheduled_date}
|
||||
onChange={e => update('scheduled_date', e.target.value)}
|
||||
className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -564,7 +517,7 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
|
||||
<select
|
||||
value={form.assigned_to}
|
||||
onChange={e => update('assigned_to', e.target.value)}
|
||||
className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
>
|
||||
<option value="">{t('common.unassigned')}</option>
|
||||
{(teamMembers || []).map(m => <option key={m._id || m.id} value={m._id || m.id}>{m.name}</option>)}
|
||||
@@ -578,7 +531,7 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
|
||||
<select
|
||||
value={form.brand_id}
|
||||
onChange={e => update('brand_id', e.target.value)}
|
||||
className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
>
|
||||
<option value="">{t('posts.selectBrand')}</option>
|
||||
{(brands || []).map(b => <option key={b._id || b.id} value={b._id || b.id}>{b.icon} {lang === 'ar' && b.name_ar ? b.name_ar : b.name}</option>)}
|
||||
@@ -589,7 +542,7 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
|
||||
<select
|
||||
value={form.campaign_id}
|
||||
onChange={e => update('campaign_id', e.target.value)}
|
||||
className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
>
|
||||
<option value="">{t('posts.noCampaign')}</option>
|
||||
{(campaigns || []).map(c => <option key={c._id || c.id} value={c._id || c.id}>{c.name}</option>)}
|
||||
@@ -603,395 +556,46 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
|
||||
|
||||
{/* ─── Versions Tab ─── */}
|
||||
{activeTab === 'versions' && !isCreateMode && (
|
||||
<div className="flex h-full">
|
||||
{/* Version Timeline (left sidebar) */}
|
||||
<div className="w-64 shrink-0 border-e border-border p-4 overflow-y-auto bg-surface-secondary/50">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wide">{t('posts.versions')}</h4>
|
||||
<button
|
||||
onClick={() => setShowNewVersionModal(true)}
|
||||
className="flex items-center gap-1 px-2.5 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors shadow-sm"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
{t('posts.newVersion')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{versions.length === 0 ? (
|
||||
<div className="text-center py-10">
|
||||
<div className="w-12 h-12 rounded-full bg-surface-tertiary flex items-center justify-center mx-auto mb-3">
|
||||
<Layers className="w-6 h-6 text-text-quaternary" />
|
||||
</div>
|
||||
<p className="text-xs text-text-tertiary leading-relaxed px-2">{t('posts.noVersions')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{versions.map((version, idx) => {
|
||||
const vId = version.Id || version.id || version._id
|
||||
const isActive = vId === (selectedVersion?.Id || selectedVersion?.id || selectedVersion?._id)
|
||||
const isLatest = idx === versions.length - 1
|
||||
return (
|
||||
<button
|
||||
key={vId}
|
||||
onClick={() => handleSelectVersion(version)}
|
||||
className={`w-full text-start p-3 rounded-xl border transition-all ${
|
||||
isActive
|
||||
? 'border-brand-primary bg-white shadow-sm ring-1 ring-brand-primary/20'
|
||||
: 'border-transparent hover:bg-white hover:border-border'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className={`flex-shrink-0 w-7 h-7 rounded-lg flex items-center justify-center text-[11px] font-bold ${
|
||||
isActive ? 'bg-brand-primary text-white' : 'bg-surface-tertiary text-text-secondary'
|
||||
}`}>
|
||||
{version.version_number}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`text-sm font-medium ${isActive ? 'text-brand-primary' : 'text-text-primary'}`}>
|
||||
v{version.version_number}
|
||||
</span>
|
||||
{isLatest && (
|
||||
<span className="text-[9px] px-1.5 py-px bg-emerald-100 text-emerald-700 rounded font-semibold uppercase">
|
||||
Latest
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{version.notes && (
|
||||
<p className="text-[11px] text-text-tertiary line-clamp-1 mt-0.5">{version.notes}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{(version.creator_name || version.created_at) && (
|
||||
<div className="flex items-center gap-2 mt-2 ms-[38px] text-[10px] text-text-quaternary">
|
||||
{version.creator_name && <span>{version.creator_name}</span>}
|
||||
{version.creator_name && version.created_at && <span>·</span>}
|
||||
{version.created_at && <span>{new Date(version.created_at).toLocaleDateString()}</span>}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Version Content (right side) */}
|
||||
<div className="flex-1 min-w-0 overflow-y-auto p-6">
|
||||
{selectedVersion && versionData ? (
|
||||
<div className="space-y-6 w-full">
|
||||
{/* Languages */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="w-4 h-4 text-text-tertiary" />
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wide">{t('posts.languages')}</h4>
|
||||
{versionData.texts?.length > 0 && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-surface-tertiary text-text-tertiary font-medium">
|
||||
{versionData.texts.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowLanguageModal(true)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-brand-primary border border-brand-primary/30 hover:bg-brand-primary/5 rounded-lg transition-colors"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
{t('posts.addLanguage')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{versionData.texts && versionData.texts.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{versionData.texts.map(text => {
|
||||
const tId = text.Id || text.id || text._id
|
||||
return (
|
||||
<div key={tId} className="rounded-xl border border-border overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-2.5 bg-surface-secondary">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="px-2 py-0.5 bg-white border border-border rounded text-[11px] font-semibold uppercase text-text-secondary">{text.language_code}</span>
|
||||
<span className="text-sm font-medium text-text-primary">{text.language_label}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setConfirmDeleteLangId(tId)}
|
||||
className="p-1.5 text-text-quaternary hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-4 py-3 text-sm whitespace-pre-wrap text-text-primary leading-relaxed" dir={text.language_code === 'ar' ? 'rtl' : 'ltr'}>
|
||||
{text.content}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 rounded-xl border-2 border-dashed border-border">
|
||||
<Globe className="w-8 h-8 text-text-quaternary mx-auto mb-2" />
|
||||
<p className="text-sm text-text-tertiary">{t('posts.noLanguages')}</p>
|
||||
<button
|
||||
onClick={() => setShowLanguageModal(true)}
|
||||
className="mt-3 text-xs font-medium text-brand-primary hover:underline"
|
||||
>
|
||||
{t('posts.addLanguage')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Media / Attachments for this version */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ImageIcon className="w-4 h-4 text-text-tertiary" />
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wide">{t('posts.media')}</h4>
|
||||
{versionData.attachments?.length > 0 && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-surface-tertiary text-text-tertiary font-medium">
|
||||
{versionData.attachments.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-brand-primary border border-brand-primary/30 hover:bg-brand-primary/5 rounded-lg cursor-pointer transition-colors">
|
||||
<Upload className="w-3 h-3" />
|
||||
{uploadingVersionFile ? t('posts.uploading') : t('posts.addImage')}
|
||||
<input
|
||||
ref={versionFileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={e => { handleVersionFileUpload(e.target.files); e.target.value = '' }}
|
||||
disabled={uploadingVersionFile}
|
||||
<PostDetailVersions
|
||||
versions={versions}
|
||||
selectedVersion={selectedVersion}
|
||||
versionData={versionData}
|
||||
onSelectVersion={handleSelectVersion}
|
||||
onCreateVersion={handleCreateVersion}
|
||||
onAddLanguage={handleAddLanguage}
|
||||
onDeleteLanguage={handleDeleteLanguage}
|
||||
onVersionFileUpload={handleVersionFileUpload}
|
||||
onDeleteVersionAttachment={handleDeleteVersionAttachment}
|
||||
uploadingVersionFile={uploadingVersionFile}
|
||||
versionFileInputRef={versionFileInputRef}
|
||||
/>
|
||||
</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 ─── */}
|
||||
{activeTab === 'platforms' && (
|
||||
<div className="p-6 space-y-6 w-full">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Share2 className="w-4 h-4 text-text-tertiary" />
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wide">{t('posts.platforms')}</h4>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 p-3 rounded-xl bg-surface-secondary min-h-[44px]">
|
||||
{Object.entries(PLATFORMS).map(([k, v]) => {
|
||||
const checked = (form.platforms || []).includes(k)
|
||||
return (
|
||||
<label
|
||||
key={k}
|
||||
className={`flex items-center gap-1.5 text-xs px-3 py-2 rounded-lg cursor-pointer border transition-all ${
|
||||
checked
|
||||
? 'bg-white border-brand-primary/30 text-brand-primary font-medium shadow-sm'
|
||||
: 'bg-white/50 border-transparent text-text-secondary hover:bg-white hover:shadow-sm'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => {
|
||||
update('platforms', checked
|
||||
? form.platforms.filter(p => p !== k)
|
||||
: [...(form.platforms || []), k]
|
||||
)
|
||||
}}
|
||||
className="sr-only"
|
||||
<PostDetailPlatforms
|
||||
form={form}
|
||||
update={update}
|
||||
updatePublicationLink={updatePublicationLink}
|
||||
/>
|
||||
<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 ─── */}
|
||||
{activeTab === 'approval' && (
|
||||
<div className="p-6 space-y-5 w-full">
|
||||
<div className="bg-surface-secondary rounded-xl p-4">
|
||||
<label className="block text-xs font-medium text-text-primary mb-2">{t('posts.approvers')}</label>
|
||||
<ApproverMultiSelect
|
||||
users={teamMembers || []}
|
||||
selected={form.approver_ids || []}
|
||||
onChange={ids => update('approver_ids', ids)}
|
||||
<PostDetailApproval
|
||||
form={form}
|
||||
update={update}
|
||||
post={post}
|
||||
isCreateMode={isCreateMode}
|
||||
reviewUrl={reviewUrl}
|
||||
copied={copied}
|
||||
submittingReview={submittingReview}
|
||||
saving={saving}
|
||||
teamMembers={teamMembers}
|
||||
onSubmitReview={handleSubmitReview}
|
||||
onCopyReviewLink={copyReviewLink}
|
||||
onStatusAction={handleStatusAction}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isCreateMode && (
|
||||
<div className="space-y-4">
|
||||
{/* Approval status cards */}
|
||||
{form.status === 'approved' && post.approved_by_name && (
|
||||
<div className="bg-emerald-50 border border-emerald-200 rounded-xl p-4 flex items-start gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-emerald-100 flex items-center justify-center shrink-0 mt-0.5">
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-emerald-800">{t('posts.approvedBy')} {post.approved_by_name}</p>
|
||||
{post.feedback && <p className="text-xs text-emerald-700 mt-1.5 leading-relaxed">{post.feedback}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{form.status === 'rejected' && post.approved_by_name && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-4 flex items-start gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-red-100 flex items-center justify-center shrink-0 mt-0.5">
|
||||
<XCircle className="w-4 h-4 text-red-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-red-800">{t('posts.rejectedBy')} {post.approved_by_name}</p>
|
||||
{post.feedback && <p className="text-xs text-red-700 mt-1.5 leading-relaxed">{post.feedback}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{form.status === 'in_review' && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4 text-center">
|
||||
<div className="w-10 h-10 rounded-full bg-amber-100 flex items-center justify-center mx-auto mb-2">
|
||||
<Clock className="w-5 h-5 text-amber-600" />
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-amber-800">{t('posts.awaitingReview')}</p>
|
||||
<p className="text-xs text-amber-600 mt-1">{t('posts.awaitingReviewDesc')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Review link */}
|
||||
{reviewUrl && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
|
||||
<div className="text-xs font-semibold text-blue-900 mb-2.5">{t('posts.reviewLinkTitle')}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="text" value={reviewUrl} readOnly className="flex-1 px-3 py-2 text-xs bg-white border border-blue-200 rounded-lg font-mono" />
|
||||
<button onClick={copyReviewLink} className="p-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors shadow-sm">
|
||||
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex gap-3">
|
||||
{!reviewUrl && (
|
||||
<button
|
||||
onClick={handleSubmitReview}
|
||||
disabled={submittingReview}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-amber-500 text-white rounded-xl hover:bg-amber-600 transition-colors font-medium text-sm disabled:opacity-50 shadow-sm"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
{submittingReview ? t('posts.submitting') : t('posts.sendToReview')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{form.status === 'approved' && (
|
||||
<button
|
||||
onClick={() => handleStatusAction('scheduled')}
|
||||
disabled={saving}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-purple-600 text-white rounded-xl hover:bg-purple-700 transition-colors font-medium text-sm disabled:opacity-50 shadow-sm"
|
||||
>
|
||||
{t('posts.schedule')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── Discussion Tab ─── */}
|
||||
@@ -1014,319 +618,6 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
|
||||
>
|
||||
{t('posts.deleteConfirm')}
|
||||
</Modal>
|
||||
|
||||
{/* New Version Modal */}
|
||||
<Modal
|
||||
isOpen={showNewVersionModal}
|
||||
onClose={() => { setShowNewVersionModal(false); setNewVersionNotes(''); setCopyFromPrevious(false) }}
|
||||
title={t('posts.createNewVersion')}
|
||||
size="sm"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<textarea
|
||||
value={newVersionNotes}
|
||||
onChange={e => setNewVersionNotes(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary resize-none"
|
||||
placeholder={t('posts.whatChanged')}
|
||||
/>
|
||||
{versions.length > 0 && (
|
||||
<label className="flex items-center gap-2 text-sm text-text-secondary cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={copyFromPrevious}
|
||||
onChange={e => setCopyFromPrevious(e.target.checked)}
|
||||
className="rounded border-border text-brand-primary focus:ring-brand-primary/20"
|
||||
/>
|
||||
{t('posts.copyLanguages')}
|
||||
</label>
|
||||
)}
|
||||
<button
|
||||
onClick={handleCreateVersion}
|
||||
disabled={creatingVersion}
|
||||
className="w-full px-4 py-2.5 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 shadow-sm"
|
||||
>
|
||||
{creatingVersion ? t('posts.creatingVersion') : t('posts.createVersion')}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Add Language Modal */}
|
||||
<Modal
|
||||
isOpen={showLanguageModal}
|
||||
onClose={() => { setShowLanguageModal(false); setLanguageForm({ language_code: '', language_label: '', content: '' }) }}
|
||||
title={t('posts.addLanguage')}
|
||||
size="md"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<select
|
||||
value={languageForm.language_code}
|
||||
onChange={e => {
|
||||
const lang = AVAILABLE_LANGUAGES.find(l => l.code === e.target.value)
|
||||
setLanguageForm(f => ({ ...f, language_code: e.target.value, language_label: lang?.label || e.target.value }))
|
||||
}}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
>
|
||||
<option value="">{t('posts.selectLanguage')}</option>
|
||||
{AVAILABLE_LANGUAGES
|
||||
.filter(lang => !(versionData?.texts || []).some(t => t.language_code === lang.code))
|
||||
.map(lang => (
|
||||
<option key={lang.code} value={lang.code}>{lang.label} ({lang.code.toUpperCase()})</option>
|
||||
))}
|
||||
</select>
|
||||
<textarea
|
||||
value={languageForm.content}
|
||||
onChange={e => setLanguageForm(f => ({ ...f, content: e.target.value }))}
|
||||
rows={8}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary resize-none"
|
||||
placeholder={t('posts.enterContent')}
|
||||
dir={languageForm.language_code === 'ar' ? 'rtl' : 'ltr'}
|
||||
/>
|
||||
<button
|
||||
onClick={handleAddLanguage}
|
||||
disabled={savingLanguage || !languageForm.language_code || !languageForm.content}
|
||||
className="w-full px-4 py-2.5 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 shadow-sm"
|
||||
>
|
||||
{savingLanguage ? t('common.loading') : t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Delete Language Confirmation */}
|
||||
<Modal
|
||||
isOpen={!!confirmDeleteLangId}
|
||||
onClose={() => setConfirmDeleteLangId(null)}
|
||||
title={t('posts.deleteLanguage')}
|
||||
isConfirm
|
||||
danger
|
||||
confirmText={t('common.delete')}
|
||||
onConfirm={() => handleDeleteLanguage(confirmDeleteLangId)}
|
||||
>
|
||||
{t('posts.deleteLanguageConfirm')}
|
||||
</Modal>
|
||||
|
||||
{/* Delete Version Attachment Confirmation */}
|
||||
<Modal
|
||||
isOpen={!!confirmDeleteAttId}
|
||||
onClose={() => setConfirmDeleteAttId(null)}
|
||||
title={t('posts.deleteAttachment')}
|
||||
isConfirm
|
||||
danger
|
||||
confirmText={t('common.delete')}
|
||||
onConfirm={() => handleDeleteVersionAttachment(confirmDeleteAttId)}
|
||||
>
|
||||
{t('posts.deleteConfirm')}
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
|
||||
// ─── Render legacy attachments helper ──────────────────────────
|
||||
function renderAttachments() {
|
||||
const images = attachments.filter(a => (a.mime_type || a.mimeType || '').startsWith('image/'))
|
||||
const audio = attachments.filter(a => (a.mime_type || a.mimeType || '').startsWith('audio/'))
|
||||
const videos = attachments.filter(a => (a.mime_type || a.mimeType || '').startsWith('video/'))
|
||||
const others = attachments.filter(a => {
|
||||
const mime = a.mime_type || a.mimeType || ''
|
||||
return !mime.startsWith('image/') && !mime.startsWith('audio/') && !mime.startsWith('video/')
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Images */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-text-secondary">
|
||||
<ImageIcon className="w-3.5 h-3.5" />
|
||||
{t('posts.images')}
|
||||
{images.length > 0 && <span className="text-text-tertiary">({images.length})</span>}
|
||||
</div>
|
||||
<label className="flex items-center gap-1 px-2 py-1 text-[11px] font-medium text-brand-primary hover:bg-brand-primary/5 rounded cursor-pointer transition-colors">
|
||||
<Upload className="w-3 h-3" />
|
||||
{t('posts.addImage')}
|
||||
<input ref={imageInputRef} type="file" multiple accept="image/*" className="hidden"
|
||||
onChange={e => { handleFileUpload(e.target.files); e.target.value = '' }} />
|
||||
</label>
|
||||
</div>
|
||||
{images.length > 0 && (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{images.map(att => {
|
||||
const attUrl = att.url || `/api/uploads/${att.filename}`
|
||||
const name = att.original_name || att.originalName || att.filename
|
||||
const attId = att.id || att._id
|
||||
return (
|
||||
<div key={attId} className="relative group/att border border-border rounded-lg overflow-hidden bg-white">
|
||||
<div className="h-20 relative">
|
||||
<a href={attUrl} target="_blank" rel="noopener noreferrer" className="block h-full">
|
||||
<img src={attUrl} alt={name} className="absolute inset-0 w-full h-full object-cover" />
|
||||
</a>
|
||||
<button onClick={() => handleDeleteAttachment(attId)}
|
||||
className="absolute top-1 right-1 p-1 bg-black/50 hover:bg-red-500 rounded-full text-white opacity-0 group-hover/att:opacity-100 transition-opacity"
|
||||
title={t('common.delete')}><X className="w-2.5 h-2.5" /></button>
|
||||
</div>
|
||||
<div className="px-2 py-1 text-[10px] text-text-tertiary truncate border-t border-border-light">{name}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Audio */}
|
||||
{audio.length > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-text-secondary mb-2">
|
||||
<Music className="w-3.5 h-3.5" />
|
||||
{t('posts.audio')} <span className="text-text-tertiary">({audio.length})</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{audio.map(att => {
|
||||
const attUrl = att.url || `/api/uploads/${att.filename}`
|
||||
const name = att.original_name || att.originalName || att.filename
|
||||
const attId = att.id || att._id
|
||||
return (
|
||||
<div key={attId} className="flex items-center gap-2 border border-border rounded-lg p-2 bg-white group/att">
|
||||
<Music className="w-4 h-4 text-text-tertiary shrink-0" />
|
||||
<span className="text-xs text-text-secondary truncate flex-1">{name}</span>
|
||||
<audio src={attUrl} controls className="h-7 max-w-[160px]" />
|
||||
<button onClick={() => handleDeleteAttachment(attId)}
|
||||
className="p-1 text-text-tertiary hover:text-red-500 opacity-0 group-hover/att:opacity-100 transition-opacity"
|
||||
title={t('common.delete')}><X className="w-3 h-3" /></button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Videos */}
|
||||
{videos.length > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-text-secondary mb-2">
|
||||
<Film className="w-3.5 h-3.5" />
|
||||
{t('posts.videos')} <span className="text-text-tertiary">({videos.length})</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{videos.map(att => {
|
||||
const attUrl = att.url || `/api/uploads/${att.filename}`
|
||||
const name = att.original_name || att.originalName || att.filename
|
||||
const attId = att.id || att._id
|
||||
return (
|
||||
<div key={attId} className="border border-border rounded-lg overflow-hidden bg-white group/att">
|
||||
<video src={attUrl} controls className="w-full max-h-40" />
|
||||
<div className="flex items-center justify-between px-2 py-1 border-t border-border-light">
|
||||
<span className="text-[10px] text-text-tertiary truncate">{name}</span>
|
||||
<button onClick={() => handleDeleteAttachment(attId)}
|
||||
className="p-1 text-text-tertiary hover:text-red-500 opacity-0 group-hover/att:opacity-100 transition-opacity"
|
||||
title={t('common.delete')}><X className="w-3 h-3" /></button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Other files */}
|
||||
{others.length > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-text-secondary mb-2">
|
||||
<FileText className="w-3.5 h-3.5" />
|
||||
{t('posts.otherFiles')} <span className="text-text-tertiary">({others.length})</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{others.map(att => {
|
||||
const attUrl = att.url || `/api/uploads/${att.filename}`
|
||||
const name = att.original_name || att.originalName || att.filename
|
||||
const attId = att.id || att._id
|
||||
return (
|
||||
<div key={attId} className="relative group/att border border-border rounded-lg overflow-hidden bg-white">
|
||||
<a href={attUrl} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 p-3 h-16">
|
||||
<FileText className="w-6 h-6 text-text-tertiary shrink-0" />
|
||||
<span className="text-xs text-text-secondary truncate">{name}</span>
|
||||
</a>
|
||||
<button onClick={() => handleDeleteAttachment(attId)}
|
||||
className="absolute top-1 right-1 p-1 bg-black/50 hover:bg-red-500 rounded-full text-white opacity-0 group-hover/att:opacity-100 transition-opacity"
|
||||
title={t('common.delete')}><X className="w-2.5 h-2.5" /></button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Drag and drop zone */}
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-3 text-center cursor-pointer transition-colors ${
|
||||
dragActive ? 'border-brand-primary bg-brand-primary/5' : 'border-border hover:border-brand-primary/40'
|
||||
}`}
|
||||
onDragEnter={e => { e.preventDefault(); setDragActive(true) }}
|
||||
onDragLeave={e => { e.preventDefault(); setDragActive(false) }}
|
||||
onDragOver={e => e.preventDefault()}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<Upload className={`w-4 h-4 text-text-tertiary mx-auto mb-1 ${uploading ? 'animate-pulse' : ''}`} />
|
||||
<p className="text-[11px] text-text-secondary">
|
||||
{dragActive ? t('posts.dropFiles') : t('posts.dragToUpload')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={openAssetPicker}
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm text-text-secondary border border-border rounded-lg hover:bg-surface-tertiary transition-colors w-full justify-center"
|
||||
>
|
||||
<FolderOpen className="w-4 h-4" />
|
||||
{t('posts.attachFromAssets')}
|
||||
</button>
|
||||
|
||||
{showAssetPicker && (
|
||||
<div className="border border-border rounded-lg p-3 bg-surface-secondary">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-xs font-medium text-text-secondary">{t('posts.selectAssets')}</p>
|
||||
<button onClick={() => setShowAssetPicker(false)} className="p-1 text-text-tertiary hover:text-text-primary">
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={assetSearch}
|
||||
onChange={e => setAssetSearch(e.target.value)}
|
||||
placeholder={t('common.search')}
|
||||
className="w-full px-3 py-1.5 text-xs border border-border rounded-lg mb-2 focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
/>
|
||||
<div className="grid grid-cols-4 gap-2 max-h-48 overflow-y-auto">
|
||||
{availableAssets
|
||||
.filter(a => !assetSearch || (a.original_name || a.filename || '').toLowerCase().includes(assetSearch.toLowerCase()))
|
||||
.map(asset => {
|
||||
const isImage = asset.mime_type?.startsWith('image/')
|
||||
const assetUrl = `/api/uploads/${asset.filename}`
|
||||
const name = asset.original_name || asset.filename
|
||||
return (
|
||||
<button
|
||||
key={asset.id || asset._id}
|
||||
onClick={() => handleAttachAsset(asset.id || asset._id)}
|
||||
className="block w-full border border-border rounded-lg overflow-hidden bg-white hover:border-brand-primary hover:ring-2 hover:ring-brand-primary/20 transition-all text-left"
|
||||
>
|
||||
<div className="aspect-square relative">
|
||||
{isImage ? (
|
||||
<img src={assetUrl} alt={name} className="absolute inset-0 w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-surface-tertiary">
|
||||
<FileText className="w-6 h-6 text-text-tertiary" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-1.5 py-1 text-[10px] text-text-tertiary truncate border-t border-border-light">{name}</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{availableAssets.filter(a => !assetSearch || (a.original_name || a.filename || '').toLowerCase().includes(assetSearch.toLowerCase())).length === 0 && (
|
||||
<p className="text-xs text-text-tertiary text-center py-4">{t('posts.noAssetsFound')}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Link2, ExternalLink, XCircle, Share2 } from 'lucide-react'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import { PLATFORMS } from '../utils/api'
|
||||
|
||||
export function PostDetailPlatforms({ form, update, updatePublicationLink }) {
|
||||
const { t } = useLanguage()
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6 w-full">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Share2 className="w-4 h-4 text-text-tertiary" />
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wide">{t('posts.platforms')}</h4>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 p-3 rounded-xl bg-surface-secondary min-h-[44px]">
|
||||
{Object.entries(PLATFORMS).map(([k, v]) => {
|
||||
const checked = (form.platforms || []).includes(k)
|
||||
return (
|
||||
<label
|
||||
key={k}
|
||||
className={`flex items-center gap-1.5 text-xs px-3 py-2 rounded-lg cursor-pointer border transition-all ${
|
||||
checked
|
||||
? 'bg-surface border-brand-primary/30 text-brand-primary font-medium shadow-sm'
|
||||
: 'bg-white/50 border-transparent text-text-secondary hover:bg-surface hover:shadow-sm'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => {
|
||||
update('platforms', checked
|
||||
? form.platforms.filter(p => p !== k)
|
||||
: [...(form.platforms || []), k]
|
||||
)
|
||||
}}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: v.color || '#888' }} />
|
||||
{v.label}
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(form.platforms || []).length > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Link2 className="w-4 h-4 text-text-tertiary" />
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wide">{t('posts.publicationLinks')}</h4>
|
||||
</div>
|
||||
<div className="space-y-2.5">
|
||||
{(form.platforms || []).map(platformKey => {
|
||||
const platformInfo = PLATFORMS[platformKey] || { label: platformKey }
|
||||
const existingLink = (form.publication_links || []).find(l => l.platform === platformKey)
|
||||
const linkUrl = existingLink?.url || ''
|
||||
return (
|
||||
<div key={platformKey} className="flex items-center gap-3 p-3 rounded-xl bg-surface-secondary">
|
||||
<span className="text-xs font-medium text-text-primary w-28 shrink-0 flex items-center gap-2">
|
||||
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: platformInfo.color || '#888' }} />
|
||||
{platformInfo.label}
|
||||
</span>
|
||||
<input
|
||||
type="url"
|
||||
value={linkUrl}
|
||||
onChange={e => updatePublicationLink(platformKey, e.target.value)}
|
||||
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
placeholder="https://..."
|
||||
/>
|
||||
{linkUrl && (
|
||||
<a href={linkUrl} target="_blank" rel="noopener noreferrer" className="p-2 text-text-tertiary hover:text-brand-primary hover:bg-surface rounded-lg transition-colors">
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{form.status === 'published' && (form.platforms || []).some(p => {
|
||||
const link = (form.publication_links || []).find(l => l.platform === p)
|
||||
return !link || !link.url?.trim()
|
||||
}) && (
|
||||
<p className="text-xs text-amber-600 mt-3 flex items-center gap-1.5">
|
||||
<XCircle className="w-3.5 h-3.5" />
|
||||
{t('posts.publishRequired')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
import { useState } from 'react'
|
||||
import { Trash2, Upload, FileText, Image as ImageIcon, Plus, Globe, Layers } from 'lucide-react'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import Modal from './Modal'
|
||||
|
||||
const AVAILABLE_LANGUAGES = [
|
||||
{ code: 'ar', label: 'Arabic' },
|
||||
{ code: 'en', label: 'English' },
|
||||
{ code: 'fr', label: 'French' },
|
||||
{ code: 'id', label: 'Bahasa Indonesia' },
|
||||
]
|
||||
|
||||
export function PostDetailVersions({
|
||||
versions,
|
||||
selectedVersion,
|
||||
versionData,
|
||||
onSelectVersion,
|
||||
onCreateVersion,
|
||||
onAddLanguage,
|
||||
onDeleteLanguage,
|
||||
onVersionFileUpload,
|
||||
onDeleteVersionAttachment,
|
||||
uploadingVersionFile,
|
||||
versionFileInputRef,
|
||||
}) {
|
||||
const { t } = useLanguage()
|
||||
const [showNewVersionModal, setShowNewVersionModal] = useState(false)
|
||||
const [newVersionNotes, setNewVersionNotes] = useState('')
|
||||
const [copyFromPrevious, setCopyFromPrevious] = useState(false)
|
||||
const [creatingVersion, setCreatingVersion] = useState(false)
|
||||
const [showLanguageModal, setShowLanguageModal] = useState(false)
|
||||
const [languageForm, setLanguageForm] = useState({ language_code: '', language_label: '', content: '' })
|
||||
const [savingLanguage, setSavingLanguage] = useState(false)
|
||||
const [confirmDeleteLangId, setConfirmDeleteLangId] = useState(null)
|
||||
const [confirmDeleteAttId, setConfirmDeleteAttId] = useState(null)
|
||||
|
||||
const handleCreateVersion = async () => {
|
||||
setCreatingVersion(true)
|
||||
try {
|
||||
await onCreateVersion({ notes: newVersionNotes || undefined, copy_from_previous: copyFromPrevious })
|
||||
setShowNewVersionModal(false)
|
||||
setNewVersionNotes('')
|
||||
setCopyFromPrevious(false)
|
||||
} finally {
|
||||
setCreatingVersion(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddLanguage = async () => {
|
||||
if (!selectedVersion || !languageForm.language_code || !languageForm.content) return
|
||||
setSavingLanguage(true)
|
||||
try {
|
||||
await onAddLanguage(languageForm)
|
||||
setShowLanguageModal(false)
|
||||
setLanguageForm({ language_code: '', language_label: '', content: '' })
|
||||
} finally {
|
||||
setSavingLanguage(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteLanguage = async (textId) => {
|
||||
await onDeleteLanguage(textId)
|
||||
setConfirmDeleteLangId(null)
|
||||
}
|
||||
|
||||
const handleDeleteAttachment = async (attId) => {
|
||||
await onDeleteVersionAttachment(attId)
|
||||
setConfirmDeleteAttId(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-full">
|
||||
{/* Version Timeline (left sidebar) */}
|
||||
<div className="w-64 shrink-0 border-e border-border p-4 overflow-y-auto bg-surface-secondary/50">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wide">{t('posts.versions')}</h4>
|
||||
<button
|
||||
onClick={() => setShowNewVersionModal(true)}
|
||||
className="flex items-center gap-1 px-2.5 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors shadow-sm"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
{t('posts.newVersion')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{versions.length === 0 ? (
|
||||
<div className="text-center py-10">
|
||||
<div className="w-12 h-12 rounded-full bg-surface-tertiary flex items-center justify-center mx-auto mb-3">
|
||||
<Layers className="w-6 h-6 text-text-quaternary" />
|
||||
</div>
|
||||
<p className="text-xs text-text-tertiary leading-relaxed px-2">{t('posts.noVersions')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{versions.map((version, idx) => {
|
||||
const vId = version.Id || version.id || version._id
|
||||
const isActive = vId === (selectedVersion?.Id || selectedVersion?.id || selectedVersion?._id)
|
||||
const isLatest = idx === versions.length - 1
|
||||
return (
|
||||
<button
|
||||
key={vId}
|
||||
onClick={() => onSelectVersion(version)}
|
||||
className={`w-full text-start p-3 rounded-xl border transition-all ${
|
||||
isActive
|
||||
? 'border-brand-primary bg-surface shadow-sm ring-1 ring-brand-primary/20'
|
||||
: 'border-transparent hover:bg-surface hover:border-border'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className={`flex-shrink-0 w-7 h-7 rounded-lg flex items-center justify-center text-[11px] font-bold ${
|
||||
isActive ? 'bg-brand-primary text-white' : 'bg-surface-tertiary text-text-secondary'
|
||||
}`}>
|
||||
{version.version_number}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`text-sm font-medium ${isActive ? 'text-brand-primary' : 'text-text-primary'}`}>
|
||||
v{version.version_number}
|
||||
</span>
|
||||
{isLatest && (
|
||||
<span className="text-[9px] px-1.5 py-px bg-emerald-100 text-emerald-700 rounded font-semibold uppercase">
|
||||
Latest
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{version.notes && (
|
||||
<p className="text-[11px] text-text-tertiary line-clamp-1 mt-0.5">{version.notes}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{(version.creator_name || version.created_at) && (
|
||||
<div className="flex items-center gap-2 mt-2 ms-[38px] text-[10px] text-text-quaternary">
|
||||
{version.creator_name && <span>{version.creator_name}</span>}
|
||||
{version.creator_name && version.created_at && <span>·</span>}
|
||||
{version.created_at && <span>{new Date(version.created_at).toLocaleDateString()}</span>}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Version Content (right side) */}
|
||||
<div className="flex-1 min-w-0 overflow-y-auto p-6">
|
||||
{selectedVersion && versionData ? (
|
||||
<div className="space-y-6 w-full">
|
||||
{/* Languages */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="w-4 h-4 text-text-tertiary" />
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wide">{t('posts.languages')}</h4>
|
||||
{versionData.texts?.length > 0 && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-surface-tertiary text-text-tertiary font-medium">
|
||||
{versionData.texts.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowLanguageModal(true)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-brand-primary border border-brand-primary/30 hover:bg-brand-primary/5 rounded-lg transition-colors"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
{t('posts.addLanguage')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{versionData.texts && versionData.texts.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{versionData.texts.map(text => {
|
||||
const tId = text.Id || text.id || text._id
|
||||
return (
|
||||
<div key={tId} className="rounded-xl border border-border overflow-clip">
|
||||
<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-clip bg-surface hover:shadow-md transition-shadow">
|
||||
{isImage ? (
|
||||
<a href={attUrl} target="_blank" rel="noopener noreferrer">
|
||||
<img src={attUrl} alt={name} className="w-full h-44 object-cover" loading="lazy" />
|
||||
</a>
|
||||
) : isVideo ? (
|
||||
<video src={attUrl} controls className="w-full h-44 object-cover" />
|
||||
) : (
|
||||
<a href={attUrl} target="_blank" rel="noopener noreferrer" className="flex items-center justify-center h-44 bg-surface-tertiary">
|
||||
<FileText className="w-10 h-10 text-text-quaternary" />
|
||||
</a>
|
||||
)}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-t border-border bg-surface-secondary/50">
|
||||
<span className="text-[11px] text-text-secondary truncate">{name}</span>
|
||||
<button
|
||||
onClick={() => setConfirmDeleteAttId(attId)}
|
||||
className="p-1 text-text-quaternary hover:text-red-500 opacity-0 group-hover:opacity-100 transition-all"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 rounded-xl border-2 border-dashed border-border">
|
||||
<ImageIcon className="w-8 h-8 text-text-quaternary mx-auto mb-2" />
|
||||
<p className="text-sm text-text-tertiary">{t('posts.noMedia')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : versions.length > 0 ? (
|
||||
<div className="flex items-center justify-center h-40">
|
||||
<div className="w-6 h-6 border-2 border-brand-primary border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* New Version Modal */}
|
||||
<Modal
|
||||
isOpen={showNewVersionModal}
|
||||
onClose={() => { setShowNewVersionModal(false); setNewVersionNotes(''); setCopyFromPrevious(false) }}
|
||||
title={t('posts.createNewVersion')}
|
||||
size="sm"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<textarea
|
||||
value={newVersionNotes}
|
||||
onChange={e => setNewVersionNotes(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary resize-none"
|
||||
placeholder={t('posts.whatChanged')}
|
||||
/>
|
||||
{versions.length > 0 && (
|
||||
<label className="flex items-center gap-2 text-sm text-text-secondary cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={copyFromPrevious}
|
||||
onChange={e => setCopyFromPrevious(e.target.checked)}
|
||||
className="rounded border-border text-brand-primary focus:ring-brand-primary/20"
|
||||
/>
|
||||
{t('posts.copyLanguages')}
|
||||
</label>
|
||||
)}
|
||||
<button
|
||||
onClick={handleCreateVersion}
|
||||
disabled={creatingVersion}
|
||||
className="w-full px-4 py-2.5 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 shadow-sm"
|
||||
>
|
||||
{creatingVersion ? t('posts.creatingVersion') : t('posts.createVersion')}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Add Language Modal */}
|
||||
<Modal
|
||||
isOpen={showLanguageModal}
|
||||
onClose={() => { setShowLanguageModal(false); setLanguageForm({ language_code: '', language_label: '', content: '' }) }}
|
||||
title={t('posts.addLanguage')}
|
||||
size="md"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<select
|
||||
value={languageForm.language_code}
|
||||
onChange={e => {
|
||||
const lang = AVAILABLE_LANGUAGES.find(l => l.code === e.target.value)
|
||||
setLanguageForm(f => ({ ...f, language_code: e.target.value, language_label: lang?.label || e.target.value }))
|
||||
}}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
>
|
||||
<option value="">{t('posts.selectLanguage')}</option>
|
||||
{AVAILABLE_LANGUAGES
|
||||
.filter(lang => !(versionData?.texts || []).some(t => t.language_code === lang.code))
|
||||
.map(lang => (
|
||||
<option key={lang.code} value={lang.code}>{lang.label} ({lang.code.toUpperCase()})</option>
|
||||
))}
|
||||
</select>
|
||||
<textarea
|
||||
value={languageForm.content}
|
||||
onChange={e => setLanguageForm(f => ({ ...f, content: e.target.value }))}
|
||||
rows={8}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary resize-none"
|
||||
placeholder={t('posts.enterContent')}
|
||||
dir={languageForm.language_code === 'ar' ? 'rtl' : 'ltr'}
|
||||
/>
|
||||
<button
|
||||
onClick={handleAddLanguage}
|
||||
disabled={savingLanguage || !languageForm.language_code || !languageForm.content}
|
||||
className="w-full px-4 py-2.5 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 shadow-sm"
|
||||
>
|
||||
{savingLanguage ? t('common.loading') : t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Delete Language Confirmation */}
|
||||
<Modal
|
||||
isOpen={!!confirmDeleteLangId}
|
||||
onClose={() => setConfirmDeleteLangId(null)}
|
||||
title={t('posts.deleteLanguage')}
|
||||
isConfirm
|
||||
danger
|
||||
confirmText={t('common.delete')}
|
||||
onConfirm={() => handleDeleteLanguage(confirmDeleteLangId)}
|
||||
>
|
||||
{t('posts.deleteLanguageConfirm')}
|
||||
</Modal>
|
||||
|
||||
{/* Delete Version Attachment Confirmation */}
|
||||
<Modal
|
||||
isOpen={!!confirmDeleteAttId}
|
||||
onClose={() => setConfirmDeleteAttId(null)}
|
||||
title={t('posts.deleteAttachment')}
|
||||
isConfirm
|
||||
danger
|
||||
confirmText={t('common.delete')}
|
||||
onConfirm={() => handleDeleteAttachment(confirmDeleteAttId)}
|
||||
>
|
||||
{t('posts.deleteConfirm')}
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -21,11 +21,11 @@ export default function ProjectCard({ project }) {
|
||||
return (
|
||||
<div
|
||||
onClick={() => navigate(`/projects/${project._id}`)}
|
||||
className="bg-white rounded-xl border border-border card-hover cursor-pointer overflow-hidden"
|
||||
className="bg-surface rounded-xl border border-border card-hover cursor-pointer overflow-hidden"
|
||||
>
|
||||
{thumbnailUrl ? (
|
||||
<div className="w-full h-32 overflow-hidden">
|
||||
<img src={thumbnailUrl} alt="" className="w-full h-full object-cover" />
|
||||
<img src={thumbnailUrl} alt="" className="w-full h-full object-cover" loading="lazy" />
|
||||
</div>
|
||||
) : null}
|
||||
<div className="p-5">
|
||||
|
||||
@@ -5,6 +5,7 @@ import { api, getBrandColor } from '../utils/api'
|
||||
import CommentsSection from './CommentsSection'
|
||||
import Modal from './Modal'
|
||||
import TabbedModal from './TabbedModal'
|
||||
import PortalSelect from './PortalSelect'
|
||||
import { AppContext } from '../App'
|
||||
|
||||
export default function ProjectEditPanel({ project, onClose, onSave, onDelete, brands, teamMembers }) {
|
||||
@@ -131,7 +132,7 @@ export default function ProjectEditPanel({ project, onClose, onSave, onDelete, b
|
||||
form.status === 'paused' ? 'bg-amber-100 text-amber-700' :
|
||||
form.status === 'completed' ? 'bg-blue-100 text-blue-700' :
|
||||
form.status === 'cancelled' ? 'bg-red-100 text-red-700' :
|
||||
'bg-gray-100 text-gray-600'
|
||||
'bg-gray-100 text-text-secondary'
|
||||
}`}>
|
||||
{statusOptions.find(s => s.value === form.status)?.label}
|
||||
</span>
|
||||
@@ -186,49 +187,42 @@ export default function ProjectEditPanel({ project, onClose, onSave, onDelete, b
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('projects.brand')}</label>
|
||||
<select
|
||||
<PortalSelect
|
||||
value={form.brand_id}
|
||||
onChange={e => update('brand_id', e.target.value)}
|
||||
onChange={val => update('brand_id', val)}
|
||||
options={[{ value: '', label: 'Select brand' }, ...(brands || []).map(b => ({ value: b._id || b.id, label: `${b.icon || ''} ${lang === 'ar' && b.name_ar ? b.name_ar : b.name}`.trim() }))]}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
>
|
||||
<option value="">Select brand</option>
|
||||
{(brands || []).map(b => <option key={b._id || b.id} value={b._id || b.id}>{b.icon} {lang === 'ar' && b.name_ar ? b.name_ar : b.name}</option>)}
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('projects.status')}</label>
|
||||
<select
|
||||
<PortalSelect
|
||||
value={form.status}
|
||||
onChange={e => update('status', e.target.value)}
|
||||
onChange={val => update('status', val)}
|
||||
options={statusOptions}
|
||||
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"
|
||||
>
|
||||
{statusOptions.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('projects.owner')}</label>
|
||||
<select
|
||||
<PortalSelect
|
||||
value={form.owner_id}
|
||||
onChange={e => update('owner_id', e.target.value)}
|
||||
onChange={val => update('owner_id', val)}
|
||||
options={[{ value: '', label: t('common.unassigned') }, ...(teamMembers || []).map(m => ({ value: m._id || m.id, label: m.name }))]}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
>
|
||||
<option value="">{t('common.unassigned')}</option>
|
||||
{(teamMembers || []).map(m => <option key={m._id || m.id} value={m._id || m.id}>{m.name}</option>)}
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('common.team')}</label>
|
||||
<select
|
||||
<PortalSelect
|
||||
value={form.team_id}
|
||||
onChange={e => update('team_id', e.target.value)}
|
||||
onChange={val => update('team_id', val)}
|
||||
options={[{ value: '', label: t('common.noTeam') }, ...(teams || []).map(tm => ({ value: tm.id || tm._id, label: tm.name }))]}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
>
|
||||
<option value="">{t('common.noTeam')}</option>
|
||||
{(teams || []).map(t => <option key={t.id || t._id} value={t.id || t._id}>{t.name}</option>)}
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -257,11 +251,11 @@ export default function ProjectEditPanel({ project, onClose, onSave, onDelete, b
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('projects.thumbnail')}</label>
|
||||
{(project.thumbnail_url || project.thumbnailUrl) ? (
|
||||
<div className="relative group rounded-lg overflow-hidden border border-border">
|
||||
<img src={project.thumbnail_url || project.thumbnailUrl} alt="" className="w-full h-24 object-cover" />
|
||||
<img src={project.thumbnail_url || project.thumbnailUrl} alt="" className="w-full h-24 object-cover" loading="lazy" />
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/30 transition-colors flex items-center justify-center gap-2 opacity-0 group-hover:opacity-100">
|
||||
<button
|
||||
onClick={() => thumbnailInputRef.current?.click()}
|
||||
className="px-3 py-1.5 text-xs bg-white/90 hover:bg-white rounded-lg font-medium text-text-primary transition-colors"
|
||||
className="px-3 py-1.5 text-xs bg-white/90 hover:bg-surface rounded-lg font-medium text-text-primary transition-colors"
|
||||
>
|
||||
{t('projects.changeThumbnail')}
|
||||
</button>
|
||||
@@ -289,7 +283,8 @@ export default function ProjectEditPanel({ project, onClose, onSave, onDelete, b
|
||||
ref={thumbnailInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
className="absolute w-0 h-0 opacity-0 pointer-events-none"
|
||||
tabIndex={-1}
|
||||
onChange={e => { handleThumbnailUpload(e.target.files[0]); e.target.value = '' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -3,8 +3,17 @@ import { NavLink } from 'react-router-dom'
|
||||
import {
|
||||
LayoutDashboard, FileEdit, Image, Calendar, Wallet,
|
||||
FolderKanban, CheckSquare, Users, ChevronLeft, ChevronRight, ChevronDown,
|
||||
Sparkles, LogOut, User, Settings, Languages, Tag, LayoutList, Receipt, BarChart3, Palette, CalendarDays, AlertCircle
|
||||
LogOut, User, Settings, Languages, Tag, LayoutList, Receipt, BarChart3, Palette, CalendarDays, AlertCircle
|
||||
} from 'lucide-react'
|
||||
|
||||
function MarkaLogo({ className = '' }) {
|
||||
return (
|
||||
<svg viewBox="0 0 32 32" fill="none" className={className}>
|
||||
<path d="M4 26V6l10 10L4 26z" fill="currentColor" opacity="0.85" />
|
||||
<path d="M18 26V6l10 10-10 10z" fill="currentColor" opacity="0.5" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
|
||||
@@ -26,7 +35,7 @@ const moduleGroups = [
|
||||
{ to: '/artefacts', icon: Palette, labelKey: 'nav.artefacts' },
|
||||
{ to: '/assets', icon: Image, labelKey: 'nav.assets', tutorial: 'assets' },
|
||||
{ to: '/brands', icon: Tag, labelKey: 'nav.brands' },
|
||||
{ to: '/translations', icon: Languages, labelKey: 'nav.translations' },
|
||||
{ to: '/translations', icon: Languages, labelKey: 'nav.copy' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -115,8 +124,8 @@ export default function Sidebar({ collapsed, setCollapsed }) {
|
||||
>
|
||||
{/* Logo */}
|
||||
<div className="flex items-center gap-3 px-4 h-16 border-b border-white/10 shrink-0">
|
||||
<div className="w-9 h-9 rounded-lg bg-gradient-to-br from-indigo-400 via-purple-500 to-pink-500 flex items-center justify-center shrink-0 shadow-lg shadow-indigo-500/30">
|
||||
<Sparkles className="w-5 h-5 text-white" />
|
||||
<div className="w-9 h-9 rounded-lg bg-brand-primary flex items-center justify-center shrink-0">
|
||||
<MarkaLogo className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className="animate-fade-in overflow-hidden">
|
||||
@@ -191,7 +200,7 @@ export default function Sidebar({ collapsed, setCollapsed }) {
|
||||
<div className="flex items-center gap-3 px-3 py-2 rounded-lg bg-white/5">
|
||||
<div className="w-8 h-8 rounded-full bg-brand-primary flex items-center justify-center shrink-0">
|
||||
{currentUser.avatar ? (
|
||||
<img src={currentUser.avatar} alt={currentUser.name} className="w-full h-full rounded-full object-cover" />
|
||||
<img src={currentUser.avatar} alt={currentUser.name} className="w-full h-full rounded-full object-cover" loading="lazy" />
|
||||
) : (
|
||||
<User className="w-4 h-4 text-white" />
|
||||
)}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
export function SkeletonCard() {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-border p-5 animate-pulse">
|
||||
<div className="bg-surface rounded-xl border border-border p-5 animate-pulse">
|
||||
<div className="h-4 bg-surface-tertiary rounded w-3/4 mb-3"></div>
|
||||
<div className="h-3 bg-surface-tertiary rounded w-1/2 mb-2"></div>
|
||||
<div className="h-3 bg-surface-tertiary rounded w-2/3"></div>
|
||||
@@ -12,7 +12,7 @@ export function SkeletonCard() {
|
||||
|
||||
export function SkeletonStatCard() {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-border p-5 animate-pulse">
|
||||
<div className="bg-surface rounded-xl border border-border p-5 animate-pulse">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="w-10 h-10 bg-surface-tertiary rounded-lg"></div>
|
||||
<div className="h-3 bg-surface-tertiary rounded w-16"></div>
|
||||
@@ -25,7 +25,7 @@ export function SkeletonStatCard() {
|
||||
|
||||
export function SkeletonTable({ rows = 5, cols = 6 }) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-border overflow-hidden animate-pulse">
|
||||
<div className="bg-surface rounded-xl border border-border overflow-clip animate-pulse">
|
||||
<div className="border-b border-border bg-surface-secondary p-4">
|
||||
<div className="flex gap-4">
|
||||
{[...Array(cols)].map((_, i) => (
|
||||
@@ -60,7 +60,7 @@ export function SkeletonKanbanBoard() {
|
||||
</div>
|
||||
<div className="bg-surface-secondary rounded-xl p-2 space-y-2 min-h-[400px]">
|
||||
{[...Array(3)].map((_, cardIdx) => (
|
||||
<div key={cardIdx} className="bg-white rounded-lg border border-border p-3">
|
||||
<div key={cardIdx} className="bg-surface rounded-lg border border-border p-3">
|
||||
<div className="h-4 bg-surface-tertiary rounded w-full mb-2"></div>
|
||||
<div className="h-3 bg-surface-tertiary rounded w-3/4 mb-3"></div>
|
||||
<div className="flex gap-2">
|
||||
@@ -78,7 +78,7 @@ export function SkeletonKanbanBoard() {
|
||||
|
||||
export function SkeletonCalendar() {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-border overflow-hidden animate-pulse">
|
||||
<div className="bg-surface rounded-xl border border-border overflow-clip animate-pulse">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||
<div className="h-6 bg-surface-tertiary rounded w-40"></div>
|
||||
<div className="h-8 bg-surface-tertiary rounded w-20"></div>
|
||||
@@ -138,7 +138,7 @@ export function SkeletonDashboard() {
|
||||
{/* Content cards */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{[...Array(2)].map((_, i) => (
|
||||
<div key={i} className="bg-white rounded-xl border border-border animate-pulse">
|
||||
<div key={i} className="bg-surface rounded-xl border border-border animate-pulse">
|
||||
<div className="px-5 py-4 border-b border-border">
|
||||
<div className="h-5 bg-surface-tertiary rounded w-32"></div>
|
||||
</div>
|
||||
|
||||
@@ -1,17 +1,48 @@
|
||||
import { useEffect, useRef, useCallback } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
export default function SlidePanel({ onClose, maxWidth = '420px', header, footer, children }) {
|
||||
const panelRef = useRef(null)
|
||||
|
||||
const handleKeyDown = useCallback((e) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}, [onClose])
|
||||
|
||||
useEffect(() => {
|
||||
if (!panelRef.current) return
|
||||
const el = panelRef.current
|
||||
const focusable = el.querySelectorAll(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
)
|
||||
if (focusable.length > 0) focusable[0].focus()
|
||||
|
||||
const handleTab = (e) => {
|
||||
if (e.key !== 'Tab' || focusable.length === 0) return
|
||||
const first = focusable[0]
|
||||
const last = focusable[focusable.length - 1]
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === first) { e.preventDefault(); last.focus() }
|
||||
} else {
|
||||
if (document.activeElement === last) { e.preventDefault(); first.focus() }
|
||||
}
|
||||
}
|
||||
el.addEventListener('keydown', handleTab)
|
||||
return () => el.removeEventListener('keydown', handleTab)
|
||||
}, [])
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm animate-backdrop-in z-[9998]" onClick={onClose} />
|
||||
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm animate-backdrop-in z-[9998]" onClick={onClose} aria-label="Close panel" />
|
||||
<div
|
||||
className="fixed top-0 right-0 h-full w-full bg-white shadow-2xl z-[9998] flex flex-col animate-slide-in-right overflow-hidden"
|
||||
ref={panelRef}
|
||||
className="fixed top-0 right-0 h-full w-full bg-surface shadow-2xl z-[9998] animate-slide-in-right overflow-y-auto"
|
||||
style={{ maxWidth }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{header}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{children}
|
||||
</div>
|
||||
<div className="sticky top-0 z-10 bg-surface">{header}</div>
|
||||
<div className="flex-1">{children}</div>
|
||||
{footer}
|
||||
</div>
|
||||
</>,
|
||||
|
||||
@@ -7,20 +7,20 @@ export default function StatCard({ icon: Icon, label, value, subtitle, color = '
|
||||
}
|
||||
|
||||
const iconBgMap = {
|
||||
'brand-primary': 'bg-indigo-50 text-indigo-600 shadow-lg shadow-indigo-500/20',
|
||||
'brand-secondary': 'bg-pink-50 text-pink-600 shadow-lg shadow-pink-500/20',
|
||||
'brand-tertiary': 'bg-amber-50 text-amber-600 shadow-lg shadow-amber-500/20',
|
||||
'brand-quaternary': 'bg-emerald-50 text-emerald-600 shadow-lg shadow-emerald-500/20',
|
||||
'brand-primary': 'bg-teal-50 text-teal-700',
|
||||
'brand-secondary': 'bg-pink-50 text-pink-600',
|
||||
'brand-tertiary': 'bg-amber-50 text-amber-600',
|
||||
'brand-quaternary': 'bg-teal-50 text-teal-600',
|
||||
}
|
||||
|
||||
const accentClass = accentMap[color] || 'accent-primary'
|
||||
|
||||
return (
|
||||
<div className={`stat-card-premium ${accentClass} bg-white rounded-xl border border-border p-5 card-hover`}>
|
||||
<div className={`stat-card-premium ${accentClass} bg-surface rounded-xl border border-border p-5 card-hover`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-text-tertiary">{label}</p>
|
||||
<p className="text-3xl font-bold text-text-primary mt-1">{value}</p>
|
||||
<p className="text-2xl font-bold text-text-primary mt-1">{value}</p>
|
||||
{subtitle && (
|
||||
<p className="text-xs text-text-tertiary mt-1">{subtitle}</p>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useRef, useCallback } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
@@ -19,26 +19,55 @@ export default function TabbedModal({
|
||||
footer,
|
||||
children,
|
||||
}) {
|
||||
const modalRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = 'hidden'
|
||||
return () => { document.body.style.overflow = '' }
|
||||
}, [])
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[9999] flex items-start justify-center pt-[5vh] px-4">
|
||||
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm animate-backdrop-in" onClick={onClose} />
|
||||
useEffect(() => {
|
||||
if (!modalRef.current) return
|
||||
const el = modalRef.current
|
||||
const focusable = el.querySelectorAll(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
)
|
||||
if (focusable.length > 0) focusable[0].focus()
|
||||
|
||||
<div className={`relative bg-white rounded-2xl shadow-2xl w-full ${SIZE_CLASSES[size] || SIZE_CLASSES.md} max-h-[90vh] flex flex-col animate-scale-in`}>
|
||||
const handleTab = (e) => {
|
||||
if (e.key !== 'Tab' || focusable.length === 0) return
|
||||
const first = focusable[0]
|
||||
const last = focusable[focusable.length - 1]
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === first) { e.preventDefault(); last.focus() }
|
||||
} else {
|
||||
if (document.activeElement === last) { e.preventDefault(); first.focus() }
|
||||
}
|
||||
}
|
||||
el.addEventListener('keydown', handleTab)
|
||||
return () => el.removeEventListener('keydown', handleTab)
|
||||
}, [])
|
||||
|
||||
const handleKeyDown = useCallback((e) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}, [onClose])
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[9999] flex items-start justify-center pt-[5vh] px-4" onKeyDown={handleKeyDown} ref={modalRef}>
|
||||
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm animate-backdrop-in" onClick={onClose} aria-label="Close dialog" />
|
||||
|
||||
<div className={`relative bg-surface rounded-2xl shadow-2xl w-full ${SIZE_CLASSES[size] || SIZE_CLASSES.md} max-h-[90vh] overflow-y-auto animate-scale-in`} role="dialog" aria-modal="true" aria-labelledby="tabbed-modal-title">
|
||||
{/* Header */}
|
||||
<div className="shrink-0">
|
||||
<div className="sticky top-0 z-10 bg-surface rounded-t-2xl">
|
||||
<div className="px-6 pt-5 pb-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div id="tabbed-modal-title" className="flex-1 min-w-0">
|
||||
{header}
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors shrink-0 -mt-1 -me-1"
|
||||
aria-label="Close dialog"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
@@ -47,13 +76,15 @@ export default function TabbedModal({
|
||||
|
||||
{/* Tabs */}
|
||||
{tabs.length > 0 && (
|
||||
<div className="flex gap-0 px-6 border-b border-border overflow-x-auto">
|
||||
<div className="flex gap-0 px-6 border-b border-border overflow-x-auto" role="tablist">
|
||||
{tabs.map(tab => {
|
||||
const TabIcon = tab.icon
|
||||
return (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => onTabChange(tab.key)}
|
||||
role="tab"
|
||||
aria-selected={activeTab === tab.key}
|
||||
className={`relative flex items-center gap-2 px-4 py-3 text-[13px] font-medium whitespace-nowrap transition-colors ${
|
||||
activeTab === tab.key
|
||||
? 'text-brand-primary'
|
||||
@@ -80,13 +111,13 @@ export default function TabbedModal({
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div role="tabpanel">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{footer && (
|
||||
<div className="border-t border-border px-6 py-3.5 flex items-center justify-between shrink-0 rounded-b-2xl bg-white">
|
||||
<div className="border-t border-border px-6 py-3.5 flex items-center justify-between rounded-b-2xl bg-surface">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -100,7 +100,7 @@ export default function TaskCalendarView({ tasks, onTaskClick }) {
|
||||
if (p === 'urgent') return 'bg-red-500 text-white'
|
||||
if (p === 'high') return 'bg-orange-400 text-white'
|
||||
if (p === 'medium') return 'bg-amber-400 text-amber-900'
|
||||
return 'bg-gray-300 text-gray-700'
|
||||
return 'bg-gray-300 text-text-secondary'
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -124,14 +124,14 @@ export default function TaskCalendarView({ tasks, onTaskClick }) {
|
||||
<div className="flex bg-surface-tertiary rounded-lg p-0.5">
|
||||
<button
|
||||
onClick={() => setCalView('month')}
|
||||
className={`px-2.5 py-1 text-[11px] font-medium rounded-md transition-colors flex items-center gap-1 ${calView === 'month' ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary'}`}
|
||||
className={`px-2.5 py-1 text-[11px] font-medium rounded-md transition-colors flex items-center gap-1 ${calView === 'month' ? 'bg-surface shadow-sm text-text-primary' : 'text-text-tertiary'}`}
|
||||
>
|
||||
<CalendarIcon className="w-3 h-3" />
|
||||
Month
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCalView('week')}
|
||||
className={`px-2.5 py-1 text-[11px] font-medium rounded-md transition-colors flex items-center gap-1 ${calView === 'week' ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary'}`}
|
||||
className={`px-2.5 py-1 text-[11px] font-medium rounded-md transition-colors flex items-center gap-1 ${calView === 'week' ? 'bg-surface shadow-sm text-text-primary' : 'text-text-tertiary'}`}
|
||||
>
|
||||
<CalendarDays className="w-3 h-3" />
|
||||
Week
|
||||
@@ -162,7 +162,7 @@ export default function TaskCalendarView({ tasks, onTaskClick }) {
|
||||
<div
|
||||
key={i}
|
||||
className={`border-r border-b border-border ${calView === 'week' ? 'min-h-[300px]' : 'min-h-[90px]'} p-1 ${
|
||||
cell.current ? 'bg-white' : 'bg-surface-secondary/50'
|
||||
cell.current ? 'bg-surface' : 'bg-surface-secondary/50'
|
||||
}`}
|
||||
>
|
||||
<div className={`text-[11px] font-medium mb-0.5 w-6 h-6 flex items-center justify-center rounded-full ${
|
||||
@@ -175,7 +175,7 @@ export default function TaskCalendarView({ tasks, onTaskClick }) {
|
||||
<button
|
||||
key={task._id || task.id}
|
||||
onClick={() => onTaskClick(task)}
|
||||
className={`w-full text-left text-[10px] px-1.5 py-0.5 rounded truncate font-medium hover:opacity-80 transition-opacity ${
|
||||
className={`w-full text-start text-[10px] px-1.5 py-0.5 rounded truncate font-medium hover:opacity-80 transition-opacity ${
|
||||
task.status === 'done' ? 'bg-emerald-100 text-emerald-700 line-through' : getPillColor(task)
|
||||
}`}
|
||||
title={task.title}
|
||||
@@ -206,7 +206,7 @@ export default function TaskCalendarView({ tasks, onTaskClick }) {
|
||||
<button
|
||||
key={task._id || task.id}
|
||||
onClick={() => onTaskClick(task)}
|
||||
className="w-full text-left bg-white border border-border rounded-lg p-2 hover:border-brand-primary/30 transition-colors"
|
||||
className="w-full text-start bg-surface border border-border rounded-lg p-2 hover:border-brand-primary/30 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className={`w-2 h-2 rounded-full ${priority.color} shrink-0`} />
|
||||
|
||||
@@ -32,7 +32,7 @@ export default function TaskCard({ task, onMove, showProject = true }) {
|
||||
const assignedName = task.assigned_name || task.assignedName
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-lg border border-border p-3 card-hover group cursor-pointer ${isExternallyAssigned ? 'border-l-[3px] border-l-blue-400' : ''}`}>
|
||||
<div className={`bg-surface rounded-lg border border-border p-3 card-hover group cursor-pointer ${isExternallyAssigned ? 'border-l-[3px] border-l-blue-400' : ''}`}>
|
||||
<div className="flex items-start gap-2.5">
|
||||
{/* Priority dot */}
|
||||
<div className={`w-2.5 h-2.5 rounded-full ${priority.color} mt-1.5 shrink-0`} title={priority.label} />
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useLanguage } from '../i18n/LanguageContext'
|
||||
import CommentsSection from './CommentsSection'
|
||||
import Modal from './Modal'
|
||||
import TabbedModal from './TabbedModal'
|
||||
import PortalSelect from './PortalSelect'
|
||||
|
||||
const API_BASE = '/api'
|
||||
|
||||
@@ -199,11 +200,11 @@ export default function TaskDetailPanel({ task, onClose, onSave, onDelete, proje
|
||||
{/* Thumbnail banner */}
|
||||
{currentThumbnail && (
|
||||
<div className="relative -mx-6 -mt-5 mb-3 h-32 overflow-hidden rounded-t-2xl">
|
||||
<img src={currentThumbnail} alt="" className="w-full h-full object-cover" />
|
||||
<img src={currentThumbnail} alt="" className="w-full h-full object-cover" loading="lazy" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-white/80 to-transparent" />
|
||||
<button
|
||||
onClick={handleRemoveThumbnail}
|
||||
className="absolute top-2 right-2 p-1 bg-black/40 hover:bg-black/60 rounded-full text-white transition-colors"
|
||||
className="absolute top-2 end-2 p-1 bg-black/40 hover:bg-black/60 rounded-full text-white transition-colors"
|
||||
title={t('tasks.removeThumbnail')}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
@@ -218,11 +219,11 @@ export default function TaskDetailPanel({ task, onClose, onSave, onDelete, proje
|
||||
placeholder={t('tasks.taskTitle')}
|
||||
/>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<span className={`inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full font-medium ${priority.color === 'bg-gray-400' ? 'bg-gray-100 text-gray-600' : priority.color === 'bg-amber-400' ? 'bg-amber-100 text-amber-700' : priority.color === 'bg-orange-500' ? 'bg-orange-100 text-orange-700' : 'bg-red-100 text-red-700'}`}>
|
||||
<span className={`inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full font-medium ${priority.color === 'bg-gray-400' ? 'bg-gray-100 text-text-secondary' : priority.color === 'bg-amber-400' ? 'bg-amber-100 text-amber-700' : priority.color === 'bg-orange-500' ? 'bg-orange-100 text-orange-700' : 'bg-red-100 text-red-700'}`}>
|
||||
<div className={`w-1.5 h-1.5 rounded-full ${priority.color}`} />
|
||||
{priorityOptions.find(p => p.value === form.priority)?.label}
|
||||
</span>
|
||||
<span className={`text-[11px] px-2 py-0.5 rounded-full font-medium ${form.status === 'done' ? 'bg-emerald-100 text-emerald-700' : form.status === 'in_progress' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600'}`}>
|
||||
<span className={`text-[11px] px-2 py-0.5 rounded-full font-medium ${form.status === 'done' ? 'bg-emerald-100 text-emerald-700' : form.status === 'in_progress' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-text-secondary'}`}>
|
||||
{statusOptions.find(s => s.value === form.status)?.label}
|
||||
</span>
|
||||
{isOverdue && !isCreateMode && (
|
||||
@@ -293,16 +294,12 @@ export default function TaskDetailPanel({ task, onClose, onSave, onDelete, proje
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.project')}</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
<PortalSelect
|
||||
value={form.project_id}
|
||||
onChange={e => update('project_id', e.target.value)}
|
||||
onChange={val => update('project_id', val)}
|
||||
options={[{ value: '', label: t('tasks.noProject') }, ...(projects || []).map(p => ({ value: p._id || p.id, label: p.name || p.title }))]}
|
||||
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"
|
||||
>
|
||||
<option value="">{t('tasks.noProject')}</option>
|
||||
{(projects || []).map(p => (
|
||||
<option key={p._id || p.id} value={p._id || p.id}>{p.name || p.title}</option>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
{brandName && (
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded shrink-0 ${getBrandColor(brandName).bg} ${getBrandColor(brandName).text}`}>
|
||||
{brandName}
|
||||
@@ -314,43 +311,33 @@ export default function TaskDetailPanel({ task, onClose, onSave, onDelete, proje
|
||||
{/* Assignee */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.assignee')}</label>
|
||||
<select
|
||||
<PortalSelect
|
||||
value={form.assigned_to}
|
||||
onChange={e => update('assigned_to', e.target.value)}
|
||||
onChange={val => update('assigned_to', val)}
|
||||
options={[{ value: '', label: t('common.unassigned') }, ...(users || []).map(m => ({ value: m._id || m.team_member_id, label: m.name }))]}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
>
|
||||
<option value="">{t('common.unassigned')}</option>
|
||||
{(users || []).map(m => (
|
||||
<option key={m._id || m.team_member_id} value={m._id || m.team_member_id}>{m.name}</option>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Priority & Status */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.priority')}</label>
|
||||
<select
|
||||
<PortalSelect
|
||||
value={form.priority}
|
||||
onChange={e => update('priority', e.target.value)}
|
||||
onChange={val => update('priority', val)}
|
||||
options={priorityOptions}
|
||||
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"
|
||||
>
|
||||
{priorityOptions.map(p => (
|
||||
<option key={p.value} value={p.value}>{p.label}</option>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.status')}</label>
|
||||
<select
|
||||
<PortalSelect
|
||||
value={form.status}
|
||||
onChange={e => update('status', e.target.value)}
|
||||
onChange={val => update('status', val)}
|
||||
options={statusOptions}
|
||||
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"
|
||||
>
|
||||
{statusOptions.map(s => (
|
||||
<option key={s.value} value={s.value}>{s.label}</option>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -401,11 +388,11 @@ export default function TaskDetailPanel({ task, onClose, onSave, onDelete, proje
|
||||
const isThumbnail = currentThumbnail && attUrl === currentThumbnail
|
||||
|
||||
return (
|
||||
<div key={attId} className="relative group/att border border-border rounded-lg overflow-hidden bg-white">
|
||||
<div key={attId} className="relative group/att border border-border rounded-lg overflow-hidden bg-surface">
|
||||
<div className="h-20 relative">
|
||||
{isImage ? (
|
||||
<a href={attUrl} target="_blank" rel="noopener noreferrer" className="block h-full">
|
||||
<img src={attUrl} alt={name} className="absolute inset-0 w-full h-full object-cover" />
|
||||
<img src={attUrl} alt={name} className="absolute inset-0 w-full h-full object-cover" loading="lazy" />
|
||||
</a>
|
||||
) : (
|
||||
<a href={attUrl} target="_blank" rel="noopener noreferrer" className="absolute inset-0 flex items-center gap-2 p-3">
|
||||
@@ -414,11 +401,11 @@ export default function TaskDetailPanel({ task, onClose, onSave, onDelete, proje
|
||||
</a>
|
||||
)}
|
||||
{isThumbnail && (
|
||||
<div className="absolute top-1 left-1 p-0.5 bg-amber-400 rounded-full text-white">
|
||||
<div className="absolute top-1 start-1 p-0.5 bg-amber-400 rounded-full text-white">
|
||||
<Star className="w-2.5 h-2.5 fill-current" />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute top-1 right-1 flex items-center gap-0.5 opacity-0 group-hover/att:opacity-100 transition-opacity">
|
||||
<div className="absolute top-1 end-1 flex items-center gap-0.5 opacity-0 group-hover/att:opacity-100 transition-opacity">
|
||||
{isImage && !isThumbnail && (
|
||||
<button
|
||||
onClick={() => handleSetThumbnail(att)}
|
||||
@@ -454,17 +441,17 @@ export default function TaskDetailPanel({ task, onClose, onSave, onDelete, proje
|
||||
const previewUrl = isImage ? URL.createObjectURL(file) : null
|
||||
|
||||
return (
|
||||
<div key={i} className="relative group/att border border-border rounded-lg overflow-hidden bg-white">
|
||||
<div key={i} className="relative group/att border border-border rounded-lg overflow-hidden bg-surface">
|
||||
<div className="h-20 relative">
|
||||
{isImage ? (
|
||||
<img src={previewUrl} alt={file.name} className="absolute inset-0 w-full h-full object-cover" />
|
||||
<img src={previewUrl} alt={file.name} className="absolute inset-0 w-full h-full object-cover" loading="lazy" />
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center gap-2 p-3">
|
||||
<FileText className="w-6 h-6 text-text-tertiary shrink-0" />
|
||||
<span className="text-xs text-text-secondary truncate">{file.name}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute top-1 right-1 flex items-center gap-0.5 opacity-0 group-hover/att:opacity-100 transition-opacity">
|
||||
<div className="absolute top-1 end-1 flex items-center gap-0.5 opacity-0 group-hover/att:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => setPendingFiles(prev => prev.filter((_, j) => j !== i))}
|
||||
className="p-1 bg-black/50 hover:bg-red-500 rounded-full text-white transition-colors"
|
||||
@@ -494,7 +481,8 @@ export default function TaskDetailPanel({ task, onClose, onSave, onDelete, proje
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
className="absolute w-0 h-0 opacity-0 pointer-events-none"
|
||||
tabIndex={-1}
|
||||
onChange={e => {
|
||||
setUploadError(null)
|
||||
const files = Array.from(e.target.files || [])
|
||||
|
||||
@@ -6,14 +6,15 @@ import { useToast } from './ToastContainer'
|
||||
import Modal from './Modal'
|
||||
import TabbedModal from './TabbedModal'
|
||||
import StatusBadge from './StatusBadge'
|
||||
import PortalSelect from './PortalSelect'
|
||||
import { AppContext, PERMISSION_LEVELS } from '../App'
|
||||
|
||||
const ALL_MODULES = ['marketing', 'projects', 'finance']
|
||||
const MODULE_LABELS = { marketing: 'Marketing', projects: 'Projects', finance: 'Finance' }
|
||||
const MODULE_COLORS = {
|
||||
marketing: { on: 'bg-emerald-100 text-emerald-700 border-emerald-300', off: 'bg-gray-100 text-gray-400 border-gray-200' },
|
||||
projects: { on: 'bg-blue-100 text-blue-700 border-blue-300', off: 'bg-gray-100 text-gray-400 border-gray-200' },
|
||||
finance: { on: 'bg-amber-100 text-amber-700 border-amber-300', off: 'bg-gray-100 text-gray-400 border-gray-200' },
|
||||
marketing: { on: 'bg-emerald-100 text-emerald-700 border-emerald-300', off: 'bg-gray-100 text-text-tertiary border-gray-200' },
|
||||
projects: { on: 'bg-blue-100 text-blue-700 border-blue-300', off: 'bg-gray-100 text-text-tertiary border-gray-200' },
|
||||
finance: { on: 'bg-amber-100 text-amber-700 border-amber-300', off: 'bg-gray-100 text-text-tertiary border-gray-200' },
|
||||
}
|
||||
|
||||
export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave, onDelete, canManageTeam, userRole, teams, brands: brandsList }) {
|
||||
@@ -231,13 +232,12 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
|
||||
{userRole === 'superadmin' && !isEditingSelf && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.permissionLevel')}</label>
|
||||
<select
|
||||
<PortalSelect
|
||||
value={form.permission_level}
|
||||
onChange={e => update('permission_level', e.target.value)}
|
||||
onChange={val => update('permission_level', val)}
|
||||
options={PERMISSION_LEVELS.map(p => ({ value: p.value, label: p.label }))}
|
||||
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"
|
||||
>
|
||||
{PERMISSION_LEVELS.map(p => <option key={p.value} value={p.value}>{p.label}</option>)}
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -252,14 +252,12 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface-tertiary text-text-tertiary cursor-not-allowed"
|
||||
/>
|
||||
) : (
|
||||
<select
|
||||
<PortalSelect
|
||||
value={form.role_id || ''}
|
||||
onChange={e => update('role_id', e.target.value ? Number(e.target.value) : null)}
|
||||
onChange={val => update('role_id', val ? Number(val) : null)}
|
||||
options={[{ value: '', label: t('team.selectRole') }, ...roles.map(r => ({ value: r.Id || r.id, label: r.name }))]}
|
||||
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('team.selectRole')}</option>
|
||||
{roles.map(r => <option key={r.Id || r.id} value={r.Id || r.id}>{r.name}</option>)}
|
||||
</select>
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -285,7 +283,7 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowBrandsDropdown(prev => !prev)}
|
||||
className="w-full flex items-center justify-between px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-white text-left"
|
||||
className="w-full flex items-center justify-between px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface text-start"
|
||||
>
|
||||
<span className={`flex-1 truncate ${(form.brands || []).length === 0 ? 'text-text-tertiary' : 'text-text-primary'}`}>
|
||||
{(form.brands || []).length === 0
|
||||
@@ -315,7 +313,7 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
|
||||
|
||||
{/* Dropdown */}
|
||||
{showBrandsDropdown && (
|
||||
<div className="absolute z-20 mt-1 w-full bg-white border border-border rounded-lg shadow-lg max-h-48 overflow-y-auto">
|
||||
<div className="absolute z-20 mt-1 w-full bg-surface border border-border rounded-lg shadow-lg max-h-48 overflow-y-auto">
|
||||
{brandsList && brandsList.length > 0 ? (
|
||||
brandsList.map(brand => {
|
||||
const name = lang === 'ar' && brand.name_ar ? brand.name_ar : brand.name
|
||||
@@ -325,7 +323,7 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
|
||||
type="button"
|
||||
key={brand.id || brand._id}
|
||||
onClick={() => toggleBrand(name)}
|
||||
className={`w-full flex items-center gap-2.5 px-3 py-2 cursor-pointer hover:bg-surface-secondary transition-colors text-left ${checked ? 'bg-brand-primary/5' : ''}`}
|
||||
className={`w-full flex items-center gap-2.5 px-3 py-2 cursor-pointer hover:bg-surface-secondary transition-colors text-start ${checked ? 'bg-brand-primary/5' : ''}`}
|
||||
>
|
||||
<div className={`w-4 h-4 rounded border flex items-center justify-center shrink-0 transition-colors ${
|
||||
checked ? 'bg-brand-primary border-brand-primary' : 'border-border'
|
||||
@@ -393,7 +391,7 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
|
||||
className={`text-xs px-3 py-1.5 rounded-full border font-medium transition-colors ${
|
||||
active
|
||||
? 'bg-blue-100 text-blue-700 border-blue-300'
|
||||
: 'bg-gray-100 text-gray-400 border-gray-200'
|
||||
: 'bg-gray-100 text-text-tertiary border-gray-200'
|
||||
}`}
|
||||
>
|
||||
{team.name}
|
||||
|
||||
@@ -149,13 +149,13 @@ export default function TeamPanel({ team, onClose, onSave, onDelete, teamMembers
|
||||
{activeTab === 'members' && (
|
||||
<div className="p-6">
|
||||
<div className="relative mb-3">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-text-tertiary" />
|
||||
<Search className="absolute start-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-text-tertiary" />
|
||||
<input
|
||||
type="text"
|
||||
value={memberSearch}
|
||||
onChange={e => setMemberSearch(e.target.value)}
|
||||
placeholder={t('teams.selectMembers')}
|
||||
className="w-full pl-9 pr-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
className="w-full ps-9 pe-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1 max-h-80 overflow-y-auto">
|
||||
|
||||
@@ -14,7 +14,7 @@ export default function ThemeToggle({ className = '' }) {
|
||||
{darkMode ? (
|
||||
<Sun className="w-5 h-5 text-yellow-500" />
|
||||
) : (
|
||||
<Moon className="w-5 h-5 text-gray-600" />
|
||||
<Moon className="w-5 h-5 text-text-secondary" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
|
||||
@@ -37,7 +37,7 @@ export function ToastProvider({ children }) {
|
||||
<ToastContext.Provider value={toast}>
|
||||
{children}
|
||||
{/* Toast container - fixed position */}
|
||||
<div className="fixed top-4 right-4 z-[10000] flex flex-col gap-2 pointer-events-none">
|
||||
<div className="fixed top-4 end-4 z-[10000] flex flex-col gap-2 pointer-events-none">
|
||||
<div className="flex flex-col gap-2 pointer-events-auto">
|
||||
{toasts.map(t => (
|
||||
<Toast
|
||||
|
||||
@@ -5,6 +5,7 @@ import { PLATFORMS } from '../utils/api'
|
||||
import Modal from './Modal'
|
||||
import TabbedModal from './TabbedModal'
|
||||
import BudgetBar from './BudgetBar'
|
||||
import PortalSelect from './PortalSelect'
|
||||
|
||||
const TRACK_TYPES = {
|
||||
organic_social: { label: 'Organic Social' },
|
||||
@@ -114,7 +115,7 @@ export default function TrackDetailPanel({ track, campaignId, onClose, onSave, o
|
||||
form.status === 'active' ? 'bg-emerald-100 text-emerald-700' :
|
||||
form.status === 'paused' ? 'bg-amber-100 text-amber-700' :
|
||||
form.status === 'completed' ? 'bg-blue-100 text-blue-700' :
|
||||
'bg-gray-100 text-gray-600'
|
||||
'bg-gray-100 text-text-secondary'
|
||||
}`}>
|
||||
{form.status?.charAt(0).toUpperCase() + form.status?.slice(1)}
|
||||
</span>
|
||||
@@ -156,29 +157,21 @@ export default function TrackDetailPanel({ track, campaignId, onClose, onSave, o
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tracks.type')}</label>
|
||||
<select
|
||||
<PortalSelect
|
||||
value={form.type}
|
||||
onChange={e => update('type', e.target.value)}
|
||||
onChange={val => update('type', val)}
|
||||
options={Object.entries(TRACK_TYPES).map(([k, v]) => ({ value: k, label: v.label }))}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
|
||||
>
|
||||
{Object.entries(TRACK_TYPES).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v.label}</option>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tracks.platform')}</label>
|
||||
<select
|
||||
<PortalSelect
|
||||
value={form.platform}
|
||||
onChange={e => update('platform', e.target.value)}
|
||||
onChange={val => update('platform', val)}
|
||||
options={[{ value: '', label: 'All / Multiple' }, ...Object.entries(PLATFORMS).map(([k, v]) => ({ value: k, label: v.label })), { value: 'google_ads', label: 'Google Ads' }]}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
|
||||
>
|
||||
<option value="">All / Multiple</option>
|
||||
{Object.entries(PLATFORMS).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v.label}</option>
|
||||
))}
|
||||
<option value="google_ads">Google Ads</option>
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -195,15 +188,12 @@ export default function TrackDetailPanel({ track, campaignId, onClose, onSave, o
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tracks.status')}</label>
|
||||
<select
|
||||
<PortalSelect
|
||||
value={form.status}
|
||||
onChange={e => update('status', e.target.value)}
|
||||
onChange={val => update('status', val)}
|
||||
options={TRACK_STATUSES.map(s => ({ value: s, label: s.charAt(0).toUpperCase() + s.slice(1) }))}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
|
||||
>
|
||||
{TRACK_STATUSES.map(s => (
|
||||
<option key={s} value={s}>{s.charAt(0).toUpperCase() + s.slice(1)}</option>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,35 +1,22 @@
|
||||
import { useState, useEffect, useContext } from 'react'
|
||||
import { Plus, Copy, Check, ExternalLink, Trash2, Save, FileEdit, Languages, ShieldCheck, Globe } from 'lucide-react'
|
||||
import { Plus, Copy, Check, ExternalLink, Trash2, Save, FileEdit, Languages, ShieldCheck, Globe, Lock } from 'lucide-react'
|
||||
import { AppContext } from '../App'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import { api } from '../utils/api'
|
||||
import { AVAILABLE_LANGUAGES, TRANSLATION_STATUS_COLORS, isTextSelected, groupTextsByLanguage } from '../utils/translations'
|
||||
import Modal from './Modal'
|
||||
import TabbedModal from './TabbedModal'
|
||||
import { useToast } from './ToastContainer'
|
||||
import ApproverMultiSelect from './ApproverMultiSelect'
|
||||
import PortalSelect from './PortalSelect'
|
||||
|
||||
const STATUS_COLORS = {
|
||||
draft: 'bg-surface-tertiary text-text-secondary',
|
||||
pending_review: 'bg-amber-100 text-amber-700',
|
||||
approved: 'bg-emerald-100 text-emerald-700',
|
||||
rejected: 'bg-red-100 text-red-700',
|
||||
revision_requested: 'bg-orange-100 text-orange-700',
|
||||
}
|
||||
|
||||
const AVAILABLE_LANGUAGES = [
|
||||
{ code: 'AR', label: 'العربية' },
|
||||
{ code: 'EN', label: 'English' },
|
||||
{ code: 'FR', label: 'Français' },
|
||||
{ code: 'ID', label: 'Bahasa Indonesia' },
|
||||
]
|
||||
|
||||
export default function TranslationDetailPanel({ translation, onClose, onUpdate, onDelete, assignableUsers = [] }) {
|
||||
export default function TranslationDetailPanel({ translation, onClose, onUpdate, onDelete, assignableUsers = [], posts: externalPosts }) {
|
||||
const { t } = useLanguage()
|
||||
const { brands } = useContext(AppContext)
|
||||
const toast = useToast()
|
||||
|
||||
const isApproved = translation.status === 'approved'
|
||||
|
||||
const [editTitle, setEditTitle] = useState(translation.title || '')
|
||||
const [editDescription, setEditDescription] = useState(translation.description || '')
|
||||
const [editSourceContent, setEditSourceContent] = useState(translation.source_content || '')
|
||||
const [editSourceLanguage, setEditSourceLanguage] = useState(translation.source_language || 'EN')
|
||||
const [editApproverIds, setEditApproverIds] = useState(
|
||||
@@ -44,6 +31,13 @@ export default function TranslationDetailPanel({ translation, onClose, onUpdate,
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [freshReviewUrl, setFreshReviewUrl] = useState('')
|
||||
const [copiedTextId, setCopiedTextId] = useState(null)
|
||||
|
||||
// Post selector
|
||||
const [posts, setPosts] = useState(externalPosts || [])
|
||||
const [showCreatePost, setShowCreatePost] = useState(false)
|
||||
const [newPostTitle, setNewPostTitle] = useState('')
|
||||
const [creatingPost, setCreatingPost] = useState(false)
|
||||
|
||||
// Language add modal
|
||||
const [showAddLang, setShowAddLang] = useState(false)
|
||||
@@ -62,9 +56,12 @@ export default function TranslationDetailPanel({ translation, onClose, onUpdate,
|
||||
loadTexts()
|
||||
}, [translation.Id])
|
||||
|
||||
useEffect(() => {
|
||||
if (externalPosts) setPosts(externalPosts)
|
||||
}, [externalPosts])
|
||||
|
||||
useEffect(() => {
|
||||
setEditTitle(translation.title || '')
|
||||
setEditDescription(translation.description || '')
|
||||
setEditSourceContent(translation.source_content || '')
|
||||
setEditSourceLanguage(translation.source_language || 'EN')
|
||||
setEditApproverIds(
|
||||
@@ -92,7 +89,6 @@ export default function TranslationDetailPanel({ translation, onClose, onUpdate,
|
||||
try {
|
||||
await api.patch(`/translations/${translation.Id}`, {
|
||||
title: editTitle,
|
||||
description: editDescription,
|
||||
source_content: editSourceContent,
|
||||
source_language: editSourceLanguage,
|
||||
approver_ids: editApproverIds.length > 0 ? editApproverIds.join(',') : null,
|
||||
@@ -142,11 +138,7 @@ export default function TranslationDetailPanel({ translation, onClose, onUpdate,
|
||||
|
||||
const handleUpdateText = async (textId) => {
|
||||
try {
|
||||
const text = texts.find(t => t.Id === textId)
|
||||
if (!text) return
|
||||
await api.post(`/translations/${translation.Id}/texts`, {
|
||||
language_code: text.language_code,
|
||||
language_label: text.language_label,
|
||||
await api.patch(`/translations/${translation.Id}/texts/${textId}`, {
|
||||
content: editingContent,
|
||||
})
|
||||
toast.success(t('translations.updated'))
|
||||
@@ -197,9 +189,35 @@ export default function TranslationDetailPanel({ translation, onClose, onUpdate,
|
||||
}
|
||||
}
|
||||
|
||||
// Available languages for adding (exclude source + already added)
|
||||
const usedCodes = new Set([translation.source_language, ...texts.map(t => t.language_code)])
|
||||
const availableForAdd = AVAILABLE_LANGUAGES.filter(l => !usedCodes.has(l.code))
|
||||
const handleCreatePost = async () => {
|
||||
if (!newPostTitle.trim()) return
|
||||
setCreatingPost(true)
|
||||
try {
|
||||
const created = await api.post('/posts', { title: newPostTitle, status: 'draft' })
|
||||
const postId = created.Id || created.id || created._id
|
||||
setPosts(prev => [created, ...prev])
|
||||
await handleFieldUpdate('post_id', postId)
|
||||
setShowCreatePost(false)
|
||||
setNewPostTitle('')
|
||||
} catch (err) {
|
||||
toast.error(t('translations.postCreateFailed'))
|
||||
} finally {
|
||||
setCreatingPost(false)
|
||||
}
|
||||
}
|
||||
|
||||
const copyTextContent = (content, id) => {
|
||||
navigator.clipboard.writeText(content)
|
||||
setCopiedTextId(id)
|
||||
toast.success(t('translations.copiedToClipboard'))
|
||||
setTimeout(() => setCopiedTextId(null), 2000)
|
||||
}
|
||||
|
||||
// Available languages (exclude source language only — multiple options per language allowed)
|
||||
const targetLanguages = AVAILABLE_LANGUAGES.filter(l => l.code !== translation.source_language)
|
||||
|
||||
// Group texts by language
|
||||
const textsByLanguage = groupTextsByLanguage(texts)
|
||||
|
||||
const tabs = [
|
||||
{ key: 'details', label: t('translations.details'), icon: FileEdit },
|
||||
@@ -222,12 +240,13 @@ export default function TranslationDetailPanel({ translation, onClose, onUpdate,
|
||||
type="text"
|
||||
value={editTitle}
|
||||
onChange={e => setEditTitle(e.target.value)}
|
||||
className="text-lg font-bold text-text-primary bg-transparent border-none outline-none focus:ring-0 w-full"
|
||||
readOnly={isApproved}
|
||||
className={`text-lg font-bold text-text-primary bg-transparent border-none outline-none focus:ring-0 w-full ${isApproved ? 'cursor-default' : ''}`}
|
||||
placeholder={t('translations.titlePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${STATUS_COLORS[translation.status] || 'bg-surface-tertiary text-text-secondary'}`}>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${TRANSLATION_STATUS_COLORS[translation.status] || 'bg-surface-tertiary text-text-secondary'}`}>
|
||||
{translation.status?.replace('_', ' ')}
|
||||
</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-blue-50 text-blue-600 font-medium">
|
||||
@@ -244,7 +263,12 @@ export default function TranslationDetailPanel({ translation, onClose, onUpdate,
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
footer={
|
||||
footer={isApproved ? (
|
||||
<div className="flex items-center gap-2 w-full justify-center">
|
||||
<Lock className="w-4 h-4 text-text-tertiary" />
|
||||
<span className="text-sm text-text-tertiary">{t('translations.approvedReadOnly')}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 w-full justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
@@ -265,20 +289,20 @@ export default function TranslationDetailPanel({ translation, onClose, onUpdate,
|
||||
{savingDraft ? t('translations.savingDraft') : t('translations.saveDraft')}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
)}
|
||||
>
|
||||
{/* Details Tab */}
|
||||
{activeTab === 'details' && (
|
||||
<div className="p-6 space-y-5">
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-2">{t('translations.sourceLanguage')}</h4>
|
||||
<select
|
||||
<PortalSelect
|
||||
value={editSourceLanguage}
|
||||
onChange={e => setEditSourceLanguage(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"
|
||||
>
|
||||
{AVAILABLE_LANGUAGES.map(l => <option key={l.code} value={l.code}>{l.label} ({l.code})</option>)}
|
||||
</select>
|
||||
onChange={val => setEditSourceLanguage(val)}
|
||||
disabled={isApproved}
|
||||
options={AVAILABLE_LANGUAGES.map(l => ({ value: l.code, label: `${l.label} (${l.code})` }))}
|
||||
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 disabled:opacity-60 disabled:cursor-default"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -286,43 +310,13 @@ export default function TranslationDetailPanel({ translation, onClose, onUpdate,
|
||||
<textarea
|
||||
value={editSourceContent}
|
||||
onChange={e => setEditSourceContent(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 min-h-[150px] resize-y"
|
||||
readOnly={isApproved}
|
||||
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 min-h-[150px] resize-y ${isApproved ? 'opacity-60 cursor-default' : ''}`}
|
||||
placeholder={t('translations.sourceContentPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-2">{t('translations.descriptionLabel')}</h4>
|
||||
<textarea
|
||||
value={editDescription}
|
||||
onChange={e => setEditDescription(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 min-h-[80px] resize-y"
|
||||
placeholder={t('translations.descriptionPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1.5">{t('translations.brand')}</h4>
|
||||
<select
|
||||
value={translation.brand_id || ''}
|
||||
onChange={e => handleFieldUpdate('brand_id', e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||
>
|
||||
<option value="">—</option>
|
||||
{brands.map(b => <option key={b._id} value={b._id}>{b.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1.5">{t('translations.approversLabel')}</h4>
|
||||
<ApproverMultiSelect
|
||||
selected={editApproverIds}
|
||||
onChange={setEditApproverIds}
|
||||
users={assignableUsers}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -340,59 +334,70 @@ export default function TranslationDetailPanel({ translation, onClose, onUpdate,
|
||||
<p className="text-sm text-blue-800 whitespace-pre-wrap">{translation.source_content}</p>
|
||||
</div>
|
||||
|
||||
{/* Add translation button */}
|
||||
{/* Add translation option button */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase">{t('translations.translationTexts')}</h4>
|
||||
{availableForAdd.length > 0 && (
|
||||
{!isApproved && (
|
||||
<button
|
||||
onClick={() => setShowAddLang(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('translations.addTranslation')}
|
||||
{t('translations.addOption')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Translation texts list */}
|
||||
{texts.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{texts.map(text => (
|
||||
<div key={text.Id} className="bg-surface-secondary rounded-lg p-4 border border-border">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-text-primary">
|
||||
{text.language_label || text.language_code}
|
||||
<span className="text-xs text-text-tertiary ml-1">({text.language_code})</span>
|
||||
{/* Grouped by language */}
|
||||
{targetLanguages.some(l => textsByLanguage[l.code]?.length > 0) ? (
|
||||
<div className="space-y-5">
|
||||
{targetLanguages.map(lang => {
|
||||
const options = textsByLanguage[lang.code] || []
|
||||
if (options.length === 0) return null
|
||||
return (
|
||||
<div key={lang.code}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-sm font-semibold text-text-primary">{lang.label}</span>
|
||||
<span className="text-xs text-text-tertiary">({lang.code})</span>
|
||||
<span className="text-xs px-1.5 py-0.5 rounded-full bg-surface-tertiary text-text-tertiary">
|
||||
{options.length} {options.length === 1 ? t('translations.option') : t('translations.options')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{(() => { const hasSelected = options.some(isTextSelected); return options.map((text, idx) => {
|
||||
const selected = isTextSelected(text)
|
||||
const isDimmed = isApproved && hasSelected && !selected
|
||||
return (
|
||||
<div key={text.Id} className={`rounded-lg p-3 border ${selected ? 'bg-emerald-50 border-emerald-300' : isDimmed ? 'bg-surface-secondary border-border opacity-50' : 'bg-surface-secondary border-border'}`}>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-xs font-medium text-text-tertiary">
|
||||
{t('translations.optionLabel')} {text.option_number || idx + 1}
|
||||
{selected && <span className="ms-2 text-emerald-600 font-semibold">{t('translations.selected')}</span>}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{editingTextId === text.Id ? (
|
||||
{editingTextId !== text.Id && (
|
||||
<button
|
||||
onClick={() => copyTextContent(text.content, text.Id)}
|
||||
className="text-text-tertiary hover:text-text-primary p-1"
|
||||
title={t('translations.copyContent')}
|
||||
>
|
||||
{copiedTextId === text.Id ? <Check className="w-3.5 h-3.5 text-emerald-600" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
)}
|
||||
{isApproved ? null : editingTextId === text.Id ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleUpdateText(text.Id)}
|
||||
className="text-emerald-600 hover:text-emerald-700 p-1"
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditingTextId(null)}
|
||||
className="text-text-tertiary hover:text-text-secondary p-1"
|
||||
>
|
||||
✕
|
||||
<button onClick={() => handleUpdateText(text.Id)} className="text-emerald-600 hover:text-emerald-700 p-1">
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button onClick={() => setEditingTextId(null)} className="text-text-tertiary hover:text-text-secondary p-1 text-xs">✕</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => { setEditingTextId(text.Id); setEditingContent(text.content || '') }}
|
||||
className="text-text-tertiary hover:text-text-secondary p-1"
|
||||
>
|
||||
<FileEdit className="w-4 h-4" />
|
||||
<button onClick={() => { setEditingTextId(text.Id); setEditingContent(text.content || '') }} className="text-text-tertiary hover:text-text-secondary p-1">
|
||||
<FileEdit className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmDeleteTextId(text.Id)}
|
||||
className="text-red-500 hover:text-red-600 p-1"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
<button onClick={() => setConfirmDeleteTextId(text.Id)} className="text-red-500 hover:text-red-600 p-1">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
@@ -402,14 +407,19 @@ export default function TranslationDetailPanel({ translation, onClose, onUpdate,
|
||||
<textarea
|
||||
value={editingContent}
|
||||
onChange={e => setEditingContent(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 min-h-[100px] resize-y"
|
||||
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 min-h-[80px] resize-y"
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-text-secondary whitespace-pre-wrap">{text.content}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
}) })()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 bg-surface-secondary rounded-lg border-2 border-dashed border-border">
|
||||
@@ -424,13 +434,28 @@ export default function TranslationDetailPanel({ translation, onClose, onUpdate,
|
||||
{activeTab === 'review' && (
|
||||
<div className="p-6 space-y-5">
|
||||
{['draft', 'revision_requested', 'rejected'].includes(translation.status) && (
|
||||
<>
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1.5">{t('artefacts.reviewer')}</h4>
|
||||
<PortalSelect
|
||||
value={editApproverIds[0] || ''}
|
||||
onChange={val => {
|
||||
const ids = val ? [val] : []
|
||||
setEditApproverIds(ids)
|
||||
handleFieldUpdate('approver_ids', val || '')
|
||||
}}
|
||||
options={[{ value: '', label: t('artefacts.selectReviewer') }, ...(assignableUsers || []).map(u => ({ value: u.id || u.Id, label: u.name }))]}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface text-text-primary"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSubmitReview}
|
||||
disabled={submitting}
|
||||
disabled={submitting || editApproverIds.length === 0}
|
||||
className="w-full py-3 bg-brand-primary text-white rounded-lg font-medium hover:bg-brand-primary-light disabled:opacity-50 transition-colors shadow-sm"
|
||||
>
|
||||
{submitting ? t('translations.submitting') : t('translations.submitForReview')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{currentReviewUrl && (
|
||||
@@ -441,7 +466,7 @@ export default function TranslationDetailPanel({ translation, onClose, onUpdate,
|
||||
type="text"
|
||||
value={currentReviewUrl}
|
||||
readOnly
|
||||
className="flex-1 px-3 py-2 text-sm bg-white border border-blue-200 rounded-lg text-blue-800"
|
||||
className="flex-1 px-3 py-2 text-sm bg-surface border border-blue-200 rounded-lg text-blue-800"
|
||||
/>
|
||||
<button
|
||||
onClick={copyReviewLink}
|
||||
@@ -491,18 +516,19 @@ export default function TranslationDetailPanel({ translation, onClose, onUpdate,
|
||||
</TabbedModal>
|
||||
|
||||
{/* Add Translation Modal */}
|
||||
<Modal isOpen={showAddLang} onClose={() => setShowAddLang(false)} title={t('translations.addTranslation')} size="md">
|
||||
<Modal isOpen={showAddLang} onClose={() => setShowAddLang(false)} title={t('translations.addOption')} size="md">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('translations.languageLabel')} *</label>
|
||||
<select
|
||||
<PortalSelect
|
||||
value={langForm.language_code}
|
||||
onChange={e => setLangForm(f => ({ ...f, language_code: e.target.value }))}
|
||||
onChange={val => setLangForm(f => ({ ...f, language_code: val }))}
|
||||
options={[{ value: '', label: t('translations.selectLanguage') }, ...targetLanguages.map(l => {
|
||||
const count = textsByLanguage[l.code]?.length || 0
|
||||
return { value: l.code, label: `${l.label} (${l.code})${count > 0 ? ` — ${count} ${t('translations.existing')}` : ''}` }
|
||||
})]}
|
||||
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"
|
||||
>
|
||||
<option value="">{t('translations.selectLanguage')}</option>
|
||||
{availableForAdd.map(l => <option key={l.code} value={l.code}>{l.label} ({l.code})</option>)}
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('translations.translatedContent')} *</label>
|
||||
@@ -522,7 +548,7 @@ export default function TranslationDetailPanel({ translation, onClose, onUpdate,
|
||||
disabled={savingLang || !langForm.language_code || !langForm.content}
|
||||
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"
|
||||
>
|
||||
{savingLang ? t('common.loading') : t('translations.addTranslation')}
|
||||
{savingLang ? t('common.loading') : t('translations.addOption')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -177,7 +177,7 @@ export default function Tutorial({ onComplete }) {
|
||||
|
||||
{/* Tooltip card */}
|
||||
<div
|
||||
className="absolute bg-white rounded-xl shadow-2xl border border-border p-6 animate-fade-in pointer-events-auto"
|
||||
className="absolute bg-surface rounded-xl shadow-2xl border border-border p-6 animate-fade-in pointer-events-auto"
|
||||
style={{
|
||||
top: tooltipPosition.top,
|
||||
left: tooltipPosition.left,
|
||||
@@ -188,7 +188,7 @@ export default function Tutorial({ onComplete }) {
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={handleSkip}
|
||||
className="absolute top-4 right-4 text-text-tertiary hover:text-text-primary transition-colors"
|
||||
className="absolute top-4 end-4 text-text-tertiary hover:text-text-primary transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { Upload } from 'lucide-react'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
|
||||
export default function UploadZone({
|
||||
onUpload,
|
||||
accept = '*',
|
||||
uploading = false,
|
||||
progress = 0,
|
||||
label,
|
||||
hint,
|
||||
compact = false,
|
||||
multiple = false,
|
||||
disabled = false,
|
||||
}) {
|
||||
const { t } = useLanguage()
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
const inputRef = useRef(null)
|
||||
|
||||
const processFiles = (files) => {
|
||||
const list = Array.from(files)
|
||||
const filtered = accept === '*' ? list : list.filter(f => {
|
||||
if (accept.endsWith('/*')) return f.type.startsWith(accept.replace('/*', '/'))
|
||||
return f.type === accept
|
||||
})
|
||||
if (filtered.length === 0) return
|
||||
if (multiple) filtered.forEach(f => onUpload(f))
|
||||
else onUpload(filtered[0])
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
if (uploading || disabled) return
|
||||
inputRef.current?.click()
|
||||
}
|
||||
|
||||
const handleChange = (e) => {
|
||||
processFiles(e.target.files || [])
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
const handleDrop = (e) => {
|
||||
e.preventDefault()
|
||||
setDragOver(false)
|
||||
if (uploading || disabled) return
|
||||
processFiles(e.dataTransfer.files || [])
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={handleClick}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragOver(true) }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={handleDrop}
|
||||
className={`flex flex-col items-center gap-2 border-2 border-dashed rounded-lg cursor-pointer transition-colors ${
|
||||
compact ? 'px-4 py-4' : 'px-6 py-6'
|
||||
} ${
|
||||
dragOver ? 'border-brand-primary bg-brand-primary/5' : 'border-border hover:border-brand-primary/30'
|
||||
} ${
|
||||
(uploading || disabled) ? 'pointer-events-none opacity-60' : ''
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
multiple={multiple}
|
||||
onChange={handleChange}
|
||||
className="absolute w-0 h-0 opacity-0 pointer-events-none"
|
||||
tabIndex={-1}
|
||||
/>
|
||||
{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: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-text-secondary">
|
||||
{t('artefacts.uploading')} {progress}%
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className={compact ? 'w-5 h-5 text-text-tertiary' : 'w-7 h-7 text-text-tertiary'} />
|
||||
{label && <span className="text-sm font-medium text-text-primary">{label}</span>}
|
||||
{hint && <span className="text-xs text-text-tertiary">{hint}</span>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"app.name": "المركز الرقمي",
|
||||
"app.subtitle": "المنصة",
|
||||
"app.name": "رواج",
|
||||
"app.subtitle": "مركز التسويق",
|
||||
"nav.dashboard": "لوحة التحكم",
|
||||
"nav.campaigns": "الحملات",
|
||||
"nav.finance": "المالية والعائد",
|
||||
@@ -31,6 +31,7 @@
|
||||
"common.loading": "جاري التحميل...",
|
||||
"common.unassigned": "غير مُسند",
|
||||
"common.close": "إغلاق",
|
||||
"common.created": "تاريخ الإنشاء",
|
||||
"common.required": "مطلوب",
|
||||
"common.saveFailed": "فشل الحفظ. حاول مجدداً.",
|
||||
"common.updateFailed": "فشل التحديث. حاول مجدداً.",
|
||||
@@ -78,6 +79,29 @@
|
||||
"posts.saveChanges": "حفظ التغييرات",
|
||||
"posts.postTitle": "العنوان",
|
||||
"posts.description": "الوصف",
|
||||
"post.caption": "التعليق",
|
||||
"post.captionPlaceholder": "اكتب تعليق المنشور...",
|
||||
"post.copy": "النص (داخل التصميم)",
|
||||
"post.designs": "التصاميم",
|
||||
"post.video": "الفيديو",
|
||||
"post.formatChecklist": "قائمة الأحجام المطلوبة",
|
||||
"post.formatsNeeded": "الأحجام المطلوبة بناءً على المنصات المختارة",
|
||||
"post.selectPlatforms": "اختر المنصات لعرض الأحجام المطلوبة",
|
||||
"post.readiness": "الجاهزية",
|
||||
"post.allPiecesReady": "جميع العناصر جاهزة — بانتظار الاعتماد",
|
||||
"post.waitingOn": "بانتظار",
|
||||
"post.signOff": "اعتماد وجدولة",
|
||||
"post.signOffConfirm": "هل تريد اعتماد هذا المنشور وتجهيزه للجدولة؟",
|
||||
"common.confirm": "تأكيد",
|
||||
"post.linkExisting": "ربط موجود",
|
||||
"post.createNew": "إنشاء جديد",
|
||||
"post.addDesign": "إضافة تصميم",
|
||||
"post.addVideo": "إضافة فيديو",
|
||||
"post.linkTranslation": "ربط ترجمة",
|
||||
"post.selectLanguage": "اللغة...",
|
||||
"post.noCopyLinked": "لا يوجد نص مرتبط بعد",
|
||||
"post.noDesignsLinked": "لا توجد تصاميم مرتبطة بعد",
|
||||
"post.noVideoLinked": "لا يوجد فيديو مرتبط بعد",
|
||||
"posts.brand": "العلامة التجارية",
|
||||
"posts.platforms": "المنصات",
|
||||
"posts.status": "الحالة",
|
||||
@@ -396,6 +420,16 @@
|
||||
"campaigns.editCampaign": "تعديل الحملة",
|
||||
"campaigns.deleteCampaign": "حذف الحملة؟",
|
||||
"campaigns.deleteConfirm": "هل أنت متأكد من حذف هذه الحملة؟ سيتم حذف جميع البيانات المرتبطة. لا يمكن التراجع.",
|
||||
"campaigns.tracks": "المسارات",
|
||||
"campaigns.addTrack": "إضافة مسار",
|
||||
"campaigns.noTracks": "لا توجد مسارات بعد. أضف مسارات عضوية أو مدفوعة أو SEO لتنظيم هذه الحملة.",
|
||||
"campaigns.postsLinked": "منشورات مرتبطة",
|
||||
"campaigns.team": "الفريق",
|
||||
"campaigns.assignMembers": "تعيين أعضاء",
|
||||
"campaigns.linkedPosts": "المنشورات المرتبطة",
|
||||
"campaigns.notFound": "الحملة غير موجودة.",
|
||||
"common.goBack": "رجوع",
|
||||
"finance.allocated": "مخصص",
|
||||
"tracks.details": "التفاصيل",
|
||||
"tracks.metrics": "المقاييس",
|
||||
"tracks.trackName": "اسم المسار",
|
||||
@@ -503,6 +537,59 @@
|
||||
"budgets.dateExpensed": "التاريخ",
|
||||
"dashboard.expenses": "المصروفات",
|
||||
"finance.expenses": "إجمالي المصروفات",
|
||||
"finance.totalReceived": "إجمالي المستلم",
|
||||
"finance.totalSpent": "إجمالي المنفق",
|
||||
"finance.remaining": "المتبقي",
|
||||
"finance.revenue": "الإيرادات",
|
||||
"finance.globalROI": "العائد الإجمالي",
|
||||
"finance.budgetAllocation": "توزيع الميزانية",
|
||||
"finance.manageBudgets": "إدارة الميزانيات",
|
||||
"finance.campaigns": "الحملات",
|
||||
"finance.projects": "المشاريع",
|
||||
"finance.unallocated": "غير مخصص",
|
||||
"finance.budgetUtilization": "استخدام الميزانية",
|
||||
"finance.globalPerformance": "الأداء العام",
|
||||
"finance.impressions": "مرات الظهور",
|
||||
"finance.clicks": "النقرات",
|
||||
"finance.conversions": "التحويلات",
|
||||
"finance.campaignBreakdown": "توزيع الحملات",
|
||||
"finance.allocatedFunds": "الأموال المخصصة",
|
||||
"finance.requestBudget": "طلب ميزانية",
|
||||
"finance.budgetRequests": "طلبات الميزانية",
|
||||
"finance.pendingApproval": "بانتظار موافقة المدير التنفيذي",
|
||||
"finance.justification": "المبرر",
|
||||
"finance.earmarkFor": "تخصيص لـ",
|
||||
"finance.submitRequest": "إرسال الطلب",
|
||||
"finance.cancelRequest": "إلغاء الطلب",
|
||||
"finance.approved": "تمت الموافقة",
|
||||
"finance.rejected": "مرفوض",
|
||||
"finance.cancelled": "ملغي",
|
||||
"finance.pending": "قيد الانتظار",
|
||||
"finance.ceoNote": "ملاحظة المدير",
|
||||
"finance.requestPending": "طلب(ات) ميزانية بانتظار الموافقة",
|
||||
"finance.insufficientBudget": "ميزانية غير كافية",
|
||||
"finance.availableBudget": "المتاح",
|
||||
"finance.requestMore": "طلب المزيد من الأموال",
|
||||
"finance.noCeoEmail": "لم يتم تكوين بريد المدير التنفيذي. اذهب إلى الإعدادات.",
|
||||
"finance.amount": "المبلغ",
|
||||
"finance.justificationPlaceholder": "لماذا هذه الميزانية مطلوبة؟",
|
||||
"finance.optional": "اختياري",
|
||||
"settings.budgetApproval": "موافقة الميزانية",
|
||||
"settings.ceoEmail": "بريد المدير التنفيذي / المعتمد",
|
||||
"settings.ceoEmailHint": "عنوان البريد الإلكتروني الذي يستلم طلبات الموافقة على الميزانية",
|
||||
"budgetApproval.title": "موافقة الميزانية",
|
||||
"budgetApproval.amount": "المبلغ المطلوب",
|
||||
"budgetApproval.requestedBy": "مقدم الطلب",
|
||||
"budgetApproval.justification": "المبرر",
|
||||
"budgetApproval.earmarkedFor": "مخصص لـ",
|
||||
"budgetApproval.approve": "موافقة",
|
||||
"budgetApproval.reject": "رفض",
|
||||
"budgetApproval.addNote": "أضف ملاحظة (اختياري)",
|
||||
"budgetApproval.approved": "تمت الموافقة على هذا الطلب.",
|
||||
"budgetApproval.rejected": "تم رفض هذا الطلب.",
|
||||
"budgetApproval.expired": "انتهت صلاحية هذا الطلب.",
|
||||
"budgetApproval.alreadyHandled": "تمت معالجة هذا الطلب بالفعل.",
|
||||
"finance.ofBudget": "من الميزانية",
|
||||
"settings.uploads": "الرفع",
|
||||
"settings.maxFileSize": "الحد الأقصى لحجم الملف",
|
||||
"settings.maxFileSizeHint": "الحد الأقصى المسموح لحجم المرفقات (١-٥٠٠ ميجابايت)",
|
||||
@@ -520,6 +607,11 @@
|
||||
"issues.noIssuesInColumn": "لا توجد مشاكل",
|
||||
"artefacts.details": "التفاصيل",
|
||||
"artefacts.review": "المراجعة",
|
||||
"artefacts.selectVersionFirst": "اختر إصداراً لعرض التعليقات.",
|
||||
"artefacts.pendingReviewInfo": "هذا العنصر قيد المراجعة حالياً.",
|
||||
"artefacts.noReviewInfo": "لا توجد معلومات مراجعة متاحة.",
|
||||
"artefacts.rejectedMustCreateNewVersion": "تم رفض هذا العنصر. أنشئ إصداراً جديداً لمعالجة الملاحظات.",
|
||||
"artefacts.revisionEditCurrentVersion": "طُلب تعديل — عدّل الإصدار الحالي وأعد إرساله للمراجعة.",
|
||||
"artefacts.grid": "شبكة",
|
||||
"artefacts.list": "قائمة",
|
||||
"artefacts.allCreators": "جميع المنشئين",
|
||||
@@ -629,7 +721,7 @@
|
||||
"review.alreadyReviewed": "تمت مراجعة هذا المحتوى بالفعل.",
|
||||
"review.statusLabel": "الحالة",
|
||||
"review.reviewedBy": "تمت المراجعة بواسطة",
|
||||
"review.poweredBy": "مدعوم بواسطة Samaya Digital Hub",
|
||||
"review.poweredBy": "مدعوم بواسطة Rawaj",
|
||||
"review.loadFailed": "فشل في تحميل المحتوى",
|
||||
"review.actionFailed": "فشل الإجراء",
|
||||
"review.actionCompleted": "تم الإجراء بنجاح",
|
||||
@@ -638,6 +730,11 @@
|
||||
"review.confirmReject": "هل تريد رفض هذا المحتوى؟",
|
||||
"review.feedbackRequired": "يرجى تقديم ملاحظات لطلب التعديل",
|
||||
"review.contentLanguages": "لغات المحتوى",
|
||||
"review.redirectReview": "لست المراجع المناسب؟ أعد التوجيه لشخص آخر",
|
||||
"review.redirectDesc": "اختر عضو فريق لإعادة توجيه المراجعة إليه:",
|
||||
"review.selectNewReviewer": "اختر مراجعاً جديداً...",
|
||||
"review.redirect": "إعادة توجيه",
|
||||
"review.redirected": "تم إعادة توجيه المراجعة بنجاح",
|
||||
"review.content": "المحتوى",
|
||||
"review.designFiles": "ملفات التصميم",
|
||||
"review.videos": "الفيديوهات",
|
||||
@@ -665,6 +762,9 @@
|
||||
"issues.trackingLinkCopied": "تم نسخ رابط التتبع!",
|
||||
"issues.deleteAttachment": "حذف المرفق؟",
|
||||
"issues.deleteAttachmentDesc": "لا يمكن التراجع عن هذا الإجراء.",
|
||||
"artefacts.editLanguage": "تعديل اللغة",
|
||||
"artefacts.linkedPost": "المنشور المرتبط",
|
||||
"artefacts.post": "منشور",
|
||||
"artefacts.deleteLanguage": "حذف هذه اللغة؟",
|
||||
"artefacts.deleteLanguageDesc": "سيتم إزالة المحتوى لهذه اللغة.",
|
||||
"artefacts.deleteAttachment": "حذف هذا المرفق؟",
|
||||
@@ -694,6 +794,9 @@
|
||||
"team.selectRole": "اختر دوراً...",
|
||||
"common.team": "الفريق",
|
||||
"common.noTeam": "بدون فريق",
|
||||
"common.none": "بدون",
|
||||
"common.untitled": "بدون عنوان",
|
||||
"common.success": "تم بنجاح",
|
||||
"common.error": "حدث خطأ",
|
||||
"settings.roles": "الأدوار",
|
||||
"settings.rolesDesc": "حدد أدوار العمل مثل مصمم، استراتيجي، إلخ. يتم تعيينها لأعضاء الفريق بشكل منفصل عن مستويات الصلاحية.",
|
||||
@@ -717,6 +820,11 @@
|
||||
"header.budgets": "الميزانيات",
|
||||
"header.issues": "البلاغات",
|
||||
"header.settings": "الإعدادات",
|
||||
"header.translations": "الترجمات",
|
||||
"header.copy": "النسخ",
|
||||
"header.postDetails": "تفاصيل المنشور",
|
||||
"calendar.unscheduledPosts": "منشورات غير مجدولة",
|
||||
"calendar.statusLegend": "دليل الحالات",
|
||||
"header.users": "إدارة المستخدمين",
|
||||
"header.projectDetails": "تفاصيل المشروع",
|
||||
"header.campaignDetails": "تفاصيل الحملة",
|
||||
@@ -814,6 +922,8 @@
|
||||
"artefacts.descriptionLabel": "الوصف",
|
||||
"artefacts.descriptionFieldPlaceholder": "أضف وصفاً...",
|
||||
"artefacts.approversLabel": "المعتمدون",
|
||||
"artefacts.reviewer": "المراجع",
|
||||
"artefacts.selectReviewer": "اختر مراجعاً...",
|
||||
"artefacts.versions": "الإصدارات",
|
||||
"artefacts.newVersion": "إصدار جديد",
|
||||
"artefacts.languages": "اللغات",
|
||||
@@ -822,6 +932,8 @@
|
||||
"artefacts.imagesLabel": "الصور",
|
||||
"artefacts.uploadImage": "رفع صورة",
|
||||
"artefacts.uploading": "جاري الرفع...",
|
||||
"artefacts.dropOrClickImage": "اسحب الصور هنا أو انقر للرفع",
|
||||
"artefacts.imageFormats": "PNG, JPG, WebP",
|
||||
"artefacts.noImages": "لم يتم رفع صور بعد",
|
||||
"artefacts.videosLabel": "الفيديوهات",
|
||||
"artefacts.addVideoBtn": "إضافة فيديو",
|
||||
@@ -922,6 +1034,30 @@
|
||||
"review.confirmRejectPostDesc": "هل أنت متأكد من رفض هذا المنشور؟ يرجى تقديم ملاحظات توضح السبب.",
|
||||
"review.feedbackRequired": "الملاحظات (مطلوبة)",
|
||||
"review.feedbackRequiredError": "يرجى تقديم ملاحظات عند الرفض",
|
||||
"review.loadFailed": "فشل تحميل المراجعة",
|
||||
"review.errorTitle": "خطأ",
|
||||
"review.thankYou": "شكراً لمراجعتك!",
|
||||
"review.approveSuccess": "تمت الموافقة على الترجمة بنجاح!",
|
||||
"review.rejectSuccess": "تم رفض الترجمة.",
|
||||
"review.revisionSuccess": "تم طلب التعديل بنجاح.",
|
||||
"review.nameRequired": "يرجى إدخال اسمك",
|
||||
"review.yourReview": "مراجعتك",
|
||||
"review.selectYourName": "اختر اسمك",
|
||||
"review.selectApprover": "اختر المراجع...",
|
||||
"review.yourName": "اسمك",
|
||||
"review.enterYourName": "أدخل اسمك...",
|
||||
"review.feedback": "الملاحظات",
|
||||
"review.feedbackPlaceholder": "شارك أفكارك أو ملاحظاتك...",
|
||||
"review.approve": "موافقة",
|
||||
"review.approved": "تمت الموافقة",
|
||||
"review.rejected": "مرفوض",
|
||||
"review.requestRevision": "طلب تعديل",
|
||||
"review.reject": "رفض",
|
||||
"review.statusLabel": "الحالة",
|
||||
"review.reviewedBy": "تمت المراجعة بواسطة",
|
||||
"review.confirmReject": "تأكيد الرفض",
|
||||
"review.rejectConfirmDesc": "هل أنت متأكد من رفض هذه الترجمة؟ تأكد من تقديم الملاحظات.",
|
||||
"review.feedbackRequiredForReject": "يرجى تقديم ملاحظات قبل الرفض.",
|
||||
"posts.versions": "الإصدارات",
|
||||
"posts.newVersion": "إصدار جديد",
|
||||
"posts.createNewVersion": "إنشاء إصدار جديد",
|
||||
@@ -961,7 +1097,6 @@
|
||||
"translations.status": "الحالة",
|
||||
"translations.languagesLabel": "اللغات",
|
||||
"translations.languagesCount": "لغات",
|
||||
"translations.updated": "تم التحديث",
|
||||
"translations.grid": "شبكة",
|
||||
"translations.list": "قائمة",
|
||||
"translations.allBrands": "جميع العلامات",
|
||||
@@ -991,7 +1126,7 @@
|
||||
"translations.draftSaved": "تم حفظ المسودة!",
|
||||
"translations.failedSaveDraft": "فشل حفظ المسودة",
|
||||
"translations.saveDraft": "حفظ المسودة",
|
||||
"translations.saveDraftTooltip": "حفظ التغييرات على العنوان والوصف والمحتوى الأصلي",
|
||||
"translations.saveDraftTooltip": "حفظ التغييرات على العنوان والمحتوى الأصلي",
|
||||
"translations.savingDraft": "جارٍ الحفظ...",
|
||||
"translations.updated": "تم التحديث!",
|
||||
"translations.failedUpdate": "فشل التحديث",
|
||||
@@ -1021,5 +1156,76 @@
|
||||
"translations.approvedByLabel": "وافق عليه",
|
||||
"translations.pendingReviewInfo": "هذه الترجمة بانتظار المراجعة حاليًا.",
|
||||
"translations.noReviewInfo": "لا توجد معلومات مراجعة متاحة.",
|
||||
"translations.failedDelete": "فشل الحذف"
|
||||
"translations.failedDelete": "فشل الحذف",
|
||||
"translations.addOption": "إضافة خيار",
|
||||
"translations.option": "خيار",
|
||||
"translations.options": "خيارات",
|
||||
"translations.optionLabel": "الخيار",
|
||||
"translations.selected": "محدد",
|
||||
"translations.selectThis": "اختيار",
|
||||
"translations.optionSelected": "تم اختيار الخيار!",
|
||||
"translations.suggestAlternative": "اقتراح بديل",
|
||||
"translations.suggestForLang": "اقترح ترجمة لـ",
|
||||
"translations.enterSuggestion": "أدخل الترجمة المقترحة...",
|
||||
"translations.submitSuggestion": "إرسال الاقتراح",
|
||||
"translations.suggestionAdded": "تمت إضافة الاقتراح!",
|
||||
"translations.existing": "موجود",
|
||||
"translations.copyContent": "نسخ إلى الحافظة",
|
||||
"translations.copiedToClipboard": "تم النسخ!",
|
||||
"translations.approvedReadOnly": "هذه الترجمة معتمدة ولا يمكن تعديلها.",
|
||||
"translations.linkedPost": "المنشور المرتبط",
|
||||
"translations.createPost": "منشور جديد",
|
||||
"translations.newPostTitle": "عنوان المنشور...",
|
||||
"translations.postCreated": "تم إنشاء المنشور!",
|
||||
"translations.postCreateFailed": "فشل إنشاء المنشور",
|
||||
|
||||
"nav.copy": "النسخ",
|
||||
|
||||
"postDetail.captionCopy": "نص التسمية التوضيحية",
|
||||
"postDetail.bodyCopy": "النص الرئيسي",
|
||||
"postDetail.design": "التصميم",
|
||||
"postDetail.video": "الفيديو",
|
||||
"postDetail.readiness": "الجاهزية",
|
||||
"postDetail.noAssets": "لا توجد أصول مرتبطة بعد",
|
||||
"postDetail.allPiecesApproved": "جميع العناصر معتمدة",
|
||||
"postDetail.waitingOn": "بانتظار",
|
||||
"postDetail.notLinked": "غير مرتبط",
|
||||
"postDetail.linkExisting": "ربط موجود",
|
||||
"postDetail.createNew": "إنشاء جديد",
|
||||
"postDetail.open": "فتح",
|
||||
"postDetail.unlink": "إلغاء الربط",
|
||||
"postDetail.viewDetails": "عرض التفاصيل",
|
||||
"postDetail.reviewer": "المراجع",
|
||||
"postDetail.selectReviewer": "اختر المراجع",
|
||||
"postDetail.submitForReview": "إرسال للمراجعة",
|
||||
"postDetail.pendingReviewBy": "بانتظار مراجعة",
|
||||
"postDetail.approved": "تمت الموافقة",
|
||||
"postDetail.sourceLanguage": "اللغة المصدر",
|
||||
"postDetail.content": "المحتوى",
|
||||
"postDetail.contentPlaceholder": "اكتب النص...",
|
||||
"postDetail.files": "الملفات",
|
||||
"postDetail.dragDropFiles": "اسحب وأفلت أو انقر للرفع",
|
||||
"postDetail.addMoreFiles": "إضافة ملفات أخرى",
|
||||
"postDetail.createAndSubmit": "إنشاء وإرسال للمراجعة",
|
||||
"postDetail.create": "إنشاء",
|
||||
"finance.campaign": "الحملة",
|
||||
"finance.budgetAssigned": "الميزانية المخصصة",
|
||||
"finance.trackAllocated": "المسار المخصص",
|
||||
"finance.spent": "المنفق",
|
||||
"finance.roi": "العائد",
|
||||
"finance.workOrder": "أمر العمل",
|
||||
"finance.budgetAllocated": "الميزانية المخصصة",
|
||||
"finance.of": "من",
|
||||
"finance.campaignCount": "{{count}} حملات · توزيع ميزانية على مستوى المسار",
|
||||
"finance.workOrderCount": "{{count}} أوامر عمل بميزانية مخصصة",
|
||||
"calendar.sun": "أحد",
|
||||
"calendar.mon": "إثن",
|
||||
"calendar.tue": "ثلا",
|
||||
"calendar.wed": "أرب",
|
||||
"calendar.thu": "خمي",
|
||||
"calendar.fri": "جمع",
|
||||
"calendar.sat": "سبت",
|
||||
"calendar.month": "شهر",
|
||||
"calendar.week": "أسبوع",
|
||||
"calendar.today": "اليوم"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"app.name": "Digital Hub",
|
||||
"app.subtitle": "Platform",
|
||||
"app.name": "Rawaj",
|
||||
"app.subtitle": "Marketing Hub",
|
||||
"nav.dashboard": "Dashboard",
|
||||
"nav.campaigns": "Campaigns",
|
||||
"nav.finance": "Finance & ROI",
|
||||
@@ -31,6 +31,7 @@
|
||||
"common.loading": "Loading...",
|
||||
"common.unassigned": "Unassigned",
|
||||
"common.close": "Close",
|
||||
"common.created": "Created",
|
||||
"common.required": "Required",
|
||||
"common.saveFailed": "Failed to save. Please try again.",
|
||||
"common.updateFailed": "Failed to update. Please try again.",
|
||||
@@ -70,7 +71,7 @@
|
||||
"dashboard.noPostsYet": "No posts yet. Create your first post!",
|
||||
"dashboard.upcomingDeadlines": "Upcoming Deadlines",
|
||||
"dashboard.noUpcomingDeadlines": "No upcoming deadlines this week. 🎉",
|
||||
"dashboard.loadingHub": "Loading Digital Hub...",
|
||||
"dashboard.loadingHub": "Loading Rawaj...",
|
||||
"posts.title": "Post Production",
|
||||
"posts.newPost": "New Post",
|
||||
"posts.editPost": "Edit Post",
|
||||
@@ -78,6 +79,29 @@
|
||||
"posts.saveChanges": "Save Changes",
|
||||
"posts.postTitle": "Title",
|
||||
"posts.description": "Description",
|
||||
"post.caption": "Caption",
|
||||
"post.captionPlaceholder": "Write your social media caption...",
|
||||
"post.copy": "Copy (In-Design Text)",
|
||||
"post.designs": "Designs",
|
||||
"post.video": "Video",
|
||||
"post.formatChecklist": "Format Checklist",
|
||||
"post.formatsNeeded": "Formats needed based on selected platforms",
|
||||
"post.selectPlatforms": "Select platforms to see required formats",
|
||||
"post.readiness": "Readiness",
|
||||
"post.allPiecesReady": "All pieces ready — awaiting sign-off",
|
||||
"post.waitingOn": "Waiting on",
|
||||
"post.signOff": "Approve & Schedule",
|
||||
"post.signOffConfirm": "Mark this post as approved and ready for scheduling?",
|
||||
"common.confirm": "Confirm",
|
||||
"post.linkExisting": "Link existing",
|
||||
"post.createNew": "Create new",
|
||||
"post.addDesign": "Add Design",
|
||||
"post.addVideo": "Add Video",
|
||||
"post.linkTranslation": "Link Translation",
|
||||
"post.selectLanguage": "Language...",
|
||||
"post.noCopyLinked": "No copy linked yet",
|
||||
"post.noDesignsLinked": "No designs linked yet",
|
||||
"post.noVideoLinked": "No video linked yet",
|
||||
"posts.brand": "Brand",
|
||||
"posts.platforms": "Platforms",
|
||||
"posts.status": "Status",
|
||||
@@ -271,7 +295,7 @@
|
||||
"settings.english": "English",
|
||||
"settings.arabic": "Arabic",
|
||||
"settings.restartTutorial": "Restart Tutorial",
|
||||
"settings.tutorialDesc": "Need a refresher? Restart the interactive tutorial to learn about all the features of Digital Hub.",
|
||||
"settings.tutorialDesc": "Need a refresher? Restart the interactive tutorial to learn about all the features of Rawaj.",
|
||||
"settings.general": "General",
|
||||
"settings.onboardingTutorial": "Onboarding Tutorial",
|
||||
"settings.tutorialRestarted": "Tutorial Restarted!",
|
||||
@@ -315,7 +339,7 @@
|
||||
"tutorial.newPost.desc": "Start creating content here. Pick your brand, platforms, and assign it to a team member.",
|
||||
"tutorial.filters.title": "Filter & Focus",
|
||||
"tutorial.filters.desc": "Use filters to focus on specific brands, platforms, or team members.",
|
||||
"login.title": "Digital Hub",
|
||||
"login.title": "Rawaj",
|
||||
"login.subtitle": "Sign in to continue",
|
||||
"login.forgotPassword": "Forgot password?",
|
||||
"login.defaultCreds": "Default credentials:",
|
||||
@@ -396,6 +420,16 @@
|
||||
"campaigns.editCampaign": "Edit Campaign",
|
||||
"campaigns.deleteCampaign": "Delete Campaign?",
|
||||
"campaigns.deleteConfirm": "Are you sure you want to delete this campaign? All associated data will be removed. This action cannot be undone.",
|
||||
"campaigns.tracks": "Tracks",
|
||||
"campaigns.addTrack": "Add Track",
|
||||
"campaigns.noTracks": "No tracks yet. Add organic, paid, or SEO tracks to organize this campaign.",
|
||||
"campaigns.postsLinked": "posts linked",
|
||||
"campaigns.team": "Team",
|
||||
"campaigns.assignMembers": "Assign Members",
|
||||
"campaigns.linkedPosts": "Linked Posts",
|
||||
"campaigns.notFound": "Campaign not found.",
|
||||
"common.goBack": "Go back",
|
||||
"finance.allocated": "allocated",
|
||||
"tracks.details": "Details",
|
||||
"tracks.metrics": "Metrics",
|
||||
"tracks.trackName": "Track Name",
|
||||
@@ -503,6 +537,59 @@
|
||||
"budgets.dateExpensed": "Date",
|
||||
"dashboard.expenses": "Expenses",
|
||||
"finance.expenses": "Total Expenses",
|
||||
"finance.totalReceived": "Total Received",
|
||||
"finance.totalSpent": "Total Spent",
|
||||
"finance.remaining": "Remaining",
|
||||
"finance.revenue": "Revenue",
|
||||
"finance.globalROI": "Global ROI",
|
||||
"finance.budgetAllocation": "Budget Allocation",
|
||||
"finance.manageBudgets": "Manage Budgets",
|
||||
"finance.campaigns": "Campaigns",
|
||||
"finance.projects": "Projects",
|
||||
"finance.unallocated": "Unallocated",
|
||||
"finance.budgetUtilization": "Budget Utilization",
|
||||
"finance.globalPerformance": "Global Performance",
|
||||
"finance.impressions": "Impressions",
|
||||
"finance.clicks": "Clicks",
|
||||
"finance.conversions": "Conversions",
|
||||
"finance.campaignBreakdown": "Campaign Breakdown",
|
||||
"finance.allocatedFunds": "Allocated Funds",
|
||||
"finance.requestBudget": "Request Budget",
|
||||
"finance.budgetRequests": "Budget Requests",
|
||||
"finance.pendingApproval": "pending CEO approval",
|
||||
"finance.justification": "Justification",
|
||||
"finance.earmarkFor": "Earmark for",
|
||||
"finance.submitRequest": "Submit Request",
|
||||
"finance.cancelRequest": "Cancel Request",
|
||||
"finance.approved": "Approved",
|
||||
"finance.rejected": "Rejected",
|
||||
"finance.cancelled": "Cancelled",
|
||||
"finance.pending": "Pending",
|
||||
"finance.ceoNote": "CEO Note",
|
||||
"finance.requestPending": "budget request(s) pending CEO approval",
|
||||
"finance.insufficientBudget": "Insufficient budget",
|
||||
"finance.availableBudget": "Available",
|
||||
"finance.requestMore": "Request more funds",
|
||||
"finance.noCeoEmail": "CEO email not configured. Go to Settings.",
|
||||
"finance.amount": "Amount",
|
||||
"finance.justificationPlaceholder": "Why is this budget needed?",
|
||||
"finance.optional": "Optional",
|
||||
"settings.budgetApproval": "Budget Approval",
|
||||
"settings.ceoEmail": "CEO / Budget Approver Email",
|
||||
"settings.ceoEmailHint": "Email address that receives budget approval requests",
|
||||
"budgetApproval.title": "Budget Approval",
|
||||
"budgetApproval.amount": "Requested Amount",
|
||||
"budgetApproval.requestedBy": "Requested by",
|
||||
"budgetApproval.justification": "Justification",
|
||||
"budgetApproval.earmarkedFor": "Earmarked for",
|
||||
"budgetApproval.approve": "Approve",
|
||||
"budgetApproval.reject": "Reject",
|
||||
"budgetApproval.addNote": "Add a note (optional)",
|
||||
"budgetApproval.approved": "This request has been approved.",
|
||||
"budgetApproval.rejected": "This request has been rejected.",
|
||||
"budgetApproval.expired": "This request has expired.",
|
||||
"budgetApproval.alreadyHandled": "This request has already been processed.",
|
||||
"finance.ofBudget": "of budget",
|
||||
"settings.uploads": "Uploads",
|
||||
"settings.maxFileSize": "Maximum File Size",
|
||||
"settings.maxFileSizeHint": "Maximum allowed file size for attachments (1-500 MB)",
|
||||
@@ -520,6 +607,11 @@
|
||||
"issues.noIssuesInColumn": "No issues",
|
||||
"artefacts.details": "Details",
|
||||
"artefacts.review": "Review",
|
||||
"artefacts.selectVersionFirst": "Select a version to view comments.",
|
||||
"artefacts.pendingReviewInfo": "This artefact is currently pending review.",
|
||||
"artefacts.noReviewInfo": "No review information available.",
|
||||
"artefacts.rejectedMustCreateNewVersion": "This artefact was rejected. Create a new version to address the feedback.",
|
||||
"artefacts.revisionEditCurrentVersion": "Revision requested — edit the current version and resubmit for review.",
|
||||
"artefacts.grid": "Grid",
|
||||
"artefacts.list": "List",
|
||||
"artefacts.allCreators": "All Creators",
|
||||
@@ -629,7 +721,7 @@
|
||||
"review.alreadyReviewed": "This artefact has already been reviewed.",
|
||||
"review.statusLabel": "Status",
|
||||
"review.reviewedBy": "Reviewed by",
|
||||
"review.poweredBy": "Powered by Samaya Digital Hub",
|
||||
"review.poweredBy": "Powered by Rawaj",
|
||||
"review.loadFailed": "Failed to load artefact",
|
||||
"review.actionFailed": "Action failed",
|
||||
"review.actionCompleted": "Action completed successfully",
|
||||
@@ -638,6 +730,11 @@
|
||||
"review.confirmReject": "Reject this artefact?",
|
||||
"review.feedbackRequired": "Please provide feedback for revision request",
|
||||
"review.contentLanguages": "Content Languages",
|
||||
"review.redirectReview": "Not the right reviewer? Redirect to someone else",
|
||||
"review.redirectDesc": "Select a team member to redirect this review to:",
|
||||
"review.selectNewReviewer": "Select new reviewer...",
|
||||
"review.redirect": "Redirect",
|
||||
"review.redirected": "Review redirected successfully",
|
||||
"review.content": "Content",
|
||||
"review.designFiles": "Design Files",
|
||||
"review.videos": "Videos",
|
||||
@@ -665,6 +762,9 @@
|
||||
"issues.trackingLinkCopied": "Tracking link copied to clipboard!",
|
||||
"issues.deleteAttachment": "Delete attachment?",
|
||||
"issues.deleteAttachmentDesc": "This action cannot be undone.",
|
||||
"artefacts.editLanguage": "Edit Language",
|
||||
"artefacts.linkedPost": "Linked Post",
|
||||
"artefacts.post": "Post",
|
||||
"artefacts.deleteLanguage": "Delete this language?",
|
||||
"artefacts.deleteLanguageDesc": "The content for this language will be removed.",
|
||||
"artefacts.deleteAttachment": "Delete this attachment?",
|
||||
@@ -694,6 +794,9 @@
|
||||
"team.selectRole": "Select role...",
|
||||
"common.team": "Team",
|
||||
"common.noTeam": "No team",
|
||||
"common.none": "None",
|
||||
"common.untitled": "Untitled",
|
||||
"common.success": "Success",
|
||||
"common.error": "An error occurred",
|
||||
"settings.roles": "Roles",
|
||||
"settings.rolesDesc": "Define job roles like Designer, Strategist, etc. These are assigned to team members separately from permission levels.",
|
||||
@@ -717,6 +820,11 @@
|
||||
"header.budgets": "Budgets",
|
||||
"header.issues": "Issues",
|
||||
"header.settings": "Settings",
|
||||
"header.translations": "Translations",
|
||||
"header.copy": "Copy",
|
||||
"header.postDetails": "Post Details",
|
||||
"calendar.unscheduledPosts": "Unscheduled Posts",
|
||||
"calendar.statusLegend": "Status Legend",
|
||||
"header.users": "User Management",
|
||||
"header.projectDetails": "Project Details",
|
||||
"header.campaignDetails": "Campaign Details",
|
||||
@@ -814,6 +922,8 @@
|
||||
"artefacts.descriptionLabel": "Description",
|
||||
"artefacts.descriptionFieldPlaceholder": "Add a description...",
|
||||
"artefacts.approversLabel": "Approvers",
|
||||
"artefacts.reviewer": "Reviewer",
|
||||
"artefacts.selectReviewer": "Select a reviewer...",
|
||||
"artefacts.versions": "Versions",
|
||||
"artefacts.newVersion": "New Version",
|
||||
"artefacts.languages": "Languages",
|
||||
@@ -822,6 +932,8 @@
|
||||
"artefacts.imagesLabel": "Images",
|
||||
"artefacts.uploadImage": "Upload Image",
|
||||
"artefacts.uploading": "Uploading...",
|
||||
"artefacts.dropOrClickImage": "Drop images here or click to upload",
|
||||
"artefacts.imageFormats": "PNG, JPG, WebP",
|
||||
"artefacts.noImages": "No images uploaded yet",
|
||||
"artefacts.videosLabel": "Videos",
|
||||
"artefacts.addVideoBtn": "Add Video",
|
||||
@@ -922,6 +1034,30 @@
|
||||
"review.confirmRejectPostDesc": "Are you sure you want to reject this post? Please provide feedback explaining why.",
|
||||
"review.feedbackRequired": "Feedback (required)",
|
||||
"review.feedbackRequiredError": "Please provide feedback when rejecting",
|
||||
"review.loadFailed": "Failed to load review",
|
||||
"review.errorTitle": "Error",
|
||||
"review.thankYou": "Thank you for your review!",
|
||||
"review.approveSuccess": "Translation approved successfully!",
|
||||
"review.rejectSuccess": "Translation has been rejected.",
|
||||
"review.revisionSuccess": "Revision requested successfully.",
|
||||
"review.nameRequired": "Please provide your name",
|
||||
"review.yourReview": "Your Review",
|
||||
"review.selectYourName": "Select your name",
|
||||
"review.selectApprover": "Select approver...",
|
||||
"review.yourName": "Your Name",
|
||||
"review.enterYourName": "Enter your name...",
|
||||
"review.feedback": "Feedback",
|
||||
"review.feedbackPlaceholder": "Share your thoughts or feedback...",
|
||||
"review.approve": "Approve",
|
||||
"review.approved": "Approved",
|
||||
"review.rejected": "Rejected",
|
||||
"review.requestRevision": "Request Revision",
|
||||
"review.reject": "Reject",
|
||||
"review.statusLabel": "Status",
|
||||
"review.reviewedBy": "Reviewed by",
|
||||
"review.confirmReject": "Confirm Rejection",
|
||||
"review.rejectConfirmDesc": "Are you sure you want to reject this translation? Please make sure you have provided feedback.",
|
||||
"review.feedbackRequiredForReject": "Please provide feedback before rejecting.",
|
||||
"posts.versions": "Versions",
|
||||
"posts.newVersion": "New Version",
|
||||
"posts.createNewVersion": "Create New Version",
|
||||
@@ -961,7 +1097,6 @@
|
||||
"translations.status": "Status",
|
||||
"translations.languagesLabel": "Languages",
|
||||
"translations.languagesCount": "languages",
|
||||
"translations.updated": "Updated",
|
||||
"translations.grid": "Grid",
|
||||
"translations.list": "List",
|
||||
"translations.allBrands": "All Brands",
|
||||
@@ -991,9 +1126,9 @@
|
||||
"translations.draftSaved": "Draft saved!",
|
||||
"translations.failedSaveDraft": "Failed to save draft",
|
||||
"translations.saveDraft": "Save Draft",
|
||||
"translations.saveDraftTooltip": "Save changes to title, description, and source content",
|
||||
"translations.saveDraftTooltip": "Save changes to title and source content",
|
||||
"translations.savingDraft": "Saving...",
|
||||
"translations.updated": "Updated!",
|
||||
"translations.updated": "Updated",
|
||||
"translations.failedUpdate": "Failed to update",
|
||||
"translations.addTranslation": "Add Translation",
|
||||
"translations.translationAdded": "Translation added!",
|
||||
@@ -1021,5 +1156,76 @@
|
||||
"translations.approvedByLabel": "Approved by",
|
||||
"translations.pendingReviewInfo": "This translation is currently pending review.",
|
||||
"translations.noReviewInfo": "No review information available.",
|
||||
"translations.failedDelete": "Failed to delete"
|
||||
"translations.failedDelete": "Failed to delete",
|
||||
"translations.addOption": "Add Option",
|
||||
"translations.option": "option",
|
||||
"translations.options": "options",
|
||||
"translations.optionLabel": "Option",
|
||||
"translations.selected": "Selected",
|
||||
"translations.selectThis": "Select",
|
||||
"translations.optionSelected": "Option selected!",
|
||||
"translations.suggestAlternative": "Suggest alternative",
|
||||
"translations.suggestForLang": "Suggest a translation for",
|
||||
"translations.enterSuggestion": "Enter your suggested translation...",
|
||||
"translations.submitSuggestion": "Submit Suggestion",
|
||||
"translations.suggestionAdded": "Suggestion added!",
|
||||
"translations.existing": "existing",
|
||||
"translations.copyContent": "Copy to clipboard",
|
||||
"translations.copiedToClipboard": "Copied to clipboard!",
|
||||
"translations.approvedReadOnly": "This translation is approved and cannot be modified.",
|
||||
"translations.linkedPost": "Linked Post",
|
||||
"translations.createPost": "New Post",
|
||||
"translations.newPostTitle": "Post title...",
|
||||
"translations.postCreated": "Post created!",
|
||||
"translations.postCreateFailed": "Failed to create post",
|
||||
|
||||
"nav.copy": "Copy",
|
||||
|
||||
"postDetail.captionCopy": "Caption Copy",
|
||||
"postDetail.bodyCopy": "Body Copy",
|
||||
"postDetail.design": "Design",
|
||||
"postDetail.video": "Video",
|
||||
"postDetail.readiness": "Readiness",
|
||||
"postDetail.noAssets": "No assets linked yet",
|
||||
"postDetail.allPiecesApproved": "All pieces approved",
|
||||
"postDetail.waitingOn": "Waiting on",
|
||||
"postDetail.notLinked": "Not linked",
|
||||
"postDetail.linkExisting": "Link existing",
|
||||
"postDetail.createNew": "Create new",
|
||||
"postDetail.open": "Open",
|
||||
"postDetail.unlink": "Unlink",
|
||||
"postDetail.viewDetails": "View details",
|
||||
"postDetail.reviewer": "Reviewer",
|
||||
"postDetail.selectReviewer": "Select reviewer",
|
||||
"postDetail.submitForReview": "Submit for Review",
|
||||
"postDetail.pendingReviewBy": "Pending review by",
|
||||
"postDetail.approved": "Approved",
|
||||
"postDetail.sourceLanguage": "Source Language",
|
||||
"postDetail.content": "Content",
|
||||
"postDetail.contentPlaceholder": "Write the copy text...",
|
||||
"postDetail.files": "Files",
|
||||
"postDetail.dragDropFiles": "Drag & drop or click to upload",
|
||||
"postDetail.addMoreFiles": "Add more files",
|
||||
"postDetail.createAndSubmit": "Create & Submit for Review",
|
||||
"postDetail.create": "Create",
|
||||
"finance.campaign": "Campaign",
|
||||
"finance.budgetAssigned": "Budget Assigned",
|
||||
"finance.trackAllocated": "Track Allocated",
|
||||
"finance.spent": "Spent",
|
||||
"finance.roi": "ROI",
|
||||
"finance.workOrder": "Work Order",
|
||||
"finance.budgetAllocated": "Budget Allocated",
|
||||
"finance.of": "of",
|
||||
"finance.campaignCount": "{{count}} campaigns · Track-level budget allocation",
|
||||
"finance.workOrderCount": "{{count}} work orders with assigned budget",
|
||||
"calendar.sun": "Sun",
|
||||
"calendar.mon": "Mon",
|
||||
"calendar.tue": "Tue",
|
||||
"calendar.wed": "Wed",
|
||||
"calendar.thu": "Thu",
|
||||
"calendar.fri": "Fri",
|
||||
"calendar.sat": "Sat",
|
||||
"calendar.month": "Month",
|
||||
"calendar.week": "Week",
|
||||
"calendar.today": "Today"
|
||||
}
|
||||
@@ -1,16 +1,16 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+Arabic:wght@300;400;500;600;700&family=Inter:wght@300;400;500;600;700&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500;600;700&family=IBM+Plex+Sans+Arabic:wght@300;400;500;600;700&display=swap');
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--font-sans: 'Inter', 'IBM Plex Sans Arabic', system-ui, -apple-system, sans-serif;
|
||||
--color-sidebar: #0f172a;
|
||||
--color-sidebar-hover: #1e293b;
|
||||
--color-sidebar-active: #020617;
|
||||
--color-brand-primary: #4f46e5;
|
||||
--color-brand-primary-light: #6366f1;
|
||||
--font-sans: 'DM Sans', 'IBM Plex Sans Arabic', system-ui, -apple-system, sans-serif;
|
||||
--color-sidebar: #0a1f1c;
|
||||
--color-sidebar-hover: #123b35;
|
||||
--color-sidebar-active: #061411;
|
||||
--color-brand-primary: #0d9488;
|
||||
--color-brand-primary-light: #14b8a6;
|
||||
--color-brand-secondary: #db2777;
|
||||
--color-brand-tertiary: #f59e0b;
|
||||
--color-brand-quaternary: #059669;
|
||||
--color-brand-quaternary: #0d9488;
|
||||
--color-surface: #ffffff;
|
||||
--color-surface-secondary: #f9fafb;
|
||||
--color-surface-tertiary: #f3f4f6;
|
||||
@@ -37,40 +37,39 @@
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════
|
||||
DARK MODE — Inspired by SpaceTime
|
||||
Deep layered surfaces, glass edges, ambient glow
|
||||
DARK MODE — Forest teal tinted surfaces
|
||||
═══════════════════════════════════════════════ */
|
||||
|
||||
.dark {
|
||||
/* Layered depth: void → surface → surface-2 → surface-3 */
|
||||
--color-surface: #15151e;
|
||||
--color-surface-secondary: #1c1c2a;
|
||||
--color-surface-tertiary: #24243a;
|
||||
/* Layered depth: deep forest → surface → elevated */
|
||||
--color-surface: #0f1a18;
|
||||
--color-surface-secondary: #162220;
|
||||
--color-surface-tertiary: #1e2e2b;
|
||||
--color-border: rgba(255, 255, 255, 0.08);
|
||||
--color-border-light: rgba(255, 255, 255, 0.04);
|
||||
|
||||
/* Text — crisp hierarchy */
|
||||
--color-text-primary: #eeecf5;
|
||||
--color-text-secondary: #a8a3c0;
|
||||
--color-text-tertiary: #706b8a;
|
||||
/* Text — warm neutrals, teal-tinted */
|
||||
--color-text-primary: #e8f0ee;
|
||||
--color-text-secondary: #9db5b0;
|
||||
--color-text-tertiary: #637e78;
|
||||
|
||||
/* Sidebar */
|
||||
--color-sidebar: #0e0e16;
|
||||
--color-sidebar-hover: #15151e;
|
||||
--color-sidebar-active: #0a0a12;
|
||||
--color-sidebar: #0a1412;
|
||||
--color-sidebar-hover: #0f1a18;
|
||||
--color-sidebar-active: #060e0c;
|
||||
|
||||
/* Brand — brighter on dark */
|
||||
--color-brand-primary: #8b5cf6;
|
||||
--color-brand-primary-light: #a78bfa;
|
||||
--color-brand-primary: #14b8a6;
|
||||
--color-brand-primary-light: #2dd4bf;
|
||||
|
||||
color-scheme: dark;
|
||||
background-color: #15151e;
|
||||
color: #eeecf5;
|
||||
background-color: #0f1a18;
|
||||
color: #e8f0ee;
|
||||
}
|
||||
|
||||
/* ─── Ambient background glow ────────────────── */
|
||||
.dark .bg-mesh {
|
||||
background-color: #15151e !important;
|
||||
background-color: #0f1a18 !important;
|
||||
background-image: none !important;
|
||||
}
|
||||
.dark .bg-mesh::before {
|
||||
@@ -78,9 +77,8 @@
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(ellipse 70% 50% at 20% 50%, rgba(139, 92, 246, 0.045) 0%, transparent 60%),
|
||||
radial-gradient(ellipse 50% 40% at 80% 30%, rgba(56, 189, 248, 0.03) 0%, transparent 60%),
|
||||
radial-gradient(ellipse 60% 40% at 50% 90%, rgba(232, 168, 56, 0.02) 0%, transparent 60%);
|
||||
radial-gradient(ellipse 70% 50% at 20% 50%, rgba(13, 148, 136, 0.04) 0%, transparent 60%),
|
||||
radial-gradient(ellipse 50% 40% at 80% 30%, rgba(20, 184, 166, 0.025) 0%, transparent 60%);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
@@ -89,11 +87,11 @@
|
||||
.dark .bg-white,
|
||||
.dark .bg-\[\#fff\],
|
||||
.dark .bg-\[\#ffffff\] {
|
||||
background-color: #22223a !important;
|
||||
background-color: #1a2a28 !important;
|
||||
}
|
||||
.dark .bg-gray-50 { background-color: #15151e !important; }
|
||||
.dark .bg-gray-100 { background-color: #1c1c2a !important; }
|
||||
.dark .bg-gray-200 { background-color: #24243a !important; }
|
||||
.dark .bg-gray-50 { background-color: #0f1a18 !important; }
|
||||
.dark .bg-gray-100 { background-color: #162220 !important; }
|
||||
.dark .bg-gray-200 { background-color: #1e2e2b !important; }
|
||||
|
||||
/* ─── Borders ────────────────────────────────── */
|
||||
.dark .border-gray-100,
|
||||
@@ -104,12 +102,12 @@
|
||||
.dark .divide-border-light > :not(:first-child) { border-color: rgba(255, 255, 255, 0.05) !important; }
|
||||
|
||||
/* ─── Text ───────────────────────────────────── */
|
||||
.dark .text-gray-900 { color: #eeecf5 !important; }
|
||||
.dark .text-gray-800 { color: #d8d5e8 !important; }
|
||||
.dark .text-gray-700 { color: #c2bedb !important; }
|
||||
.dark .text-gray-600 { color: #a8a3c0 !important; }
|
||||
.dark .text-gray-500 { color: #8b85a8 !important; }
|
||||
.dark .text-gray-400 { color: #706b8a !important; }
|
||||
.dark .text-gray-900 { color: #e8f0ee !important; }
|
||||
.dark .text-gray-800 { color: #d0ddd9 !important; }
|
||||
.dark .text-gray-700 { color: #b5cac5 !important; }
|
||||
.dark .text-gray-600 { color: #9db5b0 !important; }
|
||||
.dark .text-gray-500 { color: #7e9a94 !important; }
|
||||
.dark .text-gray-400 { color: #637e78 !important; }
|
||||
|
||||
/* ─── Status badges — translucent glass ──────── */
|
||||
.dark .bg-emerald-100, .dark .bg-emerald-50 { background-color: rgba(74, 222, 128, 0.12) !important; }
|
||||
@@ -150,49 +148,49 @@
|
||||
.dark input:focus,
|
||||
.dark select:focus,
|
||||
.dark textarea:focus {
|
||||
border-color: rgba(139, 92, 246, 0.5);
|
||||
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.1);
|
||||
border-color: rgba(20, 184, 166, 0.5);
|
||||
box-shadow: 0 0 0 3px rgba(20, 184, 166, 0.1);
|
||||
}
|
||||
.dark input::placeholder,
|
||||
.dark textarea::placeholder {
|
||||
color: #706b8a;
|
||||
color: #637e78;
|
||||
}
|
||||
.dark input:disabled,
|
||||
.dark select:disabled,
|
||||
.dark textarea:disabled {
|
||||
background-color: rgba(255, 255, 255, 0.02) !important;
|
||||
color: #706b8a !important;
|
||||
color: #637e78 !important;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Dark select arrow */
|
||||
.dark select {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23706b8a' d='M6 9L1 4h10z'/%3E%3C/svg%3E");
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23637e78' d='M6 9L1 4h10z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
/* ─── Cards — glass edges ────────────────────── */
|
||||
.dark .card-hover {
|
||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.04), 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
.dark .card-hover:hover {
|
||||
box-shadow: 0 0 0 1px rgba(139, 92, 246, 0.15), 0 16px 48px -12px rgba(0, 0, 0, 0.5);
|
||||
box-shadow: 0 0 0 1px rgba(20, 184, 166, 0.12), 0 4px 16px -4px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.dark .section-card {
|
||||
background: #1c1c2a;
|
||||
background: #162220;
|
||||
border-color: rgba(255, 255, 255, 0.06);
|
||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
.dark .section-card:hover {
|
||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.06), 0 8px 32px -8px rgba(0, 0, 0, 0.4);
|
||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.06), 0 4px 16px -4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.dark .section-card-header {
|
||||
background: linear-gradient(180deg, rgba(36, 36, 58, 0.5) 0%, #1c1c2a 100%);
|
||||
background: rgba(30, 46, 43, 0.3);
|
||||
}
|
||||
|
||||
/* ─── Sidebar ────────────────────────────────── */
|
||||
.dark .sidebar {
|
||||
background: linear-gradient(180deg, #0e0e16 0%, #0a0a12 100%);
|
||||
background: linear-gradient(180deg, #0a1412 0%, #060e0c 100%);
|
||||
box-shadow: 2px 0 24px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
@@ -216,22 +214,22 @@
|
||||
.dark .hover\:bg-red-50:hover { background-color: rgba(251, 113, 133, 0.08) !important; }
|
||||
.dark .hover\:bg-blue-100:hover { background-color: rgba(96, 165, 250, 0.08) !important; }
|
||||
|
||||
/* ─── Brand glow ─────────────────────────────── */
|
||||
/* ─── Brand accent ────────────────────────────── */
|
||||
.dark .bg-brand-primary {
|
||||
box-shadow: 0 0 24px -4px rgba(139, 92, 246, 0.35);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.dark .bg-brand-primary:hover {
|
||||
box-shadow: 0 0 32px -4px rgba(139, 92, 246, 0.45);
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/* ─── White/light text overrides on colored badges ── */
|
||||
.dark .bg-white\/90 { background-color: rgba(28, 28, 42, 0.9) !important; }
|
||||
.dark .bg-white\/90 { background-color: rgba(22, 34, 32, 0.9) !important; }
|
||||
|
||||
/* ─── Toasts — solid backgrounds, no transparency ── */
|
||||
.dark .bg-emerald-50.border-emerald-200 { background-color: #132a1e !important; border-color: #1a4a2e !important; }
|
||||
.dark .bg-red-50.border-red-200 { background-color: #2a1318 !important; border-color: #4a1a22 !important; }
|
||||
.dark .bg-blue-50.border-blue-200 { background-color: #131d2a !important; border-color: #1a2e4a !important; }
|
||||
.dark .bg-amber-50.border-amber-200 { background-color: #2a2213 !important; border-color: #4a3a1a !important; }
|
||||
/* ─── Toasts — solid backgrounds ────────────────── */
|
||||
.dark .bg-emerald-50.border-emerald-200 { background-color: #0f2a1e !important; border-color: #154a2e !important; }
|
||||
.dark .bg-red-50.border-red-200 { background-color: #2a1315 !important; border-color: #4a1a20 !important; }
|
||||
.dark .bg-blue-50.border-blue-200 { background-color: #0f1d2a !important; border-color: #152e4a !important; }
|
||||
.dark .bg-amber-50.border-amber-200 { background-color: #2a2210 !important; border-color: #4a3a15 !important; }
|
||||
.dark .text-emerald-800 { color: #6ee7b7 !important; }
|
||||
.dark .text-red-800 { color: #fca5a5 !important; }
|
||||
.dark .text-blue-800 { color: #93c5fd !important; }
|
||||
@@ -239,10 +237,19 @@
|
||||
|
||||
/* ─── Selection ──────────────────────────────── */
|
||||
.dark ::selection {
|
||||
background: rgba(139, 92, 246, 0.4);
|
||||
background: rgba(20, 184, 166, 0.4);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Reduced motion — disable animations for accessibility */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
@@ -315,15 +322,15 @@ textarea {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
/* Enhanced sidebar with gradient */
|
||||
/* Enhanced sidebar */
|
||||
.sidebar {
|
||||
background: linear-gradient(180deg, #0f172a 0%, #020617 100%);
|
||||
background: linear-gradient(180deg, #0a1f1c 0%, #061411 100%);
|
||||
box-shadow: 2px 0 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
/* Animation keyframes */
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@@ -347,11 +354,6 @@ textarea {
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
@keyframes bounce-subtle {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-4px); }
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
@@ -425,29 +427,24 @@ textarea {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* Stagger children */
|
||||
/* Stagger children — short, max 4 items */
|
||||
.stagger-children > * {
|
||||
opacity: 0;
|
||||
animation: fadeIn 0.3s ease-out forwards;
|
||||
animation: fadeIn 0.2s ease-out forwards;
|
||||
}
|
||||
.stagger-children > *:nth-child(1) { animation-delay: 0ms; }
|
||||
.stagger-children > *:nth-child(2) { animation-delay: 50ms; }
|
||||
.stagger-children > *:nth-child(3) { animation-delay: 100ms; }
|
||||
.stagger-children > *:nth-child(4) { animation-delay: 150ms; }
|
||||
.stagger-children > *:nth-child(5) { animation-delay: 200ms; }
|
||||
.stagger-children > *:nth-child(6) { animation-delay: 250ms; }
|
||||
.stagger-children > *:nth-child(7) { animation-delay: 300ms; }
|
||||
.stagger-children > *:nth-child(8) { animation-delay: 350ms; }
|
||||
.stagger-children > *:nth-child(2) { animation-delay: 40ms; }
|
||||
.stagger-children > *:nth-child(3) { animation-delay: 80ms; }
|
||||
.stagger-children > *:nth-child(n+4) { animation-delay: 120ms; }
|
||||
|
||||
/* Card hover effect - smooth and elegant */
|
||||
/* Card hover effect - refined, no lift */
|
||||
.card-hover {
|
||||
position: relative;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05), 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
transition: box-shadow 0.2s ease;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
.card-hover:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 12px 28px -6px rgba(0, 0, 0, 0.12), 0 6px 16px -8px rgba(0, 0, 0, 0.08);
|
||||
box-shadow: 0 4px 12px -2px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
/* Stat card accents - subtle colored top borders */
|
||||
@@ -470,24 +467,12 @@ textarea {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Mesh background - subtle radial gradients */
|
||||
/* Mesh background — flat, no gradients */
|
||||
.bg-mesh {
|
||||
background-color: #f8fafc;
|
||||
background-image:
|
||||
radial-gradient(at 20% 20%, rgba(79, 70, 229, 0.04) 0, transparent 50%),
|
||||
radial-gradient(at 80% 40%, rgba(219, 39, 119, 0.03) 0, transparent 50%),
|
||||
radial-gradient(at 40% 80%, rgba(5, 150, 105, 0.03) 0, transparent 50%);
|
||||
}
|
||||
|
||||
/* Gradient text */
|
||||
.text-gradient {
|
||||
background: linear-gradient(135deg, var(--color-brand-primary) 0%, #7c3aed 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* Premium stat card - always-visible gradient top bar */
|
||||
/* Stat card accent — subtle top border, no gradient */
|
||||
.stat-card-premium {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
@@ -498,20 +483,20 @@ textarea {
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
opacity: 1;
|
||||
height: 2px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.stat-card-premium.accent-primary::before {
|
||||
background: linear-gradient(90deg, #4f46e5, #7c3aed);
|
||||
background: #0d9488;
|
||||
}
|
||||
.stat-card-premium.accent-secondary::before {
|
||||
background: linear-gradient(90deg, #db2777, #ec4899);
|
||||
background: #db2777;
|
||||
}
|
||||
.stat-card-premium.accent-tertiary::before {
|
||||
background: linear-gradient(90deg, #f59e0b, #fbbf24);
|
||||
background: #f59e0b;
|
||||
}
|
||||
.stat-card-premium.accent-quaternary::before {
|
||||
background: linear-gradient(90deg, #059669, #34d399);
|
||||
background: #059669;
|
||||
}
|
||||
|
||||
/* Section card - premium container */
|
||||
@@ -519,25 +504,24 @@ textarea {
|
||||
background: white;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 1rem;
|
||||
overflow: hidden;
|
||||
overflow: clip;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
transition: box-shadow 0.3s ease;
|
||||
}
|
||||
.section-card:hover {
|
||||
box-shadow: 0 4px 20px -4px rgba(0, 0, 0, 0.08);
|
||||
box-shadow: 0 2px 8px -2px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.section-card-header {
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: linear-gradient(180deg, rgba(249, 250, 251, 0.5) 0%, white 100%);
|
||||
}
|
||||
|
||||
/* Sidebar active glow */
|
||||
.sidebar-active-glow {
|
||||
box-shadow: inset 3px 0 0 rgba(129, 140, 248, 0.8);
|
||||
box-shadow: inset 3px 0 0 rgba(20, 184, 166, 0.8);
|
||||
}
|
||||
[dir="rtl"] .sidebar-active-glow {
|
||||
box-shadow: inset -3px 0 0 rgba(129, 140, 248, 0.8);
|
||||
box-shadow: inset -3px 0 0 rgba(20, 184, 166, 0.8);
|
||||
}
|
||||
|
||||
/* Refined button styles */
|
||||
@@ -594,23 +578,6 @@ select:not(:disabled):hover {
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
}
|
||||
|
||||
/* Ripple effect on buttons (optional enhancement) */
|
||||
@keyframes ripple {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
opacity: 0.5;
|
||||
}
|
||||
100% {
|
||||
transform: scale(2.5);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Badge pulse animation */
|
||||
.badge-pulse {
|
||||
animation: pulse-subtle 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Smooth height transitions */
|
||||
.transition-height {
|
||||
transition: height 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useToast } from '../components/ToastContainer'
|
||||
import ArtefactVersionTimeline from '../components/ArtefactVersionTimeline'
|
||||
import { SkeletonCard, SkeletonTable } from '../components/SkeletonLoader'
|
||||
import ArtefactDetailPanel from '../components/ArtefactDetailPanel'
|
||||
import ApproverMultiSelect from '../components/ApproverMultiSelect'
|
||||
import PortalSelect from '../components/PortalSelect'
|
||||
|
||||
const STATUS_COLORS = {
|
||||
draft: 'bg-surface-tertiary text-text-secondary',
|
||||
@@ -56,7 +56,7 @@ export default function Artefacts() {
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const [selectedArtefact, setSelectedArtefact] = useState(null)
|
||||
const [newArtefact, setNewArtefact] = useState({ title: '', description: '', type: 'copy', brand_id: '', content: '', project_id: '', campaign_id: '', approver_ids: [] })
|
||||
const [newArtefact, setNewArtefact] = useState({ title: '', type: 'copy' })
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
// Bulk select
|
||||
@@ -101,12 +101,12 @@ export default function Artefacts() {
|
||||
setSaving(true)
|
||||
try {
|
||||
const created = await api.post('/artefacts', {
|
||||
...newArtefact,
|
||||
approver_ids: newArtefact.approver_ids.length > 0 ? newArtefact.approver_ids.join(',') : null,
|
||||
title: newArtefact.title,
|
||||
type: newArtefact.type,
|
||||
})
|
||||
toast.success(t('artefacts.created'))
|
||||
setShowCreateModal(false)
|
||||
setNewArtefact({ title: '', description: '', type: 'copy', brand_id: '', content: '', project_id: '', campaign_id: '', approver_ids: [] })
|
||||
setNewArtefact({ title: '', type: 'copy' })
|
||||
loadArtefacts()
|
||||
setSelectedArtefact(created)
|
||||
} catch (err) {
|
||||
@@ -199,8 +199,8 @@ export default function Artefacts() {
|
||||
const SortIcon = ({ col }) => {
|
||||
if (listSortBy !== col) return null
|
||||
return listSortDir === 'asc'
|
||||
? <ChevronUp className="w-3 h-3 inline ml-0.5" />
|
||||
: <ChevronDown className="w-3 h-3 inline ml-0.5" />
|
||||
? <ChevronUp className="w-3 h-3 inline ms-0.5" />
|
||||
: <ChevronDown className="w-3 h-3 inline ms-0.5" />
|
||||
}
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
@@ -211,11 +211,7 @@ export default function Artefacts() {
|
||||
return (
|
||||
<div className="space-y-4 animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-primary">{t('artefacts.title')}</h1>
|
||||
<p className="text-sm text-text-secondary mt-1">{t('artefacts.subtitle')}</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-end">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* View switcher */}
|
||||
<div className="flex items-center bg-surface-tertiary rounded-lg p-0.5">
|
||||
@@ -228,7 +224,7 @@ export default function Artefacts() {
|
||||
onClick={() => setViewMode(mode)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
|
||||
viewMode === mode
|
||||
? 'bg-white text-text-primary shadow-sm'
|
||||
? 'bg-surface text-text-primary shadow-sm'
|
||||
: 'text-text-tertiary hover:text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
@@ -251,13 +247,13 @@ export default function Artefacts() {
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="relative flex-1 min-w-[200px] max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
|
||||
<Search className="absolute start-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('artefacts.searchArtefacts')}
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface transition-colors"
|
||||
className="w-full ps-10 pe-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -351,7 +347,7 @@ export default function Artefacts() {
|
||||
<button
|
||||
key={artefact.Id}
|
||||
onClick={() => setSelectedArtefact(artefact)}
|
||||
className="bg-surface border border-border rounded-xl p-4 hover:border-brand-primary/30 hover:shadow-sm transition-colors text-left"
|
||||
className="bg-surface border border-border rounded-xl p-4 hover:border-brand-primary/30 hover:shadow-sm transition-colors text-start"
|
||||
>
|
||||
<div className="flex items-start gap-3 mb-2">
|
||||
<div className="w-10 h-10 rounded-lg bg-brand-primary/10 flex items-center justify-center shrink-0">
|
||||
@@ -418,22 +414,22 @@ export default function Artefacts() {
|
||||
<th className="w-10 px-3 py-3" onClick={e => e.stopPropagation()}>
|
||||
<input type="checkbox" checked={selectedIds.size === sortedArtefacts.length && sortedArtefacts.length > 0} onChange={toggleSelectAll} className="rounded border-border" />
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleListSort('title')}>
|
||||
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleListSort('title')}>
|
||||
{t('artefacts.titleLabel')} <SortIcon col="title" />
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleListSort('type')}>
|
||||
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleListSort('type')}>
|
||||
{t('artefacts.type')} <SortIcon col="type" />
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleListSort('status')}>
|
||||
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleListSort('status')}>
|
||||
{t('artefacts.status')} <SortIcon col="status" />
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('artefacts.brand')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('artefacts.project')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('artefacts.campaign')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('artefacts.creator')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('artefacts.approvers')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('artefacts.version')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleListSort('updated_at')}>
|
||||
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('artefacts.brand')}</th>
|
||||
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('artefacts.project')}</th>
|
||||
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('artefacts.campaign')}</th>
|
||||
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('artefacts.creator')}</th>
|
||||
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('artefacts.approvers')}</th>
|
||||
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('artefacts.version')}</th>
|
||||
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleListSort('updated_at')}>
|
||||
{t('artefacts.updated')} <SortIcon col="updated_at" />
|
||||
</th>
|
||||
</tr>
|
||||
@@ -484,7 +480,7 @@ export default function Artefacts() {
|
||||
)}
|
||||
|
||||
{/* Create Modal */}
|
||||
<Modal isOpen={showCreateModal} onClose={() => setShowCreateModal(false)} title={t('artefacts.createArtefact')} size="md">
|
||||
<Modal isOpen={showCreateModal} onClose={() => setShowCreateModal(false)} title={t('artefacts.createArtefact')} size="sm">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('artefacts.titleLabel')} *</label>
|
||||
@@ -494,67 +490,16 @@ export default function Artefacts() {
|
||||
onChange={e => setNewArtefact(f => ({ ...f, title: e.target.value }))}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
placeholder={t('artefacts.titlePlaceholder')}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('artefacts.type')}</label>
|
||||
<select
|
||||
<PortalSelect
|
||||
value={newArtefact.type}
|
||||
onChange={e => setNewArtefact(f => ({ ...f, type: 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"
|
||||
>
|
||||
{TYPES.map(t => <option key={t} value={t}>{t.charAt(0).toUpperCase() + t.slice(1)}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('artefacts.brand')}</label>
|
||||
<select
|
||||
value={newArtefact.brand_id}
|
||||
onChange={e => setNewArtefact(f => ({ ...f, brand_id: e.target.value }))}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
>
|
||||
<option value="">—</option>
|
||||
{brands.map(b => <option key={b._id} value={b._id}>{b.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('artefacts.project')}</label>
|
||||
<select
|
||||
value={newArtefact.project_id}
|
||||
onChange={e => setNewArtefact(f => ({ ...f, project_id: e.target.value }))}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
>
|
||||
<option value="">—</option>
|
||||
{projects.map(p => <option key={p.Id || p._id || p.id} value={p.Id || p._id || p.id}>{p.name || p.title}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('artefacts.campaign')}</label>
|
||||
<select
|
||||
value={newArtefact.campaign_id}
|
||||
onChange={e => setNewArtefact(f => ({ ...f, campaign_id: e.target.value }))}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
>
|
||||
<option value="">—</option>
|
||||
{campaigns.map(c => <option key={c.Id || c._id || c.id} value={c.Id || c._id || c.id}>{c.name || c.title}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('artefacts.approvers')}</label>
|
||||
<ApproverMultiSelect
|
||||
users={assignableUsers}
|
||||
selected={newArtefact.approver_ids}
|
||||
onChange={ids => setNewArtefact(f => ({ ...f, approver_ids: ids }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('artefacts.description')}</label>
|
||||
<textarea
|
||||
value={newArtefact.description}
|
||||
onChange={e => setNewArtefact(f => ({ ...f, description: e.target.value }))}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
placeholder={t('artefacts.descriptionPlaceholder')}
|
||||
onChange={val => setNewArtefact(f => ({ ...f, type: val }))}
|
||||
options={TYPES.map(t => ({ value: t, label: t.charAt(0).toUpperCase() + t.slice(1) }))}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface text-text-primary"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
|
||||
@@ -595,8 +540,6 @@ export default function Artefacts() {
|
||||
onClose={() => setSelectedArtefact(null)}
|
||||
onUpdate={loadArtefacts}
|
||||
onDelete={canDeleteResource('artefact', selectedArtefact) ? handleDelete : undefined}
|
||||
projects={projects}
|
||||
campaigns={campaigns}
|
||||
assignableUsers={assignableUsers}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -181,20 +181,20 @@ export default function Assets() {
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="relative flex-1 min-w-[200px] max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
|
||||
<Search className="absolute start-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search assets..."
|
||||
value={filters.search}
|
||||
onChange={e => setFilters(f => ({ ...f, search: e.target.value }))}
|
||||
className="w-full pl-10 pr-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-white"
|
||||
className="w-full ps-10 pe-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={filters.brand}
|
||||
onChange={e => setFilters(f => ({ ...f, brand: e.target.value }))}
|
||||
className="text-sm border border-border rounded-lg px-3 py-2 bg-white text-text-secondary focus:outline-none"
|
||||
className="text-sm border border-border rounded-lg px-3 py-2 bg-surface text-text-secondary focus:outline-none"
|
||||
>
|
||||
<option value="">All Brands</option>
|
||||
{brands.map(b => <option key={b} value={b}>{b}</option>)}
|
||||
@@ -203,7 +203,7 @@ export default function Assets() {
|
||||
<select
|
||||
value={filters.tag}
|
||||
onChange={e => setFilters(f => ({ ...f, tag: e.target.value }))}
|
||||
className="text-sm border border-border rounded-lg px-3 py-2 bg-white text-text-secondary focus:outline-none"
|
||||
className="text-sm border border-border rounded-lg px-3 py-2 bg-surface text-text-secondary focus:outline-none"
|
||||
>
|
||||
<option value="">All Tags</option>
|
||||
{allTags.map(t => <option key={t} value={t}>{t}</option>)}
|
||||
@@ -211,7 +211,7 @@ export default function Assets() {
|
||||
|
||||
<button
|
||||
onClick={() => setShowUpload(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm ml-auto"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm ms-auto"
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
Upload
|
||||
@@ -260,7 +260,7 @@ export default function Assets() {
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4 stagger-children">
|
||||
{filteredAssets.map(asset => (
|
||||
<div key={asset._id || asset.id} className="relative">
|
||||
<div className="absolute top-2 left-2 z-10" onClick={e => e.stopPropagation()}>
|
||||
<div className="absolute top-2 start-2 z-10" onClick={e => e.stopPropagation()}>
|
||||
<input type="checkbox" checked={selectedIds.has(asset._id || asset.id)} onChange={() => toggleSelect(asset._id || asset.id)} className="rounded border-border" />
|
||||
</div>
|
||||
<AssetCard asset={asset} onClick={setSelectedAsset} />
|
||||
@@ -319,7 +319,7 @@ export default function Assets() {
|
||||
<div className="space-y-4">
|
||||
{selectedAsset.type === 'image' && selectedAsset.url && (
|
||||
<div className="rounded-lg overflow-hidden bg-surface-tertiary">
|
||||
<img src={selectedAsset.url} alt={selectedAsset.name} className="w-full max-h-[400px] object-contain" />
|
||||
<img src={selectedAsset.url} alt={selectedAsset.name} className="w-full max-h-[400px] object-contain" loading="lazy" />
|
||||
</div>
|
||||
)}
|
||||
{selectedAsset.type === 'video' && selectedAsset.url && (
|
||||
@@ -374,7 +374,7 @@ export default function Assets() {
|
||||
download={selectedAsset.name}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="ml-auto px-4 py-2 text-sm font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light"
|
||||
className="ms-auto px-4 py-2 text-sm font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light"
|
||||
>
|
||||
Download
|
||||
</a>
|
||||
|
||||
@@ -143,7 +143,7 @@ export default function Brands() {
|
||||
|
||||
{/* Brand Cards Grid */}
|
||||
{brands.length === 0 ? (
|
||||
<div className="bg-white rounded-xl border border-border py-16 text-center">
|
||||
<div className="bg-surface rounded-xl border border-border py-16 text-center">
|
||||
<Tag className="w-12 h-12 text-text-quaternary mx-auto mb-3" />
|
||||
<p className="text-sm text-text-tertiary">{t('brands.noBrands')}</p>
|
||||
</div>
|
||||
@@ -154,7 +154,7 @@ export default function Brands() {
|
||||
return (
|
||||
<div
|
||||
key={getBrandId(brand)}
|
||||
className={`bg-white rounded-xl border border-border overflow-hidden hover:shadow-md transition-all aspect-square flex flex-col ${isSuperadminOrManager ? 'cursor-pointer' : ''}`}
|
||||
className={`bg-surface rounded-xl border border-border overflow-clip hover:shadow-md transition-all aspect-square flex flex-col ${isSuperadminOrManager ? 'cursor-pointer' : ''}`}
|
||||
onClick={() => isSuperadminOrManager && openEditBrand(brand)}
|
||||
>
|
||||
{/* Logo area */}
|
||||
@@ -164,6 +164,7 @@ export default function Brands() {
|
||||
src={`${API_BASE}/uploads/${brand.logo}`}
|
||||
alt={displayName}
|
||||
className="w-full h-full object-contain p-4"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-3xl">
|
||||
@@ -171,17 +172,17 @@ export default function Brands() {
|
||||
</div>
|
||||
)}
|
||||
{isSuperadminOrManager && (
|
||||
<div className="absolute top-1.5 right-1.5 flex gap-1 opacity-0 group-hover:opacity-100" onClick={e => e.stopPropagation()}>
|
||||
<div className="absolute top-1.5 end-1.5 flex gap-1 opacity-0 group-hover:opacity-100" onClick={e => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={() => openEditBrand(brand)}
|
||||
className="p-1 bg-white/90 hover:bg-white rounded-md text-text-tertiary hover:text-text-primary shadow-sm"
|
||||
className="p-1 bg-white/90 hover:bg-surface rounded-md text-text-tertiary hover:text-text-primary shadow-sm"
|
||||
title={t('common.edit')}
|
||||
>
|
||||
<Edit2 className="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setBrandToDelete(brand); setShowDeleteModal(true) }}
|
||||
className="p-1 bg-white/90 hover:bg-white rounded-md text-text-tertiary hover:text-red-500 shadow-sm"
|
||||
className="p-1 bg-white/90 hover:bg-surface rounded-md text-text-tertiary hover:text-red-500 shadow-sm"
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
@@ -269,6 +270,7 @@ export default function Brands() {
|
||||
src={`${API_BASE}/uploads/${editingBrand.logo}`}
|
||||
alt="Logo"
|
||||
className="h-16 object-contain"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -153,11 +153,7 @@ export default function Budgets() {
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-primary">{t('budgets.title')}</h1>
|
||||
<p className="text-sm text-text-tertiary mt-0.5">{t('budgets.subtitle')}</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-end">
|
||||
{canManageFinance && (
|
||||
<button
|
||||
onClick={() => { setEditing(null); setForm(EMPTY_ENTRY); setShowModal(true) }}
|
||||
@@ -171,19 +167,19 @@ export default function Budgets() {
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="relative flex-1 min-w-[200px] max-w-xs">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
|
||||
<Search className="absolute start-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
placeholder={t('budgets.searchEntries')}
|
||||
className="w-full pl-9 pr-3 py-2 text-sm border border-border rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||
className="w-full ps-9 pe-3 py-2 text-sm border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={filterCategory}
|
||||
onChange={e => setFilterCategory(e.target.value)}
|
||||
className="px-3 py-2 text-sm border border-border rounded-lg bg-white focus:outline-none"
|
||||
className="px-3 py-2 text-sm border border-border rounded-lg bg-surface focus:outline-none"
|
||||
>
|
||||
<option value="">{t('budgets.allCategories')}</option>
|
||||
{CATEGORIES.map(c => <option key={c.value} value={c.value}>{c.label}</option>)}
|
||||
@@ -191,7 +187,7 @@ export default function Budgets() {
|
||||
<select
|
||||
value={filterDestination}
|
||||
onChange={e => setFilterDestination(e.target.value)}
|
||||
className="px-3 py-2 text-sm border border-border rounded-lg bg-white focus:outline-none"
|
||||
className="px-3 py-2 text-sm border border-border rounded-lg bg-surface focus:outline-none"
|
||||
>
|
||||
<option value="">{t('budgets.allDestinations')}</option>
|
||||
{DESTINATIONS.map(d => <option key={d.value} value={d.value}>{t(d.labelKey)}</option>)}
|
||||
@@ -206,7 +202,7 @@ export default function Budgets() {
|
||||
className={`px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
filterType === opt.value
|
||||
? opt.value === 'expense' ? 'bg-red-500 text-white' : opt.value === 'income' ? 'bg-emerald-500 text-white' : 'bg-brand-primary text-white'
|
||||
: 'bg-white text-text-secondary hover:bg-surface-secondary'
|
||||
: 'bg-surface text-text-secondary hover:bg-surface-secondary'
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
@@ -215,7 +211,7 @@ export default function Budgets() {
|
||||
</div>
|
||||
|
||||
{filteredEntries.length > 0 && (
|
||||
<div className="ml-auto flex items-center gap-3 text-sm text-text-tertiary">
|
||||
<div className="ms-auto flex items-center gap-3 text-sm text-text-tertiary">
|
||||
<span>{filteredEntries.length} {filteredEntries.length === 1 ? 'entry' : 'entries'}</span>
|
||||
<span className="text-emerald-600 font-semibold">+{totalIncome.toLocaleString()}</span>
|
||||
{totalExpenseAmt > 0 && <span className="text-red-500 font-semibold">-{totalExpenseAmt.toLocaleString()}</span>}
|
||||
@@ -235,12 +231,12 @@ export default function Budgets() {
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface-secondary">
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-text-tertiary">{t('budgets.label')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-text-tertiary">{t('budgets.source')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-text-tertiary">{t('budgets.destination')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-text-tertiary">{t('budgets.linkedTo')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-text-tertiary">{t('budgets.date')}</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">{t('budgets.amount')}</th>
|
||||
<th className="px-4 py-3 text-start text-xs font-medium text-text-tertiary">{t('budgets.label')}</th>
|
||||
<th className="px-4 py-3 text-start text-xs font-medium text-text-tertiary">{t('budgets.source')}</th>
|
||||
<th className="px-4 py-3 text-start text-xs font-medium text-text-tertiary">{t('budgets.destination')}</th>
|
||||
<th className="px-4 py-3 text-start text-xs font-medium text-text-tertiary">{t('budgets.linkedTo')}</th>
|
||||
<th className="px-4 py-3 text-start text-xs font-medium text-text-tertiary">{t('budgets.date')}</th>
|
||||
<th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">{t('budgets.amount')}</th>
|
||||
{canManageFinance && <th className="px-4 py-3 w-20" />}
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -289,7 +285,7 @@ export default function Budgets() {
|
||||
<td className="px-4 py-3 text-text-secondary whitespace-nowrap">
|
||||
{entry.date_received ? format(new Date(entry.date_received), 'MMM d, yyyy') : '--'}
|
||||
</td>
|
||||
<td className={`px-4 py-3 text-right font-semibold whitespace-nowrap ${
|
||||
<td className={`px-4 py-3 text-end font-semibold whitespace-nowrap ${
|
||||
(entry.type || 'income') === 'expense' ? 'text-red-500' : 'text-emerald-600'
|
||||
}`}>
|
||||
{(entry.type || 'income') === 'expense' ? '-' : '+'}{Number(entry.amount).toLocaleString()} {currencySymbol}
|
||||
@@ -332,7 +328,7 @@ export default function Budgets() {
|
||||
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium border-2 transition-all ${
|
||||
form.type === 'income'
|
||||
? 'border-emerald-500 bg-emerald-50 text-emerald-700'
|
||||
: 'border-border bg-white text-text-secondary hover:bg-surface-secondary'
|
||||
: 'border-border bg-surface text-text-secondary hover:bg-surface-secondary'
|
||||
}`}
|
||||
>
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
@@ -344,7 +340,7 @@ export default function Budgets() {
|
||||
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium border-2 transition-all ${
|
||||
form.type === 'expense'
|
||||
? 'border-red-500 bg-red-50 text-red-700'
|
||||
: 'border-border bg-white text-text-secondary hover:bg-surface-secondary'
|
||||
: 'border-border bg-surface text-text-secondary hover:bg-surface-secondary'
|
||||
}`}
|
||||
>
|
||||
<TrendingDown className="w-4 h-4" />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useContext } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { ArrowLeft, Plus, Edit2, Trash2, DollarSign, Eye, MousePointer, Target, TrendingUp, FileText, Megaphone, Search, Globe, Pencil, Users, X, UserPlus, MessageCircle, Settings } from 'lucide-react'
|
||||
import { ArrowLeft, Plus, Edit2, Trash2, DollarSign, Eye, MousePointer, Target, TrendingUp, FileText, Megaphone, Search, Globe, Users, X, MessageCircle, Settings } from 'lucide-react'
|
||||
import { format } from 'date-fns'
|
||||
import { AppContext } from '../App'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
@@ -14,7 +14,6 @@ import BudgetBar from '../components/BudgetBar'
|
||||
import CommentsSection from '../components/CommentsSection'
|
||||
import CampaignDetailPanel from '../components/CampaignDetailPanel'
|
||||
import TrackDetailPanel from '../components/TrackDetailPanel'
|
||||
import PostDetailPanel from '../components/PostDetailPanel'
|
||||
|
||||
const TRACK_TYPES = {
|
||||
organic_social: { label: 'Organic Social', icon: Megaphone, color: 'text-green-600 bg-green-50', hasBudget: false },
|
||||
@@ -26,21 +25,11 @@ const TRACK_TYPES = {
|
||||
|
||||
const TRACK_STATUSES = ['planned', 'active', 'paused', 'completed']
|
||||
|
||||
function MetricBox({ label, value, icon: Icon, color = 'text-text-primary' }) {
|
||||
return (
|
||||
<div className="text-center">
|
||||
<Icon className={`w-4 h-4 mx-auto mb-0.5 ${color}`} />
|
||||
<div className={`text-base font-bold ${color}`}>{value ?? '—'}</div>
|
||||
<div className="text-[10px] text-text-tertiary">{label}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function CampaignDetail() {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { brands, getBrandName, teamMembers } = useContext(AppContext)
|
||||
const { lang, currencySymbol } = useLanguage()
|
||||
const { t, lang, currencySymbol } = useLanguage()
|
||||
const { permissions, user } = useAuth()
|
||||
const isSuperadmin = user?.role === 'superadmin'
|
||||
const [campaign, setCampaign] = useState(null)
|
||||
@@ -56,7 +45,6 @@ export default function CampaignDetail() {
|
||||
const [budgetValue, setBudgetValue] = useState('')
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [trackToDelete, setTrackToDelete] = useState(null)
|
||||
const [selectedPost, setSelectedPost] = useState(null)
|
||||
const [showDiscussion, setShowDiscussion] = useState(false)
|
||||
const [allCampaigns, setAllCampaigns] = useState([])
|
||||
|
||||
@@ -163,21 +151,6 @@ export default function CampaignDetail() {
|
||||
loadAll()
|
||||
}
|
||||
|
||||
const handlePostPanelSave = async (postId, data) => {
|
||||
if (postId) {
|
||||
await api.patch(`/posts/${postId}`, data)
|
||||
} else {
|
||||
await api.post('/posts', data)
|
||||
}
|
||||
loadAll()
|
||||
}
|
||||
|
||||
const handlePostPanelDelete = async (postId) => {
|
||||
await api.delete(`/posts/${postId}`)
|
||||
setSelectedPost(null)
|
||||
loadAll()
|
||||
}
|
||||
|
||||
const deleteTrack = async (trackId) => {
|
||||
setTrackToDelete(trackId)
|
||||
setShowDeleteConfirm(true)
|
||||
@@ -211,7 +184,7 @@ export default function CampaignDetail() {
|
||||
if (!campaign) {
|
||||
return (
|
||||
<div className="text-center py-12 text-text-tertiary">
|
||||
Campaign not found. <button onClick={() => navigate('/campaigns')} className="text-brand-primary underline">Go back</button>
|
||||
{t('campaigns.notFound')} <button onClick={() => navigate('/campaigns')} className="text-brand-primary underline">{t('common.goBack')}</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -244,9 +217,6 @@ export default function CampaignDetail() {
|
||||
{campaign.start_date && campaign.end_date && (
|
||||
<span>{format(new Date(campaign.start_date), 'MMM d')} – {format(new Date(campaign.end_date), 'MMM d, yyyy')}</span>
|
||||
)}
|
||||
<span>
|
||||
Budget: {campaign.budget > 0 ? `${campaign.budget.toLocaleString()} ${currencySymbol}` : 'Not set'}
|
||||
</span>
|
||||
{campaign.platforms && campaign.platforms.length > 0 && (
|
||||
<PlatformIcons platforms={campaign.platforms} size={16} />
|
||||
)}
|
||||
@@ -263,109 +233,73 @@ export default function CampaignDetail() {
|
||||
}`}
|
||||
>
|
||||
<MessageCircle className="w-4 h-4" />
|
||||
Discussion
|
||||
{t('campaigns.discussion')}
|
||||
</button>
|
||||
{canSetBudget && (
|
||||
<button
|
||||
onClick={() => { setBudgetValue(campaign.budget || ''); setEditingBudget(true) }}
|
||||
className="flex items-center gap-1.5 px-3 py-2 bg-surface-tertiary text-text-secondary hover:bg-surface-tertiary/80 hover:text-text-primary rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
<DollarSign className="w-4 h-4" />
|
||||
Budget
|
||||
</button>
|
||||
)}
|
||||
{canManage && (
|
||||
<button
|
||||
onClick={() => setPanelCampaign(campaign)}
|
||||
className="flex items-center gap-1.5 px-3 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm transition-colors"
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
Edit
|
||||
{t('common.edit')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Assigned Team */}
|
||||
<div className="bg-white rounded-xl border border-border p-5">
|
||||
{/* Budget Card */}
|
||||
<div className="bg-surface rounded-xl border border-border p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium flex items-center gap-1.5">
|
||||
<Users className="w-3.5 h-3.5" /> Assigned Team
|
||||
</h3>
|
||||
{canAssign && (
|
||||
<button
|
||||
onClick={openAssignModal}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-brand-primary text-white rounded-lg text-xs font-medium hover:bg-brand-primary-light"
|
||||
>
|
||||
<UserPlus className="w-3.5 h-3.5" /> Assign Members
|
||||
<h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium">{t('campaigns.budget')}</h3>
|
||||
{canSetBudget && (
|
||||
<button onClick={() => { setBudgetValue(campaign.budget || ''); setEditingBudget(true) }}
|
||||
className="text-xs text-brand-primary hover:text-brand-primary-light font-medium">
|
||||
{t('common.edit')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{assignments.length === 0 ? (
|
||||
<p className="text-xs text-text-tertiary py-2">No team members assigned yet.</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{assignments.map(a => (
|
||||
<div key={a.user_id} className="flex items-center gap-2 bg-surface-secondary rounded-full pl-1 pr-2 py-1">
|
||||
<div className="w-6 h-6 rounded-full bg-brand-primary/10 text-brand-primary flex items-center justify-center text-[10px] font-bold shrink-0">
|
||||
{a.user_avatar ? (
|
||||
<img src={a.user_avatar} className="w-full h-full rounded-full object-cover" alt="" />
|
||||
) : (
|
||||
getInitials(a.user_name)
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs font-medium text-text-primary">{a.user_name}</span>
|
||||
{canAssign && (
|
||||
<button
|
||||
onClick={() => removeAssignment(a.user_id)}
|
||||
className="p-0.5 rounded-full hover:bg-red-100 text-text-tertiary hover:text-red-500"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</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 className="flex items-baseline gap-2 mb-3">
|
||||
<span className="text-2xl font-bold text-text-primary">
|
||||
{totalAllocated.toLocaleString()} {currencySymbol}
|
||||
</span>
|
||||
<span className="text-sm text-text-tertiary">{t('finance.allocated')}</span>
|
||||
</div>
|
||||
{totalAllocated > 0 && (
|
||||
<div className="mt-4">
|
||||
<BudgetBar budget={totalAllocated} spent={totalSpent} height="h-2" />
|
||||
<>
|
||||
<BudgetBar budget={totalAllocated} spent={totalSpent} height="h-2.5" />
|
||||
<div className="flex justify-between mt-2 text-xs text-text-tertiary">
|
||||
<span>{totalSpent.toLocaleString()} {currencySymbol} {t('dashboard.spent')}</span>
|
||||
<span>{(totalAllocated - totalSpent).toLocaleString()} {currencySymbol} {t('dashboard.remaining')}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{(totalImpressions > 0 || totalClicks > 0) && (
|
||||
<div className="flex items-center gap-4 mt-4 pt-3 border-t border-border-light text-xs text-text-secondary">
|
||||
<span><Eye className="w-3.5 h-3.5 inline me-1" />{totalImpressions.toLocaleString()}</span>
|
||||
<span><MousePointer className="w-3.5 h-3.5 inline me-1" />{totalClicks.toLocaleString()}</span>
|
||||
{totalConversions > 0 && <span><Target className="w-3.5 h-3.5 inline me-1" />{totalConversions.toLocaleString()}</span>}
|
||||
{totalRevenue > 0 && <span><DollarSign className="w-3.5 h-3.5 inline me-1" />{totalRevenue.toLocaleString()} {currencySymbol}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tracks */}
|
||||
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
||||
<div className="bg-surface rounded-xl border border-border overflow-clip">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
|
||||
<h3 className="font-semibold text-text-primary">Tracks</h3>
|
||||
<h3 className="font-semibold text-text-primary">{t('campaigns.tracks')}</h3>
|
||||
{canManage && (
|
||||
<button
|
||||
onClick={() => { setPanelTrack({}); setTrackScrollToMetrics(false) }}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-brand-primary text-white rounded-lg text-xs font-medium hover:bg-brand-primary-light"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" /> Add Track
|
||||
<Plus className="w-3.5 h-3.5" /> {t('campaigns.addTrack')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{tracks.length === 0 ? (
|
||||
<div className="py-12 text-center text-sm text-text-tertiary">
|
||||
No tracks yet. Add organic, paid, or SEO tracks to organize this campaign.
|
||||
{t('campaigns.noTracks')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border-light">
|
||||
@@ -403,9 +337,9 @@ export default function CampaignDetail() {
|
||||
{/* Quick metrics */}
|
||||
{(track.impressions > 0 || track.clicks > 0 || track.conversions > 0) && (
|
||||
<div className="flex items-center gap-3 mt-1.5 text-[10px] text-text-tertiary">
|
||||
{track.impressions > 0 && <span>👁 {track.impressions.toLocaleString()}</span>}
|
||||
{track.clicks > 0 && <span>🖱 {track.clicks.toLocaleString()}</span>}
|
||||
{track.conversions > 0 && <span>🎯 {track.conversions.toLocaleString()}</span>}
|
||||
{track.impressions > 0 && <span><Eye className="w-3 h-3 inline" /> {track.impressions.toLocaleString()}</span>}
|
||||
{track.clicks > 0 && <span><MousePointer className="w-3 h-3 inline" /> {track.clicks.toLocaleString()}</span>}
|
||||
{track.conversions > 0 && <span><Target className="w-3 h-3 inline" /> {track.conversions.toLocaleString()}</span>}
|
||||
{track.clicks > 0 && track.budget_spent > 0 && (
|
||||
<span>CPC: {(track.budget_spent / track.clicks).toFixed(2)} {currencySymbol}</span>
|
||||
)}
|
||||
@@ -418,7 +352,7 @@ export default function CampaignDetail() {
|
||||
{/* Linked posts count */}
|
||||
{trackPosts.length > 0 && (
|
||||
<div className="text-[10px] text-text-tertiary mt-1">
|
||||
📝 {trackPosts.length} post{trackPosts.length !== 1 ? 's' : ''} linked
|
||||
<FileText className="w-3 h-3 inline" /> {trackPosts.length} {t('campaigns.postsLinked')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -461,21 +395,41 @@ export default function CampaignDetail() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Team */}
|
||||
{(assignments.length > 0 || canAssign) && (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-text-tertiary font-medium">{t('campaigns.team')}:</span>
|
||||
<div className="flex -space-x-1.5">
|
||||
{assignments.slice(0, 6).map(a => (
|
||||
<div key={a.user_id} className="w-7 h-7 rounded-full bg-brand-primary/10 text-brand-primary flex items-center justify-center text-[10px] font-bold border-2 border-surface" title={a.user_name}>
|
||||
{a.user_avatar ? <img src={a.user_avatar} className="w-full h-full rounded-full object-cover" alt="" loading="lazy" /> : getInitials(a.user_name)}
|
||||
</div>
|
||||
))}
|
||||
{assignments.length > 6 && <div className="w-7 h-7 rounded-full bg-surface-tertiary flex items-center justify-center text-[10px] text-text-tertiary font-medium border-2 border-surface">+{assignments.length - 6}</div>}
|
||||
</div>
|
||||
{canAssign && (
|
||||
<button onClick={openAssignModal} className="text-xs text-brand-primary hover:text-brand-primary-light font-medium">
|
||||
{t('campaigns.assignMembers')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Linked Posts */}
|
||||
{posts.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
||||
<div className="bg-surface rounded-xl border border-border overflow-clip">
|
||||
<div className="px-5 py-4 border-b border-border">
|
||||
<h3 className="font-semibold text-text-primary">Linked Posts ({posts.length})</h3>
|
||||
<h3 className="font-semibold text-text-primary">{t('campaigns.linkedPosts')} ({posts.length})</h3>
|
||||
</div>
|
||||
<div className="divide-y divide-border-light">
|
||||
{posts.map(post => (
|
||||
<div
|
||||
key={post.id}
|
||||
onClick={() => setSelectedPost(post)}
|
||||
onClick={() => navigate(`/posts/${post._id || post.id || post.Id}`)}
|
||||
className="flex items-center gap-3 px-5 py-3 hover:bg-surface-secondary cursor-pointer transition-colors"
|
||||
>
|
||||
{post.thumbnail_url && (
|
||||
<img src={post.thumbnail_url} alt="" className="w-10 h-10 rounded-lg object-cover shrink-0" />
|
||||
<img src={post.thumbnail_url} alt="" className="w-10 h-10 rounded-lg object-cover shrink-0" loading="lazy" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -501,11 +455,11 @@ export default function CampaignDetail() {
|
||||
|
||||
{/* ─── DISCUSSION SIDEBAR ─── */}
|
||||
{showDiscussion && (
|
||||
<div className="w-[340px] shrink-0 bg-white rounded-xl border border-border flex flex-col self-start sticky top-4" style={{ maxHeight: 'calc(100vh - 6rem)' }}>
|
||||
<div className="w-[340px] shrink-0 bg-surface rounded-xl border border-border flex flex-col self-start sticky top-4" style={{ maxHeight: 'calc(100vh - 6rem)' }}>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-1.5">
|
||||
<MessageCircle className="w-4 h-4" />
|
||||
Discussion
|
||||
{t('campaigns.discussion')}
|
||||
</h3>
|
||||
<button onClick={() => setShowDiscussion(false)} className="p-1 hover:bg-surface-tertiary rounded-lg text-text-tertiary">
|
||||
<X className="w-4 h-4" />
|
||||
@@ -557,7 +511,7 @@ export default function CampaignDetail() {
|
||||
/>
|
||||
<div className="w-7 h-7 rounded-full bg-brand-primary/10 text-brand-primary flex items-center justify-center text-[10px] font-bold shrink-0">
|
||||
{u.avatar ? (
|
||||
<img src={u.avatar} className="w-full h-full rounded-full object-cover" alt="" />
|
||||
<img src={u.avatar} className="w-full h-full rounded-full object-cover" alt="" loading="lazy" />
|
||||
) : (
|
||||
getInitials(u.name)
|
||||
)}
|
||||
@@ -618,19 +572,6 @@ export default function CampaignDetail() {
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Post Detail Panel */}
|
||||
{selectedPost && (
|
||||
<PostDetailPanel
|
||||
post={selectedPost}
|
||||
onClose={() => setSelectedPost(null)}
|
||||
onSave={handlePostPanelSave}
|
||||
onDelete={handlePostPanelDelete}
|
||||
brands={brands}
|
||||
teamMembers={teamMembers}
|
||||
campaigns={allCampaigns}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Campaign Edit Panel */}
|
||||
{panelCampaign && (
|
||||
<CampaignDetailPanel
|
||||
|
||||
@@ -145,7 +145,7 @@ export default function Campaigns() {
|
||||
<select
|
||||
value={filters.brand}
|
||||
onChange={e => setFilters(f => ({ ...f, brand: e.target.value }))}
|
||||
className="text-sm border border-border rounded-lg px-3 py-2 bg-white text-text-secondary focus:outline-none"
|
||||
className="text-sm border border-border rounded-lg px-3 py-2 bg-surface text-text-secondary focus:outline-none"
|
||||
>
|
||||
<option value="">All Brands</option>
|
||||
{brands.map(b => <option key={b.id || b._id} value={b.id || b._id}>{lang === 'ar' && b.name_ar ? b.name_ar : b.name}</option>)}
|
||||
@@ -154,7 +154,7 @@ export default function Campaigns() {
|
||||
<select
|
||||
value={filters.status}
|
||||
onChange={e => setFilters(f => ({ ...f, status: e.target.value }))}
|
||||
className="text-sm border border-border rounded-lg px-3 py-2 bg-white text-text-secondary focus:outline-none"
|
||||
className="text-sm border border-border rounded-lg px-3 py-2 bg-surface text-text-secondary focus:outline-none"
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
<option value="planning">Planning</option>
|
||||
@@ -167,7 +167,7 @@ export default function Campaigns() {
|
||||
{permissions?.canCreateCampaigns && (
|
||||
<button
|
||||
onClick={openNew}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm ml-auto"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm ms-auto"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
New Campaign
|
||||
@@ -178,7 +178,7 @@ export default function Campaigns() {
|
||||
{/* Summary Cards */}
|
||||
{(totalBudget > 0 || totalSpent > 0) && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3 stagger-children">
|
||||
<div className="bg-white rounded-xl border border-border p-4">
|
||||
<div className="bg-surface rounded-xl border border-border p-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<DollarSign className="w-4 h-4 text-blue-500" />
|
||||
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Budget</span>
|
||||
@@ -186,7 +186,7 @@ export default function Campaigns() {
|
||||
<div className="text-lg font-bold text-text-primary">{totalBudget.toLocaleString()}</div>
|
||||
<div className="text-[10px] text-text-tertiary">{currencySymbol} total</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-border p-4">
|
||||
<div className="bg-surface rounded-xl border border-border p-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<TrendingUp className="w-4 h-4 text-amber-500" />
|
||||
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Spent</span>
|
||||
@@ -194,28 +194,28 @@ export default function Campaigns() {
|
||||
<div className="text-lg font-bold text-text-primary">{totalSpent.toLocaleString()}</div>
|
||||
<div className="text-[10px] text-text-tertiary">{currencySymbol} spent</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-border p-4">
|
||||
<div className="bg-surface rounded-xl border border-border p-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Eye className="w-4 h-4 text-purple-500" />
|
||||
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Impressions</span>
|
||||
</div>
|
||||
<div className="text-lg font-bold text-text-primary">{totalImpressions.toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-border p-4">
|
||||
<div className="bg-surface rounded-xl border border-border p-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<MousePointer className="w-4 h-4 text-green-500" />
|
||||
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Clicks</span>
|
||||
</div>
|
||||
<div className="text-lg font-bold text-text-primary">{totalClicks.toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-border p-4">
|
||||
<div className="bg-surface rounded-xl border border-border p-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Target className="w-4 h-4 text-red-500" />
|
||||
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Conversions</span>
|
||||
</div>
|
||||
<div className="text-lg font-bold text-text-primary">{totalConversions.toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-border p-4">
|
||||
<div className="bg-surface rounded-xl border border-border p-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<BarChart3 className="w-4 h-4 text-emerald-500" />
|
||||
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Revenue</span>
|
||||
@@ -264,7 +264,7 @@ export default function Campaigns() {
|
||||
/>
|
||||
|
||||
{/* Campaign list */}
|
||||
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
||||
<div className="bg-surface rounded-xl border border-border overflow-clip">
|
||||
<div className="px-5 py-4 border-b border-border">
|
||||
<h3 className="font-semibold text-text-primary">All Campaigns</h3>
|
||||
</div>
|
||||
@@ -308,7 +308,7 @@ export default function Campaigns() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<div className="text-end shrink-0">
|
||||
<StatusBadge status={campaign.status} size="xs" />
|
||||
<div className="text-xs text-text-tertiary mt-1">
|
||||
{campaign.startDate && campaign.endDate ? (
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { useContext, useEffect, useState, useMemo } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { format, isAfter, isBefore, addDays } from 'date-fns'
|
||||
import { FileText, Megaphone, AlertTriangle, ArrowRight, Clock, Wallet, TrendingUp, TrendingDown, DollarSign, Landmark, CheckSquare, FolderKanban } from 'lucide-react'
|
||||
import { FileText, Megaphone, AlertTriangle, ArrowRight, Clock, Landmark, CheckSquare, FolderKanban } from 'lucide-react'
|
||||
import { AppContext } from '../App'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import { api, PRIORITY_CONFIG } from '../utils/api'
|
||||
import StatCard from '../components/StatCard'
|
||||
import StatusBadge from '../components/StatusBadge'
|
||||
import BrandBadge from '../components/BrandBadge'
|
||||
import DatePresetPicker from '../components/DatePresetPicker'
|
||||
@@ -18,24 +17,17 @@ function getBudgetBarColor(percentage) {
|
||||
return 'bg-emerald-500'
|
||||
}
|
||||
|
||||
function FinanceMini({ finance }) {
|
||||
function BudgetSummary({ finance }) {
|
||||
const { t, currencySymbol } = useLanguage()
|
||||
if (!finance) return null
|
||||
const totalReceived = finance.totalReceived || 0
|
||||
const spent = finance.spent || 0
|
||||
const remaining = finance.remaining || 0
|
||||
const roi = finance.roi || 0
|
||||
const totalExpenses = finance.totalExpenses || 0
|
||||
const campaignBudget = finance.totalCampaignBudget || 0
|
||||
const projectBudget = finance.totalProjectBudget || 0
|
||||
const unallocated = finance.unallocated ?? (totalReceived - campaignBudget - projectBudget)
|
||||
const pct = totalReceived > 0 ? (spent / totalReceived) * 100 : 0
|
||||
const mainAvailable = finance.mainAvailable != null ? finance.mainAvailable : (finance.remaining || 0)
|
||||
const consumed = totalReceived - mainAvailable
|
||||
const pct = totalReceived > 0 ? (consumed / totalReceived) * 100 : 0
|
||||
const barColor = getBudgetBarColor(pct)
|
||||
const campPct = totalReceived > 0 ? (campaignBudget / totalReceived) * 100 : 0
|
||||
const projPct = totalReceived > 0 ? (projectBudget / totalReceived) * 100 : 0
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-border p-5">
|
||||
<div className="bg-surface rounded-xl border border-border p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold text-text-primary">{t('dashboard.budgetOverview')}</h3>
|
||||
<Link to="/finance" className="text-sm text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1">
|
||||
@@ -49,58 +41,15 @@ function FinanceMini({ finance }) {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Spending bar */}
|
||||
<div className="mb-3">
|
||||
<div className="flex justify-between text-xs text-text-tertiary mb-1">
|
||||
<span>{spent.toLocaleString()} {currencySymbol} {t('dashboard.spent')}</span>
|
||||
<span>{consumed.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>
|
||||
|
||||
{/* Allocation bar */}
|
||||
{(campaignBudget > 0 || projectBudget > 0) && (
|
||||
<div className="mb-3">
|
||||
<div className="text-[10px] text-text-tertiary mb-1 font-medium uppercase tracking-wide">Allocation</div>
|
||||
<div className="h-2 bg-surface-tertiary rounded-full overflow-hidden flex">
|
||||
{campPct > 0 && <div className="h-full bg-blue-500" style={{ width: `${campPct}%` }} />}
|
||||
{projPct > 0 && <div className="h-full bg-purple-500" style={{ width: `${projPct}%` }} />}
|
||||
</div>
|
||||
<div className="flex gap-3 mt-1 text-[10px] text-text-tertiary">
|
||||
{campaignBudget > 0 && <span><span className="inline-block w-1.5 h-1.5 rounded-full bg-blue-500 mr-1" />{campaignBudget.toLocaleString()}</span>}
|
||||
{projectBudget > 0 && <span><span className="inline-block w-1.5 h-1.5 rounded-full bg-purple-500 mr-1" />{projectBudget.toLocaleString()}</span>}
|
||||
{unallocated > 0 && <span><span className="inline-block w-1.5 h-1.5 rounded-full bg-gray-300 mr-1" />{unallocated.toLocaleString()} free</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Key numbers */}
|
||||
<div className={`grid ${totalExpenses > 0 ? 'grid-cols-3' : 'grid-cols-2'} gap-3`}>
|
||||
<div className="text-center p-2 bg-surface-secondary rounded-lg">
|
||||
<Landmark className="w-4 h-4 mx-auto mb-1 text-emerald-500" />
|
||||
<div className={`text-sm font-bold ${remaining >= 0 ? 'text-emerald-600' : 'text-red-600'}`}>
|
||||
{remaining.toLocaleString()}
|
||||
</div>
|
||||
<div className="text-[10px] text-text-tertiary">{t('dashboard.remaining')}</div>
|
||||
</div>
|
||||
{totalExpenses > 0 && (
|
||||
<div className="text-center p-2 bg-surface-secondary rounded-lg">
|
||||
<TrendingDown className="w-4 h-4 mx-auto mb-1 text-red-500" />
|
||||
<div className="text-sm font-bold text-red-600">
|
||||
{totalExpenses.toLocaleString()}
|
||||
</div>
|
||||
<div className="text-[10px] text-text-tertiary">{t('dashboard.expenses')}</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-center p-2 bg-surface-secondary rounded-lg">
|
||||
<TrendingUp className="w-4 h-4 mx-auto mb-1 text-blue-500" />
|
||||
<div className={`text-sm font-bold ${roi >= 0 ? 'text-emerald-600' : 'text-red-600'}`}>
|
||||
{roi.toFixed(0)}%
|
||||
</div>
|
||||
<div className="text-[10px] text-text-tertiary">{t('dashboard.roi')}</div>
|
||||
</div>
|
||||
<div className={`mt-3 text-sm font-semibold ${mainAvailable >= 0 ? 'text-emerald-600' : 'text-red-600'}`}>
|
||||
{mainAvailable.toLocaleString()} {currencySymbol} {t('dashboard.remaining')}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -146,13 +95,6 @@ function ActiveCampaignsList({ campaigns, finance }) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
{cd.tracks_impressions > 0 && (
|
||||
<div className="text-[10px] text-text-tertiary">
|
||||
{cd.tracks_impressions.toLocaleString()} imp. / {cd.tracks_clicks.toLocaleString()} clicks
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
@@ -162,12 +104,12 @@ function ActiveCampaignsList({ campaigns, finance }) {
|
||||
}
|
||||
|
||||
function MyTasksList({ tasks, currentUserId, navigate, t }) {
|
||||
const myTasks = tasks
|
||||
const myTasks = useMemo(() => tasks
|
||||
.filter(task => {
|
||||
const assignedId = task.assigned_to_id || task.assignedTo
|
||||
return assignedId === currentUserId && task.status !== 'done'
|
||||
})
|
||||
.slice(0, 5)
|
||||
.slice(0, 5), [tasks, currentUserId])
|
||||
|
||||
return (
|
||||
<div className="section-card">
|
||||
@@ -187,10 +129,10 @@ function MyTasksList({ tasks, currentUserId, navigate, t }) {
|
||||
</div>
|
||||
) : (
|
||||
myTasks.map(task => (
|
||||
<div
|
||||
<button
|
||||
key={task._id || task.id}
|
||||
onClick={() => navigate('/tasks')}
|
||||
className="flex items-center gap-3 px-5 py-3 hover:bg-surface-secondary transition-colors cursor-pointer"
|
||||
className="w-full flex items-center gap-3 px-5 py-3 hover:bg-surface-secondary transition-colors text-start"
|
||||
>
|
||||
<div className={`w-2 h-2 rounded-full shrink-0 ${(PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium).color}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
@@ -203,7 +145,7 @@ function MyTasksList({ tasks, currentUserId, navigate, t }) {
|
||||
{format(new Date(task.dueDate), 'MMM d')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
@@ -261,10 +203,84 @@ function ProjectProgress({ projects, tasks, t }) {
|
||||
)
|
||||
}
|
||||
|
||||
function ActivityFeed({ posts, deadlines, navigate, t }) {
|
||||
const [tab, setTab] = useState('posts')
|
||||
const hasPosts = posts.length > 0
|
||||
const hasDeadlines = deadlines.length > 0
|
||||
|
||||
return (
|
||||
<div className="section-card">
|
||||
<div className="section-card-header flex items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setTab('posts')}
|
||||
className={`px-3 py-1 text-sm font-medium rounded-md transition-colors ${
|
||||
tab === 'posts' ? 'bg-brand-primary/10 text-brand-primary' : 'text-text-tertiary hover:text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
{t('dashboard.recentPosts')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTab('deadlines')}
|
||||
className={`px-3 py-1 text-sm font-medium rounded-md transition-colors ${
|
||||
tab === 'deadlines' ? 'bg-brand-primary/10 text-brand-primary' : 'text-text-tertiary hover:text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
{t('dashboard.upcomingDeadlines')}
|
||||
</button>
|
||||
</div>
|
||||
<Link to={tab === 'posts' ? '/posts' : '/tasks'} className="text-sm text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1">
|
||||
{t('dashboard.viewAll')} <ArrowRight className="w-3.5 h-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="divide-y divide-border-light">
|
||||
{tab === 'posts' ? (
|
||||
!hasPosts ? (
|
||||
<div className="py-10 text-center text-sm text-text-tertiary">{t('dashboard.noPostsYet')}</div>
|
||||
) : (
|
||||
posts.slice(0, 6).map(post => (
|
||||
<button key={post._id} onClick={() => navigate('/posts')} className="w-full flex items-center gap-3 px-5 py-3 hover:bg-surface-secondary transition-colors text-start">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-text-primary truncate">{post.title}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{post.brand && <BrandBadge brand={post.brand} />}
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={post.status} size="xs" />
|
||||
</button>
|
||||
))
|
||||
)
|
||||
) : (
|
||||
!hasDeadlines ? (
|
||||
<div className="py-10 text-center text-sm text-text-tertiary">{t('dashboard.noUpcomingDeadlines')}</div>
|
||||
) : (
|
||||
deadlines.map(task => (
|
||||
<button key={task._id} onClick={() => navigate('/tasks')} className="w-full flex items-center gap-3 px-5 py-3 hover:bg-surface-secondary transition-colors text-start">
|
||||
<div className={`w-2 h-2 rounded-full ${(PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium).color}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-text-primary truncate">{task.title}</p>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<StatusBadge status={task.status} size="xs" />
|
||||
{task.assignedName && <span className="text-xs text-text-tertiary truncate">{task.assignedName}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-xs text-text-tertiary shrink-0">
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
{format(new Date(task.dueDate), 'MMM d')}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const { t, currencySymbol } = useLanguage()
|
||||
const navigate = useNavigate()
|
||||
const { currentUser, teamMembers } = useContext(AppContext)
|
||||
const { currentUser } = useContext(AppContext)
|
||||
const { hasModule } = useAuth()
|
||||
const [posts, setPosts] = useState([])
|
||||
const [campaigns, setCampaigns] = useState([])
|
||||
@@ -273,7 +289,6 @@ export default function Dashboard() {
|
||||
const [finance, setFinance] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
// Date filtering
|
||||
const [dateFrom, setDateFrom] = useState('')
|
||||
const [dateTo, setDateTo] = useState('')
|
||||
const [activePreset, setActivePreset] = useState('')
|
||||
@@ -285,7 +300,6 @@ export default function Dashboard() {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const fetches = []
|
||||
// Only fetch data for modules the user has access to
|
||||
if (hasModule('marketing')) {
|
||||
fetches.push(api.get('/posts?limit=50&sort=-createdAt').then(r => ({ key: 'posts', data: Array.isArray(r) ? r : [] })))
|
||||
fetches.push(api.get('/campaigns').then(r => ({ key: 'campaigns', data: Array.isArray(r) ? r : [] })))
|
||||
@@ -315,7 +329,6 @@ export default function Dashboard() {
|
||||
}
|
||||
}
|
||||
|
||||
// Filtered data based on date range
|
||||
const filteredPosts = useMemo(() => {
|
||||
if (!dateFrom && !dateTo) return posts
|
||||
return posts.filter(p => {
|
||||
@@ -343,7 +356,7 @@ export default function Dashboard() {
|
||||
t.dueDate && new Date(t.dueDate) < new Date() && t.status !== 'done'
|
||||
).length
|
||||
|
||||
const upcomingDeadlines = filteredTasks
|
||||
const upcomingDeadlines = useMemo(() => filteredTasks
|
||||
.filter(t => {
|
||||
if (!t.dueDate || t.status === 'done') return false
|
||||
const due = new Date(t.dueDate)
|
||||
@@ -351,60 +364,27 @@ export default function Dashboard() {
|
||||
return isAfter(due, now) && isBefore(due, addDays(now, 7))
|
||||
})
|
||||
.sort((a, b) => new Date(a.dueDate) - new Date(b.dueDate))
|
||||
.slice(0, 8)
|
||||
.slice(0, 6), [filteredTasks])
|
||||
|
||||
const statCards = []
|
||||
// Inline stat values — no card component needed
|
||||
const stats = []
|
||||
if (hasModule('marketing')) {
|
||||
statCards.push({
|
||||
icon: FileText,
|
||||
label: t('dashboard.totalPosts'),
|
||||
value: filteredPosts.length || 0,
|
||||
subtitle: `${filteredPosts.filter(p => p.status === 'published').length} ${t('dashboard.published')}`,
|
||||
color: 'brand-primary',
|
||||
})
|
||||
statCards.push({
|
||||
icon: Megaphone,
|
||||
label: t('dashboard.activeCampaigns'),
|
||||
value: activeCampaigns,
|
||||
subtitle: `${campaigns.length} ${t('dashboard.total')}`,
|
||||
color: 'brand-secondary',
|
||||
})
|
||||
}
|
||||
if (hasModule('finance')) {
|
||||
statCards.push({
|
||||
icon: Landmark,
|
||||
label: t('dashboard.budgetRemaining'),
|
||||
value: `${(finance?.remaining ?? 0).toLocaleString()}`,
|
||||
subtitle: finance?.totalReceived ? `${(finance.spent || 0).toLocaleString()} ${t('dashboard.spent')} ${t('dashboard.of')} ${finance.totalReceived.toLocaleString()} ${currencySymbol}` : t('dashboard.noBudget'),
|
||||
color: 'brand-tertiary',
|
||||
})
|
||||
stats.push({ label: t('dashboard.totalPosts'), value: filteredPosts.length, detail: `${filteredPosts.filter(p => p.status === 'published').length} ${t('dashboard.published')}`, icon: FileText, accent: 'text-indigo-600' })
|
||||
stats.push({ label: t('dashboard.activeCampaigns'), value: activeCampaigns, detail: `${campaigns.length} ${t('dashboard.total')}`, icon: Megaphone, accent: 'text-pink-600' })
|
||||
}
|
||||
if (hasModule('projects')) {
|
||||
statCards.push({
|
||||
icon: AlertTriangle,
|
||||
label: t('dashboard.overdueTasks'),
|
||||
value: overdueTasks,
|
||||
subtitle: overdueTasks > 0 ? t('dashboard.needsAttention') : t('dashboard.allOnTrack'),
|
||||
color: 'brand-quaternary',
|
||||
})
|
||||
stats.push({ label: t('dashboard.overdueTasks'), value: overdueTasks, detail: overdueTasks > 0 ? t('dashboard.needsAttention') : t('dashboard.allOnTrack'), icon: AlertTriangle, accent: overdueTasks > 0 ? 'text-red-600' : 'text-emerald-600' })
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <SkeletonDashboard />
|
||||
}
|
||||
if (loading) return <SkeletonDashboard />
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* Welcome + Date presets */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gradient">
|
||||
<p className="text-lg font-medium text-text-primary">
|
||||
{t('dashboard.welcomeBack')}, {currentUser?.name || 'there'}
|
||||
</h1>
|
||||
<p className="text-text-secondary mt-1">
|
||||
{t('dashboard.happeningToday')}
|
||||
</p>
|
||||
</div>
|
||||
<DatePresetPicker
|
||||
activePreset={activePreset}
|
||||
onSelect={(from, to, key) => { setDateFrom(from); setDateTo(to); setActivePreset(key) }}
|
||||
@@ -412,11 +392,18 @@ export default function Dashboard() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
{statCards.length > 0 && (
|
||||
<div className={`grid grid-cols-1 sm:grid-cols-2 ${statCards.length >= 4 ? 'lg:grid-cols-4' : statCards.length === 3 ? 'lg:grid-cols-3' : 'lg:grid-cols-2'} gap-4 stagger-children`}>
|
||||
{statCards.map((card, i) => (
|
||||
<StatCard key={i} {...card} />
|
||||
{/* Stats — compact inline row, no cards */}
|
||||
{stats.length > 0 && (
|
||||
<div className="flex flex-wrap gap-6">
|
||||
{stats.map((s, i) => (
|
||||
<div key={i} className="flex items-center gap-3">
|
||||
<s.icon className={`w-5 h-5 ${s.accent}`} />
|
||||
<div>
|
||||
<span className="text-2xl font-bold text-text-primary">{s.value}</span>
|
||||
<span className="text-sm text-text-tertiary ms-1.5">{s.label}</span>
|
||||
<p className="text-xs text-text-tertiary">{s.detail}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -432,7 +419,7 @@ export default function Dashboard() {
|
||||
{/* Budget + Active Campaigns */}
|
||||
{(hasModule('finance') || hasModule('marketing')) && (
|
||||
<div className={`grid grid-cols-1 ${hasModule('finance') && hasModule('marketing') ? 'lg:grid-cols-3' : ''} gap-6`}>
|
||||
{hasModule('finance') && <FinanceMini finance={finance} />}
|
||||
{hasModule('finance') && <BudgetSummary finance={finance} />}
|
||||
{hasModule('marketing') && (
|
||||
<div className={hasModule('finance') ? 'lg:col-span-2' : ''}>
|
||||
<ActiveCampaignsList campaigns={campaigns} finance={finance} />
|
||||
@@ -441,86 +428,14 @@ export default function Dashboard() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent Posts + Upcoming Deadlines */}
|
||||
{/* Activity — merged posts + deadlines */}
|
||||
{(hasModule('marketing') || hasModule('projects')) && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Recent Posts */}
|
||||
{hasModule('marketing') && (
|
||||
<div className="section-card">
|
||||
<div className="section-card-header flex items-center justify-between">
|
||||
<h3 className="font-semibold text-text-primary">{t('dashboard.recentPosts')}</h3>
|
||||
<Link to="/posts" className="text-sm text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1">
|
||||
{t('dashboard.viewAll')} <ArrowRight className="w-3.5 h-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="divide-y divide-border-light">
|
||||
{filteredPosts.length === 0 ? (
|
||||
<div className="py-12 text-center text-sm text-text-tertiary">
|
||||
{t('dashboard.noPostsYet')}
|
||||
</div>
|
||||
) : (
|
||||
filteredPosts.slice(0, 8).map((post) => (
|
||||
<div
|
||||
key={post._id}
|
||||
onClick={() => navigate('/posts')}
|
||||
className="flex items-center gap-3 px-5 py-3 hover:bg-surface-secondary transition-colors cursor-pointer"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-text-primary truncate">{post.title}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{post.brand && <BrandBadge brand={post.brand} />}
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={post.status} size="xs" />
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upcoming Deadlines */}
|
||||
{hasModule('projects') && (
|
||||
<div className="section-card">
|
||||
<div className="section-card-header flex items-center justify-between">
|
||||
<h3 className="font-semibold text-text-primary">{t('dashboard.upcomingDeadlines')}</h3>
|
||||
<Link to="/tasks" className="text-sm text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1">
|
||||
{t('dashboard.viewAll')} <ArrowRight className="w-3.5 h-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="divide-y divide-border-light">
|
||||
{upcomingDeadlines.length === 0 ? (
|
||||
<div className="py-12 text-center text-sm text-text-tertiary">
|
||||
{t('dashboard.noUpcomingDeadlines')}
|
||||
</div>
|
||||
) : (
|
||||
upcomingDeadlines.map((task) => (
|
||||
<div
|
||||
key={task._id}
|
||||
onClick={() => navigate('/tasks')}
|
||||
className="flex items-center gap-3 px-5 py-3 hover:bg-surface-secondary transition-colors cursor-pointer"
|
||||
>
|
||||
<div className={`w-2 h-2 rounded-full ${(PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium).color}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-text-primary truncate">{task.title}</p>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<StatusBadge status={task.status} size="xs" />
|
||||
{task.assignedName && (
|
||||
<span className="text-xs text-text-tertiary truncate">{task.assignedName}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-xs text-text-tertiary shrink-0">
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
{format(new Date(task.dueDate), 'MMM d')}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ActivityFeed
|
||||
posts={hasModule('marketing') ? filteredPosts : []}
|
||||
deadlines={hasModule('projects') ? upcomingDeadlines : []}
|
||||
navigate={navigate}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { useState, useEffect, useContext } from 'react'
|
||||
import { DollarSign, TrendingUp, TrendingDown, Wallet, Landmark, Eye, MousePointer, Target, Briefcase, ArrowRight, Receipt } from 'lucide-react'
|
||||
import { DollarSign, TrendingUp, TrendingDown, Wallet, Landmark, Eye, MousePointer, Target, Briefcase, ArrowRight, Receipt, Plus, X } from 'lucide-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { AppContext } from '../App'
|
||||
import { api } from '../utils/api'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import StatusBadge from '../components/StatusBadge'
|
||||
import Modal from '../components/Modal'
|
||||
import { useToast } from '../components/ToastContainer'
|
||||
import { SkeletonStatCard, SkeletonTable } from '../components/SkeletonLoader'
|
||||
|
||||
function FinanceStatCard({ icon: Icon, label, value, sub, color = 'text-text-primary', bgColor = 'bg-white' }) {
|
||||
function FinanceStatCard({ icon: Icon, label, value, sub, color = 'text-text-primary', bgColor = 'bg-surface' }) {
|
||||
return (
|
||||
<div className={`${bgColor} rounded-xl border border-border p-5`}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
@@ -40,19 +42,36 @@ function ProgressRing({ pct, size = 80, stroke = 8, color = '#10b981' }) {
|
||||
)
|
||||
}
|
||||
|
||||
const BUDGET_REQUEST_STATUS_COLORS = {
|
||||
pending: 'bg-amber-100 text-amber-800',
|
||||
approved: 'bg-emerald-100 text-emerald-800',
|
||||
rejected: 'bg-red-100 text-red-800',
|
||||
cancelled: 'bg-gray-100 text-gray-600',
|
||||
}
|
||||
|
||||
export default function Finance() {
|
||||
const { brands } = useContext(AppContext)
|
||||
const { permissions } = useAuth()
|
||||
const { currencySymbol } = useLanguage()
|
||||
const { permissions, user } = useAuth()
|
||||
const { t, currencySymbol } = useLanguage()
|
||||
const toast = useToast()
|
||||
const [summary, setSummary] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [budgetRequests, setBudgetRequests] = useState([])
|
||||
const [showRequestModal, setShowRequestModal] = useState(false)
|
||||
const [requestForm, setRequestForm] = useState({ amount: '', justification: '', earmark_type: '', earmark_id: '' })
|
||||
const [submittingRequest, setSubmittingRequest] = useState(false)
|
||||
|
||||
const isSuperadmin = user?.role === 'superadmin'
|
||||
|
||||
useEffect(() => { loadAll() }, [])
|
||||
|
||||
const loadAll = async () => {
|
||||
try {
|
||||
const sum = await api.get('/finance/summary')
|
||||
const fetches = [api.get('/finance/summary')]
|
||||
if (isSuperadmin) fetches.push(api.get('/budget-requests').catch(() => []))
|
||||
const [sum, reqs] = await Promise.all(fetches)
|
||||
setSummary(sum.data || sum || {})
|
||||
if (reqs) setBudgetRequests(Array.isArray(reqs) ? reqs : [])
|
||||
} catch (err) {
|
||||
console.error('Failed to load finance:', err)
|
||||
} finally {
|
||||
@@ -60,6 +79,41 @@ export default function Finance() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmitRequest = async () => {
|
||||
if (!requestForm.amount || !requestForm.justification.trim()) return
|
||||
setSubmittingRequest(true)
|
||||
try {
|
||||
const body = {
|
||||
amount: Number(requestForm.amount),
|
||||
justification: requestForm.justification.trim(),
|
||||
}
|
||||
if (requestForm.earmark_type === 'campaign' && requestForm.earmark_id) {
|
||||
body.earmarked_campaign_id = Number(requestForm.earmark_id)
|
||||
} else if (requestForm.earmark_type === 'project' && requestForm.earmark_id) {
|
||||
body.earmarked_project_id = Number(requestForm.earmark_id)
|
||||
}
|
||||
await api.post('/budget-requests', body)
|
||||
toast.success(t('finance.requestBudget') + ' — ' + t('common.success'))
|
||||
setShowRequestModal(false)
|
||||
setRequestForm({ amount: '', justification: '', earmark_type: '', earmark_id: '' })
|
||||
loadAll()
|
||||
} catch (err) {
|
||||
toast.error(err.message || t('common.error'))
|
||||
} finally {
|
||||
setSubmittingRequest(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancelRequest = async (id) => {
|
||||
try {
|
||||
await api.patch(`/budget-requests/${id}/cancel`)
|
||||
toast.success(t('common.success'))
|
||||
loadAll()
|
||||
} catch (err) {
|
||||
toast.error(err.message || t('common.error'))
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -86,18 +140,35 @@ export default function Finance() {
|
||||
const projectPct = totalReceived > 0 ? (totalProjectBudget / totalReceived) * 100 : 0
|
||||
const unallocatedPct = totalReceived > 0 ? (Math.max(0, unallocated) / totalReceived) * 100 : 0
|
||||
|
||||
const campaigns = s.campaigns || []
|
||||
const projects = s.projects || []
|
||||
const pendingCount = budgetRequests.filter(r => r.status === 'pending').length
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* Request Budget button (superadmin) */}
|
||||
{isSuperadmin && (
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={() => setShowRequestModal(true)}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light transition-colors shadow-sm"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
{t('finance.requestBudget')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top metrics */}
|
||||
<div className={`grid grid-cols-2 ${totalExpenses > 0 ? 'lg:grid-cols-6' : 'lg:grid-cols-5'} gap-4 stagger-children`}>
|
||||
<FinanceStatCard icon={Wallet} label="Total Received" value={`${totalReceived.toLocaleString()} ${currencySymbol}`} color="text-blue-600" />
|
||||
<FinanceStatCard icon={TrendingUp} label="Total Spent" value={`${totalSpent.toLocaleString()} ${currencySymbol}`} sub={`${spendPct.toFixed(1)}% of budget`} color="text-amber-600" />
|
||||
<FinanceStatCard icon={Wallet} label={t('finance.totalReceived')} value={`${totalReceived.toLocaleString()} ${currencySymbol}`} color="text-blue-600" />
|
||||
<FinanceStatCard icon={TrendingUp} label={t('finance.totalSpent')} value={`${totalSpent.toLocaleString()} ${currencySymbol}`} sub={`${spendPct.toFixed(1)}% ${t('finance.ofBudget')}`} color="text-amber-600" />
|
||||
{totalExpenses > 0 && (
|
||||
<FinanceStatCard icon={Receipt} label="Expenses" value={`${totalExpenses.toLocaleString()} ${currencySymbol}`} color="text-red-600" />
|
||||
<FinanceStatCard icon={Receipt} label={t('finance.expenses')} value={`${totalExpenses.toLocaleString()} ${currencySymbol}`} color="text-red-600" />
|
||||
)}
|
||||
<FinanceStatCard icon={Landmark} label="Remaining" value={`${remaining.toLocaleString()} ${currencySymbol}`} color={remaining >= 0 ? 'text-emerald-600' : 'text-red-600'} />
|
||||
<FinanceStatCard icon={DollarSign} label="Revenue" value={`${totalRevenue.toLocaleString()} ${currencySymbol}`} color="text-purple-600" />
|
||||
<FinanceStatCard icon={roi >= 0 ? TrendingUp : TrendingDown} label="Global ROI"
|
||||
<FinanceStatCard icon={Landmark} label={t('finance.remaining')} value={`${remaining.toLocaleString()} ${currencySymbol}`} color={remaining >= 0 ? 'text-emerald-600' : 'text-red-600'} />
|
||||
<FinanceStatCard icon={DollarSign} label={t('finance.revenue')} value={`${totalRevenue.toLocaleString()} ${currencySymbol}`} color="text-purple-600" />
|
||||
<FinanceStatCard icon={roi >= 0 ? TrendingUp : TrendingDown} label={t('finance.globalROI')}
|
||||
value={`${roi.toFixed(1)}%`}
|
||||
color={roi >= 0 ? 'text-emerald-600' : 'text-red-600'} />
|
||||
</div>
|
||||
@@ -106,9 +177,9 @@ export default function Finance() {
|
||||
{totalReceived > 0 && (
|
||||
<div className="section-card p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium">Budget Allocation</h3>
|
||||
<h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium">{t('finance.budgetAllocation')}</h3>
|
||||
<Link to="/budgets" className="text-xs text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1">
|
||||
Manage Budgets <ArrowRight className="w-3 h-3" />
|
||||
{t('finance.manageBudgets')} <ArrowRight className="w-3 h-3" />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="h-4 bg-surface-tertiary rounded-full overflow-hidden flex">
|
||||
@@ -122,17 +193,17 @@ export default function Finance() {
|
||||
<div className="flex items-center gap-4 mt-2.5 text-xs">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-blue-500" />
|
||||
<span className="text-text-secondary">Campaigns: <span className="font-semibold text-text-primary">{totalCampaignBudget.toLocaleString()} {currencySymbol}</span></span>
|
||||
<span className="text-text-secondary">{t('finance.campaigns')}: <span className="font-semibold text-text-primary">{totalCampaignBudget.toLocaleString()} {currencySymbol}</span></span>
|
||||
<span className="text-text-tertiary">({campaignPct.toFixed(0)}%)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-purple-500" />
|
||||
<span className="text-text-secondary">Projects: <span className="font-semibold text-text-primary">{totalProjectBudget.toLocaleString()} {currencySymbol}</span></span>
|
||||
<span className="text-text-secondary">{t('finance.projects')}: <span className="font-semibold text-text-primary">{totalProjectBudget.toLocaleString()} {currencySymbol}</span></span>
|
||||
<span className="text-text-tertiary">({projectPct.toFixed(0)}%)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-gray-300" />
|
||||
<span className="text-text-secondary">Unallocated: <span className="font-semibold text-text-primary">{Math.max(0, unallocated).toLocaleString()} {currencySymbol}</span></span>
|
||||
<span className="text-text-secondary">{t('finance.unallocated')}: <span className="font-semibold text-text-primary">{Math.max(0, unallocated).toLocaleString()} {currencySymbol}</span></span>
|
||||
<span className="text-text-tertiary">({unallocatedPct.toFixed(0)}%)</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -143,7 +214,7 @@ export default function Finance() {
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
{/* Utilization ring */}
|
||||
<div className="section-card p-5 flex flex-col items-center justify-center">
|
||||
<h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium mb-4">Budget Utilization</h3>
|
||||
<h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium mb-4">{t('finance.budgetUtilization')}</h3>
|
||||
<ProgressRing
|
||||
pct={spendPct}
|
||||
size={120}
|
||||
@@ -151,23 +222,23 @@ export default function Finance() {
|
||||
color={spendPct > 90 ? '#ef4444' : spendPct > 70 ? '#f59e0b' : '#10b981'}
|
||||
/>
|
||||
<div className="text-xs text-text-tertiary mt-3">
|
||||
{totalSpent.toLocaleString()} of {totalReceived.toLocaleString()} {currencySymbol}
|
||||
{totalSpent.toLocaleString()} {t('finance.of')} {totalReceived.toLocaleString()} {currencySymbol}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Global performance */}
|
||||
<div className="section-card p-5 lg:col-span-2">
|
||||
<h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium mb-4">Global Performance</h3>
|
||||
<h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium mb-4">{t('finance.globalPerformance')}</h3>
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
<div className="text-center">
|
||||
<Eye className="w-5 h-5 mx-auto mb-1 text-purple-500" />
|
||||
<div className="text-xl font-bold text-text-primary">{(s.impressions || 0).toLocaleString()}</div>
|
||||
<div className="text-xs text-text-tertiary">Impressions</div>
|
||||
<div className="text-xs text-text-tertiary">{t('finance.impressions')}</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<MousePointer className="w-5 h-5 mx-auto mb-1 text-blue-500" />
|
||||
<div className="text-xl font-bold text-text-primary">{(s.clicks || 0).toLocaleString()}</div>
|
||||
<div className="text-xs text-text-tertiary">Clicks</div>
|
||||
<div className="text-xs text-text-tertiary">{t('finance.clicks')}</div>
|
||||
{s.clicks > 0 && s.spent > 0 && (
|
||||
<div className="text-[10px] text-text-tertiary mt-0.5">CPC: {(s.spent / s.clicks).toFixed(2)} {currencySymbol}</div>
|
||||
)}
|
||||
@@ -175,7 +246,7 @@ export default function Finance() {
|
||||
<div className="text-center">
|
||||
<Target className="w-5 h-5 mx-auto mb-1 text-emerald-500" />
|
||||
<div className="text-xl font-bold text-text-primary">{(s.conversions || 0).toLocaleString()}</div>
|
||||
<div className="text-xs text-text-tertiary">Conversions</div>
|
||||
<div className="text-xs text-text-tertiary">{t('finance.conversions')}</div>
|
||||
{s.conversions > 0 && s.spent > 0 && (
|
||||
<div className="text-[10px] text-text-tertiary mt-0.5">CPA: {(s.spent / s.conversions).toFixed(2)} {currencySymbol}</div>
|
||||
)}
|
||||
@@ -200,22 +271,22 @@ export default function Finance() {
|
||||
<Target className="w-4 h-4 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-text-primary text-base">Campaign Breakdown</h3>
|
||||
<p className="text-xs text-text-tertiary mt-0.5">{s.campaigns.length} campaigns · Track-level budget allocation</p>
|
||||
<h3 className="font-semibold text-text-primary">{t('finance.campaignBreakdown')}</h3>
|
||||
<p className="text-xs text-text-tertiary mt-0.5">{t('finance.campaignCount').replace('{{count}}', s.campaigns.length)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface-secondary">
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-text-tertiary">Campaign</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">Budget Assigned</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">Track Allocated</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">Spent</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">Expenses</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">Revenue</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">ROI</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-text-tertiary">Status</th>
|
||||
<th className="px-4 py-3 text-start text-xs font-medium text-text-tertiary">{t('finance.campaign')}</th>
|
||||
<th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">{t('finance.budgetAssigned')}</th>
|
||||
<th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">{t('finance.trackAllocated')}</th>
|
||||
<th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">{t('finance.spent')}</th>
|
||||
<th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">{t('finance.expenses')}</th>
|
||||
<th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">{t('finance.revenue')}</th>
|
||||
<th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">{t('finance.roi')}</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-text-tertiary">{t('common.status')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border-light">
|
||||
@@ -225,20 +296,20 @@ export default function Finance() {
|
||||
return (
|
||||
<tr key={c.id} className="hover:bg-surface-secondary">
|
||||
<td className="px-4 py-3 font-medium text-text-primary">{c.name}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<td className="px-4 py-3 text-end">
|
||||
{c.budget_from_entries > 0 ? (
|
||||
<span className="font-semibold text-blue-600">{c.budget_from_entries.toLocaleString()}</span>
|
||||
) : <span className="text-text-tertiary">{'\u2014'}</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-text-secondary">{c.tracks_allocated.toLocaleString()}</td>
|
||||
<td className="px-4 py-3 text-right text-text-secondary">{c.tracks_spent.toLocaleString()}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<td className="px-4 py-3 text-end text-text-secondary">{c.tracks_allocated.toLocaleString()}</td>
|
||||
<td className="px-4 py-3 text-end text-text-secondary">{c.tracks_spent.toLocaleString()}</td>
|
||||
<td className="px-4 py-3 text-end">
|
||||
{c.expenses > 0 ? (
|
||||
<span className="font-semibold text-red-500">-{c.expenses.toLocaleString()}</span>
|
||||
) : <span className="text-text-tertiary">{'\u2014'}</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-text-secondary">{c.tracks_revenue.toLocaleString()}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<td className="px-4 py-3 text-end text-text-secondary">{c.tracks_revenue.toLocaleString()}</td>
|
||||
<td className="px-4 py-3 text-end">
|
||||
{totalCampaignConsumed > 0 ? (
|
||||
<span className={`text-xs font-semibold px-1.5 py-0.5 rounded ${cRoi >= 0 ? 'text-emerald-600 bg-emerald-50' : 'text-red-600 bg-red-50'}`}>
|
||||
{cRoi.toFixed(0)}%
|
||||
@@ -263,26 +334,26 @@ export default function Finance() {
|
||||
<Briefcase className="w-4 h-4 text-purple-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-text-primary text-base">Allocated Funds</h3>
|
||||
<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>
|
||||
<h3 className="font-semibold text-text-primary">{t('finance.allocatedFunds')}</h3>
|
||||
<p className="text-xs text-text-tertiary mt-0.5">{t('finance.workOrderCount').replace('{{count}}', s.projects.filter(p => p.budget_allocated > 0 || p.expenses > 0).length)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface-secondary">
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-text-tertiary">Work Order</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">Budget Allocated</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">Expenses</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-text-tertiary">Status</th>
|
||||
<th className="px-4 py-3 text-start text-xs font-medium text-text-tertiary">{t('finance.workOrder')}</th>
|
||||
<th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">{t('finance.budgetAllocated')}</th>
|
||||
<th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">{t('finance.expenses')}</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-text-tertiary">{t('common.status')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border-light">
|
||||
{s.projects.filter(p => p.budget_allocated > 0 || p.expenses > 0).map(p => (
|
||||
<tr key={p.id} className="hover:bg-surface-secondary">
|
||||
<td className="px-4 py-3 font-medium text-text-primary">{p.name}</td>
|
||||
<td className="px-4 py-3 text-right text-text-secondary">{p.budget_allocated.toLocaleString()} {currencySymbol}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<td className="px-4 py-3 text-end text-text-secondary">{p.budget_allocated.toLocaleString()} {currencySymbol}</td>
|
||||
<td className="px-4 py-3 text-end">
|
||||
{p.expenses > 0 ? (
|
||||
<span className="font-semibold text-red-500">-{p.expenses.toLocaleString()}</span>
|
||||
) : <span className="text-text-tertiary">{'\u2014'}</span>}
|
||||
@@ -295,6 +366,151 @@ export default function Finance() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Budget Requests (superadmin) */}
|
||||
{isSuperadmin && (
|
||||
<div className="section-card">
|
||||
<div className="section-card-header flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-amber-50">
|
||||
<Wallet className="w-4 h-4 text-amber-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-text-primary">{t('finance.budgetRequests')}</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pendingCount > 0 && (
|
||||
<div className="mx-5 mt-4 px-4 py-2.5 bg-amber-50 border border-amber-200 rounded-lg text-sm text-amber-800 font-medium">
|
||||
{pendingCount} {t('finance.requestPending')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{budgetRequests.length === 0 ? (
|
||||
<div className="px-5 py-8 text-center text-sm text-text-tertiary">
|
||||
{t('common.noData')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface-secondary">
|
||||
<th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">{t('finance.amount')}</th>
|
||||
<th className="px-4 py-3 text-start text-xs font-medium text-text-tertiary">{t('budgetApproval.justification')}</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-text-tertiary">{t('common.status')}</th>
|
||||
<th className="px-4 py-3 text-start text-xs font-medium text-text-tertiary">{t('budgetApproval.earmarkedFor')}</th>
|
||||
<th className="px-4 py-3 text-start text-xs font-medium text-text-tertiary">{t('common.date')}</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-text-tertiary"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border-light">
|
||||
{budgetRequests.map(req => (
|
||||
<tr key={req.id || req.Id} className="hover:bg-surface-secondary">
|
||||
<td className="px-4 py-3 text-end font-semibold text-text-primary">
|
||||
{Number(req.amount).toLocaleString()} {currencySymbol}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-text-secondary max-w-[200px]">
|
||||
<span title={req.justification}>
|
||||
{req.justification?.length > 60 ? req.justification.slice(0, 60) + '...' : req.justification}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${BUDGET_REQUEST_STATUS_COLORS[req.status] || 'bg-gray-100 text-gray-600'}`}>
|
||||
{req.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-text-secondary text-xs">
|
||||
{req.earmark_name || '\u2014'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-text-tertiary text-xs">
|
||||
{req.created_at ? new Date(req.created_at).toLocaleDateString() : '\u2014'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{req.status === 'pending' && (
|
||||
<button
|
||||
onClick={() => handleCancelRequest(req.id || req.Id)}
|
||||
className="text-xs text-red-600 hover:text-red-700 font-medium hover:bg-red-50 px-2 py-1 rounded transition-colors"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Budget Request Modal */}
|
||||
<Modal isOpen={showRequestModal} onClose={() => setShowRequestModal(false)} title={t('finance.requestBudget')} size="md">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('finance.amount')}</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={requestForm.amount}
|
||||
onChange={e => setRequestForm(f => ({ ...f, amount: e.target.value }))}
|
||||
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface"
|
||||
placeholder="0"
|
||||
autoFocus
|
||||
/>
|
||||
<span className="text-sm text-text-tertiary">{currencySymbol}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('budgetApproval.justification')}</label>
|
||||
<textarea
|
||||
value={requestForm.justification}
|
||||
onChange={e => setRequestForm(f => ({ ...f, justification: e.target.value }))}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface"
|
||||
placeholder={t('budgetApproval.justification')}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('budgetApproval.earmarkedFor')}</label>
|
||||
<select
|
||||
value={requestForm.earmark_type ? `${requestForm.earmark_type}:${requestForm.earmark_id}` : ''}
|
||||
onChange={e => {
|
||||
if (!e.target.value) {
|
||||
setRequestForm(f => ({ ...f, earmark_type: '', earmark_id: '' }))
|
||||
} else {
|
||||
const [type, id] = e.target.value.split(':')
|
||||
setRequestForm(f => ({ ...f, earmark_type: type, earmark_id: id }))
|
||||
}
|
||||
}}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface"
|
||||
>
|
||||
<option value="">{t('common.none')}</option>
|
||||
{campaigns.length > 0 && (
|
||||
<optgroup label={t('finance.campaigns')}>
|
||||
{campaigns.map(c => (
|
||||
<option key={`campaign:${c.id}`} value={`campaign:${c.id}`}>{c.name}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
{projects.length > 0 && (
|
||||
<optgroup label={t('finance.projects')}>
|
||||
{projects.map(p => (
|
||||
<option key={`project:${p.id}`} value={`project:${p.id}`}>{p.name}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSubmitRequest}
|
||||
disabled={!requestForm.amount || !requestForm.justification.trim() || submittingRequest}
|
||||
className={`w-full px-4 py-2.5 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm transition-colors ${submittingRequest ? 'btn-loading' : ''}`}
|
||||
>
|
||||
{t('finance.requestBudget')}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import { Megaphone, Mail, AlertCircle, CheckCircle, ArrowLeft } from 'lucide-react'
|
||||
import { Mail, AlertCircle, CheckCircle, ArrowLeft } from 'lucide-react'
|
||||
import { api } from '../utils/api'
|
||||
|
||||
function MarkaLogo({ className = '' }) {
|
||||
return (
|
||||
<svg viewBox="0 0 32 32" fill="none" className={className}>
|
||||
<path d="M4 26V6l10 10L4 26z" fill="currentColor" opacity="0.85" />
|
||||
<path d="M18 26V6l10 10-10 10z" fill="currentColor" opacity="0.5" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ForgotPassword() {
|
||||
const { t } = useLanguage()
|
||||
const [email, setEmail] = useState('')
|
||||
@@ -27,11 +36,11 @@ export default function ForgotPassword() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-blue-900 to-slate-900 flex items-center justify-center px-4">
|
||||
<div className="min-h-screen bg-slate-900 flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-purple-600 rounded-2xl flex items-center justify-center mx-auto mb-4 shadow-lg">
|
||||
<Megaphone className="w-8 h-8 text-white" />
|
||||
<div className="w-16 h-16 bg-brand-primary rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
<MarkaLogo className="w-9 h-9 text-white" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-white mb-2">{t('forgotPassword.title')}</h1>
|
||||
<p className="text-slate-400">{t('forgotPassword.subtitle')}</p>
|
||||
@@ -57,13 +66,13 @@ export default function ForgotPassword() {
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-2">{t('auth.email')}</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
||||
<Mail className="absolute start-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
dir="auto"
|
||||
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||
className="w-full ps-11 pe-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||
placeholder={t('forgotPassword.emailPlaceholder')}
|
||||
required
|
||||
autoFocus
|
||||
@@ -81,7 +90,7 @@ export default function ForgotPassword() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-3 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="w-full py-3 bg-brand-primary hover:bg-brand-primary-light text-white font-semibold rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
|
||||
@@ -196,8 +196,8 @@ export default function Issues() {
|
||||
const SortIcon = ({ col }) => {
|
||||
if (sortBy !== col) return null
|
||||
return sortDir === 'asc'
|
||||
? <ChevronUp className="w-3 h-3 inline ml-0.5" />
|
||||
: <ChevronDown className="w-3 h-3 inline ml-0.5" />
|
||||
? <ChevronUp className="w-3 h-3 inline ms-0.5" />
|
||||
: <ChevronDown className="w-3 h-3 inline ms-0.5" />
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
@@ -211,15 +211,7 @@ export default function Issues() {
|
||||
return (
|
||||
<div className="space-y-4 animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-primary flex items-center gap-2">
|
||||
<AlertCircle className="w-7 h-7" />
|
||||
{t('issues.title')}
|
||||
</h1>
|
||||
<p className="text-text-secondary mt-1">{t('issues.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={copyPublicLink}
|
||||
@@ -241,7 +233,7 @@ export default function Issues() {
|
||||
onClick={() => setViewMode(mode)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
|
||||
viewMode === mode
|
||||
? 'bg-white text-text-primary shadow-sm'
|
||||
? 'bg-surface text-text-primary shadow-sm'
|
||||
: 'text-text-tertiary hover:text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
@@ -276,13 +268,13 @@ export default function Issues() {
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1 min-w-[200px] max-w-xs">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
|
||||
<Search className="absolute start-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('issues.searchPlaceholder')}
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 bg-surface"
|
||||
className="w-full ps-10 pe-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 bg-surface"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -413,21 +405,21 @@ export default function Issues() {
|
||||
<th className="w-10 px-3 py-3" onClick={e => e.stopPropagation()}>
|
||||
<input type="checkbox" checked={selectedIds.size === sortedIssues.length && sortedIssues.length > 0} onChange={toggleSelectAll} className="rounded border-border" />
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleSort('title')}>
|
||||
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleSort('title')}>
|
||||
{t('issues.tableTitle')} <SortIcon col="title" />
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('issues.tableSubmitter')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('issues.tableBrand')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('issues.tableCategory')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('issues.tableType')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleSort('priority')}>
|
||||
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('issues.tableSubmitter')}</th>
|
||||
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('issues.tableBrand')}</th>
|
||||
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('issues.tableCategory')}</th>
|
||||
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('issues.tableType')}</th>
|
||||
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleSort('priority')}>
|
||||
{t('issues.tablePriority')} <SortIcon col="priority" />
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleSort('status')}>
|
||||
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleSort('status')}>
|
||||
{t('issues.tableStatus')} <SortIcon col="status" />
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('issues.tableAssignedTo')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleSort('created_at')}>
|
||||
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('issues.tableAssignedTo')}</th>
|
||||
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleSort('created_at')}>
|
||||
{t('issues.tableCreated')} <SortIcon col="created_at" />
|
||||
</th>
|
||||
</tr>
|
||||
|
||||
@@ -2,9 +2,18 @@ import { useState, useEffect } from 'react'
|
||||
import { useNavigate, Link } from 'react-router-dom'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import { Megaphone, Lock, Mail, AlertCircle, User, CheckCircle } from 'lucide-react'
|
||||
import { Lock, Mail, AlertCircle, User, CheckCircle } from 'lucide-react'
|
||||
import { api } from '../utils/api'
|
||||
|
||||
function MarkaLogo({ className = '' }) {
|
||||
return (
|
||||
<svg viewBox="0 0 32 32" fill="none" className={className}>
|
||||
<path d="M4 26V6l10 10L4 26z" fill="currentColor" opacity="0.85" />
|
||||
<path d="M18 26V6l10 10-10 10z" fill="currentColor" opacity="0.5" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate()
|
||||
const { login } = useAuth()
|
||||
@@ -63,19 +72,19 @@ export default function Login() {
|
||||
|
||||
if (needsSetup === null) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-blue-900 to-slate-900 flex items-center justify-center">
|
||||
<div className="min-h-screen bg-slate-900 flex items-center justify-center">
|
||||
<div className="w-8 h-8 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-blue-900 to-slate-900 flex items-center justify-center px-4">
|
||||
<div className="min-h-screen bg-slate-900 flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Logo & Title */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-purple-600 rounded-2xl flex items-center justify-center mx-auto mb-4 shadow-lg">
|
||||
<Megaphone className="w-8 h-8 text-white" />
|
||||
<div className="w-16 h-16 bg-brand-primary rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
<MarkaLogo className="w-9 h-9 text-white" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-white mb-2">
|
||||
{needsSetup ? t('login.initialSetup') : t('login.title')}
|
||||
@@ -101,15 +110,16 @@ export default function Login() {
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-2">{t('login.fullName')}</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
||||
<User className="absolute start-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
||||
<input
|
||||
type="text"
|
||||
value={setupName}
|
||||
onChange={(e) => setSetupName(e.target.value)}
|
||||
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||
className="w-full ps-11 pe-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||
placeholder={t('login.fullNamePlaceholder')}
|
||||
required
|
||||
autoFocus
|
||||
aria-describedby={error ? 'setup-error' : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -118,13 +128,13 @@ export default function Login() {
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-2">{t('login.email')}</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
||||
<Mail className="absolute start-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
||||
<input
|
||||
type="email"
|
||||
value={setupEmail}
|
||||
onChange={(e) => setSetupEmail(e.target.value)}
|
||||
dir="auto"
|
||||
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||
className="w-full ps-11 pe-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||
placeholder="admin@company.com"
|
||||
required
|
||||
/>
|
||||
@@ -135,12 +145,12 @@ export default function Login() {
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-2">{t('login.password')}</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
||||
<Lock className="absolute start-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
||||
<input
|
||||
type="password"
|
||||
value={setupPassword}
|
||||
onChange={(e) => setSetupPassword(e.target.value)}
|
||||
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||
className="w-full ps-11 pe-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||
placeholder={t('login.passwordPlaceholder')}
|
||||
required
|
||||
minLength={6}
|
||||
@@ -152,12 +162,12 @@ export default function Login() {
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-2">{t('login.confirmPassword')}</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
||||
<Lock className="absolute start-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
||||
<input
|
||||
type="password"
|
||||
value={setupConfirm}
|
||||
onChange={(e) => setSetupConfirm(e.target.value)}
|
||||
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||
className="w-full ps-11 pe-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||
placeholder={t('login.confirmPasswordPlaceholder')}
|
||||
required
|
||||
minLength={6}
|
||||
@@ -167,7 +177,7 @@ export default function Login() {
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
|
||||
<div id="setup-error" className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg" role="alert">
|
||||
<AlertCircle className="w-5 h-5 text-red-400 shrink-0" />
|
||||
<p className="text-sm text-red-400">{error}</p>
|
||||
</div>
|
||||
@@ -177,7 +187,7 @@ export default function Login() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-3 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="w-full py-3 bg-brand-primary hover:bg-brand-primary-light text-white font-semibold rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
@@ -197,16 +207,17 @@ export default function Login() {
|
||||
{t('auth.email')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
||||
<Mail className="absolute start-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
dir="auto"
|
||||
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||
className="w-full ps-11 pe-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||
placeholder="user@company.com"
|
||||
required
|
||||
autoFocus
|
||||
aria-describedby={error ? 'login-error' : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -217,21 +228,22 @@ export default function Login() {
|
||||
{t('auth.password')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
||||
<Lock className="absolute start-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||
className="w-full ps-11 pe-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
aria-describedby={error ? 'login-error' : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
|
||||
<div id="login-error" className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg" role="alert">
|
||||
<AlertCircle className="w-5 h-5 text-red-400 shrink-0" />
|
||||
<p className="text-sm text-red-400">{error}</p>
|
||||
</div>
|
||||
@@ -241,7 +253,7 @@ export default function Login() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-3 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="w-full py-3 bg-brand-primary hover:bg-brand-primary-light text-white font-semibold rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
|
||||
@@ -6,7 +6,7 @@ import { api, PLATFORMS } from '../utils/api'
|
||||
import PostDetailPanel from '../components/PostDetailPanel'
|
||||
import { SkeletonCalendar } from '../components/SkeletonLoader'
|
||||
|
||||
const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
||||
const DAY_KEYS = ['calendar.sun', 'calendar.mon', 'calendar.tue', 'calendar.wed', 'calendar.thu', 'calendar.fri', 'calendar.sat']
|
||||
|
||||
const STATUS_COLORS = {
|
||||
draft: 'bg-surface-tertiary text-text-secondary',
|
||||
@@ -158,14 +158,6 @@ export default function PostCalendar() {
|
||||
|
||||
return (
|
||||
<div className="space-y-4 animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-primary">Content Calendar</h1>
|
||||
<p className="text-sm text-text-secondary mt-1">Schedule and plan your posts</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<select
|
||||
@@ -202,7 +194,7 @@ export default function PostCalendar() {
|
||||
</div>
|
||||
|
||||
{/* Calendar */}
|
||||
<div className="bg-surface rounded-xl border border-border overflow-hidden">
|
||||
<div className="bg-surface rounded-xl border border-border overflow-clip">
|
||||
{/* Nav */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -220,30 +212,30 @@ export default function PostCalendar() {
|
||||
<div className="flex bg-surface-tertiary rounded-lg p-0.5">
|
||||
<button
|
||||
onClick={() => setCalView('month')}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors flex items-center gap-1.5 ${calView === 'month' ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary'}`}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors flex items-center gap-1.5 ${calView === 'month' ? 'bg-surface shadow-sm text-text-primary' : 'text-text-tertiary'}`}
|
||||
>
|
||||
<CalendarIcon className="w-3.5 h-3.5" />
|
||||
Month
|
||||
{t('calendar.month')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCalView('week')}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors flex items-center gap-1.5 ${calView === 'week' ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary'}`}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors flex items-center gap-1.5 ${calView === 'week' ? 'bg-surface shadow-sm text-text-primary' : 'text-text-tertiary'}`}
|
||||
>
|
||||
<CalendarDays className="w-3.5 h-3.5" />
|
||||
Week
|
||||
{t('calendar.week')}
|
||||
</button>
|
||||
</div>
|
||||
<button onClick={goToday} className="px-4 py-2 text-sm font-medium text-brand-primary hover:bg-brand-primary/5 rounded-lg transition-colors">
|
||||
Today
|
||||
{t('calendar.today')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Day headers */}
|
||||
<div className="grid grid-cols-7 border-b border-border bg-surface-secondary">
|
||||
{DAYS.map(d => (
|
||||
<div key={d} className="text-center text-xs font-semibold text-text-tertiary uppercase py-3">
|
||||
{d}
|
||||
{DAY_KEYS.map(k => (
|
||||
<div key={k} className="text-center text-xs font-semibold text-text-tertiary uppercase py-3">
|
||||
{t(k)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -271,7 +263,7 @@ export default function PostCalendar() {
|
||||
<button
|
||||
key={post.Id || post._id}
|
||||
onClick={() => handlePostClick(post)}
|
||||
className={`w-full text-left text-[10px] px-2 py-1 rounded font-medium hover:opacity-80 transition-opacity truncate ${
|
||||
className={`w-full text-start text-[10px] px-2 py-1 rounded font-medium hover:opacity-80 transition-opacity truncate ${
|
||||
STATUS_COLORS[post.status] || 'bg-surface-tertiary text-text-secondary'
|
||||
}`}
|
||||
title={post.title}
|
||||
@@ -294,13 +286,13 @@ export default function PostCalendar() {
|
||||
{/* Unscheduled Posts */}
|
||||
{unscheduled.length > 0 && (
|
||||
<div className="bg-surface rounded-xl border border-border p-6">
|
||||
<h3 className="text-sm font-semibold text-text-tertiary uppercase mb-3">Unscheduled Posts</h3>
|
||||
<h3 className="text-sm font-semibold text-text-tertiary uppercase mb-3">{t('calendar.unscheduledPosts')}</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{unscheduled.map(post => (
|
||||
<button
|
||||
key={post.Id || post._id}
|
||||
onClick={() => handlePostClick(post)}
|
||||
className="text-left bg-surface-secondary border border-border rounded-lg p-3 hover:border-brand-primary/30 transition-colors"
|
||||
className="text-start bg-surface-secondary border border-border rounded-lg p-3 hover:border-brand-primary/30 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${STATUS_COLORS[post.status] || 'bg-surface-tertiary text-text-secondary'}`}>
|
||||
@@ -319,7 +311,7 @@ export default function PostCalendar() {
|
||||
|
||||
{/* Legend */}
|
||||
<div className="bg-surface rounded-xl border border-border p-4">
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-3">Status Legend</h4>
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-3">{t('calendar.statusLegend')}</h4>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{Object.entries(STATUS_COLORS).map(([status, color]) => (
|
||||
<div key={status} className="flex items-center gap-2">
|
||||
|
||||
@@ -0,0 +1,626 @@
|
||||
import { useState, useEffect, useContext, useCallback, useRef } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { ArrowLeft, Save, FileText, Image as ImageIcon, Film, Type, Search, Link2, Unlink, Plus, CheckCircle, Clock, X, ExternalLink } from 'lucide-react'
|
||||
import { AppContext } from '../App'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import { api, PLATFORMS } from '../utils/api'
|
||||
import PlatformIcon from '../components/PlatformIcon'
|
||||
import StatusBadge from '../components/StatusBadge'
|
||||
import PortalSelect from '../components/PortalSelect'
|
||||
import CommentsSection from '../components/CommentsSection'
|
||||
import ArtefactDetailPanel from '../components/ArtefactDetailPanel'
|
||||
import { useToast } from '../components/ToastContainer'
|
||||
|
||||
const STATUS_OPTS = ['draft', 'in_review', 'approved', 'rejected', 'scheduled', 'published']
|
||||
|
||||
// Maps asset type key → composition field name
|
||||
const PIECE_MAP = { caption: 'caption', body: 'body_copy', design: 'design', video: 'video' }
|
||||
// Maps asset type key → i18n label key
|
||||
const LABEL_KEYS = {
|
||||
caption: 'postDetail.captionCopy',
|
||||
body: 'postDetail.bodyCopy',
|
||||
design: 'postDetail.design',
|
||||
video: 'postDetail.video',
|
||||
}
|
||||
const ASSET_ICONS = { caption: Type, body: FileText, design: ImageIcon, video: Film }
|
||||
const ASSET_TYPES = ['caption', 'body', 'design', 'video']
|
||||
// Maps server-generated waiting_on labels → asset type key
|
||||
const WAITING_TYPE_MAP = { Caption: 'caption', Copy: 'body', Design: 'design', Video: 'video' }
|
||||
|
||||
export default function PostDetail() {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { brands, getBrandName, teamMembers } = useContext(AppContext)
|
||||
const { t, lang } = useLanguage()
|
||||
const { user } = useAuth()
|
||||
const toast = useToast()
|
||||
|
||||
const [post, setPost] = useState(null)
|
||||
const [composition, setComposition] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [campaigns, setCampaigns] = useState([])
|
||||
|
||||
// Editable form fields
|
||||
const [title, setTitle] = useState('')
|
||||
const [status, setStatus] = useState('draft')
|
||||
const [brandId, setBrandId] = useState('')
|
||||
const [campaignId, setCampaignId] = useState('')
|
||||
const [assignedTo, setAssignedTo] = useState('')
|
||||
const [platforms, setPlatforms] = useState([])
|
||||
const [scheduledDate, setScheduledDate] = useState('')
|
||||
|
||||
// Link pickers / create
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [activePicker, setActivePicker] = useState(null) // 'caption' | 'body' | 'design' | 'video'
|
||||
const [pickerSearch, setPickerSearch] = useState('')
|
||||
const [linkCandidates, setLinkCandidates] = useState([])
|
||||
const [linking, setLinking] = useState(false)
|
||||
const allArtefactsRef = useRef(null)
|
||||
|
||||
// Sub-panels
|
||||
const [openArtefact, setOpenArtefact] = useState(null)
|
||||
|
||||
const loadPost = useCallback(async () => {
|
||||
try {
|
||||
const [p, comp] = await Promise.all([
|
||||
api.get(`/posts/${id}`),
|
||||
api.get(`/posts/${id}/composition`),
|
||||
])
|
||||
setPost(p)
|
||||
setComposition(comp)
|
||||
setTitle(p.title || '')
|
||||
setStatus(p.status || 'draft')
|
||||
setBrandId(p.brand_id || p.brandId || '')
|
||||
setCampaignId(p.campaign_id || p.campaignId || '')
|
||||
setAssignedTo(p.assigned_to || p.assignedTo || '')
|
||||
const plats = p.platforms || (p.platform ? [p.platform] : [])
|
||||
setPlatforms(Array.isArray(plats) ? plats : [])
|
||||
const sd = p.scheduled_date || p.scheduledDate
|
||||
setScheduledDate(sd ? new Date(sd).toISOString().slice(0, 10) : '')
|
||||
} catch (err) {
|
||||
console.error('Failed to load post:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [id])
|
||||
|
||||
useEffect(() => {
|
||||
loadPost()
|
||||
api.get('/campaigns').then(r => setCampaigns(Array.isArray(r) ? r : [])).catch(() => {})
|
||||
}, [loadPost])
|
||||
|
||||
const loadComposition = useCallback(async () => {
|
||||
try {
|
||||
setComposition(await api.get(`/posts/${id}/composition`))
|
||||
} catch (err) {
|
||||
console.error('Failed to load composition:', err)
|
||||
}
|
||||
}, [id])
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await api.patch(`/posts/${id}`, {
|
||||
title,
|
||||
status,
|
||||
brand_id: brandId ? Number(brandId) : null,
|
||||
campaign_id: campaignId ? Number(campaignId) : null,
|
||||
assigned_to: assignedTo ? Number(assignedTo) : null,
|
||||
platforms,
|
||||
scheduled_date: scheduledDate || null,
|
||||
})
|
||||
toast.success(t('posts.updated'))
|
||||
// Update local post state — composition is unaffected by metadata changes
|
||||
setPost(p => ({ ...p, title, status, brand_id: brandId, campaign_id: campaignId, assigned_to: assignedTo, platforms, scheduled_date: scheduledDate || null }))
|
||||
} catch {
|
||||
toast.error(t('common.saveFailed'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const togglePlatform = (key) => {
|
||||
setPlatforms(prev => prev.includes(key) ? prev.filter(p => p !== key) : [...prev, key])
|
||||
}
|
||||
|
||||
// ─── Link / Unlink / Create ───
|
||||
|
||||
const TYPE_FILTERS = {
|
||||
caption: a => a.type === 'copy' && a.copy_type === 'caption',
|
||||
body: a => a.type === 'copy' && (a.copy_type === 'body' || !a.copy_type),
|
||||
video: a => a.type === 'video',
|
||||
design: a => (a.type || 'design') === 'design',
|
||||
}
|
||||
|
||||
const openLinkPicker = async (type) => {
|
||||
setActivePicker(type)
|
||||
setPickerSearch('')
|
||||
try {
|
||||
if (!allArtefactsRef.current) allArtefactsRef.current = await api.get('/artefacts')
|
||||
const all = Array.isArray(allArtefactsRef.current) ? allArtefactsRef.current : []
|
||||
setLinkCandidates(all.filter(a => {
|
||||
const linkedTo = a.post_id || a.postId
|
||||
return TYPE_FILTERS[type](a) && (!linkedTo || String(linkedTo) !== String(id))
|
||||
}))
|
||||
} catch {
|
||||
setLinkCandidates([])
|
||||
toast.error(t('common.error'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleLink = async (itemId) => {
|
||||
setLinking(true)
|
||||
try {
|
||||
await api.patch(`/artefacts/${itemId}`, { post_id: Number(id) })
|
||||
allArtefactsRef.current = null
|
||||
toast.success(t('posts.updated'))
|
||||
setActivePicker(null)
|
||||
loadComposition()
|
||||
} catch {
|
||||
toast.error(t('common.saveFailed'))
|
||||
} finally {
|
||||
setLinking(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUnlink = async (type) => {
|
||||
const piece = composition?.[PIECE_MAP[type]]
|
||||
if (!piece) return
|
||||
try {
|
||||
await api.patch(`/artefacts/${piece.id}`, { post_id: null })
|
||||
allArtefactsRef.current = null
|
||||
toast.success(t('posts.updated'))
|
||||
loadComposition()
|
||||
} catch {
|
||||
toast.error(t('common.saveFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenPiece = async (type) => {
|
||||
const piece = composition?.[PIECE_MAP[type]]
|
||||
if (!piece) return
|
||||
try {
|
||||
const full = await api.get(`/artefacts/${piece.id}`)
|
||||
setOpenArtefact(full)
|
||||
} catch { toast.error(t('common.saveFailed')) }
|
||||
}
|
||||
|
||||
const handleCreate = async (type) => {
|
||||
if (creating) return
|
||||
setCreating(true)
|
||||
try {
|
||||
const created = await api.post('/artefacts', {
|
||||
title: title.trim() ? `${t(LABEL_KEYS[type])} — ${title.trim()}` : t(LABEL_KEYS[type]),
|
||||
type: type === 'caption' || type === 'body' ? 'copy' : type,
|
||||
copy_type: type === 'caption' ? 'caption' : type === 'body' ? 'body' : undefined,
|
||||
post_id: Number(id),
|
||||
})
|
||||
allArtefactsRef.current = null
|
||||
setOpenArtefact(created)
|
||||
loadComposition()
|
||||
} catch {
|
||||
toast.error(t('common.saveFailed'))
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Rendering ───
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-6 animate-pulse">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-8 h-8 bg-surface-tertiary rounded-lg"></div>
|
||||
<div className="flex-1">
|
||||
<div className="h-6 bg-surface-tertiary rounded w-64 mb-2"></div>
|
||||
<div className="h-4 bg-surface-tertiary rounded w-96"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{[1,2,3,4].map(i => <div key={i} className="h-40 bg-surface-tertiary rounded-xl"></div>)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!post) {
|
||||
return (
|
||||
<div className="text-center py-12 text-text-tertiary">
|
||||
{t('common.noResults')}{' '}
|
||||
<button onClick={() => navigate('/posts')} className="text-brand-primary underline">{t('common.goBack')}</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const filteredCandidates = linkCandidates.filter(c => {
|
||||
if (!pickerSearch) return true
|
||||
return (c.title || '').toLowerCase().includes(pickerSearch.toLowerCase())
|
||||
})
|
||||
|
||||
const isDirty = Boolean(post) && (
|
||||
title !== (post.title || '') ||
|
||||
status !== (post.status || 'draft') ||
|
||||
String(brandId) !== String(post.brand_id || post.brandId || '') ||
|
||||
String(campaignId) !== String(post.campaign_id || post.campaignId || '') ||
|
||||
String(assignedTo) !== String(post.assigned_to || post.assignedTo || '') ||
|
||||
JSON.stringify(platforms) !== JSON.stringify(Array.isArray(post.platforms) ? post.platforms : (post.platform ? [post.platform] : [])) ||
|
||||
scheduledDate !== ((post.scheduled_date || post.scheduledDate) ? new Date(post.scheduled_date || post.scheduledDate).toISOString().slice(0, 10) : '')
|
||||
)
|
||||
|
||||
const waitingOn = composition?.waiting_on || []
|
||||
const piecesReady = composition?.pieces_ready || false
|
||||
const hasPieces = composition?.caption || composition?.body_copy || composition?.design || composition?.video
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* ─── HEADER ─── */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={() => navigate('/posts')} className="p-1.5 hover:bg-surface-tertiary rounded-lg">
|
||||
<ArrowLeft className="w-5 h-5 text-text-secondary" />
|
||||
</button>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
className="flex-1 text-xl font-bold text-text-primary bg-transparent border-none outline-none focus:ring-0 placeholder:text-text-tertiary"
|
||||
placeholder={t('posts.postTitlePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<PortalSelect
|
||||
value={status}
|
||||
onChange={val => setStatus(val)}
|
||||
options={STATUS_OPTS.map(s => ({ value: s, label: t(`posts.status.${s}`) }))}
|
||||
className="text-xs"
|
||||
/>
|
||||
|
||||
<PortalSelect
|
||||
value={brandId}
|
||||
onChange={val => setBrandId(val)}
|
||||
options={[
|
||||
{ value: '', label: t('posts.selectBrand') },
|
||||
...brands.map(b => ({ value: String(b._id), label: lang === 'ar' && b.name_ar ? b.name_ar : b.name }))
|
||||
]}
|
||||
placeholder={t('posts.selectBrand')}
|
||||
className="text-xs"
|
||||
/>
|
||||
|
||||
<PortalSelect
|
||||
value={campaignId}
|
||||
onChange={val => setCampaignId(val)}
|
||||
options={[
|
||||
{ value: '', label: t('posts.noCampaign') },
|
||||
...campaigns.map(c => ({ value: String(c._id || c.id), label: c.name }))
|
||||
]}
|
||||
placeholder={t('posts.noCampaign')}
|
||||
className="text-xs"
|
||||
/>
|
||||
|
||||
<PortalSelect
|
||||
value={assignedTo}
|
||||
onChange={val => setAssignedTo(val)}
|
||||
options={[
|
||||
{ value: '', label: t('common.unassigned') },
|
||||
...teamMembers.map(m => ({ value: String(m._id), label: m.name }))
|
||||
]}
|
||||
placeholder={t('common.unassigned')}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Platforms */}
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{Object.entries(PLATFORMS).map(([key, p]) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => togglePlatform(key)}
|
||||
className={`flex items-center gap-1 px-2 py-1 rounded-lg text-xs font-medium border transition-colors ${
|
||||
platforms.includes(key)
|
||||
? 'border-brand-primary bg-brand-primary/10 text-brand-primary'
|
||||
: 'border-border text-text-tertiary hover:border-brand-primary/40'
|
||||
}`}
|
||||
>
|
||||
<PlatformIcon platform={key} size={14} />
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Date + Save */}
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="date"
|
||||
value={scheduledDate}
|
||||
onChange={e => setScheduledDate(e.target.value)}
|
||||
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"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className={`flex items-center gap-1.5 px-4 py-1.5 rounded-lg text-sm font-medium shadow-sm disabled:opacity-50 transition-colors ${
|
||||
isDirty ? 'bg-amber-500 hover:bg-amber-600 text-white' : 'bg-brand-primary hover:bg-brand-primary-light text-white'
|
||||
}`}
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{saving ? t('common.loading') : t('common.save')}
|
||||
{isDirty && !saving && <span className="w-1.5 h-1.5 rounded-full bg-white/70 ms-0.5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── ASSET CARDS ─── */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{ASSET_TYPES.map(type => (
|
||||
<AssetCard
|
||||
key={type}
|
||||
id={`asset-${type}`}
|
||||
type={type}
|
||||
label={t(LABEL_KEYS[type])}
|
||||
icon={ASSET_ICONS[type]}
|
||||
piece={composition?.[PIECE_MAP[type]]}
|
||||
onCreate={() => handleCreate(type)}
|
||||
creating={creating}
|
||||
onOpen={() => handleOpenPiece(type)}
|
||||
onUnlink={() => handleUnlink(type)}
|
||||
onOpenPicker={() => openLinkPicker(type)}
|
||||
activePicker={activePicker}
|
||||
pickerSearch={pickerSearch}
|
||||
filteredCandidates={filteredCandidates}
|
||||
linking={linking}
|
||||
onLink={handleLink}
|
||||
onPickerSearchChange={setPickerSearch}
|
||||
onClosePicker={() => setActivePicker(null)}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ─── READINESS ─── */}
|
||||
<div className="bg-surface rounded-xl border border-border p-5">
|
||||
<h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium mb-3">{t('postDetail.readiness')}</h3>
|
||||
{!hasPieces ? (
|
||||
<p className="text-sm text-text-tertiary">{t('postDetail.noAssets')}</p>
|
||||
) : piecesReady ? (
|
||||
<div className="flex items-center gap-2 text-emerald-600">
|
||||
<CheckCircle className="w-5 h-5" />
|
||||
<span className="text-sm font-medium">{t('postDetail.allPiecesApproved')}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-start gap-2 text-amber-600">
|
||||
<Clock className="w-5 h-5 shrink-0 mt-0.5" />
|
||||
<div className="flex flex-wrap gap-1.5 items-center">
|
||||
<span className="text-sm font-medium">{t('postDetail.waitingOn')}:</span>
|
||||
{waitingOn.map(label => {
|
||||
const type = WAITING_TYPE_MAP[label]
|
||||
return type ? (
|
||||
<button
|
||||
key={label}
|
||||
onClick={() => document.getElementById(`asset-${type}`)?.scrollIntoView({ behavior: 'smooth', block: 'center' })}
|
||||
className="text-xs px-2 py-0.5 rounded-full bg-amber-100 text-amber-700 hover:bg-amber-200 transition-colors font-medium"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
) : <span key={label} className="text-sm">{label}</span>
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ─── COMMENTS ─── */}
|
||||
<div className="bg-surface rounded-xl border border-border p-5">
|
||||
<h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium mb-3">{t('posts.discussion')}</h3>
|
||||
<CommentsSection entityType="post" entityId={Number(id)} />
|
||||
</div>
|
||||
|
||||
{/* ─── SUB-PANELS (they render their own SlidePanel internally) ─── */}
|
||||
{openArtefact && (
|
||||
<ArtefactDetailPanel
|
||||
key={openArtefact._id}
|
||||
artefact={openArtefact}
|
||||
onClose={() => { setOpenArtefact(null); loadComposition() }}
|
||||
onUpdate={loadComposition}
|
||||
onDelete={() => { setOpenArtefact(null); loadComposition() }}
|
||||
assignableUsers={teamMembers}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Asset Card Component ───
|
||||
|
||||
function AssetCard({
|
||||
id, type, label, icon: Icon, piece,
|
||||
onCreate, creating, onOpen, onUnlink,
|
||||
onOpenPicker, activePicker, pickerSearch, filteredCandidates, linking,
|
||||
onLink, onPickerSearchChange, onClosePicker, t,
|
||||
}) {
|
||||
const isPickerOpen = activePicker === type
|
||||
const isCopy = type === 'caption' || type === 'body'
|
||||
|
||||
const isPending = piece?.status === 'pending_review'
|
||||
const isApproved = piece?.status === 'approved'
|
||||
|
||||
return (
|
||||
<div id={id} className="bg-surface rounded-xl border border-border p-4 flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Icon className="w-4 h-4 text-text-tertiary" />
|
||||
<h4 className="text-xs uppercase tracking-wider text-text-tertiary font-medium flex-1">{label}</h4>
|
||||
</div>
|
||||
|
||||
{/* ─── State 2: Linked ─── */}
|
||||
{piece && (
|
||||
<>
|
||||
<div className="flex-1">
|
||||
{/* Thumbnail for design/video */}
|
||||
{!isCopy && piece.thumbnail_url && (
|
||||
<div className="mb-3 rounded-lg overflow-hidden border border-border-light bg-surface-secondary">
|
||||
<img src={piece.thumbnail_url} alt={piece.title} className="w-full h-32 object-cover" loading="lazy" />
|
||||
</div>
|
||||
)}
|
||||
{!isCopy && !piece.thumbnail_url && (
|
||||
<div className="mb-3 rounded-lg border border-border-light bg-surface-secondary flex items-center justify-center h-24">
|
||||
<Icon className="w-8 h-8 text-text-tertiary/30" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-sm font-medium text-text-primary truncate">{piece.title}</span>
|
||||
<StatusBadge status={piece.status} size="xs" />
|
||||
</div>
|
||||
|
||||
{/* Copy: content preview + languages */}
|
||||
{isCopy && piece.content_preview && (
|
||||
<p className="text-xs text-text-secondary mt-1 line-clamp-2">{piece.content_preview}</p>
|
||||
)}
|
||||
{isCopy && piece.languages && piece.languages.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{piece.languages.map((l, i) => (
|
||||
<span key={i} className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${
|
||||
l.status === 'approved' ? 'bg-emerald-100 text-emerald-700' :
|
||||
l.status === 'in_review' ? 'bg-amber-100 text-amber-700' :
|
||||
'bg-surface-tertiary text-text-tertiary'
|
||||
}`}>
|
||||
{l.language}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{isCopy && (!piece.languages || piece.languages.length === 0) && piece.language && (
|
||||
<p className="text-xs text-text-tertiary mt-1">{piece.language}</p>
|
||||
)}
|
||||
|
||||
{/* Design/Video: version info */}
|
||||
{!isCopy && piece.current_version && (
|
||||
<p className="text-xs text-text-tertiary mt-1">v{piece.current_version}</p>
|
||||
)}
|
||||
|
||||
{/* Approval info */}
|
||||
<div className="mt-3 space-y-2">
|
||||
{isPending && piece.approver_name && (
|
||||
<p className="text-xs text-amber-600 flex items-center gap-1.5">
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
{t('postDetail.pendingReviewBy')} {piece.approver_name}
|
||||
</p>
|
||||
)}
|
||||
{isApproved && (
|
||||
<p className="text-xs text-emerald-600 flex items-center gap-1.5">
|
||||
<CheckCircle className="w-3.5 h-3.5" />
|
||||
{t('postDetail.approved')}{piece.approver_name ? ` — ${piece.approver_name}` : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Open + Unlink */}
|
||||
<div className="flex items-center justify-between mt-3 pt-3 border-t border-border-light">
|
||||
<button
|
||||
onClick={onOpen}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium text-brand-primary hover:bg-brand-primary/10 rounded-lg transition-colors"
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
{t('postDetail.viewDetails')}
|
||||
</button>
|
||||
<button
|
||||
onClick={onUnlink}
|
||||
className="flex items-center gap-1 px-2.5 py-1 text-xs font-medium text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||
>
|
||||
<Unlink className="w-3.5 h-3.5" />
|
||||
{t('postDetail.unlink')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ─── State 1: Empty (no asset) ─── */}
|
||||
{!piece && (
|
||||
<>
|
||||
<div className="flex-1 flex items-center justify-center py-4">
|
||||
<p className="text-sm text-text-tertiary">{t('postDetail.notLinked')}</p>
|
||||
</div>
|
||||
{!isPickerOpen && (
|
||||
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-border-light">
|
||||
<button
|
||||
onClick={onOpenPicker}
|
||||
className="flex items-center gap-1 px-2.5 py-1 text-xs font-medium text-brand-primary hover:bg-brand-primary/10 rounded-lg transition-colors"
|
||||
>
|
||||
<Link2 className="w-3.5 h-3.5" />
|
||||
{t('postDetail.linkExisting')}
|
||||
</button>
|
||||
<button
|
||||
onClick={onCreate}
|
||||
disabled={creating}
|
||||
className="flex items-center gap-1 px-2.5 py-1 text-xs font-medium text-brand-primary hover:bg-brand-primary/10 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
{creating ? t('common.loading') : t('postDetail.createNew')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Inline link picker */}
|
||||
{isPickerOpen && (
|
||||
<div className="mt-3 pt-3 border-t border-border-light animate-fade-in">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute start-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-text-tertiary" />
|
||||
<input
|
||||
type="text"
|
||||
value={pickerSearch}
|
||||
onChange={e => onPickerSearchChange(e.target.value)}
|
||||
placeholder={t('common.search')}
|
||||
className="w-full ps-7 pe-2 py-1.5 text-xs border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<button onClick={onClosePicker} className="p-1 hover:bg-surface-tertiary rounded">
|
||||
<X className="w-3.5 h-3.5 text-text-tertiary" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-h-32 overflow-y-auto space-y-1">
|
||||
{filteredCandidates.length === 0 ? (
|
||||
<p className="text-xs text-text-tertiary text-center py-2">{t('common.noResults')}</p>
|
||||
) : (
|
||||
filteredCandidates.slice(0, 10).map(c => (
|
||||
<button
|
||||
key={c._id || c.id}
|
||||
onClick={() => onLink(c._id || c.id)}
|
||||
disabled={linking}
|
||||
className="w-full text-start px-2 py-2 text-xs rounded-lg hover:bg-surface-secondary transition-colors flex items-start gap-2 disabled:opacity-50"
|
||||
>
|
||||
{!isCopy && (c.thumbnail_url || c.file_url) && (
|
||||
<img src={c.thumbnail_url || c.file_url} alt="" className="w-10 h-10 rounded object-cover shrink-0" loading="lazy" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="truncate text-text-primary font-medium">{c.title || t('common.untitled')}</span>
|
||||
<StatusBadge status={c.status} size="xs" />
|
||||
</div>
|
||||
{isCopy && (
|
||||
<p className="text-text-tertiary mt-0.5 truncate">
|
||||
{c.source_language && <span className="uppercase">{c.source_language} · </span>}
|
||||
{(c.source_content || '').slice(0, 60)}
|
||||
</p>
|
||||
)}
|
||||
{!isCopy && c.type && (
|
||||
<p className="text-text-tertiary mt-0.5 capitalize">{c.type}</p>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useContext } from 'react'
|
||||
import { useState, useEffect, useContext, useMemo } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Plus, LayoutGrid, List, Search, X, FileText, Filter } from 'lucide-react'
|
||||
import { AppContext } from '../App'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
@@ -7,12 +8,11 @@ import { api, PLATFORMS } from '../utils/api'
|
||||
import KanbanBoard from '../components/KanbanBoard'
|
||||
import KanbanCard from '../components/KanbanCard'
|
||||
import PostCard from '../components/PostCard'
|
||||
import PostDetailPanel from '../components/PostDetailPanel'
|
||||
import DatePresetPicker from '../components/DatePresetPicker'
|
||||
import { SkeletonKanbanBoard, SkeletonTable } from '../components/SkeletonLoader'
|
||||
import EmptyState from '../components/EmptyState'
|
||||
import BulkSelectBar from '../components/BulkSelectBar'
|
||||
import Modal from '../components/Modal'
|
||||
import BulkSelectBar from '../components/BulkSelectBar'
|
||||
import { useToast } from '../components/ToastContainer'
|
||||
|
||||
const EMPTY_POST = {
|
||||
@@ -23,13 +23,13 @@ const EMPTY_POST = {
|
||||
|
||||
export default function PostProduction() {
|
||||
const { t, lang } = useLanguage()
|
||||
const navigate = useNavigate()
|
||||
const { teamMembers, brands, getBrandName } = useContext(AppContext)
|
||||
const { canEditResource } = useAuth()
|
||||
const toast = useToast()
|
||||
const [posts, setPosts] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [view, setView] = useState('kanban')
|
||||
const [panelPost, setPanelPost] = useState(null)
|
||||
const [campaigns, setCampaigns] = useState([])
|
||||
const [filters, setFilters] = useState({ brand: '', platform: '', assignedTo: '', campaign: '', periodFrom: '', periodTo: '' })
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
@@ -38,9 +38,6 @@ export default function PostProduction() {
|
||||
const [selectedIds, setSelectedIds] = useState(new Set())
|
||||
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false)
|
||||
const [showFilters, setShowFilters] = useState(false)
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const [createForm, setCreateForm] = useState({ ...EMPTY_POST })
|
||||
const [createSaving, setCreateSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadPosts()
|
||||
@@ -78,20 +75,6 @@ export default function PostProduction() {
|
||||
}
|
||||
}
|
||||
|
||||
const handlePanelSave = async (postId, data) => {
|
||||
let result
|
||||
if (postId) {
|
||||
result = await api.patch(`/posts/${postId}`, data)
|
||||
toast.success(t('posts.updated'))
|
||||
} else {
|
||||
result = await api.post('/posts', data)
|
||||
toast.success(t('posts.created'))
|
||||
}
|
||||
loadPosts()
|
||||
// Update panel with fresh server data so form stays in sync
|
||||
if (result && postId) setPanelPost(result)
|
||||
}
|
||||
|
||||
const handlePanelDelete = async (postId) => {
|
||||
try {
|
||||
await api.delete(`/posts/${postId}`)
|
||||
@@ -131,43 +114,22 @@ export default function PostProduction() {
|
||||
}
|
||||
|
||||
const openEdit = (post) => {
|
||||
if (!canEditResource('post', post)) {
|
||||
toast.error(t('posts.canOnlyEditOwn'))
|
||||
return
|
||||
}
|
||||
setPanelPost(post)
|
||||
const postId = post._id || post.id || post.Id
|
||||
navigate(`/posts/${postId}`)
|
||||
}
|
||||
|
||||
const openNew = () => {
|
||||
setCreateForm({ ...EMPTY_POST })
|
||||
setShowCreateModal(true)
|
||||
}
|
||||
|
||||
const handleCreate = async () => {
|
||||
setCreateSaving(true)
|
||||
const openNew = async () => {
|
||||
try {
|
||||
const data = {
|
||||
title: createForm.title,
|
||||
brand_id: createForm.brand_id ? Number(createForm.brand_id) : null,
|
||||
campaign_id: createForm.campaign_id ? Number(createForm.campaign_id) : null,
|
||||
assigned_to: createForm.assigned_to ? Number(createForm.assigned_to) : null,
|
||||
status: 'draft',
|
||||
}
|
||||
const created = await api.post('/posts', data)
|
||||
setShowCreateModal(false)
|
||||
const result = await api.post('/posts', { title: '', status: 'draft', platforms: [] })
|
||||
const newId = result._id || result.id || result.Id
|
||||
toast.success(t('posts.created'))
|
||||
loadPosts()
|
||||
// Open the detail panel for further editing
|
||||
if (created) setPanelPost(created)
|
||||
} catch (err) {
|
||||
console.error('Create post failed:', err)
|
||||
navigate(`/posts/${newId}`)
|
||||
} catch {
|
||||
toast.error(t('common.saveFailed'))
|
||||
} finally {
|
||||
setCreateSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const filteredPosts = posts.filter(p => {
|
||||
const filteredPosts = useMemo(() => posts.filter(p => {
|
||||
if (filters.brand && String(p.brandId || p.brand_id) !== filters.brand) return false
|
||||
if (filters.platform && !(p.platforms || []).includes(filters.platform) && p.platform !== filters.platform) return false
|
||||
if (filters.assignedTo && String(p.assignedTo || p.assigned_to) !== filters.assignedTo) return false
|
||||
@@ -181,7 +143,7 @@ export default function PostProduction() {
|
||||
if (filters.periodTo && d > filters.periodTo) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}), [posts, filters, searchTerm])
|
||||
|
||||
if (loading) {
|
||||
return view === 'kanban' ? <SkeletonKanbanBoard /> : <SkeletonTable rows={8} cols={6} />
|
||||
@@ -193,20 +155,20 @@ export default function PostProduction() {
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="relative flex-1 min-w-[200px] max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
|
||||
<Search className="absolute start-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('posts.searchPosts')}
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-white"
|
||||
className="w-full ps-10 pe-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
data-tutorial="filters"
|
||||
onClick={() => setShowFilters(f => !f)}
|
||||
className={`relative flex items-center gap-1.5 px-3 py-2 text-sm border rounded-lg transition-colors ${showFilters ? 'border-brand-primary bg-brand-primary/5 text-brand-primary' : 'border-border bg-white text-text-secondary hover:border-brand-primary/40'}`}
|
||||
className={`relative flex items-center gap-1.5 px-3 py-2 text-sm border rounded-lg transition-colors ${showFilters ? 'border-brand-primary bg-brand-primary/5 text-brand-primary' : 'border-border bg-surface text-text-secondary hover:border-brand-primary/40'}`}
|
||||
>
|
||||
<Filter className="w-4 h-4" />
|
||||
{t('common.filter')}
|
||||
@@ -215,16 +177,16 @@ export default function PostProduction() {
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="flex bg-surface-tertiary rounded-lg p-0.5 ml-auto">
|
||||
<div className="flex bg-surface-tertiary rounded-lg p-0.5 ms-auto">
|
||||
<button
|
||||
onClick={() => setView('kanban')}
|
||||
className={`p-2 rounded-md ${view === 'kanban' ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary'}`}
|
||||
className={`p-2 rounded-md ${view === 'kanban' ? 'bg-surface shadow-sm text-text-primary' : 'text-text-tertiary'}`}
|
||||
>
|
||||
<LayoutGrid className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView('list')}
|
||||
className={`p-2 rounded-md ${view === 'list' ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary'}`}
|
||||
className={`p-2 rounded-md ${view === 'list' ? 'bg-surface shadow-sm text-text-primary' : 'text-text-tertiary'}`}
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
</button>
|
||||
@@ -245,7 +207,7 @@ export default function PostProduction() {
|
||||
<select
|
||||
value={filters.brand}
|
||||
onChange={e => setFilters(f => ({ ...f, brand: e.target.value }))}
|
||||
className="text-xs border border-border rounded-lg px-2.5 py-1.5 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||
className="text-xs border border-border rounded-lg px-2.5 py-1.5 bg-surface text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||
>
|
||||
<option value="">{t('posts.allBrands')}</option>
|
||||
{brands.map(b => <option key={b._id} value={b._id}>{lang === 'ar' && b.name_ar ? b.name_ar : b.name}</option>)}
|
||||
@@ -254,7 +216,7 @@ export default function PostProduction() {
|
||||
<select
|
||||
value={filters.platform}
|
||||
onChange={e => setFilters(f => ({ ...f, platform: e.target.value }))}
|
||||
className="text-xs border border-border rounded-lg px-2.5 py-1.5 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||
className="text-xs border border-border rounded-lg px-2.5 py-1.5 bg-surface text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||
>
|
||||
<option value="">{t('posts.allPlatforms')}</option>
|
||||
{Object.entries(PLATFORMS).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
|
||||
@@ -263,7 +225,7 @@ export default function PostProduction() {
|
||||
<select
|
||||
value={filters.assignedTo}
|
||||
onChange={e => setFilters(f => ({ ...f, assignedTo: e.target.value }))}
|
||||
className="text-xs border border-border rounded-lg px-2.5 py-1.5 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||
className="text-xs border border-border rounded-lg px-2.5 py-1.5 bg-surface text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||
>
|
||||
<option value="">{t('posts.allPeople')}</option>
|
||||
{teamMembers.map(m => <option key={m._id} value={String(m._id)}>{m.name}</option>)}
|
||||
@@ -281,7 +243,7 @@ export default function PostProduction() {
|
||||
value={filters.periodFrom}
|
||||
onChange={e => { setFilters(f => ({ ...f, periodFrom: e.target.value })); setActivePreset('') }}
|
||||
title={t('posts.periodFrom')}
|
||||
className="text-xs border border-border rounded-lg px-2 py-1.5 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||
className="text-xs border border-border rounded-lg px-2 py-1.5 bg-surface text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||
/>
|
||||
<span className="text-xs text-text-tertiary">–</span>
|
||||
<input
|
||||
@@ -289,7 +251,7 @@ export default function PostProduction() {
|
||||
value={filters.periodTo}
|
||||
onChange={e => { setFilters(f => ({ ...f, periodTo: e.target.value })); setActivePreset('') }}
|
||||
title={t('posts.periodTo')}
|
||||
className="text-xs border border-border rounded-lg px-2 py-1.5 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||
className="text-xs border border-border rounded-lg px-2 py-1.5 bg-surface text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -334,7 +296,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-clip">
|
||||
{filteredPosts.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={FileText}
|
||||
@@ -361,12 +323,12 @@ export default function PostProduction() {
|
||||
<th className="w-10 px-3 py-3" onClick={e => e.stopPropagation()}>
|
||||
<input type="checkbox" checked={selectedIds.size === filteredPosts.length && filteredPosts.length > 0} onChange={toggleSelectAll} className="rounded border-border" />
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.postTitle')}</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.brand')}</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.status')}</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.platforms')}</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.assignTo')}</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.scheduledDate')}</th>
|
||||
<th className="text-start px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.postTitle')}</th>
|
||||
<th className="text-start px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.brand')}</th>
|
||||
<th className="text-start px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.status')}</th>
|
||||
<th className="text-start px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.platforms')}</th>
|
||||
<th className="text-start px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.assignTo')}</th>
|
||||
<th className="text-start px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.scheduledDate')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border-light">
|
||||
@@ -401,59 +363,6 @@ export default function PostProduction() {
|
||||
{t('common.bulkDeleteDesc')}
|
||||
</Modal>
|
||||
|
||||
{/* Create Post Modal */}
|
||||
<Modal isOpen={showCreateModal} onClose={() => setShowCreateModal(false)} title={t('posts.newPost')} size="md">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.postTitle')} *</label>
|
||||
<input type="text" value={createForm.title} onChange={e => setCreateForm(f => ({ ...f, title: e.target.value }))}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary" autoFocus />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.brand')}</label>
|
||||
<select value={createForm.brand_id} onChange={e => setCreateForm(f => ({ ...f, brand_id: e.target.value }))}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary">
|
||||
<option value="">{t('posts.allBrands')}</option>
|
||||
{brands.map(b => <option key={b._id} value={b._id}>{lang === 'ar' && b.name_ar ? b.name_ar : b.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.campaign')}</label>
|
||||
<select value={createForm.campaign_id} onChange={e => setCreateForm(f => ({ ...f, campaign_id: e.target.value }))}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary">
|
||||
<option value="">—</option>
|
||||
{campaigns.map(c => <option key={c._id || c.id} value={c._id || c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.assignedTo')}</label>
|
||||
<select value={createForm.assigned_to} onChange={e => setCreateForm(f => ({ ...f, assigned_to: e.target.value }))}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary">
|
||||
<option value="">{t('common.unassigned')}</option>
|
||||
{teamMembers.map(m => <option key={m._id} value={String(m._id)}>{m.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<button onClick={handleCreate} disabled={!createForm.title || createSaving}
|
||||
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 ${createSaving ? 'btn-loading' : ''}`}>
|
||||
{t('posts.newPost')}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Post Detail Panel (edit only) */}
|
||||
{panelPost && (
|
||||
<PostDetailPanel
|
||||
post={panelPost}
|
||||
onClose={() => setPanelPost(null)}
|
||||
onSave={handlePanelSave}
|
||||
onDelete={handlePanelDelete}
|
||||
brands={brands}
|
||||
teamMembers={teamMembers}
|
||||
campaigns={campaigns}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -223,14 +223,14 @@ export default function ProjectDetail() {
|
||||
</button>
|
||||
|
||||
{/* Project header */}
|
||||
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
||||
<div className="bg-surface rounded-xl border border-border overflow-clip">
|
||||
{/* Thumbnail banner */}
|
||||
{(project.thumbnail_url || project.thumbnailUrl) && (
|
||||
<div className="relative w-full h-40 overflow-hidden">
|
||||
<img src={project.thumbnail_url || project.thumbnailUrl} alt="" className="w-full h-full object-cover" />
|
||||
<img src={project.thumbnail_url || project.thumbnailUrl} alt="" className="w-full h-full object-cover" loading="lazy" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/30 to-transparent" />
|
||||
{canEditProject && (
|
||||
<div className="absolute top-2 right-2 flex items-center gap-1">
|
||||
<div className="absolute top-2 end-2 flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => thumbnailInputRef.current?.click()}
|
||||
className="px-2 py-1 text-xs bg-black/40 hover:bg-black/60 rounded text-white transition-colors"
|
||||
@@ -341,7 +341,7 @@ export default function ProjectDetail() {
|
||||
key={v.id}
|
||||
onClick={() => setView(v.id)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
||||
view === v.id ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary hover:text-text-secondary'
|
||||
view === v.id ? 'bg-surface shadow-sm text-text-primary' : 'text-text-tertiary hover:text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
<v.icon className="w-4 h-4" />
|
||||
@@ -411,21 +411,21 @@ export default function ProjectDetail() {
|
||||
|
||||
{/* ─── LIST VIEW ─── */}
|
||||
{view === 'list' && (
|
||||
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
||||
<div className="bg-surface rounded-xl border border-border overflow-clip">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface-secondary">
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider w-8"></th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Task</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Status</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Priority</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Assignee</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Due</th>
|
||||
<th className="text-start px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider w-8"></th>
|
||||
<th className="text-start px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Task</th>
|
||||
<th className="text-start px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Status</th>
|
||||
<th className="text-start px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Priority</th>
|
||||
<th className="text-start px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Assignee</th>
|
||||
<th className="text-start px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Due</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border-light">
|
||||
{tasks.length === 0 ? (
|
||||
<tr><td colSpan={6} className="py-12 text-center text-sm text-text-tertiary">No tasks yet</td></tr>
|
||||
<tr><td colSpan={6} className="py-12 text-center text-sm text-text-tertiary">{t('tasks.noTasks')}</td></tr>
|
||||
) : (
|
||||
tasks.map(task => {
|
||||
const prio = PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium
|
||||
@@ -470,7 +470,7 @@ export default function ProjectDetail() {
|
||||
|
||||
{/* ─── DISCUSSION SIDEBAR ─── */}
|
||||
{showDiscussion && (
|
||||
<div className="w-[340px] shrink-0 bg-white rounded-xl border border-border flex flex-col self-start sticky top-4" style={{ maxHeight: 'calc(100vh - 6rem)' }}>
|
||||
<div className="w-[340px] shrink-0 bg-surface rounded-xl border border-border flex flex-col self-start sticky top-4" style={{ maxHeight: 'calc(100vh - 6rem)' }}>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-1.5">
|
||||
<MessageCircle className="w-4 h-4" />
|
||||
@@ -539,7 +539,7 @@ function TaskKanbanCard({ task, canEdit, canDelete, onClick, onDelete, onStatusC
|
||||
onDragStart={(e) => canEdit && onDragStart(e, task)}
|
||||
onDragEnd={onDragEnd}
|
||||
onClick={onClick}
|
||||
className={`bg-white rounded-lg border border-border p-3 group hover:border-brand-primary/30 hover:shadow-sm transition-all cursor-pointer ${canEdit ? 'active:cursor-grabbing' : ''}`}
|
||||
className={`bg-surface rounded-lg border border-border p-3 group hover:border-brand-primary/30 hover:shadow-sm transition-all cursor-pointer ${canEdit ? 'active:cursor-grabbing' : ''}`}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className={`w-2 h-2 rounded-full ${priority.color} mt-1.5 shrink-0`} title={priority.label} />
|
||||
@@ -572,7 +572,7 @@ function TaskKanbanCard({ task, canEdit, canDelete, onClick, onDelete, onStatusC
|
||||
)}
|
||||
{canDelete && (
|
||||
<button onClick={(e) => { e.stopPropagation(); onDelete() }}
|
||||
className="text-[10px] text-red-400 hover:bg-red-50 px-2 py-0.5 rounded-full flex items-center gap-1 ml-auto">
|
||||
className="text-[10px] text-red-400 hover:bg-red-50 px-2 py-0.5 rounded-full flex items-center gap-1 ms-auto">
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
@@ -614,7 +614,7 @@ function GanttView({ tasks, project, onEditTask, onTaskColorChange }) {
|
||||
|
||||
if (tasks.length === 0) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-border py-16 text-center">
|
||||
<div className="bg-surface rounded-xl border border-border py-16 text-center">
|
||||
<GanttChart className="w-12 h-12 text-text-tertiary mx-auto mb-3" />
|
||||
<p className="text-text-secondary font-medium">No tasks to display</p>
|
||||
<p className="text-sm text-text-tertiary mt-1">Add tasks with due dates to see the timeline</p>
|
||||
@@ -666,7 +666,7 @@ function GanttView({ tasks, project, onEditTask, onTaskColorChange }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
||||
<div className="bg-surface rounded-xl border border-border overflow-clip">
|
||||
{/* Zoom toolbar */}
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-border bg-surface-secondary">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -757,7 +757,7 @@ function GanttView({ tasks, project, onEditTask, onTaskColorChange }) {
|
||||
)}
|
||||
{!onTaskColorChange && <div className={`w-2 h-2 rounded-full ${prio.color} shrink-0`} />}
|
||||
<button onClick={() => onEditTask(task)}
|
||||
className="text-xs font-medium text-text-primary truncate hover:text-brand-primary text-left">
|
||||
className="text-xs font-medium text-text-primary truncate hover:text-brand-primary text-start">
|
||||
{task.title}
|
||||
</button>
|
||||
</div>
|
||||
@@ -787,7 +787,7 @@ function GanttView({ tasks, project, onEditTask, onTaskColorChange }) {
|
||||
{colorPicker && onTaskColorChange && (
|
||||
<div
|
||||
ref={colorPickerRef}
|
||||
className="fixed z-50 bg-white rounded-lg shadow-xl border border-border p-2"
|
||||
className="fixed z-50 bg-surface rounded-lg shadow-xl border border-border p-2"
|
||||
style={{ left: colorPicker.x, top: colorPicker.y }}
|
||||
>
|
||||
<div className="grid grid-cols-4 gap-1.5 mb-2">
|
||||
|
||||
@@ -80,13 +80,13 @@ export default function Projects() {
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="relative flex-1 min-w-[200px] max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
|
||||
<Search className="absolute start-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search projects..."
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-white"
|
||||
className="w-full ps-10 pe-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -100,7 +100,7 @@ export default function Projects() {
|
||||
key={v.id}
|
||||
onClick={() => setView(v.id)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
||||
view === v.id ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary hover:text-text-secondary'
|
||||
view === v.id ? 'bg-surface shadow-sm text-text-primary' : 'text-text-tertiary hover:text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
<v.icon className="w-4 h-4" />
|
||||
@@ -112,7 +112,7 @@ export default function Projects() {
|
||||
{permissions?.canCreateProjects && (
|
||||
<button
|
||||
onClick={() => setShowModal(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm ml-auto"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm ms-auto"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
New Project
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { CheckCircle, XCircle, DollarSign, User, FileText, Clock, Sparkles } from 'lucide-react'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
|
||||
export default function PublicBudgetApproval() {
|
||||
const { token } = useParams()
|
||||
const { t, currencySymbol } = useLanguage()
|
||||
const [request, setRequest] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [expired, setExpired] = useState(false)
|
||||
const [success, setSuccess] = useState('')
|
||||
const [note, setNote] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
useEffect(() => { loadRequest() }, [token])
|
||||
|
||||
const loadRequest = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/budget-approval/${token}`)
|
||||
if (!res.ok) {
|
||||
const err = await res.json()
|
||||
if (res.status === 410 || err.error?.toLowerCase().includes('expired')) {
|
||||
setExpired(true)
|
||||
} else {
|
||||
setError(err.error || t('budgetApproval.loadFailed') || 'Failed to load request')
|
||||
}
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
const data = await res.json()
|
||||
setRequest(data)
|
||||
} catch {
|
||||
setError(t('budgetApproval.loadFailed') || 'Failed to load request')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAction = async (action) => {
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const res = await fetch(`/api/budget-approval/${token}/respond`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action, note: note.trim() || undefined }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json()
|
||||
setError(err.error || t('budgetApproval.actionFailed') || 'Action failed')
|
||||
setSubmitting(false)
|
||||
return
|
||||
}
|
||||
setSuccess(action === 'approve'
|
||||
? (t('budgetApproval.approved') || 'Budget request approved')
|
||||
: (t('budgetApproval.rejected') || 'Budget request rejected'))
|
||||
} catch {
|
||||
setError(t('budgetApproval.actionFailed') || 'Action failed')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Loading state
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-900 flex items-center justify-center">
|
||||
<div className="w-12 h-12 border-4 border-brand-primary border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Expired state
|
||||
if (expired) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-900 flex items-center justify-center p-4">
|
||||
<div className="max-w-md w-full bg-white rounded-2xl shadow-2xl p-8 text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-amber-100 flex items-center justify-center mx-auto mb-4">
|
||||
<Clock className="w-8 h-8 text-amber-600" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">{t('budgetApproval.expired') || 'Request Expired'}</h2>
|
||||
<p className="text-gray-500">{t('budgetApproval.expiredDesc') || 'This budget approval request has expired.'}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-900 flex items-center justify-center p-4">
|
||||
<div className="max-w-md w-full bg-white rounded-2xl shadow-2xl p-8 text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-red-100 flex items-center justify-center mx-auto mb-4">
|
||||
<XCircle className="w-8 h-8 text-red-600" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">{t('budgetApproval.error') || 'Error'}</h2>
|
||||
<p className="text-gray-500">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Success state
|
||||
if (success) {
|
||||
const isApproved = success.toLowerCase().includes('approved') || success.toLowerCase().includes('approve')
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-900 flex items-center justify-center p-4">
|
||||
<div className="max-w-md w-full bg-white rounded-2xl shadow-2xl p-8 text-center">
|
||||
<div className={`w-16 h-16 rounded-full ${isApproved ? 'bg-emerald-100' : 'bg-red-100'} flex items-center justify-center mx-auto mb-4`}>
|
||||
{isApproved
|
||||
? <CheckCircle className="w-8 h-8 text-emerald-600" />
|
||||
: <XCircle className="w-8 h-8 text-red-600" />
|
||||
}
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">{t('budgetApproval.thankYou') || 'Thank You'}</h2>
|
||||
<p className="text-gray-500">{success}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!request) return null
|
||||
|
||||
// Already handled (not pending)
|
||||
if (request.status && request.status !== 'pending') {
|
||||
const statusLabel = request.status.charAt(0).toUpperCase() + request.status.slice(1)
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-900 flex items-center justify-center p-4">
|
||||
<div className="max-w-md w-full bg-white rounded-2xl shadow-2xl p-8 text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-blue-100 flex items-center justify-center mx-auto mb-4">
|
||||
<FileText className="w-8 h-8 text-blue-600" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">{t('budgetApproval.alreadyHandled') || 'Already Handled'}</h2>
|
||||
<p className="text-gray-500">
|
||||
{t('budgetApproval.statusIs') || 'This request has been'}: <span className="font-semibold">{statusLabel}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Active state — show request details + approve/reject
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-900 flex items-center justify-center p-4 py-12">
|
||||
<div className="max-w-lg w-full">
|
||||
{/* Header card */}
|
||||
<div className="bg-white rounded-2xl shadow-2xl overflow-hidden">
|
||||
<div className="bg-brand-primary px-8 py-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-white/20 flex items-center justify-center">
|
||||
<Sparkles className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white">{t('budgetApproval.title') || 'Budget Approval'}</h1>
|
||||
<p className="text-white/80 text-sm">Rawaj</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-8 space-y-6">
|
||||
{/* Amount */}
|
||||
<div className="text-center">
|
||||
<div className="inline-flex items-center gap-2 bg-emerald-50 px-6 py-4 rounded-2xl">
|
||||
<DollarSign className="w-6 h-6 text-emerald-600" />
|
||||
<span className="text-3xl font-bold text-emerald-700">
|
||||
{Number(request.amount).toLocaleString()} {currencySymbol}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Requested by */}
|
||||
{request.requested_by_name && (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-slate-100 flex items-center justify-center shrink-0">
|
||||
<User className="w-4 h-4 text-slate-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-400 uppercase tracking-wider">{t('budgetApproval.requestedBy') || 'Requested by'}</p>
|
||||
<p className="text-sm font-semibold text-gray-900">{request.requested_by_name}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Justification */}
|
||||
<div>
|
||||
<p className="text-xs text-gray-400 uppercase tracking-wider mb-1">{t('budgetApproval.justification') || 'Justification'}</p>
|
||||
<p className="text-sm text-gray-700 whitespace-pre-wrap bg-gray-50 rounded-xl p-4 border border-gray-100">
|
||||
{request.justification}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Earmarked for */}
|
||||
{request.earmark_name && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-400 uppercase tracking-wider mb-1">{t('budgetApproval.earmarkedFor') || 'Earmarked for'}</p>
|
||||
<p className="text-sm font-medium text-gray-700">
|
||||
{request.earmark_type && <span className="text-gray-400 capitalize">{request.earmark_type}: </span>}
|
||||
{request.earmark_name}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Note textarea */}
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 uppercase tracking-wider mb-1">
|
||||
{t('budgetApproval.note') || 'Note'} ({t('common.optional') || 'optional'})
|
||||
</label>
|
||||
<textarea
|
||||
value={note}
|
||||
onChange={e => setNote(e.target.value)}
|
||||
rows={3}
|
||||
placeholder={t('budgetApproval.notePlaceholder') || 'Add a note...'}
|
||||
className="w-full px-4 py-2.5 text-sm border border-gray-200 rounded-xl bg-white text-gray-900 focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="grid grid-cols-2 gap-3 pt-2">
|
||||
<button
|
||||
onClick={() => handleAction('approve')}
|
||||
disabled={submitting}
|
||||
className="flex items-center justify-center gap-2 px-4 py-3.5 bg-emerald-600 text-white rounded-xl font-medium hover:bg-emerald-700 transition-colors disabled:opacity-50 shadow-sm"
|
||||
>
|
||||
<CheckCircle className="w-5 h-5" />
|
||||
{t('budgetApproval.approve') || 'Approve'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleAction('reject')}
|
||||
disabled={submitting}
|
||||
className="flex items-center justify-center gap-2 px-4 py-3.5 bg-red-600 text-white rounded-xl font-medium hover:bg-red-700 transition-colors disabled:opacity-50 shadow-sm"
|
||||
>
|
||||
<XCircle className="w-5 h-5" />
|
||||
{t('budgetApproval.reject') || 'Reject'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center text-slate-500 text-sm mt-6">
|
||||
<p>{t('review.poweredBy') || 'Powered by Rawaj'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -174,11 +174,11 @@ export default function PublicIssueTracker() {
|
||||
acknowledged: { label: t('acknowledged'), bg: 'bg-blue-100', text: 'text-blue-700', dot: 'bg-blue-500', icon: CheckCircle2 },
|
||||
in_progress: { label: t('in_progress'), bg: 'bg-amber-100', text: 'text-amber-700', dot: 'bg-amber-500', icon: Clock },
|
||||
resolved: { label: t('resolved'), bg: 'bg-emerald-100', text: 'text-emerald-700', dot: 'bg-emerald-500', icon: CheckCircle2 },
|
||||
declined: { label: t('declined_status'), bg: 'bg-gray-100', text: 'text-gray-700', dot: 'bg-gray-500', icon: XCircle },
|
||||
declined: { label: t('declined_status'), bg: 'bg-gray-100', text: 'text-text-secondary', dot: 'bg-gray-500', icon: XCircle },
|
||||
}
|
||||
|
||||
const PRIORITY_CONFIG = {
|
||||
low: { label: t('low'), color: 'text-gray-700' },
|
||||
low: { label: t('low'), color: 'text-text-secondary' },
|
||||
medium: { label: t('medium'), color: 'text-blue-700' },
|
||||
high: { label: t('high'), color: 'text-orange-700' },
|
||||
urgent: { label: t('urgent'), color: 'text-red-700' },
|
||||
@@ -267,16 +267,16 @@ export default function PublicIssueTracker() {
|
||||
<div className="flex items-start gap-3">
|
||||
{issue.status === 'resolved'
|
||||
? <CheckCircle2 className="w-6 h-6 text-emerald-600 shrink-0 mt-1" />
|
||||
: <XCircle className="w-6 h-6 text-gray-600 shrink-0 mt-1" />}
|
||||
: <XCircle className="w-6 h-6 text-text-secondary shrink-0 mt-1" />}
|
||||
<div className="flex-1">
|
||||
<h2 className={`text-lg font-bold mb-2 ${issue.status === 'resolved' ? 'text-emerald-900' : 'text-gray-900'}`}>
|
||||
<h2 className={`text-lg font-bold mb-2 ${issue.status === 'resolved' ? 'text-emerald-900' : 'text-text-primary'}`}>
|
||||
{issue.status === 'resolved' ? t('resolution') : t('declined')}
|
||||
</h2>
|
||||
<p className={`${issue.status === 'resolved' ? 'text-emerald-800' : 'text-gray-800'} whitespace-pre-wrap`}>
|
||||
<p className={`${issue.status === 'resolved' ? 'text-emerald-800' : 'text-text-primary'} whitespace-pre-wrap`}>
|
||||
{issue.resolution_summary}
|
||||
</p>
|
||||
{issue.resolved_at && (
|
||||
<p className={`text-sm mt-2 ${issue.status === 'resolved' ? 'text-emerald-600' : 'text-gray-600'}`}>
|
||||
<p className={`text-sm mt-2 ${issue.status === 'resolved' ? 'text-emerald-600' : 'text-text-secondary'}`}>
|
||||
{dateFmt(issue.resolved_at)}
|
||||
</p>
|
||||
)}
|
||||
@@ -303,7 +303,7 @@ export default function PublicIssueTracker() {
|
||||
<div className="flex items-start justify-between gap-3 mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-text-primary">{update.author_name}</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${update.author_type === 'staff' ? 'bg-purple-100 text-purple-700' : 'bg-gray-200 text-gray-700'}`}>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${update.author_type === 'staff' ? 'bg-purple-100 text-purple-700' : 'bg-gray-200 text-text-secondary'}`}>
|
||||
{update.author_type === 'staff' ? t('team') : t('you')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -146,7 +146,7 @@ export default function PublicPostReview() {
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">{t('review.postReview')}</h1>
|
||||
<p className="text-white/80 text-sm">Samaya Digital Hub</p>
|
||||
<p className="text-white/80 text-sm">Rawaj</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -181,7 +181,7 @@ export default function PublicPostReview() {
|
||||
{images.map((att, idx) => (
|
||||
<a key={idx} href={att.url} target="_blank" rel="noopener noreferrer"
|
||||
className="block rounded-xl overflow-hidden border-2 border-border hover:border-brand-primary transition-colors shadow-sm">
|
||||
<img src={att.url} alt={att.original_name || `Image ${idx + 1}`} className="w-full h-64 object-cover" />
|
||||
<img src={att.url} alt={att.original_name || `Image ${idx + 1}`} className="w-full h-64 object-cover" loading="lazy" />
|
||||
{att.original_name && (
|
||||
<div className="bg-surface-secondary px-4 py-2 border-t border-border">
|
||||
<p className="text-sm text-text-secondary truncate">{att.original_name}</p>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { CheckCircle, XCircle, AlertCircle, FileText, Image as ImageIcon, Film, Sparkles, Globe, User } from 'lucide-react'
|
||||
import { CheckCircle, XCircle, AlertCircle, FileText, Image as ImageIcon, Film, Sparkles, Globe, User, ArrowRightLeft } from 'lucide-react'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import { useToast } from '../components/ToastContainer'
|
||||
import Modal from '../components/Modal'
|
||||
@@ -21,8 +21,13 @@ export default function PublicReview() {
|
||||
const [error, setError] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [success, setSuccess] = useState('')
|
||||
const [successType, setSuccessType] = useState('review') // 'review' | 'redirect'
|
||||
const [reviewerName, setReviewerName] = useState('')
|
||||
const [feedback, setFeedback] = useState('')
|
||||
const [showRedirect, setShowRedirect] = useState(false)
|
||||
const [redirectTo, setRedirectTo] = useState('')
|
||||
const [teamMembers, setTeamMembers] = useState([])
|
||||
const [redirecting, setRedirecting] = useState(false)
|
||||
const [selectedLanguage, setSelectedLanguage] = useState(0)
|
||||
const [pendingAction, setPendingAction] = useState(null)
|
||||
|
||||
@@ -41,8 +46,8 @@ export default function PublicReview() {
|
||||
}
|
||||
const data = await res.json()
|
||||
setArtefact(data)
|
||||
// Auto-set reviewer name if there's exactly one approver
|
||||
if (data.approvers?.length === 1 && data.approvers[0].name) {
|
||||
// Auto-set reviewer name from the selected approver
|
||||
if (data.approvers?.length > 0 && data.approvers[0].name) {
|
||||
setReviewerName(data.approvers[0].name)
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -102,6 +107,41 @@ export default function PublicReview() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenRedirect = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/public/review-redirect/${token}/team`)
|
||||
const data = await res.json()
|
||||
setTeamMembers(Array.isArray(data) ? data : [])
|
||||
setShowRedirect(true)
|
||||
} catch {
|
||||
toast.error(t('review.actionFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleRedirect = async () => {
|
||||
if (!redirectTo) return
|
||||
setRedirecting(true)
|
||||
try {
|
||||
const res = await fetch(`/api/public/review-redirect/${token}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ new_approver_id: Number(redirectTo) }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
toast.error(data.error || t('review.actionFailed'))
|
||||
return
|
||||
}
|
||||
setSuccessType('redirect')
|
||||
setSuccess(data.message || t('review.redirected'))
|
||||
setShowRedirect(false)
|
||||
} catch {
|
||||
toast.error(t('review.actionFailed'))
|
||||
} finally {
|
||||
setRedirecting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const extractDriveFileId = (url) => {
|
||||
const patterns = [
|
||||
/\/file\/d\/([^\/]+)/,
|
||||
@@ -157,10 +197,15 @@ export default function PublicReview() {
|
||||
return (
|
||||
<div className="min-h-screen bg-surface-secondary flex items-center justify-center p-4">
|
||||
<div className="max-w-md w-full bg-surface rounded-2xl shadow-sm p-8 text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-emerald-100 flex items-center justify-center mx-auto mb-4">
|
||||
<CheckCircle className="w-8 h-8 text-emerald-600" />
|
||||
<div className={`w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4 ${successType === 'redirect' ? 'bg-blue-100' : 'bg-emerald-100'}`}>
|
||||
{successType === 'redirect'
|
||||
? <ArrowRightLeft className="w-8 h-8 text-blue-600" />
|
||||
: <CheckCircle className="w-8 h-8 text-emerald-600" />
|
||||
}
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-text-primary mb-2">{t('review.thankYou')}</h2>
|
||||
<h2 className="text-2xl font-bold text-text-primary mb-2">
|
||||
{successType === 'redirect' ? t('review.redirected') : t('review.thankYou')}
|
||||
</h2>
|
||||
<p className="text-text-secondary">{success}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -184,7 +229,7 @@ export default function PublicReview() {
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">{t('review.contentReview')}</h1>
|
||||
<p className="text-white/80 text-sm">Samaya Digital Hub</p>
|
||||
<p className="text-white/80 text-sm">Rawaj</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -281,6 +326,7 @@ export default function PublicReview() {
|
||||
src={att.url}
|
||||
alt={att.original_name || `Design ${idx + 1}`}
|
||||
className="w-full h-64 object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
{att.original_name && (
|
||||
<div className="bg-surface-secondary px-4 py-2 border-t border-border">
|
||||
@@ -354,6 +400,7 @@ export default function PublicReview() {
|
||||
src={att.url}
|
||||
alt={att.original_name}
|
||||
className="w-full h-48 object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="bg-surface-secondary px-3 py-2 border-t border-border">
|
||||
<p className="text-xs text-text-secondary truncate">{att.original_name}</p>
|
||||
@@ -416,31 +463,10 @@ export default function PublicReview() {
|
||||
{/* Reviewer identity */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('review.reviewer')}</label>
|
||||
{artefact.approvers?.length === 1 ? (
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-surface-secondary border border-border rounded-lg">
|
||||
<User className="w-4 h-4 text-text-tertiary" />
|
||||
<span className="text-sm text-text-primary">{artefact.approvers[0].name}</span>
|
||||
<span className="text-sm text-text-primary">{artefact.approvers?.[0]?.name || reviewerName || '—'}</span>
|
||||
</div>
|
||||
) : artefact.approvers?.length > 1 ? (
|
||||
<select
|
||||
value={reviewerName}
|
||||
onChange={e => setReviewerName(e.target.value)}
|
||||
className="w-full px-4 py-2 text-sm border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary transition-colors"
|
||||
>
|
||||
<option value="">{t('review.selectYourName')}</option>
|
||||
{artefact.approvers.map(a => (
|
||||
<option key={a.id} value={a.name}>{a.name}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={reviewerName}
|
||||
onChange={e => setReviewerName(e.target.value)}
|
||||
placeholder={t('review.enterYourName')}
|
||||
className="w-full px-4 py-2 text-sm border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary transition-colors"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -481,6 +507,48 @@ export default function PublicReview() {
|
||||
{t('review.reject')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Redirect to another reviewer */}
|
||||
<div className="pt-3 border-t border-border-light">
|
||||
{!showRedirect ? (
|
||||
<button
|
||||
onClick={handleOpenRedirect}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 text-sm text-text-secondary hover:text-text-primary hover:bg-surface-secondary rounded-lg transition-colors"
|
||||
>
|
||||
<ArrowRightLeft className="w-4 h-4" />
|
||||
{t('review.redirectReview')}
|
||||
</button>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-text-secondary">{t('review.redirectDesc')}</p>
|
||||
<select
|
||||
value={redirectTo}
|
||||
onChange={e => setRedirectTo(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface text-text-primary"
|
||||
>
|
||||
<option value="">{t('review.selectNewReviewer')}</option>
|
||||
{teamMembers.map(u => (
|
||||
<option key={u.id} value={u.id}>{u.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setShowRedirect(false)}
|
||||
className="flex-1 px-3 py-2 text-sm text-text-secondary hover:bg-surface-secondary rounded-lg transition-colors"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRedirect}
|
||||
disabled={!redirectTo || redirecting}
|
||||
className="flex-1 px-3 py-2 text-sm bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors disabled:opacity-50"
|
||||
>
|
||||
{redirecting ? '...' : t('review.redirect')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { CheckCircle, XCircle, AlertCircle, Languages, Globe, User } from 'lucide-react'
|
||||
import { CheckCircle, XCircle, AlertCircle, Languages, Globe, User, Check, PenLine, Copy, Lock } from 'lucide-react'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import { AVAILABLE_LANGUAGES, isTextSelected, groupTextsByLanguage } from '../utils/translations'
|
||||
import { useToast } from '../components/ToastContainer'
|
||||
import Modal from '../components/Modal'
|
||||
|
||||
@@ -17,6 +18,10 @@ export default function PublicTranslationReview() {
|
||||
const [reviewerName, setReviewerName] = useState('')
|
||||
const [feedback, setFeedback] = useState('')
|
||||
const [pendingAction, setPendingAction] = useState(null)
|
||||
const [suggestingLang, setSuggestingLang] = useState(null)
|
||||
const [suggestionContent, setSuggestionContent] = useState('')
|
||||
const [selectingId, setSelectingId] = useState(null)
|
||||
const [copiedId, setCopiedId] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadTranslation()
|
||||
@@ -44,12 +49,12 @@ export default function PublicTranslationReview() {
|
||||
}
|
||||
|
||||
const handleAction = async (action) => {
|
||||
if (action === 'approve' && !reviewerName.trim()) {
|
||||
if ((action === 'approve' || action === 'reject') && !reviewerName.trim()) {
|
||||
toast.error(t('review.nameRequired'))
|
||||
return
|
||||
}
|
||||
if (action === 'reject' && !feedback.trim()) {
|
||||
toast.error(t('review.feedbackRequired'))
|
||||
if ((action === 'reject' || action === 'revision') && !feedback.trim()) {
|
||||
toast.error(t('review.feedbackRequiredError'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -78,6 +83,86 @@ export default function PublicTranslationReview() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelect = async (textId) => {
|
||||
setSelectingId(textId)
|
||||
try {
|
||||
const res = await fetch(`/api/public/review-translation/${token}/select`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text_id: textId }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json()
|
||||
throw new Error(err.error || 'Selection failed')
|
||||
}
|
||||
setTranslation(prev => ({
|
||||
...prev,
|
||||
texts: prev.texts.map(txt => ({
|
||||
...txt,
|
||||
is_selected: txt.language_code === prev.texts.find(t => (t.Id || t.id) === textId)?.language_code
|
||||
? (txt.Id || txt.id) === textId
|
||||
: txt.is_selected,
|
||||
})),
|
||||
}))
|
||||
toast.success(t('translations.optionSelected'))
|
||||
} catch (err) {
|
||||
toast.error(err.message)
|
||||
} finally {
|
||||
setSelectingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSuggest = async (langCode) => {
|
||||
if (!suggestionContent.trim()) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const langDef = AVAILABLE_LANGUAGES.find(l => l.code === langCode)
|
||||
const res = await fetch(`/api/public/review-translation/${token}/suggest`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
language_code: langCode,
|
||||
language_label: langDef?.label || langCode,
|
||||
content: suggestionContent,
|
||||
suggested_by: reviewerName || 'Reviewer',
|
||||
}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json()
|
||||
throw new Error(err.error || 'Suggestion failed')
|
||||
}
|
||||
const newText = await res.json()
|
||||
setTranslation(prev => ({
|
||||
...prev,
|
||||
texts: [...(prev.texts || []), newText],
|
||||
}))
|
||||
setSuggestingLang(null)
|
||||
setSuggestionContent('')
|
||||
toast.success(t('translations.suggestionAdded'))
|
||||
} catch (err) {
|
||||
toast.error(err.message)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const copyContent = (text, id) => {
|
||||
navigator.clipboard.writeText(text)
|
||||
setCopiedId(id)
|
||||
toast.success(t('translations.copiedToClipboard'))
|
||||
setTimeout(() => setCopiedId(null), 2000)
|
||||
}
|
||||
|
||||
// Group texts by language (memoized)
|
||||
const textsByLanguage = useMemo(
|
||||
() => translation?.texts ? groupTextsByLanguage(translation.texts) : {},
|
||||
[translation?.texts]
|
||||
)
|
||||
|
||||
const isPendingReview = translation?.status === 'pending_review'
|
||||
const isApproved = translation?.status === 'approved'
|
||||
const isRejected = translation?.status === 'rejected'
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-surface-secondary">
|
||||
@@ -122,10 +207,20 @@ export default function PublicTranslationReview() {
|
||||
<Languages className="w-6 h-6 text-brand-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-2xl font-bold text-text-primary mb-1">{translation.title}</h2>
|
||||
{translation.description && (
|
||||
<p className="text-text-secondary mb-2">{translation.description}</p>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h2 className="text-2xl font-bold text-text-primary">{translation.title}</h2>
|
||||
{isApproved && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-700 font-medium flex items-center gap-1">
|
||||
<Lock className="w-3 h-3" />
|
||||
{t('review.approved')}
|
||||
</span>
|
||||
)}
|
||||
{isRejected && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-red-100 text-red-700 font-medium">
|
||||
{t('review.rejected')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-text-tertiary flex-wrap">
|
||||
{translation.brand_name && <span>{translation.brand_name}</span>}
|
||||
{translation.creator_name && <span className="font-medium text-text-secondary">{t('review.createdBy')} <strong>{translation.creator_name}</strong></span>}
|
||||
@@ -150,30 +245,139 @@ export default function PublicTranslationReview() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Translations */}
|
||||
{translation.texts && translation.texts.length > 0 && (
|
||||
{/* Translation Options by Language */}
|
||||
{Object.keys(textsByLanguage).length > 0 && (
|
||||
<div className="bg-surface rounded-xl border border-border p-6 mb-6">
|
||||
<h3 className="text-lg font-semibold text-text-primary mb-4">
|
||||
{t('translations.translationTexts')} ({translation.texts.length})
|
||||
{t('translations.translationTexts')}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{translation.texts.map((text, idx) => (
|
||||
<div key={text.Id || idx} className="bg-surface-secondary rounded-lg p-4 border border-border">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-sm font-semibold text-text-primary">
|
||||
{text.language_label || text.language_code}
|
||||
<div className="space-y-6">
|
||||
{Object.entries(textsByLanguage).map(([langCode, options]) => {
|
||||
const langLabel = options[0]?.language_label || langCode
|
||||
const hasSelected = options.some(isTextSelected)
|
||||
return (
|
||||
<div key={langCode}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-text-primary">{langLabel}</span>
|
||||
<span className="text-xs text-text-tertiary">({langCode})</span>
|
||||
<span className="text-xs text-text-tertiary">
|
||||
— {options.length} {options.length === 1 ? t('translations.option') : t('translations.options')}
|
||||
</span>
|
||||
<span className="text-xs text-text-tertiary">({text.language_code})</span>
|
||||
</div>
|
||||
{isPendingReview && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSuggestingLang(langCode)
|
||||
setSuggestionContent('')
|
||||
}}
|
||||
className="flex items-center gap-1 text-xs text-brand-primary hover:text-brand-primary/80 font-medium"
|
||||
>
|
||||
<PenLine className="w-3.5 h-3.5" />
|
||||
{t('translations.suggestAlternative')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{options.map((text) => {
|
||||
const textId = text.Id || text.id
|
||||
const isSelected = isTextSelected(text)
|
||||
// When approved, show only the selected option prominently; others are dimmed
|
||||
const isDimmed = isApproved && hasSelected && !isSelected
|
||||
return (
|
||||
<div
|
||||
key={textId}
|
||||
className={`rounded-lg p-4 border transition-all ${
|
||||
isSelected
|
||||
? 'bg-emerald-50 border-emerald-300 ring-1 ring-emerald-200'
|
||||
: isDimmed
|
||||
? 'bg-surface-secondary border-border opacity-50'
|
||||
: 'bg-surface-secondary border-border hover:border-brand-primary/30'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-medium text-text-tertiary">
|
||||
{t('translations.optionLabel')} {text.option_number || 1}
|
||||
</span>
|
||||
{isSelected && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-emerald-100 text-emerald-700 font-medium flex items-center gap-1">
|
||||
<Check className="w-3 h-3" />
|
||||
{t('translations.selected')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-text-secondary whitespace-pre-wrap leading-relaxed">{text.content}</p>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{/* Copy button — always available, especially useful for approved */}
|
||||
{(isApproved || isSelected) && (
|
||||
<button
|
||||
onClick={() => copyContent(text.content, textId)}
|
||||
className="p-1.5 rounded-lg text-text-tertiary hover:text-text-primary hover:bg-surface-tertiary transition-colors"
|
||||
title={t('translations.copyContent')}
|
||||
>
|
||||
{copiedId === textId ? <Check className="w-4 h-4 text-emerald-600" /> : <Copy className="w-4 h-4" />}
|
||||
</button>
|
||||
)}
|
||||
{/* Select button — only when pending review */}
|
||||
{isPendingReview && (
|
||||
<button
|
||||
onClick={() => handleSelect(textId)}
|
||||
disabled={selectingId === textId || isSelected}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
|
||||
isSelected
|
||||
? 'bg-emerald-100 text-emerald-700 cursor-default'
|
||||
: 'bg-brand-primary/10 text-brand-primary hover:bg-brand-primary/20'
|
||||
}`}
|
||||
>
|
||||
{selectingId === textId ? '...' : isSelected ? t('translations.selected') : t('translations.selectThis')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Inline suggestion form for this language */}
|
||||
{suggestingLang === langCode && (
|
||||
<div className="mt-3 bg-amber-50 border border-amber-200 rounded-lg p-4">
|
||||
<p className="text-sm font-medium text-amber-800 mb-2">{t('translations.suggestForLang')} {langLabel}</p>
|
||||
<textarea
|
||||
value={suggestionContent}
|
||||
onChange={e => setSuggestionContent(e.target.value)}
|
||||
placeholder={t('translations.enterSuggestion')}
|
||||
className="w-full px-3 py-2 text-sm border border-amber-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-amber-400/30 min-h-[80px] resize-y bg-surface"
|
||||
/>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<button
|
||||
onClick={() => handleSuggest(langCode)}
|
||||
disabled={submitting || !suggestionContent.trim()}
|
||||
className="px-3 py-1.5 bg-amber-600 text-white text-xs font-medium rounded-lg hover:bg-amber-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{submitting ? '...' : t('translations.submitSuggestion')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSuggestingLang(null)}
|
||||
className="px-3 py-1.5 text-xs text-text-secondary hover:text-text-primary transition-colors"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Review Actions */}
|
||||
{translation.status === 'pending_review' && (
|
||||
{/* Review Actions — only pending_review */}
|
||||
{isPendingReview && (
|
||||
<div className="bg-surface rounded-xl border border-border p-6">
|
||||
<h3 className="text-lg font-semibold text-text-primary mb-4">{t('review.yourReview')}</h3>
|
||||
|
||||
@@ -253,17 +457,63 @@ export default function PublicTranslationReview() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Already reviewed */}
|
||||
{translation.status !== 'pending_review' && (
|
||||
{/* Approved state — read-only with copy buttons */}
|
||||
{isApproved && (
|
||||
<div className="bg-surface rounded-xl border border-border p-6">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<CheckCircle className="w-6 h-6 text-emerald-500" />
|
||||
<div>
|
||||
<p className="text-text-primary font-semibold">{t('review.approved')}</p>
|
||||
{translation.approved_by_name && (
|
||||
<p className="text-sm text-text-secondary">
|
||||
{t('review.reviewedBy')} <strong>{translation.approved_by_name}</strong>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{translation.feedback && (
|
||||
<div className="bg-surface-secondary rounded-lg p-3 mt-3">
|
||||
<p className="text-xs font-medium text-text-tertiary mb-1">{t('review.feedback')}</p>
|
||||
<p className="text-sm text-text-secondary whitespace-pre-wrap">{translation.feedback}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rejected state */}
|
||||
{isRejected && (
|
||||
<div className="bg-surface rounded-xl border border-border p-6">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<XCircle className="w-6 h-6 text-red-500" />
|
||||
<div>
|
||||
<p className="text-text-primary font-semibold">{t('review.rejected')}</p>
|
||||
{translation.approved_by_name && (
|
||||
<p className="text-sm text-text-secondary">
|
||||
{t('review.reviewedBy')} <strong>{translation.approved_by_name}</strong>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{translation.feedback && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-3 mt-3">
|
||||
<p className="text-xs font-medium text-red-700 mb-1">{t('review.feedback')}</p>
|
||||
<p className="text-sm text-red-800 whitespace-pre-wrap">{translation.feedback}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Other statuses (revision_requested, draft) */}
|
||||
{!isPendingReview && !isApproved && !isRejected && (
|
||||
<div className="bg-surface rounded-xl border border-border p-6">
|
||||
<div className="text-center py-4">
|
||||
<CheckCircle className="w-10 h-10 text-emerald-500 mx-auto mb-2" />
|
||||
<AlertCircle className="w-10 h-10 text-amber-500 mx-auto mb-2" />
|
||||
<p className="text-text-primary font-medium">
|
||||
{t('review.statusLabel')}: <span className="font-semibold capitalize">{translation.status.replace('_', ' ')}</span>
|
||||
</p>
|
||||
{translation.approved_by_name && (
|
||||
<p className="text-sm text-text-secondary mt-1">
|
||||
{t('review.reviewedBy')}: <span className="font-semibold">{translation.approved_by_name}</span>
|
||||
{t('review.reviewedBy')} <strong>{translation.approved_by_name}</strong>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import { Megaphone, Lock, AlertCircle, CheckCircle, ArrowLeft } from 'lucide-react'
|
||||
import { Lock, AlertCircle, CheckCircle, ArrowLeft } from 'lucide-react'
|
||||
import { api } from '../utils/api'
|
||||
|
||||
function MarkaLogo({ className = '' }) {
|
||||
return (
|
||||
<svg viewBox="0 0 32 32" fill="none" className={className}>
|
||||
<path d="M4 26V6l10 10L4 26z" fill="currentColor" opacity="0.85" />
|
||||
<path d="M18 26V6l10 10-10 10z" fill="currentColor" opacity="0.5" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ResetPassword() {
|
||||
const { t } = useLanguage()
|
||||
const [searchParams] = useSearchParams()
|
||||
@@ -16,7 +25,7 @@ export default function ResetPassword() {
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-blue-900 to-slate-900 flex items-center justify-center px-4">
|
||||
<div className="min-h-screen bg-slate-900 flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-md text-center">
|
||||
<div className="bg-slate-800/50 backdrop-blur-sm rounded-2xl border border-slate-700/50 p-8 shadow-2xl">
|
||||
<AlertCircle className="w-12 h-12 text-red-400 mx-auto mb-4" />
|
||||
@@ -51,11 +60,11 @@ export default function ResetPassword() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-blue-900 to-slate-900 flex items-center justify-center px-4">
|
||||
<div className="min-h-screen bg-slate-900 flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-purple-600 rounded-2xl flex items-center justify-center mx-auto mb-4 shadow-lg">
|
||||
<Megaphone className="w-8 h-8 text-white" />
|
||||
<div className="w-16 h-16 bg-brand-primary rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
<MarkaLogo className="w-9 h-9 text-white" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-white mb-2">{t('resetPassword.title')}</h1>
|
||||
<p className="text-slate-400">{t('resetPassword.subtitle')}</p>
|
||||
@@ -81,12 +90,12 @@ export default function ResetPassword() {
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-2">{t('resetPassword.newPassword')}</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
||||
<Lock className="absolute start-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||
className="w-full ps-11 pe-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
minLength={6}
|
||||
@@ -98,12 +107,12 @@ export default function ResetPassword() {
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-2">{t('resetPassword.confirmPassword')}</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
||||
<Lock className="absolute start-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
||||
<input
|
||||
type="password"
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||
className="w-full ps-11 pe-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
minLength={6}
|
||||
@@ -121,7 +130,7 @@ export default function ResetPassword() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-3 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="w-full py-3 bg-brand-primary hover:bg-brand-primary-light text-white font-semibold rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useContext } from 'react'
|
||||
import { Settings as SettingsIcon, Play, CheckCircle, Languages, Coins, Upload, Tag, Plus, Pencil, Trash2, X } from 'lucide-react'
|
||||
import { Settings as SettingsIcon, Play, CheckCircle, Languages, Coins, Upload, Tag, Plus, Pencil, Trash2, X, Mail } from 'lucide-react'
|
||||
import { api } from '../utils/api'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import { useToast } from '../components/ToastContainer'
|
||||
@@ -23,9 +23,15 @@ export default function Settings() {
|
||||
const [maxSizeMB, setMaxSizeMB] = useState(50)
|
||||
const [sizeSaving, setSizeSaving] = useState(false)
|
||||
const [sizeSaved, setSizeSaved] = useState(false)
|
||||
const [ceoEmail, setCeoEmail] = useState('')
|
||||
const [ceoSaving, setCeoSaving] = useState(false)
|
||||
const [ceoSaved, setCeoSaved] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/settings/app').then(s => setMaxSizeMB(s.uploadMaxSizeMB || 50)).catch(() => {})
|
||||
api.get('/settings/app').then(s => {
|
||||
setMaxSizeMB(s.uploadMaxSizeMB || 50)
|
||||
if (s.ceoEmail) setCeoEmail(s.ceoEmail)
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const handleSaveMaxSize = async () => {
|
||||
@@ -65,9 +71,9 @@ export default function Settings() {
|
||||
<p className="text-sm text-text-tertiary">{t('settings.preferences')}</p>
|
||||
|
||||
{/* General Settings */}
|
||||
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
||||
<div className="bg-surface rounded-xl border border-border overflow-clip">
|
||||
<div className="px-6 py-4 border-b border-border">
|
||||
<h2 className="text-lg font-semibold text-text-primary">{t('settings.general')}</h2>
|
||||
<h3 className="font-semibold text-text-primary">{t('settings.general')}</h3>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Language Selector */}
|
||||
@@ -79,7 +85,7 @@ export default function Settings() {
|
||||
<select
|
||||
value={lang}
|
||||
onChange={(e) => setLang(e.target.value)}
|
||||
className="w-full max-w-xs px-4 py-2.5 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-white"
|
||||
className="w-full max-w-xs px-4 py-2.5 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface"
|
||||
>
|
||||
<option value="en">{t('settings.english')}</option>
|
||||
<option value="ar">{t('settings.arabic')}</option>
|
||||
@@ -95,7 +101,7 @@ export default function Settings() {
|
||||
<select
|
||||
value={currency}
|
||||
onChange={(e) => setCurrency(e.target.value)}
|
||||
className="w-full max-w-xs px-4 py-2.5 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-white"
|
||||
className="w-full max-w-xs px-4 py-2.5 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface"
|
||||
>
|
||||
{CURRENCIES.map(c => (
|
||||
<option key={c.code} value={c.code}>
|
||||
@@ -109,12 +115,12 @@ export default function Settings() {
|
||||
</div>
|
||||
|
||||
{/* Uploads Section */}
|
||||
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
||||
<div className="bg-surface rounded-xl border border-border overflow-clip">
|
||||
<div className="px-6 py-4 border-b border-border">
|
||||
<h2 className="text-lg font-semibold text-text-primary flex items-center gap-2">
|
||||
<h3 className="font-semibold text-text-primary flex items-center gap-2">
|
||||
<Upload className="w-5 h-5 text-brand-primary" />
|
||||
{t('settings.uploads')}
|
||||
</h2>
|
||||
</h3>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
@@ -128,7 +134,7 @@ export default function Settings() {
|
||||
max="500"
|
||||
value={maxSizeMB}
|
||||
onChange={(e) => setMaxSizeMB(Number(e.target.value))}
|
||||
className="w-24 px-3 py-2.5 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-white"
|
||||
className="w-24 px-3 py-2.5 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface"
|
||||
/>
|
||||
<span className="text-sm text-text-secondary">{t('settings.mb')}</span>
|
||||
<button
|
||||
@@ -147,9 +153,9 @@ export default function Settings() {
|
||||
</div>
|
||||
|
||||
{/* Tutorial Section */}
|
||||
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
||||
<div className="bg-surface rounded-xl border border-border overflow-clip">
|
||||
<div className="px-6 py-4 border-b border-border">
|
||||
<h2 className="text-lg font-semibold text-text-primary">{t('settings.onboardingTutorial')}</h2>
|
||||
<h3 className="font-semibold text-text-primary">{t('settings.onboardingTutorial')}</h3>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<p className="text-sm text-text-secondary">
|
||||
@@ -180,6 +186,56 @@ export default function Settings() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Budget Approval (Superadmin only) */}
|
||||
{user?.role === 'superadmin' && (
|
||||
<div className="bg-surface rounded-xl border border-border overflow-clip">
|
||||
<div className="px-6 py-4 border-b border-border">
|
||||
<h3 className="font-semibold text-text-primary flex items-center gap-2">
|
||||
<Mail className="w-5 h-5 text-brand-primary" />
|
||||
{t('settings.budgetApproval') || 'Budget Approval'}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-2">
|
||||
{t('settings.ceoEmail')}
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="email"
|
||||
value={ceoEmail}
|
||||
onChange={(e) => setCeoEmail(e.target.value)}
|
||||
placeholder="ceo@company.com"
|
||||
className="flex-1 max-w-sm px-4 py-2.5 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface"
|
||||
/>
|
||||
<button
|
||||
onClick={async () => {
|
||||
setCeoSaving(true)
|
||||
setCeoSaved(false)
|
||||
try {
|
||||
await api.patch('/settings/app', { ceoEmail })
|
||||
setCeoSaved(true)
|
||||
setTimeout(() => setCeoSaved(false), 2000)
|
||||
} catch (err) {
|
||||
toast.error(err.message || t('settings.saveFailed'))
|
||||
} finally {
|
||||
setCeoSaving(false)
|
||||
}
|
||||
}}
|
||||
disabled={ceoSaving}
|
||||
className="px-4 py-2.5 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{ceoSaved ? (
|
||||
<span className="flex items-center gap-1.5"><CheckCircle className="w-4 h-4" />{t('settings.saved')}</span>
|
||||
) : ceoSaving ? '...' : t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-text-tertiary mt-1.5">{t('settings.ceoEmailHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Roles Management (Superadmin only) */}
|
||||
{user?.role === 'superadmin' && <RolesSection roles={roles} loadRoles={loadRoles} t={t} toast={toast} />}
|
||||
</div>
|
||||
@@ -235,12 +291,12 @@ function RolesSection({ roles, loadRoles, t, toast }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bg-white dark:bg-surface-primary rounded-xl border border-border overflow-hidden">
|
||||
<div className="bg-surface dark:bg-surface-primary rounded-xl border border-border overflow-clip">
|
||||
<div className="px-6 py-4 border-b border-border flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-text-primary flex items-center gap-2">
|
||||
<h3 className="font-semibold text-text-primary flex items-center gap-2">
|
||||
<Tag className="w-5 h-5 text-brand-primary" />
|
||||
{t('settings.roles')}
|
||||
</h2>
|
||||
</h3>
|
||||
<button
|
||||
onClick={openAddModal}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors"
|
||||
|
||||
@@ -325,16 +325,16 @@ export default function Tasks() {
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1 max-w-xs">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
|
||||
<Search className="absolute start-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
placeholder={t('tasks.search')}
|
||||
className="w-full pl-9 pr-3 py-1.5 text-sm border border-border rounded-lg bg-surface-primary text-text-primary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
className="w-full ps-9 pe-3 py-1.5 text-sm border border-border rounded-lg bg-surface-primary text-text-primary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button onClick={() => setSearchQuery('')} className="absolute right-2 top-1/2 -translate-y-1/2 p-0.5 rounded text-text-tertiary hover:text-text-primary">
|
||||
<button onClick={() => setSearchQuery('')} className="absolute end-2 top-1/2 -translate-y-1/2 p-0.5 rounded text-text-tertiary hover:text-text-primary">
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
@@ -350,7 +350,7 @@ export default function Tasks() {
|
||||
onClick={() => setViewMode(mode)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
|
||||
viewMode === mode
|
||||
? 'bg-white text-text-primary shadow-sm'
|
||||
? 'bg-surface text-text-primary shadow-sm'
|
||||
: 'text-text-tertiary hover:text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
@@ -399,7 +399,7 @@ export default function Tasks() {
|
||||
<select
|
||||
value={filterProject}
|
||||
onChange={e => setFilterProject(e.target.value)}
|
||||
className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-white text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
|
||||
className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
|
||||
>
|
||||
<option value="">{t('tasks.allProjects')}</option>
|
||||
{taskProjects.map(p => (
|
||||
@@ -411,7 +411,7 @@ export default function Tasks() {
|
||||
<select
|
||||
value={filterBrand}
|
||||
onChange={e => setFilterBrand(e.target.value)}
|
||||
className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-white text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
|
||||
className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
|
||||
>
|
||||
<option value="">{t('tasks.allBrands')}</option>
|
||||
{taskBrands.map(b => (
|
||||
@@ -440,7 +440,7 @@ export default function Tasks() {
|
||||
className={`px-2.5 py-1 text-[11px] font-medium rounded-full border transition-colors ${
|
||||
active
|
||||
? 'bg-brand-primary/10 border-brand-primary/20 text-brand-primary'
|
||||
: 'bg-white border-border text-text-tertiary'
|
||||
: 'bg-surface border-border text-text-tertiary'
|
||||
}`}
|
||||
>
|
||||
{t(`tasks.${s}`)}
|
||||
@@ -453,7 +453,7 @@ export default function Tasks() {
|
||||
<select
|
||||
value={filterPriority}
|
||||
onChange={e => setFilterPriority(e.target.value)}
|
||||
className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-white text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
|
||||
className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
|
||||
>
|
||||
<option value="">{t('tasks.allPriorities')}</option>
|
||||
<option value="low">{t('tasks.priority.low')}</option>
|
||||
@@ -466,7 +466,7 @@ export default function Tasks() {
|
||||
<select
|
||||
value={filterAssignee}
|
||||
onChange={e => setFilterAssignee(e.target.value)}
|
||||
className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-white text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
|
||||
className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
|
||||
>
|
||||
<option value="">{t('tasks.allAssignees')}</option>
|
||||
{(assignableUsers || []).map(m => (
|
||||
@@ -479,7 +479,7 @@ export default function Tasks() {
|
||||
<select
|
||||
value={filterCreator}
|
||||
onChange={e => setFilterCreator(e.target.value)}
|
||||
className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-white text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
|
||||
className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
|
||||
>
|
||||
<option value="">{t('tasks.allCreators')}</option>
|
||||
{users.map(m => (
|
||||
@@ -501,7 +501,7 @@ export default function Tasks() {
|
||||
type="date"
|
||||
value={filterDateFrom}
|
||||
onChange={e => { setFilterDateFrom(e.target.value); setActivePreset('') }}
|
||||
className="px-2 py-1.5 text-xs border border-border rounded-lg bg-white text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
|
||||
className="px-2 py-1.5 text-xs border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
|
||||
title={t('posts.periodFrom')}
|
||||
/>
|
||||
<span className="text-text-tertiary text-xs">-</span>
|
||||
@@ -509,7 +509,7 @@ export default function Tasks() {
|
||||
type="date"
|
||||
value={filterDateTo}
|
||||
onChange={e => { setFilterDateTo(e.target.value); setActivePreset('') }}
|
||||
className="px-2 py-1.5 text-xs border border-border rounded-lg bg-white text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
|
||||
className="px-2 py-1.5 text-xs border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
|
||||
title={t('posts.periodTo')}
|
||||
/>
|
||||
</div>
|
||||
@@ -520,7 +520,7 @@ export default function Tasks() {
|
||||
className={`px-2.5 py-1 text-[11px] font-medium rounded-full border transition-colors ${
|
||||
filterOverdue
|
||||
? 'bg-red-50 border-red-200 text-red-600'
|
||||
: 'bg-white border-border text-text-tertiary'
|
||||
: 'bg-surface border-border text-text-tertiary'
|
||||
}`}
|
||||
>
|
||||
{t('tasks.overdue')}
|
||||
@@ -599,7 +599,7 @@ export default function Tasks() {
|
||||
onDelete={() => setShowBulkDeleteConfirm(true)}
|
||||
/>
|
||||
)}
|
||||
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
||||
<div className="bg-surface rounded-xl border border-border overflow-clip">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface-secondary/50">
|
||||
@@ -614,28 +614,28 @@ export default function Tasks() {
|
||||
</th>
|
||||
<th className="w-8 px-3 py-2.5"></th>
|
||||
<th
|
||||
className="text-left px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase cursor-pointer hover:text-text-primary"
|
||||
className="text-start px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase cursor-pointer hover:text-text-primary"
|
||||
onClick={() => toggleSort('title')}
|
||||
>
|
||||
{t('tasks.taskTitle')} {sortBy === 'title' && (sortDir === 'asc' ? '↑' : '↓')}
|
||||
</th>
|
||||
<th className="text-left px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase">{t('tasks.project')}</th>
|
||||
<th className="text-left px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase">{t('tasks.brand')}</th>
|
||||
<th className="text-start px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase">{t('tasks.project')}</th>
|
||||
<th className="text-start px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase">{t('tasks.brand')}</th>
|
||||
<th
|
||||
className="text-left px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase cursor-pointer hover:text-text-primary"
|
||||
className="text-start px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase cursor-pointer hover:text-text-primary"
|
||||
onClick={() => toggleSort('status')}
|
||||
>
|
||||
{t('tasks.status')} {sortBy === 'status' && (sortDir === 'asc' ? '↑' : '↓')}
|
||||
</th>
|
||||
<th className="text-left px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase">{t('tasks.assignee')}</th>
|
||||
<th className="text-start px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase">{t('tasks.assignee')}</th>
|
||||
<th
|
||||
className="text-left px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase cursor-pointer hover:text-text-primary"
|
||||
className="text-start px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase cursor-pointer hover:text-text-primary"
|
||||
onClick={() => toggleSort('due_date')}
|
||||
>
|
||||
{t('tasks.dueDate')} {sortBy === 'due_date' && (sortDir === 'asc' ? '↑' : '↓')}
|
||||
</th>
|
||||
<th
|
||||
className="text-left px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase cursor-pointer hover:text-text-primary"
|
||||
className="text-start px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase cursor-pointer hover:text-text-primary"
|
||||
onClick={() => toggleSort('priority')}
|
||||
>
|
||||
{t('tasks.priority')} {sortBy === 'priority' && (sortDir === 'asc' ? '↑' : '↓')}
|
||||
@@ -651,7 +651,7 @@ export default function Tasks() {
|
||||
const brandName = task.brand_name || task.brandName
|
||||
const assignedName = task.assigned_name || task.assignedName
|
||||
const statusLabels = { todo: t('tasks.todo'), in_progress: t('tasks.in_progress'), done: t('tasks.done') }
|
||||
const statusColors = { todo: 'bg-gray-100 text-gray-600', in_progress: 'bg-blue-100 text-blue-700', done: 'bg-emerald-100 text-emerald-700' }
|
||||
const statusColors = { todo: 'bg-gray-100 text-text-secondary', in_progress: 'bg-blue-100 text-blue-700', done: 'bg-emerald-100 text-emerald-700' }
|
||||
|
||||
return (
|
||||
<tr
|
||||
@@ -675,7 +675,7 @@ export default function Tasks() {
|
||||
{task.title}
|
||||
</span>
|
||||
{(task.comment_count || task.commentCount) > 0 && (
|
||||
<span className="ml-2 text-[10px] text-text-tertiary">💬 {task.comment_count || task.commentCount}</span>
|
||||
<span className="ms-2 text-[10px] text-text-tertiary">💬 {task.comment_count || task.commentCount}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-text-tertiary text-xs">{projectName || '—'}</td>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useContext, useRef } from 'react'
|
||||
import { useState, useEffect, useContext, useRef, useMemo } from 'react'
|
||||
import { Plus, Users, ArrowLeft, User as UserIcon, Edit2, LayoutGrid, Network, Link2, ChevronDown, Check, X } from 'lucide-react'
|
||||
import { getInitials } from '../utils/api'
|
||||
import { AppContext, PERMISSION_LEVELS } from '../App'
|
||||
@@ -16,9 +16,9 @@ import { useToast } from '../components/ToastContainer'
|
||||
const ALL_MODULES = ['marketing', 'projects', 'finance']
|
||||
const MODULE_LABELS = { marketing: 'Marketing', projects: 'Projects', finance: 'Finance' }
|
||||
const MODULE_COLORS = {
|
||||
marketing: { on: 'bg-emerald-100 text-emerald-700 border-emerald-300', off: 'bg-gray-100 text-gray-400 border-gray-200' },
|
||||
projects: { on: 'bg-blue-100 text-blue-700 border-blue-300', off: 'bg-gray-100 text-gray-400 border-gray-200' },
|
||||
finance: { on: 'bg-amber-100 text-amber-700 border-amber-300', off: 'bg-gray-100 text-gray-400 border-gray-200' },
|
||||
marketing: { on: 'bg-emerald-100 text-emerald-700 border-emerald-300', off: 'bg-gray-100 text-text-tertiary border-gray-200' },
|
||||
projects: { on: 'bg-blue-100 text-blue-700 border-blue-300', off: 'bg-gray-100 text-text-tertiary border-gray-200' },
|
||||
finance: { on: 'bg-amber-100 text-amber-700 border-amber-300', off: 'bg-gray-100 text-text-tertiary border-gray-200' },
|
||||
}
|
||||
|
||||
const EMPTY_MEMBER = {
|
||||
@@ -238,9 +238,11 @@ export default function Team() {
|
||||
|
||||
// Member detail view
|
||||
if (selectedMember) {
|
||||
const todoCount = memberTasks.filter(t => t.status === 'todo').length
|
||||
const inProgressCount = memberTasks.filter(t => t.status === 'in_progress').length
|
||||
const doneCount = memberTasks.filter(t => t.status === 'done').length
|
||||
const { todoCount, inProgressCount, doneCount } = useMemo(() => ({
|
||||
todoCount: memberTasks.filter(t => t.status === 'todo').length,
|
||||
inProgressCount: memberTasks.filter(t => t.status === 'in_progress').length,
|
||||
doneCount: memberTasks.filter(t => t.status === 'done').length,
|
||||
}), [memberTasks])
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
@@ -253,7 +255,7 @@ export default function Team() {
|
||||
</button>
|
||||
|
||||
{/* Member profile */}
|
||||
<div className="bg-white rounded-xl border border-border p-6">
|
||||
<div className="bg-surface rounded-xl border border-border p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className={`w-16 h-16 rounded-full bg-gradient-to-br from-indigo-400 to-purple-500 flex items-center justify-center text-white text-xl font-bold`}>
|
||||
{selectedMember.name?.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase()}
|
||||
@@ -281,19 +283,19 @@ export default function Team() {
|
||||
|
||||
{/* Workload stats */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl border border-border p-4 text-center">
|
||||
<div className="bg-surface rounded-xl border border-border p-4 text-center">
|
||||
<p className="text-2xl font-bold text-text-primary">{memberTasks.length}</p>
|
||||
<p className="text-xs text-text-tertiary">{t('team.totalTasks')}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-border p-4 text-center">
|
||||
<div className="bg-surface rounded-xl border border-border p-4 text-center">
|
||||
<p className="text-2xl font-bold text-amber-500">{todoCount}</p>
|
||||
<p className="text-xs text-text-tertiary">{t('team.toDo')}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-border p-4 text-center">
|
||||
<div className="bg-surface rounded-xl border border-border p-4 text-center">
|
||||
<p className="text-2xl font-bold text-blue-500">{inProgressCount}</p>
|
||||
<p className="text-xs text-text-tertiary">{t('team.inProgress')}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-border p-4 text-center">
|
||||
<div className="bg-surface rounded-xl border border-border p-4 text-center">
|
||||
<p className="text-2xl font-bold text-emerald-500">{doneCount}</p>
|
||||
<p className="text-xs text-text-tertiary">{t('tasks.done')}</p>
|
||||
</div>
|
||||
@@ -302,7 +304,7 @@ export default function Team() {
|
||||
{/* Tasks & Posts */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Tasks */}
|
||||
<div className="bg-white rounded-xl border border-border">
|
||||
<div className="bg-surface rounded-xl border border-border">
|
||||
<div className="px-5 py-4 border-b border-border">
|
||||
<h3 className="font-semibold text-text-primary">{t('tasks.title')} ({memberTasks.length})</h3>
|
||||
</div>
|
||||
@@ -327,7 +329,7 @@ export default function Team() {
|
||||
</div>
|
||||
|
||||
{/* Posts */}
|
||||
<div className="bg-white rounded-xl border border-border">
|
||||
<div className="bg-surface rounded-xl border border-border">
|
||||
<div className="px-5 py-4 border-b border-border">
|
||||
<h3 className="font-semibold text-text-primary">{t('nav.posts')} ({memberPosts.length})</h3>
|
||||
</div>
|
||||
@@ -394,7 +396,7 @@ export default function Team() {
|
||||
{displayedMembers.length} {displayedMembers.length !== 1 ? t('team.membersPlural') : t('team.member')}
|
||||
</p>
|
||||
{/* View toggle */}
|
||||
<div className="flex items-center bg-white border border-border rounded-lg overflow-hidden">
|
||||
<div className="flex items-center bg-surface border border-border rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => setViewMode('grid')}
|
||||
className={`p-2 transition-colors ${viewMode === 'grid' ? 'bg-brand-primary text-white' : 'text-text-tertiary hover:text-text-primary'}`}
|
||||
@@ -415,7 +417,7 @@ export default function Team() {
|
||||
{/* Copy generic issue link */}
|
||||
<button
|
||||
onClick={() => copyIssueLink()}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white border border-border text-text-primary rounded-lg text-sm font-medium hover:bg-surface-secondary transition-colors"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-surface border border-border text-text-primary rounded-lg text-sm font-medium hover:bg-surface-secondary transition-colors"
|
||||
title={t('team.copyGenericIssueLink')}
|
||||
>
|
||||
<Link2 className="w-4 h-4" />
|
||||
@@ -428,7 +430,7 @@ export default function Team() {
|
||||
const self = teamMembers.find(m => m._id === user?.id || m.id === user?.id)
|
||||
if (self) openEdit(self)
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white border border-border text-text-primary rounded-lg text-sm font-medium hover:bg-surface-secondary transition-colors"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-surface border border-border text-text-primary rounded-lg text-sm font-medium hover:bg-surface-secondary transition-colors"
|
||||
>
|
||||
<UserIcon className="w-4 h-4" />
|
||||
{t('team.myProfile')}
|
||||
@@ -438,7 +440,7 @@ export default function Team() {
|
||||
{canManageTeam && (
|
||||
<button
|
||||
onClick={() => setPanelTeam({})}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white border border-border text-text-primary rounded-lg text-sm font-medium hover:bg-surface-secondary transition-colors"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-surface border border-border text-text-primary rounded-lg text-sm font-medium hover:bg-surface-secondary transition-colors"
|
||||
>
|
||||
<Users className="w-4 h-4" />
|
||||
{t('teams.createTeam')}
|
||||
@@ -468,7 +470,7 @@ export default function Team() {
|
||||
<button
|
||||
onClick={() => setTeamFilter(null)}
|
||||
className={`text-xs px-3 py-1.5 rounded-full border font-medium transition-colors ${
|
||||
!teamFilter ? 'bg-brand-primary text-white border-brand-primary' : 'bg-white text-text-secondary border-border hover:bg-surface-tertiary'
|
||||
!teamFilter ? 'bg-brand-primary text-white border-brand-primary' : 'bg-surface text-text-secondary border-border hover:bg-surface-tertiary'
|
||||
}`}
|
||||
>
|
||||
{t('common.all')}
|
||||
@@ -481,7 +483,7 @@ export default function Team() {
|
||||
<button
|
||||
onClick={() => setTeamFilter(active ? null : tid)}
|
||||
className={`text-xs px-3 py-1.5 rounded-full border font-medium transition-colors ${
|
||||
active ? 'bg-blue-600 text-white border-blue-600' : 'bg-white text-text-secondary border-border hover:bg-surface-tertiary'
|
||||
active ? 'bg-blue-600 text-white border-blue-600' : 'bg-surface text-text-secondary border-border hover:bg-surface-tertiary'
|
||||
}`}
|
||||
>
|
||||
{team.name} ({team.member_count || 0})
|
||||
@@ -531,7 +533,7 @@ export default function Team() {
|
||||
const tid = team.id || team._id
|
||||
const members = teamMembers.filter(m => m.teams?.some(t => t.id === tid))
|
||||
return (
|
||||
<div key={tid} className="bg-white rounded-xl border border-border overflow-hidden">
|
||||
<div key={tid} className="bg-surface rounded-xl border border-border overflow-clip">
|
||||
{/* Team header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 bg-gradient-to-r from-blue-50 to-indigo-50 border-b border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -601,7 +603,7 @@ export default function Team() {
|
||||
|
||||
{/* Unassigned members */}
|
||||
{unassignedMembers.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
||||
<div className="bg-surface rounded-xl border border-border overflow-clip">
|
||||
<div className="flex items-center gap-3 px-5 py-4 bg-gray-50 border-b border-border">
|
||||
<div className="w-10 h-10 rounded-lg bg-gray-400 flex items-center justify-center text-white">
|
||||
<UserIcon className="w-5 h-5" />
|
||||
@@ -707,7 +709,7 @@ export default function Team() {
|
||||
<div ref={addBrandsRef} className="relative">
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.brands')}</label>
|
||||
<button type="button" onClick={() => setShowAddBrandsDropdown(p => !p)}
|
||||
className="w-full flex items-center justify-between px-3 py-2 text-sm border border-border rounded-lg bg-white text-left focus:outline-none focus:ring-2 focus:ring-brand-primary/20">
|
||||
className="w-full flex items-center justify-between px-3 py-2 text-sm border border-border rounded-lg bg-surface text-start focus:outline-none focus:ring-2 focus:ring-brand-primary/20">
|
||||
<span className={`flex-1 truncate ${addForm.brands.length === 0 ? 'text-text-tertiary' : 'text-text-primary'}`}>
|
||||
{addForm.brands.length === 0 ? t('team.selectBrands') : addForm.brands.join(', ')}
|
||||
</span>
|
||||
@@ -724,13 +726,13 @@ export default function Team() {
|
||||
</div>
|
||||
)}
|
||||
{showAddBrandsDropdown && (
|
||||
<div className="absolute z-20 mt-1 w-full bg-white border border-border rounded-lg shadow-lg max-h-48 overflow-y-auto">
|
||||
<div className="absolute z-20 mt-1 w-full bg-surface border border-border rounded-lg shadow-lg max-h-48 overflow-y-auto">
|
||||
{brands.map(brand => {
|
||||
const name = lang === 'ar' && brand.name_ar ? brand.name_ar : brand.name
|
||||
const checked = addForm.brands.includes(name)
|
||||
return (
|
||||
<button key={brand.id || brand._id} type="button" onClick={() => toggleAddBrand(name)}
|
||||
className={`w-full flex items-center gap-2.5 px-3 py-2 hover:bg-surface-secondary transition-colors text-left ${checked ? 'bg-brand-primary/5' : ''}`}>
|
||||
className={`w-full flex items-center gap-2.5 px-3 py-2 hover:bg-surface-secondary transition-colors text-start ${checked ? 'bg-brand-primary/5' : ''}`}>
|
||||
<div className={`w-4 h-4 rounded border flex items-center justify-center shrink-0 ${checked ? 'bg-brand-primary border-brand-primary' : 'border-border'}`}>
|
||||
{checked && <Check className="w-3 h-3 text-white" />}
|
||||
</div>
|
||||
@@ -771,7 +773,7 @@ export default function Team() {
|
||||
return (
|
||||
<button key={tid} type="button"
|
||||
onClick={() => updateAdd('team_ids', active ? addForm.team_ids.filter(id => id !== tid) : [...addForm.team_ids, tid])}
|
||||
className={`text-xs px-3 py-1.5 rounded-full border font-medium transition-colors ${active ? 'bg-blue-100 text-blue-700 border-blue-300' : 'bg-gray-100 text-gray-400 border-gray-200'}`}>
|
||||
className={`text-xs px-3 py-1.5 rounded-full border font-medium transition-colors ${active ? 'bg-blue-100 text-blue-700 border-blue-300' : 'bg-gray-100 text-text-tertiary border-gray-200'}`}>
|
||||
{team.name}
|
||||
</button>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useContext, useMemo } from 'react'
|
||||
import { Plus, Search, LayoutGrid, List, ChevronUp, ChevronDown, Languages, Globe } from 'lucide-react'
|
||||
import { Plus, Search, LayoutGrid, List, ChevronUp, ChevronDown, Languages, Globe, FileEdit } from 'lucide-react'
|
||||
import { AppContext } from '../App'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
@@ -10,21 +10,8 @@ import { useToast } from '../components/ToastContainer'
|
||||
import { SkeletonCard, SkeletonTable } from '../components/SkeletonLoader'
|
||||
import TranslationDetailPanel from '../components/TranslationDetailPanel'
|
||||
import ApproverMultiSelect from '../components/ApproverMultiSelect'
|
||||
import { AVAILABLE_LANGUAGES, TRANSLATION_STATUS_COLORS } from '../utils/translations'
|
||||
|
||||
const STATUS_COLORS = {
|
||||
draft: 'bg-surface-tertiary text-text-secondary',
|
||||
pending_review: 'bg-amber-100 text-amber-700',
|
||||
approved: 'bg-emerald-100 text-emerald-700',
|
||||
rejected: 'bg-red-100 text-red-700',
|
||||
revision_requested: 'bg-orange-100 text-orange-700',
|
||||
}
|
||||
|
||||
const AVAILABLE_LANGUAGES = [
|
||||
{ code: 'AR', label: 'العربية' },
|
||||
{ code: 'EN', label: 'English' },
|
||||
{ code: 'FR', label: 'Français' },
|
||||
{ code: 'ID', label: 'Bahasa Indonesia' },
|
||||
]
|
||||
|
||||
const SORT_OPTIONS = [
|
||||
{ value: 'updated_at', dir: 'desc', labelKey: 'translations.sortRecentlyUpdated' },
|
||||
@@ -45,8 +32,12 @@ export default function Translations() {
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const [selectedTranslation, setSelectedTranslation] = useState(null)
|
||||
const [newTranslation, setNewTranslation] = useState({ title: '', description: '', source_language: 'EN', source_content: '', brand_id: '', approver_ids: [] })
|
||||
const [newTranslation, setNewTranslation] = useState({ title: '', source_language: 'EN', source_content: '', brand_id: '', post_id: '', approver_ids: [] })
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [posts, setPosts] = useState([])
|
||||
const [showCreatePost, setShowCreatePost] = useState(false)
|
||||
const [newPostTitle, setNewPostTitle] = useState('')
|
||||
const [creatingPost, setCreatingPost] = useState(false)
|
||||
|
||||
// Bulk select
|
||||
const [selectedIds, setSelectedIds] = useState(new Set())
|
||||
@@ -63,6 +54,7 @@ export default function Translations() {
|
||||
useEffect(() => {
|
||||
loadTranslations()
|
||||
api.get('/users/assignable').then(res => setAssignableUsers(Array.isArray(res) ? res : [])).catch(() => {})
|
||||
api.get('/posts').then(res => setPosts(Array.isArray(res) ? res : [])).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const loadTranslations = async () => {
|
||||
@@ -91,10 +83,11 @@ export default function Translations() {
|
||||
const created = await api.post('/translations', {
|
||||
...newTranslation,
|
||||
approver_ids: newTranslation.approver_ids.length > 0 ? newTranslation.approver_ids.join(',') : null,
|
||||
post_id: newTranslation.post_id || null,
|
||||
})
|
||||
toast.success(t('translations.created'))
|
||||
setShowCreateModal(false)
|
||||
setNewTranslation({ title: '', description: '', source_language: 'EN', source_content: '', brand_id: '', approver_ids: [] })
|
||||
setNewTranslation({ title: '', source_language: 'EN', source_content: '', brand_id: '', post_id: '', approver_ids: [] })
|
||||
loadTranslations()
|
||||
setSelectedTranslation(created)
|
||||
} catch (err) {
|
||||
@@ -116,6 +109,24 @@ export default function Translations() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreatePost = async () => {
|
||||
if (!newPostTitle.trim()) return
|
||||
setCreatingPost(true)
|
||||
try {
|
||||
const created = await api.post('/posts', { title: newPostTitle, status: 'draft' })
|
||||
const postId = created.Id || created.id || created._id
|
||||
setPosts(prev => [created, ...prev])
|
||||
setNewTranslation(f => ({ ...f, post_id: String(postId) }))
|
||||
setShowCreatePost(false)
|
||||
setNewPostTitle('')
|
||||
toast.success(t('translations.postCreated'))
|
||||
} catch (err) {
|
||||
toast.error(t('translations.postCreateFailed'))
|
||||
} finally {
|
||||
setCreatingPost(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
try {
|
||||
await api.post('/translations/bulk-delete', { ids: [...selectedIds] })
|
||||
@@ -178,8 +189,8 @@ export default function Translations() {
|
||||
const SortIcon = ({ col }) => {
|
||||
if (listSortBy !== col) return null
|
||||
return listSortDir === 'asc'
|
||||
? <ChevronUp className="w-3 h-3 inline ml-0.5" />
|
||||
: <ChevronDown className="w-3 h-3 inline ml-0.5" />
|
||||
? <ChevronUp className="w-3 h-3 inline ms-0.5" />
|
||||
: <ChevronDown className="w-3 h-3 inline ms-0.5" />
|
||||
}
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
@@ -208,7 +219,7 @@ export default function Translations() {
|
||||
onClick={() => setViewMode(mode)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
|
||||
viewMode === mode
|
||||
? 'bg-white text-text-primary shadow-sm'
|
||||
? 'bg-surface text-text-primary shadow-sm'
|
||||
: 'text-text-tertiary hover:text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
@@ -231,13 +242,13 @@ export default function Translations() {
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="relative flex-1 min-w-[200px] max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
|
||||
<Search className="absolute start-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('translations.searchTranslations')}
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface transition-colors"
|
||||
className="w-full ps-10 pe-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -314,7 +325,7 @@ export default function Translations() {
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-text-primary line-clamp-1">{tr.title}</h3>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${STATUS_COLORS[tr.status] || 'bg-surface-tertiary text-text-secondary'}`}>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${TRANSLATION_STATUS_COLORS[tr.status] || 'bg-surface-tertiary text-text-secondary'}`}>
|
||||
{tr.status?.replace('_', ' ')}
|
||||
</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-blue-50 text-blue-600 font-medium">
|
||||
@@ -323,11 +334,9 @@ export default function Translations() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{tr.description && (
|
||||
<p className="text-sm text-text-secondary line-clamp-2 mb-3">{tr.description}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 text-xs text-text-tertiary flex-wrap">
|
||||
{tr.brand_name && <span>{tr.brand_name}</span>}
|
||||
{tr.post_name && <span className="flex items-center gap-1"><FileEdit className="w-3 h-3" />{tr.post_name}</span>}
|
||||
{tr.creator_name && <span>by {tr.creator_name}</span>}
|
||||
<span>{tr.translation_count || 0} {t('translations.languagesCount')}</span>
|
||||
</div>
|
||||
@@ -343,26 +352,26 @@ export default function Translations() {
|
||||
<p className="text-text-secondary">{t('translations.noTranslations')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-surface rounded-xl border border-border overflow-hidden">
|
||||
<div className="bg-surface rounded-xl border border-border overflow-clip">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface-secondary">
|
||||
<th className="px-4 py-3 text-left w-10">
|
||||
<th className="px-4 py-3 text-start w-10">
|
||||
<input type="checkbox" checked={selectedIds.size === sortedTranslations.length && sortedTranslations.length > 0} onChange={toggleSelectAll} className="rounded border-border" />
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer" onClick={() => toggleListSort('title')}>
|
||||
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase cursor-pointer" onClick={() => toggleListSort('title')}>
|
||||
{t('translations.titleLabel')} <SortIcon col="title" />
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">
|
||||
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">
|
||||
{t('translations.sourceLanguage')}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer" onClick={() => toggleListSort('status')}>
|
||||
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase cursor-pointer" onClick={() => toggleListSort('status')}>
|
||||
{t('translations.status')} <SortIcon col="status" />
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('translations.brand')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('translations.creator')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('translations.languagesLabel')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer" onClick={() => toggleListSort('updated_at')}>
|
||||
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('translations.brand')}</th>
|
||||
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('translations.creator')}</th>
|
||||
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('translations.languagesLabel')}</th>
|
||||
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase cursor-pointer" onClick={() => toggleListSort('updated_at')}>
|
||||
{t('translations.updated')} <SortIcon col="updated_at" />
|
||||
</th>
|
||||
</tr>
|
||||
@@ -384,7 +393,7 @@ export default function Translations() {
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${STATUS_COLORS[tr.status] || 'bg-surface-tertiary text-text-secondary'}`}>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${TRANSLATION_STATUS_COLORS[tr.status] || 'bg-surface-tertiary text-text-secondary'}`}>
|
||||
{tr.status?.replace('_', ' ')}
|
||||
</span>
|
||||
</td>
|
||||
@@ -408,6 +417,7 @@ export default function Translations() {
|
||||
onUpdate={loadTranslations}
|
||||
onDelete={handleDelete}
|
||||
assignableUsers={assignableUsers}
|
||||
posts={posts}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -454,6 +464,53 @@ export default function Translations() {
|
||||
{brands.map(b => <option key={b._id} value={b._id}>{b.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('translations.linkedPost')}</label>
|
||||
{showCreatePost ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newPostTitle}
|
||||
onChange={e => setNewPostTitle(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleCreatePost()}
|
||||
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"
|
||||
placeholder={t('translations.newPostTitle')}
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={handleCreatePost}
|
||||
disabled={creatingPost || !newPostTitle.trim()}
|
||||
className="px-3 py-2 bg-brand-primary text-white text-sm rounded-lg hover:bg-brand-primary-light disabled:opacity-50"
|
||||
>
|
||||
{creatingPost ? '...' : t('common.create')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowCreatePost(false)}
|
||||
className="px-2 py-2 text-sm text-text-secondary hover:text-text-primary"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={newTranslation.post_id}
|
||||
onChange={e => setNewTranslation(f => ({ ...f, post_id: 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"
|
||||
>
|
||||
<option value="">—</option>
|
||||
{posts.map(p => <option key={p.Id || p.id || p._id} value={p.Id || p.id || p._id}>{p.title}</option>)}
|
||||
</select>
|
||||
<button
|
||||
onClick={() => setShowCreatePost(true)}
|
||||
className="flex items-center gap-1 px-3 py-2 text-sm text-brand-primary hover:text-brand-primary/80 font-medium whitespace-nowrap"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
{t('translations.createPost')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('translations.approvers')}</label>
|
||||
<ApproverMultiSelect
|
||||
@@ -462,15 +519,6 @@ export default function Translations() {
|
||||
users={assignableUsers}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('translations.description')}</label>
|
||||
<textarea
|
||||
value={newTranslation.description}
|
||||
onChange={e => setNewTranslation(f => ({ ...f, description: 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 min-h-[60px] resize-y"
|
||||
placeholder={t('translations.descriptionPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
|
||||
<button onClick={() => setShowCreateModal(false)} className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg">
|
||||
{t('common.cancel')}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
export const PLATFORM_FORMATS = {
|
||||
instagram: [
|
||||
{ key: 'ig_feed', label: 'Feed (1:1)', ratio: '1:1' },
|
||||
{ key: 'ig_story', label: 'Story (9:16)', ratio: '9:16' },
|
||||
{ key: 'ig_reel', label: 'Reel (9:16)', ratio: '9:16' },
|
||||
],
|
||||
tiktok: [
|
||||
{ key: 'tt_video', label: 'TikTok (9:16)', ratio: '9:16' },
|
||||
],
|
||||
youtube: [
|
||||
{ key: 'yt_video', label: 'YouTube (16:9)', ratio: '16:9' },
|
||||
{ key: 'yt_short', label: 'Short (9:16)', ratio: '9:16' },
|
||||
{ key: 'yt_thumb', label: 'Thumbnail (16:9)', ratio: '16:9' },
|
||||
],
|
||||
facebook: [
|
||||
{ key: 'fb_post', label: 'Post (1:1)', ratio: '1:1' },
|
||||
{ key: 'fb_story', label: 'Story (9:16)', ratio: '9:16' },
|
||||
],
|
||||
twitter: [
|
||||
{ key: 'tw_post', label: 'Post (16:9)', ratio: '16:9' },
|
||||
],
|
||||
linkedin: [
|
||||
{ key: 'li_post', label: 'Post (1:1)', ratio: '1:1' },
|
||||
],
|
||||
snapchat: [
|
||||
{ key: 'sc_snap', label: 'Snap (9:16)', ratio: '9:16' },
|
||||
],
|
||||
}
|
||||
|
||||
export function getFormatsForPlatforms(platforms = []) {
|
||||
const formats = []
|
||||
const seen = new Set()
|
||||
for (const p of platforms) {
|
||||
for (const f of (PLATFORM_FORMATS[p] || [])) {
|
||||
if (!seen.has(f.key)) { seen.add(f.key); formats.push(f) }
|
||||
}
|
||||
}
|
||||
return formats
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
export const AVAILABLE_LANGUAGES = [
|
||||
{ code: 'AR', label: 'العربية' },
|
||||
{ code: 'EN', label: 'English' },
|
||||
{ code: 'FR', label: 'Français' },
|
||||
{ code: 'ID', label: 'Bahasa Indonesia' },
|
||||
]
|
||||
|
||||
export const TRANSLATION_STATUS_COLORS = {
|
||||
draft: 'bg-surface-tertiary text-text-secondary',
|
||||
pending_review: 'bg-amber-100 text-amber-700',
|
||||
approved: 'bg-emerald-100 text-emerald-700',
|
||||
rejected: 'bg-red-100 text-red-700',
|
||||
revision_requested: 'bg-orange-100 text-orange-700',
|
||||
}
|
||||
|
||||
export function isTextSelected(text) {
|
||||
return text.is_selected === true || text.is_selected === 1
|
||||
}
|
||||
|
||||
export function groupTextsByLanguage(texts) {
|
||||
const grouped = {}
|
||||
for (const text of texts) {
|
||||
if (!grouped[text.language_code]) grouped[text.language_code] = []
|
||||
grouped[text.language_code].push(text)
|
||||
}
|
||||
for (const code in grouped) {
|
||||
grouped[code].sort((a, b) => (a.option_number || 1) - (b.option_number || 1))
|
||||
}
|
||||
return grouped
|
||||
}
|
||||
@@ -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,405 @@
|
||||
# Post Composition 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:** Transform Posts from flat entities into composition orchestrators that assemble Caption + Copy (Translations) + Designs (Artefacts) + Video into a publishable unit with auto-computed readiness.
|
||||
|
||||
**Architecture:** Add `caption` and `stage` fields to Posts table. New `/api/posts/:id/composition` endpoint aggregates linked Translations + Artefacts with approval statuses. PostDetailPanel is rewritten as a composition workspace (single scroll, no tabs). Platform→format mapping is a client-side constant.
|
||||
|
||||
**Tech Stack:** Express.js, NocoDB, React, Tailwind CSS
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-03-15-post-composition-redesign.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**Server:**
|
||||
- Modify: `server/server.js` — add `caption`/`stage` to Posts TEXT_COLUMNS, new composition endpoint, update POST/PATCH handlers
|
||||
- Create: `server/post-composition.js` — helper to compute composition, readiness, and stage auto-advance
|
||||
|
||||
**Client — New:**
|
||||
- Create: `client/src/components/PostCompositionPanel.jsx` — the new composition workspace (replaces PostDetailPanel usage)
|
||||
- Create: `client/src/components/PostCompositionCaption.jsx` — caption section
|
||||
- Create: `client/src/components/PostCompositionCopy.jsx` — linked translations section
|
||||
- Create: `client/src/components/PostCompositionDesigns.jsx` — linked design artefacts section
|
||||
- Create: `client/src/components/PostCompositionVideo.jsx` — linked video artefact section
|
||||
- Create: `client/src/components/PostCompositionFormats.jsx` — platform format checklist
|
||||
- Create: `client/src/components/PostCompositionReadiness.jsx` — readiness summary + sign-off
|
||||
- Create: `client/src/utils/platformFormats.js` — PLATFORM_FORMATS constant
|
||||
|
||||
**Client — Modify:**
|
||||
- Modify: `client/src/pages/PostProduction.jsx` — use PostCompositionPanel instead of PostDetailPanel
|
||||
- Modify: `client/src/pages/CampaignDetail.jsx` — same
|
||||
- Modify: `client/src/i18n/en.json` / `ar.json` — new i18n keys
|
||||
|
||||
**Client — Keep (unchanged):**
|
||||
- `PostDetailVersions.jsx`, `PostDetailPlatforms.jsx`, `PostDetailApproval.jsx`, `PostDetailAttachments.jsx` — kept for backward compat, old PostDetailPanel still importable
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: Server — Schema + Composition Endpoint
|
||||
|
||||
### Task 1: Add caption and stage to Posts schema
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/server.js` — TEXT_COLUMNS for Posts (~line 520)
|
||||
|
||||
- [ ] **Step 1: Add new columns to TEXT_COLUMNS**
|
||||
|
||||
Add to the Posts array in TEXT_COLUMNS:
|
||||
```javascript
|
||||
{ name: 'caption', uidt: 'LongText' },
|
||||
{ name: 'stage', uidt: 'SingleLineText' },
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update POST /api/posts to accept caption and stage**
|
||||
|
||||
In the POST handler, add `caption` and `stage` to the create payload:
|
||||
```javascript
|
||||
caption: caption || '',
|
||||
stage: stage || 'copy',
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update PATCH /api/posts/:id to accept caption**
|
||||
|
||||
Add `caption` to the allowed update fields.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add server/server.js
|
||||
git commit -m "feat: add caption and stage fields to Posts schema"
|
||||
```
|
||||
|
||||
### Task 2: Create post-composition helper
|
||||
|
||||
**Files:**
|
||||
- Create: `server/post-composition.js`
|
||||
|
||||
- [ ] **Step 1: Create the helper module**
|
||||
|
||||
```javascript
|
||||
// server/post-composition.js
|
||||
const nocodb = require('./nocodb');
|
||||
|
||||
// Compute full composition for a post
|
||||
async function getPostComposition(postId) {
|
||||
const post = await nocodb.get('Posts', postId);
|
||||
if (!post) return null;
|
||||
|
||||
// Linked translations (copy)
|
||||
const allTranslations = await nocodb.list('Translations', {
|
||||
where: `(post_id,eq,${postId})`,
|
||||
limit: 100,
|
||||
});
|
||||
const copy = allTranslations.map(t => ({
|
||||
id: t.Id,
|
||||
language: t.language,
|
||||
status: t.status || 'draft',
|
||||
is_original: t.is_original,
|
||||
title: t.title,
|
||||
}));
|
||||
|
||||
// Linked artefacts (designs + video)
|
||||
const allArtefacts = await nocodb.list('Artefacts', {
|
||||
where: `(post_id,eq,${postId})`,
|
||||
limit: 100,
|
||||
});
|
||||
const designs = allArtefacts
|
||||
.filter(a => (a.type || 'design') === 'design')
|
||||
.map(a => ({
|
||||
id: a.Id,
|
||||
title: a.title,
|
||||
status: a.status || 'draft',
|
||||
thumbnail_url: a.thumbnail_url || null,
|
||||
}));
|
||||
const videoArtefact = allArtefacts.find(a => a.type === 'video');
|
||||
const video = videoArtefact ? {
|
||||
id: videoArtefact.Id,
|
||||
title: videoArtefact.title,
|
||||
status: videoArtefact.status || 'draft',
|
||||
thumbnail_url: videoArtefact.thumbnail_url || null,
|
||||
} : null;
|
||||
|
||||
// Platforms and formats
|
||||
let platforms = [];
|
||||
try { platforms = JSON.parse(post.platforms || '[]'); } catch { platforms = post.platform ? [post.platform] : []; }
|
||||
|
||||
// Readiness
|
||||
const waitingOn = [];
|
||||
const copyNotApproved = copy.filter(c => c.status !== 'approved');
|
||||
if (copyNotApproved.length > 0) waitingOn.push(...copyNotApproved.map(c => `Copy (${c.language})`));
|
||||
const designsNotApproved = designs.filter(d => d.status !== 'approved');
|
||||
if (designsNotApproved.length > 0) waitingOn.push(...designsNotApproved.map(d => `Design: ${d.title}`));
|
||||
if (video && video.status !== 'approved') waitingOn.push('Video');
|
||||
|
||||
const piecesReady = copy.length > 0 && waitingOn.length === 0;
|
||||
|
||||
return {
|
||||
caption: post.caption || '',
|
||||
copy,
|
||||
designs,
|
||||
video,
|
||||
platforms,
|
||||
pieces_ready: piecesReady,
|
||||
waiting_on: waitingOn,
|
||||
};
|
||||
}
|
||||
|
||||
// Auto-compute stage from linked pieces
|
||||
function computeStage(composition) {
|
||||
const { copy, designs, video, pieces_ready } = composition;
|
||||
if (pieces_ready) return 'post';
|
||||
if (designs.length > 0 || video) return 'design';
|
||||
if (copy.length > 1 || copy.some(c => !c.is_original)) return 'translate';
|
||||
return 'copy';
|
||||
}
|
||||
|
||||
module.exports = { getPostComposition, computeStage };
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add server/post-composition.js
|
||||
git commit -m "feat: add post composition helper (readiness, stage auto-compute)"
|
||||
```
|
||||
|
||||
### Task 3: Add composition API endpoint
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/server.js` — add GET /api/posts/:id/composition
|
||||
|
||||
- [ ] **Step 1: Add the endpoint**
|
||||
|
||||
After the existing GET /api/posts/:id route, add:
|
||||
```javascript
|
||||
app.get('/api/posts/:id/composition', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const { getPostComposition } = require('./post-composition');
|
||||
const composition = await getPostComposition(req.params.id);
|
||||
if (!composition) return res.status(404).json({ error: 'Post not found' });
|
||||
res.json(composition);
|
||||
} catch (err) {
|
||||
console.error('Composition error:', err);
|
||||
res.status(500).json({ error: 'Failed to load composition' });
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Auto-update stage on PATCH /api/posts/:id**
|
||||
|
||||
In the existing PATCH handler, after saving, re-compute and update stage:
|
||||
```javascript
|
||||
const { getPostComposition, computeStage } = require('./post-composition');
|
||||
const composition = await getPostComposition(req.params.id);
|
||||
if (composition) {
|
||||
const newStage = computeStage(composition);
|
||||
await nocodb.update('Posts', Number(req.params.id), { stage: newStage });
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Also auto-update post stage when Translation or Artefact status changes**
|
||||
|
||||
In PATCH /api/translations/:id and PATCH /api/artefacts/:id — if the record has a `post_id`, re-compute the post's stage after saving.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add server/server.js
|
||||
git commit -m "feat: add /posts/:id/composition endpoint + stage auto-update"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: Client — Platform Formats + Composition Sub-Components
|
||||
|
||||
### Task 4: Create platform formats constant
|
||||
|
||||
**Files:**
|
||||
- Create: `client/src/utils/platformFormats.js`
|
||||
|
||||
- [ ] **Step 1: Create the file**
|
||||
|
||||
```javascript
|
||||
export const PLATFORM_FORMATS = {
|
||||
instagram: [
|
||||
{ key: 'ig_feed', label: 'Feed (1:1)', ratio: '1:1' },
|
||||
{ key: 'ig_story', label: 'Story (9:16)', ratio: '9:16' },
|
||||
{ key: 'ig_reel', label: 'Reel (9:16)', ratio: '9:16' },
|
||||
],
|
||||
tiktok: [
|
||||
{ key: 'tt_video', label: 'TikTok (9:16)', ratio: '9:16' },
|
||||
],
|
||||
youtube: [
|
||||
{ key: 'yt_video', label: 'YouTube (16:9)', ratio: '16:9' },
|
||||
{ key: 'yt_short', label: 'Short (9:16)', ratio: '9:16' },
|
||||
{ key: 'yt_thumb', label: 'Thumbnail (16:9)', ratio: '16:9' },
|
||||
],
|
||||
facebook: [
|
||||
{ key: 'fb_post', label: 'Post (1:1)', ratio: '1:1' },
|
||||
{ key: 'fb_story', label: 'Story (9:16)', ratio: '9:16' },
|
||||
],
|
||||
twitter: [
|
||||
{ key: 'tw_post', label: 'Post (16:9)', ratio: '16:9' },
|
||||
],
|
||||
linkedin: [
|
||||
{ key: 'li_post', label: 'Post (1:1)', ratio: '1:1' },
|
||||
],
|
||||
snapchat: [
|
||||
{ key: 'sc_snap', label: 'Snap (9:16)', ratio: '9:16' },
|
||||
],
|
||||
}
|
||||
|
||||
export function getFormatsForPlatforms(platforms = []) {
|
||||
const formats = []
|
||||
const seen = new Set()
|
||||
for (const p of platforms) {
|
||||
for (const f of (PLATFORM_FORMATS[p] || [])) {
|
||||
if (!seen.has(f.key)) { seen.add(f.key); formats.push(f) }
|
||||
}
|
||||
}
|
||||
return formats
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add client/src/utils/platformFormats.js
|
||||
git commit -m "feat: add platform format mapping constant"
|
||||
```
|
||||
|
||||
### Task 5: Create composition sub-components
|
||||
|
||||
**Files:**
|
||||
- Create: `client/src/components/PostCompositionCaption.jsx`
|
||||
- Create: `client/src/components/PostCompositionCopy.jsx`
|
||||
- Create: `client/src/components/PostCompositionDesigns.jsx`
|
||||
- Create: `client/src/components/PostCompositionVideo.jsx`
|
||||
- Create: `client/src/components/PostCompositionFormats.jsx`
|
||||
- Create: `client/src/components/PostCompositionReadiness.jsx`
|
||||
|
||||
Each is a small focused component (~40-80 lines) rendering one section of the composition workspace.
|
||||
|
||||
- [ ] **Step 1: Caption section**
|
||||
|
||||
PostCompositionCaption.jsx — textarea for the social media caption. Props: `caption`, `onChange`, `disabled`.
|
||||
|
||||
- [ ] **Step 2: Copy section**
|
||||
|
||||
PostCompositionCopy.jsx — shows linked translations as language pills with status icons. Props: `copy` (array from composition), `onLink` (opens translation picker), `onCreate` (creates new translation for this post). Each pill is clickable to open the TranslationDetailPanel.
|
||||
|
||||
- [ ] **Step 3: Designs section**
|
||||
|
||||
PostCompositionDesigns.jsx — shows linked design artefacts as thumbnail cards with status badges. Props: `designs` (array), `onLink`, `onCreate`, `onOpen` (opens ArtefactDetailPanel). Shows "+ Add Design" button.
|
||||
|
||||
- [ ] **Step 4: Video section**
|
||||
|
||||
PostCompositionVideo.jsx — shows linked video artefact (0 or 1) as a card. Props: `video` (object or null), `onLink`, `onCreate`, `onOpen`.
|
||||
|
||||
- [ ] **Step 5: Formats checklist**
|
||||
|
||||
PostCompositionFormats.jsx — reads `platforms` from the post, computes needed formats via `getFormatsForPlatforms()`, renders as a checkbox list. This is informational only — checkboxes are manual (designer checks off what they've produced). No database storage for checked state (tracked visually only).
|
||||
|
||||
- [ ] **Step 6: Readiness summary**
|
||||
|
||||
PostCompositionReadiness.jsx — shows bullet list of what's ready and what's blocking. Props: `piecesReady`, `waitingOn` (array of strings), `onSignOff` (callback for approve/schedule button). Sign-off button disabled until `piecesReady` is true.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add client/src/components/PostComposition*.jsx
|
||||
git commit -m "feat: add composition sub-components (caption, copy, designs, video, formats, readiness)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 3: Client — Main Composition Panel + Page Integration
|
||||
|
||||
### Task 6: Create PostCompositionPanel
|
||||
|
||||
**Files:**
|
||||
- Create: `client/src/components/PostCompositionPanel.jsx`
|
||||
|
||||
- [ ] **Step 1: Build the panel**
|
||||
|
||||
This is a SlidePanel-based component (like existing detail panels) but with a composition layout instead of tabs:
|
||||
|
||||
```
|
||||
Header (title input, status, brand, campaign, platforms, assigned_to, close/save/delete)
|
||||
─────────
|
||||
Scrollable body:
|
||||
PostCompositionCaption
|
||||
PostCompositionCopy
|
||||
PostCompositionDesigns
|
||||
PostCompositionVideo
|
||||
PostCompositionFormats
|
||||
PostCompositionReadiness
|
||||
CommentsSection
|
||||
```
|
||||
|
||||
Key behavior:
|
||||
- On mount: fetches composition via `GET /api/posts/:id/composition`
|
||||
- Caption changes are saved with the post (dirty tracking + save button)
|
||||
- Copy/Design/Video sections have "Link existing" and "Create new" actions
|
||||
- "Link existing" opens a small picker modal (list of unlinked translations/artefacts)
|
||||
- "Create new" calls the create API with `post_id` pre-set, then refreshes composition
|
||||
- Readiness section shows sign-off button (sets post status to `approved`)
|
||||
- Each section is a collapsible card (use CollapsibleSection component)
|
||||
|
||||
- [ ] **Step 2: Add i18n keys**
|
||||
|
||||
Add to en.json and ar.json:
|
||||
- `post.caption`, `post.captionPlaceholder`, `post.copy`, `post.copyInDesign`, `post.designs`, `post.video`, `post.formatChecklist`, `post.formatsNeeded`, `post.readiness`, `post.allPiecesReady`, `post.waitingOn`, `post.signOff`, `post.approveAndSchedule`, `post.linkExisting`, `post.createNew`, `post.addDesign`, `post.addVideo`, `post.linkTranslation`, `post.noCopyLinked`, `post.noDesignsLinked`, `post.noVideoLinked`
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add client/src/components/PostCompositionPanel.jsx client/src/i18n/en.json client/src/i18n/ar.json
|
||||
git commit -m "feat: add PostCompositionPanel — composition workspace"
|
||||
```
|
||||
|
||||
### Task 7: Wire up PostCompositionPanel in pages
|
||||
|
||||
**Files:**
|
||||
- Modify: `client/src/pages/PostProduction.jsx`
|
||||
- Modify: `client/src/pages/CampaignDetail.jsx`
|
||||
|
||||
- [ ] **Step 1: Update PostProduction.jsx**
|
||||
|
||||
Replace `PostDetailPanel` import and usage with `PostCompositionPanel`. The panel receives the same props (post, onClose, onSave, onDelete, brands, teamMembers, campaigns) plus the new composition data fetching happens inside the panel.
|
||||
|
||||
- [ ] **Step 2: Update CampaignDetail.jsx**
|
||||
|
||||
Same — replace PostDetailPanel with PostCompositionPanel for post detail views.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add client/src/pages/PostProduction.jsx client/src/pages/CampaignDetail.jsx
|
||||
git commit -m "feat: wire PostCompositionPanel into PostProduction and CampaignDetail"
|
||||
```
|
||||
|
||||
### Task 8: Final verification
|
||||
|
||||
- [ ] **Step 1: Build check**
|
||||
|
||||
```bash
|
||||
cd client && npx vite build --logLevel error
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Manual test checklist**
|
||||
|
||||
1. Open a post → composition panel shows caption, copy, designs, video, formats, readiness
|
||||
2. Edit caption → save → caption persists
|
||||
3. Link an existing translation → appears in copy section with status
|
||||
4. Link an existing artefact → appears in designs section with thumbnail
|
||||
5. Create new design artefact from panel → auto-linked to post
|
||||
6. Select platforms → format checklist updates
|
||||
7. Approve all pieces → readiness shows "All pieces ready"
|
||||
8. Sign off → post status changes to approved
|
||||
9. Stage auto-advances as pieces are linked
|
||||
|
||||
- [ ] **Step 3: Commit any fixes**
|
||||
@@ -0,0 +1,382 @@
|
||||
# UX/UI Overhaul — Design Spec
|
||||
|
||||
## Overview
|
||||
|
||||
Comprehensive UX/UI improvement focused on three pillars: **consistency**, **navigation reorganization**, and **premium polish**. The biggest structural change is unifying Posts, Translations, and Artefacts into a single "Content" page with a pipeline model reflecting the real production workflow.
|
||||
|
||||
## 1. Navigation Reorganization
|
||||
|
||||
### Current State
|
||||
- 4 collapsible module groups (Marketing, Projects, Finance, Issues)
|
||||
- 17 clickable items, some requiring 2 clicks (expand group → click item)
|
||||
- Issues is its own module group with a single item
|
||||
|
||||
### New Structure: Flat Nav with Dividers (9 items)
|
||||
|
||||
```
|
||||
Dashboard
|
||||
──────────────────
|
||||
Campaigns
|
||||
Content ← NEW (replaces Posts, Translations, Artefacts, Calendar)
|
||||
──────────────────
|
||||
Projects ← absorbs Tasks as a tab/view
|
||||
Issues
|
||||
──────────────────
|
||||
Finance ← absorbs Budgets as a tab
|
||||
──────────────────
|
||||
Team
|
||||
Settings ← absorbs Brands, Assets as tabs
|
||||
```
|
||||
|
||||
### Rules
|
||||
- No collapsible groups — every page is 1 click
|
||||
- Subtle divider lines (1px, white at 6% opacity) separate conceptual groups
|
||||
- Active state: existing `sidebar-active-glow` style
|
||||
- Collapsed sidebar: icons only, same as current behavior
|
||||
|
||||
### What Moves Where
|
||||
| Current Location | New Location |
|
||||
|-----------------|-------------|
|
||||
| Posts (page) | Content → Posts tab |
|
||||
| Calendar (page) | Content → Posts tab → Calendar view toggle |
|
||||
| Translations (page) | Content → Copy tab (originals, `is_original=true`) + Translations tab (translated versions, `is_original=false`) |
|
||||
| Artefacts (page) | Content → Design tab |
|
||||
| Assets (page) | Settings → Assets tab |
|
||||
| Brands (page) | Settings → Brands tab |
|
||||
| Tasks (page) | Projects → Tasks tab inside project detail. Unlinked tasks accessible via a global "My Tasks" widget on Dashboard + a "All Tasks" view inside the Projects page (not just per-project). The standalone `/tasks` route redirects to `/projects?tab=tasks`. |
|
||||
| Budgets (page) | Finance → Budgets tab |
|
||||
|
||||
## 2. Content Page — Unified Pipeline
|
||||
|
||||
### Concept
|
||||
The content production pipeline is: **Copy → Translate → Design → Post → Publish**. Currently these are 4 separate pages with no visible connection. The Content page unifies them under one roof with tabs.
|
||||
|
||||
### Tabs
|
||||
|
||||
| Tab | Purpose | Views Available |
|
||||
|-----|---------|----------------|
|
||||
| **Pipeline** | Bird's-eye view of all content items by stage | Kanban (stages as columns) |
|
||||
| **Copy** | Write original copy (usually AR) | List |
|
||||
| **Translations** | Translate/review/correct copy in other languages | List, grouped by language |
|
||||
| **Design** | Create artefacts (images/videos) from approved copy | Grid, List |
|
||||
| **Posts** | Assemble final posts for publishing | Kanban, List, Calendar |
|
||||
|
||||
### Pipeline Tab
|
||||
- Kanban columns: `Copy` → `Translate` → `Design` → `Post` → `Published`
|
||||
- Each card shows: title, stage badge, brand, assignee avatar, approval status icon (✅/⏳/❌)
|
||||
- Drag between columns advances stage (triggers approval flow if applicable)
|
||||
- Campaign grouping: cards from the same campaign share a visual group (shared top border or header)
|
||||
- "New Content" button creates a content item starting at Copy stage
|
||||
|
||||
### Content Item — Data Model
|
||||
|
||||
A **Content Item** is a new NocoDB table (`ContentItems`) that threads all pipeline stages together.
|
||||
|
||||
**ContentItems table schema:**
|
||||
| Column | NocoDB Type | Description |
|
||||
|--------|-----------|-------------|
|
||||
| `Id` | AutoNumber (PK) | NocoDB default |
|
||||
| `title` | SingleLineText | Post concept / idea name |
|
||||
| `stage` | SingleLineText | Current pipeline stage: `copy`, `translate`, `design`, `post`, `published` |
|
||||
| `campaign_id` | Number (FK) | Optional link to Campaigns table |
|
||||
| `brand_id` | Number (FK) | Brand association |
|
||||
| `assignee_id` | Number (FK) | Current stage assignee |
|
||||
| `created_by` | SingleLineText | Creator user ID |
|
||||
|
||||
**Stage is stored explicitly**, not derived. Advancing stage is a manual action (drag on kanban or "Advance" button) that also triggers the approval flow for the new stage. This keeps queries simple and avoids complex derivation logic.
|
||||
|
||||
**FK linkage — existing tables get a `content_item_id` column:**
|
||||
- `Translations` table → `content_item_id` (Number, FK) — for both original copy and translations
|
||||
- `Artefacts` table → `content_item_id` (Number, FK) — for designs/videos
|
||||
- `Posts` table → `content_item_id` (Number, FK) — for the assembled post
|
||||
|
||||
These use the existing `FK_COLUMNS` pattern (Number columns, added via `ensureFKColumns()`).
|
||||
|
||||
**Copy vs Translation distinction:** Both live in the `Translations` table. A copy entry has `is_original: true` (new Boolean column). The Copy tab filters to `is_original = true`, the Translations tab filters to `is_original = false`. No new table needed.
|
||||
|
||||
**Server FK_COLUMNS additions:**
|
||||
```js
|
||||
FK_COLUMNS = {
|
||||
...existing,
|
||||
Translations: [...existing, 'content_item_id'],
|
||||
Artefacts: [...existing, 'content_item_id'],
|
||||
Posts: [...existing, 'content_item_id'],
|
||||
}
|
||||
```
|
||||
|
||||
**Server TEXT_COLUMNS additions:**
|
||||
```js
|
||||
TEXT_COLUMNS = {
|
||||
...existing,
|
||||
Translations: [...existing, { name: 'is_original', uidt: 'Checkbox' }],
|
||||
}
|
||||
```
|
||||
|
||||
### Detail Panel
|
||||
- Pipeline breadcrumb at top: `Copy ✅ → Translate ✅ → Design ⏳ → Post ○`
|
||||
- Clicking a stage in the breadcrumb scrolls to that section in the panel
|
||||
- "Advance to next stage" action button when current stage is approved
|
||||
- All linked items visible in one panel (copy text, translation list, design thumbnails, post config)
|
||||
|
||||
### Approval Flow Per Stage
|
||||
| Stage | Approval Type |
|
||||
|-------|--------------|
|
||||
| Copy | Formal approve/reject |
|
||||
| Translate | Review & correct (lighter — suggestions, not gates) |
|
||||
| Design | Formal approve/reject |
|
||||
| Post | Formal approve/reject (final gate before publish) |
|
||||
|
||||
### Standalone Posts
|
||||
Not every content item needs a campaign or full pipeline. Users can:
|
||||
- Create a post directly from the Posts tab (skips Copy/Translate/Design)
|
||||
- Create copy without linking to a campaign
|
||||
- The pipeline is the recommended flow, not enforced
|
||||
|
||||
### Routing Scheme
|
||||
| Route | Content |
|
||||
|-------|---------|
|
||||
| `/content` | Default → Pipeline tab |
|
||||
| `/content/pipeline` | Pipeline kanban |
|
||||
| `/content/copy` | Copy tab (original texts) |
|
||||
| `/content/translations` | Translations tab |
|
||||
| `/content/design` | Design tab (artefacts) |
|
||||
| `/content/posts` | Posts tab (kanban/list/calendar) |
|
||||
|
||||
**Old route redirects:** `/posts` → `/content/posts`, `/translations` → `/content/translations`, `/artefacts` → `/content/design`, `/calendar` → `/content/posts?view=calendar`. Redirects ensure existing bookmarks and shared links continue to work.
|
||||
|
||||
**Public review routes remain unchanged:** `/review/:token`, `/review-post/:token`, `/review-translation/:token` are not affected by this reorganization.
|
||||
|
||||
## 3. Campaign Brief Enhancement
|
||||
|
||||
### Current State
|
||||
Campaigns exist but are mostly containers for posts. The campaign detail has a calendar timeline view.
|
||||
|
||||
### Enhanced Campaign Brief
|
||||
Campaign detail page becomes a proper strategic document.
|
||||
|
||||
**New columns on Campaigns table:**
|
||||
| Column | NocoDB Type | Description |
|
||||
|--------|-----------|-------------|
|
||||
| `goals` | SingleLineText | Comma-separated from: `awareness`, `engagement`, `conversions`, `brand_building`, `lead_generation` |
|
||||
| `target_audience` | LongText | Free text description of target audience |
|
||||
| `key_messages` | LongText | Free text key messages / talking points |
|
||||
| `reach_target` | Number | Target reach count |
|
||||
| `engagement_target` | Number | Target engagement rate (stored as percentage × 100) |
|
||||
| `conversion_target` | Number | Target conversion count |
|
||||
| `approval_status` | SingleLineText | `draft`, `pending_approval`, `approved`, `rejected` |
|
||||
| `approved_by` | SingleLineText | User ID who approved |
|
||||
| `approved_at` | SingleLineText | ISO timestamp of approval |
|
||||
|
||||
**Campaign detail sections:**
|
||||
- **Brief section**: goals (multi-select chips), target audience, key messages
|
||||
- **Metrics targets**: reach, engagement rate, conversions (numeric inputs)
|
||||
- **Timeline**: keep existing calendar timeline (no changes)
|
||||
- **Budget**: link to finance/budget allocation
|
||||
- **Approval gate**: campaign must be approved (`approval_status = approved`) before "Create Content" button appears
|
||||
- **Content items**: cards showing linked content items with pipeline progress indicators
|
||||
|
||||
### Campaign → Content Flow
|
||||
- Approved campaign shows "Create Content" button
|
||||
- Creates a content item pre-linked to the campaign
|
||||
- Campaign detail shows all its content items with stage progress
|
||||
|
||||
## 4. Dashboard Redesign
|
||||
|
||||
### Current Problem
|
||||
Dashboard feels messy and doesn't answer "what needs my attention?"
|
||||
|
||||
### New Layout
|
||||
|
||||
**Top row — 4 metric cards:**
|
||||
| Card | Content |
|
||||
|------|---------|
|
||||
| Active Campaigns | Count + pending approval count |
|
||||
| Content in Pipeline | Total + breakdown by stage |
|
||||
| Awaiting Your Approval | Items needing your review |
|
||||
| Published This Period | Week/month toggle |
|
||||
|
||||
**Middle — Two columns:**
|
||||
- **Left: Pipeline funnel** — horizontal bar/funnel showing item count per stage. Click any stage → navigates to Content page filtered to that stage.
|
||||
- **Right: My Tasks** — items assigned to current user needing action, sorted by urgency (overdue first, then by due date)
|
||||
|
||||
**Bottom row — Two sections:**
|
||||
- **Left: Recent Activity** — feed of actions (approvals, new content, status changes, comments). Shows avatar + action + item + timestamp.
|
||||
- **Right: Upcoming Deadlines** — content items and campaigns with approaching due dates. Color-coded by urgency (red = overdue, amber = this week, gray = later).
|
||||
|
||||
### Principle
|
||||
Dashboard answers: **"What needs my attention right now?"** — not just display stats.
|
||||
|
||||
### "My Tasks" Widget — Data Sources
|
||||
The "My Tasks" widget aggregates items assigned to the current user from:
|
||||
- **Content items** where `assignee_id = currentUser` and stage needs action
|
||||
- **Approval requests** pending the user's review (from any stage)
|
||||
- **Tasks** assigned to the user (from Projects)
|
||||
- **Issues** assigned to the user
|
||||
|
||||
Sorted by: overdue first, then by due date ascending, then by creation date.
|
||||
|
||||
## 5. Consistency Standards
|
||||
|
||||
### Page Header Pattern
|
||||
Every page uses the same header layout:
|
||||
```
|
||||
[Page Title] [Search] [Filters] [View Toggle] [+ Create]
|
||||
```
|
||||
- Same order, same position on every page
|
||||
- Search: expandable icon → input field (consistent interaction)
|
||||
- Filters: always visible inline (no toggle button to show/hide)
|
||||
- View toggle: far right, before Create button
|
||||
- Create button: always primary style, always rightmost
|
||||
|
||||
### Filter Bar
|
||||
- Always inline, always visible, consistent height
|
||||
- Same dropdown component across all pages
|
||||
- "Clear all" link appears when any filter is active
|
||||
- Active filters show as pills/badges
|
||||
|
||||
### Detail Panels (SlidePanel)
|
||||
- Width: 480px on all pages
|
||||
- Header: `[← Back] Title [⋯ Actions]`
|
||||
- Pipeline breadcrumb at top (for content items)
|
||||
- Tab order: Details → Activity → Approval (consistent)
|
||||
- Save: always top-right in header
|
||||
- Delete: moved from standalone header button to ⋯ overflow menu (intentional change from current Artefacts pattern — reduces accidental deletes, consistent placement)
|
||||
|
||||
### Cards (Kanban/Grid)
|
||||
- Single `KanbanCard` component used everywhere (already exists — enforce it)
|
||||
- Required elements: title, status badge, brand badge, assignee avatar, date
|
||||
- Hover: lift + shadow via existing `card-hover` class
|
||||
|
||||
### Empty States
|
||||
- Always use shared `EmptyState` component
|
||||
- Always include primary action button ("Create your first X")
|
||||
- Consistent icon size, spacing, copy tone
|
||||
|
||||
### Loading States
|
||||
- Skeleton loaders that match actual content dimensions
|
||||
- No bare full-page spinners — always skeletons
|
||||
- Skeleton shapes match the view (card skeletons for grid, row skeletons for list)
|
||||
|
||||
## 6. Premium Polish
|
||||
|
||||
### Transitions & Animations
|
||||
| Element | Animation |
|
||||
|---------|-----------|
|
||||
| Route changes | Fade + slide-up (150ms ease-out) |
|
||||
| Detail panel open | CSS `cubic-bezier(0.34, 1.56, 0.64, 1)` slide-in from right (simulates spring overshoot without a library) |
|
||||
| Kanban drag | Card lifts with shadow + slight rotation (2deg), drop zone pulses |
|
||||
| Status badge change | Color crossfade (not instant swap) |
|
||||
| View toggle (list↔grid) | Crossfade between views |
|
||||
| Toasts | CSS `cubic-bezier(0.34, 1.56, 0.64, 1)` slide-in from top-right, stack with spacing |
|
||||
| Tab active indicator | Slides to follow selection |
|
||||
|
||||
### Hover & Interaction States
|
||||
| Element | Hover Effect |
|
||||
|---------|-------------|
|
||||
| Cards | Lift 2px + deeper shadow + subtle brand-color border glow (10% opacity) |
|
||||
| Buttons | Scale 1.02 on hover, 0.98 on press |
|
||||
| Nav items | Background fills from left (not instant) |
|
||||
| Avatars | Ring glow in role color |
|
||||
|
||||
### Visual Depth & Glass Effects
|
||||
- Detail panel header: frosted glass (`backdrop-blur-xl`), stays visible while body scrolls
|
||||
- Modal backdrop: deeper blur (12px, up from current 4px)
|
||||
- Pipeline cards: layered multi-shadow for realistic depth
|
||||
- Sidebar: subtle inner glow at top edge
|
||||
- Status badges: glass morphism (translucent bg + subtle border)
|
||||
|
||||
### Typography
|
||||
- Page titles: `text-3xl font-light tracking-tight` (up from text-2xl)
|
||||
- Consistent use of `text-text-secondary` variable (no raw gray-* classes)
|
||||
- Numbers/metrics: `tabular-nums`, slightly heavier weight
|
||||
- Tighter heading letter-spacing across the board
|
||||
|
||||
### Empty States — Premium
|
||||
- Illustrated line-art SVG icons from [Iconoir](https://iconoir.com/) library (matching app's line-art aesthetic) instead of plain Lucide icons. Fallback: compose simple illustrations from multiple Lucide icons if Iconoir doesn't have a match.
|
||||
- Subtle gradient background behind icon circle
|
||||
- Friendly, helpful copy ("No content yet — start by writing some copy")
|
||||
|
||||
### Dashboard Widgets — Premium
|
||||
- Pipeline funnel: animated fill on first load (bars grow from left)
|
||||
- Metric cards: gradient left-border accent (stage color), number count-up animation
|
||||
- Activity feed: staggered fade-in (50ms between items)
|
||||
- Metric card hover: gentle pulse on accent border
|
||||
|
||||
## 7. Animation Approach
|
||||
|
||||
All animations use **CSS only** — no motion library dependency (no framer-motion, no react-spring). Spring-like effects approximated with `cubic-bezier(0.34, 1.56, 0.64, 1)`. All animations respect `prefers-reduced-motion: reduce` — when enabled, transitions are instant (duration: 0ms) and all decorative animations are disabled.
|
||||
|
||||
## 8. Migration & Compatibility
|
||||
|
||||
### Route Redirects
|
||||
Old routes redirect to new locations via React Router `<Navigate>`:
|
||||
- `/posts` → `/content/posts`
|
||||
- `/calendar` → `/content/posts?view=calendar`
|
||||
- `/translations` → `/content/translations`
|
||||
- `/artefacts` → `/content/design`
|
||||
- `/assets` → `/settings?tab=assets`
|
||||
- `/brands` → `/settings?tab=brands`
|
||||
- `/tasks` → `/projects?tab=tasks`
|
||||
- `/budgets` → `/finance?tab=budgets`
|
||||
|
||||
### Data Migration
|
||||
- New `ContentItems` table created via `ensureRequiredTables()` on server restart
|
||||
- New columns (`content_item_id`, `is_original`) added via `FK_COLUMNS` / `TEXT_COLUMNS` — auto-created on restart
|
||||
- Campaign brief columns added via `TEXT_COLUMNS` — auto-created on restart
|
||||
- **No data migration needed for existing records** — existing posts/translations/artefacts continue to work without a content_item_id (they're standalone)
|
||||
|
||||
### Rollout Strategy
|
||||
Phased implementation — each phase is independently deployable:
|
||||
1. **Phase 1**: Nav reorganization + consistency standards (no data model changes)
|
||||
2. **Phase 2**: Content page with tabs (restructure existing pages as tab sub-views)
|
||||
3. **Phase 3**: Content Item model + pipeline (new table, FK linkages, pipeline kanban)
|
||||
4. **Phase 4**: Campaign brief enhancement
|
||||
5. **Phase 5**: Dashboard redesign
|
||||
6. **Phase 6**: Premium polish (animations, glass effects, typography)
|
||||
|
||||
### Public Routes
|
||||
Unchanged: `/review/:token`, `/review-post/:token`, `/review-translation/:token` continue to work as-is.
|
||||
|
||||
## 9. Files Impacted
|
||||
|
||||
### Navigation
|
||||
- `client/src/components/Sidebar.jsx` — rewrite nav structure
|
||||
- `client/src/App.jsx` — update routes (remove standalone pages, add Content route)
|
||||
|
||||
### Content Page (new)
|
||||
- `client/src/pages/Content.jsx` — new unified page with tabs
|
||||
- `client/src/components/ContentPipelineBoard.jsx` — new pipeline kanban
|
||||
- `client/src/components/ContentDetailPanel.jsx` — new detail panel with pipeline breadcrumb
|
||||
- Existing pages (`PostProduction.jsx`, `Translations.jsx`, `Artefacts.jsx`) become tab sub-components or are refactored into Content
|
||||
|
||||
### Campaigns
|
||||
- `client/src/pages/Campaigns.jsx` — add brief fields (goals, metrics, audience)
|
||||
- `client/src/pages/CampaignDetail.jsx` — add brief section, content items list, approval gate
|
||||
- `server/server.js` — add campaign brief fields to schema, approval endpoint
|
||||
|
||||
### Dashboard
|
||||
- `client/src/pages/Dashboard.jsx` — full redesign with new widget layout
|
||||
|
||||
### Consistency
|
||||
- `client/src/components/SlidePanel.jsx` — standardize width, header layout
|
||||
- `client/src/components/EmptyState.jsx` — add illustrated SVG variants
|
||||
- `client/src/components/SkeletonLoader.jsx` — match actual content dimensions
|
||||
- All page files — standardize header pattern, filter bar, loading states
|
||||
|
||||
### Polish
|
||||
- `client/src/index.css` — new animations, glass effects, spring transitions, typography updates
|
||||
- `client/src/components/KanbanBoard.jsx` — enhanced drag animations
|
||||
- `client/src/components/KanbanCard.jsx` — premium hover states
|
||||
- `client/src/components/StatusBadge.jsx` — glass morphism variant
|
||||
- `client/src/components/Modal.jsx` — deeper backdrop blur
|
||||
- `client/src/components/Toast.jsx` — spring animations
|
||||
|
||||
### Server
|
||||
- `server/server.js` — content item model, campaign brief fields, pipeline stage tracking
|
||||
- New `ContentItems` table in `REQUIRED_TABLES`
|
||||
- New columns via `FK_COLUMNS`: `content_item_id` on Translations, Artefacts, Posts
|
||||
- New columns via `TEXT_COLUMNS`: `is_original` on Translations, campaign brief fields on Campaigns (goals, target_audience, key_messages, reach_target, engagement_target, conversion_target, approval_status, approved_by, approved_at)
|
||||
|
||||
### i18n
|
||||
- `client/src/i18n/en.json` — new keys for Content page, pipeline stages, dashboard widgets, campaign brief
|
||||
- `client/src/i18n/ar.json` — same keys in Arabic
|
||||
@@ -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)
|
||||
@@ -0,0 +1,226 @@
|
||||
# Post Composition Redesign — Post as Orchestrator
|
||||
|
||||
**Date:** 2026-03-15
|
||||
**Status:** Draft
|
||||
|
||||
## Problem
|
||||
|
||||
The current Post model is flat — a post has a title, description, status, attachments, and platforms. There's no structure showing that a social media post is actually a **composition** of distinct production pieces (caption, in-design copy, design assets, video). The Post detail panel is a disorganized form with tabs that don't map to how content is actually produced.
|
||||
|
||||
Additionally, ContentItems (from the UX overhaul pipeline) duplicates Post metadata and adds confusion about where to create content.
|
||||
|
||||
## Design
|
||||
|
||||
### Post = Orchestrator
|
||||
|
||||
A Post is a container that assembles independently-produced pieces into a publishable unit:
|
||||
|
||||
```
|
||||
Post "Summer Sale Launch"
|
||||
├─ Caption (text field on Post, one base version, minor platform tweaks)
|
||||
├─ Copy (in-design text): linked Translation(s) — approved via Translation flow
|
||||
├─ Design(s): linked Artefact(s) — approved via Artefact flow
|
||||
├─ Video: linked Artefact (optional) — approved via Artefact flow
|
||||
├─ Platforms: [IG, TikTok, YouTube]
|
||||
└─ Format checklist: auto-derived from platforms
|
||||
```
|
||||
|
||||
### Composition Pieces
|
||||
|
||||
| Piece | Storage | Approval | Notes |
|
||||
|-------|---------|----------|-------|
|
||||
| **Caption** | `Post.caption` field (text) | Part of final Post sign-off | The text posted WITH the content (IG caption, tweet text, etc.). One base version with minor platform tweaks (hashtags). Multilingual via existing Translation system if needed. |
|
||||
| **In-design copy** | Translation record (`post_id` FK, `is_original=true`) | Translation approval flow | Text that goes INSIDE the design (overlaid on image/video). Already exists. |
|
||||
| **Design(s)** | Artefact(s) linked via `post_id` FK, `type='design'` | Artefact approval flow | 1..N designs per post (carousel = multiple). Each artefact can have versions. |
|
||||
| **Video** | Artefact linked via `post_id` FK, `type='video'` | Artefact approval flow | 0..1 video per post. Has its own versions/approval. |
|
||||
| **Format specs** | Derived from `Post.platforms` | None (production checklist) | System maps platforms → required formats (IG→1:1, TikTok→9:16, etc.). Designer uses as a guide. |
|
||||
|
||||
### Platform → Format Mapping
|
||||
|
||||
```javascript
|
||||
const PLATFORM_FORMATS = {
|
||||
instagram: [
|
||||
{ key: 'ig_feed', label: 'Feed (1:1)', ratio: '1:1' },
|
||||
{ key: 'ig_story', label: 'Story (9:16)', ratio: '9:16' },
|
||||
{ key: 'ig_reel', label: 'Reel (9:16)', ratio: '9:16' },
|
||||
],
|
||||
tiktok: [
|
||||
{ key: 'tt_video', label: 'TikTok (9:16)', ratio: '9:16' },
|
||||
],
|
||||
youtube: [
|
||||
{ key: 'yt_video', label: 'YouTube (16:9)', ratio: '16:9' },
|
||||
{ key: 'yt_short', label: 'Short (9:16)', ratio: '9:16' },
|
||||
{ key: 'yt_thumb', label: 'Thumbnail (16:9)', ratio: '16:9' },
|
||||
],
|
||||
facebook: [
|
||||
{ key: 'fb_post', label: 'Post (1:1 or 16:9)', ratio: '1:1' },
|
||||
{ key: 'fb_story', label: 'Story (9:16)', ratio: '9:16' },
|
||||
],
|
||||
twitter: [
|
||||
{ key: 'tw_post', label: 'Post (16:9)', ratio: '16:9' },
|
||||
],
|
||||
linkedin: [
|
||||
{ key: 'li_post', label: 'Post (1:1 or 1.91:1)', ratio: '1:1' },
|
||||
],
|
||||
snapchat: [
|
||||
{ key: 'sc_snap', label: 'Snap (9:16)', ratio: '9:16' },
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
This is a **checklist** for the designer, not enforced entities. The Post detail panel shows "Formats needed" based on selected platforms. No separate database records per format.
|
||||
|
||||
### Post Status & Readiness
|
||||
|
||||
**Post status field** (unchanged): `idea` | `in_progress` | `in_review` | `approved` | `rejected` | `scheduled` | `published`
|
||||
|
||||
**Readiness is auto-computed** from pieces:
|
||||
- `pieces_ready`: true when ALL linked Translations are approved AND ALL linked Artefacts are approved
|
||||
- Displayed as: "Ready for sign-off" or "Waiting on: Copy (AR), Video"
|
||||
|
||||
**Final publish flow:**
|
||||
1. All pieces get approved through their own flows
|
||||
2. Post auto-shows "All pieces ready — awaiting sign-off"
|
||||
3. Someone manually moves Post to `approved` or `scheduled`
|
||||
4. Published when scheduled date arrives (or manually)
|
||||
|
||||
### ContentItems Merge
|
||||
|
||||
ContentItems table is removed. Its fields map to Post:
|
||||
- `ContentItems.stage` → `Post.stage` (copy / translate / design / post / published)
|
||||
- `ContentItems.title` → already `Post.title`
|
||||
- `ContentItems.campaign_id` → already `Post.campaign_id`
|
||||
- `ContentItems.brand_id` → already `Post.brand_id`
|
||||
- `ContentItems.assignee_id` → already `Post.assigned_to`
|
||||
|
||||
Stage auto-advances based on what exists:
|
||||
- Post created → stage = `copy`
|
||||
- Translation linked → stage = `translate` (if multiple languages)
|
||||
- Artefact (design) linked → stage = `design`
|
||||
- All pieces approved → stage = `post`
|
||||
- Published → stage = `published`
|
||||
|
||||
### Post Detail Panel — Composition View
|
||||
|
||||
Replace the current tabbed panel with a **composition workspace**:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Header: Title, Status, Brand, Campaign │
|
||||
│ Platforms: [IG] [TikTok] [YouTube] │
|
||||
├─────────────────────────────────────────┤
|
||||
│ │
|
||||
│ CAPTION │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ Textarea: "🔥 Summer deals..." │ │
|
||||
│ │ Platform hashtags: #summer #sale │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ COPY (in-design text) │
|
||||
│ ┌────────┐ ┌────────┐ ┌────────┐ │
|
||||
│ │ EN ✓ │ │ AR ✓ │ │ FR ⏳ │ │
|
||||
│ └────────┘ └────────┘ └────────┘ │
|
||||
│ [Link Translation] or [Create New] │
|
||||
│ │
|
||||
│ DESIGNS │
|
||||
│ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ Slide 1 │ │ Slide 2 │ │
|
||||
│ │ [thumbnail] │ │ [thumbnail] │ │
|
||||
│ │ ✓ Approved │ │ ✓ Approved │ │
|
||||
│ └──────────────┘ └──────────────┘ │
|
||||
│ [Link Artefact] or [Create New] │
|
||||
│ │
|
||||
│ VIDEO (optional) │
|
||||
│ ┌──────────────────────────────────┐ │
|
||||
│ │ [video thumbnail] Reel v2 │ │
|
||||
│ │ ⏳ In Review │ │
|
||||
│ └──────────────────────────────────┘ │
|
||||
│ [Link Artefact] or [Create New] │
|
||||
│ │
|
||||
│ FORMAT CHECKLIST │
|
||||
│ ☑ IG Feed 1:1 ☑ IG Story 9:16 │
|
||||
│ ☑ TikTok 9:16 ☐ YT 16:9 │
|
||||
│ │
|
||||
│ READINESS │
|
||||
│ ● Copy: 2/3 languages approved │
|
||||
│ ● Design: 2/2 approved │
|
||||
│ ● Video: In review │
|
||||
│ [Approve & Schedule] (disabled until │
|
||||
│ all pieces ready) │
|
||||
│ │
|
||||
│ DISCUSSION │
|
||||
│ [comments section] │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
This is a **single scrollable view**, not tabs. Each section is a collapsible card. The readiness summary at the bottom gives a clear picture of what's blocking publication.
|
||||
|
||||
### Schema Changes
|
||||
|
||||
**Post table — add:**
|
||||
- `caption` (LongText) — the social media caption
|
||||
- `stage` (SingleLineText) — pipeline stage: copy/translate/design/post/published
|
||||
|
||||
**Post table — remove:**
|
||||
- `description` (deprecated — copy lives in Translations)
|
||||
|
||||
**Artefact table — ensure:**
|
||||
- `post_id` FK already exists
|
||||
- `type` field already exists (design/video/copy)
|
||||
|
||||
**Translation table — ensure:**
|
||||
- `post_id` FK already exists
|
||||
|
||||
**ContentItems table:**
|
||||
- Delete after migration
|
||||
|
||||
### Migration
|
||||
|
||||
1. For each ContentItem: if no Post exists with matching title + campaign_id, create a Post from it
|
||||
2. Move `stage` values to the new Post.stage field
|
||||
3. Relink any Translations/Artefacts that referenced ContentItem IDs
|
||||
4. Drop ContentItems table (or leave empty, mark deprecated)
|
||||
|
||||
### API Changes
|
||||
|
||||
**POST /api/posts** — add `caption` field
|
||||
**PATCH /api/posts/:id** — add `caption` field, auto-update `stage` based on linked pieces
|
||||
**GET /api/posts/:id** — include linked Translations (with approval status), linked Artefacts (with type + approval status), computed `pieces_ready` boolean, computed `waiting_on` array
|
||||
|
||||
**New helper endpoint:**
|
||||
**GET /api/posts/:id/composition** — returns the full composition view:
|
||||
```json
|
||||
{
|
||||
"caption": "🔥 Summer deals...",
|
||||
"copy": [
|
||||
{ "id": 1, "language": "EN", "status": "approved" },
|
||||
{ "id": 2, "language": "AR", "status": "approved" },
|
||||
{ "id": 3, "language": "FR", "status": "in_review" }
|
||||
],
|
||||
"designs": [
|
||||
{ "id": 10, "title": "Slide 1", "status": "approved", "thumbnail_url": "..." },
|
||||
{ "id": 11, "title": "Slide 2", "status": "approved", "thumbnail_url": "..." }
|
||||
],
|
||||
"video": { "id": 20, "title": "Reel v2", "status": "in_review", "thumbnail_url": "..." },
|
||||
"platforms": ["instagram", "tiktok", "youtube"],
|
||||
"formats_needed": ["ig_feed", "ig_story", "ig_reel", "tt_video", "yt_video", "yt_short", "yt_thumb"],
|
||||
"pieces_ready": false,
|
||||
"waiting_on": ["Copy (FR)", "Video"]
|
||||
}
|
||||
```
|
||||
|
||||
### What Stays the Same
|
||||
|
||||
- Artefact approval flow (unchanged)
|
||||
- Translation approval flow (unchanged)
|
||||
- Post review via public link (unchanged — now reviews the full composition)
|
||||
- Campaign/brand/platform selection on Posts (unchanged)
|
||||
- KanbanBoard for pipeline view (unchanged — works with `stage` or `status`)
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Auto-publishing to social media platforms
|
||||
- Caption AI generation
|
||||
- Design template system
|
||||
- Format-specific cropping tool
|
||||
- Per-platform caption variations (just one caption with manual tweaks)
|
||||
@@ -1,3 +1,4 @@
|
||||
{
|
||||
"uploadMaxSizeMB": 500
|
||||
"uploadMaxSizeMB": 500,
|
||||
"ceoEmail": "fahed@softhouse.io"
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// server/budget-helpers.js — Budget availability calculations
|
||||
// Single source of truth: BudgetEntries table
|
||||
|
||||
const nocodb = require('./nocodb');
|
||||
|
||||
function computeFromEntries(entries) {
|
||||
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 getMainAvailable(prefetchedEntries) {
|
||||
const entries = prefetchedEntries || await nocodb.list('BudgetEntries', { limit: 10000 });
|
||||
return computeFromEntries(entries);
|
||||
}
|
||||
|
||||
async function getCampaignAvailable(campaignId, prefetchedEntries) {
|
||||
const entries = prefetchedEntries || 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, prefetchedEntries) {
|
||||
const entries = prefetchedEntries || 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);
|
||||
}
|
||||
|
||||
async function getAllBudgetData() {
|
||||
const entries = await nocodb.list('BudgetEntries', { limit: 10000 });
|
||||
const main = computeFromEntries(entries);
|
||||
return { entries, ...main };
|
||||
}
|
||||
|
||||
module.exports = { getMainAvailable, getCampaignAvailable, getCampaignAllocatedFromEntries, getAllBudgetData, computeFromEntries };
|
||||
@@ -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 };
|
||||
@@ -40,17 +40,25 @@ function buildWhere(conditions) {
|
||||
.join('~and');
|
||||
}
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 20_000;
|
||||
|
||||
async function request(method, url, body) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
|
||||
const opts = {
|
||||
method,
|
||||
headers: {
|
||||
'xc-token': NOCODB_TOKEN,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
signal: controller.signal,
|
||||
};
|
||||
if (body !== undefined) opts.body = JSON.stringify(body);
|
||||
|
||||
try {
|
||||
const res = await fetch(url, opts);
|
||||
clearTimeout(timer);
|
||||
if (!res.ok) {
|
||||
let details;
|
||||
try { details = await res.json(); } catch {}
|
||||
@@ -63,6 +71,13 @@ async function request(method, url, body) {
|
||||
// DELETE returns empty or {msg}
|
||||
const text = await res.text();
|
||||
return text ? JSON.parse(text) : {};
|
||||
} catch (err) {
|
||||
clearTimeout(timer);
|
||||
if (err.name === 'AbortError') {
|
||||
throw new NocoDBError(`NocoDB ${method} ${url} timed out after ${REQUEST_TIMEOUT_MS}ms`, 408);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Link Resolution ─────────────────────────────────────────
|
||||
|
||||
@@ -3,9 +3,14 @@ const { sendMail } = require('./mail');
|
||||
const nocodb = require('./nocodb');
|
||||
const { parseApproverIds } = require('./helpers');
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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_AR = 'المركز الرقمي لسمايا';
|
||||
const APP_NAME_EN = 'Rawaj';
|
||||
const APP_NAME_AR = 'رواج';
|
||||
|
||||
// ─── TRANSLATIONS ───────────────────────────────────────────────
|
||||
|
||||
@@ -94,6 +99,21 @@ const t = {
|
||||
view: { en: 'View', ar: 'عرض' },
|
||||
viewTask: { en: 'View Task', 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; }
|
||||
@@ -111,7 +131,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>
|
||||
<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="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}
|
||||
</div>
|
||||
<div style="background:#fff;padding:32px 24px;border-radius:0 0 12px 12px;border:1px solid #e2e8f0;border-top:none">
|
||||
@@ -121,7 +141,7 @@ function renderEmail({ heading, bodyHtml, ctaText, ctaUrl, lang = 'en' }) {
|
||||
</div>
|
||||
${ctaText && ctaUrl ? `
|
||||
<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 style="text-align:center;padding:16px;color:#94a3b8;font-size:12px">
|
||||
@@ -151,8 +171,10 @@ async function getMultipleUsers(userIds) {
|
||||
}
|
||||
|
||||
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 });
|
||||
sendMail({ to, subject, html, text })
|
||||
sendMail({ to, subject: fullSubject, html, text })
|
||||
.then(() => console.log(`[notifications] Sent "${subject}" to ${to}`))
|
||||
.catch(err => console.error(`[notifications] FAILED "${subject}" to ${to}:`, err.message));
|
||||
}
|
||||
@@ -222,7 +244,7 @@ function notifyRejected({ type, record, approverName, feedback }) {
|
||||
heading: tr('rejectedHeading', l)(typeLabel),
|
||||
bodyHtml: `
|
||||
<p>${tr('rejectedBody', l)(title, approverName || (l === 'ar' ? 'مراجع' : 'a reviewer'))}</p>
|
||||
${feedback ? `<blockquote ${BLOCKQUOTE}>${feedback}</blockquote>` : ''}`,
|
||||
${feedback ? `<blockquote ${BLOCKQUOTE}>${escapeHtml(feedback)}</blockquote>` : ''}`,
|
||||
ctaText: `${tr('view', l)} ${typeLabel}`,
|
||||
ctaUrl: `${APP_URL}/${type === 'post' ? 'posts' : type === 'translation' ? 'translations' : 'artefacts'}`,
|
||||
});
|
||||
@@ -246,7 +268,7 @@ function notifyRevisionRequested({ type, record, approverName, feedback }) {
|
||||
heading: tr('revisionRequested', l),
|
||||
bodyHtml: `
|
||||
<p>${tr('revisionRequestedBody', l)(title, approverName || (l === 'ar' ? 'مراجع' : 'a reviewer'))}</p>
|
||||
${feedback ? `<blockquote ${BLOCKQUOTE}>${feedback}</blockquote>` : ''}`,
|
||||
${feedback ? `<blockquote ${BLOCKQUOTE}>${escapeHtml(feedback)}</blockquote>` : ''}`,
|
||||
ctaText: `${tr('view', l)} ${tr(entityType, l)}`,
|
||||
ctaUrl: `${APP_URL}/${entityPath}`,
|
||||
});
|
||||
@@ -269,7 +291,7 @@ function notifyTaskAssigned({ task, assignerName }) {
|
||||
bodyHtml: `
|
||||
<p>${tr('taskAssignedBody', l)(assignerName || (l === 'ar' ? 'أحدهم' : 'Someone'))}</p>
|
||||
<p style="font-size:16px;font-weight:600;color:#1e293b">${title}</p>
|
||||
${task.description ? `<p style="color:#64748b">${task.description.substring(0, 200)}</p>` : ''}
|
||||
${task.description ? `<p style="color:#64748b">${escapeHtml(task.description.substring(0, 200))}</p>` : ''}
|
||||
${task.priority ? `<p>${tr('priority', l)}: <strong>${task.priority}</strong></p>` : ''}
|
||||
${task.due_date ? `<p>${tr('dueDate', l)}: <strong>${task.due_date}</strong></p>` : ''}`,
|
||||
ctaText: tr('viewTask', l),
|
||||
@@ -334,7 +356,7 @@ function notifyIssueStatusUpdate({ issue, oldStatus, newStatus }) {
|
||||
bodyHtml: `
|
||||
<p>${tr('issueUpdateBody', 'en')(title)}</p>
|
||||
<p><span style="color:#94a3b8">${oldStatus || 'new'}</span> → <strong style="color:#3b82f6">${newStatus}</strong></p>
|
||||
${issue.resolution_summary ? `<p style="margin-top:12px"><strong>${tr('resolution', 'en')}:</strong> ${issue.resolution_summary}</p>` : ''}`,
|
||||
${issue.resolution_summary ? `<p style="margin-top:12px"><strong>${tr('resolution', 'en')}:</strong> ${escapeHtml(issue.resolution_summary)}</p>` : ''}`,
|
||||
ctaText: issue.tracking_token ? tr('trackIssue', 'en') : null,
|
||||
ctaUrl: issue.tracking_token ? `${APP_URL}/track/${issue.tracking_token}` : null,
|
||||
});
|
||||
@@ -387,7 +409,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> ${escapeHtml(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}>${escapeHtml(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}>${escapeHtml(request.response_note)}</blockquote>` : ''}`,
|
||||
ctaText: null, ctaUrl: null,
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
renderEmail,
|
||||
notifyReviewSubmitted,
|
||||
notifyApproved,
|
||||
notifyRejected,
|
||||
@@ -398,4 +465,7 @@ module.exports = {
|
||||
notifyIssueStatusUpdate,
|
||||
notifyCampaignCreated,
|
||||
notifyUserInvited,
|
||||
notifyBudgetRequest,
|
||||
notifyBudgetApproved,
|
||||
notifyBudgetRejected,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
const nocodb = require('./nocodb');
|
||||
|
||||
async function getPostComposition(postId) {
|
||||
const post = await nocodb.get('Posts', postId);
|
||||
if (!post) return null;
|
||||
|
||||
const artefacts = await nocodb.list('Artefacts', {
|
||||
where: `(post_id,eq,${postId})`, limit: 100,
|
||||
});
|
||||
|
||||
const caption = artefacts.find(a => a.type === 'copy' && a.copy_type === 'caption') || null;
|
||||
const bodyCopy = artefacts.find(a => a.type === 'copy' && (a.copy_type === 'body' || !a.copy_type)) || null;
|
||||
const design = artefacts.find(a => (a.type || 'design') === 'design') || null;
|
||||
const video = artefacts.find(a => a.type === 'video') || null;
|
||||
|
||||
let platforms = [];
|
||||
try { platforms = JSON.parse(post.platforms || '[]'); } catch { platforms = post.platform ? [post.platform] : []; }
|
||||
|
||||
const waitingOn = [];
|
||||
if (caption && caption.status !== 'approved') waitingOn.push('Caption');
|
||||
if (bodyCopy && bodyCopy.status !== 'approved') waitingOn.push('Copy');
|
||||
if (design && design.status !== 'approved') waitingOn.push('Design');
|
||||
if (video && video.status !== 'approved') waitingOn.push('Video');
|
||||
|
||||
const hasPieces = caption || bodyCopy || design || video;
|
||||
const piecesReady = hasPieces && waitingOn.length === 0;
|
||||
|
||||
// Get texts from ArtefactVersionTexts for copy artefacts (content preview + languages)
|
||||
const getTexts = async (artefactId) => {
|
||||
try {
|
||||
const versions = await nocodb.list('ArtefactVersions', { where: `(artefact_id,eq,${artefactId})`, sort: '-version_number', limit: 1 });
|
||||
if (versions.length === 0) return { texts: [], contentPreview: '' };
|
||||
const texts = await nocodb.list('ArtefactVersionTexts', { where: `(version_id,eq,${versions[0].Id})`, limit: 20 });
|
||||
const languages = texts.map(tt => ({ language: tt.language_code || tt.language, status: tt.status || 'draft' }));
|
||||
const contentPreview = texts.length > 0 ? (texts[0].content || '').slice(0, 120) : '';
|
||||
return { texts: languages, contentPreview };
|
||||
} catch { return { texts: [], contentPreview: '' }; }
|
||||
};
|
||||
|
||||
const [captionTexts, bodyTexts] = await Promise.all([
|
||||
caption ? getTexts(caption.Id) : { texts: [], contentPreview: '' },
|
||||
bodyCopy ? getTexts(bodyCopy.Id) : { texts: [], contentPreview: '' },
|
||||
]);
|
||||
|
||||
// Get first attachment for design/video thumbnail
|
||||
const getFirstAttachment = async (artefactId) => {
|
||||
try {
|
||||
const versions = await nocodb.list('ArtefactVersions', { where: `(artefact_id,eq,${artefactId})`, sort: '-version_number', limit: 1 });
|
||||
if (versions.length === 0) return null;
|
||||
const attachments = await nocodb.list('ArtefactAttachments', { where: `(version_id,eq,${versions[0].Id})`, limit: 1 });
|
||||
if (attachments.length === 0) return null;
|
||||
const att = attachments[0];
|
||||
return att.drive_url || (att.filename ? `/api/uploads/${att.filename}` : null);
|
||||
} catch { return null; }
|
||||
};
|
||||
const [designThumb, videoThumb] = await Promise.all([
|
||||
design ? (design.thumbnail_url || getFirstAttachment(design.Id)) : null,
|
||||
video ? (video.thumbnail_url || getFirstAttachment(video.Id)) : null,
|
||||
]);
|
||||
|
||||
// Resolve approver names for each piece
|
||||
const resolveApprover = async (record) => {
|
||||
if (!record || !record.approver_ids) return { approver_ids: null, approver_name: null };
|
||||
const ids = record.approver_ids.split(',').map(s => s.trim()).filter(Boolean);
|
||||
if (ids.length === 0) return { approver_ids: null, approver_name: null };
|
||||
try {
|
||||
const user = await nocodb.get('Users', Number(ids[0]));
|
||||
return { approver_ids: record.approver_ids, approver_name: user ? (user.display_name || user.name || user.email) : null };
|
||||
} catch { return { approver_ids: record.approver_ids, approver_name: null }; }
|
||||
};
|
||||
|
||||
const [captionApprover, bodyApprover, designApprover, videoApprover] = await Promise.all([
|
||||
resolveApprover(caption),
|
||||
resolveApprover(bodyCopy),
|
||||
resolveApprover(design),
|
||||
resolveApprover(video),
|
||||
]);
|
||||
|
||||
return {
|
||||
caption: caption ? { id: caption.Id, title: caption.title, status: caption.status, content_preview: captionTexts.contentPreview, languages: captionTexts.texts, ...captionApprover } : null,
|
||||
body_copy: bodyCopy ? { id: bodyCopy.Id, title: bodyCopy.title, status: bodyCopy.status, content_preview: bodyTexts.contentPreview, languages: bodyTexts.texts, ...bodyApprover } : null,
|
||||
design: design ? { id: design.Id, title: design.title, status: design.status, thumbnail_url: designThumb, current_version: design.current_version, ...designApprover } : null,
|
||||
video: video ? { id: video.Id, title: video.title, status: video.status, thumbnail_url: videoThumb, current_version: video.current_version, ...videoApprover } : null,
|
||||
platforms,
|
||||
pieces_ready: piecesReady,
|
||||
waiting_on: waitingOn,
|
||||
stage: post.stage || 'copy',
|
||||
};
|
||||
}
|
||||
|
||||
function computeStage(composition) {
|
||||
const { caption, body_copy, design, video, pieces_ready } = composition;
|
||||
if (pieces_ready) return 'post';
|
||||
if (design || video) return 'design';
|
||||
if (caption || body_copy) return 'translate';
|
||||
return 'copy';
|
||||
}
|
||||
|
||||
// Sync the post's thumbnail_url from its linked design artefact
|
||||
async function syncPostThumbnail(postId) {
|
||||
try {
|
||||
const artefacts = await nocodb.list('Artefacts', {
|
||||
where: `(post_id,eq,${postId})`, limit: 100,
|
||||
});
|
||||
const design = artefacts.find(a => (a.type || 'design') === 'design');
|
||||
let thumb = null;
|
||||
if (design) {
|
||||
thumb = design.thumbnail_url || null;
|
||||
if (!thumb) {
|
||||
const versions = await nocodb.list('ArtefactVersions', { where: `(artefact_id,eq,${design.Id})`, sort: '-version_number', limit: 1 });
|
||||
if (versions.length > 0) {
|
||||
const attachments = await nocodb.list('ArtefactAttachments', { where: `(version_id,eq,${versions[0].Id})`, limit: 1 });
|
||||
if (attachments.length > 0) {
|
||||
const att = attachments[0];
|
||||
thumb = att.drive_url || (att.filename ? `/api/uploads/${att.filename}` : null);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
await nocodb.update('Posts', Number(postId), { thumbnail_url: thumb || null });
|
||||
} catch (e) {
|
||||
console.error('syncPostThumbnail error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getPostComposition, computeStage, syncPostThumbnail };
|
||||
@@ -0,0 +1,37 @@
|
||||
const n = require('./nocodb');
|
||||
const OLD_ID = 6;
|
||||
const NEW_ID = 1;
|
||||
|
||||
const TABLES_AND_FIELDS = [
|
||||
{ table: 'Posts', fields: ['assigned_to_id', 'created_by_user_id'] },
|
||||
{ table: 'Tasks', fields: ['assigned_to_id', 'created_by_user_id'] },
|
||||
{ table: 'Projects', fields: ['owner_id', 'created_by_user_id'] },
|
||||
{ table: 'Campaigns', fields: ['created_by_user_id'] },
|
||||
{ table: 'Assets', fields: ['uploader_id'] },
|
||||
{ table: 'Comments', fields: ['user_id'] },
|
||||
{ table: 'CampaignAssignments', fields: ['member_id', 'assigner_id'] },
|
||||
{ table: 'Artefacts', fields: ['created_by_user_id'] },
|
||||
{ table: 'ArtefactVersions', fields: ['created_by_user_id'] },
|
||||
{ table: 'Issues', fields: ['assigned_to_id'] },
|
||||
{ table: 'TeamMembers', fields: ['user_id'] },
|
||||
{ table: 'BudgetEntries', fields: [] },
|
||||
];
|
||||
|
||||
(async () => {
|
||||
for (const { table, fields } of TABLES_AND_FIELDS) {
|
||||
for (const field of fields) {
|
||||
try {
|
||||
const records = await n.list(table, { where: `(${field},eq,${OLD_ID})`, limit: 200 });
|
||||
if (records.length === 0) continue;
|
||||
console.log(`${table}.${field}: ${records.length} records to update`);
|
||||
for (const r of records) {
|
||||
await n.update(table, r.Id, { [field]: NEW_ID });
|
||||
}
|
||||
console.log(` -> done`);
|
||||
} catch (err) {
|
||||
console.log(`${table}.${field}: skipped (${err.message})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log('Reassignment complete.');
|
||||
})();
|
||||