Compare commits
65 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| af91dba268 | |||
| 94ce012837 | |||
| a67b2afb0d | |||
| 378d91648b | |||
| 16a94a2f19 | |||
| eb23931ce0 | |||
| 49e1a796ed | |||
| ce4d6025d7 | |||
| e1d1c392eb | |||
| 3c857856c5 | |||
| 94f448344e | |||
| ba3900bc33 | |||
| 7ace32a070 | |||
| 18785ed901 | |||
| b17108b321 | |||
| 14751c42e4 | |||
| 51708267d3 | |||
| 05dc5df3f8 | |||
| f1bcf217ac | |||
| ed98ebb67f | |||
| 44e706f777 | |||
| 539c204bde | |||
| 64e377060f | |||
| 0ee3552c58 | |||
| 5d733ce066 | |||
| 12628e2f78 | |||
| 5e47d11e32 | |||
| 8c69f1846f | |||
| c9d0b8f151 | |||
| 35a0c4d6ce | |||
| f4cf2e39a4 | |||
| 96fb838388 | |||
| 8eaea27e89 | |||
| 593adbbc0b | |||
| 6203bf36e6 | |||
| 0789b7e550 | |||
| 70de02c97c | |||
| 93956ff117 | |||
| 0e948cbf37 | |||
| 8e243517e2 | |||
| 82236ecffa | |||
| daf2404bda | |||
| 7dc7fbbbe2 | |||
| fe509b65a9 | |||
| ad539fd7f4 | |||
| e8539af4f7 | |||
| 1c10f79036 | |||
| 06e992e2eb | |||
| f3c53e27aa | |||
| 643d004dc7 | |||
| 959bd6066d | |||
| da161014af | |||
| 7c6e8dce08 | |||
| fa6345f63e | |||
| c31e6222d7 | |||
| 42a5f17d0b | |||
| 20d76dea8b | |||
| 12415d5426 | |||
| 0c945405e3 | |||
| 01fdb93efd | |||
| 52d69ee02d | |||
| 7554b1cb56 | |||
| 6cdec2b4b5 | |||
| 4d91e8e8a8 | |||
| b1f7d574ed |
@@ -3,4 +3,9 @@ dist/
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
*.db.bak
|
||||
.vite/
|
||||
.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>
|
||||
|
||||
@@ -18,8 +18,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
@@ -1627,26 +1625,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.2.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.13.tgz",
|
||||
"integrity": "sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-dom": {
|
||||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "5.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.3.tgz",
|
||||
@@ -1903,13 +1881,6 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||
|
||||
@@ -20,8 +20,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
|
||||
@@ -1,57 +1,63 @@
|
||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { useState, useEffect, createContext } from 'react'
|
||||
import { useState, useEffect, createContext, lazy, Suspense } from 'react'
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext'
|
||||
import { LanguageProvider } from './i18n/LanguageContext'
|
||||
import { ToastProvider } from './components/ToastContainer'
|
||||
import { ThemeProvider } from './contexts/ThemeContext'
|
||||
import ErrorBoundary from './components/ErrorBoundary'
|
||||
import Layout from './components/Layout'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import PostProduction from './pages/PostProduction'
|
||||
import Assets from './pages/Assets'
|
||||
import Campaigns from './pages/Campaigns'
|
||||
import CampaignDetail from './pages/CampaignDetail'
|
||||
import Finance from './pages/Finance'
|
||||
import Budgets from './pages/Budgets'
|
||||
import Projects from './pages/Projects'
|
||||
import ProjectDetail from './pages/ProjectDetail'
|
||||
import Tasks from './pages/Tasks'
|
||||
import Team from './pages/Team'
|
||||
import Users from './pages/Users'
|
||||
import Settings from './pages/Settings'
|
||||
import Brands from './pages/Brands'
|
||||
import Login from './pages/Login'
|
||||
import Artefacts from './pages/Artefacts'
|
||||
import PostCalendar from './pages/PostCalendar'
|
||||
import PublicReview from './pages/PublicReview'
|
||||
import Issues from './pages/Issues'
|
||||
import PublicIssueSubmit from './pages/PublicIssueSubmit'
|
||||
import PublicIssueTracker from './pages/PublicIssueTracker'
|
||||
import Tutorial from './components/Tutorial'
|
||||
import Modal from './components/Modal'
|
||||
import { api } from './utils/api'
|
||||
import { useLanguage } from './i18n/LanguageContext'
|
||||
import { useKeyboardShortcuts, DEFAULT_SHORTCUTS } from './hooks/useKeyboardShortcuts'
|
||||
|
||||
const TEAM_ROLES = [
|
||||
// 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'))
|
||||
const Finance = lazy(() => import('./pages/Finance'))
|
||||
const Budgets = lazy(() => import('./pages/Budgets'))
|
||||
const Projects = lazy(() => import('./pages/Projects'))
|
||||
const ProjectDetail = lazy(() => import('./pages/ProjectDetail'))
|
||||
const Tasks = lazy(() => import('./pages/Tasks'))
|
||||
const Team = lazy(() => import('./pages/Team'))
|
||||
// Users page removed — unified into Team page
|
||||
const Settings = lazy(() => import('./pages/Settings'))
|
||||
const Brands = lazy(() => import('./pages/Brands'))
|
||||
const Login = lazy(() => import('./pages/Login'))
|
||||
const Artefacts = lazy(() => import('./pages/Artefacts'))
|
||||
const PostCalendar = lazy(() => import('./pages/PostCalendar'))
|
||||
const PublicReview = lazy(() => import('./pages/PublicReview'))
|
||||
const PublicPostReview = lazy(() => import('./pages/PublicPostReview'))
|
||||
const Issues = lazy(() => import('./pages/Issues'))
|
||||
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'))
|
||||
|
||||
// Permission levels (access control)
|
||||
export const PERMISSION_LEVELS = [
|
||||
{ value: 'superadmin', label: 'Super Admin' },
|
||||
{ value: 'manager', label: 'Manager' },
|
||||
{ value: 'approver', label: 'Approver' },
|
||||
{ value: 'publisher', label: 'Publisher' },
|
||||
{ value: 'content_creator', label: 'Content Creator' },
|
||||
{ value: 'producer', label: 'Producer' },
|
||||
{ value: 'designer', label: 'Designer' },
|
||||
{ value: 'content_writer', label: 'Content Writer' },
|
||||
{ value: 'social_media_manager', label: 'Social Media Manager' },
|
||||
{ value: 'photographer', label: 'Photographer' },
|
||||
{ value: 'videographer', label: 'Videographer' },
|
||||
{ value: 'strategist', label: 'Strategist' },
|
||||
{ value: 'contributor', label: 'Contributor' },
|
||||
]
|
||||
|
||||
export const AppContext = createContext()
|
||||
|
||||
function AppContent() {
|
||||
const { user, loading: authLoading, checkAuth, hasModule } = useAuth()
|
||||
const { t, lang } = useLanguage()
|
||||
const { t, lang, setLang } = useLanguage()
|
||||
const [teamMembers, setTeamMembers] = useState([])
|
||||
const [brands, setBrands] = useState([])
|
||||
const [teams, setTeams] = useState([])
|
||||
const [roles, setRoles] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showTutorial, setShowTutorial] = useState(false)
|
||||
const [showProfilePrompt, setShowProfilePrompt] = useState(false)
|
||||
@@ -59,6 +65,9 @@ function AppContent() {
|
||||
const [profileForm, setProfileForm] = useState({ name: '', team_role: '', phone: '', brands: '' })
|
||||
const [profileSaving, setProfileSaving] = useState(false)
|
||||
|
||||
// Keyboard shortcuts
|
||||
useKeyboardShortcuts(DEFAULT_SHORTCUTS)
|
||||
|
||||
useEffect(() => {
|
||||
if (user && !authLoading) {
|
||||
loadInitialData()
|
||||
@@ -87,7 +96,7 @@ function AppContent() {
|
||||
const loadTeam = async () => {
|
||||
try {
|
||||
const data = await api.get('/users/team')
|
||||
const members = Array.isArray(data) ? data : (data.data || [])
|
||||
const members = Array.isArray(data) ? data : []
|
||||
setTeamMembers(members)
|
||||
return members
|
||||
} catch (err) {
|
||||
@@ -99,18 +108,28 @@ function AppContent() {
|
||||
const loadTeams = async () => {
|
||||
try {
|
||||
const data = await api.get('/teams')
|
||||
setTeams(Array.isArray(data) ? data : (data.data || []))
|
||||
setTeams(Array.isArray(data) ? data : [])
|
||||
} catch (err) {
|
||||
console.error('Failed to load teams:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const loadRoles = async () => {
|
||||
try {
|
||||
const data = await api.get('/roles')
|
||||
setRoles(Array.isArray(data) ? data : [])
|
||||
} catch (err) {
|
||||
console.error('Failed to load roles:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const loadInitialData = async () => {
|
||||
try {
|
||||
const [, brandsData] = await Promise.all([
|
||||
loadTeam(),
|
||||
api.get('/brands').then(d => Array.isArray(d) ? d : (d.data || [])).catch(() => []),
|
||||
api.get('/brands').then(d => Array.isArray(d) ? d : []).catch(() => []),
|
||||
loadTeams(),
|
||||
loadRoles(),
|
||||
])
|
||||
setBrands(brandsData)
|
||||
} catch (err) {
|
||||
@@ -141,10 +160,10 @@ function AppContent() {
|
||||
}
|
||||
|
||||
return (
|
||||
<AppContext.Provider value={{ currentUser: user, teamMembers, brands, loadTeam, getBrandName, teams, loadTeams }}>
|
||||
<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">
|
||||
⚠️
|
||||
@@ -200,17 +219,6 @@ function AppContent() {
|
||||
placeholder={t('team.fullName')}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('team.teamRole')}</label>
|
||||
<select
|
||||
value={profileForm.team_role}
|
||||
onChange={e => setProfileForm(f => ({ ...f, team_role: 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>
|
||||
{TEAM_ROLES.map(r => <option key={r.value} value={r.value}>{r.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('team.phone')} {t('team.optional')}</label>
|
||||
<input
|
||||
@@ -221,14 +229,29 @@ function AppContent() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('team.brands')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={profileForm.brands}
|
||||
onChange={e => setProfileForm(f => ({ ...f, brands: 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('team.brandsHelp')}
|
||||
/>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('settings.language')}</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLang('en')}
|
||||
className={`p-3 rounded-lg border-2 text-center transition-all ${
|
||||
lang === 'en' ? 'border-brand-primary bg-brand-primary/5' : 'border-border hover:border-brand-primary/30'
|
||||
}`}
|
||||
>
|
||||
<div className="text-lg mb-1">EN</div>
|
||||
<div className="text-xs font-medium text-text-primary">English</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLang('ar')}
|
||||
className={`p-3 rounded-lg border-2 text-center transition-all ${
|
||||
lang === 'ar' ? 'border-brand-primary bg-brand-primary/5' : 'border-border hover:border-brand-primary/30'
|
||||
}`}
|
||||
>
|
||||
<div className="text-lg mb-1">ع</div>
|
||||
<div className="text-xs font-medium text-text-primary">العربية</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
|
||||
<button
|
||||
@@ -241,15 +264,9 @@ function AppContent() {
|
||||
onClick={async () => {
|
||||
setProfileSaving(true)
|
||||
try {
|
||||
const brandsArr = profileForm.brands
|
||||
.split(',')
|
||||
.map(b => b.trim())
|
||||
.filter(Boolean)
|
||||
await api.patch('/users/me/profile', {
|
||||
name: profileForm.name,
|
||||
team_role: profileForm.team_role,
|
||||
phone: profileForm.phone || null,
|
||||
brands: brandsArr,
|
||||
})
|
||||
await checkAuth()
|
||||
setShowProfileModal(false)
|
||||
@@ -260,7 +277,7 @@ function AppContent() {
|
||||
setProfileSaving(false)
|
||||
}
|
||||
}}
|
||||
disabled={!profileForm.name || !profileForm.team_role || profileSaving}
|
||||
disabled={!profileForm.name || profileSaving}
|
||||
className="px-5 py-2 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"
|
||||
>
|
||||
{profileSaving ? t('common.loading') : t('team.saveProfile')}
|
||||
@@ -272,14 +289,22 @@ function AppContent() {
|
||||
{/* Tutorial overlay */}
|
||||
{showTutorial && <Tutorial onComplete={handleTutorialComplete} />}
|
||||
|
||||
<ErrorBoundary>
|
||||
<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 />} />
|
||||
<Route path="/reset-password" element={user ? <Navigate to="/" replace /> : <ResetPassword />} />
|
||||
<Route path="/review/:token" element={<PublicReview />} />
|
||||
<Route path="/review-post/:token" element={<PublicPostReview />} />
|
||||
<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 />} />
|
||||
@@ -287,6 +312,7 @@ function AppContent() {
|
||||
<Route path="campaigns" element={<Campaigns />} />
|
||||
<Route path="campaigns/:id" element={<CampaignDetail />} />
|
||||
<Route path="brands" element={<Brands />} />
|
||||
<Route path="translations" element={<Translations />} />
|
||||
</>}
|
||||
{hasModule('finance') && (user?.role === 'superadmin' || user?.role === 'manager') && <>
|
||||
<Route path="finance" element={<Finance />} />
|
||||
@@ -300,12 +326,11 @@ function AppContent() {
|
||||
{hasModule('issues') && <Route path="issues" element={<Issues />} />}
|
||||
<Route path="team" element={<Team />} />
|
||||
<Route path="settings" element={<Settings />} />
|
||||
{user?.role === 'superadmin' && (
|
||||
<Route path="users" element={<Users />} />
|
||||
)}
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</AppContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -315,7 +340,9 @@ function App() {
|
||||
<LanguageProvider>
|
||||
<AuthProvider>
|
||||
<ToastProvider>
|
||||
<ThemeProvider>
|
||||
<AppContent />
|
||||
</ThemeProvider>
|
||||
</ToastProvider>
|
||||
</AuthProvider>
|
||||
</LanguageProvider>
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
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 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])
|
||||
|
||||
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])
|
||||
|
||||
const handleOpen = () => {
|
||||
updatePosition()
|
||||
setOpen(!open)
|
||||
}
|
||||
|
||||
const toggle = (userId) => {
|
||||
const id = String(userId)
|
||||
const next = selected.includes(id) ? selected.filter(s => s !== id) : [...selected, id]
|
||||
onChange(next)
|
||||
}
|
||||
|
||||
const remove = (id) => {
|
||||
onChange(selected.filter(s => s !== String(id)))
|
||||
}
|
||||
|
||||
const selectedUsers = selected.map(id => users.find(u => String(u._id || u.id || u.Id) === String(id))).filter(Boolean)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
{selectedUsers.length === 0 && (
|
||||
<span className="text-text-tertiary">Select approvers...</span>
|
||||
)}
|
||||
{selectedUsers.map(u => (
|
||||
<span
|
||||
key={u._id || u.id || u.Id}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-amber-100 text-amber-800 text-xs font-medium"
|
||||
>
|
||||
{u.name}
|
||||
<button
|
||||
type="button"
|
||||
onClick={e => { e.stopPropagation(); remove(u._id || u.id || u.Id) }}
|
||||
className="hover:text-amber-950 transition-colors"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<ChevronDown className={`w-4 h-4 text-text-tertiary ms-auto shrink-0 transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||
</div>
|
||||
|
||||
{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)
|
||||
return (
|
||||
<button
|
||||
key={uid}
|
||||
type="button"
|
||||
onClick={() => toggle(uid)}
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
<span>{u.name}</span>
|
||||
{isSelected && <Check className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{users.length === 0 && (
|
||||
<div className="px-3 py-4 text-sm text-text-tertiary text-center">No users available</div>
|
||||
)}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,576 @@
|
||||
import { useState, useEffect, useContext } from '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 PortalSelect from './PortalSelect'
|
||||
import { ArtefactDetailVersionsTab } from './ArtefactDetailVersionsTab'
|
||||
|
||||
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 TYPE_ICONS = {
|
||||
copy: FileText,
|
||||
design: ImageIcon,
|
||||
video: Film,
|
||||
other: Sparkles,
|
||||
}
|
||||
|
||||
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()
|
||||
const [versions, setVersions] = useState([])
|
||||
const [selectedVersion, setSelectedVersion] = useState(null)
|
||||
const [versionData, setVersionData] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [freshReviewUrl, setFreshReviewUrl] = useState('')
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState(artefact.type === 'copy' ? 'versions' : 'details')
|
||||
|
||||
// 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 [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 [showDeleteArtefactConfirm, setShowDeleteArtefactConfirm] = useState(false)
|
||||
|
||||
// File upload (for design/video)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadProgress, setUploadProgress] = useState(0)
|
||||
|
||||
// Comments
|
||||
const [comments, setComments] = useState([])
|
||||
const [newComment, setNewComment] = useState('')
|
||||
const [addingComment, setAddingComment] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadVersions()
|
||||
}, [artefact.Id])
|
||||
|
||||
const loadVersions = async () => {
|
||||
try {
|
||||
const res = await api.get(`/artefacts/${artefact.Id}/versions`)
|
||||
const versionsList = Array.isArray(res) ? res : []
|
||||
setVersions(versionsList)
|
||||
|
||||
// Select latest version by default
|
||||
if (versionsList.length > 0) {
|
||||
const latest = versionsList[versionsList.length - 1]
|
||||
setSelectedVersion(latest)
|
||||
loadVersionData(latest.Id)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load versions:', err)
|
||||
toast.error(t('artefacts.failedLoadVersions'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadVersionData = async (versionId) => {
|
||||
try {
|
||||
const [versionRes, commentsRes] = await Promise.all([
|
||||
api.get(`/artefacts/${artefact.Id}/versions/${versionId}`),
|
||||
api.get(`/artefacts/${artefact.Id}/versions/${versionId}/comments`),
|
||||
])
|
||||
|
||||
setVersionData(versionRes.data || versionRes)
|
||||
setComments(commentsRes.data || commentsRes || [])
|
||||
} catch (err) {
|
||||
console.error('Failed to load version data:', err)
|
||||
toast.error(t('artefacts.failedLoadVersionData'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectVersion = (version) => {
|
||||
setSelectedVersion(version)
|
||||
loadVersionData(version.Id)
|
||||
}
|
||||
|
||||
const handleAddLanguage = async (languageForm) => {
|
||||
await api.post(`/artefacts/${artefact.Id}/versions/${selectedVersion.Id}/texts`, languageForm)
|
||||
toast.success(t('artefacts.languageAdded'))
|
||||
loadVersionData(selectedVersion.Id)
|
||||
}
|
||||
|
||||
const handleDeleteLanguage = async (textId) => {
|
||||
await api.delete(`/artefact-version-texts/${textId}`)
|
||||
toast.success(t('artefacts.languageDeleted'))
|
||||
loadVersionData(selectedVersion.Id)
|
||||
}
|
||||
|
||||
const handleFileUpload = async (fileOrEvent) => {
|
||||
const file = fileOrEvent instanceof File ? fileOrEvent : fileOrEvent.target?.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
setUploading(true)
|
||||
setUploadProgress(0)
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
await api.upload(`/artefacts/${artefact.Id}/versions/${selectedVersion.Id}/attachments`, formData, {
|
||||
onUploadProgress: (e) => {
|
||||
if (e.total) setUploadProgress(Math.round((e.loaded / e.total) * 100))
|
||||
}
|
||||
})
|
||||
toast.success(t('artefacts.fileUploaded'))
|
||||
loadVersionData(selectedVersion.Id)
|
||||
} catch (err) {
|
||||
console.error('Upload failed:', err)
|
||||
toast.error(t('artefacts.uploadFailed'))
|
||||
} finally {
|
||||
setUploading(false)
|
||||
setUploadProgress(0)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddDriveVideo = async (driveUrl) => {
|
||||
if (!driveUrl.trim()) {
|
||||
toast.error(t('artefacts.enterDriveUrl'))
|
||||
return
|
||||
}
|
||||
|
||||
setUploading(true)
|
||||
try {
|
||||
await api.post(`/artefacts/${artefact.Id}/versions/${selectedVersion.Id}/attachments`, {
|
||||
drive_url: driveUrl,
|
||||
})
|
||||
toast.success(t('artefacts.videoLinkAdded'))
|
||||
loadVersionData(selectedVersion.Id)
|
||||
} catch (err) {
|
||||
console.error('Add Drive link failed:', err)
|
||||
toast.error(t('artefacts.failedAddVideoLink'))
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteAttachment = async (attId) => {
|
||||
await api.delete(`/artefact-attachments/${attId}`)
|
||||
toast.success(t('artefacts.attachmentDeleted'))
|
||||
loadVersionData(selectedVersion.Id)
|
||||
}
|
||||
|
||||
const handleSubmitReview = async () => {
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const res = await api.post(`/artefacts/${artefact.Id}/submit-review`)
|
||||
setFreshReviewUrl(res.reviewUrl || res.data?.reviewUrl || '')
|
||||
toast.success(t('artefacts.submittedForReview'))
|
||||
onUpdate()
|
||||
} catch (err) {
|
||||
toast.error(t('artefacts.failedSubmitReview'))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const copyReviewLink = () => {
|
||||
navigator.clipboard.writeText(reviewUrl)
|
||||
setCopied(true)
|
||||
toast.success(t('artefacts.linkCopied'))
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
const handleAddComment = async () => {
|
||||
if (!newComment.trim()) return
|
||||
|
||||
setAddingComment(true)
|
||||
try {
|
||||
await api.post(`/artefacts/${artefact.Id}/versions/${selectedVersion.Id}/comments`, {
|
||||
content: newComment.trim(),
|
||||
})
|
||||
toast.success(t('artefacts.commentAdded'))
|
||||
setNewComment('')
|
||||
loadVersionData(selectedVersion.Id)
|
||||
} catch (err) {
|
||||
toast.error(t('artefacts.failedAddComment'))
|
||||
} finally {
|
||||
setAddingComment(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateField = async (field, value) => {
|
||||
try {
|
||||
await api.patch(`/artefacts/${artefact.Id}`, { [field]: value || null })
|
||||
toast.success(t('artefacts.updated'))
|
||||
onUpdate()
|
||||
} catch (err) {
|
||||
toast.error(t('artefacts.failedUpdate'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveDraft = async () => {
|
||||
if (!editTitle.trim()) {
|
||||
toast.error(t('artefacts.titleRequired'))
|
||||
return
|
||||
}
|
||||
setSavingDraft(true)
|
||||
try {
|
||||
await api.patch(`/artefacts/${artefact.Id}`, {
|
||||
title: editTitle.trim(),
|
||||
description: editDescription.trim() || null,
|
||||
})
|
||||
toast.success(t('artefacts.draftSaved'))
|
||||
onUpdate()
|
||||
} catch (err) {
|
||||
toast.error(t('artefacts.failedSaveDraft'))
|
||||
} finally {
|
||||
setSavingDraft(false)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
await onDelete(artefact.Id || artefact.id || artefact._id)
|
||||
} catch (err) {
|
||||
toast.error(t('artefacts.failedDelete'))
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const extractDriveFileId = (url) => {
|
||||
const patterns = [
|
||||
/\/file\/d\/([^\/]+)/,
|
||||
/id=([^&]+)/,
|
||||
/\/d\/([^\/]+)/,
|
||||
]
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = url.match(pattern)
|
||||
if (match) return match[1]
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const getDriveEmbedUrl = (url) => {
|
||||
const fileId = extractDriveFileId(url)
|
||||
return fileId ? `https://drive.google.com/file/d/${fileId}/preview` : url
|
||||
}
|
||||
|
||||
const TypeIcon = TYPE_ICONS[artefact.type] || Sparkles
|
||||
|
||||
const tabs = [
|
||||
{ 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) {
|
||||
return (
|
||||
<TabbedModal onClose={onClose} size="xl">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="w-8 h-8 border-4 border-brand-primary border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
</TabbedModal>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<TabbedModal
|
||||
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" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<input
|
||||
type="text"
|
||||
value={editTitle}
|
||||
onChange={e => setEditTitle(e.target.value)}
|
||||
className="w-full text-lg font-semibold text-text-primary bg-transparent border-0 border-b border-transparent hover:border-border focus:border-brand-primary focus:outline-none focus:ring-0 px-0 py-0.5 transition-colors"
|
||||
/>
|
||||
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${STATUS_COLORS[artefact.status] || 'bg-surface-tertiary text-text-secondary'}`}>
|
||||
{artefact.status?.replace('_', ' ')}
|
||||
</span>
|
||||
<span className="text-xs text-text-tertiary capitalize">{artefact.type}</span>
|
||||
{artefact.creator_name && (
|
||||
<span className="text-xs text-text-secondary font-medium">
|
||||
{t('review.createdBy')} <strong className="text-text-primary">{artefact.creator_name}</strong>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
footer={
|
||||
<>
|
||||
<div>
|
||||
{onDelete && (
|
||||
<button
|
||||
onClick={() => setShowDeleteArtefactConfirm(true)}
|
||||
disabled={deleting}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title={t('artefacts.deleteArtefactTooltip')}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{activeTab === 'details' && (
|
||||
<button
|
||||
onClick={handleSaveDraft}
|
||||
disabled={savingDraft}
|
||||
className="flex items-center gap-1.5 px-4 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light disabled:opacity-50 transition-colors"
|
||||
title={t('artefacts.saveDraftTooltip')}
|
||||
>
|
||||
<Save className="w-3.5 h-3.5" />
|
||||
{savingDraft ? t('artefacts.savingDraft') : t('artefacts.saveDraft')}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{/* Details Tab */}
|
||||
{activeTab === 'details' && (
|
||||
<div className="p-6 space-y-5">
|
||||
{/* Description */}
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-2">{t('artefacts.descriptionLabel')}</h4>
|
||||
<textarea
|
||||
value={editDescription}
|
||||
onChange={e => setEditDescription(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 text-sm text-text-secondary bg-surface-secondary border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary resize-none"
|
||||
placeholder={t('artefacts.descriptionFieldPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 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">{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">{t('common.created')}</h4>
|
||||
<p className="text-sm text-text-secondary">{new Date(artefact.CreatedAt).toLocaleDateString()}</p>
|
||||
</div>
|
||||
)}
|
||||
{/* Linked post */}
|
||||
{(artefact.post_id || artefact.postId) && (
|
||||
<div>
|
||||
<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' && (
|
||||
<ArtefactDetailVersionsTab
|
||||
artefact={artefact}
|
||||
versions={versions}
|
||||
selectedVersion={selectedVersion}
|
||||
versionData={versionData}
|
||||
uploading={uploading}
|
||||
uploadProgress={uploadProgress}
|
||||
onSelectVersion={handleSelectVersion}
|
||||
onAddLanguage={handleAddLanguage}
|
||||
onUpdateLanguage={handleUpdateLanguage}
|
||||
onDeleteLanguage={handleDeleteLanguage}
|
||||
onFileUpload={handleFileUpload}
|
||||
onDeleteAttachment={handleDeleteAttachment}
|
||||
onAddDriveVideo={handleAddDriveVideo}
|
||||
getDriveEmbedUrl={getDriveEmbedUrl}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Discussion Tab */}
|
||||
{activeTab === 'discussion' && (
|
||||
<div className="p-6 space-y-5">
|
||||
{selectedVersion ? (
|
||||
<>
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-3">
|
||||
{t('artefacts.comments')} ({comments.length})
|
||||
</h4>
|
||||
|
||||
<div className="space-y-3 mb-4">
|
||||
{comments.map(comment => (
|
||||
<div key={comment.Id} className="flex gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-brand-primary/10 flex items-center justify-center shrink-0">
|
||||
{comment.user_avatar ? (
|
||||
<img src={comment.user_avatar} alt="" className="w-full h-full rounded-full object-cover" />
|
||||
) : (
|
||||
<MessageSquare className="w-4 h-4 text-brand-primary" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 bg-surface-secondary rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-sm font-medium text-text-primary">{comment.user_name || 'Anonymous'}</span>
|
||||
<span className="text-xs text-text-tertiary">
|
||||
{new Date(comment.CreatedAt).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-text-secondary whitespace-pre-wrap">{comment.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newComment}
|
||||
onChange={e => setNewComment(e.target.value)}
|
||||
onKeyPress={e => e.key === 'Enter' && handleAddComment()}
|
||||
placeholder={t('artefacts.addCommentPlaceholder')}
|
||||
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"
|
||||
/>
|
||||
<button
|
||||
onClick={handleAddComment}
|
||||
disabled={addingComment || !newComment.trim()}
|
||||
className="px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{t('artefacts.sendComment')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-8 text-sm text-text-tertiary">
|
||||
{t('artefacts.selectVersionFirst')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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 || 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" />
|
||||
{submitting ? t('artefacts.submitting') : t('artefacts.submitForReview')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Review Link */}
|
||||
{reviewUrl && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div className="text-sm font-semibold text-blue-900 mb-2">{t('artefacts.reviewLinkTitle')}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={reviewUrl}
|
||||
readOnly
|
||||
className="flex-1 px-3 py-2 text-sm bg-surface border border-border rounded"
|
||||
/>
|
||||
<button
|
||||
onClick={copyReviewLink}
|
||||
className="p-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Feedback */}
|
||||
{artefact.feedback && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
|
||||
<h4 className="text-sm font-semibold text-amber-900 mb-2">{t('artefacts.feedbackTitle')}</h4>
|
||||
<p className="text-sm text-amber-800 whitespace-pre-wrap">{artefact.feedback}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Approval Info */}
|
||||
{artefact.status === 'approved' && artefact.approved_by_name && (
|
||||
<div className="bg-emerald-50 border border-emerald-200 rounded-lg p-4">
|
||||
<div className="font-medium text-emerald-900">{t('artefacts.approvedByLabel')} {artefact.approved_by_name}</div>
|
||||
{artefact.approved_at && (
|
||||
<div className="text-sm text-emerald-700 mt-1">
|
||||
{new Date(artefact.approved_at).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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">
|
||||
{t('artefacts.pendingReviewInfo')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TabbedModal>
|
||||
|
||||
{/* Delete Artefact Confirmation */}
|
||||
<Modal
|
||||
isOpen={showDeleteArtefactConfirm}
|
||||
onClose={() => setShowDeleteArtefactConfirm(false)}
|
||||
title={t('artefacts.deleteArtefact')}
|
||||
isConfirm
|
||||
danger
|
||||
onConfirm={handleDeleteArtefact}
|
||||
confirmText={t('common.delete')}
|
||||
>
|
||||
{t('artefacts.deleteArtefactDesc')}
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -19,7 +19,7 @@ export default function ArtefactVersionTimeline({ versions, activeVersionId, onS
|
||||
<button
|
||||
key={version.Id}
|
||||
onClick={() => onSelectVersion(version)}
|
||||
className={`w-full text-left p-3 rounded-lg border transition-colors ${
|
||||
className={`w-full text-start p-3 rounded-lg border transition-colors ${
|
||||
isActive
|
||||
? 'border-brand-primary bg-brand-primary/5'
|
||||
: 'border-border hover:border-brand-primary/30 bg-surface hover:shadow-sm'
|
||||
@@ -80,11 +80,12 @@ export default function ArtefactVersionTimeline({ versions, activeVersionId, onS
|
||||
|
||||
{/* Thumbnail for image artefacts */}
|
||||
{artefactType === 'design' && version.thumbnail && (
|
||||
<div className="mt-2 ml-11">
|
||||
<div className="mt-2 ms-11">
|
||||
<img
|
||||
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'
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Trash2, X } from 'lucide-react'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
|
||||
export default function BulkSelectBar({ selectedCount, onDelete, onClear }) {
|
||||
const { t } = useLanguage()
|
||||
if (selectedCount === 0) return null
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between px-4 py-2.5 bg-red-50 border border-red-200 rounded-lg animate-fade-in">
|
||||
<span className="text-sm font-medium text-red-800">
|
||||
{selectedCount} {t('common.selected')}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onClear}
|
||||
className="text-xs text-text-tertiary hover:text-text-primary transition-colors"
|
||||
>
|
||||
{t('common.clearSelection')}
|
||||
</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-red-600 text-white rounded-lg text-xs font-medium hover:bg-red-700 transition-colors"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
{t('common.deleteSelected')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 : ''}
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { X, Trash2, DollarSign, Eye, MousePointer, Target } from 'lucide-react'
|
||||
import { useState, useEffect, useContext } from 'react'
|
||||
import { Trash2, DollarSign, Eye, MousePointer, Target, FileEdit, BarChart3, MessageSquare } from 'lucide-react'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import { PLATFORMS, getBrandColor } from '../utils/api'
|
||||
import CommentsSection from './CommentsSection'
|
||||
import Modal from './Modal'
|
||||
import SlidePanel from './SlidePanel'
|
||||
import CollapsibleSection from './CollapsibleSection'
|
||||
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 }) {
|
||||
const { t, lang, currencySymbol } = useLanguage()
|
||||
const { teams } = useContext(AppContext)
|
||||
const [form, setForm] = useState({})
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState('details')
|
||||
|
||||
const campaignId = campaign?._id || campaign?.id
|
||||
const isCreateMode = !campaignId
|
||||
@@ -24,6 +27,7 @@ export default function CampaignDetailPanel({ campaign, onClose, onSave, onDelet
|
||||
name: campaign.name || '',
|
||||
description: campaign.description || '',
|
||||
brand_id: campaign.brandId || campaign.brand_id || '',
|
||||
team_id: campaign.team_id || '',
|
||||
status: campaign.status || 'planning',
|
||||
start_date: campaign.startDate ? new Date(campaign.startDate).toISOString().slice(0, 10) : (campaign.start_date || ''),
|
||||
end_date: campaign.endDate ? new Date(campaign.endDate).toISOString().slice(0, 10) : (campaign.end_date || ''),
|
||||
@@ -63,6 +67,7 @@ export default function CampaignDetailPanel({ campaign, onClose, onSave, onDelet
|
||||
name: form.name,
|
||||
description: form.description,
|
||||
brand_id: form.brand_id ? Number(form.brand_id) : null,
|
||||
team_id: form.team_id ? Number(form.team_id) : null,
|
||||
status: form.status,
|
||||
start_date: form.start_date,
|
||||
end_date: form.end_date,
|
||||
@@ -98,10 +103,21 @@ export default function CampaignDetailPanel({ campaign, onClose, onSave, onDelet
|
||||
return campaign.brand_name || campaign.brandName || null
|
||||
})()
|
||||
|
||||
const header = (
|
||||
<div className="px-5 py-4 border-b border-border shrink-0">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
const tabs = isCreateMode
|
||||
? [{ key: 'details', label: t('campaigns.details'), icon: FileEdit }]
|
||||
: [
|
||||
{ key: 'details', label: t('campaigns.details'), icon: FileEdit },
|
||||
{ key: 'performance', label: t('campaigns.performance'), icon: BarChart3 },
|
||||
{ key: 'discussion', label: t('campaigns.discussion'), icon: MessageSquare },
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<TabbedModal
|
||||
onClose={onClose}
|
||||
size="lg"
|
||||
header={
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
@@ -115,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>
|
||||
@@ -125,23 +141,41 @@ export default function CampaignDetailPanel({ campaign, onClose, onSave, onDelet
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors shrink-0"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
</>
|
||||
}
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
footer={
|
||||
<>
|
||||
<SlidePanel onClose={onClose} maxWidth="520px" header={header}>
|
||||
{/* Details Section */}
|
||||
<CollapsibleSection title={t('campaigns.details')}>
|
||||
<div className="px-5 pb-4 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{onDelete && !isCreateMode && (
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="p-2 text-text-tertiary hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5">
|
||||
{dirty && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!form.name || !form.start_date || !form.end_date || saving}
|
||||
className={`px-4 py-2 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 ${saving ? 'btn-loading' : ''}`}
|
||||
>
|
||||
{isCreateMode ? t('campaigns.createCampaign') : t('tasks.saveChanges')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{/* Details Tab */}
|
||||
{activeTab === 'details' && (
|
||||
<div className="p-6 space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.description')}</label>
|
||||
<textarea
|
||||
@@ -156,31 +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>
|
||||
<PortalSelect
|
||||
value={form.team_id}
|
||||
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"
|
||||
/>
|
||||
</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 (
|
||||
@@ -235,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"
|
||||
@@ -257,34 +299,12 @@ export default function CampaignDetailPanel({ campaign, onClose, onSave, onDelet
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
{dirty && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!form.name || !form.start_date || !form.end_date || saving}
|
||||
className={`flex-1 px-4 py-2 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 ${saving ? 'btn-loading' : ''}`}
|
||||
>
|
||||
{isCreateMode ? t('campaigns.createCampaign') : t('tasks.saveChanges')}
|
||||
</button>
|
||||
)}
|
||||
{onDelete && !isCreateMode && (
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="p-2 text-text-tertiary hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Performance Section (hidden in create mode) */}
|
||||
{!isCreateMode && (
|
||||
<CollapsibleSection title={t('campaigns.performance')}>
|
||||
<div className="px-5 pb-4 space-y-3">
|
||||
{/* Performance Tab */}
|
||||
{activeTab === 'performance' && !isCreateMode && (
|
||||
<div className="p-6 space-y-3">
|
||||
{(form.budget_spent || form.impressions || form.clicks) && (
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<div className="bg-surface-secondary rounded-lg p-2 text-center">
|
||||
@@ -398,18 +418,15 @@ export default function CampaignDetailPanel({ campaign, onClose, onSave, onDelet
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Discussion Section (hidden in create mode) */}
|
||||
{!isCreateMode && (
|
||||
<CollapsibleSection title={t('campaigns.discussion')} noBorder>
|
||||
<div className="px-5 pb-5">
|
||||
{/* Discussion Tab */}
|
||||
{activeTab === 'discussion' && !isCreateMode && (
|
||||
<div className="p-6 space-y-3">
|
||||
<CommentsSection entityType="campaign" entityId={campaignId} />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
</SlidePanel>
|
||||
</TabbedModal>
|
||||
|
||||
<Modal
|
||||
isOpen={showDeleteConfirm}
|
||||
|
||||
@@ -33,7 +33,7 @@ export default function CommentsSection({ entityType, entityId }) {
|
||||
const loadComments = async () => {
|
||||
try {
|
||||
const data = await api.get(`/comments/${entityType}/${entityId}`)
|
||||
setComments(Array.isArray(data) ? data : (data.data || []))
|
||||
setComments(Array.isArray(data) ? data : [])
|
||||
} catch (err) {
|
||||
console.error('Failed to load comments:', err)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Component } from 'react'
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
|
||||
export default class ErrorBoundary extends Component {
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.state = { hasError: false, error: null }
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error) {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
|
||||
componentDidCatch(error, errorInfo) {
|
||||
console.error('ErrorBoundary caught:', error, errorInfo)
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
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-red-100 flex items-center justify-center mx-auto mb-4">
|
||||
<AlertTriangle className="w-8 h-8 text-red-600" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-text-primary mb-2">Something went wrong</h2>
|
||||
<p className="text-text-secondary mb-6">An unexpected error occurred. Please try refreshing the page.</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-6 py-2.5 bg-brand-primary text-white rounded-lg font-medium hover:bg-brand-primary-light transition-colors"
|
||||
>
|
||||
Refresh Page
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
@@ -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" />
|
||||
) : (
|
||||
|
||||
@@ -1,38 +1,54 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { Bell, ChevronDown, LogOut, Shield } from 'lucide-react'
|
||||
import { ChevronDown, LogOut, Shield, Lock, AlertCircle, CheckCircle } from 'lucide-react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { getInitials } from '../utils/api'
|
||||
import { getInitials, api } from '../utils/api'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import Modal from './Modal'
|
||||
import ThemeToggle from './ThemeToggle'
|
||||
|
||||
const pageTitles = {
|
||||
'/': 'Dashboard',
|
||||
'/posts': 'Post Production',
|
||||
'/assets': 'Assets',
|
||||
'/campaigns': 'Campaigns',
|
||||
'/finance': 'Finance',
|
||||
'/projects': 'Projects',
|
||||
'/tasks': 'My Tasks',
|
||||
'/team': 'Team',
|
||||
'/users': 'User Management',
|
||||
const PAGE_TITLE_KEYS = {
|
||||
'/': 'header.dashboard',
|
||||
'/posts': 'header.posts',
|
||||
'/calendar': 'header.calendar',
|
||||
'/assets': 'header.assets',
|
||||
'/artefacts': 'header.artefacts',
|
||||
'/campaigns': 'header.campaigns',
|
||||
'/brands': 'header.brands',
|
||||
'/finance': 'header.finance',
|
||||
'/budgets': 'header.budgets',
|
||||
'/projects': 'header.projects',
|
||||
'/tasks': 'header.tasks',
|
||||
'/issues': 'header.issues',
|
||||
'/team': 'header.team',
|
||||
'/settings': 'header.settings',
|
||||
'/translations': 'header.copy',
|
||||
}
|
||||
|
||||
const ROLE_INFO = {
|
||||
superadmin: { label: 'Superadmin', color: 'bg-purple-100 text-purple-700', icon: '👑' },
|
||||
manager: { label: 'Manager', color: 'bg-blue-100 text-blue-700', icon: '📊' },
|
||||
contributor: { label: 'Contributor', color: 'bg-green-100 text-green-700', icon: '✏️' },
|
||||
superadmin: { labelKey: 'header.superadmin', color: 'bg-purple-100 text-purple-700', icon: '👑' },
|
||||
manager: { labelKey: 'header.manager', color: 'bg-blue-100 text-blue-700', icon: '📊' },
|
||||
contributor: { labelKey: 'header.contributor', color: 'bg-green-100 text-green-700', icon: '✏️' },
|
||||
}
|
||||
|
||||
export default function Header() {
|
||||
const { user, logout } = useAuth()
|
||||
const { t } = useLanguage()
|
||||
const [showDropdown, setShowDropdown] = useState(false)
|
||||
const [showPasswordModal, setShowPasswordModal] = useState(false)
|
||||
const [passwordForm, setPasswordForm] = useState({ currentPassword: '', newPassword: '', confirmPassword: '' })
|
||||
const [passwordError, setPasswordError] = useState('')
|
||||
const [passwordSuccess, setPasswordSuccess] = useState('')
|
||||
const [passwordSaving, setPasswordSaving] = useState(false)
|
||||
const dropdownRef = useRef(null)
|
||||
const location = useLocation()
|
||||
|
||||
function getPageTitle(pathname) {
|
||||
if (pageTitles[pathname]) return pageTitles[pathname]
|
||||
if (pathname.startsWith('/projects/')) return 'Project Details'
|
||||
if (pathname.startsWith('/campaigns/')) return 'Campaign Details'
|
||||
return 'Page'
|
||||
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')
|
||||
}
|
||||
const pageTitle = getPageTitle(location.pathname)
|
||||
|
||||
@@ -46,22 +62,55 @@ export default function Header() {
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
const handlePasswordChange = async () => {
|
||||
setPasswordError('')
|
||||
setPasswordSuccess('')
|
||||
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
|
||||
setPasswordError(t('header.passwordMismatch'))
|
||||
return
|
||||
}
|
||||
if (passwordForm.newPassword.length < 6) {
|
||||
setPasswordError(t('header.passwordMinLength'))
|
||||
return
|
||||
}
|
||||
setPasswordSaving(true)
|
||||
try {
|
||||
await api.patch('/users/me/password', {
|
||||
currentPassword: passwordForm.currentPassword,
|
||||
newPassword: passwordForm.newPassword,
|
||||
})
|
||||
setPasswordSuccess(t('header.passwordUpdateSuccess'))
|
||||
setPasswordForm({ currentPassword: '', newPassword: '', confirmPassword: '' })
|
||||
setTimeout(() => setShowPasswordModal(false), 1500)
|
||||
} catch (err) {
|
||||
setPasswordError(err.message || t('header.passwordUpdateFailed'))
|
||||
} finally {
|
||||
setPasswordSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openPasswordModal = () => {
|
||||
setShowDropdown(false)
|
||||
setPasswordForm({ currentPassword: '', newPassword: '', confirmPassword: '' })
|
||||
setPasswordError('')
|
||||
setPasswordSuccess('')
|
||||
setShowPasswordModal(true)
|
||||
}
|
||||
|
||||
const roleInfo = ROLE_INFO[user?.role] || ROLE_INFO.contributor
|
||||
|
||||
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>
|
||||
</div>
|
||||
|
||||
{/* Right side */}
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Notifications */}
|
||||
<button className="relative p-2 rounded-lg hover:bg-surface-tertiary text-text-secondary hover:text-text-primary transition-colors">
|
||||
<Bell className="w-5 h-5" />
|
||||
<span className="absolute top-1.5 right-1.5 w-2 h-2 bg-red-500 rounded-full"></span>
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Theme toggle */}
|
||||
<ThemeToggle />
|
||||
|
||||
{/* User menu */}
|
||||
<div ref={dropdownRef} className="relative">
|
||||
@@ -71,31 +120,31 @@ 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>
|
||||
<div className="text-left hidden sm:block">
|
||||
<div className="text-start hidden sm:block">
|
||||
<p className="text-sm font-medium text-text-primary">
|
||||
{user?.name || 'User'}
|
||||
</p>
|
||||
<p className={`text-[10px] font-medium ${roleInfo.color.split(' ')[1]}`}>
|
||||
{roleInfo.icon} {roleInfo.label}
|
||||
{roleInfo.icon} {t(roleInfo.labelKey)}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronDown className={`w-4 h-4 text-text-tertiary transition-transform ${showDropdown ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{showDropdown && (
|
||||
<div className="absolute right-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>
|
||||
<p className="text-xs text-text-tertiary">{user?.email}</p>
|
||||
<div className={`inline-flex items-center gap-1 text-[10px] font-medium px-2 py-0.5 rounded-full mt-2 ${roleInfo.color}`}>
|
||||
<span>{roleInfo.icon}</span>
|
||||
{roleInfo.label}
|
||||
{t(roleInfo.labelKey)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -107,22 +156,30 @@ export default function Header() {
|
||||
setShowDropdown(false)
|
||||
window.location.href = '/users'
|
||||
}}
|
||||
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-surface-secondary transition-colors text-left"
|
||||
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-surface-secondary transition-colors text-start"
|
||||
>
|
||||
<Shield className="w-4 h-4 text-text-tertiary" />
|
||||
<span className="text-sm text-text-primary">User Management</span>
|
||||
<span className="text-sm text-text-primary">{t('header.userManagement')}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={openPasswordModal}
|
||||
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-surface-secondary transition-colors text-start"
|
||||
>
|
||||
<Lock className="w-4 h-4 text-text-tertiary" />
|
||||
<span className="text-sm text-text-primary">{t('header.changePassword')}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
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">Sign Out</span>
|
||||
<span className="text-sm text-text-primary group-hover:text-red-500">{t('header.signOut')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -130,5 +187,77 @@ export default function Header() {
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Change Password Modal */}
|
||||
<Modal isOpen={showPasswordModal} onClose={() => setShowPasswordModal(false)} title={t('header.changePassword')} size="md">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('header.currentPassword')}</label>
|
||||
<input
|
||||
type="password"
|
||||
value={passwordForm.currentPassword}
|
||||
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>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('header.newPassword')}</label>
|
||||
<input
|
||||
type="password"
|
||||
value={passwordForm.newPassword}
|
||||
onChange={e => { setPasswordForm(f => ({ ...f, newPassword: 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="••••••••"
|
||||
minLength={6}
|
||||
aria-describedby={passwordError ? 'password-error' : undefined}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('header.confirmNewPassword')}</label>
|
||||
<input
|
||||
type="password"
|
||||
value={passwordForm.confirmPassword}
|
||||
onChange={e => { setPasswordForm(f => ({ ...f, confirmPassword: 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="••••••••"
|
||||
minLength={6}
|
||||
aria-describedby={passwordError ? 'password-error' : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{passwordError && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{passwordSuccess && (
|
||||
<div className="flex items-center gap-2 p-3 bg-green-500/10 border border-green-500/30 rounded-lg">
|
||||
<CheckCircle className="w-4 h-4 text-green-500 shrink-0" />
|
||||
<p className="text-sm text-green-500">{passwordSuccess}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
|
||||
<button
|
||||
onClick={() => setShowPasswordModal(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={handlePasswordChange}
|
||||
disabled={!passwordForm.currentPassword || !passwordForm.newPassword || !passwordForm.confirmPassword || passwordSaving}
|
||||
className="px-5 py-2 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"
|
||||
>
|
||||
{passwordSaving ? t('header.saving') : t('header.updatePassword')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
|
||||
import { format, differenceInDays, startOfDay, addDays, isBefore, isAfter } from 'date-fns'
|
||||
import { Calendar, Rows3, Rows4 } from 'lucide-react'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
|
||||
const STATUS_COLORS = {
|
||||
todo: 'bg-gray-500',
|
||||
@@ -33,8 +34,15 @@ const PRIORITY_BORDER = {
|
||||
}
|
||||
|
||||
const ZOOM_LEVELS = [
|
||||
{ key: 'day', label: 'Day', pxPerDay: 48 },
|
||||
{ key: 'week', label: 'Week', pxPerDay: 20 },
|
||||
{ key: 'month', i18n: 'timeline.month', pxPerDay: 8 },
|
||||
{ key: 'week', i18n: 'timeline.week', pxPerDay: 20 },
|
||||
{ key: 'day', i18n: 'timeline.day', pxPerDay: 48 },
|
||||
]
|
||||
|
||||
const COLOR_PALETTE = [
|
||||
'#6366f1', '#ec4899', '#10b981', '#f59e0b',
|
||||
'#8b5cf6', '#06b6d4', '#f43f5e', '#14b8a6',
|
||||
'#3b82f6', '#ef4444', '#84cc16', '#a855f7',
|
||||
]
|
||||
|
||||
function getInitials(name) {
|
||||
@@ -42,7 +50,8 @@ function getInitials(name) {
|
||||
return name.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2)
|
||||
}
|
||||
|
||||
export default function InteractiveTimeline({ items = [], mapItem, onDateChange, onItemClick, readOnly = false }) {
|
||||
export default function InteractiveTimeline({ items = [], mapItem, onDateChange, onColorChange, onItemClick, readOnly = false }) {
|
||||
const { t } = useLanguage()
|
||||
const containerRef = useRef(null)
|
||||
const didDragRef = useRef(false)
|
||||
const optimisticRef = useRef({}) // { [itemId]: { startDate, endDate } } — keeps bar in place until fresh data arrives
|
||||
@@ -51,10 +60,24 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
|
||||
const [tooltip, setTooltip] = useState(null)
|
||||
const [dragState, setDragState] = useState(null) // { itemId, mode: 'move'|'resize-left'|'resize-right', startX, origStart, origEnd }
|
||||
const dragStateRef = useRef(null)
|
||||
const [colorPicker, setColorPicker] = useState(null) // { itemId, x, y }
|
||||
const colorPickerRef = useRef(null)
|
||||
|
||||
const pxPerDay = ZOOM_LEVELS[zoomIdx].pxPerDay
|
||||
const today = useMemo(() => startOfDay(new Date()), [])
|
||||
|
||||
// Close color picker on outside click
|
||||
useEffect(() => {
|
||||
if (!colorPicker) return
|
||||
const handleClick = (e) => {
|
||||
if (colorPickerRef.current && !colorPickerRef.current.contains(e.target)) {
|
||||
setColorPicker(null)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
return () => document.removeEventListener('mousedown', handleClick)
|
||||
}, [colorPicker])
|
||||
|
||||
// Clear optimistic overrides when fresh data arrives
|
||||
useEffect(() => {
|
||||
optimisticRef.current = {}
|
||||
@@ -214,16 +237,16 @@ 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">No items to display</p>
|
||||
<p className="text-sm text-text-tertiary mt-1">Add items with dates to see the timeline</p>
|
||||
<p className="text-text-secondary font-medium">{t('timeline.noItems')}</p>
|
||||
<p className="text-sm text-text-tertiary mt-1">{t('timeline.addItems')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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">
|
||||
@@ -237,7 +260,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
|
||||
: 'text-text-tertiary hover:text-text-primary hover:bg-surface-tertiary'
|
||||
}`}
|
||||
>
|
||||
{z.label}
|
||||
{t(z.i18n)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -245,34 +268,35 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
|
||||
<button
|
||||
onClick={() => setBarMode(m => m === 'compact' ? 'expanded' : 'compact')}
|
||||
className="flex items-center gap-1.5 px-3 py-1 text-xs font-medium text-text-secondary hover:text-text-primary hover:bg-surface-tertiary rounded-md transition-colors"
|
||||
title={isExpanded ? 'Compact bars' : 'Expanded bars'}
|
||||
title={isExpanded ? t('timeline.compactBars') : t('timeline.expandedBars')}
|
||||
>
|
||||
{isExpanded ? <Rows4 className="w-3.5 h-3.5" /> : <Rows3 className="w-3.5 h-3.5" />}
|
||||
{isExpanded ? 'Compact' : 'Expand'}
|
||||
{isExpanded ? t('timeline.compact') : t('timeline.expand')}
|
||||
</button>
|
||||
<button
|
||||
onClick={scrollToToday}
|
||||
className="flex items-center gap-1.5 px-3 py-1 text-xs font-medium text-brand-primary hover:bg-brand-primary/10 rounded-md transition-colors"
|
||||
>
|
||||
<Calendar className="w-3.5 h-3.5" />
|
||||
Today
|
||||
{t('timeline.today')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
<div ref={containerRef} className="overflow-x-auto relative" style={{ cursor: dragState ? 'grabbing' : undefined }}>
|
||||
<div ref={containerRef} dir="ltr" className="overflow-x-auto relative" style={{ cursor: dragState ? 'grabbing' : undefined }}>
|
||||
<div style={{ minWidth: `${labelWidth + totalDays * pxPerDay}px` }}>
|
||||
{/* Day header */}
|
||||
<div className="flex sticky top-0 z-20 bg-white border-b border-border" style={{ height: headerHeight }}>
|
||||
<div className="shrink-0 border-r border-border bg-surface-secondary flex items-center px-4 sticky left-0 z-30" style={{ width: labelWidth }}>
|
||||
<span className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">Item</span>
|
||||
<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">
|
||||
{days.map((day, i) => {
|
||||
const isToday = differenceInDays(day, today) === 0
|
||||
const isWeekend = day.getDay() === 0 || day.getDay() === 6
|
||||
const isMonthStart = day.getDate() === 1
|
||||
const isWeekStart = day.getDay() === 1 // Monday
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
@@ -285,7 +309,13 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
|
||||
>
|
||||
{pxPerDay >= 30 && <div>{format(day, 'd')}</div>}
|
||||
{pxPerDay >= 40 && <div className="text-[8px] uppercase">{format(day, 'EEE')}</div>}
|
||||
{pxPerDay < 30 && day.getDate() % 7 === 1 && <div>{format(day, 'd')}</div>}
|
||||
{pxPerDay >= 15 && pxPerDay < 30 && day.getDate() % 7 === 1 && <div>{format(day, 'd')}</div>}
|
||||
{pxPerDay < 15 && isMonthStart && (
|
||||
<div className="text-[8px] font-semibold whitespace-nowrap">{format(day, 'MMM')}</div>
|
||||
)}
|
||||
{pxPerDay < 15 && !isMonthStart && isWeekStart && day.getDate() % 14 <= 7 && (
|
||||
<div className="text-[8px]">{format(day, 'd')}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -295,7 +325,8 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
|
||||
{/* Rows */}
|
||||
{mapped.map((item, idx) => {
|
||||
const { left, width } = getBarPosition(item)
|
||||
const statusColor = STATUS_COLORS[item.status] || STATUS_COLORS[item.type] || 'bg-gray-400'
|
||||
const hasCustomColor = !!item.color
|
||||
const statusColor = hasCustomColor ? '' : (STATUS_COLORS[item.status] || STATUS_COLORS[item.type] || 'bg-gray-400')
|
||||
const priorityRing = PRIORITY_BORDER[item.priority] || ''
|
||||
const isDragging = dragState?.itemId === item.id
|
||||
|
||||
@@ -307,15 +338,27 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
|
||||
>
|
||||
{/* Label column */}
|
||||
<div
|
||||
className={`shrink-0 border-r 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 ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
{onColorChange && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
setColorPicker(colorPicker?.itemId === item.id ? null : { itemId: item.id, x: rect.left, y: rect.bottom + 4 })
|
||||
}}
|
||||
className={`w-5 h-5 rounded-full border-2 border-white shadow-sm shrink-0 hover:scale-110 transition-transform ${!item.color ? (STATUS_COLORS[item.status] || 'bg-gray-400') : ''}`}
|
||||
style={item.color ? { backgroundColor: item.color } : undefined}
|
||||
title={t('timeline.changeColor')}
|
||||
/>
|
||||
)}
|
||||
{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">
|
||||
@@ -337,9 +380,21 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{onColorChange && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
setColorPicker(colorPicker?.itemId === item.id ? null : { itemId: item.id, x: rect.left, y: rect.bottom + 4 })
|
||||
}}
|
||||
className={`w-4 h-4 rounded-full border-2 border-white shadow-sm shrink-0 hover:scale-110 transition-transform ${!item.color ? (STATUS_COLORS[item.status] || 'bg-gray-400') : ''}`}
|
||||
style={item.color ? { backgroundColor: item.color } : undefined}
|
||||
title={t('timeline.changeColor')}
|
||||
/>
|
||||
)}
|
||||
{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">
|
||||
@@ -360,8 +415,8 @@ 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">
|
||||
Today
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
@@ -377,6 +432,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
|
||||
width: `${width}px`,
|
||||
height: `${barHeight}px`,
|
||||
top: isExpanded ? '8px' : '8px',
|
||||
...(hasCustomColor ? { backgroundColor: item.color } : {}),
|
||||
}}
|
||||
onMouseDown={(e) => handleMouseDown(e, item, 'move')}
|
||||
onClick={(e) => {
|
||||
@@ -403,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')}
|
||||
/>
|
||||
)}
|
||||
@@ -423,7 +479,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
|
||||
</span>
|
||||
)}
|
||||
{width > 120 && item.status && (
|
||||
<span className="text-[9px] text-white/70 bg-white/15 px-1.5 py-0.5 rounded ml-auto shrink-0">
|
||||
<span className="text-[9px] text-white/70 bg-white/15 px-1.5 py-0.5 rounded ms-auto shrink-0">
|
||||
{item.status.replace(/_/g, ' ')}
|
||||
</span>
|
||||
)}
|
||||
@@ -439,7 +495,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
|
||||
<span key={i} className="text-[8px] px-1 py-0.5 rounded bg-white/15 text-white/70 font-medium">{tag}</span>
|
||||
))}
|
||||
{width > 140 && item.startDate && item.endDate && (
|
||||
<span className="text-[8px] text-white/50 ml-auto">
|
||||
<span className="text-[8px] text-white/50 ms-auto">
|
||||
{format(item.startDate, 'MMM d')} – {format(item.endDate, 'MMM d')}
|
||||
</span>
|
||||
)}
|
||||
@@ -464,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')}
|
||||
/>
|
||||
)}
|
||||
@@ -476,6 +532,38 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Color Picker Popover */}
|
||||
{colorPicker && onColorChange && (
|
||||
<div
|
||||
ref={colorPickerRef}
|
||||
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">
|
||||
{COLOR_PALETTE.map(c => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => {
|
||||
onColorChange(colorPicker.itemId, c)
|
||||
setColorPicker(null)
|
||||
}}
|
||||
className="w-7 h-7 rounded-full border-2 border-transparent hover:border-gray-400 hover:scale-110 transition-all"
|
||||
style={{ backgroundColor: c }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
onColorChange(colorPicker.itemId, null)
|
||||
setColorPicker(null)
|
||||
}}
|
||||
className="w-full text-[10px] text-text-tertiary hover:text-text-primary text-center py-1 hover:bg-surface-tertiary rounded transition-colors"
|
||||
>
|
||||
{t('timeline.resetColor')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tooltip */}
|
||||
{tooltip && !dragState && (
|
||||
<div
|
||||
@@ -490,21 +578,21 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
|
||||
<div className="font-semibold mb-1">{tooltip.item.label}</div>
|
||||
<div className="text-gray-300 space-y-0.5">
|
||||
{tooltip.item.startDate && (
|
||||
<div>Start: {format(tooltip.item.startDate, 'MMM d, yyyy')}</div>
|
||||
<div>{t('timeline.startDate')}: {format(tooltip.item.startDate, 'MMM d, yyyy')}</div>
|
||||
)}
|
||||
{tooltip.item.endDate && (
|
||||
<div>End: {format(tooltip.item.endDate, 'MMM d, yyyy')}</div>
|
||||
<div>{t('timeline.endDate')}: {format(tooltip.item.endDate, 'MMM d, yyyy')}</div>
|
||||
)}
|
||||
{tooltip.item.assigneeName && (
|
||||
<div>Assignee: {tooltip.item.assigneeName}</div>
|
||||
<div>{t('timeline.assignee')}: {tooltip.item.assigneeName}</div>
|
||||
)}
|
||||
{tooltip.item.status && (
|
||||
<div>Status: {tooltip.item.status.replace(/_/g, ' ')}</div>
|
||||
<div>{t('timeline.status')}: {tooltip.item.status.replace(/_/g, ' ')}</div>
|
||||
)}
|
||||
</div>
|
||||
{!readOnly && onDateChange && (
|
||||
<div className="text-gray-400 mt-1 text-[10px] italic">
|
||||
Drag to move · Drag edges to resize
|
||||
<div className="text-text-tertiary mt-1 text-[10px] italic">
|
||||
{t('timeline.dragToMove')} · {t('timeline.dragToResize')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,20 +1,12 @@
|
||||
const PRIORITY_CONFIG = {
|
||||
low: { label: 'Low', dot: 'bg-text-tertiary' },
|
||||
medium: { label: 'Medium', dot: 'bg-blue-500' },
|
||||
high: { label: 'High', dot: 'bg-orange-500' },
|
||||
urgent: { label: 'Urgent', dot: 'bg-red-500' },
|
||||
}
|
||||
|
||||
const TYPE_LABELS = {
|
||||
request: 'Request',
|
||||
correction: 'Correction',
|
||||
complaint: 'Complaint',
|
||||
suggestion: 'Suggestion',
|
||||
other: 'Other',
|
||||
const PRIORITY_DOTS = {
|
||||
low: 'bg-text-tertiary',
|
||||
medium: 'bg-blue-500',
|
||||
high: 'bg-orange-500',
|
||||
urgent: 'bg-red-500',
|
||||
}
|
||||
|
||||
export default function IssueCard({ issue, onClick }) {
|
||||
const priority = PRIORITY_CONFIG[issue.priority] || PRIORITY_CONFIG.medium
|
||||
const priority = { dot: PRIORITY_DOTS[issue.priority] || PRIORITY_DOTS.medium }
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return ''
|
||||
|
||||
@@ -1,37 +1,29 @@
|
||||
import { useState, useEffect, useContext } from 'react'
|
||||
import { X, Copy, Eye, Lock, Send, Upload, FileText, Trash2, Check, Clock, CheckCircle2, XCircle } from 'lucide-react'
|
||||
import { api } from '../utils/api'
|
||||
import SlidePanel from './SlidePanel'
|
||||
import FormInput from './FormInput'
|
||||
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'
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
new: { label: 'New', bg: 'bg-purple-100', text: 'text-purple-700', dot: 'bg-purple-500' },
|
||||
acknowledged: { label: 'Acknowledged', bg: 'bg-blue-100', text: 'text-blue-700', dot: 'bg-blue-500' },
|
||||
in_progress: { label: 'In Progress', bg: 'bg-amber-100', text: 'text-amber-700', dot: 'bg-amber-500' },
|
||||
resolved: { label: 'Resolved', bg: 'bg-emerald-100', text: 'text-emerald-700', dot: 'bg-emerald-500' },
|
||||
declined: { label: 'Declined', bg: 'bg-surface-tertiary', text: 'text-text-secondary', dot: 'bg-text-tertiary' },
|
||||
}
|
||||
|
||||
const PRIORITY_CONFIG = {
|
||||
low: { label: 'Low', bg: 'bg-surface-tertiary', text: 'text-text-secondary' },
|
||||
medium: { label: 'Medium', bg: 'bg-blue-100', text: 'text-blue-700' },
|
||||
high: { label: 'High', bg: 'bg-orange-100', text: 'text-orange-700' },
|
||||
urgent: { label: 'Urgent', bg: 'bg-red-100', text: 'text-red-700' },
|
||||
}
|
||||
|
||||
export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers }) {
|
||||
export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers, teams = [] }) {
|
||||
const { brands } = useContext(AppContext)
|
||||
const toast = useToast()
|
||||
const { t } = useLanguage()
|
||||
const [issueData, setIssueData] = useState(null)
|
||||
const [updates, setUpdates] = useState([])
|
||||
const [attachments, setAttachments] = useState([])
|
||||
const [initialLoading, setInitialLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [uploadingFile, setUploadingFile] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState('details')
|
||||
|
||||
// Form state
|
||||
const [assignedTo, setAssignedTo] = useState('')
|
||||
const [teamId, setTeamId] = useState('')
|
||||
const [internalNotes, setInternalNotes] = useState('')
|
||||
const [resolutionSummary, setResolutionSummary] = useState('')
|
||||
const [newUpdate, setNewUpdate] = useState('')
|
||||
@@ -40,6 +32,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
// Modals
|
||||
const [showResolveModal, setShowResolveModal] = useState(false)
|
||||
const [showDeclineModal, setShowDeclineModal] = useState(false)
|
||||
const [confirmDeleteAttId, setConfirmDeleteAttId] = useState(null)
|
||||
|
||||
const issueId = issue?.Id || issue?.id
|
||||
|
||||
@@ -54,6 +47,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
setUpdates(data.updates || [])
|
||||
setAttachments(data.attachments || [])
|
||||
setAssignedTo(data.assigned_to_id || '')
|
||||
setTeamId(data.team_id || '')
|
||||
setInternalNotes(data.internal_notes || '')
|
||||
setResolutionSummary(data.resolution_summary || '')
|
||||
} catch (err) {
|
||||
@@ -72,7 +66,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
await loadIssueDetails()
|
||||
} catch (err) {
|
||||
console.error('Failed to update status:', err)
|
||||
alert('Failed to update status')
|
||||
toast.error(t('issues.failedToUpdateStatus'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
@@ -88,7 +82,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
await loadIssueDetails()
|
||||
} catch (err) {
|
||||
console.error('Failed to resolve issue:', err)
|
||||
alert('Failed to resolve issue')
|
||||
toast.error(t('issues.failedToResolve'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
@@ -104,7 +98,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
await loadIssueDetails()
|
||||
} catch (err) {
|
||||
console.error('Failed to decline issue:', err)
|
||||
alert('Failed to decline issue')
|
||||
toast.error(t('issues.failedToDecline'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
@@ -117,7 +111,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
await onUpdate()
|
||||
} catch (err) {
|
||||
console.error('Failed to update assignment:', err)
|
||||
alert('Failed to update assignment')
|
||||
toast.error(t('issues.failedToUpdateAssignment'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,7 +122,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
await api.patch(`/issues/${issueId}`, { internal_notes: internalNotes })
|
||||
} catch (err) {
|
||||
console.error('Failed to save notes:', err)
|
||||
alert('Failed to save notes')
|
||||
toast.error(t('issues.failedToSaveNotes'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
@@ -144,7 +138,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
await loadIssueDetails()
|
||||
} catch (err) {
|
||||
console.error('Failed to add update:', err)
|
||||
alert('Failed to add update')
|
||||
toast.error(t('issues.failedToAddUpdate'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
@@ -162,27 +156,26 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
e.target.value = '' // Reset input
|
||||
} catch (err) {
|
||||
console.error('Failed to upload file:', err)
|
||||
alert('Failed to upload file')
|
||||
toast.error(t('issues.failedToUploadFile'))
|
||||
} finally {
|
||||
setUploadingFile(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteAttachment = async (attachmentId) => {
|
||||
if (!confirm('Delete this attachment?')) return
|
||||
try {
|
||||
await api.delete(`/issue-attachments/${attachmentId}`)
|
||||
await loadIssueDetails()
|
||||
} catch (err) {
|
||||
console.error('Failed to delete attachment:', err)
|
||||
alert('Failed to delete attachment')
|
||||
toast.error(t('issues.failedToDeleteAttachment'))
|
||||
}
|
||||
}
|
||||
|
||||
const copyTrackingLink = () => {
|
||||
const url = `${window.location.origin}/track/${issueData.tracking_token}`
|
||||
navigator.clipboard.writeText(url)
|
||||
alert('Tracking link copied to clipboard!')
|
||||
toast.success(t('issues.trackingLinkCopied'))
|
||||
}
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
@@ -199,31 +192,33 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
|
||||
if (initialLoading || !issueData) {
|
||||
return (
|
||||
<SlidePanel onClose={onClose} maxWidth="600px">
|
||||
<TabbedModal onClose={onClose} size="lg">
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-primary"></div>
|
||||
</div>
|
||||
</SlidePanel>
|
||||
</TabbedModal>
|
||||
)
|
||||
}
|
||||
|
||||
const statusConfig = STATUS_CONFIG[issueData.status] || STATUS_CONFIG.new
|
||||
const priorityConfig = PRIORITY_CONFIG[issueData.priority] || PRIORITY_CONFIG.medium
|
||||
|
||||
const tabs = [
|
||||
{ key: 'details', label: t('issues.details') || 'Details', icon: FileEdit },
|
||||
{ key: 'actions', label: t('issues.actions') || 'Actions', icon: Wrench },
|
||||
{ key: 'updates', label: t('issues.updates') || 'Updates', icon: MessageSquare, badge: updates.length },
|
||||
{ key: 'attachments', label: t('issues.attachments') || 'Attachments', icon: Paperclip, badge: attachments.length },
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<SlidePanel
|
||||
<TabbedModal
|
||||
onClose={onClose}
|
||||
maxWidth="600px"
|
||||
size="lg"
|
||||
header={
|
||||
<div className="p-4 border-b border-border bg-surface-secondary">
|
||||
<div className="flex items-start justify-between gap-3 mb-3">
|
||||
<h2 className="text-lg font-bold text-text-primary flex-1">{issueData.title}</h2>
|
||||
<button onClick={onClose} className="p-1 hover:bg-surface-tertiary rounded">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<>
|
||||
<h2 className="text-lg font-bold text-text-primary">{issueData.title}</h2>
|
||||
<div className="flex items-center gap-2 flex-wrap mt-2">
|
||||
<span className={`text-xs px-2 py-1 rounded-full font-medium flex items-center gap-1.5 ${statusConfig.bg} ${statusConfig.text}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${statusConfig.dot}`}></span>
|
||||
{statusConfig.label}
|
||||
@@ -243,80 +238,115 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
footer={
|
||||
<>
|
||||
<button
|
||||
onClick={copyTrackingLink}
|
||||
className="px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
{t('issues.publicTrackingLink')}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-surface-secondary text-text-primary rounded-lg text-sm font-medium hover:bg-surface-tertiary transition-colors"
|
||||
>
|
||||
{t('common.close') || 'Close'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="p-4 space-y-6">
|
||||
{/* Details Tab */}
|
||||
{activeTab === 'details' && (
|
||||
<div className="p-6 space-y-5">
|
||||
{/* Submitter Info */}
|
||||
<div className="bg-surface-secondary rounded-lg p-4">
|
||||
<h3 className="text-sm font-semibold text-text-primary mb-2">Submitter Information</h3>
|
||||
<h3 className="text-sm font-semibold text-text-primary mb-2">{t('issues.submitterInfo')}</h3>
|
||||
<div className="space-y-1 text-sm">
|
||||
<div><span className="text-text-tertiary">Name:</span> <span className="text-text-primary font-medium">{issueData.submitter_name}</span></div>
|
||||
<div><span className="text-text-tertiary">Email:</span> <span className="text-text-primary">{issueData.submitter_email}</span></div>
|
||||
<div><span className="text-text-tertiary">{t('issues.nameLabel')}</span> <span className="text-text-primary font-medium">{issueData.submitter_name}</span></div>
|
||||
<div><span className="text-text-tertiary">{t('issues.emailLabel')}</span> <span className="text-text-primary">{issueData.submitter_email}</span></div>
|
||||
{issueData.submitter_phone && (
|
||||
<div><span className="text-text-tertiary">Phone:</span> <span className="text-text-primary">{issueData.submitter_phone}</span></div>
|
||||
<div><span className="text-text-tertiary">{t('issues.phoneLabel')}</span> <span className="text-text-primary">{issueData.submitter_phone}</span></div>
|
||||
)}
|
||||
<div><span className="text-text-tertiary">Submitted:</span> <span className="text-text-primary">{formatDate(issueData.created_at)}</span></div>
|
||||
<div><span className="text-text-tertiary">{t('issues.submittedLabel')}</span> <span className="text-text-primary">{formatDate(issueData.created_at)}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-text-primary mb-2">Description</h3>
|
||||
<p className="text-sm text-text-secondary whitespace-pre-wrap">{issueData.description || 'No description provided'}</p>
|
||||
<h3 className="text-sm font-semibold text-text-primary mb-2">{t('issues.description')}</h3>
|
||||
<p className="text-sm text-text-secondary whitespace-pre-wrap">{issueData.description || t('issues.noDescription')}</p>
|
||||
</div>
|
||||
|
||||
{/* Assigned To */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-text-primary mb-2">Assigned To</label>
|
||||
<select
|
||||
<label className="block text-sm font-semibold text-text-primary mb-2">{t('issues.assignedTo')}</label>
|
||||
<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="">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>
|
||||
<PortalSelect
|
||||
value={teamId}
|
||||
onChange={async (val) => {
|
||||
const resolvedVal = val || null
|
||||
setTeamId(resolvedVal || '')
|
||||
try {
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Brand */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-text-primary mb-2">Brand</label>
|
||||
<select
|
||||
<label className="block text-sm font-semibold text-text-primary mb-2">{t('issues.brandLabel')}</label>
|
||||
<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="">No brand</option>
|
||||
{(brands || []).map((b) => (
|
||||
<option key={b._id || b.Id} value={b._id || b.Id}>{b.name}</option>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Internal Notes */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-text-primary mb-2 flex items-center gap-2">
|
||||
<Lock className="w-4 h-4" />
|
||||
Internal Notes (Staff Only)
|
||||
{t('issues.internalNotes')}
|
||||
</label>
|
||||
<textarea
|
||||
value={internalNotes}
|
||||
onChange={(e) => setInternalNotes(e.target.value)}
|
||||
onBlur={handleNotesChange}
|
||||
rows={4}
|
||||
placeholder="Internal notes not visible to submitter..."
|
||||
placeholder={t('issues.internalNotesPlaceholder')}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
@@ -326,89 +356,79 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
<div className="bg-emerald-50 border border-emerald-200 rounded-lg p-4">
|
||||
<h3 className="text-sm font-semibold text-emerald-900 mb-2 flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
Resolution Summary (Public)
|
||||
{t('issues.resolutionSummary')}
|
||||
</h3>
|
||||
<p className="text-sm text-emerald-800 whitespace-pre-wrap">{issueData.resolution_summary}</p>
|
||||
{issueData.resolved_at && (
|
||||
<p className="text-xs text-emerald-600 mt-2">Resolved on {formatDate(issueData.resolved_at)}</p>
|
||||
<p className="text-xs text-emerald-600 mt-2">{t('issues.resolvedOn')} {formatDate(issueData.resolved_at)}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Actions */}
|
||||
{issueData.status !== 'resolved' && issueData.status !== 'declined' && (
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{/* Actions Tab */}
|
||||
{activeTab === 'actions' && (
|
||||
<div className="p-6 space-y-5">
|
||||
{issueData.status !== 'resolved' && issueData.status !== 'declined' ? (
|
||||
<div className="space-y-3">
|
||||
{issueData.status === 'new' && (
|
||||
<button
|
||||
onClick={() => handleUpdateStatus('acknowledged')}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50"
|
||||
className="w-full px-4 py-3 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
<Check className="w-4 h-4 inline mr-1" />
|
||||
Acknowledge
|
||||
<Check className="w-4 h-4" />
|
||||
{t('issues.acknowledge')}
|
||||
</button>
|
||||
)}
|
||||
{(issueData.status === 'new' || issueData.status === 'acknowledged') && (
|
||||
<button
|
||||
onClick={() => handleUpdateStatus('in_progress')}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 bg-amber-600 text-white rounded-lg text-sm font-medium hover:bg-amber-700 disabled:opacity-50"
|
||||
className="w-full px-4 py-3 bg-amber-600 text-white rounded-lg text-sm font-medium hover:bg-amber-700 disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
<Clock className="w-4 h-4 inline mr-1" />
|
||||
Start Work
|
||||
<Clock className="w-4 h-4" />
|
||||
{t('issues.startWork')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowResolveModal(true)}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 bg-emerald-600 text-white rounded-lg text-sm font-medium hover:bg-emerald-700 disabled:opacity-50"
|
||||
className="w-full px-4 py-3 bg-emerald-600 text-white rounded-lg text-sm font-medium hover:bg-emerald-700 disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4 inline mr-1" />
|
||||
Resolve
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
{t('issues.resolve')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowDeclineModal(true)}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 bg-gray-600 text-white rounded-lg text-sm font-medium hover:bg-gray-700 disabled:opacity-50"
|
||||
className="w-full px-4 py-3 bg-gray-600 text-white rounded-lg text-sm font-medium hover:bg-gray-700 disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
<XCircle className="w-4 h-4 inline mr-1" />
|
||||
Decline
|
||||
<XCircle className="w-4 h-4" />
|
||||
{t('issues.decline')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<CheckCircle2 className="w-10 h-10 mx-auto mb-3 text-text-tertiary" />
|
||||
<p className="text-sm text-text-tertiary">
|
||||
{issueData.status === 'resolved' ? t('issues.issueResolved') || 'This issue has been resolved.' : t('issues.issueDeclined') || 'This issue has been declined.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tracking Link */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-text-primary mb-2">Public Tracking Link</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={`${window.location.origin}/track/${issueData.tracking_token}`}
|
||||
readOnly
|
||||
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg bg-surface-secondary"
|
||||
/>
|
||||
<button
|
||||
onClick={copyTrackingLink}
|
||||
className="px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light transition-colors"
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Updates Timeline */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-text-primary mb-3 flex items-center gap-2">
|
||||
Updates Timeline
|
||||
<span className="text-xs text-text-tertiary font-normal">({updates.length})</span>
|
||||
</h3>
|
||||
|
||||
{/* Updates Tab */}
|
||||
{activeTab === 'updates' && (
|
||||
<div className="p-6 space-y-5">
|
||||
{/* Add Update */}
|
||||
<div className="bg-surface-secondary rounded-lg p-3 mb-4">
|
||||
<div className="bg-surface-secondary rounded-lg p-3">
|
||||
<textarea
|
||||
value={newUpdate}
|
||||
onChange={(e) => setNewUpdate(e.target.value)}
|
||||
placeholder="Add an update..."
|
||||
placeholder={t('issues.addUpdatePlaceholder')}
|
||||
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 mb-2"
|
||||
/>
|
||||
@@ -421,7 +441,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
className="rounded"
|
||||
/>
|
||||
<Eye className="w-4 h-4" />
|
||||
Make public (visible to submitter)
|
||||
{t('issues.makePublic')}
|
||||
</label>
|
||||
<button
|
||||
onClick={handleAddUpdate}
|
||||
@@ -429,7 +449,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
className="px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
Add Update
|
||||
{t('issues.addUpdate')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -459,28 +479,22 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
</div>
|
||||
))}
|
||||
{updates.length === 0 && (
|
||||
<p className="text-sm text-text-tertiary text-center py-6">No updates yet</p>
|
||||
<p className="text-sm text-text-tertiary text-center py-6">{t('issues.noUpdates')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Attachments */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-text-primary mb-3 flex items-center gap-2">
|
||||
Attachments
|
||||
<span className="text-xs text-text-tertiary font-normal">({attachments.length})</span>
|
||||
</h3>
|
||||
|
||||
{/* Attachments Tab */}
|
||||
{activeTab === 'attachments' && (
|
||||
<div className="p-6 space-y-5">
|
||||
{/* Upload */}
|
||||
<label className="block mb-3">
|
||||
<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 ? 'Uploading...' : 'Click to upload file'}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
<UploadZone
|
||||
onUpload={handleFileUpload}
|
||||
uploading={uploadingFile}
|
||||
label={t('issues.clickToUpload')}
|
||||
compact
|
||||
/>
|
||||
|
||||
{/* Attachments List */}
|
||||
<div className="space-y-2">
|
||||
@@ -491,7 +505,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-text-primary truncate">{att.original_name}</p>
|
||||
<p className="text-xs text-text-tertiary">
|
||||
{formatFileSize(att.size)} • {att.uploaded_by}
|
||||
{formatFileSize(att.size)} • {att.uploaded_by}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -502,31 +516,31 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-brand-primary hover:underline"
|
||||
>
|
||||
Download
|
||||
{t('issues.download')}
|
||||
</a>
|
||||
<button onClick={() => handleDeleteAttachment(att.Id || att.id)} className="p-1 hover:bg-surface-tertiary rounded">
|
||||
<button onClick={() => setConfirmDeleteAttId(att.Id || att.id)} className="p-1 hover:bg-surface-tertiary rounded">
|
||||
<Trash2 className="w-4 h-4 text-red-600" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{attachments.length === 0 && (
|
||||
<p className="text-sm text-text-tertiary text-center py-4">No attachments</p>
|
||||
<p className="text-sm text-text-tertiary text-center py-4">{t('issues.noAttachments')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SlidePanel>
|
||||
)}
|
||||
</TabbedModal>
|
||||
|
||||
{/* Resolve Modal */}
|
||||
{showResolveModal && (
|
||||
<Modal isOpen title="Resolve Issue" onClose={() => setShowResolveModal(false)}>
|
||||
<Modal isOpen title={t('issues.resolveIssue')} onClose={() => setShowResolveModal(false)}>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-text-secondary">Provide a resolution summary that will be visible to the submitter.</p>
|
||||
<p className="text-sm text-text-secondary">{t('issues.resolveSummaryHint')}</p>
|
||||
<textarea
|
||||
value={resolutionSummary}
|
||||
onChange={(e) => setResolutionSummary(e.target.value)}
|
||||
placeholder="Explain how this issue was resolved..."
|
||||
placeholder={t('issues.resolutionPlaceholder')}
|
||||
rows={5}
|
||||
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"
|
||||
/>
|
||||
@@ -535,14 +549,14 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
onClick={() => setShowResolveModal(false)}
|
||||
className="px-4 py-2 bg-surface-secondary text-text-primary rounded-lg text-sm font-medium hover:bg-surface-tertiary"
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleResolve}
|
||||
disabled={!resolutionSummary.trim() || saving}
|
||||
className="px-4 py-2 bg-emerald-600 text-white rounded-lg text-sm font-medium hover:bg-emerald-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Resolving...' : 'Mark as Resolved'}
|
||||
{saving ? t('issues.resolving') : t('issues.markAsResolved')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -551,13 +565,13 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
|
||||
{/* Decline Modal */}
|
||||
{showDeclineModal && (
|
||||
<Modal isOpen title="Decline Issue" onClose={() => setShowDeclineModal(false)}>
|
||||
<Modal isOpen title={t('issues.declineIssue')} onClose={() => setShowDeclineModal(false)}>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-text-secondary">Provide a reason for declining this issue. This will be visible to the submitter.</p>
|
||||
<p className="text-sm text-text-secondary">{t('issues.declineReasonHint')}</p>
|
||||
<textarea
|
||||
value={resolutionSummary}
|
||||
onChange={(e) => setResolutionSummary(e.target.value)}
|
||||
placeholder="Explain why this issue cannot be addressed..."
|
||||
placeholder={t('issues.declinePlaceholder')}
|
||||
rows={5}
|
||||
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"
|
||||
/>
|
||||
@@ -566,19 +580,32 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
onClick={() => setShowDeclineModal(false)}
|
||||
className="px-4 py-2 bg-surface-secondary text-text-primary rounded-lg text-sm font-medium hover:bg-surface-tertiary"
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDecline}
|
||||
disabled={!resolutionSummary.trim() || saving}
|
||||
className="px-4 py-2 bg-gray-600 text-white rounded-lg text-sm font-medium hover:bg-gray-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Declining...' : 'Decline Issue'}
|
||||
{saving ? t('issues.declining') : t('issues.declineIssue')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* Delete Attachment Confirmation */}
|
||||
<Modal
|
||||
isOpen={!!confirmDeleteAttId}
|
||||
onClose={() => setConfirmDeleteAttId(null)}
|
||||
title={t('issues.deleteAttachment')}
|
||||
isConfirm
|
||||
danger
|
||||
onConfirm={() => handleDeleteAttachment(confirmDeleteAttId)}
|
||||
confirmText={t('common.delete')}
|
||||
>
|
||||
{t('issues.deleteAttachmentDesc')}
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,24 +1,14 @@
|
||||
import { useState } from 'react'
|
||||
import PostCard from './PostCard'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
|
||||
const COLUMNS = [
|
||||
{ id: 'draft', labelKey: 'posts.status.draft', color: 'bg-gray-400' },
|
||||
{ id: 'in_review', labelKey: 'posts.status.in_review', color: 'bg-amber-400' },
|
||||
{ id: 'approved', labelKey: 'posts.status.approved', color: 'bg-blue-400' },
|
||||
{ id: 'scheduled', labelKey: 'posts.status.scheduled', color: 'bg-purple-400' },
|
||||
{ id: 'published', labelKey: 'posts.status.published', color: 'bg-emerald-400' },
|
||||
]
|
||||
|
||||
export default function KanbanBoard({ posts, onPostClick, onMovePost }) {
|
||||
export default function KanbanBoard({ columns, items, renderCard, getItemId, onMove, emptyLabel }) {
|
||||
const { t } = useLanguage()
|
||||
const [draggedPost, setDraggedPost] = useState(null)
|
||||
const [draggedItem, setDraggedItem] = useState(null)
|
||||
const [dragOverCol, setDragOverCol] = useState(null)
|
||||
|
||||
const handleDragStart = (e, post) => {
|
||||
setDraggedPost(post)
|
||||
const handleDragStart = (e, item) => {
|
||||
setDraggedItem(item)
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
// Make the drag image slightly transparent
|
||||
if (e.target) {
|
||||
setTimeout(() => e.target.style.opacity = '0.4', 0)
|
||||
}
|
||||
@@ -26,7 +16,7 @@ export default function KanbanBoard({ posts, onPostClick, onMovePost }) {
|
||||
|
||||
const handleDragEnd = (e) => {
|
||||
e.target.style.opacity = '1'
|
||||
setDraggedPost(null)
|
||||
setDraggedItem(null)
|
||||
setDragOverCol(null)
|
||||
}
|
||||
|
||||
@@ -36,8 +26,7 @@ export default function KanbanBoard({ posts, onPostClick, onMovePost }) {
|
||||
setDragOverCol(colId)
|
||||
}
|
||||
|
||||
const handleDragLeave = (e, colId) => {
|
||||
// Only clear if we're actually leaving the column (not entering a child)
|
||||
const handleDragLeave = (e) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget)) {
|
||||
setDragOverCol(null)
|
||||
}
|
||||
@@ -46,59 +35,54 @@ export default function KanbanBoard({ posts, onPostClick, onMovePost }) {
|
||||
const handleDrop = (e, colId) => {
|
||||
e.preventDefault()
|
||||
setDragOverCol(null)
|
||||
if (draggedPost && draggedPost.status !== colId) {
|
||||
onMovePost(draggedPost._id, colId)
|
||||
if (draggedItem && draggedItem.status !== colId) {
|
||||
onMove(getItemId(draggedItem), colId)
|
||||
}
|
||||
setDraggedPost(null)
|
||||
setDraggedItem(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-4 overflow-x-auto pb-4">
|
||||
{COLUMNS.map((col) => {
|
||||
const colPosts = posts.filter((p) => p.status === col.id)
|
||||
const isOver = dragOverCol === col.id && draggedPost?.status !== col.id
|
||||
{columns.map((col) => {
|
||||
const colItems = items.filter((item) => item.status === col.id)
|
||||
const isOver = dragOverCol === col.id && draggedItem?.status !== col.id
|
||||
|
||||
return (
|
||||
<div key={col.id} className="flex-shrink-0 w-72">
|
||||
<div key={col.id} className="min-w-[240px] flex-1">
|
||||
{/* Column header */}
|
||||
<div className="flex items-center gap-2 mb-3 px-1">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className={`w-2.5 h-2.5 rounded-full ${col.color}`} />
|
||||
<h4 className="text-sm font-semibold text-text-primary">{t(col.labelKey)}</h4>
|
||||
<span className="text-xs font-medium text-text-tertiary bg-surface-tertiary px-2 py-0.5 rounded-full ml-auto">
|
||||
{colPosts.length}
|
||||
<h4 className="text-sm font-semibold text-text-primary">{col.label}</h4>
|
||||
<span className="text-xs font-medium text-text-tertiary bg-surface-tertiary px-2 py-0.5 rounded-full">
|
||||
{colItems.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Column body — drop zone */}
|
||||
<div
|
||||
className={`kanban-column rounded-xl p-2 space-y-2 border-2 transition-colors min-h-[120px] ${
|
||||
className={`rounded-xl p-2 space-y-2 border-2 transition-colors min-h-[200px] ${
|
||||
isOver
|
||||
? 'bg-brand-primary/5 border-brand-primary/40 border-dashed'
|
||||
: 'bg-surface-secondary border-border-light border-solid'
|
||||
}`}
|
||||
onDragOver={(e) => handleDragOver(e, col.id)}
|
||||
onDragLeave={(e) => handleDragLeave(e, col.id)}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={(e) => handleDrop(e, col.id)}
|
||||
>
|
||||
{colPosts.length === 0 ? (
|
||||
{colItems.length === 0 ? (
|
||||
<div className={`py-8 text-center text-xs ${isOver ? 'text-brand-primary font-medium' : 'text-text-tertiary'}`}>
|
||||
{isOver ? t('posts.dropHere') : t('posts.noPosts')}
|
||||
{isOver ? t('posts.dropHere') : (emptyLabel || t('posts.noPosts'))}
|
||||
</div>
|
||||
) : (
|
||||
colPosts.map((post) => (
|
||||
colItems.map((item) => (
|
||||
<div
|
||||
key={post._id}
|
||||
key={getItemId(item)}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, post)}
|
||||
onDragStart={(e) => handleDragStart(e, item)}
|
||||
onDragEnd={handleDragEnd}
|
||||
className="cursor-grab active:cursor-grabbing"
|
||||
>
|
||||
<PostCard
|
||||
post={post}
|
||||
onClick={() => onPostClick(post)}
|
||||
onMove={onMovePost}
|
||||
compact
|
||||
/>
|
||||
{renderCard(item)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { format } from 'date-fns'
|
||||
import { Clock } from 'lucide-react'
|
||||
import { getInitials } from '../utils/api'
|
||||
import BrandBadge from './BrandBadge'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
|
||||
export default function KanbanCard({ title, thumbnail, brandName, tags, assigneeName, date, dateOverdue, onClick, children }) {
|
||||
const { t } = useLanguage()
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
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" loading="lazy" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
<h5 className="text-sm font-medium text-text-primary line-clamp-2 leading-snug mb-2">{title}</h5>
|
||||
|
||||
{/* Tags row: brand + extra tags */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{brandName && <BrandBadge brand={brandName} />}
|
||||
{tags}
|
||||
</div>
|
||||
|
||||
{/* Footer: assignee + date */}
|
||||
<div className="flex items-center justify-between mt-3 pt-2 border-t border-border-light">
|
||||
{assigneeName ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-5 h-5 rounded-full bg-brand-primary/10 text-brand-primary flex items-center justify-center text-[10px] font-semibold">
|
||||
{getInitials(assigneeName)}
|
||||
</div>
|
||||
<span className="text-xs text-text-tertiary">{assigneeName}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-text-tertiary">{t('common.unassigned')}</span>
|
||||
)}
|
||||
|
||||
{date && (
|
||||
<span className={`text-[10px] flex items-center gap-1 ${dateOverdue ? 'text-red-500 font-medium' : 'text-text-tertiary'}`}>
|
||||
<Clock className="w-3 h-3" />
|
||||
{format(new Date(date), 'MMM d')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Optional extra content (quick actions, delete overlay, etc.) */}
|
||||
{children}
|
||||
</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
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import BrandBadge from './BrandBadge'
|
||||
import StatusBadge from './StatusBadge'
|
||||
import { PlatformIcons } from './PlatformIcon'
|
||||
|
||||
export default function PostCard({ post, onClick, onMove, compact = false }) {
|
||||
export default function PostCard({ post, onClick, onMove, compact = false, checkboxSlot }) {
|
||||
const { t } = useLanguage()
|
||||
const { getBrandName } = useContext(AppContext)
|
||||
const brandName = getBrandName(post.brand_id || post.brandId) || post.brand_name || post.brand
|
||||
@@ -23,11 +23,11 @@ export default function PostCard({ post, onClick, onMove, compact = false }) {
|
||||
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={`http://localhost:3001${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>
|
||||
)}
|
||||
|
||||
@@ -97,6 +97,7 @@ export default function PostCard({ post, onClick, onMove, compact = false }) {
|
||||
// Table row view
|
||||
return (
|
||||
<tr onClick={onClick} className="hover:bg-surface-secondary cursor-pointer group">
|
||||
{checkboxSlot && <td className="w-10 px-3 py-3" onClick={e => e.stopPropagation()}>{checkboxSlot}</td>}
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="shrink-0">
|
||||
|
||||
@@ -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,31 +1,46 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { X, Trash2, Upload, FileText, Link2, ExternalLink, FolderOpen } 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 { api, getBrandColor } from '../utils/api'
|
||||
import CommentsSection from './CommentsSection'
|
||||
import Modal from './Modal'
|
||||
import SlidePanel from './SlidePanel'
|
||||
import CollapsibleSection from './CollapsibleSection'
|
||||
import TabbedModal from './TabbedModal'
|
||||
import { useToast } from './ToastContainer'
|
||||
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 fileInputRef = useRef(null)
|
||||
const toast = useToast()
|
||||
const versionFileInputRef = useRef(null)
|
||||
const [activeTab, setActiveTab] = useState('details')
|
||||
const [form, setForm] = useState({})
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [publishError, setPublishError] = useState('')
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
|
||||
// Attachments state
|
||||
// Review state
|
||||
const [submittingReview, setSubmittingReview] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
// 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 [uploadingVersionFile, setUploadingVersionFile] = useState(false)
|
||||
|
||||
const postId = post?._id || post?.id
|
||||
const isCreateMode = !postId
|
||||
const reviewUrl = post?.approval_token ? `${window.location.origin}/review-post/${post.approval_token}` : ''
|
||||
|
||||
useEffect(() => {
|
||||
if (post) {
|
||||
@@ -36,14 +51,19 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
|
||||
platforms: post.platforms || (post.platform ? [post.platform] : []),
|
||||
status: post.status || 'draft',
|
||||
assigned_to: post.assignedTo || post.assigned_to || '',
|
||||
scheduled_date: post.scheduledDate ? new Date(post.scheduledDate).toISOString().slice(0, 16) : '',
|
||||
scheduled_date: post.scheduledDate ? new Date(post.scheduledDate).toISOString().slice(0, 10) : (post.scheduled_date ? new Date(post.scheduled_date).toISOString().slice(0, 10) : ''),
|
||||
notes: post.notes || '',
|
||||
campaign_id: post.campaignId || post.campaign_id || '',
|
||||
publication_links: post.publication_links || post.publicationLinks || [],
|
||||
approver_ids: post.approvers?.map(a => String(a.id)) || (post.approver_ids ? post.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : []),
|
||||
})
|
||||
setDirty(isCreateMode)
|
||||
setPublishError('')
|
||||
if (!isCreateMode) loadAttachments()
|
||||
setActiveTab('details')
|
||||
if (!isCreateMode) {
|
||||
loadAttachments()
|
||||
loadVersions()
|
||||
}
|
||||
}
|
||||
}, [post])
|
||||
|
||||
@@ -53,6 +73,7 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
|
||||
{ value: 'draft', label: t('posts.status.draft') },
|
||||
{ value: 'in_review', label: t('posts.status.in_review') },
|
||||
{ value: 'approved', label: t('posts.status.approved') },
|
||||
{ value: 'rejected', label: t('posts.status.rejected') },
|
||||
{ value: 'scheduled', label: t('posts.status.scheduled') },
|
||||
{ value: 'published', label: t('posts.status.published') },
|
||||
]
|
||||
@@ -91,9 +112,11 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
|
||||
notes: form.notes,
|
||||
campaign_id: form.campaign_id ? Number(form.campaign_id) : null,
|
||||
publication_links: form.publication_links || [],
|
||||
approver_ids: (form.approver_ids || []).length > 0 ? form.approver_ids.join(',') : null,
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -118,18 +141,53 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
|
||||
}
|
||||
}
|
||||
|
||||
const handleStatusAction = async (newStatus) => {
|
||||
if (!postId || saving) return
|
||||
setSaving(true)
|
||||
try {
|
||||
await onSave(postId, { ...form, status: newStatus, approver_ids: (form.approver_ids || []).length > 0 ? form.approver_ids.join(',') : null })
|
||||
setForm(f => ({ ...f, status: newStatus }))
|
||||
setDirty(false)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmitReview = async () => {
|
||||
if (!postId || submittingReview) return
|
||||
if (dirty) await handleSave()
|
||||
setSubmittingReview(true)
|
||||
try {
|
||||
await api.post(`/posts/${postId}/submit-review`)
|
||||
setForm(f => ({ ...f, status: 'in_review' }))
|
||||
toast.success(t('posts.submittedForReview'))
|
||||
onSave(postId, {})
|
||||
} catch (err) {
|
||||
toast.error(err.message || t('posts.failedSubmitReview'))
|
||||
} finally {
|
||||
setSubmittingReview(false)
|
||||
}
|
||||
}
|
||||
|
||||
const copyReviewLink = () => {
|
||||
navigator.clipboard.writeText(reviewUrl)
|
||||
setCopied(true)
|
||||
toast.success(t('posts.reviewLinkCopied'))
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
const confirmDelete = async () => {
|
||||
setShowDeleteConfirm(false)
|
||||
await onDelete(postId)
|
||||
onClose()
|
||||
}
|
||||
|
||||
// ─── Attachments ──────────────────────────────
|
||||
// ─── Legacy Attachments ──────────────────────────
|
||||
async function loadAttachments() {
|
||||
if (!postId) return
|
||||
try {
|
||||
const data = await api.get(`/posts/${postId}/attachments`)
|
||||
setAttachments(Array.isArray(data) ? data : (data.data || []))
|
||||
setAttachments(Array.isArray(data) ? data : [])
|
||||
} catch {
|
||||
setAttachments([])
|
||||
}
|
||||
@@ -160,31 +218,105 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
|
||||
}
|
||||
}
|
||||
|
||||
const openAssetPicker = async () => {
|
||||
try {
|
||||
const data = await api.get('/assets')
|
||||
setAvailableAssets(Array.isArray(data) ? data : (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
|
||||
try {
|
||||
const data = await api.get(`/posts/${postId}/versions`)
|
||||
const vList = Array.isArray(data) ? data : []
|
||||
setVersions(vList)
|
||||
if (vList.length > 0) {
|
||||
const latest = vList[vList.length - 1]
|
||||
setSelectedVersion(latest)
|
||||
loadVersionData(latest.Id || latest.id || latest._id)
|
||||
} else {
|
||||
setSelectedVersion(null)
|
||||
setVersionData(null)
|
||||
}
|
||||
} catch {
|
||||
setVersions([])
|
||||
}
|
||||
}
|
||||
|
||||
async function loadVersionData(versionId) {
|
||||
if (!postId || !versionId) return
|
||||
try {
|
||||
const data = await api.get(`/posts/${postId}/versions/${versionId}`)
|
||||
setVersionData(data)
|
||||
} catch {
|
||||
setVersionData(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectVersion = (version) => {
|
||||
setSelectedVersion(version)
|
||||
loadVersionData(version.Id || version.id || version._id)
|
||||
}
|
||||
|
||||
const handleCreateVersion = async ({ notes, copy_from_previous }) => {
|
||||
try {
|
||||
await api.post(`/posts/${postId}/versions`, {
|
||||
notes: notes || undefined,
|
||||
copy_from_previous,
|
||||
})
|
||||
loadVersions()
|
||||
} catch (err) {
|
||||
console.error('Create version failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddLanguage = async (languageForm) => {
|
||||
if (!selectedVersion) return
|
||||
const vId = selectedVersion.Id || selectedVersion.id || selectedVersion._id
|
||||
await api.post(`/posts/${postId}/versions/${vId}/texts`, languageForm)
|
||||
loadVersionData(vId)
|
||||
}
|
||||
|
||||
const handleDeleteLanguage = async (textId) => {
|
||||
try {
|
||||
await api.delete(`/post-version-texts/${textId}`)
|
||||
const vId = selectedVersion.Id || selectedVersion.id || selectedVersion._id
|
||||
loadVersionData(vId)
|
||||
} catch (err) {
|
||||
console.error('Delete language failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleVersionFileUpload = async (files) => {
|
||||
if (!selectedVersion || !files?.length) return
|
||||
setUploadingVersionFile(true)
|
||||
const vId = selectedVersion.Id || selectedVersion.id || selectedVersion._id
|
||||
for (const file of files) {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
try {
|
||||
await api.upload(`/posts/${postId}/versions/${vId}/attachments`, fd)
|
||||
} catch (err) {
|
||||
console.error('Version upload failed:', err)
|
||||
}
|
||||
}
|
||||
setUploadingVersionFile(false)
|
||||
loadVersionData(vId)
|
||||
}
|
||||
|
||||
const handleDeleteVersionAttachment = async (attId) => {
|
||||
try {
|
||||
await api.delete(`/attachments/${attId}`)
|
||||
const vId = selectedVersion.Id || selectedVersion.id || selectedVersion._id
|
||||
loadVersionData(vId)
|
||||
} catch (err) {
|
||||
console.error('Delete version attachment failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const brandName = (() => {
|
||||
@@ -195,10 +327,21 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
|
||||
return post.brand_name || post.brandName || null
|
||||
})()
|
||||
|
||||
const header = (
|
||||
<div className="px-5 py-4 border-b border-border shrink-0">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
const tabConfig = {
|
||||
details: { label: t('posts.details'), icon: FileEdit },
|
||||
versions: { label: t('posts.versions'), icon: Layers, badge: versions.length || null },
|
||||
platforms: { label: t('posts.platformsLinks'), icon: Share2 },
|
||||
approval: { label: t('posts.approval'), icon: ShieldCheck },
|
||||
discussion: { label: t('posts.discussion'), icon: MessageSquare },
|
||||
}
|
||||
|
||||
// Filter tabs: hide some in create mode
|
||||
const visibleTabs = isCreateMode
|
||||
? ['details', 'platforms']
|
||||
: TABS
|
||||
|
||||
const modalHeader = (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
value={form.title}
|
||||
@@ -206,366 +349,264 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
|
||||
className="w-full text-lg font-semibold text-text-primary bg-transparent border-0 p-0 focus:outline-none focus:ring-0"
|
||||
placeholder={t('posts.postTitlePlaceholder')}
|
||||
/>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<span className={`text-[11px] px-2 py-0.5 rounded-full font-medium ${
|
||||
<div className="flex items-center gap-2 mt-2 flex-wrap">
|
||||
<span className={`text-[11px] px-2.5 py-0.5 rounded-full font-medium ${
|
||||
form.status === 'published' ? 'bg-emerald-100 text-emerald-700' :
|
||||
form.status === 'scheduled' ? 'bg-purple-100 text-purple-700' :
|
||||
form.status === 'approved' ? 'bg-blue-100 text-blue-700' :
|
||||
form.status === 'in_review' ? 'bg-amber-100 text-amber-700' :
|
||||
'bg-gray-100 text-gray-600'
|
||||
form.status === 'rejected' ? 'bg-red-100 text-red-700' :
|
||||
'bg-gray-100 text-text-secondary'
|
||||
}`}>
|
||||
{statusOptions.find(s => s.value === form.status)?.label}
|
||||
</span>
|
||||
{brandName && (
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded ${getBrandColor(brandName).bg} ${getBrandColor(brandName).text}`}>
|
||||
<span className={`text-[11px] px-2 py-0.5 rounded-full ${getBrandColor(brandName).bg} ${getBrandColor(brandName).text}`}>
|
||||
{brandName}
|
||||
</span>
|
||||
)}
|
||||
{post.current_version && (
|
||||
<span className="text-[11px] px-2 py-0.5 rounded-full bg-indigo-50 text-indigo-600 font-medium">
|
||||
v{post.current_version}
|
||||
</span>
|
||||
)}
|
||||
{post.creator_user_name && (
|
||||
<span className="text-[11px] text-text-tertiary">
|
||||
{t('review.createdBy')} <span className="text-text-secondary font-medium">{post.creator_user_name}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
const modalFooter = (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
{onDelete && !isCreateMode && (
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="px-3 py-2 text-sm font-medium text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5">
|
||||
{dirty ? (
|
||||
<>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors shrink-0"
|
||||
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!form.title || saving}
|
||||
className={`px-6 py-2 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 ${saving ? 'btn-loading' : ''}`}
|
||||
>
|
||||
{isCreateMode ? t('posts.createPost') : t('posts.saveChanges')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-5 py-2 text-sm font-medium text-text-secondary border border-border rounded-lg hover:bg-surface-tertiary transition-colors"
|
||||
>
|
||||
{t('common.close')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<SlidePanel onClose={onClose} maxWidth="520px" header={header}>
|
||||
{/* Details Section */}
|
||||
<CollapsibleSection title={t('posts.details')}>
|
||||
<div className="px-5 pb-4 space-y-3">
|
||||
<TabbedModal
|
||||
onClose={onClose}
|
||||
size="xl"
|
||||
header={modalHeader}
|
||||
tabs={visibleTabs.map(tab => ({ key: tab, ...tabConfig[tab] }))}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
footer={modalFooter}
|
||||
>
|
||||
{/* ─── Details Tab ─── */}
|
||||
{activeTab === 'details' && (
|
||||
<div className="p-6 space-y-5">
|
||||
{/* Two-column layout for details */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
|
||||
{/* Main content — left column */}
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
<div className="bg-surface-secondary rounded-xl p-4 space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.description')}</label>
|
||||
<label className="block text-xs font-medium text-text-primary mb-1.5">{t('posts.description')}</label>
|
||||
<textarea
|
||||
value={form.description}
|
||||
onChange={e => update('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 resize-none"
|
||||
rows={4}
|
||||
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>
|
||||
|
||||
<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={form.brand_id}
|
||||
onChange={e => update('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.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>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.campaign')}</label>
|
||||
<select
|
||||
value={form.campaign_id}
|
||||
onChange={e => update('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="">{t('posts.noCampaign')}</option>
|
||||
{(campaigns || []).map(c => <option key={c._id || c.id} value={c._id || c.id}>{c.name}</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('posts.assignTo')}</label>
|
||||
<select
|
||||
value={form.assigned_to}
|
||||
onChange={e => update('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 || 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('posts.status')}</label>
|
||||
<select
|
||||
value={form.status}
|
||||
onChange={e => update('status', 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"
|
||||
>
|
||||
{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('posts.scheduledDate')}</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={form.scheduled_date}
|
||||
onChange={e => update('scheduled_date', 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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.notes')}</label>
|
||||
<label className="block text-xs font-medium text-text-primary mb-1.5">{t('posts.notes')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.notes}
|
||||
onChange={e => update('notes', 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"
|
||||
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>
|
||||
</div>
|
||||
|
||||
{publishError && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-xl text-sm text-red-700 flex items-center gap-2">
|
||||
<XCircle className="w-4 h-4 shrink-0" />
|
||||
{publishError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
{dirty && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!form.title || saving}
|
||||
className={`flex-1 px-4 py-2 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 ${saving ? 'btn-loading' : ''}`}
|
||||
>
|
||||
{isCreateMode ? t('posts.createPost') : t('posts.saveChanges')}
|
||||
</button>
|
||||
)}
|
||||
{onDelete && !isCreateMode && (
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="p-2 text-text-tertiary hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Platforms & Links Section */}
|
||||
<CollapsibleSection title={t('posts.platformsLinks')}>
|
||||
<div className="px-5 pb-4 space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.platforms')}</label>
|
||||
<div className="flex flex-wrap gap-2 p-2 border border-border rounded-lg bg-white min-h-[38px]">
|
||||
{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-2.5 py-1.5 rounded-full cursor-pointer border transition-colors ${
|
||||
checked
|
||||
? 'bg-brand-primary/10 border-brand-primary/30 text-brand-primary font-medium'
|
||||
: 'bg-surface-tertiary border-transparent text-text-secondary hover:bg-surface-tertiary/80'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => {
|
||||
update('platforms', checked
|
||||
? form.platforms.filter(p => p !== k)
|
||||
: [...(form.platforms || []), k]
|
||||
)
|
||||
}}
|
||||
className="sr-only"
|
||||
/>
|
||||
{v.label}
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(form.platforms || []).length > 0 && (
|
||||
<div className="space-y-2 border border-border rounded-lg p-3 bg-surface-secondary">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-text-secondary mb-1">
|
||||
<Link2 className="w-3.5 h-3.5" />
|
||||
{t('posts.publicationLinks')}
|
||||
</div>
|
||||
{(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-2">
|
||||
<span className="text-xs font-medium text-text-secondary w-24 shrink-0 flex items-center gap-1.5">
|
||||
<span className="w-2 h-2 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-1.5 text-sm border border-border rounded-lg 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-1.5 text-text-tertiary hover:text-brand-primary">
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
)}
|
||||
</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-1">{t('posts.publishRequired')}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Attachments Section (hidden in create mode) */}
|
||||
{/* Legacy Attachments (non-versioned) */}
|
||||
{!isCreateMode && (
|
||||
<CollapsibleSection
|
||||
title={t('posts.attachments')}
|
||||
badge={attachments.length > 0 ? (
|
||||
<span className="text-xs font-medium text-text-tertiary bg-surface-tertiary px-1.5 py-0.5 rounded-full">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wide">{t('posts.attachments')}</h4>
|
||||
{attachments.length > 0 && (
|
||||
<span className="text-[10px] font-medium text-text-tertiary bg-surface-tertiary px-1.5 py-0.5 rounded-full">
|
||||
{attachments.length}
|
||||
</span>
|
||||
) : null}
|
||||
>
|
||||
<div className="px-5 pb-4">
|
||||
{attachments.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-2 mb-3">
|
||||
{attachments.map(att => {
|
||||
const isImage = att.mime_type?.startsWith('image/') || att.mimeType?.startsWith('image/')
|
||||
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">
|
||||
{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" />
|
||||
</a>
|
||||
) : (
|
||||
<a href={attUrl} target="_blank" rel="noopener noreferrer" 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">{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 className="px-2 py-1 text-[10px] text-text-tertiary truncate border-t border-border-light">{name}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-4 text-center cursor-pointer transition-colors ${
|
||||
dragActive ? 'border-brand-primary bg-brand-primary/5' : 'border-border hover:border-brand-primary/40'
|
||||
}`}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragEnter={e => { e.preventDefault(); setDragActive(true) }}
|
||||
onDragLeave={e => { e.preventDefault(); setDragActive(false) }}
|
||||
onDragOver={e => e.preventDefault()}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={e => { handleFileUpload(e.target.files); e.target.value = '' }}
|
||||
<PostDetailAttachments
|
||||
attachments={attachments}
|
||||
uploading={uploading}
|
||||
onFileUpload={handleFileUpload}
|
||||
onDeleteAttachment={handleDeleteAttachment}
|
||||
onAttachAsset={handleAttachAsset}
|
||||
/>
|
||||
<Upload className={`w-5 h-5 text-text-tertiary mx-auto mb-1 ${uploading ? 'animate-pulse' : ''}`} />
|
||||
<p className="text-xs text-text-secondary">
|
||||
{dragActive ? t('posts.dropFiles') : t('posts.uploadFiles')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={openAssetPicker}
|
||||
className="mt-2 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"
|
||||
{/* Sidebar — right column */}
|
||||
<div className="space-y-4">
|
||||
<div className="bg-surface-secondary rounded-xl p-4 space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-primary mb-1.5">{t('posts.status')}</label>
|
||||
<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-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
>
|
||||
<FolderOpen className="w-4 h-4" />
|
||||
{t('posts.attachFromAssets')}
|
||||
</button>
|
||||
|
||||
{showAssetPicker && (
|
||||
<div className="mt-2 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>
|
||||
{statusOptions.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-primary mb-1.5">{t('posts.scheduledDate')}</label>
|
||||
<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"
|
||||
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-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
/>
|
||||
<div className="grid grid-cols-3 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>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-primary mb-1.5">{t('posts.assignTo')}</label>
|
||||
<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-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
>
|
||||
<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" />
|
||||
<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>
|
||||
|
||||
<div className="bg-surface-secondary rounded-xl p-4 space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-primary mb-1.5">{t('posts.brand')}</label>
|
||||
<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-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>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-primary mb-1.5">{t('posts.campaign')}</label>
|
||||
<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-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>)}
|
||||
</select>
|
||||
</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>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Discussion Section (hidden in create mode) */}
|
||||
{!isCreateMode && (
|
||||
<CollapsibleSection title={t('posts.discussion')} noBorder>
|
||||
<div className="px-5 pb-5">
|
||||
{/* ─── Versions Tab ─── */}
|
||||
{activeTab === 'versions' && !isCreateMode && (
|
||||
<PostDetailVersions
|
||||
versions={versions}
|
||||
selectedVersion={selectedVersion}
|
||||
versionData={versionData}
|
||||
onSelectVersion={handleSelectVersion}
|
||||
onCreateVersion={handleCreateVersion}
|
||||
onAddLanguage={handleAddLanguage}
|
||||
onDeleteLanguage={handleDeleteLanguage}
|
||||
onVersionFileUpload={handleVersionFileUpload}
|
||||
onDeleteVersionAttachment={handleDeleteVersionAttachment}
|
||||
uploadingVersionFile={uploadingVersionFile}
|
||||
versionFileInputRef={versionFileInputRef}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ─── Platforms & Links Tab ─── */}
|
||||
{activeTab === 'platforms' && (
|
||||
<PostDetailPlatforms
|
||||
form={form}
|
||||
update={update}
|
||||
updatePublicationLink={updatePublicationLink}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ─── Approval Tab ─── */}
|
||||
{activeTab === 'approval' && (
|
||||
<PostDetailApproval
|
||||
form={form}
|
||||
update={update}
|
||||
post={post}
|
||||
isCreateMode={isCreateMode}
|
||||
reviewUrl={reviewUrl}
|
||||
copied={copied}
|
||||
submittingReview={submittingReview}
|
||||
saving={saving}
|
||||
teamMembers={teamMembers}
|
||||
onSubmitReview={handleSubmitReview}
|
||||
onCopyReviewLink={copyReviewLink}
|
||||
onStatusAction={handleStatusAction}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ─── Discussion Tab ─── */}
|
||||
{activeTab === 'discussion' && !isCreateMode && (
|
||||
<div className="p-6 w-full">
|
||||
<CommentsSection entityType="post" entityId={postId} />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
</SlidePanel>
|
||||
</TabbedModal>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<Modal
|
||||
isOpen={showDeleteConfirm}
|
||||
onClose={() => setShowDeleteConfirm(false)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { X, Trash2, Upload } from 'lucide-react'
|
||||
import { useState, useEffect, useRef, useContext } from 'react'
|
||||
import { Trash2, Upload, FileEdit, MessageSquare } from 'lucide-react'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import { api, getBrandColor } from '../utils/api'
|
||||
import CommentsSection from './CommentsSection'
|
||||
import Modal from './Modal'
|
||||
import SlidePanel from './SlidePanel'
|
||||
import CollapsibleSection from './CollapsibleSection'
|
||||
import TabbedModal from './TabbedModal'
|
||||
import PortalSelect from './PortalSelect'
|
||||
import { AppContext } from '../App'
|
||||
|
||||
export default function ProjectEditPanel({ project, onClose, onSave, onDelete, brands, teamMembers }) {
|
||||
const { teams } = useContext(AppContext)
|
||||
const { t, lang } = useLanguage()
|
||||
const thumbnailInputRef = useRef(null)
|
||||
const [form, setForm] = useState({})
|
||||
@@ -15,6 +17,7 @@ export default function ProjectEditPanel({ project, onClose, onSave, onDelete, b
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [thumbnailUploading, setThumbnailUploading] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState('details')
|
||||
|
||||
const projectId = project?._id || project?.id
|
||||
if (!project) return null
|
||||
@@ -26,6 +29,7 @@ export default function ProjectEditPanel({ project, onClose, onSave, onDelete, b
|
||||
description: project.description || '',
|
||||
brand_id: project.brandId || project.brand_id || '',
|
||||
owner_id: project.ownerId || project.owner_id || '',
|
||||
team_id: project.team_id || '',
|
||||
status: project.status || 'active',
|
||||
start_date: project.startDate || project.start_date ? new Date(project.startDate || project.start_date).toISOString().slice(0, 10) : '',
|
||||
due_date: project.dueDate ? new Date(project.dueDate).toISOString().slice(0, 10) : '',
|
||||
@@ -54,6 +58,7 @@ export default function ProjectEditPanel({ project, onClose, onSave, onDelete, b
|
||||
description: form.description,
|
||||
brand_id: form.brand_id ? Number(form.brand_id) : null,
|
||||
owner_id: form.owner_id ? Number(form.owner_id) : null,
|
||||
team_id: form.team_id ? Number(form.team_id) : null,
|
||||
status: form.status,
|
||||
start_date: form.start_date || null,
|
||||
due_date: form.due_date || null,
|
||||
@@ -103,10 +108,17 @@ export default function ProjectEditPanel({ project, onClose, onSave, onDelete, b
|
||||
return project.brand_name || project.brandName || null
|
||||
})()
|
||||
|
||||
const header = (
|
||||
<div className="px-5 py-4 border-b border-border shrink-0">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
const tabs = [
|
||||
{ key: 'details', label: t('projects.details'), icon: FileEdit },
|
||||
{ key: 'discussion', label: t('projects.discussion'), icon: MessageSquare },
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<TabbedModal
|
||||
onClose={onClose}
|
||||
size="md"
|
||||
header={<>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
@@ -120,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>
|
||||
@@ -130,23 +142,37 @@ export default function ProjectEditPanel({ project, onClose, onSave, onDelete, b
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>}
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
footer={<>
|
||||
<div className="flex items-center gap-2">
|
||||
{onDelete && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors shrink-0"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="p-2 text-text-tertiary hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5">
|
||||
{dirty && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!form.name || saving}
|
||||
className={`px-4 py-2 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 ${saving ? 'btn-loading' : ''}`}
|
||||
>
|
||||
{t('tasks.saveChanges')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<SlidePanel onClose={onClose} maxWidth="420px" header={header}>
|
||||
{/* Details Section */}
|
||||
<CollapsibleSection title={t('projects.details')}>
|
||||
<div className="px-5 pb-4 space-y-3">
|
||||
</>}
|
||||
>
|
||||
{activeTab === 'details' && (
|
||||
<div className="p-6 space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('projects.description')}</label>
|
||||
<textarea
|
||||
@@ -161,39 +187,45 @@ 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>
|
||||
<PortalSelect
|
||||
value={form.team_id}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('projects.startDate')}</label>
|
||||
<input
|
||||
@@ -203,7 +235,6 @@ export default function ProjectEditPanel({ project, onClose, onSave, onDelete, b
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('projects.dueDate')}</label>
|
||||
@@ -220,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>
|
||||
@@ -252,41 +283,20 @@ 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>
|
||||
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
{dirty && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!form.name || saving}
|
||||
className={`flex-1 px-4 py-2 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 ${saving ? 'btn-loading' : ''}`}
|
||||
>
|
||||
{t('tasks.saveChanges')}
|
||||
</button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="p-2 text-text-tertiary hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Discussion Section */}
|
||||
<CollapsibleSection title={t('projects.discussion')} noBorder>
|
||||
<div className="px-5 pb-5">
|
||||
{activeTab === 'discussion' && (
|
||||
<div className="p-6 space-y-3">
|
||||
<CommentsSection entityType="project" entityId={projectId} />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</SlidePanel>
|
||||
)}
|
||||
</TabbedModal>
|
||||
|
||||
<Modal
|
||||
isOpen={showDeleteConfirm}
|
||||
|
||||
@@ -3,8 +3,17 @@ import { NavLink } from 'react-router-dom'
|
||||
import {
|
||||
LayoutDashboard, FileEdit, Image, Calendar, Wallet,
|
||||
FolderKanban, CheckSquare, Users, ChevronLeft, ChevronRight, ChevronDown,
|
||||
Sparkles, Shield, 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,6 +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.copy' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -114,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">
|
||||
@@ -167,23 +177,6 @@ export default function Sidebar({ collapsed, setCollapsed }) {
|
||||
{standaloneBottom.map(item => navLink(item))}
|
||||
</div>
|
||||
|
||||
{/* Superadmin Only: Users Management */}
|
||||
{currentUser?.role === 'superadmin' && (
|
||||
<NavLink
|
||||
to="/users"
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-all duration-200 group ${
|
||||
isActive
|
||||
? 'bg-white/15 text-white shadow-sm'
|
||||
: 'text-text-on-dark-muted hover:bg-white/8 hover:text-white'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<Shield className="w-5 h-5 shrink-0" />
|
||||
{!collapsed && <span className="animate-fade-in whitespace-nowrap">{t('nav.users')}</span>}
|
||||
</NavLink>
|
||||
)}
|
||||
|
||||
{/* Settings (visible to all) */}
|
||||
<NavLink
|
||||
to="/settings"
|
||||
@@ -207,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,49 @@
|
||||
import { useEffect, useRef, useCallback } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
export default function SlidePanel({ onClose, maxWidth = '420px', header, children }) {
|
||||
export default function SlidePanel({ onClose, maxWidth = '420px', header, footer, children }) {
|
||||
const panelRef = useRef(null)
|
||||
|
||||
const handleKeyDown = useCallback((e) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}, [onClose])
|
||||
|
||||
useEffect(() => {
|
||||
if (!panelRef.current) return
|
||||
const el = panelRef.current
|
||||
const focusable = el.querySelectorAll(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
)
|
||||
if (focusable.length > 0) focusable[0].focus()
|
||||
|
||||
const handleTab = (e) => {
|
||||
if (e.key !== 'Tab' || focusable.length === 0) return
|
||||
const first = focusable[0]
|
||||
const last = focusable[focusable.length - 1]
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === first) { e.preventDefault(); last.focus() }
|
||||
} else {
|
||||
if (document.activeElement === last) { e.preventDefault(); first.focus() }
|
||||
}
|
||||
}
|
||||
el.addEventListener('keydown', handleTab)
|
||||
return () => el.removeEventListener('keydown', handleTab)
|
||||
}, [])
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<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>
|
||||
</>,
|
||||
document.body
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useEffect, useRef, useCallback } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
const SIZE_CLASSES = {
|
||||
sm: 'max-w-md',
|
||||
md: 'max-w-lg',
|
||||
lg: 'max-w-2xl',
|
||||
xl: 'max-w-4xl',
|
||||
}
|
||||
|
||||
export default function TabbedModal({
|
||||
onClose,
|
||||
size = 'md',
|
||||
header,
|
||||
tabs = [],
|
||||
activeTab,
|
||||
onTabChange,
|
||||
footer,
|
||||
children,
|
||||
}) {
|
||||
const modalRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = 'hidden'
|
||||
return () => { document.body.style.overflow = '' }
|
||||
}, [])
|
||||
|
||||
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()
|
||||
|
||||
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="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 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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
{tabs.length > 0 && (
|
||||
<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'
|
||||
: 'text-text-tertiary hover:text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
{TabIcon && <TabIcon className="w-4 h-4" />}
|
||||
{tab.label}
|
||||
{tab.badge > 0 && (
|
||||
<span className={`text-[10px] px-1.5 py-px rounded-full font-medium leading-tight ${
|
||||
activeTab === tab.key ? 'bg-brand-primary/10 text-brand-primary' : 'bg-surface-tertiary text-text-tertiary'
|
||||
}`}>
|
||||
{tab.badge}
|
||||
</span>
|
||||
)}
|
||||
{activeTab === tab.key && (
|
||||
<span className="absolute bottom-0 inset-x-1 h-0.5 bg-brand-primary rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div role="tabpanel">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{footer && (
|
||||
<div className="border-t border-border px-6 py-3.5 flex items-center justify-between rounded-b-2xl bg-surface">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { ChevronLeft, ChevronRight, Calendar as CalendarIcon, CalendarDays } from 'lucide-react'
|
||||
import { PRIORITY_CONFIG } from '../utils/api'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
|
||||
@@ -27,6 +27,18 @@ function getMonthData(year, month) {
|
||||
return cells
|
||||
}
|
||||
|
||||
function getWeekData(startDate) {
|
||||
const cells = []
|
||||
const start = new Date(startDate)
|
||||
start.setDate(start.getDate() - start.getDay())
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const d = new Date(start)
|
||||
d.setDate(start.getDate() + i)
|
||||
cells.push({ day: d.getDate(), current: true, date: d })
|
||||
}
|
||||
return cells
|
||||
}
|
||||
|
||||
function dateKey(d) {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
@@ -36,8 +48,12 @@ export default function TaskCalendarView({ tasks, onTaskClick }) {
|
||||
const today = new Date()
|
||||
const [year, setYear] = useState(today.getFullYear())
|
||||
const [month, setMonth] = useState(today.getMonth())
|
||||
const [calView, setCalView] = useState('month')
|
||||
const [weekStart, setWeekStart] = useState(() => {
|
||||
const d = new Date(); d.setDate(d.getDate() - d.getDay()); return d
|
||||
})
|
||||
|
||||
const cells = getMonthData(year, month)
|
||||
const cells = calView === 'month' ? getMonthData(year, month) : getWeekData(weekStart)
|
||||
const todayKey = dateKey(today)
|
||||
|
||||
// Group tasks by due_date
|
||||
@@ -62,16 +78,29 @@ export default function TaskCalendarView({ tasks, onTaskClick }) {
|
||||
if (month === 11) { setMonth(0); setYear(y => y + 1) }
|
||||
else setMonth(m => m + 1)
|
||||
}
|
||||
const goToday = () => { setYear(today.getFullYear()); setMonth(today.getMonth()) }
|
||||
const prevWeek = () => setWeekStart(d => { const n = new Date(d); n.setDate(n.getDate() - 7); return n })
|
||||
const nextWeek = () => setWeekStart(d => { const n = new Date(d); n.setDate(n.getDate() + 7); return n })
|
||||
|
||||
const goToday = () => {
|
||||
setYear(today.getFullYear()); setMonth(today.getMonth())
|
||||
const d = new Date(); d.setDate(d.getDate() - d.getDay()); setWeekStart(d)
|
||||
}
|
||||
|
||||
const monthLabel = new Date(year, month).toLocaleString('default', { month: 'long', year: 'numeric' })
|
||||
const weekLabel = (() => {
|
||||
const start = new Date(weekStart)
|
||||
start.setDate(start.getDate() - start.getDay())
|
||||
const end = new Date(start); end.setDate(start.getDate() + 6)
|
||||
const fmt = (d) => d.toLocaleString('default', { month: 'short', day: 'numeric' })
|
||||
return `${fmt(start)} – ${fmt(end)}, ${end.getFullYear()}`
|
||||
})()
|
||||
|
||||
const getPillColor = (task) => {
|
||||
const p = task.priority || 'medium'
|
||||
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 (
|
||||
@@ -81,18 +110,38 @@ export default function TaskCalendarView({ tasks, onTaskClick }) {
|
||||
{/* Nav */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={prevMonth} className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors">
|
||||
<button onClick={calView === 'month' ? prevMonth : prevWeek} className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors">
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<h3 className="text-sm font-semibold text-text-primary min-w-[150px] text-center">{monthLabel}</h3>
|
||||
<button onClick={nextMonth} className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors">
|
||||
<h3 className="text-sm font-semibold text-text-primary min-w-[180px] text-center">
|
||||
{calView === 'month' ? monthLabel : weekLabel}
|
||||
</h3>
|
||||
<button onClick={calView === 'month' ? nextMonth : nextWeek} className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors">
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<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-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-surface shadow-sm text-text-primary' : 'text-text-tertiary'}`}
|
||||
>
|
||||
<CalendarDays className="w-3 h-3" />
|
||||
Week
|
||||
</button>
|
||||
</div>
|
||||
<button onClick={goToday} className="px-3 py-1 text-xs font-medium text-brand-primary hover:bg-brand-primary/5 rounded-lg transition-colors">
|
||||
{t('tasks.today')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Day headers */}
|
||||
<div className="grid grid-cols-7 mb-1">
|
||||
@@ -112,8 +161,8 @@ export default function TaskCalendarView({ tasks, onTaskClick }) {
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`border-r border-b border-border min-h-[90px] p-1 ${
|
||||
cell.current ? 'bg-white' : 'bg-surface-secondary/50'
|
||||
className={`border-r border-b border-border ${calView === 'week' ? 'min-h-[300px]' : 'min-h-[90px]'} p-1 ${
|
||||
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 ${
|
||||
@@ -122,11 +171,11 @@ export default function TaskCalendarView({ tasks, onTaskClick }) {
|
||||
{cell.day}
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{dayTasks.slice(0, 3).map(task => (
|
||||
{dayTasks.slice(0, calView === 'week' ? 10 : 3).map(task => (
|
||||
<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}
|
||||
@@ -134,9 +183,9 @@ export default function TaskCalendarView({ tasks, onTaskClick }) {
|
||||
{task.title}
|
||||
</button>
|
||||
))}
|
||||
{dayTasks.length > 3 && (
|
||||
{dayTasks.length > (calView === 'week' ? 10 : 3) && (
|
||||
<div className="text-[9px] text-text-tertiary text-center font-medium">
|
||||
+{dayTasks.length - 3} more
|
||||
+{dayTasks.length - (calView === 'week' ? 10 : 3)} more
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -157,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} />
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { X, Trash2, AlertCircle, Upload, FileText, Star } from 'lucide-react'
|
||||
import { X, Trash2, AlertCircle, Upload, FileText, Star, FileEdit, Paperclip, MessageSquare } from 'lucide-react'
|
||||
import { PRIORITY_CONFIG, getBrandColor, api } from '../utils/api'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import CommentsSection from './CommentsSection'
|
||||
import Modal from './Modal'
|
||||
import SlidePanel from './SlidePanel'
|
||||
import CollapsibleSection from './CollapsibleSection'
|
||||
import TabbedModal from './TabbedModal'
|
||||
import PortalSelect from './PortalSelect'
|
||||
|
||||
const API_BASE = '/api'
|
||||
|
||||
export default function TaskDetailPanel({ task, onClose, onSave, onDelete, projects, users, brands }) {
|
||||
const { t } = useLanguage()
|
||||
const fileInputRef = useRef(null)
|
||||
const [activeTab, setActiveTab] = useState('details')
|
||||
const [form, setForm] = useState({
|
||||
title: '', description: '', project_id: '', assigned_to: '',
|
||||
priority: 'medium', status: 'todo', start_date: '', due_date: '',
|
||||
@@ -120,7 +121,7 @@ export default function TaskDetailPanel({ task, onClose, onSave, onDelete, proje
|
||||
if (!taskId) return
|
||||
try {
|
||||
const data = await api.get(`/tasks/${taskId}/attachments`)
|
||||
setAttachments(Array.isArray(data) ? data : (data.data || []))
|
||||
setAttachments(Array.isArray(data) ? data : [])
|
||||
} catch {
|
||||
setAttachments([])
|
||||
}
|
||||
@@ -186,24 +187,30 @@ export default function TaskDetailPanel({ task, onClose, onSave, onDelete, proje
|
||||
const selectedProject = projects?.find(p => String(p._id || p.id) === String(form.project_id))
|
||||
const brandName = selectedProject ? (selectedProject.brand_name || selectedProject.brandName) : (task.brand_name || task.brandName)
|
||||
|
||||
const header = (
|
||||
<div className="px-5 py-4 border-b border-border shrink-0">
|
||||
const attachmentCount = attachments.length + pendingFiles.length
|
||||
|
||||
const tabs = [
|
||||
{ key: 'details', label: t('tasks.details'), icon: FileEdit },
|
||||
{ key: 'attachments', label: t('tasks.attachments'), icon: Paperclip, badge: attachmentCount },
|
||||
...(!isCreateMode ? [{ key: 'discussion', label: t('tasks.discussion'), icon: MessageSquare }] : []),
|
||||
]
|
||||
|
||||
const headerContent = (
|
||||
<>
|
||||
{/* Thumbnail banner */}
|
||||
{currentThumbnail && (
|
||||
<div className="relative -mx-5 -mt-4 mb-3 h-32 overflow-hidden">
|
||||
<img src={currentThumbnail} alt="" className="w-full h-full object-cover" />
|
||||
<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" 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" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<input
|
||||
type="text"
|
||||
value={form.title}
|
||||
@@ -212,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 && (
|
||||
@@ -226,23 +233,51 @@ export default function TaskDetailPanel({ task, onClose, onSave, onDelete, proje
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
const footerContent = (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
{onDelete && !isCreateMode && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors shrink-0"
|
||||
onClick={handleDelete}
|
||||
className="p-2 text-text-tertiary hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5">
|
||||
{dirty && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!form.title || saving}
|
||||
className={`px-4 py-2 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 ${saving ? 'btn-loading' : ''}`}
|
||||
>
|
||||
{isCreateMode ? t('tasks.createTask') : t('tasks.saveChanges')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<SlidePanel onClose={onClose} maxWidth="420px" header={header}>
|
||||
{/* Details Section */}
|
||||
<CollapsibleSection title={t('tasks.details')}>
|
||||
<div className="px-5 pb-4 space-y-3">
|
||||
<TabbedModal
|
||||
onClose={onClose}
|
||||
size="md"
|
||||
header={headerContent}
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
footer={footerContent}
|
||||
>
|
||||
{/* Details Tab */}
|
||||
{activeTab === 'details' && (
|
||||
<div className="p-6">
|
||||
<div className="space-y-3">
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.description')}</label>
|
||||
@@ -259,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}
|
||||
@@ -280,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>
|
||||
|
||||
@@ -349,41 +370,13 @@ export default function TaskDetailPanel({ task, onClose, onSave, onDelete, proje
|
||||
<p className="text-sm text-text-secondary">{creatorName}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
{dirty && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!form.title || saving}
|
||||
className={`flex-1 px-4 py-2 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 ${saving ? 'btn-loading' : ''}`}
|
||||
>
|
||||
{isCreateMode ? t('tasks.createTask') : t('tasks.saveChanges')}
|
||||
</button>
|
||||
)}
|
||||
{onDelete && !isCreateMode && (
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="p-2 text-text-tertiary hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Attachments Section */}
|
||||
<CollapsibleSection
|
||||
title={t('tasks.attachments')}
|
||||
badge={(attachments.length + pendingFiles.length) > 0 ? (
|
||||
<span className="text-xs font-medium text-text-tertiary bg-surface-tertiary px-1.5 py-0.5 rounded-full">
|
||||
{attachments.length + pendingFiles.length}
|
||||
</span>
|
||||
) : null}
|
||||
>
|
||||
<div className="px-5 pb-4">
|
||||
{/* Attachments Tab */}
|
||||
{activeTab === 'attachments' && (
|
||||
<div className="p-6">
|
||||
{/* Existing attachment grid (edit mode) */}
|
||||
{attachments.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-2 mb-3">
|
||||
@@ -395,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">
|
||||
@@ -408,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)}
|
||||
@@ -448,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"
|
||||
@@ -488,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 || [])
|
||||
@@ -524,17 +518,15 @@ export default function TaskDetailPanel({ task, onClose, onSave, onDelete, proje
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Discussion Section (hidden in create mode) */}
|
||||
{!isCreateMode && (
|
||||
<CollapsibleSection title={t('tasks.discussion')} noBorder>
|
||||
<div className="px-5 pb-5">
|
||||
{/* Discussion Tab */}
|
||||
{activeTab === 'discussion' && !isCreateMode && (
|
||||
<div className="p-6">
|
||||
<CommentsSection entityType="task" entityId={taskId} />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
</SlidePanel>
|
||||
</TabbedModal>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<Modal
|
||||
|
||||
@@ -1,43 +1,35 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { X, Trash2, ChevronDown, Check } from 'lucide-react'
|
||||
import { useState, useEffect, useRef, useContext } from 'react'
|
||||
import { Trash2, ChevronDown, Check, ShieldAlert, Eye, EyeOff, FileEdit, BarChart3, X } from 'lucide-react'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import { api } from '../utils/api'
|
||||
import { useToast } from './ToastContainer'
|
||||
import Modal from './Modal'
|
||||
import SlidePanel from './SlidePanel'
|
||||
import CollapsibleSection from './CollapsibleSection'
|
||||
import TabbedModal from './TabbedModal'
|
||||
import StatusBadge from './StatusBadge'
|
||||
|
||||
const ROLES = [
|
||||
{ value: 'manager', label: 'Manager' },
|
||||
{ value: 'approver', label: 'Approver' },
|
||||
{ value: 'publisher', label: 'Publisher' },
|
||||
{ value: 'content_creator', label: 'Content Creator' },
|
||||
{ value: 'producer', label: 'Producer' },
|
||||
{ value: 'designer', label: 'Designer' },
|
||||
{ value: 'content_writer', label: 'Content Writer' },
|
||||
{ value: 'social_media_manager', label: 'Social Media Manager' },
|
||||
{ value: 'photographer', label: 'Photographer' },
|
||||
{ value: 'videographer', label: 'Videographer' },
|
||||
{ value: 'strategist', label: 'Strategist' },
|
||||
]
|
||||
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 }) {
|
||||
const { t, lang } = useLanguage()
|
||||
const toast = useToast()
|
||||
const { roles } = useContext(AppContext)
|
||||
const [form, setForm] = useState({})
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [showBrandsDropdown, setShowBrandsDropdown] = useState(false)
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [passwordError, setPasswordError] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [passwordSaving, setPasswordSaving] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState('details')
|
||||
const brandsDropdownRef = useRef(null)
|
||||
|
||||
// Workload state (loaded internally)
|
||||
@@ -46,7 +38,6 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
|
||||
const [loadingWorkload, setLoadingWorkload] = useState(false)
|
||||
|
||||
const memberId = member?._id || member?.id
|
||||
const isCreateMode = !memberId
|
||||
|
||||
useEffect(() => {
|
||||
if (member) {
|
||||
@@ -54,16 +45,18 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
|
||||
name: member.name || '',
|
||||
email: member.email || '',
|
||||
password: '',
|
||||
role: member.team_role || member.role || 'content_writer',
|
||||
permission_level: member.role || 'contributor',
|
||||
role_id: member.role_id || '',
|
||||
brands: Array.isArray(member.brands) ? member.brands : [],
|
||||
phone: member.phone || '',
|
||||
modules: Array.isArray(member.modules) ? member.modules : ALL_MODULES,
|
||||
team_ids: Array.isArray(member.teams) ? member.teams.map(t => t.id) : [],
|
||||
})
|
||||
setDirty(isCreateMode)
|
||||
setDirty(false)
|
||||
setConfirmPassword('')
|
||||
setPasswordError('')
|
||||
if (!isCreateMode) loadWorkload()
|
||||
setShowPassword(false)
|
||||
setActiveTab('details')
|
||||
if (memberId) loadWorkload()
|
||||
}
|
||||
}, [member])
|
||||
|
||||
@@ -112,30 +105,40 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setPasswordError('')
|
||||
if (isCreateMode && form.password && form.password !== confirmPassword) {
|
||||
setPasswordError('Passwords do not match')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
await onSave(isCreateMode ? null : memberId, {
|
||||
await onSave(memberId, {
|
||||
name: form.name,
|
||||
email: form.email,
|
||||
password: form.password,
|
||||
role: form.role,
|
||||
role: form.permission_level,
|
||||
role_id: form.role_id || null,
|
||||
brands: form.brands || [],
|
||||
phone: form.phone,
|
||||
modules: form.modules,
|
||||
team_ids: form.team_ids,
|
||||
}, isEditingSelf)
|
||||
setDirty(false)
|
||||
if (isCreateMode) onClose()
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePasswordChange = async () => {
|
||||
if (!form.password || form.password !== confirmPassword) return
|
||||
setPasswordSaving(true)
|
||||
try {
|
||||
await onSave(memberId, { password: form.password }, false)
|
||||
setForm(f => ({ ...f, password: '' }))
|
||||
setConfirmPassword('')
|
||||
setShowPassword(false)
|
||||
toast.success(t('team.passwordChanged'))
|
||||
} finally {
|
||||
setPasswordSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const passwordMismatch = confirmPassword && form.password !== confirmPassword
|
||||
|
||||
const confirmDelete = async () => {
|
||||
setShowDeleteConfirm(false)
|
||||
await onDelete(memberId)
|
||||
@@ -143,14 +146,26 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
|
||||
}
|
||||
|
||||
const initials = member.name?.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase() || '?'
|
||||
const roleName = (form.role || '').replace(/_/g, ' ')
|
||||
const currentRole = roles.find(r => (r.Id || r.id) === form.role_id)
|
||||
const roleName = currentRole?.name || member.role_name || member.team_role || ''
|
||||
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 header = (
|
||||
<div className="px-5 py-4 border-b border-border shrink-0">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
const showAdminTab = !isEditingSelf && userRole === 'superadmin'
|
||||
|
||||
const tabs = [
|
||||
{ key: 'details', label: t('team.details'), icon: FileEdit },
|
||||
{ key: 'workload', label: t('team.workload'), icon: BarChart3 },
|
||||
...(showAdminTab ? [{ key: 'admin', label: t('team.adminActions'), icon: ShieldAlert }] : []),
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<TabbedModal
|
||||
onClose={onClose}
|
||||
size="md"
|
||||
header={
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-indigo-400 to-purple-500 flex items-center justify-center text-white text-sm font-bold shrink-0">
|
||||
{initials}
|
||||
@@ -168,93 +183,84 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
footer={<>
|
||||
<div className="flex items-center gap-2">
|
||||
{canManageTeam && onDelete && !isEditingSelf && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors shrink-0"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-red-600 border border-red-200 rounded-lg hover:bg-red-50 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
<Trash2 className="w-4 h-4" />
|
||||
{t('team.removeMember')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5">
|
||||
{dirty && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!form.name || saving}
|
||||
className={`px-4 py-2 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 ${saving ? 'btn-loading' : ''}`}
|
||||
>
|
||||
{isEditingSelf ? t('team.saveProfile') : t('team.saveChanges')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<SlidePanel onClose={onClose} maxWidth="420px" header={header}>
|
||||
{/* Details Section */}
|
||||
<CollapsibleSection title={t('team.details')}>
|
||||
<div className="px-5 pb-4 space-y-3">
|
||||
</>}
|
||||
>
|
||||
{/* Details Tab */}
|
||||
{activeTab === 'details' && (
|
||||
<div className="p-6 space-y-3">
|
||||
{!isEditingSelf && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.email')} *</label>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.email')}</label>
|
||||
<input
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={e => update('email', 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="email@example.com"
|
||||
disabled={!isCreateMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isCreateMode && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.password')}</label>
|
||||
<input
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={e => update('password', 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="••••••••"
|
||||
/>
|
||||
{!form.password && (
|
||||
<p className="text-xs text-text-tertiary mt-1">{t('team.defaultPassword')}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isCreateMode && form.password && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">Confirm Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={e => { setConfirmPassword(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="••••••••"
|
||||
/>
|
||||
{passwordError && (
|
||||
<p className="text-xs text-red-500 mt-1">{passwordError}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.teamRole')}</label>
|
||||
{userRole === 'manager' && isCreateMode && !isEditingSelf ? (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
value="Contributor"
|
||||
disabled
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface-tertiary text-text-tertiary cursor-not-allowed"
|
||||
/>
|
||||
<p className="text-xs text-text-tertiary mt-1">{t('team.fixedRole')}</p>
|
||||
</>
|
||||
) : (
|
||||
<select
|
||||
value={form.role}
|
||||
onChange={e => update('role', e.target.value)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* Permission Level (superadmin only) */}
|
||||
{userRole === 'superadmin' && !isEditingSelf && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.permissionLevel')}</label>
|
||||
<PortalSelect
|
||||
value={form.permission_level}
|
||||
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"
|
||||
>
|
||||
{ROLES.map(r => <option key={r.value} value={r.value}>{r.label}</option>)}
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Role (from Roles table) */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.role')}</label>
|
||||
{isEditingSelf ? (
|
||||
<input
|
||||
type="text"
|
||||
value={roleName || '—'}
|
||||
disabled
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface-tertiary text-text-tertiary cursor-not-allowed"
|
||||
/>
|
||||
) : (
|
||||
<PortalSelect
|
||||
value={form.role_id || ''}
|
||||
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"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.phone')}</label>
|
||||
<input
|
||||
@@ -269,10 +275,15 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
|
||||
|
||||
<div ref={brandsDropdownRef} className="relative">
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.brands')}</label>
|
||||
{isEditingSelf && userRole !== 'superadmin' ? (
|
||||
<div className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface-tertiary text-text-tertiary cursor-not-allowed">
|
||||
{(form.brands || []).length === 0 ? '—' : (form.brands || []).join(', ')}
|
||||
</div>
|
||||
) : <>
|
||||
<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
|
||||
@@ -302,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
|
||||
@@ -312,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'
|
||||
@@ -328,10 +339,11 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>}
|
||||
</div>
|
||||
|
||||
{/* Modules toggle */}
|
||||
{!isEditingSelf && canManageTeam && (
|
||||
{(!isEditingSelf || userRole === 'superadmin') && canManageTeam && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.modules')}</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -379,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}
|
||||
@@ -389,34 +401,12 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
{dirty && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!form.name || (!isEditingSelf && isCreateMode && !form.email) || saving}
|
||||
className={`flex-1 px-4 py-2 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 ${saving ? 'btn-loading' : ''}`}
|
||||
>
|
||||
{isEditingSelf ? t('team.saveProfile') : (isCreateMode ? t('team.addMember') : t('team.saveChanges'))}
|
||||
</button>
|
||||
)}
|
||||
{!isCreateMode && !isEditingSelf && canManageTeam && onDelete && (
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="p-2 text-text-tertiary hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title={t('team.remove')}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Workload Section (hidden in create mode) */}
|
||||
{!isCreateMode && (
|
||||
<CollapsibleSection title={t('team.workload')} noBorder>
|
||||
<div className="px-5 pb-4 space-y-3">
|
||||
{/* Workload Tab */}
|
||||
{activeTab === 'workload' && (
|
||||
<div className="p-6 space-y-3">
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<div className="bg-surface-secondary rounded-lg p-2 text-center">
|
||||
@@ -473,9 +463,57 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
|
||||
<p className="text-xs text-text-tertiary text-center py-2">{t('common.loading')}</p>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
</SlidePanel>
|
||||
|
||||
{/* Admin Actions Tab */}
|
||||
{activeTab === 'admin' && showAdminTab && (
|
||||
<div className="p-6 space-y-3">
|
||||
{/* Change password */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.password')}</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={form.password}
|
||||
onChange={e => update('password', e.target.value)}
|
||||
className="w-full px-3 py-2 pe-9 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
placeholder={t('team.newPassword')}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(v => !v)}
|
||||
className="absolute end-2.5 top-1/2 -translate-y-1/2 text-text-tertiary hover:text-text-primary"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.confirmPassword')}</label>
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={confirmPassword}
|
||||
onChange={e => setConfirmPassword(e.target.value)}
|
||||
className={`w-full px-3 py-2 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary ${passwordMismatch ? 'border-red-400' : 'border-border'}`}
|
||||
placeholder={t('team.confirmPassword')}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
{passwordMismatch && (
|
||||
<p className="text-[11px] text-red-500 mt-1">{t('team.passwordsDoNotMatch')}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handlePasswordChange}
|
||||
disabled={!form.password || form.password.length < 6 || form.password !== confirmPassword || passwordSaving}
|
||||
className={`w-full px-4 py-2 bg-amber-500 text-white rounded-lg text-sm font-medium hover:bg-amber-600 disabled:opacity-50 disabled:cursor-not-allowed shadow-sm ${passwordSaving ? 'btn-loading' : ''}`}
|
||||
>
|
||||
{t('team.changePassword')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</TabbedModal>
|
||||
|
||||
<Modal
|
||||
isOpen={showDeleteConfirm}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { X, Trash2, Search } from 'lucide-react'
|
||||
import { Trash2, Search, FileEdit, Users } from 'lucide-react'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import { getInitials } from '../utils/api'
|
||||
import Modal from './Modal'
|
||||
import SlidePanel from './SlidePanel'
|
||||
import CollapsibleSection from './CollapsibleSection'
|
||||
import TabbedModal from './TabbedModal'
|
||||
|
||||
export default function TeamPanel({ team, onClose, onSave, onDelete, teamMembers }) {
|
||||
const { t } = useLanguage()
|
||||
@@ -13,6 +12,7 @@ export default function TeamPanel({ team, onClose, onSave, onDelete, teamMembers
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [memberSearch, setMemberSearch] = useState('')
|
||||
const [activeTab, setActiveTab] = useState('details')
|
||||
|
||||
const teamId = team?.id || team?._id
|
||||
const isCreateMode = !teamId
|
||||
@@ -68,10 +68,15 @@ export default function TeamPanel({ team, onClose, onSave, onDelete, teamMembers
|
||||
!memberSearch || m.name?.toLowerCase().includes(memberSearch.toLowerCase())
|
||||
)
|
||||
|
||||
const header = (
|
||||
<div className="px-5 py-4 border-b border-border shrink-0">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
const memberCount = (form.member_ids || []).length
|
||||
|
||||
return (
|
||||
<>
|
||||
<TabbedModal
|
||||
onClose={onClose}
|
||||
size="md"
|
||||
header={
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
@@ -80,24 +85,45 @@ export default function TeamPanel({ team, onClose, onSave, onDelete, teamMembers
|
||||
placeholder={t('teams.name')}
|
||||
/>
|
||||
<span className="text-[11px] px-2 py-0.5 rounded-full font-medium bg-blue-100 text-blue-700">
|
||||
{(form.member_ids || []).length} {t('teams.members')}
|
||||
{memberCount} {t('teams.members')}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors shrink-0"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
</>
|
||||
}
|
||||
tabs={[
|
||||
{ key: 'details', label: t('teams.details'), icon: FileEdit },
|
||||
{ key: 'members', label: t('teams.members'), icon: Users, badge: memberCount },
|
||||
]}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
footer={
|
||||
<>
|
||||
<SlidePanel onClose={onClose} maxWidth="420px" header={header}>
|
||||
<CollapsibleSection title={t('teams.details')}>
|
||||
<div className="px-5 pb-4 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{!isCreateMode && onDelete && (
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="p-2 text-text-tertiary hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title={t('teams.deleteTeam')}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5">
|
||||
{dirty && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!form.name || saving}
|
||||
className={`px-4 py-2 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 ${saving ? 'btn-loading' : ''}`}
|
||||
>
|
||||
{isCreateMode ? t('teams.createTeam') : t('common.save')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{activeTab === 'details' && (
|
||||
<div className="p-6 space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('teams.name')}</label>
|
||||
<input
|
||||
@@ -117,40 +143,19 @@ export default function TeamPanel({ team, onClose, onSave, onDelete, teamMembers
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
{dirty && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!form.name || saving}
|
||||
className={`flex-1 px-4 py-2 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 ${saving ? 'btn-loading' : ''}`}
|
||||
>
|
||||
{isCreateMode ? t('teams.createTeam') : t('common.save')}
|
||||
</button>
|
||||
)}
|
||||
{!isCreateMode && onDelete && (
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="p-2 text-text-tertiary hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title={t('teams.deleteTeam')}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
<CollapsibleSection title={t('teams.members')} noBorder>
|
||||
<div className="px-5 pb-4">
|
||||
{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">
|
||||
@@ -180,8 +185,8 @@ export default function TeamPanel({ team, onClose, onSave, onDelete, teamMembers
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</SlidePanel>
|
||||
)}
|
||||
</TabbedModal>
|
||||
|
||||
<Modal
|
||||
isOpen={showDeleteConfirm}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useTheme } from '../contexts/ThemeContext'
|
||||
import { Moon, Sun } from 'lucide-react'
|
||||
|
||||
export default function ThemeToggle({ className = '' }) {
|
||||
const { darkMode, toggleDarkMode } = useTheme()
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={toggleDarkMode}
|
||||
className={`p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors ${className}`}
|
||||
title={darkMode ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
aria-label={darkMode ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
>
|
||||
{darkMode ? (
|
||||
<Sun className="w-5 h-5 text-yellow-500" />
|
||||
) : (
|
||||
<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-50 flex flex-col gap-2 pointer-events-none">
|
||||
<div className="fixed top-4 end-4 z-[10000] flex flex-col gap-2 pointer-events-none">
|
||||
<div className="flex flex-col gap-2 pointer-events-auto">
|
||||
{toasts.map(t => (
|
||||
<Toast
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { X, Trash2 } from 'lucide-react'
|
||||
import { Trash2, FileEdit, BarChart3 } from 'lucide-react'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import { PLATFORMS } from '../utils/api'
|
||||
import Modal from './Modal'
|
||||
import SlidePanel from './SlidePanel'
|
||||
import CollapsibleSection from './CollapsibleSection'
|
||||
import TabbedModal from './TabbedModal'
|
||||
import BudgetBar from './BudgetBar'
|
||||
import PortalSelect from './PortalSelect'
|
||||
|
||||
const TRACK_TYPES = {
|
||||
organic_social: { label: 'Organic Social' },
|
||||
@@ -23,6 +23,7 @@ export default function TrackDetailPanel({ track, campaignId, onClose, onSave, o
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState(scrollToMetrics ? 'metrics' : 'details')
|
||||
|
||||
const trackId = track?._id || track?.id
|
||||
const isCreateMode = !trackId
|
||||
@@ -85,10 +86,20 @@ export default function TrackDetailPanel({ track, campaignId, onClose, onSave, o
|
||||
|
||||
const typeInfo = TRACK_TYPES[form.type] || TRACK_TYPES.organic_social
|
||||
|
||||
const header = (
|
||||
<div className="px-5 py-4 border-b border-border shrink-0">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
const tabs = isCreateMode
|
||||
? [{ key: 'details', label: t('tracks.details'), icon: FileEdit }]
|
||||
: [
|
||||
{ key: 'details', label: t('tracks.details'), icon: FileEdit },
|
||||
{ key: 'metrics', label: t('tracks.metrics'), icon: BarChart3 },
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<TabbedModal
|
||||
onClose={onClose}
|
||||
size="md"
|
||||
header={
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
@@ -104,54 +115,63 @@ 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>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors shrink-0"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
</>
|
||||
}
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
footer={
|
||||
<>
|
||||
<SlidePanel onClose={onClose} maxWidth="420px" header={header}>
|
||||
{/* Details Section */}
|
||||
<CollapsibleSection title={t('tracks.details')}>
|
||||
<div className="px-5 pb-4 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{onDelete && !isCreateMode && (
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="p-2 text-text-tertiary hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5">
|
||||
{dirty && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className={`px-4 py-2 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 ${saving ? 'btn-loading' : ''}`}
|
||||
>
|
||||
{isCreateMode ? t('tracks.addTrack') : t('tasks.saveChanges')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{activeTab === 'details' && (
|
||||
<div className="p-6 space-y-3">
|
||||
<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>
|
||||
|
||||
@@ -168,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>
|
||||
|
||||
@@ -190,34 +207,11 @@ export default function TrackDetailPanel({ track, campaignId, onClose, onSave, o
|
||||
placeholder="Keywords, targeting details..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
{dirty && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className={`flex-1 px-4 py-2 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 ${saving ? 'btn-loading' : ''}`}
|
||||
>
|
||||
{isCreateMode ? t('tracks.addTrack') : t('tasks.saveChanges')}
|
||||
</button>
|
||||
)}
|
||||
{onDelete && !isCreateMode && (
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="p-2 text-text-tertiary hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Metrics Section (hidden in create mode) */}
|
||||
{!isCreateMode && (
|
||||
<CollapsibleSection title={t('tracks.metrics')} defaultOpen={!!scrollToMetrics} noBorder>
|
||||
<div className="px-5 pb-4 space-y-3">
|
||||
{activeTab === 'metrics' && !isCreateMode && (
|
||||
<div className="p-6 space-y-3">
|
||||
{Number(form.budget_allocated) > 0 && (
|
||||
<div className="p-3 bg-surface-secondary rounded-lg">
|
||||
<BudgetBar budget={Number(form.budget_allocated)} spent={Number(form.budget_spent) || 0} height="h-2" />
|
||||
@@ -287,9 +281,8 @@ export default function TrackDetailPanel({ track, campaignId, onClose, onSave, o
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
</SlidePanel>
|
||||
</TabbedModal>
|
||||
|
||||
<Modal
|
||||
isOpen={showDeleteConfirm}
|
||||
|
||||
@@ -0,0 +1,584 @@
|
||||
import { useState, useEffect, useContext } from '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 PortalSelect from './PortalSelect'
|
||||
|
||||
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 [editSourceContent, setEditSourceContent] = useState(translation.source_content || '')
|
||||
const [editSourceLanguage, setEditSourceLanguage] = useState(translation.source_language || 'EN')
|
||||
const [editApproverIds, setEditApproverIds] = useState(
|
||||
translation.approvers?.map(a => String(a.id)) || (translation.approver_ids ? translation.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : [])
|
||||
)
|
||||
const reviewUrl = translation.approval_token ? `${window.location.origin}/review-translation/${translation.approval_token}` : ''
|
||||
|
||||
const [activeTab, setActiveTab] = useState('details')
|
||||
const [texts, setTexts] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [savingDraft, setSavingDraft] = useState(false)
|
||||
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)
|
||||
const [langForm, setLangForm] = useState({ language_code: '', content: '' })
|
||||
const [savingLang, setSavingLang] = useState(false)
|
||||
|
||||
// Delete confirm
|
||||
const [confirmDeleteTextId, setConfirmDeleteTextId] = useState(null)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
|
||||
// Inline editing for translation texts
|
||||
const [editingTextId, setEditingTextId] = useState(null)
|
||||
const [editingContent, setEditingContent] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
loadTexts()
|
||||
}, [translation.Id])
|
||||
|
||||
useEffect(() => {
|
||||
if (externalPosts) setPosts(externalPosts)
|
||||
}, [externalPosts])
|
||||
|
||||
useEffect(() => {
|
||||
setEditTitle(translation.title || '')
|
||||
setEditSourceContent(translation.source_content || '')
|
||||
setEditSourceLanguage(translation.source_language || 'EN')
|
||||
setEditApproverIds(
|
||||
translation.approvers?.map(a => String(a.id)) || (translation.approver_ids ? translation.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : [])
|
||||
)
|
||||
}, [translation.Id])
|
||||
|
||||
const loadTexts = async () => {
|
||||
try {
|
||||
const res = await api.get(`/translations/${translation.Id}/texts`)
|
||||
setTexts(Array.isArray(res) ? res : [])
|
||||
} catch (err) {
|
||||
console.error('Failed to load texts:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveDraft = async () => {
|
||||
if (!editTitle.trim()) {
|
||||
toast.error(t('translations.titleRequired'))
|
||||
return
|
||||
}
|
||||
setSavingDraft(true)
|
||||
try {
|
||||
await api.patch(`/translations/${translation.Id}`, {
|
||||
title: editTitle,
|
||||
source_content: editSourceContent,
|
||||
source_language: editSourceLanguage,
|
||||
approver_ids: editApproverIds.length > 0 ? editApproverIds.join(',') : null,
|
||||
})
|
||||
toast.success(t('translations.draftSaved'))
|
||||
onUpdate()
|
||||
} catch (err) {
|
||||
toast.error(t('translations.failedSaveDraft'))
|
||||
} finally {
|
||||
setSavingDraft(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFieldUpdate = async (field, value) => {
|
||||
try {
|
||||
await api.patch(`/translations/${translation.Id}`, { [field]: value || null })
|
||||
toast.success(t('translations.updated'))
|
||||
onUpdate()
|
||||
} catch (err) {
|
||||
toast.error(t('translations.failedUpdate'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddTranslationText = async () => {
|
||||
if (!langForm.language_code || !langForm.content) {
|
||||
toast.error(t('translations.allFieldsRequired'))
|
||||
return
|
||||
}
|
||||
setSavingLang(true)
|
||||
try {
|
||||
const lang = AVAILABLE_LANGUAGES.find(l => l.code === langForm.language_code)
|
||||
await api.post(`/translations/${translation.Id}/texts`, {
|
||||
language_code: langForm.language_code,
|
||||
language_label: lang?.label || langForm.language_code,
|
||||
content: langForm.content,
|
||||
})
|
||||
toast.success(t('translations.translationAdded'))
|
||||
setShowAddLang(false)
|
||||
setLangForm({ language_code: '', content: '' })
|
||||
loadTexts()
|
||||
} catch (err) {
|
||||
toast.error(t('translations.failedAddTranslation'))
|
||||
} finally {
|
||||
setSavingLang(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateText = async (textId) => {
|
||||
try {
|
||||
await api.patch(`/translations/${translation.Id}/texts/${textId}`, {
|
||||
content: editingContent,
|
||||
})
|
||||
toast.success(t('translations.updated'))
|
||||
setEditingTextId(null)
|
||||
loadTexts()
|
||||
} catch (err) {
|
||||
toast.error(t('translations.failedUpdate'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteText = async (textId) => {
|
||||
try {
|
||||
await api.delete(`/translations/${translation.Id}/texts/${textId}`)
|
||||
toast.success(t('translations.translationDeleted'))
|
||||
loadTexts()
|
||||
} catch (err) {
|
||||
toast.error(t('translations.failedDeleteTranslation'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmitReview = async () => {
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const res = await api.post(`/translations/${translation.Id}/submit-review`)
|
||||
setFreshReviewUrl(res.reviewUrl || res.data?.reviewUrl || '')
|
||||
toast.success(t('translations.submittedForReview'))
|
||||
onUpdate()
|
||||
} catch (err) {
|
||||
toast.error(t('translations.failedSubmitReview'))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const copyReviewLink = () => {
|
||||
const url = freshReviewUrl || reviewUrl
|
||||
navigator.clipboard.writeText(url)
|
||||
setCopied(true)
|
||||
toast.success(t('translations.linkCopied'))
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await onDelete(translation.Id || translation.id || translation._id)
|
||||
} catch (err) {
|
||||
toast.error(t('translations.failedDelete'))
|
||||
}
|
||||
}
|
||||
|
||||
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 },
|
||||
{ key: 'translations', label: t('translations.translationTexts'), icon: Languages, badge: texts.length },
|
||||
{ key: 'review', label: t('translations.review'), icon: ShieldCheck },
|
||||
]
|
||||
|
||||
const currentReviewUrl = freshReviewUrl || reviewUrl
|
||||
|
||||
return (
|
||||
<>
|
||||
<TabbedModal
|
||||
onClose={onClose}
|
||||
size="xl"
|
||||
header={
|
||||
<>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<Languages className="w-5 h-5 text-brand-primary shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
value={editTitle}
|
||||
onChange={e => setEditTitle(e.target.value)}
|
||||
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 ${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">
|
||||
{AVAILABLE_LANGUAGES.find(l => l.code === editSourceLanguage)?.label || editSourceLanguage}
|
||||
</span>
|
||||
{translation.creator_name && (
|
||||
<span className="text-xs text-text-tertiary">
|
||||
{t('review.createdBy')} <strong className="text-text-primary">{translation.creator_name}</strong>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
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
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="p-2 rounded-lg text-red-500 hover:bg-red-50 transition-colors"
|
||||
title={t('translations.deleteTranslation')}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSaveDraft}
|
||||
disabled={savingDraft}
|
||||
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 disabled:opacity-50 shadow-sm"
|
||||
title={t('translations.saveDraftTooltip')}
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{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>
|
||||
<PortalSelect
|
||||
value={editSourceLanguage}
|
||||
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>
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-2">{t('translations.sourceContent')}</h4>
|
||||
<textarea
|
||||
value={editSourceContent}
|
||||
onChange={e => setEditSourceContent(e.target.value)}
|
||||
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>
|
||||
)}
|
||||
|
||||
{/* Translations Tab */}
|
||||
{activeTab === 'translations' && (
|
||||
<div className="p-6 space-y-5">
|
||||
{/* Source content reference */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Globe className="w-4 h-4 text-blue-600" />
|
||||
<h4 className="text-sm font-semibold text-blue-900">
|
||||
{t('translations.sourceContent')} — {AVAILABLE_LANGUAGES.find(l => l.code === translation.source_language)?.label || translation.source_language}
|
||||
</h4>
|
||||
</div>
|
||||
<p className="text-sm text-blue-800 whitespace-pre-wrap">{translation.source_content}</p>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
{!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.addOption')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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 && (
|
||||
<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-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-3.5 h-3.5" />
|
||||
</button>
|
||||
<button onClick={() => setConfirmDeleteTextId(text.Id)} className="text-red-500 hover:text-red-600 p-1">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{editingTextId === text.Id ? (
|
||||
<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-[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">
|
||||
<Languages className="w-8 h-8 text-text-tertiary mx-auto mb-2" />
|
||||
<p className="text-sm text-text-secondary">{t('translations.noTranslationTexts')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Review Tab */}
|
||||
{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 || 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 && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div className="text-sm font-semibold text-blue-900 mb-2">{t('translations.reviewLinkTitle')}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={currentReviewUrl}
|
||||
readOnly
|
||||
className="flex-1 px-3 py-2 text-sm bg-surface border border-blue-200 rounded-lg text-blue-800"
|
||||
/>
|
||||
<button
|
||||
onClick={copyReviewLink}
|
||||
className="p-2 rounded-lg bg-blue-600 text-white hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
||||
</button>
|
||||
<a
|
||||
href={currentReviewUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-2 rounded-lg bg-blue-600 text-white hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{translation.feedback && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
|
||||
<h4 className="text-sm font-semibold text-amber-900 mb-2">{t('translations.feedbackTitle')}</h4>
|
||||
<p className="text-sm text-amber-800 whitespace-pre-wrap">{translation.feedback}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{translation.status === 'approved' && translation.approved_by_name && (
|
||||
<div className="bg-emerald-50 border border-emerald-200 rounded-lg p-4">
|
||||
<div className="font-medium text-emerald-900">{t('translations.approvedByLabel')} {translation.approved_by_name}</div>
|
||||
{translation.approved_at && (
|
||||
<div className="text-sm text-emerald-700 mt-1">
|
||||
{new Date(translation.approved_at).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!['draft', 'revision_requested', 'rejected'].includes(translation.status) && !currentReviewUrl && !translation.feedback && !(translation.status === 'approved' && translation.approved_by_name) && (
|
||||
<p className="text-sm text-text-secondary text-center py-4">
|
||||
{translation.status === 'pending_review'
|
||||
? t('translations.pendingReviewInfo')
|
||||
: t('translations.noReviewInfo')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TabbedModal>
|
||||
|
||||
{/* Add Translation Modal */}
|
||||
<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>
|
||||
<PortalSelect
|
||||
value={langForm.language_code}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('translations.translatedContent')} *</label>
|
||||
<textarea
|
||||
value={langForm.content}
|
||||
onChange={e => setLangForm(f => ({ ...f, content: 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"
|
||||
placeholder={t('translations.enterTranslatedContent')}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
|
||||
<button onClick={() => setShowAddLang(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={handleAddTranslationText}
|
||||
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.addOption')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Delete translation text confirm */}
|
||||
<Modal
|
||||
isOpen={!!confirmDeleteTextId}
|
||||
onClose={() => setConfirmDeleteTextId(null)}
|
||||
title={t('translations.deleteTranslationText')}
|
||||
isConfirm
|
||||
danger
|
||||
onConfirm={() => { handleDeleteText(confirmDeleteTextId); setConfirmDeleteTextId(null) }}
|
||||
confirmText={t('common.delete')}
|
||||
>
|
||||
{t('translations.deleteTranslationTextDesc')}
|
||||
</Modal>
|
||||
|
||||
{/* Delete translation confirm */}
|
||||
<Modal
|
||||
isOpen={showDeleteConfirm}
|
||||
onClose={() => setShowDeleteConfirm(false)}
|
||||
title={t('translations.deleteTranslation')}
|
||||
isConfirm
|
||||
danger
|
||||
onConfirm={() => { handleDelete(); setShowDeleteConfirm(false) }}
|
||||
confirmText={t('common.delete')}
|
||||
>
|
||||
{t('translations.deleteTranslationDesc')}
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { createContext, useContext, useState, useEffect } from 'react'
|
||||
|
||||
const ThemeContext = createContext()
|
||||
|
||||
export function ThemeProvider({ children }) {
|
||||
const [darkMode, setDarkMode] = useState(() => {
|
||||
// Check localStorage or system preference
|
||||
const stored = localStorage.getItem('darkMode')
|
||||
if (stored !== null) return stored === 'true'
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
// Apply dark mode class to document
|
||||
if (darkMode) {
|
||||
document.documentElement.classList.add('dark')
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
localStorage.setItem('darkMode', String(darkMode))
|
||||
}, [darkMode])
|
||||
|
||||
const toggleDarkMode = () => setDarkMode(prev => !prev)
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ darkMode, toggleDarkMode }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const context = useContext(ThemeContext)
|
||||
if (!context) {
|
||||
throw new Error('useTheme must be used within ThemeProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useEffect } from 'react'
|
||||
|
||||
export function useKeyboardShortcuts(shortcuts = {}) {
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e) => {
|
||||
// Ignore if user is typing in an input/textarea
|
||||
if (
|
||||
e.target.tagName === 'INPUT' ||
|
||||
e.target.tagName === 'TEXTAREA' ||
|
||||
e.target.isContentEditable
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check for modifier + key
|
||||
const key = e.key.toLowerCase()
|
||||
const ctrl = e.ctrlKey || e.metaKey
|
||||
const shift = e.shiftKey
|
||||
|
||||
for (const [combination, callback] of Object.entries(shortcuts)) {
|
||||
const parts = combination.toLowerCase().split('+')
|
||||
const needsCtrl = parts.includes('ctrl') || parts.includes('cmd')
|
||||
const needsShift = parts.includes('shift')
|
||||
const keyPart = parts.find(p => !['ctrl', 'cmd', 'shift'].includes(p))
|
||||
|
||||
if (
|
||||
key === keyPart &&
|
||||
needsCtrl === ctrl &&
|
||||
needsShift === shift
|
||||
) {
|
||||
e.preventDefault()
|
||||
callback()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [shortcuts])
|
||||
}
|
||||
|
||||
// Default keyboard shortcuts
|
||||
export const DEFAULT_SHORTCUTS = {
|
||||
'?': () => {
|
||||
// Show help (could implement a shortcuts modal)
|
||||
console.log('Keyboard shortcuts: ? to show help')
|
||||
},
|
||||
'g d': () => window.location.hash = '#/dashboard',
|
||||
'g p': () => window.location.hash = '#/posts',
|
||||
'g c': () => window.location.hash = '#/campaigns',
|
||||
'g t': () => window.location.hash = '#/tasks',
|
||||
'g a': () => window.location.hash = '#/artefacts',
|
||||
'/': () => {
|
||||
// Focus search - implement based on your search component
|
||||
const searchInput = document.querySelector('[data-search-input]')
|
||||
if (searchInput) searchInput.focus()
|
||||
},
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createContext, useContext, useState, useEffect } from 'react'
|
||||
import { api } from '../utils/api'
|
||||
import en from './en.json'
|
||||
import ar from './ar.json'
|
||||
|
||||
@@ -33,6 +34,7 @@ export function LanguageProvider({ children }) {
|
||||
if (newLang !== 'en' && newLang !== 'ar') return
|
||||
setLangState(newLang)
|
||||
localStorage.setItem('digitalhub-lang', newLang)
|
||||
api.patch('/api/users/me/language', { language: newLang }).catch(() => {})
|
||||
}
|
||||
|
||||
const setCurrency = (code) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"app.name": "المركز الرقمي",
|
||||
"app.subtitle": "المنصة",
|
||||
"app.name": "رواج",
|
||||
"app.subtitle": "مركز التسويق",
|
||||
"nav.dashboard": "لوحة التحكم",
|
||||
"nav.campaigns": "الحملات",
|
||||
"nav.finance": "المالية والعائد",
|
||||
@@ -30,6 +30,8 @@
|
||||
"common.noResults": "لا توجد نتائج",
|
||||
"common.loading": "جاري التحميل...",
|
||||
"common.unassigned": "غير مُسند",
|
||||
"common.close": "إغلاق",
|
||||
"common.created": "تاريخ الإنشاء",
|
||||
"common.required": "مطلوب",
|
||||
"common.saveFailed": "فشل الحفظ. حاول مجدداً.",
|
||||
"common.updateFailed": "فشل التحديث. حاول مجدداً.",
|
||||
@@ -77,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": "الحالة",
|
||||
@@ -130,6 +155,7 @@
|
||||
"posts.status.approved": "مُعتمد",
|
||||
"posts.status.scheduled": "مجدول",
|
||||
"posts.status.published": "منشور",
|
||||
"posts.status.rejected": "مرفوض",
|
||||
"tasks.title": "المهام",
|
||||
"tasks.newTask": "مهمة جديدة",
|
||||
"tasks.editTask": "تعديل المهمة",
|
||||
@@ -209,6 +235,7 @@
|
||||
"team.title": "الفريق",
|
||||
"team.members": "أعضاء الفريق",
|
||||
"team.addMember": "إضافة عضو",
|
||||
"team.memberAdded": "تمت إضافة العضو بنجاح",
|
||||
"team.newMember": "عضو جديد",
|
||||
"team.editMember": "تعديل العضو",
|
||||
"team.myProfile": "ملفي الشخصي",
|
||||
@@ -231,6 +258,12 @@
|
||||
"team.membersPlural": "أعضاء فريق",
|
||||
"team.fullName": "الاسم الكامل",
|
||||
"team.defaultPassword": "افتراضياً: changeme123",
|
||||
"team.confirmPassword": "تأكيد كلمة المرور",
|
||||
"team.passwordsDoNotMatch": "كلمتا المرور غير متطابقتين",
|
||||
"team.adminActions": "إجراءات المسؤول",
|
||||
"team.newPassword": "كلمة مرور جديدة (٦ أحرف على الأقل)",
|
||||
"team.changePassword": "تغيير كلمة المرور",
|
||||
"team.passwordChanged": "تم تغيير كلمة المرور بنجاح",
|
||||
"team.optional": "(اختياري)",
|
||||
"team.fixedRole": "دور ثابت للمديرين",
|
||||
"team.remove": "إزالة",
|
||||
@@ -310,6 +343,25 @@
|
||||
"login.subtitle": "سجل دخولك للمتابعة",
|
||||
"login.forgotPassword": "نسيت كلمة المرور؟",
|
||||
"login.defaultCreds": "بيانات الدخول الافتراضية:",
|
||||
"forgotPassword.title": "نسيت كلمة المرور",
|
||||
"forgotPassword.subtitle": "أدخل بريدك الإلكتروني لتلقي رابط إعادة التعيين",
|
||||
"forgotPassword.emailPlaceholder": "بريدك@email.com",
|
||||
"forgotPassword.submit": "إرسال رابط إعادة التعيين",
|
||||
"forgotPassword.sending": "جارٍ الإرسال...",
|
||||
"forgotPassword.success": "إذا كان هناك حساب بهذا البريد الإلكتروني، فقد تم إرسال رابط إعادة التعيين.",
|
||||
"forgotPassword.backToLogin": "العودة لتسجيل الدخول",
|
||||
"forgotPassword.error": "حدث خطأ. يرجى المحاولة مرة أخرى.",
|
||||
"resetPassword.title": "إعادة تعيين كلمة المرور",
|
||||
"resetPassword.subtitle": "أدخل كلمة المرور الجديدة",
|
||||
"resetPassword.newPassword": "كلمة المرور الجديدة",
|
||||
"resetPassword.confirmPassword": "تأكيد كلمة المرور",
|
||||
"resetPassword.submit": "إعادة تعيين كلمة المرور",
|
||||
"resetPassword.resetting": "جارٍ إعادة التعيين...",
|
||||
"resetPassword.success": "تم إعادة تعيين كلمة المرور. يمكنك الآن تسجيل الدخول.",
|
||||
"resetPassword.invalidToken": "رابط إعادة التعيين غير صالح أو منتهي الصلاحية.",
|
||||
"resetPassword.goToLogin": "الذهاب لتسجيل الدخول",
|
||||
"resetPassword.passwordMismatch": "كلمتا المرور غير متطابقتين",
|
||||
"resetPassword.error": "فشل إعادة تعيين كلمة المرور. ربما انتهت صلاحية الرابط.",
|
||||
"comments.title": "النقاش",
|
||||
"comments.noComments": "لا توجد تعليقات بعد. ابدأ المحادثة.",
|
||||
"comments.placeholder": "اكتب تعليقاً...",
|
||||
@@ -325,13 +377,24 @@
|
||||
"timeline.day": "يوم",
|
||||
"timeline.week": "أسبوع",
|
||||
"timeline.today": "اليوم",
|
||||
"timeline.startDate": "تاريخ البدء",
|
||||
"timeline.startDate": "البداية",
|
||||
"timeline.endDate": "النهاية",
|
||||
"timeline.assignee": "المُكلّف",
|
||||
"timeline.status": "الحالة",
|
||||
"timeline.dragToMove": "اسحب للنقل",
|
||||
"timeline.dragToResize": "اسحب الحواف لتغيير الحجم",
|
||||
"timeline.noItems": "لا توجد عناصر للعرض",
|
||||
"timeline.addItems": "أضف عناصر بتواريخ لعرض الجدول الزمني",
|
||||
"timeline.tracks": "المسارات",
|
||||
"timeline.timeline": "الجدول الزمني",
|
||||
"timeline.item": "العنصر",
|
||||
"timeline.month": "شهر",
|
||||
"timeline.compact": "مضغوط",
|
||||
"timeline.expand": "موسّع",
|
||||
"timeline.resetColor": "إعادة إلى الافتراضي",
|
||||
"timeline.changeColor": "تغيير اللون",
|
||||
"timeline.compactBars": "أشرطة مضغوطة",
|
||||
"timeline.expandedBars": "أشرطة موسّعة",
|
||||
"posts.details": "التفاصيل",
|
||||
"posts.platformsLinks": "المنصات والروابط",
|
||||
"posts.discussion": "النقاش",
|
||||
@@ -357,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": "اسم المسار",
|
||||
@@ -464,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": "الحد الأقصى المسموح لحجم المرفقات (١-٥٠٠ ميجابايت)",
|
||||
@@ -471,11 +597,21 @@
|
||||
"settings.saved": "تم حفظ الإعدادات!",
|
||||
"tasks.maxFileSize": "الحد الأقصى: {size} ميجابايت",
|
||||
"tasks.fileTooLarge": "الملف \"{name}\" كبير جداً ({size} ميجابايت). الحد المسموح: {max} ميجابايت.",
|
||||
"issues.details": "التفاصيل",
|
||||
"issues.actions": "الإجراءات",
|
||||
"issues.updates": "التحديثات",
|
||||
"issues.board": "لوحة",
|
||||
"issues.list": "قائمة",
|
||||
"issues.statusUpdated": "تم تحديث حالة المشكلة!",
|
||||
"issues.dropHere": "أفلت هنا",
|
||||
"issues.noIssuesInColumn": "لا توجد مشاكل",
|
||||
"artefacts.details": "التفاصيل",
|
||||
"artefacts.review": "المراجعة",
|
||||
"artefacts.selectVersionFirst": "اختر إصداراً لعرض التعليقات.",
|
||||
"artefacts.pendingReviewInfo": "هذا العنصر قيد المراجعة حالياً.",
|
||||
"artefacts.noReviewInfo": "لا توجد معلومات مراجعة متاحة.",
|
||||
"artefacts.rejectedMustCreateNewVersion": "تم رفض هذا العنصر. أنشئ إصداراً جديداً لمعالجة الملاحظات.",
|
||||
"artefacts.revisionEditCurrentVersion": "طُلب تعديل — عدّل الإصدار الحالي وأعد إرساله للمراجعة.",
|
||||
"artefacts.grid": "شبكة",
|
||||
"artefacts.list": "قائمة",
|
||||
"artefacts.allCreators": "جميع المنشئين",
|
||||
@@ -486,5 +622,610 @@
|
||||
"artefacts.sortRecentlyUpdated": "آخر تحديث",
|
||||
"artefacts.sortNewest": "الأحدث أولاً",
|
||||
"artefacts.sortOldest": "الأقدم أولاً",
|
||||
"artefacts.sortTitleAZ": "العنوان أ-ي"
|
||||
"artefacts.sortTitleAZ": "العنوان أ-ي",
|
||||
|
||||
"login.initialSetup": "الإعداد الأولي",
|
||||
"login.initialSetupDesc": "أنشئ حساب المسؤول للبدء",
|
||||
"login.createAccount": "إنشاء حساب",
|
||||
"login.signIn": "تسجيل الدخول",
|
||||
"login.fullName": "الاسم الكامل",
|
||||
"login.fullNamePlaceholder": "اسمك",
|
||||
"login.email": "البريد الإلكتروني",
|
||||
"login.password": "كلمة المرور",
|
||||
"login.passwordPlaceholder": "اختر كلمة مرور قوية",
|
||||
"login.confirmPassword": "تأكيد كلمة المرور",
|
||||
"login.confirmPasswordPlaceholder": "أعد إدخال كلمة المرور",
|
||||
"login.passwordMismatch": "كلمات المرور غير متطابقة",
|
||||
"login.setupFailed": "فشل الإعداد",
|
||||
"login.accountCreated": "تم إنشاء الحساب. يمكنك الآن تسجيل الدخول.",
|
||||
"login.welcomeBack": "مرحباً بعودتك",
|
||||
"login.signInDesc": "سجل الدخول للمتابعة",
|
||||
"login.invalidCredentials": "البريد الإلكتروني أو كلمة المرور غير صحيحة",
|
||||
"login.creatingAccount": "جاري إنشاء الحساب...",
|
||||
|
||||
"users.title": "إدارة المستخدمين",
|
||||
"users.addUser": "إضافة مستخدم",
|
||||
"users.addNewUser": "إضافة مستخدم جديد",
|
||||
"users.editUser": "تعديل المستخدم",
|
||||
"users.deleteUser": "حذف المستخدم",
|
||||
"users.deleteUserConfirmTitle": "حذف المستخدم؟",
|
||||
"users.deleteConfirm": "هل أنت متأكد من حذف هذا المستخدم؟ لا يمكن التراجع.",
|
||||
"users.userSingular": "مستخدم",
|
||||
"users.usersPlural": "مستخدمين",
|
||||
"users.noUsers": "لم يتم العثور على مستخدمين",
|
||||
"users.you": "أنت",
|
||||
"users.name": "الاسم",
|
||||
"users.fullNamePlaceholder": "الاسم الكامل",
|
||||
"users.email": "البريد الإلكتروني",
|
||||
"users.password": "كلمة المرور",
|
||||
"users.confirmPassword": "تأكيد كلمة المرور",
|
||||
"users.role": "الدور",
|
||||
"users.created": "تاريخ الإنشاء",
|
||||
"users.actions": "الإجراءات",
|
||||
"users.leaveBlankToKeep": "اتركه فارغاً للإبقاء على الحالي",
|
||||
"users.saveChanges": "حفظ التغييرات",
|
||||
"users.passwordMismatch": "كلمات المرور غير متطابقة",
|
||||
"users.passwordRequired": "كلمة المرور مطلوبة للمستخدمين الجدد",
|
||||
"users.saveFailed": "فشل في حفظ المستخدم",
|
||||
"users.preferredLanguage": "اللغة المفضلة",
|
||||
"users.deleteFailed": "فشل في حذف المستخدم",
|
||||
|
||||
"settings.saveFailed": "فشل في الحفظ",
|
||||
"settings.restartTutorialFailed": "فشل في إعادة تشغيل البرنامج التعليمي",
|
||||
|
||||
"artefacts.title": "القطع الإبداعية",
|
||||
"artefacts.subtitle": "سير عمل الموافقة على المحتوى مع إدارة الإصدارات",
|
||||
"artefacts.newArtefact": "محتوى جديد",
|
||||
"artefacts.createArtefact": "إنشاء محتوى",
|
||||
"artefacts.searchArtefacts": "البحث في المحتوى...",
|
||||
"artefacts.allBrands": "جميع العلامات التجارية",
|
||||
"artefacts.allStatuses": "جميع الحالات",
|
||||
"artefacts.allTypes": "جميع الأنواع",
|
||||
"artefacts.noArtefacts": "لم يتم العثور على محتوى",
|
||||
"artefacts.titleLabel": "العنوان",
|
||||
"artefacts.titlePlaceholder": "عنوان المحتوى",
|
||||
"artefacts.type": "النوع",
|
||||
"artefacts.status": "الحالة",
|
||||
"artefacts.brand": "العلامة التجارية",
|
||||
"artefacts.creator": "المنشئ",
|
||||
"artefacts.approvers": "المعتمدون",
|
||||
"artefacts.version": "الإصدار",
|
||||
"artefacts.updated": "آخر تحديث",
|
||||
"artefacts.description": "الوصف",
|
||||
"artefacts.descriptionPlaceholder": "وصف مختصر",
|
||||
"artefacts.titleRequired": "العنوان مطلوب",
|
||||
"artefacts.created": "تم إنشاء المحتوى",
|
||||
"artefacts.createFailed": "فشل في إنشاء المحتوى",
|
||||
"artefacts.deleted": "تم حذف المحتوى",
|
||||
"artefacts.deleteFailed": "فشل في حذف المحتوى",
|
||||
"artefacts.loadFailed": "فشل في تحميل المحتوى",
|
||||
"artefacts.creating": "جاري الإنشاء...",
|
||||
"artefacts.status.draft": "مسودة",
|
||||
"artefacts.status.pendingReview": "بانتظار المراجعة",
|
||||
"artefacts.status.approved": "مُعتمد",
|
||||
"artefacts.status.rejected": "مرفوض",
|
||||
"artefacts.status.revisionRequested": "مطلوب تعديل",
|
||||
|
||||
"review.contentReview": "مراجعة المحتوى",
|
||||
"review.yourReview": "مراجعتك",
|
||||
"review.approve": "موافقة",
|
||||
"review.reject": "رفض",
|
||||
"review.requestRevision": "طلب تعديل",
|
||||
"review.reviewer": "المراجع",
|
||||
"review.selectYourName": "اختر اسمك...",
|
||||
"review.enterYourName": "أدخل اسمك",
|
||||
"review.feedbackOptional": "ملاحظات (اختياري)",
|
||||
"review.feedbackPlaceholder": "شارك أفكارك أو اقتراحاتك أو التغييرات المطلوبة...",
|
||||
"review.thankYou": "شكراً لك!",
|
||||
"review.notAvailable": "المراجعة غير متاحة",
|
||||
"review.alreadyReviewed": "تمت مراجعة هذا المحتوى بالفعل.",
|
||||
"review.statusLabel": "الحالة",
|
||||
"review.reviewedBy": "تمت المراجعة بواسطة",
|
||||
"review.poweredBy": "مدعوم بواسطة Rawaj",
|
||||
"review.loadFailed": "فشل في تحميل المحتوى",
|
||||
"review.actionFailed": "فشل الإجراء",
|
||||
"review.actionCompleted": "تم الإجراء بنجاح",
|
||||
"review.enterName": "يرجى اختيار أو إدخال اسمك",
|
||||
"review.confirmApprove": "هل تريد الموافقة على هذا المحتوى؟",
|
||||
"review.confirmReject": "هل تريد رفض هذا المحتوى؟",
|
||||
"review.feedbackRequired": "يرجى تقديم ملاحظات لطلب التعديل",
|
||||
"review.contentLanguages": "لغات المحتوى",
|
||||
"review.redirectReview": "لست المراجع المناسب؟ أعد التوجيه لشخص آخر",
|
||||
"review.redirectDesc": "اختر عضو فريق لإعادة توجيه المراجعة إليه:",
|
||||
"review.selectNewReviewer": "اختر مراجعاً جديداً...",
|
||||
"review.redirect": "إعادة توجيه",
|
||||
"review.redirected": "تم إعادة توجيه المراجعة بنجاح",
|
||||
"review.content": "المحتوى",
|
||||
"review.designFiles": "ملفات التصميم",
|
||||
"review.videos": "الفيديوهات",
|
||||
"review.googleDriveVideo": "فيديو Google Drive",
|
||||
"review.attachments": "المرفقات",
|
||||
"review.previousComments": "التعليقات السابقة",
|
||||
"review.version": "الإصدار",
|
||||
|
||||
"common.failedToSave": "فشل في الحفظ",
|
||||
"common.copiedToClipboard": "تم النسخ إلى الحافظة!",
|
||||
"team.failedToSaveTeam": "فشل في حفظ الفريق",
|
||||
"posts.canOnlyEditOwn": "يمكنك فقط تعديل منشوراتك الخاصة",
|
||||
"assets.uploadFailed": "فشل في الرفع",
|
||||
"assets.failedToDelete": "فشل في حذف الملف",
|
||||
"issues.failedToAddComment": "فشل في إضافة التعليق",
|
||||
"issues.failedToUploadFile": "فشل في رفع الملف",
|
||||
"issues.failedToSubmit": "فشل في إرسال المشكلة. حاول مجدداً.",
|
||||
"issues.failedToUpdateStatus": "فشل في تحديث الحالة",
|
||||
"issues.failedToResolve": "فشل في حل المشكلة",
|
||||
"issues.failedToDecline": "فشل في رفض المشكلة",
|
||||
"issues.failedToUpdateAssignment": "فشل في تحديث التعيين",
|
||||
"issues.failedToSaveNotes": "فشل في حفظ الملاحظات",
|
||||
"issues.failedToAddUpdate": "فشل في إضافة التحديث",
|
||||
"issues.failedToDeleteAttachment": "فشل في حذف المرفق",
|
||||
"issues.trackingLinkCopied": "تم نسخ رابط التتبع!",
|
||||
"issues.deleteAttachment": "حذف المرفق؟",
|
||||
"issues.deleteAttachmentDesc": "لا يمكن التراجع عن هذا الإجراء.",
|
||||
"artefacts.editLanguage": "تعديل اللغة",
|
||||
"artefacts.linkedPost": "المنشور المرتبط",
|
||||
"artefacts.post": "منشور",
|
||||
"artefacts.deleteLanguage": "حذف هذه اللغة؟",
|
||||
"artefacts.deleteLanguageDesc": "سيتم إزالة المحتوى لهذه اللغة.",
|
||||
"artefacts.deleteAttachment": "حذف هذا المرفق؟",
|
||||
"artefacts.deleteAttachmentDesc": "لا يمكن التراجع عن هذا الإجراء.",
|
||||
"artefacts.deleteArtefact": "حذف هذا المحتوى؟",
|
||||
"artefacts.deleteArtefactDesc": "لا يمكن التراجع عن هذا الإجراء.",
|
||||
"review.confirmApproveDesc": "هل أنت متأكد من الموافقة على هذا المحتوى؟",
|
||||
"review.confirmRejectDesc": "هل أنت متأكد من رفض هذا المحتوى؟",
|
||||
|
||||
"common.selected": "محدد",
|
||||
"common.deleteSelected": "حذف المحدد",
|
||||
"common.clearSelection": "إلغاء التحديد",
|
||||
"common.bulkDeleteConfirm": "حذف {count} عناصر؟",
|
||||
"common.bulkDeleteDesc": "لا يمكن التراجع عن هذا الإجراء.",
|
||||
"common.selectAll": "تحديد الكل",
|
||||
|
||||
"issues.team": "الفريق",
|
||||
"issues.allTeams": "جميع الفرق",
|
||||
"issues.copyPublicLink": "نسخ الرابط العام",
|
||||
"issues.linkCopied": "تم نسخ الرابط!",
|
||||
"issues.selectTeam": "اختر فريقاً",
|
||||
"issues.publicSubmitTeam": "أي فريق يجب أن يتولى مشكلتك؟",
|
||||
"team.copyIssueLink": "نسخ رابط المشكلة",
|
||||
"team.copyGenericIssueLink": "نسخ رابط المشاكل العام",
|
||||
"team.permissionLevel": "مستوى الصلاحية",
|
||||
"team.role": "الدور",
|
||||
"team.selectRole": "اختر دوراً...",
|
||||
"common.team": "الفريق",
|
||||
"common.noTeam": "بدون فريق",
|
||||
"common.none": "بدون",
|
||||
"common.untitled": "بدون عنوان",
|
||||
"common.success": "تم بنجاح",
|
||||
"common.error": "حدث خطأ",
|
||||
"settings.roles": "الأدوار",
|
||||
"settings.rolesDesc": "حدد أدوار العمل مثل مصمم، استراتيجي، إلخ. يتم تعيينها لأعضاء الفريق بشكل منفصل عن مستويات الصلاحية.",
|
||||
"settings.addRole": "إضافة دور",
|
||||
"settings.roleName": "اسم الدور",
|
||||
"settings.roleColor": "اللون",
|
||||
"settings.deleteRoleConfirm": "هل أنت متأكد من حذف هذا الدور؟",
|
||||
"settings.noRoles": "لم يتم تحديد أدوار بعد. أضف أول دور.",
|
||||
|
||||
"header.dashboard": "لوحة التحكم",
|
||||
"header.posts": "إنتاج المحتوى",
|
||||
"header.assets": "الأصول",
|
||||
"header.campaigns": "الحملات",
|
||||
"header.finance": "المالية",
|
||||
"header.projects": "المشاريع",
|
||||
"header.tasks": "مهامي",
|
||||
"header.team": "الفريق",
|
||||
"header.calendar": "تقويم المنشورات",
|
||||
"header.artefacts": "المخرجات",
|
||||
"header.brands": "العلامات التجارية",
|
||||
"header.budgets": "الميزانيات",
|
||||
"header.issues": "البلاغات",
|
||||
"header.settings": "الإعدادات",
|
||||
"header.translations": "الترجمات",
|
||||
"header.copy": "النسخ",
|
||||
"header.postDetails": "تفاصيل المنشور",
|
||||
"calendar.unscheduledPosts": "منشورات غير مجدولة",
|
||||
"calendar.statusLegend": "دليل الحالات",
|
||||
"header.users": "إدارة المستخدمين",
|
||||
"header.projectDetails": "تفاصيل المشروع",
|
||||
"header.campaignDetails": "تفاصيل الحملة",
|
||||
"header.page": "الصفحة",
|
||||
"header.superadmin": "مسؤول عام",
|
||||
"header.manager": "مدير",
|
||||
"header.contributor": "مساهم",
|
||||
"header.passwordMismatch": "كلمتا المرور الجديدتان غير متطابقتين",
|
||||
"header.passwordMinLength": "كلمة المرور الجديدة يجب أن تكون ٦ أحرف على الأقل",
|
||||
"header.passwordUpdateSuccess": "تم تحديث كلمة المرور بنجاح",
|
||||
"header.passwordUpdateFailed": "فشل في تغيير كلمة المرور",
|
||||
"header.userManagement": "إدارة المستخدمين",
|
||||
"header.changePassword": "تغيير كلمة المرور",
|
||||
"header.signOut": "تسجيل الخروج",
|
||||
"header.currentPassword": "كلمة المرور الحالية",
|
||||
"header.newPassword": "كلمة المرور الجديدة",
|
||||
"header.confirmNewPassword": "تأكيد كلمة المرور الجديدة",
|
||||
"header.updatePassword": "تحديث كلمة المرور",
|
||||
"header.saving": "جاري الحفظ...",
|
||||
|
||||
"issues.title": "المشاكل",
|
||||
"issues.subtitle": "تتبع وإدارة البلاغات المقدمة",
|
||||
"issues.searchPlaceholder": "البحث في المشاكل...",
|
||||
"issues.allStatuses": "جميع الحالات",
|
||||
"issues.allCategories": "جميع الفئات",
|
||||
"issues.allTypes": "جميع الأنواع",
|
||||
"issues.allBrands": "جميع العلامات",
|
||||
"issues.allPriorities": "جميع الأولويات",
|
||||
"issues.clearAll": "مسح الكل",
|
||||
"issues.noIssuesFound": "لم يتم العثور على مشاكل",
|
||||
"issues.tryAdjustingFilters": "جرّب تعديل الفلاتر",
|
||||
"issues.noIssuesSubmitted": "لم يتم تقديم أي مشاكل بعد",
|
||||
"issues.issuesDeleted": "تم حذف المشاكل",
|
||||
"issues.tableTitle": "العنوان",
|
||||
"issues.tableSubmitter": "مُقدّم البلاغ",
|
||||
"issues.tableBrand": "العلامة التجارية",
|
||||
"issues.tableCategory": "الفئة",
|
||||
"issues.tableType": "النوع",
|
||||
"issues.tablePriority": "الأولوية",
|
||||
"issues.tableStatus": "الحالة",
|
||||
"issues.tableAssignedTo": "مُسند إلى",
|
||||
"issues.tableCreated": "تاريخ الإنشاء",
|
||||
|
||||
"issues.typeRequest": "طلب",
|
||||
"issues.typeCorrection": "تصحيح",
|
||||
"issues.typeComplaint": "شكوى",
|
||||
"issues.typeSuggestion": "اقتراح",
|
||||
"issues.typeOther": "أخرى",
|
||||
|
||||
"issues.priorityLow": "منخفض",
|
||||
"issues.priorityMedium": "متوسط",
|
||||
"issues.priorityHigh": "عالي",
|
||||
"issues.priorityUrgent": "عاجل",
|
||||
|
||||
"issues.submitterInfo": "معلومات مُقدّم البلاغ",
|
||||
"issues.nameLabel": "الاسم:",
|
||||
"issues.emailLabel": "البريد الإلكتروني:",
|
||||
"issues.phoneLabel": "الهاتف:",
|
||||
"issues.submittedLabel": "تاريخ التقديم:",
|
||||
"issues.description": "الوصف",
|
||||
"issues.noDescription": "لا يوجد وصف",
|
||||
"issues.assignedTo": "مُسند إلى",
|
||||
"issues.unassigned": "غير مُسند",
|
||||
"issues.brandLabel": "العلامة التجارية",
|
||||
"issues.noBrand": "بدون علامة تجارية",
|
||||
"issues.internalNotes": "ملاحظات داخلية (للموظفين فقط)",
|
||||
"issues.internalNotesPlaceholder": "ملاحظات داخلية غير مرئية لمقدم البلاغ...",
|
||||
"issues.resolutionSummary": "ملخص الحل (عام)",
|
||||
"issues.resolvedOn": "تم الحل في",
|
||||
"issues.acknowledge": "إقرار",
|
||||
"issues.startWork": "بدء العمل",
|
||||
"issues.resolve": "حل",
|
||||
"issues.decline": "رفض",
|
||||
"issues.publicTrackingLink": "رابط التتبع العام",
|
||||
"issues.updatesTimeline": "الجدول الزمني للتحديثات",
|
||||
"issues.addUpdatePlaceholder": "أضف تحديثاً...",
|
||||
"issues.makePublic": "جعله عاماً (مرئي لمقدم البلاغ)",
|
||||
"issues.addUpdate": "إضافة تحديث",
|
||||
"issues.noUpdates": "لا توجد تحديثات بعد",
|
||||
"issues.attachments": "المرفقات",
|
||||
"issues.clickToUpload": "انقر لرفع ملف",
|
||||
"issues.uploading": "جاري الرفع...",
|
||||
"issues.download": "تحميل",
|
||||
"issues.noAttachments": "لا توجد مرفقات",
|
||||
"issues.resolveIssue": "حل المشكلة",
|
||||
"issues.resolveSummaryHint": "قدّم ملخصاً للحل سيكون مرئياً لمقدم البلاغ.",
|
||||
"issues.resolutionPlaceholder": "اشرح كيف تم حل هذه المشكلة...",
|
||||
"issues.markAsResolved": "تحديد كمحلولة",
|
||||
"issues.resolving": "جاري الحل...",
|
||||
"issues.declineIssue": "رفض المشكلة",
|
||||
"issues.declineReasonHint": "قدّم سبباً لرفض هذه المشكلة. سيكون مرئياً لمقدم البلاغ.",
|
||||
"issues.declinePlaceholder": "اشرح لماذا لا يمكن معالجة هذه المشكلة...",
|
||||
"issues.declining": "جاري الرفض...",
|
||||
|
||||
"artefacts.descriptionLabel": "الوصف",
|
||||
"artefacts.descriptionFieldPlaceholder": "أضف وصفاً...",
|
||||
"artefacts.approversLabel": "المعتمدون",
|
||||
"artefacts.reviewer": "المراجع",
|
||||
"artefacts.selectReviewer": "اختر مراجعاً...",
|
||||
"artefacts.versions": "الإصدارات",
|
||||
"artefacts.newVersion": "إصدار جديد",
|
||||
"artefacts.languages": "اللغات",
|
||||
"artefacts.addLanguage": "إضافة لغة",
|
||||
"artefacts.noLanguages": "لم تتم إضافة لغات بعد",
|
||||
"artefacts.imagesLabel": "الصور",
|
||||
"artefacts.uploadImage": "رفع صورة",
|
||||
"artefacts.uploading": "جاري الرفع...",
|
||||
"artefacts.dropOrClickImage": "اسحب الصور هنا أو انقر للرفع",
|
||||
"artefacts.imageFormats": "PNG, JPG, WebP",
|
||||
"artefacts.noImages": "لم يتم رفع صور بعد",
|
||||
"artefacts.videosLabel": "الفيديوهات",
|
||||
"artefacts.addVideoBtn": "إضافة فيديو",
|
||||
"artefacts.noVideos": "لم تتم إضافة فيديوهات بعد",
|
||||
"artefacts.comments": "التعليقات",
|
||||
"artefacts.sendComment": "إرسال",
|
||||
"artefacts.addCommentPlaceholder": "أضف تعليقاً...",
|
||||
"artefacts.submitForReview": "إرسال للمراجعة",
|
||||
"artefacts.submitting": "جاري الإرسال...",
|
||||
"artefacts.reviewLinkTitle": "رابط المراجعة (ينتهي خلال ٧ أيام)",
|
||||
"artefacts.feedbackTitle": "الملاحظات",
|
||||
"artefacts.approvedByLabel": "تمت الموافقة بواسطة",
|
||||
"artefacts.saveDraft": "حفظ",
|
||||
"artefacts.savingDraft": "جاري الحفظ...",
|
||||
"artefacts.versionNotes": "ملاحظات الإصدار",
|
||||
"artefacts.whatChanged": "ما الذي تغير في هذا الإصدار؟",
|
||||
"artefacts.copyLanguages": "نسخ اللغات من الإصدار السابق",
|
||||
"artefacts.createVersion": "إنشاء إصدار",
|
||||
"artefacts.creatingVersion": "جاري الإنشاء...",
|
||||
"artefacts.languageLabel": "اللغة",
|
||||
"artefacts.contentLabel": "المحتوى",
|
||||
"artefacts.selectLanguage": "اختر لغة...",
|
||||
"artefacts.enterContent": "أدخل المحتوى بهذه اللغة...",
|
||||
"artefacts.addVideoTitle": "إضافة فيديو",
|
||||
"artefacts.uploadFile": "رفع ملف",
|
||||
"artefacts.chooseVideoFile": "اختر ملف فيديو",
|
||||
"artefacts.videoFormats": "MP4، MOV، AVI، إلخ.",
|
||||
"artefacts.dropOrClickVideo": "اسحب فيديو هنا أو انقر للتصفح",
|
||||
"artefacts.googleDriveLink": "رابط Google Drive",
|
||||
"artefacts.googleDriveUrl": "رابط Google Drive",
|
||||
"artefacts.driveUrlPlaceholder": "https://drive.google.com/file/d/...",
|
||||
"artefacts.publiclyAccessible": "الصق رابط مشاركة Google Drive. تأكد أن الملف متاح للعامة.",
|
||||
"artefacts.addLink": "إضافة رابط",
|
||||
"artefacts.adding": "جاري الإضافة...",
|
||||
"artefacts.googleDriveVideo": "فيديو Google Drive",
|
||||
"artefacts.deleteArtefactTooltip": "حذف المحتوى",
|
||||
"artefacts.saveDraftTooltip": "حفظ المسودة",
|
||||
"artefacts.createNewVersion": "إنشاء إصدار جديد",
|
||||
"artefacts.failedLoadVersions": "فشل في تحميل الإصدارات",
|
||||
"artefacts.failedLoadVersionData": "فشل في تحميل بيانات الإصدار",
|
||||
"artefacts.versionCreated": "تم إنشاء الإصدار الجديد",
|
||||
"artefacts.failedCreateVersion": "فشل في إنشاء الإصدار",
|
||||
"artefacts.languageAdded": "تمت إضافة اللغة",
|
||||
"artefacts.allFieldsRequired": "جميع الحقول مطلوبة",
|
||||
"artefacts.failedAddLanguage": "فشل في إضافة اللغة",
|
||||
"artefacts.languageDeleted": "تم حذف اللغة",
|
||||
"artefacts.failedDeleteLanguage": "فشل في حذف اللغة",
|
||||
"artefacts.fileUploaded": "تم رفع الملف",
|
||||
"artefacts.uploadFailed": "فشل في الرفع",
|
||||
"artefacts.videoLinkAdded": "تمت إضافة رابط الفيديو",
|
||||
"artefacts.failedAddVideoLink": "فشل في إضافة رابط الفيديو",
|
||||
"artefacts.enterDriveUrl": "يرجى إدخال رابط Google Drive",
|
||||
"artefacts.attachmentDeleted": "تم حذف المرفق",
|
||||
"artefacts.failedDeleteAttachment": "فشل في حذف المرفق",
|
||||
"artefacts.submittedForReview": "تم الإرسال للمراجعة!",
|
||||
"artefacts.failedSubmitReview": "فشل في الإرسال للمراجعة",
|
||||
"artefacts.linkCopied": "تم نسخ الرابط",
|
||||
"artefacts.commentAdded": "تمت إضافة التعليق",
|
||||
"artefacts.failedAddComment": "فشل في إضافة التعليق",
|
||||
"artefacts.updated": "تم التحديث",
|
||||
"artefacts.failedUpdate": "فشل في التحديث",
|
||||
"artefacts.draftSaved": "تم حفظ المسودة",
|
||||
"artefacts.failedSaveDraft": "فشل في حفظ المسودة",
|
||||
"artefacts.titleRequired": "العنوان مطلوب",
|
||||
"artefacts.failedDelete": "فشل في الحذف",
|
||||
|
||||
"posts.images": "الصور",
|
||||
"posts.audio": "الصوت",
|
||||
"posts.videos": "الفيديوهات",
|
||||
"posts.otherFiles": "ملفات أخرى",
|
||||
"posts.addImage": "إضافة صورة",
|
||||
"posts.addAudio": "إضافة صوت",
|
||||
"posts.addVideo": "إضافة فيديو",
|
||||
"posts.dragToUpload": "اسحب الملفات هنا للرفع",
|
||||
"posts.assignedTo": "مُسند إلى",
|
||||
"posts.approval": "الموافقة",
|
||||
"posts.approvers": "المعتمدون",
|
||||
"posts.selectApprovers": "اختر المعتمدين...",
|
||||
"posts.scheduling": "الجدولة والتعيين",
|
||||
"posts.content": "المحتوى",
|
||||
"posts.reject": "رفض",
|
||||
"posts.submittedForReview": "تم إرسال المنشور للمراجعة",
|
||||
"posts.failedSubmitReview": "فشل إرسال المراجعة",
|
||||
"posts.reviewLinkCopied": "تم نسخ رابط المراجعة!",
|
||||
"posts.reviewLinkTitle": "رابط المراجعة",
|
||||
"posts.awaitingReview": "بانتظار المراجعة",
|
||||
"posts.awaitingReviewDesc": "هذا المنشور بانتظار الموافقة الخارجية.",
|
||||
"posts.approvedBy": "تمت الموافقة من",
|
||||
"posts.rejectedBy": "تم الرفض من",
|
||||
"posts.submitting": "جارٍ الإرسال...",
|
||||
"posts.submitForReview": "إرسال للمراجعة",
|
||||
"posts.schedulePost": "جدولة المنشور",
|
||||
"review.postReview": "مراجعة المنشور",
|
||||
"review.createdBy": "أنشئ بواسطة",
|
||||
"review.confirmApprovePost": "الموافقة على هذا المنشور؟",
|
||||
"review.confirmRejectPost": "رفض هذا المنشور؟",
|
||||
"review.confirmApprovePostDesc": "هل أنت متأكد من الموافقة على هذا المنشور؟",
|
||||
"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": "إنشاء إصدار جديد",
|
||||
"posts.createVersion": "إنشاء إصدار",
|
||||
"posts.creatingVersion": "جارٍ الإنشاء...",
|
||||
"posts.whatChanged": "ما الذي تغير في هذا الإصدار؟",
|
||||
"posts.copyLanguages": "نسخ اللغات من الإصدار السابق",
|
||||
"posts.languages": "اللغات",
|
||||
"posts.addLanguage": "إضافة لغة",
|
||||
"posts.selectLanguage": "اختر لغة...",
|
||||
"posts.enterContent": "أدخل المحتوى بهذه اللغة...",
|
||||
"posts.noLanguages": "لم تتم إضافة لغات بعد",
|
||||
"posts.noVersions": "لا توجد إصدارات بعد. أنشئ إصدارًا لبدء إدارة المحتوى متعدد اللغات والوسائط.",
|
||||
"posts.deleteLanguage": "حذف هذه اللغة؟",
|
||||
"posts.deleteLanguageConfirm": "سيتم حذف محتوى اللغة من هذا الإصدار.",
|
||||
"posts.media": "الوسائط",
|
||||
"posts.noMedia": "لم يتم رفع ملفات وسائط",
|
||||
|
||||
"nav.translations": "الترجمات",
|
||||
"translations.title": "الترجمات",
|
||||
"translations.subtitle": "إدارة ترجمات المحتوى مع سير عمل الموافقة",
|
||||
"translations.newTranslation": "ترجمة جديدة",
|
||||
"translations.createTranslation": "إنشاء ترجمة",
|
||||
"translations.searchTranslations": "البحث في الترجمات...",
|
||||
"translations.titleLabel": "العنوان",
|
||||
"translations.titlePlaceholder": "مثال: ترجمة شعار الحملة",
|
||||
"translations.sourceLanguage": "لغة المصدر",
|
||||
"translations.sourceContent": "المحتوى الأصلي",
|
||||
"translations.sourceContentPlaceholder": "أدخل المحتوى الأصلي المراد ترجمته...",
|
||||
"translations.description": "الوصف",
|
||||
"translations.descriptionLabel": "الوصف",
|
||||
"translations.descriptionPlaceholder": "سياق أو ملاحظات حول هذه الترجمة...",
|
||||
"translations.brand": "العلامة التجارية",
|
||||
"translations.creator": "المنشئ",
|
||||
"translations.approvers": "المراجعون",
|
||||
"translations.approversLabel": "المراجعون",
|
||||
"translations.status": "الحالة",
|
||||
"translations.languagesLabel": "اللغات",
|
||||
"translations.languagesCount": "لغات",
|
||||
"translations.grid": "شبكة",
|
||||
"translations.list": "قائمة",
|
||||
"translations.allBrands": "جميع العلامات",
|
||||
"translations.allStatuses": "جميع الحالات",
|
||||
"translations.allCreators": "جميع المنشئين",
|
||||
"translations.status.draft": "مسودة",
|
||||
"translations.status.pendingReview": "بانتظار المراجعة",
|
||||
"translations.status.approved": "موافق عليه",
|
||||
"translations.status.rejected": "مرفوض",
|
||||
"translations.status.revisionRequested": "طلب تعديل",
|
||||
"translations.sortRecentlyUpdated": "آخر تحديث",
|
||||
"translations.sortNewest": "الأحدث أولاً",
|
||||
"translations.sortOldest": "الأقدم أولاً",
|
||||
"translations.sortTitleAZ": "العنوان أ-ي",
|
||||
"translations.noTranslations": "لم يتم العثور على ترجمات",
|
||||
"translations.loadFailed": "فشل تحميل الترجمات",
|
||||
"translations.titleRequired": "العنوان مطلوب",
|
||||
"translations.sourceContentRequired": "المحتوى الأصلي مطلوب",
|
||||
"translations.created": "تم إنشاء الترجمة!",
|
||||
"translations.createFailed": "فشل إنشاء الترجمة",
|
||||
"translations.creating": "جارٍ الإنشاء...",
|
||||
"translations.deleted": "تم حذف الترجمة!",
|
||||
"translations.deleteFailed": "فشل حذف الترجمة",
|
||||
"translations.details": "التفاصيل",
|
||||
"translations.translationTexts": "الترجمات",
|
||||
"translations.review": "المراجعة",
|
||||
"translations.draftSaved": "تم حفظ المسودة!",
|
||||
"translations.failedSaveDraft": "فشل حفظ المسودة",
|
||||
"translations.saveDraft": "حفظ المسودة",
|
||||
"translations.saveDraftTooltip": "حفظ التغييرات على العنوان والمحتوى الأصلي",
|
||||
"translations.savingDraft": "جارٍ الحفظ...",
|
||||
"translations.updated": "تم التحديث!",
|
||||
"translations.failedUpdate": "فشل التحديث",
|
||||
"translations.addTranslation": "إضافة ترجمة",
|
||||
"translations.translationAdded": "تمت إضافة الترجمة!",
|
||||
"translations.failedAddTranslation": "فشل إضافة الترجمة",
|
||||
"translations.translationDeleted": "تم حذف الترجمة!",
|
||||
"translations.failedDeleteTranslation": "فشل حذف الترجمة",
|
||||
"translations.noTranslationTexts": "لا توجد ترجمات بعد. أضف واحدة لكل لغة مستهدفة.",
|
||||
"translations.allFieldsRequired": "اللغة والمحتوى مطلوبان",
|
||||
"translations.languageLabel": "اللغة",
|
||||
"translations.selectLanguage": "اختر لغة",
|
||||
"translations.translatedContent": "المحتوى المترجم",
|
||||
"translations.enterTranslatedContent": "أدخل المحتوى المترجم...",
|
||||
"translations.deleteTranslation": "حذف الترجمة",
|
||||
"translations.deleteTranslationDesc": "سيتم حذف هذه الترجمة وجميع نسخ اللغات نهائيًا.",
|
||||
"translations.deleteTranslationText": "حذف نص الترجمة",
|
||||
"translations.deleteTranslationTextDesc": "سيتم حذف ترجمة هذه اللغة.",
|
||||
"translations.bulkDeleteDesc": "حذف الترجمات المحددة؟",
|
||||
"translations.submitForReview": "تقديم للمراجعة",
|
||||
"translations.submitting": "جارٍ التقديم...",
|
||||
"translations.submittedForReview": "تم التقديم للمراجعة!",
|
||||
"translations.failedSubmitReview": "فشل التقديم للمراجعة",
|
||||
"translations.reviewLinkTitle": "رابط المراجعة",
|
||||
"translations.linkCopied": "تم نسخ الرابط!",
|
||||
"translations.feedbackTitle": "ملاحظات المراجع",
|
||||
"translations.approvedByLabel": "وافق عليه",
|
||||
"translations.pendingReviewInfo": "هذه الترجمة بانتظار المراجعة حاليًا.",
|
||||
"translations.noReviewInfo": "لا توجد معلومات مراجعة متاحة.",
|
||||
"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",
|
||||
@@ -30,6 +30,8 @@
|
||||
"common.noResults": "No results",
|
||||
"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.",
|
||||
@@ -69,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",
|
||||
@@ -77,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",
|
||||
@@ -130,6 +155,7 @@
|
||||
"posts.status.approved": "Approved",
|
||||
"posts.status.scheduled": "Scheduled",
|
||||
"posts.status.published": "Published",
|
||||
"posts.status.rejected": "Rejected",
|
||||
"tasks.title": "Tasks",
|
||||
"tasks.newTask": "New Task",
|
||||
"tasks.editTask": "Edit Task",
|
||||
@@ -209,6 +235,7 @@
|
||||
"team.title": "Team",
|
||||
"team.members": "Team Members",
|
||||
"team.addMember": "Add Member",
|
||||
"team.memberAdded": "Member added successfully",
|
||||
"team.newMember": "New Team Member",
|
||||
"team.editMember": "Edit Team Member",
|
||||
"team.myProfile": "My Profile",
|
||||
@@ -231,6 +258,12 @@
|
||||
"team.membersPlural": "team members",
|
||||
"team.fullName": "Full name",
|
||||
"team.defaultPassword": "Default: changeme123",
|
||||
"team.confirmPassword": "Confirm Password",
|
||||
"team.passwordsDoNotMatch": "Passwords do not match",
|
||||
"team.adminActions": "Admin Actions",
|
||||
"team.newPassword": "New password (min 6 characters)",
|
||||
"team.changePassword": "Change Password",
|
||||
"team.passwordChanged": "Password changed successfully",
|
||||
"team.optional": "(optional)",
|
||||
"team.fixedRole": "Fixed role for managers",
|
||||
"team.remove": "Remove",
|
||||
@@ -262,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!",
|
||||
@@ -306,10 +339,29 @@
|
||||
"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:",
|
||||
"forgotPassword.title": "Forgot Password",
|
||||
"forgotPassword.subtitle": "Enter your email to receive a reset link",
|
||||
"forgotPassword.emailPlaceholder": "your@email.com",
|
||||
"forgotPassword.submit": "Send Reset Link",
|
||||
"forgotPassword.sending": "Sending...",
|
||||
"forgotPassword.success": "If an account with that email exists, a reset link has been sent.",
|
||||
"forgotPassword.backToLogin": "Back to Login",
|
||||
"forgotPassword.error": "Something went wrong. Please try again.",
|
||||
"resetPassword.title": "Reset Password",
|
||||
"resetPassword.subtitle": "Enter your new password",
|
||||
"resetPassword.newPassword": "New Password",
|
||||
"resetPassword.confirmPassword": "Confirm Password",
|
||||
"resetPassword.submit": "Reset Password",
|
||||
"resetPassword.resetting": "Resetting...",
|
||||
"resetPassword.success": "Password has been reset. You can now log in.",
|
||||
"resetPassword.invalidToken": "Invalid or expired reset link.",
|
||||
"resetPassword.goToLogin": "Go to Login",
|
||||
"resetPassword.passwordMismatch": "Passwords do not match",
|
||||
"resetPassword.error": "Failed to reset password. The link may have expired.",
|
||||
"comments.title": "Discussion",
|
||||
"comments.noComments": "No comments yet. Start the conversation.",
|
||||
"comments.placeholder": "Write a comment...",
|
||||
@@ -325,13 +377,24 @@
|
||||
"timeline.day": "Day",
|
||||
"timeline.week": "Week",
|
||||
"timeline.today": "Today",
|
||||
"timeline.startDate": "Start Date",
|
||||
"timeline.startDate": "Start",
|
||||
"timeline.endDate": "End",
|
||||
"timeline.assignee": "Assignee",
|
||||
"timeline.status": "Status",
|
||||
"timeline.dragToMove": "Drag to move",
|
||||
"timeline.dragToResize": "Drag edges to resize",
|
||||
"timeline.noItems": "No items to display",
|
||||
"timeline.addItems": "Add items with dates to see the timeline",
|
||||
"timeline.tracks": "Tracks",
|
||||
"timeline.timeline": "Timeline",
|
||||
"timeline.item": "Item",
|
||||
"timeline.month": "Month",
|
||||
"timeline.compact": "Compact",
|
||||
"timeline.expand": "Expand",
|
||||
"timeline.resetColor": "Reset to default",
|
||||
"timeline.changeColor": "Change color",
|
||||
"timeline.compactBars": "Compact bars",
|
||||
"timeline.expandedBars": "Expanded bars",
|
||||
"posts.details": "Details",
|
||||
"posts.platformsLinks": "Platforms & Links",
|
||||
"posts.discussion": "Discussion",
|
||||
@@ -357,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",
|
||||
@@ -464,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)",
|
||||
@@ -471,11 +597,21 @@
|
||||
"settings.saved": "Settings saved!",
|
||||
"tasks.maxFileSize": "Max file size: {size} MB",
|
||||
"tasks.fileTooLarge": "File \"{name}\" is too large ({size} MB). Maximum allowed: {max} MB.",
|
||||
"issues.details": "Details",
|
||||
"issues.actions": "Actions",
|
||||
"issues.updates": "Updates",
|
||||
"issues.board": "Board",
|
||||
"issues.list": "List",
|
||||
"issues.statusUpdated": "Issue status updated!",
|
||||
"issues.dropHere": "Drop here",
|
||||
"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",
|
||||
@@ -486,5 +622,610 @@
|
||||
"artefacts.sortRecentlyUpdated": "Recently Updated",
|
||||
"artefacts.sortNewest": "Newest First",
|
||||
"artefacts.sortOldest": "Oldest First",
|
||||
"artefacts.sortTitleAZ": "Title A-Z"
|
||||
"artefacts.sortTitleAZ": "Title A-Z",
|
||||
|
||||
"login.initialSetup": "Initial Setup",
|
||||
"login.initialSetupDesc": "Create your admin account to get started",
|
||||
"login.createAccount": "Create Account",
|
||||
"login.signIn": "Sign In",
|
||||
"login.fullName": "Full Name",
|
||||
"login.fullNamePlaceholder": "Your name",
|
||||
"login.email": "Email",
|
||||
"login.password": "Password",
|
||||
"login.passwordPlaceholder": "Choose a strong password",
|
||||
"login.confirmPassword": "Confirm Password",
|
||||
"login.confirmPasswordPlaceholder": "Re-enter your password",
|
||||
"login.passwordMismatch": "Passwords do not match",
|
||||
"login.setupFailed": "Setup failed",
|
||||
"login.accountCreated": "Account created. You can now log in.",
|
||||
"login.welcomeBack": "Welcome Back",
|
||||
"login.signInDesc": "Sign in to continue",
|
||||
"login.invalidCredentials": "Invalid email or password",
|
||||
"login.creatingAccount": "Creating account...",
|
||||
|
||||
"users.title": "User Management",
|
||||
"users.addUser": "Add User",
|
||||
"users.addNewUser": "Add New User",
|
||||
"users.editUser": "Edit User",
|
||||
"users.deleteUser": "Delete User",
|
||||
"users.deleteUserConfirmTitle": "Delete User?",
|
||||
"users.deleteConfirm": "Are you sure you want to delete this user? This action cannot be undone.",
|
||||
"users.userSingular": "user",
|
||||
"users.usersPlural": "users",
|
||||
"users.noUsers": "No users found",
|
||||
"users.you": "You",
|
||||
"users.name": "Name",
|
||||
"users.fullNamePlaceholder": "Full name",
|
||||
"users.email": "Email",
|
||||
"users.password": "Password",
|
||||
"users.confirmPassword": "Confirm Password",
|
||||
"users.role": "Role",
|
||||
"users.created": "Created",
|
||||
"users.actions": "Actions",
|
||||
"users.leaveBlankToKeep": "leave blank to keep current",
|
||||
"users.saveChanges": "Save Changes",
|
||||
"users.passwordMismatch": "Passwords do not match",
|
||||
"users.passwordRequired": "Password is required for new users",
|
||||
"users.saveFailed": "Failed to save user",
|
||||
"users.preferredLanguage": "Preferred Language",
|
||||
"users.deleteFailed": "Failed to delete user",
|
||||
|
||||
"settings.saveFailed": "Failed to save",
|
||||
"settings.restartTutorialFailed": "Failed to restart tutorial",
|
||||
|
||||
"artefacts.title": "Artefacts",
|
||||
"artefacts.subtitle": "Content approval workflow with versioning",
|
||||
"artefacts.newArtefact": "New Artefact",
|
||||
"artefacts.createArtefact": "Create Artefact",
|
||||
"artefacts.searchArtefacts": "Search artefacts...",
|
||||
"artefacts.allBrands": "All Brands",
|
||||
"artefacts.allStatuses": "All Statuses",
|
||||
"artefacts.allTypes": "All Types",
|
||||
"artefacts.noArtefacts": "No artefacts found",
|
||||
"artefacts.titleLabel": "Title",
|
||||
"artefacts.titlePlaceholder": "Artefact title",
|
||||
"artefacts.type": "Type",
|
||||
"artefacts.status": "Status",
|
||||
"artefacts.brand": "Brand",
|
||||
"artefacts.creator": "Creator",
|
||||
"artefacts.approvers": "Approvers",
|
||||
"artefacts.version": "Version",
|
||||
"artefacts.updated": "Updated",
|
||||
"artefacts.description": "Description",
|
||||
"artefacts.descriptionPlaceholder": "Brief description",
|
||||
"artefacts.titleRequired": "Title is required",
|
||||
"artefacts.created": "Artefact created",
|
||||
"artefacts.createFailed": "Failed to create artefact",
|
||||
"artefacts.deleted": "Artefact deleted",
|
||||
"artefacts.deleteFailed": "Failed to delete artefact",
|
||||
"artefacts.loadFailed": "Failed to load artefacts",
|
||||
"artefacts.creating": "Creating...",
|
||||
"artefacts.status.draft": "Draft",
|
||||
"artefacts.status.pendingReview": "Pending Review",
|
||||
"artefacts.status.approved": "Approved",
|
||||
"artefacts.status.rejected": "Rejected",
|
||||
"artefacts.status.revisionRequested": "Revision Requested",
|
||||
|
||||
"review.contentReview": "Content Review",
|
||||
"review.yourReview": "Your Review",
|
||||
"review.approve": "Approve",
|
||||
"review.reject": "Reject",
|
||||
"review.requestRevision": "Request Revision",
|
||||
"review.reviewer": "Reviewer",
|
||||
"review.selectYourName": "Select your name...",
|
||||
"review.enterYourName": "Enter your name",
|
||||
"review.feedbackOptional": "Feedback (optional)",
|
||||
"review.feedbackPlaceholder": "Share your thoughts, suggestions, or required changes...",
|
||||
"review.thankYou": "Thank You!",
|
||||
"review.notAvailable": "Review Not Available",
|
||||
"review.alreadyReviewed": "This artefact has already been reviewed.",
|
||||
"review.statusLabel": "Status",
|
||||
"review.reviewedBy": "Reviewed by",
|
||||
"review.poweredBy": "Powered by Rawaj",
|
||||
"review.loadFailed": "Failed to load artefact",
|
||||
"review.actionFailed": "Action failed",
|
||||
"review.actionCompleted": "Action completed successfully",
|
||||
"review.enterName": "Please select or enter your name",
|
||||
"review.confirmApprove": "Approve this artefact?",
|
||||
"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",
|
||||
"review.googleDriveVideo": "Google Drive Video",
|
||||
"review.attachments": "Attachments",
|
||||
"review.previousComments": "Previous Comments",
|
||||
"review.version": "Version",
|
||||
|
||||
"common.failedToSave": "Failed to save",
|
||||
"common.copiedToClipboard": "Copied to clipboard!",
|
||||
"team.failedToSaveTeam": "Failed to save team",
|
||||
"posts.canOnlyEditOwn": "You can only edit your own posts",
|
||||
"assets.uploadFailed": "Upload failed",
|
||||
"assets.failedToDelete": "Failed to delete asset",
|
||||
"issues.failedToAddComment": "Failed to add comment",
|
||||
"issues.failedToUploadFile": "Failed to upload file",
|
||||
"issues.failedToSubmit": "Failed to submit issue. Please try again.",
|
||||
"issues.failedToUpdateStatus": "Failed to update status",
|
||||
"issues.failedToResolve": "Failed to resolve issue",
|
||||
"issues.failedToDecline": "Failed to decline issue",
|
||||
"issues.failedToUpdateAssignment": "Failed to update assignment",
|
||||
"issues.failedToSaveNotes": "Failed to save notes",
|
||||
"issues.failedToAddUpdate": "Failed to add update",
|
||||
"issues.failedToDeleteAttachment": "Failed to delete attachment",
|
||||
"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?",
|
||||
"artefacts.deleteAttachmentDesc": "This action cannot be undone.",
|
||||
"artefacts.deleteArtefact": "Delete this artefact?",
|
||||
"artefacts.deleteArtefactDesc": "This action cannot be undone.",
|
||||
"review.confirmApproveDesc": "Are you sure you want to approve this artefact?",
|
||||
"review.confirmRejectDesc": "Are you sure you want to reject this artefact?",
|
||||
|
||||
"common.selected": "selected",
|
||||
"common.deleteSelected": "Delete Selected",
|
||||
"common.clearSelection": "Clear selection",
|
||||
"common.bulkDeleteConfirm": "Delete {count} items?",
|
||||
"common.bulkDeleteDesc": "This action cannot be undone.",
|
||||
"common.selectAll": "Select all",
|
||||
|
||||
"issues.team": "Team",
|
||||
"issues.allTeams": "All Teams",
|
||||
"issues.copyPublicLink": "Copy Public Link",
|
||||
"issues.linkCopied": "Link copied!",
|
||||
"issues.selectTeam": "Select a team",
|
||||
"issues.publicSubmitTeam": "Which team should handle your issue?",
|
||||
"team.copyIssueLink": "Copy Issue Link",
|
||||
"team.copyGenericIssueLink": "Copy Public Issue Link",
|
||||
"team.permissionLevel": "Permission Level",
|
||||
"team.role": "Role",
|
||||
"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.",
|
||||
"settings.addRole": "Add Role",
|
||||
"settings.roleName": "Role name",
|
||||
"settings.roleColor": "Color",
|
||||
"settings.deleteRoleConfirm": "Are you sure you want to delete this role?",
|
||||
"settings.noRoles": "No roles defined yet. Add your first role.",
|
||||
|
||||
"header.dashboard": "Dashboard",
|
||||
"header.posts": "Post Production",
|
||||
"header.assets": "Assets",
|
||||
"header.campaigns": "Campaigns",
|
||||
"header.finance": "Finance",
|
||||
"header.projects": "Projects",
|
||||
"header.tasks": "My Tasks",
|
||||
"header.team": "Team",
|
||||
"header.calendar": "Post Calendar",
|
||||
"header.artefacts": "Artefacts",
|
||||
"header.brands": "Brands",
|
||||
"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",
|
||||
"header.page": "Page",
|
||||
"header.superadmin": "Superadmin",
|
||||
"header.manager": "Manager",
|
||||
"header.contributor": "Contributor",
|
||||
"header.passwordMismatch": "New passwords do not match",
|
||||
"header.passwordMinLength": "New password must be at least 6 characters",
|
||||
"header.passwordUpdateSuccess": "Password updated successfully",
|
||||
"header.passwordUpdateFailed": "Failed to change password",
|
||||
"header.userManagement": "User Management",
|
||||
"header.changePassword": "Change Password",
|
||||
"header.signOut": "Sign Out",
|
||||
"header.currentPassword": "Current Password",
|
||||
"header.newPassword": "New Password",
|
||||
"header.confirmNewPassword": "Confirm New Password",
|
||||
"header.updatePassword": "Update Password",
|
||||
"header.saving": "Saving...",
|
||||
|
||||
"issues.title": "Issues",
|
||||
"issues.subtitle": "Track and manage issue submissions",
|
||||
"issues.searchPlaceholder": "Search issues...",
|
||||
"issues.allStatuses": "All Statuses",
|
||||
"issues.allCategories": "All Categories",
|
||||
"issues.allTypes": "All Types",
|
||||
"issues.allBrands": "All Brands",
|
||||
"issues.allPriorities": "All Priorities",
|
||||
"issues.clearAll": "Clear All",
|
||||
"issues.noIssuesFound": "No issues found",
|
||||
"issues.tryAdjustingFilters": "Try adjusting your filters",
|
||||
"issues.noIssuesSubmitted": "No issues have been submitted yet",
|
||||
"issues.issuesDeleted": "Issues deleted",
|
||||
"issues.tableTitle": "Title",
|
||||
"issues.tableSubmitter": "Submitter",
|
||||
"issues.tableBrand": "Brand",
|
||||
"issues.tableCategory": "Category",
|
||||
"issues.tableType": "Type",
|
||||
"issues.tablePriority": "Priority",
|
||||
"issues.tableStatus": "Status",
|
||||
"issues.tableAssignedTo": "Assigned To",
|
||||
"issues.tableCreated": "Created",
|
||||
|
||||
"issues.typeRequest": "Request",
|
||||
"issues.typeCorrection": "Correction",
|
||||
"issues.typeComplaint": "Complaint",
|
||||
"issues.typeSuggestion": "Suggestion",
|
||||
"issues.typeOther": "Other",
|
||||
|
||||
"issues.priorityLow": "Low",
|
||||
"issues.priorityMedium": "Medium",
|
||||
"issues.priorityHigh": "High",
|
||||
"issues.priorityUrgent": "Urgent",
|
||||
|
||||
"issues.submitterInfo": "Submitter Information",
|
||||
"issues.nameLabel": "Name:",
|
||||
"issues.emailLabel": "Email:",
|
||||
"issues.phoneLabel": "Phone:",
|
||||
"issues.submittedLabel": "Submitted:",
|
||||
"issues.description": "Description",
|
||||
"issues.noDescription": "No description provided",
|
||||
"issues.assignedTo": "Assigned To",
|
||||
"issues.unassigned": "Unassigned",
|
||||
"issues.brandLabel": "Brand",
|
||||
"issues.noBrand": "No brand",
|
||||
"issues.internalNotes": "Internal Notes (Staff Only)",
|
||||
"issues.internalNotesPlaceholder": "Internal notes not visible to submitter...",
|
||||
"issues.resolutionSummary": "Resolution Summary (Public)",
|
||||
"issues.resolvedOn": "Resolved on",
|
||||
"issues.acknowledge": "Acknowledge",
|
||||
"issues.startWork": "Start Work",
|
||||
"issues.resolve": "Resolve",
|
||||
"issues.decline": "Decline",
|
||||
"issues.publicTrackingLink": "Public Tracking Link",
|
||||
"issues.updatesTimeline": "Updates Timeline",
|
||||
"issues.addUpdatePlaceholder": "Add an update...",
|
||||
"issues.makePublic": "Make public (visible to submitter)",
|
||||
"issues.addUpdate": "Add Update",
|
||||
"issues.noUpdates": "No updates yet",
|
||||
"issues.attachments": "Attachments",
|
||||
"issues.clickToUpload": "Click to upload file",
|
||||
"issues.uploading": "Uploading...",
|
||||
"issues.download": "Download",
|
||||
"issues.noAttachments": "No attachments",
|
||||
"issues.resolveIssue": "Resolve Issue",
|
||||
"issues.resolveSummaryHint": "Provide a resolution summary that will be visible to the submitter.",
|
||||
"issues.resolutionPlaceholder": "Explain how this issue was resolved...",
|
||||
"issues.markAsResolved": "Mark as Resolved",
|
||||
"issues.resolving": "Resolving...",
|
||||
"issues.declineIssue": "Decline Issue",
|
||||
"issues.declineReasonHint": "Provide a reason for declining this issue. This will be visible to the submitter.",
|
||||
"issues.declinePlaceholder": "Explain why this issue cannot be addressed...",
|
||||
"issues.declining": "Declining...",
|
||||
|
||||
"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",
|
||||
"artefacts.addLanguage": "Add Language",
|
||||
"artefacts.noLanguages": "No languages added yet",
|
||||
"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",
|
||||
"artefacts.noVideos": "No videos added yet",
|
||||
"artefacts.comments": "Comments",
|
||||
"artefacts.sendComment": "Send",
|
||||
"artefacts.addCommentPlaceholder": "Add a comment...",
|
||||
"artefacts.submitForReview": "Submit for Review",
|
||||
"artefacts.submitting": "Submitting...",
|
||||
"artefacts.reviewLinkTitle": "Review Link (expires in 7 days)",
|
||||
"artefacts.feedbackTitle": "Feedback",
|
||||
"artefacts.approvedByLabel": "Approved by",
|
||||
"artefacts.saveDraft": "Save",
|
||||
"artefacts.savingDraft": "Saving...",
|
||||
"artefacts.versionNotes": "Version Notes",
|
||||
"artefacts.whatChanged": "What changed in this version?",
|
||||
"artefacts.copyLanguages": "Copy languages from previous version",
|
||||
"artefacts.createVersion": "Create Version",
|
||||
"artefacts.creatingVersion": "Creating...",
|
||||
"artefacts.languageLabel": "Language",
|
||||
"artefacts.contentLabel": "Content",
|
||||
"artefacts.selectLanguage": "Select a language...",
|
||||
"artefacts.enterContent": "Enter the content in this language...",
|
||||
"artefacts.addVideoTitle": "Add Video",
|
||||
"artefacts.uploadFile": "Upload File",
|
||||
"artefacts.chooseVideoFile": "Choose video file",
|
||||
"artefacts.videoFormats": "MP4, MOV, AVI, etc.",
|
||||
"artefacts.dropOrClickVideo": "Drop a video here or click to browse",
|
||||
"artefacts.googleDriveLink": "Google Drive Link",
|
||||
"artefacts.googleDriveUrl": "Google Drive URL",
|
||||
"artefacts.driveUrlPlaceholder": "https://drive.google.com/file/d/...",
|
||||
"artefacts.publiclyAccessible": "Paste a Google Drive share link. Make sure the file is publicly accessible.",
|
||||
"artefacts.addLink": "Add Link",
|
||||
"artefacts.adding": "Adding...",
|
||||
"artefacts.googleDriveVideo": "Google Drive Video",
|
||||
"artefacts.deleteArtefactTooltip": "Delete artefact",
|
||||
"artefacts.saveDraftTooltip": "Save draft",
|
||||
"artefacts.createNewVersion": "Create New Version",
|
||||
"artefacts.failedLoadVersions": "Failed to load versions",
|
||||
"artefacts.failedLoadVersionData": "Failed to load version data",
|
||||
"artefacts.versionCreated": "New version created",
|
||||
"artefacts.failedCreateVersion": "Failed to create version",
|
||||
"artefacts.languageAdded": "Language added",
|
||||
"artefacts.allFieldsRequired": "All fields are required",
|
||||
"artefacts.failedAddLanguage": "Failed to add language",
|
||||
"artefacts.languageDeleted": "Language deleted",
|
||||
"artefacts.failedDeleteLanguage": "Failed to delete language",
|
||||
"artefacts.fileUploaded": "File uploaded",
|
||||
"artefacts.uploadFailed": "Upload failed",
|
||||
"artefacts.videoLinkAdded": "Video link added",
|
||||
"artefacts.failedAddVideoLink": "Failed to add video link",
|
||||
"artefacts.enterDriveUrl": "Please enter a Google Drive URL",
|
||||
"artefacts.attachmentDeleted": "Attachment deleted",
|
||||
"artefacts.failedDeleteAttachment": "Failed to delete attachment",
|
||||
"artefacts.submittedForReview": "Submitted for review!",
|
||||
"artefacts.failedSubmitReview": "Failed to submit for review",
|
||||
"artefacts.linkCopied": "Link copied to clipboard",
|
||||
"artefacts.commentAdded": "Comment added",
|
||||
"artefacts.failedAddComment": "Failed to add comment",
|
||||
"artefacts.updated": "Updated",
|
||||
"artefacts.failedUpdate": "Failed to update",
|
||||
"artefacts.draftSaved": "Draft saved",
|
||||
"artefacts.failedSaveDraft": "Failed to save draft",
|
||||
"artefacts.titleRequired": "Title is required",
|
||||
"artefacts.failedDelete": "Failed to delete",
|
||||
|
||||
"posts.images": "Images",
|
||||
"posts.audio": "Audio",
|
||||
"posts.videos": "Videos",
|
||||
"posts.otherFiles": "Other Files",
|
||||
"posts.addImage": "Add Image",
|
||||
"posts.addAudio": "Add Audio",
|
||||
"posts.addVideo": "Add Video",
|
||||
"posts.dragToUpload": "Drag files here to upload",
|
||||
"posts.assignedTo": "Assigned To",
|
||||
"posts.approval": "Approval",
|
||||
"posts.approvers": "Approvers",
|
||||
"posts.selectApprovers": "Select approvers...",
|
||||
"posts.scheduling": "Scheduling & Assignment",
|
||||
"posts.content": "Content",
|
||||
"posts.reject": "Reject",
|
||||
"posts.submittedForReview": "Post submitted for review",
|
||||
"posts.failedSubmitReview": "Failed to submit for review",
|
||||
"posts.reviewLinkCopied": "Review link copied!",
|
||||
"posts.reviewLinkTitle": "Review Link",
|
||||
"posts.awaitingReview": "Awaiting Review",
|
||||
"posts.awaitingReviewDesc": "This post is waiting for external approval.",
|
||||
"posts.approvedBy": "Approved by",
|
||||
"posts.rejectedBy": "Rejected by",
|
||||
"posts.submitting": "Submitting...",
|
||||
"posts.submitForReview": "Submit for Review",
|
||||
"posts.schedulePost": "Schedule Post",
|
||||
"review.postReview": "Post Review",
|
||||
"review.createdBy": "Created by",
|
||||
"review.confirmApprovePost": "Approve this post?",
|
||||
"review.confirmRejectPost": "Reject this post?",
|
||||
"review.confirmApprovePostDesc": "Are you sure you want to approve this post?",
|
||||
"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",
|
||||
"posts.createVersion": "Create Version",
|
||||
"posts.creatingVersion": "Creating...",
|
||||
"posts.whatChanged": "What changed in this version?",
|
||||
"posts.copyLanguages": "Copy languages from previous version",
|
||||
"posts.languages": "Languages",
|
||||
"posts.addLanguage": "Add Language",
|
||||
"posts.selectLanguage": "Select a language...",
|
||||
"posts.enterContent": "Enter the content in this language...",
|
||||
"posts.noLanguages": "No languages added yet",
|
||||
"posts.noVersions": "No versions yet. Create one to start managing multilingual content and media.",
|
||||
"posts.deleteLanguage": "Delete this language?",
|
||||
"posts.deleteLanguageConfirm": "This will remove the language content from this version.",
|
||||
"posts.media": "Media",
|
||||
"posts.noMedia": "No media files uploaded",
|
||||
|
||||
"nav.translations": "Translations",
|
||||
"translations.title": "Translations",
|
||||
"translations.subtitle": "Manage content translations with approval workflow",
|
||||
"translations.newTranslation": "New Translation",
|
||||
"translations.createTranslation": "Create Translation",
|
||||
"translations.searchTranslations": "Search translations...",
|
||||
"translations.titleLabel": "Title",
|
||||
"translations.titlePlaceholder": "e.g. Campaign tagline translation",
|
||||
"translations.sourceLanguage": "Source Language",
|
||||
"translations.sourceContent": "Source Content",
|
||||
"translations.sourceContentPlaceholder": "Enter the original content to translate...",
|
||||
"translations.description": "Description",
|
||||
"translations.descriptionLabel": "Description",
|
||||
"translations.descriptionPlaceholder": "Context or notes about this translation...",
|
||||
"translations.brand": "Brand",
|
||||
"translations.creator": "Creator",
|
||||
"translations.approvers": "Approvers",
|
||||
"translations.approversLabel": "Approvers",
|
||||
"translations.status": "Status",
|
||||
"translations.languagesLabel": "Languages",
|
||||
"translations.languagesCount": "languages",
|
||||
"translations.grid": "Grid",
|
||||
"translations.list": "List",
|
||||
"translations.allBrands": "All Brands",
|
||||
"translations.allStatuses": "All Statuses",
|
||||
"translations.allCreators": "All Creators",
|
||||
"translations.status.draft": "Draft",
|
||||
"translations.status.pendingReview": "Pending Review",
|
||||
"translations.status.approved": "Approved",
|
||||
"translations.status.rejected": "Rejected",
|
||||
"translations.status.revisionRequested": "Revision Requested",
|
||||
"translations.sortRecentlyUpdated": "Recently Updated",
|
||||
"translations.sortNewest": "Newest First",
|
||||
"translations.sortOldest": "Oldest First",
|
||||
"translations.sortTitleAZ": "Title A-Z",
|
||||
"translations.noTranslations": "No translations found",
|
||||
"translations.loadFailed": "Failed to load translations",
|
||||
"translations.titleRequired": "Title is required",
|
||||
"translations.sourceContentRequired": "Source content is required",
|
||||
"translations.created": "Translation created!",
|
||||
"translations.createFailed": "Failed to create translation",
|
||||
"translations.creating": "Creating...",
|
||||
"translations.deleted": "Translation deleted!",
|
||||
"translations.deleteFailed": "Failed to delete translation",
|
||||
"translations.details": "Details",
|
||||
"translations.translationTexts": "Translations",
|
||||
"translations.review": "Review",
|
||||
"translations.draftSaved": "Draft saved!",
|
||||
"translations.failedSaveDraft": "Failed to save draft",
|
||||
"translations.saveDraft": "Save Draft",
|
||||
"translations.saveDraftTooltip": "Save changes to title and source content",
|
||||
"translations.savingDraft": "Saving...",
|
||||
"translations.updated": "Updated",
|
||||
"translations.failedUpdate": "Failed to update",
|
||||
"translations.addTranslation": "Add Translation",
|
||||
"translations.translationAdded": "Translation added!",
|
||||
"translations.failedAddTranslation": "Failed to add translation",
|
||||
"translations.translationDeleted": "Translation deleted!",
|
||||
"translations.failedDeleteTranslation": "Failed to delete translation",
|
||||
"translations.noTranslationTexts": "No translations yet. Add one for each target language.",
|
||||
"translations.allFieldsRequired": "Language and content are required",
|
||||
"translations.languageLabel": "Language",
|
||||
"translations.selectLanguage": "Select a language",
|
||||
"translations.translatedContent": "Translated Content",
|
||||
"translations.enterTranslatedContent": "Enter the translated content...",
|
||||
"translations.deleteTranslation": "Delete Translation",
|
||||
"translations.deleteTranslationDesc": "This will permanently delete this translation and all its language versions.",
|
||||
"translations.deleteTranslationText": "Delete Translation Text",
|
||||
"translations.deleteTranslationTextDesc": "This will remove this language translation.",
|
||||
"translations.bulkDeleteDesc": "Delete selected translations?",
|
||||
"translations.submitForReview": "Submit for Review",
|
||||
"translations.submitting": "Submitting...",
|
||||
"translations.submittedForReview": "Submitted for review!",
|
||||
"translations.failedSubmitReview": "Failed to submit for review",
|
||||
"translations.reviewLinkTitle": "Review Link",
|
||||
"translations.linkCopied": "Link copied!",
|
||||
"translations.feedbackTitle": "Reviewer Feedback",
|
||||
"translations.approvedByLabel": "Approved by",
|
||||
"translations.pendingReviewInfo": "This translation is currently pending review.",
|
||||
"translations.noReviewInfo": "No review information available.",
|
||||
"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;
|
||||
@@ -36,6 +36,220 @@
|
||||
--color-status-cancelled: #dc2626;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════
|
||||
DARK MODE — Forest teal tinted surfaces
|
||||
═══════════════════════════════════════════════ */
|
||||
|
||||
.dark {
|
||||
/* 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 — warm neutrals, teal-tinted */
|
||||
--color-text-primary: #e8f0ee;
|
||||
--color-text-secondary: #9db5b0;
|
||||
--color-text-tertiary: #637e78;
|
||||
|
||||
/* Sidebar */
|
||||
--color-sidebar: #0a1412;
|
||||
--color-sidebar-hover: #0f1a18;
|
||||
--color-sidebar-active: #060e0c;
|
||||
|
||||
/* Brand — brighter on dark */
|
||||
--color-brand-primary: #14b8a6;
|
||||
--color-brand-primary-light: #2dd4bf;
|
||||
|
||||
color-scheme: dark;
|
||||
background-color: #0f1a18;
|
||||
color: #e8f0ee;
|
||||
}
|
||||
|
||||
/* ─── Ambient background glow ────────────────── */
|
||||
.dark .bg-mesh {
|
||||
background-color: #0f1a18 !important;
|
||||
background-image: none !important;
|
||||
}
|
||||
.dark .bg-mesh::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background:
|
||||
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;
|
||||
}
|
||||
|
||||
/* ─── Every white surface → elevated dark ────── */
|
||||
.dark .bg-white,
|
||||
.dark .bg-\[\#fff\],
|
||||
.dark .bg-\[\#ffffff\] {
|
||||
background-color: #1a2a28 !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,
|
||||
.dark .border-gray-200,
|
||||
.dark .border-gray-300 { border-color: rgba(255, 255, 255, 0.08) !important; }
|
||||
.dark .divide-gray-100 > :not(:first-child),
|
||||
.dark .divide-gray-200 > :not(:first-child),
|
||||
.dark .divide-border-light > :not(:first-child) { border-color: rgba(255, 255, 255, 0.05) !important; }
|
||||
|
||||
/* ─── Text ───────────────────────────────────── */
|
||||
.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; }
|
||||
.dark .bg-blue-100, .dark .bg-blue-50 { background-color: rgba(96, 165, 250, 0.12) !important; }
|
||||
.dark .bg-amber-100, .dark .bg-amber-50 { background-color: rgba(251, 191, 36, 0.12) !important; }
|
||||
.dark .bg-red-100, .dark .bg-red-50 { background-color: rgba(251, 113, 133, 0.12) !important; }
|
||||
.dark .bg-purple-100, .dark .bg-purple-50 { background-color: rgba(167, 139, 250, 0.12) !important; }
|
||||
.dark .bg-orange-100 { background-color: rgba(251, 146, 60, 0.12) !important; }
|
||||
.dark .bg-indigo-100, .dark .bg-indigo-50 { background-color: rgba(129, 140, 248, 0.12) !important; }
|
||||
.dark .bg-pink-100 { background-color: rgba(244, 114, 182, 0.12) !important; }
|
||||
.dark .bg-cyan-100 { background-color: rgba(34, 211, 238, 0.12) !important; }
|
||||
.dark .bg-teal-100 { background-color: rgba(45, 212, 191, 0.12) !important; }
|
||||
|
||||
/* Status text colors — brighter on dark */
|
||||
.dark .text-emerald-700 { color: #4ade80 !important; }
|
||||
.dark .text-emerald-600 { color: #4ade80 !important; }
|
||||
.dark .text-blue-700 { color: #60a5fa !important; }
|
||||
.dark .text-amber-700 { color: #fbbf24 !important; }
|
||||
.dark .text-amber-900 { color: #fbbf24 !important; }
|
||||
.dark .text-red-700, .dark .text-red-600, .dark .text-red-500 { color: #fb7185 !important; }
|
||||
.dark .text-purple-700 { color: #a78bfa !important; }
|
||||
.dark .text-orange-700 { color: #fb923c !important; }
|
||||
.dark .text-indigo-700 { color: #818cf8 !important; }
|
||||
|
||||
/* Gradient backgrounds for team sections etc. */
|
||||
.dark .from-blue-50 { --tw-gradient-from: rgba(96, 165, 250, 0.06) !important; }
|
||||
.dark .to-indigo-50 { --tw-gradient-to: rgba(129, 140, 248, 0.06) !important; }
|
||||
.dark .bg-gradient-to-r.from-blue-50 { background: rgba(96, 165, 250, 0.06) !important; }
|
||||
|
||||
/* ─── Form elements ──────────────────────────── */
|
||||
.dark input,
|
||||
.dark select,
|
||||
.dark textarea {
|
||||
background-color: rgba(255, 255, 255, 0.04);
|
||||
color: #eeecf5;
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.dark input:focus,
|
||||
.dark select:focus,
|
||||
.dark textarea:focus {
|
||||
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: #637e78;
|
||||
}
|
||||
.dark input:disabled,
|
||||
.dark select:disabled,
|
||||
.dark textarea:disabled {
|
||||
background-color: rgba(255, 255, 255, 0.02) !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='%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);
|
||||
}
|
||||
.dark .card-hover:hover {
|
||||
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: #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 4px 16px -4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.dark .section-card-header {
|
||||
background: rgba(30, 46, 43, 0.3);
|
||||
}
|
||||
|
||||
/* ─── Sidebar ────────────────────────────────── */
|
||||
.dark .sidebar {
|
||||
background: linear-gradient(180deg, #0a1412 0%, #060e0c 100%);
|
||||
box-shadow: 2px 0 24px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* ─── Scrollbar ──────────────────────────────── */
|
||||
.dark ::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.08); }
|
||||
.dark ::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.15); }
|
||||
|
||||
/* ─── Shadows ────────────────────────────────── */
|
||||
.dark .shadow-sm { box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4) !important; }
|
||||
.dark .shadow { box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4) !important; }
|
||||
.dark .shadow-lg { box-shadow: 0 12px 40px -10px rgba(0, 0, 0, 0.6) !important; }
|
||||
.dark .shadow-2xl { box-shadow: 0 20px 60px -15px rgba(0, 0, 0, 0.7) !important; }
|
||||
|
||||
/* ─── Modal backdrop ─────────────────────────── */
|
||||
.dark .bg-black\/40 { background-color: rgba(0, 0, 0, 0.7) !important; }
|
||||
|
||||
/* ─── Hover states ───────────────────────────── */
|
||||
.dark .hover\:bg-surface-secondary:hover,
|
||||
.dark .hover\:bg-gray-50:hover,
|
||||
.dark .hover\:bg-gray-100:hover { background-color: rgba(255, 255, 255, 0.04) !important; }
|
||||
.dark .hover\:bg-red-50:hover { background-color: rgba(251, 113, 133, 0.08) !important; }
|
||||
.dark .hover\:bg-blue-100:hover { background-color: rgba(96, 165, 250, 0.08) !important; }
|
||||
|
||||
/* ─── Brand accent ────────────────────────────── */
|
||||
.dark .bg-brand-primary {
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.dark .bg-brand-primary:hover {
|
||||
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(22, 34, 32, 0.9) !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; }
|
||||
.dark .text-amber-800 { color: #fcd34d !important; }
|
||||
|
||||
/* ─── Selection ──────────────────────────────── */
|
||||
.dark ::selection {
|
||||
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;
|
||||
@@ -52,12 +266,10 @@
|
||||
background: #94a3b8;
|
||||
}
|
||||
|
||||
/* Smooth transitions */
|
||||
* {
|
||||
transition-property: background-color, border-color, color, opacity, box-shadow, transform;
|
||||
transition-duration: 200ms;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
/* Smooth transitions — scoped to interactive elements only.
|
||||
Do NOT use * selector — it causes every element to re-animate
|
||||
on any React state change (e.g. drag-and-drop). Components should
|
||||
use Tailwind transition-colors / transition-all where needed. */
|
||||
|
||||
/* Arabic text support */
|
||||
[dir="rtl"] {
|
||||
@@ -110,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); }
|
||||
}
|
||||
|
||||
@@ -142,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); }
|
||||
@@ -216,29 +423,28 @@ textarea {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Stagger children */
|
||||
.collapsible-content.is-open > .collapsible-inner {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* 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 */
|
||||
@@ -261,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;
|
||||
@@ -289,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 */
|
||||
@@ -310,38 +504,30 @@ 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 */
|
||||
button {
|
||||
border-radius: 0.625rem;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
button:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px -2px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
button:active:not(:disabled) {
|
||||
transform: translateY(0) scale(0.98);
|
||||
transition: background-color 0.2s, border-color 0.2s, color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
@@ -386,34 +572,12 @@ select:not(:disabled):hover {
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Kanban column */
|
||||
.kanban-column {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
/* Calendar grid */
|
||||
.calendar-grid {
|
||||
display: grid;
|
||||
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);
|
||||
|
||||
@@ -3,10 +3,15 @@ import { Plus, Upload, Search, FolderOpen, ChevronRight, Grid3X3, X } from 'luci
|
||||
import { api } from '../utils/api'
|
||||
import AssetCard from '../components/AssetCard'
|
||||
import Modal from '../components/Modal'
|
||||
import BulkSelectBar from '../components/BulkSelectBar'
|
||||
import CommentsSection from '../components/CommentsSection'
|
||||
import { SkeletonAssetGrid } from '../components/SkeletonLoader'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import { useToast } from '../components/ToastContainer'
|
||||
|
||||
export default function Assets() {
|
||||
const { t } = useLanguage()
|
||||
const toast = useToast()
|
||||
const [assets, setAssets] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [filters, setFilters] = useState({ brand: '', tag: '', folder: '', search: '' })
|
||||
@@ -18,13 +23,15 @@ export default function Assets() {
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [assetToDelete, setAssetToDelete] = useState(null)
|
||||
const fileRef = useRef(null)
|
||||
const [selectedIds, setSelectedIds] = useState(new Set())
|
||||
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false)
|
||||
|
||||
useEffect(() => { loadAssets() }, [])
|
||||
|
||||
const loadAssets = async () => {
|
||||
try {
|
||||
const res = await api.get('/assets')
|
||||
const assetsData = res.data || res || []
|
||||
const assetsData = Array.isArray(res) ? res : []
|
||||
// Map assets to include URL for thumbnails
|
||||
const assetsWithUrls = assetsData.map(asset => ({
|
||||
...asset,
|
||||
@@ -91,7 +98,7 @@ export default function Assets() {
|
||||
setUploadProgress(0)
|
||||
} catch (err) {
|
||||
console.error('Upload failed:', err)
|
||||
alert('Upload failed: ' + err.message)
|
||||
toast.error(t('assets.uploadFailed') + ': ' + err.message)
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
@@ -111,10 +118,36 @@ export default function Assets() {
|
||||
loadAssets()
|
||||
} catch (err) {
|
||||
console.error('Delete failed:', err)
|
||||
alert('Failed to delete asset')
|
||||
toast.error(t('assets.failedToDelete'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
try {
|
||||
await api.post('/assets/bulk-delete', { ids: [...selectedIds] })
|
||||
setSelectedIds(new Set())
|
||||
setShowBulkDeleteConfirm(false)
|
||||
loadAssets()
|
||||
} catch (err) {
|
||||
console.error('Bulk delete failed:', err)
|
||||
toast.error(t('common.deleteFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSelect = (id) => {
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedIds.size === filteredAssets.length) setSelectedIds(new Set())
|
||||
else setSelectedIds(new Set(filteredAssets.map(a => a._id || a.id)))
|
||||
}
|
||||
|
||||
const handleDrop = (e) => {
|
||||
e.preventDefault()
|
||||
setDragOver(false)
|
||||
@@ -148,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>)}
|
||||
@@ -170,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>)}
|
||||
@@ -178,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
|
||||
@@ -212,6 +245,10 @@ export default function Assets() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedIds.size > 0 && (
|
||||
<BulkSelectBar selectedCount={selectedIds.size} onDelete={() => setShowBulkDeleteConfirm(true)} onClear={() => setSelectedIds(new Set())} />
|
||||
)}
|
||||
|
||||
{/* Asset grid */}
|
||||
{filteredAssets.length === 0 ? (
|
||||
<div className="py-20 text-center">
|
||||
@@ -222,7 +259,10 @@ 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}>
|
||||
<div key={asset._id || asset.id} className="relative">
|
||||
<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} />
|
||||
</div>
|
||||
))}
|
||||
@@ -279,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 && (
|
||||
@@ -334,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>
|
||||
@@ -343,6 +383,18 @@ export default function Assets() {
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
isOpen={showBulkDeleteConfirm}
|
||||
onClose={() => setShowBulkDeleteConfirm(false)}
|
||||
title={t('common.bulkDeleteConfirm').replace('{count}', selectedIds.size)}
|
||||
isConfirm
|
||||
danger
|
||||
confirmText={t('common.deleteSelected')}
|
||||
onConfirm={handleBulkDelete}
|
||||
>
|
||||
{t('common.bulkDeleteDesc')}
|
||||
</Modal>
|
||||
|
||||
{/* Delete Asset Confirmation */}
|
||||
<Modal
|
||||
isOpen={showDeleteConfirm}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { AppContext } from '../App'
|
||||
import Modal from '../components/Modal'
|
||||
import { SkeletonCard } from '../components/SkeletonLoader'
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001/api'
|
||||
const API_BASE = '/api'
|
||||
|
||||
const EMPTY_BRAND = { name: '', name_ar: '', priority: 2, icon: '' }
|
||||
|
||||
@@ -34,7 +34,7 @@ export default function Brands() {
|
||||
const loadBrands = async () => {
|
||||
try {
|
||||
const data = await api.get('/brands')
|
||||
setBrands(Array.isArray(data) ? data : (data.data || []))
|
||||
setBrands(Array.isArray(data) ? data : [])
|
||||
} catch (err) {
|
||||
console.error('Failed to load brands:', err)
|
||||
} finally {
|
||||
@@ -129,13 +129,7 @@ export default function Brands() {
|
||||
<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 flex items-center gap-3">
|
||||
<Tag className="w-7 h-7 text-brand-primary" />
|
||||
{t('brands.title')}
|
||||
</h1>
|
||||
<p className="text-sm text-text-tertiary mt-1">{t('brands.manageBrands')}</p>
|
||||
</div>
|
||||
<p className="text-sm text-text-tertiary">{t('brands.manageBrands')}</p>
|
||||
{isSuperadminOrManager && (
|
||||
<button
|
||||
onClick={openNewBrand}
|
||||
@@ -149,64 +143,63 @@ 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>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 stagger-children">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 stagger-children">
|
||||
{brands.map(brand => {
|
||||
const displayName = lang === 'ar' && brand.name_ar ? brand.name_ar : brand.name
|
||||
return (
|
||||
<div
|
||||
key={getBrandId(brand)}
|
||||
className={`bg-white rounded-xl border border-border overflow-hidden hover:shadow-md transition-all ${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 */}
|
||||
<div className="h-32 bg-surface-secondary flex items-center justify-center relative">
|
||||
<div className="flex-1 bg-surface-secondary flex items-center justify-center relative min-h-0">
|
||||
{brand.logo ? (
|
||||
<img
|
||||
src={`${API_BASE}/uploads/${brand.logo}`}
|
||||
alt={displayName}
|
||||
className="w-full h-full object-contain p-4"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-4xl">
|
||||
{brand.icon || <Image className="w-12 h-12 text-text-quaternary" />}
|
||||
<div className="text-3xl">
|
||||
{brand.icon || <Image className="w-10 h-10 text-text-quaternary" />}
|
||||
</div>
|
||||
)}
|
||||
{isSuperadminOrManager && (
|
||||
<div className="absolute top-2 right-2 flex gap-1" 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.5 bg-white/90 hover:bg-white rounded-lg 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.5 h-3.5" />
|
||||
<Edit2 className="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setBrandToDelete(brand); setShowDeleteModal(true) }}
|
||||
className="p-1.5 bg-white/90 hover:bg-white rounded-lg 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.5 h-3.5" />
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Card body */}
|
||||
<div className="p-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{brand.icon && <span className="text-lg">{brand.icon}</span>}
|
||||
<h3 className="text-sm font-semibold text-text-primary truncate">{displayName}</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-[11px] text-text-tertiary">
|
||||
{brand.name && <span>EN: {brand.name}</span>}
|
||||
{brand.name_ar && <span>AR: {brand.name_ar}</span>}
|
||||
<span>Priority: {brand.priority ?? '—'}</span>
|
||||
<div className="p-3">
|
||||
<div className="flex items-center gap-1.5 mb-0.5">
|
||||
{brand.icon && <span className="text-sm">{brand.icon}</span>}
|
||||
<h3 className="text-xs font-semibold text-text-primary truncate">{displayName}</h3>
|
||||
</div>
|
||||
<p className="text-[10px] text-text-tertiary truncate">
|
||||
{brand.name_ar && lang !== 'ar' ? brand.name_ar : brand.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -277,6 +270,7 @@ export default function Brands() {
|
||||
src={`${API_BASE}/uploads/${editingBrand.logo}`}
|
||||
alt="Logo"
|
||||
className="h-16 object-contain"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -305,7 +299,16 @@ export default function Brands() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
|
||||
<div className="flex items-center justify-between pt-4 border-t border-border">
|
||||
{editingBrand && isSuperadminOrManager ? (
|
||||
<button
|
||||
onClick={() => { setShowModal(false); setBrandToDelete(editingBrand); setShowDeleteModal(true) }}
|
||||
className="px-3 py-2 text-sm font-medium text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||
>
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
) : <div />}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => { setShowModal(false); setEditingBrand(null) }}
|
||||
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
|
||||
@@ -321,6 +324,7 @@ export default function Brands() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
|
||||
@@ -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" />
|
||||
@@ -410,7 +406,6 @@ export default function Budgets() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('budgets.category')}</label>
|
||||
<select
|
||||
@@ -421,14 +416,15 @@ export default function Budgets() {
|
||||
{CATEGORIES.map(c => <option key={c.value} value={c.value}>{c.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('budgets.linkedTo')}</label>
|
||||
<div className="flex gap-2">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<select
|
||||
value={form.campaign_id}
|
||||
onChange={e => setForm(f => ({ ...f, campaign_id: e.target.value, project_id: '' }))}
|
||||
disabled={!!form.project_id}
|
||||
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg focus:outline-none disabled:opacity-50 disabled:bg-surface-secondary"
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none disabled:opacity-50 disabled:bg-surface-secondary"
|
||||
>
|
||||
<option value="">{t('budgets.noCampaign')}</option>
|
||||
{campaigns.map(c => <option key={c._id || c.id} value={c._id || c.id}>{c.name}</option>)}
|
||||
@@ -437,14 +433,13 @@ export default function Budgets() {
|
||||
value={form.project_id}
|
||||
onChange={e => setForm(f => ({ ...f, project_id: e.target.value, campaign_id: '' }))}
|
||||
disabled={!!form.campaign_id}
|
||||
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg focus:outline-none disabled:opacity-50 disabled:bg-surface-secondary"
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none disabled:opacity-50 disabled:bg-surface-secondary"
|
||||
>
|
||||
<option value="">{t('budgets.noProject')}</option>
|
||||
{projects.map(p => <option key={p._id || p.id} value={p._id || p.id}>{p.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('budgets.notes')}</label>
|
||||
|
||||
@@ -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([])
|
||||
|
||||
@@ -71,7 +59,7 @@ export default function CampaignDetail() {
|
||||
|
||||
useEffect(() => { loadAll() }, [id])
|
||||
useEffect(() => {
|
||||
api.get('/campaigns').then(r => setAllCampaigns(Array.isArray(r) ? r : (r.data || []))).catch(() => {})
|
||||
api.get('/campaigns').then(r => setAllCampaigns(Array.isArray(r) ? r : [])).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const loadAll = async () => {
|
||||
@@ -82,10 +70,10 @@ export default function CampaignDetail() {
|
||||
api.get(`/campaigns/${id}/posts`),
|
||||
api.get(`/campaigns/${id}/assignments`),
|
||||
])
|
||||
setCampaign(campRes.data || campRes || null)
|
||||
setTracks(tracksRes.data || tracksRes || [])
|
||||
setPosts(postsRes.data || postsRes || [])
|
||||
setAssignments(Array.isArray(assignRes) ? assignRes : (assignRes.data || []))
|
||||
setCampaign(campRes)
|
||||
setTracks(Array.isArray(tracksRes) ? tracksRes : [])
|
||||
setPosts(Array.isArray(postsRes) ? postsRes : [])
|
||||
setAssignments(Array.isArray(assignRes) ? assignRes : [])
|
||||
} catch (err) {
|
||||
console.error('Failed to load campaign:', err)
|
||||
} finally {
|
||||
@@ -96,7 +84,7 @@ export default function CampaignDetail() {
|
||||
const loadUsersForAssign = async () => {
|
||||
try {
|
||||
const users = await api.get('/users/team?all=true')
|
||||
setAllUsers(Array.isArray(users) ? users : (users.data || []))
|
||||
setAllUsers(Array.isArray(users) ? users : [])
|
||||
} catch (err) {
|
||||
console.error('Failed to load users:', err)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -12,8 +12,14 @@ import BrandBadge from '../components/BrandBadge'
|
||||
import BudgetBar from '../components/BudgetBar'
|
||||
import InteractiveTimeline from '../components/InteractiveTimeline'
|
||||
import CampaignDetailPanel from '../components/CampaignDetailPanel'
|
||||
import Modal from '../components/Modal'
|
||||
import { SkeletonStatCard, SkeletonTable } from '../components/SkeletonLoader'
|
||||
|
||||
const EMPTY_CAMPAIGN = {
|
||||
name: '', description: '', brand_id: '', status: 'planning',
|
||||
start_date: '', end_date: '', budget: '', team_id: '',
|
||||
}
|
||||
|
||||
function ROIBadge({ revenue, spent }) {
|
||||
if (!spent || spent <= 0) return null
|
||||
const roi = ((revenue - spent) / spent * 100).toFixed(0)
|
||||
@@ -36,21 +42,24 @@ function MetricCard({ icon: Icon, label, value, color = 'text-text-primary' }) {
|
||||
}
|
||||
|
||||
export default function Campaigns() {
|
||||
const { brands, getBrandName } = useContext(AppContext)
|
||||
const { lang, currencySymbol } = useLanguage()
|
||||
const { brands, getBrandName, teams } = useContext(AppContext)
|
||||
const { t, lang, currencySymbol } = useLanguage()
|
||||
const { permissions } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [campaigns, setCampaigns] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [panelCampaign, setPanelCampaign] = useState(null)
|
||||
const [filters, setFilters] = useState({ brand: '', status: '' })
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const [createForm, setCreateForm] = useState({ ...EMPTY_CAMPAIGN })
|
||||
const [createSaving, setCreateSaving] = useState(false)
|
||||
|
||||
useEffect(() => { loadCampaigns() }, [])
|
||||
|
||||
const loadCampaigns = async () => {
|
||||
try {
|
||||
const res = await api.get('/campaigns')
|
||||
setCampaigns(res.data || res || [])
|
||||
setCampaigns(Array.isArray(res) ? res : [])
|
||||
} catch (err) {
|
||||
console.error('Failed to load campaigns:', err)
|
||||
} finally {
|
||||
@@ -73,7 +82,34 @@ export default function Campaigns() {
|
||||
}
|
||||
|
||||
const openNew = () => {
|
||||
setPanelCampaign({ status: 'planning', platforms: [] })
|
||||
setCreateForm({ ...EMPTY_CAMPAIGN })
|
||||
setShowCreateModal(true)
|
||||
}
|
||||
|
||||
const handleCreate = async () => {
|
||||
setCreateSaving(true)
|
||||
try {
|
||||
const data = {
|
||||
name: createForm.name,
|
||||
description: createForm.description,
|
||||
brand_id: createForm.brand_id ? Number(createForm.brand_id) : null,
|
||||
status: createForm.status,
|
||||
start_date: createForm.start_date || null,
|
||||
end_date: createForm.end_date || null,
|
||||
budget: createForm.budget ? Number(createForm.budget) : null,
|
||||
team_id: createForm.team_id ? Number(createForm.team_id) : null,
|
||||
}
|
||||
const created = await api.post('/campaigns', data)
|
||||
setShowCreateModal(false)
|
||||
loadCampaigns()
|
||||
// Navigate to the new campaign detail page
|
||||
const id = created?.Id || created?.id || created?._id
|
||||
if (id) navigate(`/campaigns/${id}`)
|
||||
} catch (err) {
|
||||
console.error('Create campaign failed:', err)
|
||||
} finally {
|
||||
setCreateSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const filtered = campaigns.filter(c => {
|
||||
@@ -109,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>)}
|
||||
@@ -118,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>
|
||||
@@ -131,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
|
||||
@@ -142,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>
|
||||
@@ -150,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>
|
||||
@@ -158,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>
|
||||
@@ -202,6 +238,7 @@ export default function Campaigns() {
|
||||
status: campaign.status,
|
||||
assigneeName: campaign.brandName || campaign.brand_name,
|
||||
tags: campaign.platforms || [],
|
||||
color: campaign.color,
|
||||
})}
|
||||
onDateChange={async (campaignId, { startDate, endDate }) => {
|
||||
try {
|
||||
@@ -212,13 +249,22 @@ export default function Campaigns() {
|
||||
loadCampaigns()
|
||||
}
|
||||
}}
|
||||
onColorChange={async (campaignId, color) => {
|
||||
try {
|
||||
await api.patch(`/campaigns/${campaignId}`, { color: color || '' })
|
||||
} catch (err) {
|
||||
console.error('Color update failed:', err)
|
||||
} finally {
|
||||
loadCampaigns()
|
||||
}
|
||||
}}
|
||||
onItemClick={(campaign) => {
|
||||
navigate(`/campaigns/${campaign._id || campaign.id}`)
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 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>
|
||||
@@ -262,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 ? (
|
||||
@@ -285,7 +331,62 @@ export default function Campaigns() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Campaign Panel */}
|
||||
{/* Create Campaign Modal */}
|
||||
<Modal isOpen={showCreateModal} onClose={() => setShowCreateModal(false)} title={t('campaigns.newCampaign') || 'New Campaign'} size="md">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.name')} *</label>
|
||||
<input type="text" value={createForm.name} onChange={e => setCreateForm(f => ({ ...f, name: 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>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.description')}</label>
|
||||
<textarea value={createForm.description} onChange={e => setCreateForm(f => ({ ...f, description: e.target.value }))} rows={2}
|
||||
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" />
|
||||
</div>
|
||||
<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 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 || b._id} value={b.id || 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('common.team')}</label>
|
||||
<select value={createForm.team_id} onChange={e => setCreateForm(f => ({ ...f, team_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('common.noTeam')}</option>
|
||||
{(teams || []).map(team => <option key={team.id || team._id} value={team.id || team._id}>{team.name}</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('campaigns.startDate')}</label>
|
||||
<input type="date" value={createForm.start_date} onChange={e => setCreateForm(f => ({ ...f, start_date: 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" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.endDate')}</label>
|
||||
<input type="date" value={createForm.end_date} onChange={e => setCreateForm(f => ({ ...f, end_date: 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" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.budget')}</label>
|
||||
<input type="number" value={createForm.budget} onChange={e => setCreateForm(f => ({ ...f, budget: 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="0" />
|
||||
</div>
|
||||
<button onClick={handleCreate} disabled={!createForm.name || 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('campaigns.newCampaign') || 'Create Campaign'}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Campaign Panel (edit only) */}
|
||||
{panelCampaign && (
|
||||
<CampaignDetailPanel
|
||||
campaign={panelCampaign}
|
||||
|
||||
@@ -1,11 +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'
|
||||
@@ -17,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">
|
||||
@@ -48,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>
|
||||
</>
|
||||
)}
|
||||
@@ -145,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>
|
||||
)
|
||||
})}
|
||||
@@ -161,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">
|
||||
@@ -186,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">
|
||||
@@ -202,7 +145,7 @@ function MyTasksList({ tasks, currentUserId, navigate, t }) {
|
||||
{format(new Date(task.dueDate), 'MMM d')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
@@ -260,10 +203,85 @@ 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([])
|
||||
const [tasks, setTasks] = useState([])
|
||||
@@ -271,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('')
|
||||
@@ -282,18 +299,29 @@ export default function Dashboard() {
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [postsRes, campaignsRes, tasksRes, financeRes, projectsRes] = await Promise.allSettled([
|
||||
api.get('/posts?limit=50&sort=-createdAt'),
|
||||
api.get('/campaigns'),
|
||||
api.get('/tasks'),
|
||||
api.get('/finance/summary'),
|
||||
api.get('/projects'),
|
||||
])
|
||||
setPosts(postsRes.status === 'fulfilled' ? (postsRes.value.data || postsRes.value || []) : [])
|
||||
setCampaigns(campaignsRes.status === 'fulfilled' ? (campaignsRes.value.data || campaignsRes.value || []) : [])
|
||||
setTasks(tasksRes.status === 'fulfilled' ? (tasksRes.value.data || tasksRes.value || []) : [])
|
||||
setFinance(financeRes.status === 'fulfilled' ? (financeRes.value.data || financeRes.value || null) : null)
|
||||
setProjects(projectsRes.status === 'fulfilled' ? (projectsRes.value.data || projectsRes.value || []) : [])
|
||||
const fetches = []
|
||||
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 : [] })))
|
||||
}
|
||||
if (hasModule('projects')) {
|
||||
fetches.push(api.get('/tasks').then(r => ({ key: 'tasks', data: Array.isArray(r) ? r : [] })))
|
||||
fetches.push(api.get('/projects').then(r => ({ key: 'projects', data: Array.isArray(r) ? r : [] })))
|
||||
}
|
||||
if (hasModule('finance')) {
|
||||
fetches.push(api.get('/finance/summary').then(r => ({ key: 'finance', data: r || null })))
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(fetches)
|
||||
results.forEach(r => {
|
||||
if (r.status !== 'fulfilled') return
|
||||
const { key, data } = r.value
|
||||
if (key === 'posts') setPosts(data)
|
||||
else if (key === 'campaigns') setCampaigns(data)
|
||||
else if (key === 'tasks') setTasks(data)
|
||||
else if (key === 'projects') setProjects(data)
|
||||
else if (key === 'finance') setFinance(data)
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Dashboard load error:', err)
|
||||
} finally {
|
||||
@@ -301,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 => {
|
||||
@@ -329,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)
|
||||
@@ -337,24 +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])
|
||||
|
||||
if (loading) {
|
||||
return <SkeletonDashboard />
|
||||
// Inline stat values — no card component needed
|
||||
const stats = []
|
||||
if (hasModule('marketing')) {
|
||||
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')) {
|
||||
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 />
|
||||
|
||||
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) }}
|
||||
@@ -362,122 +392,51 @@ export default function Dashboard() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 stagger-children">
|
||||
<StatCard
|
||||
icon={FileText}
|
||||
label={t('dashboard.totalPosts')}
|
||||
value={filteredPosts.length || 0}
|
||||
subtitle={`${filteredPosts.filter(p => p.status === 'published').length} ${t('dashboard.published')}`}
|
||||
color="brand-primary"
|
||||
/>
|
||||
<StatCard
|
||||
icon={Megaphone}
|
||||
label={t('dashboard.activeCampaigns')}
|
||||
value={activeCampaigns}
|
||||
subtitle={`${campaigns.length} ${t('dashboard.total')}`}
|
||||
color="brand-secondary"
|
||||
/>
|
||||
<StatCard
|
||||
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"
|
||||
/>
|
||||
<StatCard
|
||||
icon={AlertTriangle}
|
||||
label={t('dashboard.overdueTasks')}
|
||||
value={overdueTasks}
|
||||
subtitle={overdueTasks > 0 ? t('dashboard.needsAttention') : t('dashboard.allOnTrack')}
|
||||
color="brand-quaternary"
|
||||
/>
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* My Tasks + Project Progress */}
|
||||
{hasModule('projects') && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<MyTasksList tasks={filteredTasks} currentUserId={currentUser?.id || currentUser?._id} navigate={navigate} t={t} />
|
||||
<ProjectProgress projects={projects} tasks={tasks} t={t} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Budget + Active Campaigns */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<FinanceMini finance={finance} />
|
||||
<div className="lg:col-span-2">
|
||||
{(hasModule('finance') || hasModule('marketing')) && (
|
||||
<div className={`grid grid-cols-1 ${hasModule('finance') && hasModule('marketing') ? 'lg:grid-cols-3' : ''} gap-6`}>
|
||||
{hasModule('finance') && <BudgetSummary finance={finance} />}
|
||||
{hasModule('marketing') && (
|
||||
<div className={hasModule('finance') ? 'lg:col-span-2' : ''}>
|
||||
<ActiveCampaignsList campaigns={campaigns} finance={finance} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Posts + Upcoming Deadlines */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Recent Posts */}
|
||||
<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 */}
|
||||
<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>
|
||||
<StatusBadge status={task.status} size="xs" />
|
||||
</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>
|
||||
|
||||
{/* Activity — merged posts + deadlines */}
|
||||
{(hasModule('marketing') || hasModule('projects')) && (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
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('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [sent, setSent] = useState(false)
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
await api.post('/auth/forgot-password', { email })
|
||||
setSent(true)
|
||||
} catch (err) {
|
||||
setError(err.message || t('forgotPassword.error'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<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-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>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-800/50 backdrop-blur-sm rounded-2xl border border-slate-700/50 p-8 shadow-2xl">
|
||||
{sent ? (
|
||||
<div className="text-center space-y-4">
|
||||
<div className="w-12 h-12 bg-green-500/20 rounded-full flex items-center justify-center mx-auto">
|
||||
<CheckCircle className="w-6 h-6 text-green-400" />
|
||||
</div>
|
||||
<p className="text-slate-300 text-sm">{t('forgotPassword.success')}</p>
|
||||
<Link
|
||||
to="/login"
|
||||
className="inline-flex items-center gap-2 text-sm text-blue-400 hover:text-blue-300 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
{t('forgotPassword.backToLogin')}
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-2">{t('auth.email')}</label>
|
||||
<div className="relative">
|
||||
<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 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
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
|
||||
<AlertCircle className="w-5 h-5 text-red-400 shrink-0" />
|
||||
<p className="text-sm text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
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">
|
||||
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
{t('forgotPassword.sending')}
|
||||
</span>
|
||||
) : (
|
||||
t('forgotPassword.submit')
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="text-center">
|
||||
<Link
|
||||
to="/login"
|
||||
className="inline-flex items-center gap-2 text-sm text-slate-400 hover:text-slate-300 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
{t('forgotPassword.backToLogin')}
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,64 +1,59 @@
|
||||
import { useState, useEffect, useContext, useMemo } from 'react'
|
||||
import { AlertCircle, Search, LayoutGrid, List, ChevronUp, ChevronDown } from 'lucide-react'
|
||||
import { AlertCircle, Search, LayoutGrid, List, ChevronUp, ChevronDown, Link2 } from 'lucide-react'
|
||||
import { AppContext } from '../App'
|
||||
import { api } from '../utils/api'
|
||||
import { api, STATUS_CONFIG, PRIORITY_CONFIG } from '../utils/api'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import { useToast } from '../components/ToastContainer'
|
||||
import IssueDetailPanel from '../components/IssueDetailPanel'
|
||||
import IssueCard from '../components/IssueCard'
|
||||
import KanbanBoard from '../components/KanbanBoard'
|
||||
import KanbanCard from '../components/KanbanCard'
|
||||
import EmptyState from '../components/EmptyState'
|
||||
import { SkeletonTable, SkeletonKanbanBoard } from '../components/SkeletonLoader'
|
||||
import BulkSelectBar from '../components/BulkSelectBar'
|
||||
import Modal from '../components/Modal'
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: 'request', label: 'Request' },
|
||||
{ value: 'correction', label: 'Correction' },
|
||||
{ value: 'complaint', label: 'Complaint' },
|
||||
{ value: 'suggestion', label: 'Suggestion' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
const TYPE_OPTION_KEYS = [
|
||||
{ value: 'request', labelKey: 'issues.typeRequest' },
|
||||
{ value: 'correction', labelKey: 'issues.typeCorrection' },
|
||||
{ value: 'complaint', labelKey: 'issues.typeComplaint' },
|
||||
{ value: 'suggestion', labelKey: 'issues.typeSuggestion' },
|
||||
{ value: 'other', labelKey: 'issues.typeOther' },
|
||||
]
|
||||
|
||||
const PRIORITY_CONFIG = {
|
||||
low: { label: 'Low', bg: 'bg-surface-tertiary', text: 'text-text-secondary', dot: 'bg-text-tertiary' },
|
||||
medium: { label: 'Medium', bg: 'bg-blue-100', text: 'text-blue-700', dot: 'bg-blue-500' },
|
||||
high: { label: 'High', bg: 'bg-orange-100', text: 'text-orange-700', dot: 'bg-orange-500' },
|
||||
urgent: { label: 'Urgent', bg: 'bg-red-100', text: 'text-red-700', dot: 'bg-red-500' },
|
||||
// Issue-specific status order for the kanban board
|
||||
const ISSUE_STATUS_CONFIG = {
|
||||
new: STATUS_CONFIG.new,
|
||||
acknowledged: STATUS_CONFIG.acknowledged,
|
||||
in_progress: STATUS_CONFIG.in_progress,
|
||||
resolved: STATUS_CONFIG.resolved,
|
||||
declined: STATUS_CONFIG.declined,
|
||||
}
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
new: { label: 'New', bg: 'bg-purple-100', text: 'text-purple-700', dot: 'bg-purple-500' },
|
||||
acknowledged: { label: 'Acknowledged', bg: 'bg-blue-100', text: 'text-blue-700', dot: 'bg-blue-500' },
|
||||
in_progress: { label: 'In Progress', bg: 'bg-amber-100', text: 'text-amber-700', dot: 'bg-amber-500' },
|
||||
resolved: { label: 'Resolved', bg: 'bg-emerald-100', text: 'text-emerald-700', dot: 'bg-emerald-500' },
|
||||
declined: { label: 'Declined', bg: 'bg-surface-tertiary', text: 'text-text-secondary', dot: 'bg-text-tertiary' },
|
||||
}
|
||||
|
||||
const STATUS_ORDER = ['new', 'acknowledged', 'in_progress', 'resolved', 'declined']
|
||||
|
||||
export default function Issues() {
|
||||
const { t } = useLanguage()
|
||||
const toast = useToast()
|
||||
const { brands } = useContext(AppContext)
|
||||
const { brands, teams } = useContext(AppContext)
|
||||
|
||||
const [issues, setIssues] = useState([])
|
||||
const [counts, setCounts] = useState({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [selectedIssue, setSelectedIssue] = useState(null)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [filters, setFilters] = useState({ status: '', category: '', type: '', priority: '', brand: '' })
|
||||
const [filters, setFilters] = useState({ status: '', category: '', type: '', priority: '', brand: '', team: '' })
|
||||
const [categories, setCategories] = useState([])
|
||||
const [teamMembers, setTeamMembers] = useState([])
|
||||
|
||||
// View mode
|
||||
const [viewMode, setViewMode] = useState('board')
|
||||
|
||||
// Drag and drop
|
||||
const [draggedIssue, setDraggedIssue] = useState(null)
|
||||
const [dragOverCol, setDragOverCol] = useState(null)
|
||||
|
||||
// List sorting
|
||||
const [sortBy, setSortBy] = useState('created_at')
|
||||
const [sortDir, setSortDir] = useState('desc')
|
||||
|
||||
const [selectedIds, setSelectedIds] = useState(new Set())
|
||||
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false)
|
||||
|
||||
useEffect(() => { loadData() }, [])
|
||||
|
||||
const loadData = async () => {
|
||||
@@ -72,7 +67,7 @@ export default function Issues() {
|
||||
setIssues(issuesData.issues || [])
|
||||
setCounts(issuesData.counts || {})
|
||||
setCategories(categoriesData || [])
|
||||
setTeamMembers(Array.isArray(teamData) ? teamData : teamData.data || [])
|
||||
setTeamMembers(Array.isArray(teamData) ? teamData : [])
|
||||
} catch (err) {
|
||||
console.error('Failed to load issues:', err)
|
||||
} finally {
|
||||
@@ -97,6 +92,7 @@ export default function Issues() {
|
||||
if (filters.type) filtered = filtered.filter(i => i.type === filters.type)
|
||||
if (filters.priority) filtered = filtered.filter(i => i.priority === filters.priority)
|
||||
if (filters.brand) filtered = filtered.filter(i => String(i.brand_id) === String(filters.brand))
|
||||
if (filters.team) filtered = filtered.filter(i => String(i.team_id) === String(filters.team))
|
||||
return filtered
|
||||
}, [issues, searchTerm, filters])
|
||||
|
||||
@@ -121,7 +117,7 @@ export default function Issues() {
|
||||
|
||||
const updateFilter = (key, value) => setFilters(f => ({ ...f, [key]: value }))
|
||||
const clearFilters = () => {
|
||||
setFilters({ status: '', category: '', type: '', priority: '', brand: '' })
|
||||
setFilters({ status: '', category: '', type: '', priority: '', brand: '', team: '' })
|
||||
setSearchTerm('')
|
||||
}
|
||||
const hasActiveFilters = Object.values(filters).some(Boolean) || searchTerm
|
||||
@@ -139,41 +135,57 @@ export default function Issues() {
|
||||
|
||||
// Drag and drop handlers
|
||||
const handleMoveIssue = async (issueId, newStatus) => {
|
||||
// Optimistic update — move the card instantly
|
||||
const prev = issues
|
||||
setIssues(issues.map(i => (i.Id || i.id) === issueId ? { ...i, status: newStatus } : i))
|
||||
setCounts(c => {
|
||||
const old = prev.find(i => (i.Id || i.id) === issueId)
|
||||
if (!old || old.status === newStatus) return c
|
||||
return { ...c, [old.status]: (c[old.status] || 1) - 1, [newStatus]: (c[newStatus] || 0) + 1 }
|
||||
})
|
||||
try {
|
||||
await api.patch(`/issues/${issueId}`, { status: newStatus })
|
||||
toast.success(t('issues.statusUpdated'))
|
||||
loadData()
|
||||
} catch (err) {
|
||||
console.error('Move issue failed:', err)
|
||||
toast.error('Failed to update status')
|
||||
toast.error(t('issues.failedToUpdateStatus'))
|
||||
// Rollback on error
|
||||
setIssues(prev)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragStart = (e, issue) => {
|
||||
setDraggedIssue(issue)
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
setTimeout(() => { if (e.target) e.target.style.opacity = '0.4' }, 0)
|
||||
const handleBulkDelete = async () => {
|
||||
try {
|
||||
await api.post('/issues/bulk-delete', { ids: [...selectedIds] })
|
||||
toast.success(t('issues.issuesDeleted'))
|
||||
setSelectedIds(new Set())
|
||||
setShowBulkDeleteConfirm(false)
|
||||
loadData()
|
||||
} catch (err) {
|
||||
console.error('Bulk delete failed:', err)
|
||||
toast.error(t('common.deleteFailed'))
|
||||
}
|
||||
const handleDragEnd = (e) => {
|
||||
e.target.style.opacity = '1'
|
||||
setDraggedIssue(null)
|
||||
setDragOverCol(null)
|
||||
}
|
||||
const handleDragOver = (e, colStatus) => {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
setDragOverCol(colStatus)
|
||||
|
||||
const toggleSelect = (id) => {
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
const handleDragLeave = (e) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget)) setDragOverCol(null)
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedIds.size === sortedIssues.length) setSelectedIds(new Set())
|
||||
else setSelectedIds(new Set(sortedIssues.map(i => i.Id || i.id)))
|
||||
}
|
||||
const handleDrop = (e, colStatus) => {
|
||||
e.preventDefault()
|
||||
setDragOverCol(null)
|
||||
if (draggedIssue && draggedIssue.status !== colStatus) {
|
||||
handleMoveIssue(draggedIssue.Id || draggedIssue.id, colStatus)
|
||||
}
|
||||
setDraggedIssue(null)
|
||||
|
||||
const copyPublicLink = () => {
|
||||
const base = `${window.location.origin}/submit-issue`
|
||||
const url = filters.team ? `${base}?team=${filters.team}` : base
|
||||
navigator.clipboard.writeText(url)
|
||||
toast.success(t('issues.linkCopied'))
|
||||
}
|
||||
|
||||
const toggleSort = (col) => {
|
||||
@@ -184,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) {
|
||||
@@ -199,14 +211,16 @@ 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" />
|
||||
Issues
|
||||
</h1>
|
||||
<p className="text-text-secondary mt-1">Track and manage issue submissions</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-end">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={copyPublicLink}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium border border-border rounded-lg hover:bg-surface-secondary transition-colors text-text-secondary"
|
||||
title={t('issues.copyPublicLink')}
|
||||
>
|
||||
<Link2 className="w-3.5 h-3.5" />
|
||||
{t('issues.copyPublicLink')}
|
||||
</button>
|
||||
|
||||
{/* View switcher */}
|
||||
<div className="flex items-center bg-surface-tertiary rounded-lg p-0.5">
|
||||
@@ -219,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'
|
||||
}`}
|
||||
>
|
||||
@@ -229,10 +243,11 @@ export default function Issues() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Counts */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 stagger-children">
|
||||
{Object.entries(STATUS_CONFIG).map(([status, config]) => (
|
||||
{Object.entries(ISSUE_STATUS_CONFIG).map(([status, config]) => (
|
||||
<div
|
||||
key={status}
|
||||
className={`bg-surface rounded-lg border p-4 cursor-pointer hover:shadow-sm transition-all ${
|
||||
@@ -253,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="Search issues..."
|
||||
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>
|
||||
|
||||
@@ -268,8 +283,8 @@ export default function Issues() {
|
||||
onChange={e => updateFilter('status', e.target.value)}
|
||||
className="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="">All Statuses</option>
|
||||
{Object.entries(STATUS_CONFIG).map(([key, config]) => (
|
||||
<option value="">{t('issues.allStatuses')}</option>
|
||||
{Object.entries(ISSUE_STATUS_CONFIG).map(([key, config]) => (
|
||||
<option key={key} value={key}>{config.label}</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -279,7 +294,7 @@ export default function Issues() {
|
||||
onChange={e => updateFilter('category', e.target.value)}
|
||||
className="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="">All Categories</option>
|
||||
<option value="">{t('issues.allCategories')}</option>
|
||||
{categories.map(cat => <option key={cat} value={cat}>{cat}</option>)}
|
||||
</select>
|
||||
|
||||
@@ -288,8 +303,8 @@ export default function Issues() {
|
||||
onChange={e => updateFilter('type', e.target.value)}
|
||||
className="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="">All Types</option>
|
||||
{TYPE_OPTIONS.map(opt => <option key={opt.value} value={opt.value}>{opt.label}</option>)}
|
||||
<option value="">{t('issues.allTypes')}</option>
|
||||
{TYPE_OPTION_KEYS.map(opt => <option key={opt.value} value={opt.value}>{t(opt.labelKey)}</option>)}
|
||||
</select>
|
||||
|
||||
<select
|
||||
@@ -297,18 +312,29 @@ export default function Issues() {
|
||||
onChange={e => updateFilter('brand', e.target.value)}
|
||||
className="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="">All Brands</option>
|
||||
<option value="">{t('issues.allBrands')}</option>
|
||||
{(brands || []).map(b => (
|
||||
<option key={b._id || b.Id} value={b._id || b.Id}>{b.name}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={filters.team || ''}
|
||||
onChange={e => updateFilter('team', e.target.value)}
|
||||
className="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="">{t('issues.allTeams')}</option>
|
||||
{(teams || []).map(tm => (
|
||||
<option key={tm.id || tm.Id} value={tm.id || tm.Id}>{tm.name}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={filters.priority}
|
||||
onChange={e => updateFilter('priority', e.target.value)}
|
||||
className="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="">All Priorities</option>
|
||||
<option value="">{t('issues.allPriorities')}</option>
|
||||
{Object.entries(PRIORITY_CONFIG).map(([key, config]) => (
|
||||
<option key={key} value={key}>{config.label}</option>
|
||||
))}
|
||||
@@ -316,7 +342,7 @@ export default function Issues() {
|
||||
|
||||
{hasActiveFilters && (
|
||||
<button onClick={clearFilters} className="px-3 py-2 rounded-lg text-sm font-medium text-text-tertiary hover:text-text-primary">
|
||||
Clear All
|
||||
{t('issues.clearAll')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -326,58 +352,32 @@ export default function Issues() {
|
||||
filteredIssues.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title="No issues found"
|
||||
description={hasActiveFilters ? 'Try adjusting your filters' : 'No issues have been submitted yet'}
|
||||
title={t('issues.noIssuesFound')}
|
||||
description={hasActiveFilters ? t('issues.tryAdjustingFilters') : t('issues.noIssuesSubmitted')}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex gap-4 overflow-x-auto pb-4">
|
||||
{STATUS_ORDER.map(status => {
|
||||
const config = STATUS_CONFIG[status]
|
||||
const columnIssues = filteredIssues.filter(i => i.status === status)
|
||||
return (
|
||||
<div
|
||||
key={status}
|
||||
className={`flex-shrink-0 w-72 rounded-xl border transition-colors ${
|
||||
dragOverCol === status ? 'border-brand-primary bg-brand-primary/5' : 'border-border bg-surface-secondary/50'
|
||||
}`}
|
||||
onDragOver={e => handleDragOver(e, status)}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={e => handleDrop(e, status)}
|
||||
>
|
||||
{/* Column header */}
|
||||
<div className="px-3 py-3 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`w-2.5 h-2.5 rounded-full ${config.dot}`} />
|
||||
<span className="text-sm font-semibold text-text-primary">{config.label}</span>
|
||||
<span className="text-xs bg-surface-tertiary text-text-tertiary px-1.5 py-0.5 rounded-full font-medium">
|
||||
{columnIssues.length}
|
||||
<KanbanBoard
|
||||
columns={Object.entries(ISSUE_STATUS_CONFIG).map(([id, cfg]) => ({ id, label: cfg.label, color: cfg.dot }))}
|
||||
items={filteredIssues}
|
||||
getItemId={(i) => i.Id || i.id}
|
||||
onMove={handleMoveIssue}
|
||||
emptyLabel={t('issues.noIssuesInColumn')}
|
||||
renderCard={(issue) => (
|
||||
<KanbanCard
|
||||
title={issue.title}
|
||||
thumbnail={issue.thumbnail_url}
|
||||
brandName={issue.brand_name}
|
||||
assigneeName={issue.submitter_name}
|
||||
date={issue.created_at || issue.CreatedAt}
|
||||
onClick={() => setSelectedIssue(issue)}
|
||||
tags={issue.category && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-surface-tertiary text-text-secondary font-medium">
|
||||
{issue.category}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cards */}
|
||||
<div className="p-2 space-y-2 min-h-[120px]">
|
||||
{columnIssues.length === 0 ? (
|
||||
<div className="text-center py-6 text-xs text-text-tertiary">
|
||||
{t('issues.noIssuesInColumn')}
|
||||
</div>
|
||||
) : (
|
||||
columnIssues.map(issue => (
|
||||
<div
|
||||
key={issue.Id || issue.id}
|
||||
draggable
|
||||
onDragStart={e => handleDragStart(e, issue)}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<IssueCard issue={issue} onClick={setSelectedIssue} />
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
@@ -386,31 +386,41 @@ export default function Issues() {
|
||||
sortedIssues.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title="No issues found"
|
||||
description={hasActiveFilters ? 'Try adjusting your filters' : 'No issues have been submitted yet'}
|
||||
title={t('issues.noIssuesFound')}
|
||||
description={hasActiveFilters ? t('issues.tryAdjustingFilters') : t('issues.noIssuesSubmitted')}
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-surface rounded-lg border border-border overflow-hidden">
|
||||
{selectedIds.size > 0 && (
|
||||
<BulkSelectBar
|
||||
selectedCount={selectedIds.size}
|
||||
onClearSelection={() => setSelectedIds(new Set())}
|
||||
onDelete={() => setShowBulkDeleteConfirm(true)}
|
||||
/>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-surface-secondary border-b border-border">
|
||||
<tr>
|
||||
<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')}>
|
||||
Title <SortIcon col="title" />
|
||||
<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">Submitter</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">Brand</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">Category</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">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={() => toggleSort('priority')}>
|
||||
Priority <SortIcon col="priority" />
|
||||
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleSort('title')}>
|
||||
{t('issues.tableTitle')} <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={() => toggleSort('status')}>
|
||||
Status <SortIcon col="status" />
|
||||
<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">Assigned To</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')}>
|
||||
Created <SortIcon col="created_at" />
|
||||
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleSort('status')}>
|
||||
{t('issues.tableStatus')} <SortIcon col="status" />
|
||||
</th>
|
||||
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('issues.tableAssignedTo')}</th>
|
||||
<th className="px-4 py-3 text-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>
|
||||
</thead>
|
||||
@@ -424,6 +434,9 @@ export default function Issues() {
|
||||
onClick={() => setSelectedIssue(issue)}
|
||||
className="hover:bg-surface-secondary cursor-pointer transition-colors"
|
||||
>
|
||||
<td className="w-10 px-3 py-3" onClick={e => e.stopPropagation()}>
|
||||
<input type="checkbox" checked={selectedIds.has(issue.Id || issue.id)} onChange={() => toggleSelect(issue.Id || issue.id)} className="rounded border-border" />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm font-medium text-text-primary">{issue.title}</td>
|
||||
<td className="px-4 py-3 text-sm text-text-secondary">
|
||||
<div>{issue.submitter_name}</div>
|
||||
@@ -433,7 +446,7 @@ export default function Issues() {
|
||||
<td className="px-4 py-3 text-sm text-text-secondary">{issue.category || '—'}</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
<span className="text-xs px-2 py-1 rounded-full bg-surface-tertiary text-text-secondary">
|
||||
{TYPE_OPTIONS.find(t => t.value === issue.type)?.label || issue.type}
|
||||
{(() => { const opt = TYPE_OPTION_KEYS.find(o => o.value === issue.type); return opt ? t(opt.labelKey) : issue.type })()}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
@@ -459,6 +472,19 @@ export default function Issues() {
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Bulk Delete Confirm */}
|
||||
<Modal
|
||||
isOpen={showBulkDeleteConfirm}
|
||||
onClose={() => setShowBulkDeleteConfirm(false)}
|
||||
title={t('common.bulkDeleteConfirm').replace('{count}', selectedIds.size)}
|
||||
isConfirm
|
||||
danger
|
||||
confirmText={t('common.deleteSelected')}
|
||||
onConfirm={handleBulkDelete}
|
||||
>
|
||||
{t('common.bulkDeleteDesc')}
|
||||
</Modal>
|
||||
|
||||
{/* Detail Panel */}
|
||||
{selectedIssue && (
|
||||
<IssueDetailPanel
|
||||
@@ -466,6 +492,7 @@ export default function Issues() {
|
||||
onClose={() => setSelectedIssue(null)}
|
||||
onUpdate={loadData}
|
||||
teamMembers={teamMembers}
|
||||
teams={teams}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
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()
|
||||
@@ -34,7 +43,7 @@ export default function Login() {
|
||||
await login(email, password)
|
||||
navigate('/')
|
||||
} catch (err) {
|
||||
setError(err.message || 'Invalid email or password')
|
||||
setError(err.message || t('login.invalidCredentials'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -44,7 +53,7 @@ export default function Login() {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
if (setupPassword !== setupConfirm) {
|
||||
setError('Passwords do not match')
|
||||
setError(t('login.passwordMismatch'))
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
@@ -55,7 +64,7 @@ export default function Login() {
|
||||
setNeedsSetup(false)
|
||||
setEmail(setupEmail)
|
||||
} catch (err) {
|
||||
setError(err.message || 'Setup failed')
|
||||
setError(err.message || t('login.setupFailed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -63,25 +72,25 @@ 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 ? 'Initial Setup' : t('login.title')}
|
||||
{needsSetup ? t('login.initialSetup') : t('login.title')}
|
||||
</h1>
|
||||
<p className="text-slate-400">
|
||||
{needsSetup ? 'Create your superadmin account to get started' : t('login.subtitle')}
|
||||
{needsSetup ? t('login.initialSetupDesc') : t('login.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -89,7 +98,7 @@ export default function Login() {
|
||||
{setupDone && (
|
||||
<div className="flex items-center gap-2 p-3 mb-4 bg-green-500/10 border border-green-500/30 rounded-lg">
|
||||
<CheckCircle className="w-5 h-5 text-green-400 shrink-0" />
|
||||
<p className="text-sm text-green-400">Account created. You can now log in.</p>
|
||||
<p className="text-sm text-green-400">{t('login.accountCreated')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -99,32 +108,33 @@ export default function Login() {
|
||||
<form onSubmit={handleSetup} className="space-y-5">
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-2">Name</label>
|
||||
<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"
|
||||
placeholder="Your name"
|
||||
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>
|
||||
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-2">Email</label>
|
||||
<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
|
||||
/>
|
||||
@@ -133,15 +143,15 @@ export default function Login() {
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-2">Password</label>
|
||||
<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"
|
||||
placeholder="Choose a strong password"
|
||||
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}
|
||||
/>
|
||||
@@ -150,15 +160,15 @@ export default function Login() {
|
||||
|
||||
{/* Confirm Password */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-2">Confirm Password</label>
|
||||
<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"
|
||||
placeholder="Re-enter your password"
|
||||
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,15 +187,15 @@ 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">
|
||||
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
Creating account...
|
||||
{t('login.creatingAccount')}
|
||||
</span>
|
||||
) : (
|
||||
'Create Superadmin Account'
|
||||
t('login.createAccount')
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
@@ -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">
|
||||
@@ -259,7 +271,9 @@ export default function Login() {
|
||||
{!needsSetup && (
|
||||
<div className="mt-6 pt-6 border-t border-slate-700/50">
|
||||
<p className="text-xs text-slate-500 text-center">
|
||||
<Link to="/forgot-password" className="hover:text-slate-300 transition-colors underline">
|
||||
{t('login.forgotPassword')}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useState, useEffect, useContext } from 'react'
|
||||
import { ChevronLeft, ChevronRight, Calendar as CalendarIcon } from 'lucide-react'
|
||||
import { ChevronLeft, ChevronRight, Calendar as CalendarIcon, CalendarDays } from 'lucide-react'
|
||||
import { AppContext } from '../App'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
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',
|
||||
@@ -39,6 +39,19 @@ function getMonthData(year, month) {
|
||||
return cells
|
||||
}
|
||||
|
||||
function getWeekData(startDate) {
|
||||
const cells = []
|
||||
const start = new Date(startDate)
|
||||
// Align to Sunday
|
||||
start.setDate(start.getDate() - start.getDay())
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const d = new Date(start)
|
||||
d.setDate(start.getDate() + i)
|
||||
cells.push({ day: d.getDate(), current: true, date: d })
|
||||
}
|
||||
return cells
|
||||
}
|
||||
|
||||
function dateKey(d) {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
@@ -53,6 +66,10 @@ export default function PostCalendar() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [filters, setFilters] = useState({ brand: '', platform: '', status: '' })
|
||||
const [selectedPost, setSelectedPost] = useState(null)
|
||||
const [calView, setCalView] = useState('month') // 'month' | 'week'
|
||||
const [weekStart, setWeekStart] = useState(() => {
|
||||
const d = new Date(); d.setDate(d.getDate() - d.getDay()); return d
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
loadPosts()
|
||||
@@ -61,7 +78,7 @@ export default function PostCalendar() {
|
||||
const loadPosts = async () => {
|
||||
try {
|
||||
const res = await api.get('/posts')
|
||||
setPosts(res.data || res || [])
|
||||
setPosts(Array.isArray(res) ? res : [])
|
||||
} catch (err) {
|
||||
console.error('Failed to load posts:', err)
|
||||
} finally {
|
||||
@@ -69,7 +86,7 @@ export default function PostCalendar() {
|
||||
}
|
||||
}
|
||||
|
||||
const cells = getMonthData(year, month)
|
||||
const cells = calView === 'month' ? getMonthData(year, month) : getWeekData(weekStart)
|
||||
const todayKey = dateKey(today)
|
||||
|
||||
// Filter posts
|
||||
@@ -105,9 +122,22 @@ export default function PostCalendar() {
|
||||
if (month === 11) { setMonth(0); setYear(y => y + 1) }
|
||||
else setMonth(m => m + 1)
|
||||
}
|
||||
const goToday = () => { setYear(today.getFullYear()); setMonth(today.getMonth()) }
|
||||
const prevWeek = () => setWeekStart(d => { const n = new Date(d); n.setDate(n.getDate() - 7); return n })
|
||||
const nextWeek = () => setWeekStart(d => { const n = new Date(d); n.setDate(n.getDate() + 7); return n })
|
||||
|
||||
const goToday = () => {
|
||||
setYear(today.getFullYear()); setMonth(today.getMonth())
|
||||
const d = new Date(); d.setDate(d.getDate() - d.getDay()); setWeekStart(d)
|
||||
}
|
||||
|
||||
const monthLabel = new Date(year, month).toLocaleString('default', { month: 'long', year: 'numeric' })
|
||||
const weekLabel = (() => {
|
||||
const start = new Date(weekStart)
|
||||
start.setDate(start.getDate() - start.getDay())
|
||||
const end = new Date(start); end.setDate(start.getDate() + 6)
|
||||
const fmt = (d) => d.toLocaleString('default', { month: 'short', day: 'numeric' })
|
||||
return `${fmt(start)} – ${fmt(end)}, ${end.getFullYear()}`
|
||||
})()
|
||||
|
||||
const handlePostClick = (post) => {
|
||||
setSelectedPost(post)
|
||||
@@ -128,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
|
||||
@@ -172,28 +194,48 @@ 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">
|
||||
<button onClick={prevMonth} className="p-2 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors">
|
||||
<button onClick={calView === 'month' ? prevMonth : prevWeek} className="p-2 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors">
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<h3 className="text-lg font-semibold text-text-primary min-w-[180px] text-center">{monthLabel}</h3>
|
||||
<button onClick={nextMonth} className="p-2 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors">
|
||||
<h3 className="text-lg font-semibold text-text-primary min-w-[220px] text-center">
|
||||
{calView === 'month' ? monthLabel : weekLabel}
|
||||
</h3>
|
||||
<button onClick={calView === 'month' ? nextMonth : nextWeek} className="p-2 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors">
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</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
|
||||
<div className="flex items-center gap-2">
|
||||
<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-surface shadow-sm text-text-primary' : 'text-text-tertiary'}`}
|
||||
>
|
||||
<CalendarIcon className="w-3.5 h-3.5" />
|
||||
{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-surface shadow-sm text-text-primary' : 'text-text-tertiary'}`}
|
||||
>
|
||||
<CalendarDays className="w-3.5 h-3.5" />
|
||||
{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">
|
||||
{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>
|
||||
@@ -207,7 +249,7 @@ export default function PostCalendar() {
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`border-r border-b border-border min-h-[110px] p-2 ${
|
||||
className={`border-r border-b border-border ${calView === 'week' ? 'min-h-[300px]' : 'min-h-[110px]'} p-2 ${
|
||||
cell.current ? 'bg-surface' : 'bg-surface-secondary/30'
|
||||
} ${i % 7 === 6 ? 'border-r-0' : ''}`}
|
||||
>
|
||||
@@ -217,11 +259,11 @@ export default function PostCalendar() {
|
||||
{cell.day}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{dayPosts.slice(0, 3).map(post => (
|
||||
{dayPosts.slice(0, calView === 'week' ? 10 : 3).map(post => (
|
||||
<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}
|
||||
@@ -229,9 +271,9 @@ export default function PostCalendar() {
|
||||
{post.title}
|
||||
</button>
|
||||
))}
|
||||
{dayPosts.length > 3 && (
|
||||
{dayPosts.length > (calView === 'week' ? 10 : 3) && (
|
||||
<div className="text-[9px] text-text-tertiary text-center font-medium">
|
||||
+{dayPosts.length - 3} more
|
||||
+{dayPosts.length - (calView === 'week' ? 10 : 3)} more
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -244,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'}`}>
|
||||
@@ -269,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,15 +1,18 @@
|
||||
import { useState, useEffect, useContext } from 'react'
|
||||
import { Plus, LayoutGrid, List, Search, X, FileText } from 'lucide-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'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
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 Modal from '../components/Modal'
|
||||
import BulkSelectBar from '../components/BulkSelectBar'
|
||||
import { useToast } from '../components/ToastContainer'
|
||||
|
||||
const EMPTY_POST = {
|
||||
@@ -20,28 +23,31 @@ const EMPTY_POST = {
|
||||
|
||||
export default function PostProduction() {
|
||||
const { t, lang } = useLanguage()
|
||||
const { teamMembers, brands } = useContext(AppContext)
|
||||
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('')
|
||||
const [activePreset, setActivePreset] = useState('')
|
||||
const [moveError, setMoveError] = useState('')
|
||||
const [selectedIds, setSelectedIds] = useState(new Set())
|
||||
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false)
|
||||
const [showFilters, setShowFilters] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadPosts()
|
||||
api.get('/campaigns').then(r => setCampaigns(Array.isArray(r) ? r : (r.data || []))).catch(() => {})
|
||||
api.get('/campaigns').then(r => setCampaigns(Array.isArray(r) ? r : [])).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const loadPosts = async () => {
|
||||
try {
|
||||
const res = await api.get('/posts')
|
||||
setPosts(res.data || res || [])
|
||||
setPosts(Array.isArray(res) ? res : [])
|
||||
} catch (err) {
|
||||
console.error('Failed to load posts:', err)
|
||||
} finally {
|
||||
@@ -50,12 +56,15 @@ export default function PostProduction() {
|
||||
}
|
||||
|
||||
const handleMovePost = async (postId, newStatus) => {
|
||||
// Optimistic update — move the card instantly
|
||||
const prev = posts
|
||||
setPosts(posts.map(p => p._id === postId ? { ...p, status: newStatus } : p))
|
||||
try {
|
||||
await api.patch(`/posts/${postId}`, { status: newStatus })
|
||||
toast.success(t('posts.statusUpdated'))
|
||||
loadPosts()
|
||||
} catch (err) {
|
||||
console.error('Move failed:', err)
|
||||
setPosts(prev)
|
||||
if (err.message?.includes('Cannot publish')) {
|
||||
setMoveError(t('posts.publishRequired'))
|
||||
setTimeout(() => setMoveError(''), 5000)
|
||||
@@ -66,17 +75,6 @@ export default function PostProduction() {
|
||||
}
|
||||
}
|
||||
|
||||
const handlePanelSave = async (postId, data) => {
|
||||
if (postId) {
|
||||
await api.patch(`/posts/${postId}`, data)
|
||||
toast.success(t('posts.updated'))
|
||||
} else {
|
||||
await api.post('/posts', data)
|
||||
toast.success(t('posts.created'))
|
||||
}
|
||||
loadPosts()
|
||||
}
|
||||
|
||||
const handlePanelDelete = async (postId) => {
|
||||
try {
|
||||
await api.delete(`/posts/${postId}`)
|
||||
@@ -88,19 +86,50 @@ export default function PostProduction() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
try {
|
||||
await api.post('/posts/bulk-delete', { ids: [...selectedIds] })
|
||||
toast.success(t('posts.deleted'))
|
||||
setSelectedIds(new Set())
|
||||
setShowBulkDeleteConfirm(false)
|
||||
loadPosts()
|
||||
} catch (err) {
|
||||
console.error('Bulk delete failed:', err)
|
||||
toast.error(t('common.deleteFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSelect = (id) => {
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedIds.size === filteredPosts.length) setSelectedIds(new Set())
|
||||
else setSelectedIds(new Set(filteredPosts.map(p => p._id || p.id || p.Id)))
|
||||
}
|
||||
|
||||
const openEdit = (post) => {
|
||||
if (!canEditResource('post', post)) {
|
||||
alert('You can only edit your own posts')
|
||||
return
|
||||
}
|
||||
setPanelPost(post)
|
||||
const postId = post._id || post.id || post.Id
|
||||
navigate(`/posts/${postId}`)
|
||||
}
|
||||
|
||||
const openNew = () => {
|
||||
setPanelPost(EMPTY_POST)
|
||||
const openNew = async () => {
|
||||
try {
|
||||
const result = await api.post('/posts', { title: '', status: 'draft', platforms: [] })
|
||||
const newId = result._id || result.id || result.Id
|
||||
toast.success(t('posts.created'))
|
||||
navigate(`/posts/${newId}`)
|
||||
} catch {
|
||||
toast.error(t('common.saveFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -114,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} />
|
||||
@@ -123,85 +152,41 @@ export default function PostProduction() {
|
||||
return (
|
||||
<div className="space-y-4 animate-fade-in">
|
||||
{/* Toolbar */}
|
||||
<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>
|
||||
|
||||
<div data-tutorial="filters" className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<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"
|
||||
<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-surface text-text-secondary hover:border-brand-primary/40'}`}
|
||||
>
|
||||
<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>
|
||||
<Filter className="w-4 h-4" />
|
||||
{t('common.filter')}
|
||||
{(filters.brand || filters.platform || filters.assignedTo || filters.periodFrom || filters.periodTo) && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-brand-primary" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<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"
|
||||
>
|
||||
<option value="">{t('posts.allPlatforms')}</option>
|
||||
{Object.entries(PLATFORMS).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
|
||||
</select>
|
||||
|
||||
<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"
|
||||
>
|
||||
<option value="">{t('posts.allPeople')}</option>
|
||||
{teamMembers.map(m => <option key={m._id} value={String(m._id)}>{m.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<DatePresetPicker
|
||||
activePreset={activePreset}
|
||||
onSelect={(from, to, key) => { setFilters(f => ({ ...f, periodFrom: from, periodTo: to })); setActivePreset(key) }}
|
||||
onClear={() => { setFilters(f => ({ ...f, periodFrom: '', periodTo: '' })); setActivePreset('') }}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="date"
|
||||
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"
|
||||
/>
|
||||
<span className="text-xs text-text-tertiary">–</span>
|
||||
<input
|
||||
type="date"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
@@ -217,6 +202,62 @@ export default function PostProduction() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showFilters && (
|
||||
<div className="flex items-center gap-2 flex-wrap animate-fade-in">
|
||||
<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-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>)}
|
||||
</select>
|
||||
|
||||
<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-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>)}
|
||||
</select>
|
||||
|
||||
<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-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>)}
|
||||
</select>
|
||||
|
||||
<DatePresetPicker
|
||||
activePreset={activePreset}
|
||||
onSelect={(from, to, key) => { setFilters(f => ({ ...f, periodFrom: from, periodTo: to })); setActivePreset(key) }}
|
||||
onClear={() => { setFilters(f => ({ ...f, periodFrom: '', periodTo: '' })); setActivePreset('') }}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="date"
|
||||
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-surface text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||
/>
|
||||
<span className="text-xs text-text-tertiary">–</span>
|
||||
<input
|
||||
type="date"
|
||||
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-surface text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{moveError && (
|
||||
<div className="p-3 bg-amber-50 border border-amber-200 rounded-lg text-sm text-amber-700 flex items-center justify-between">
|
||||
<span>{moveError}</span>
|
||||
@@ -227,9 +268,35 @@ export default function PostProduction() {
|
||||
)}
|
||||
|
||||
{view === 'kanban' ? (
|
||||
<KanbanBoard posts={filteredPosts} onPostClick={openEdit} onMovePost={handleMovePost} />
|
||||
<KanbanBoard
|
||||
columns={[
|
||||
{ id: 'draft', label: t('posts.status.draft'), color: 'bg-gray-400' },
|
||||
{ id: 'in_review', label: t('posts.status.in_review'), color: 'bg-amber-400' },
|
||||
{ id: 'approved', label: t('posts.status.approved'), color: 'bg-blue-400' },
|
||||
{ id: 'rejected', label: t('posts.status.rejected'), color: 'bg-red-400' },
|
||||
{ id: 'scheduled', label: t('posts.status.scheduled'), color: 'bg-purple-400' },
|
||||
{ id: 'published', label: t('posts.status.published'), color: 'bg-emerald-400' },
|
||||
]}
|
||||
items={filteredPosts}
|
||||
getItemId={(p) => p._id}
|
||||
onMove={(id, status) => handleMovePost(id, status)}
|
||||
renderCard={(post) => {
|
||||
const brandName = getBrandName(post.brand_id || post.brandId) || post.brand_name || post.brand
|
||||
const assignee = post.assignedToName || post.assignedName || post.assigned_name
|
||||
return (
|
||||
<KanbanCard
|
||||
title={post.title}
|
||||
thumbnail={post.thumbnail_url}
|
||||
brandName={brandName}
|
||||
assigneeName={assignee}
|
||||
date={post.scheduledDate}
|
||||
onClick={() => openEdit(post)}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<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}
|
||||
@@ -244,39 +311,58 @@ export default function PostProduction() {
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{selectedIds.size > 0 && (
|
||||
<div className="px-4 pt-3">
|
||||
<BulkSelectBar selectedCount={selectedIds.size} onDelete={() => setShowBulkDeleteConfirm(true)} onClear={() => setSelectedIds(new Set())} />
|
||||
</div>
|
||||
)}
|
||||
<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">{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="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-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">
|
||||
{filteredPosts.map(post => (
|
||||
<PostCard key={post._id} post={post} onClick={() => openEdit(post)} />
|
||||
))}
|
||||
{filteredPosts.map(post => {
|
||||
const postId = post._id || post.id || post.Id
|
||||
return (
|
||||
<PostCard
|
||||
key={postId}
|
||||
post={post}
|
||||
onClick={() => openEdit(post)}
|
||||
checkboxSlot={<input type="checkbox" checked={selectedIds.has(postId)} onChange={() => toggleSelect(postId)} className="rounded border-border" />}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Post Detail Panel */}
|
||||
{panelPost && (
|
||||
<PostDetailPanel
|
||||
post={panelPost}
|
||||
onClose={() => setPanelPost(null)}
|
||||
onSave={handlePanelSave}
|
||||
onDelete={handlePanelDelete}
|
||||
brands={brands}
|
||||
teamMembers={teamMembers}
|
||||
campaigns={campaigns}
|
||||
/>
|
||||
)}
|
||||
{/* Bulk Delete Confirmation */}
|
||||
<Modal
|
||||
isOpen={showBulkDeleteConfirm}
|
||||
onClose={() => setShowBulkDeleteConfirm(false)}
|
||||
title={t('common.bulkDeleteConfirm').replace('{count}', selectedIds.size)}
|
||||
isConfirm
|
||||
danger
|
||||
confirmText={t('common.deleteSelected')}
|
||||
onConfirm={handleBulkDelete}
|
||||
>
|
||||
{t('common.bulkDeleteDesc')}
|
||||
</Modal>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -50,15 +50,15 @@ export default function ProjectDetail() {
|
||||
|
||||
useEffect(() => { loadProject() }, [id])
|
||||
useEffect(() => {
|
||||
api.get('/users/assignable').then(res => setAssignableUsers(res.data || res || [])).catch(() => {})
|
||||
api.get('/users/assignable').then(res => setAssignableUsers(Array.isArray(res) ? res : [])).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const loadProject = async () => {
|
||||
try {
|
||||
const proj = await api.get(`/projects/${id}`)
|
||||
setProject(proj.data || proj)
|
||||
setProject(proj)
|
||||
const tasksRes = await api.get(`/tasks?project_id=${id}`)
|
||||
setTasks(Array.isArray(tasksRes) ? tasksRes : (tasksRes.data || []))
|
||||
setTasks(Array.isArray(tasksRes) ? tasksRes : [])
|
||||
} catch (err) {
|
||||
console.error('Failed to load project:', err)
|
||||
} finally {
|
||||
@@ -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
|
||||
@@ -458,12 +458,19 @@ export default function ProjectDetail() {
|
||||
)}
|
||||
|
||||
{/* ─── GANTT / TIMELINE VIEW ─── */}
|
||||
{view === 'gantt' && <GanttView tasks={tasks} project={project} onEditTask={openEditTask} />}
|
||||
{view === 'gantt' && <GanttView tasks={tasks} project={project} onEditTask={openEditTask} onTaskColorChange={async (taskId, color) => {
|
||||
try {
|
||||
await api.patch(`/tasks/${taskId}`, { color: color || '' })
|
||||
loadProject()
|
||||
} catch (err) {
|
||||
console.error('Task color update failed:', err)
|
||||
}
|
||||
}} />}
|
||||
</div>{/* end main content */}
|
||||
|
||||
{/* ─── 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" />
|
||||
@@ -532,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} />
|
||||
@@ -565,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>
|
||||
)}
|
||||
@@ -576,10 +583,38 @@ function TaskKanbanCard({ task, canEdit, canDelete, onClick, onDelete, onStatusC
|
||||
}
|
||||
|
||||
// ─── Gantt / Timeline View ──────────────────────────
|
||||
function GanttView({ tasks, project, onEditTask }) {
|
||||
const GANTT_ZOOM = [
|
||||
{ key: 'month', label: 'Month', pxPerDay: 8 },
|
||||
{ key: 'week', label: 'Week', pxPerDay: 20 },
|
||||
{ key: 'day', label: 'Day', pxPerDay: 48 },
|
||||
]
|
||||
|
||||
const GANTT_COLOR_PALETTE = [
|
||||
'#6366f1', '#ec4899', '#10b981', '#f59e0b',
|
||||
'#8b5cf6', '#06b6d4', '#f43f5e', '#14b8a6',
|
||||
'#3b82f6', '#ef4444', '#84cc16', '#a855f7',
|
||||
]
|
||||
|
||||
function GanttView({ tasks, project, onEditTask, onTaskColorChange }) {
|
||||
const [zoomIdx, setZoomIdx] = useState(0)
|
||||
const ganttRef = useRef(null)
|
||||
const [colorPicker, setColorPicker] = useState(null)
|
||||
const colorPickerRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!colorPicker) return
|
||||
const handleClick = (e) => {
|
||||
if (colorPickerRef.current && !colorPickerRef.current.contains(e.target)) {
|
||||
setColorPicker(null)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
return () => document.removeEventListener('mousedown', handleClick)
|
||||
}, [colorPicker])
|
||||
|
||||
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>
|
||||
@@ -590,17 +625,19 @@ function GanttView({ tasks, project, onEditTask }) {
|
||||
const today = startOfDay(new Date())
|
||||
|
||||
// Calculate range
|
||||
let earliest = today
|
||||
let latest = addDays(today, 21)
|
||||
let earliest = addDays(today, -7)
|
||||
let latest = addDays(today, 30)
|
||||
tasks.forEach(t => {
|
||||
const created = t.createdAt ? startOfDay(new Date(t.createdAt)) : today
|
||||
const start = t.startDate || t.start_date ? startOfDay(new Date(t.startDate || t.start_date)) : created
|
||||
const due = t.dueDate ? startOfDay(new Date(t.dueDate)) : null
|
||||
if (isBefore(created, earliest)) earliest = created
|
||||
if (due && isAfter(due, latest)) latest = addDays(due, 2)
|
||||
if (isBefore(start, earliest)) earliest = addDays(start, -3)
|
||||
if (isBefore(created, earliest)) earliest = addDays(created, -3)
|
||||
if (due && isAfter(due, latest)) latest = addDays(due, 7)
|
||||
})
|
||||
if (project.dueDate) {
|
||||
const pd = startOfDay(new Date(project.dueDate))
|
||||
if (isAfter(pd, latest)) latest = addDays(pd, 2)
|
||||
if (isAfter(pd, latest)) latest = addDays(pd, 7)
|
||||
}
|
||||
const totalDays = differenceInDays(latest, earliest) + 1
|
||||
|
||||
@@ -610,7 +647,7 @@ function GanttView({ tasks, project, onEditTask }) {
|
||||
days.push(addDays(earliest, i))
|
||||
}
|
||||
|
||||
const dayWidth = Math.max(36, Math.min(60, 800 / totalDays))
|
||||
const dayWidth = GANTT_ZOOM[zoomIdx].pxPerDay
|
||||
|
||||
const getBarStyle = (task) => {
|
||||
const start = task.startDate || task.start_date
|
||||
@@ -629,8 +666,39 @@ function GanttView({ tasks, project, onEditTask }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<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">
|
||||
{GANTT_ZOOM.map((z, i) => (
|
||||
<button
|
||||
key={z.key}
|
||||
onClick={() => setZoomIdx(i)}
|
||||
className={`px-3 py-1 text-xs font-medium rounded-md transition-colors ${
|
||||
zoomIdx === i
|
||||
? 'bg-brand-primary text-white shadow-sm'
|
||||
: 'text-text-tertiary hover:text-text-primary hover:bg-surface-tertiary'
|
||||
}`}
|
||||
>
|
||||
{z.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (ganttRef.current) {
|
||||
const todayOff = differenceInDays(today, earliest) * dayWidth
|
||||
ganttRef.current.scrollTo({ left: Math.max(0, todayOff - 200), behavior: 'smooth' })
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-3 py-1 text-xs font-medium text-brand-primary hover:bg-brand-primary/10 rounded-md transition-colors"
|
||||
>
|
||||
<Calendar className="w-3.5 h-3.5" />
|
||||
Today
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div ref={ganttRef} className="overflow-x-auto">
|
||||
<div style={{ minWidth: `${totalDays * dayWidth + 200}px` }}>
|
||||
{/* Day headers */}
|
||||
<div className="flex border-b border-border bg-surface-secondary sticky top-0 z-10">
|
||||
@@ -641,6 +709,8 @@ function GanttView({ tasks, project, onEditTask }) {
|
||||
{days.map((day, i) => {
|
||||
const isToday = differenceInDays(day, today) === 0
|
||||
const isWeekend = day.getDay() === 0 || day.getDay() === 6
|
||||
const isMonthStart = day.getDate() === 1
|
||||
const isWeekStart = day.getDay() === 1
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
@@ -648,10 +718,17 @@ function GanttView({ tasks, project, onEditTask }) {
|
||||
className={`text-center py-2 border-r border-border-light text-[10px] ${
|
||||
isToday ? 'bg-brand-primary/10 font-bold text-brand-primary' :
|
||||
isWeekend ? 'bg-surface-tertiary/50 text-text-tertiary' : 'text-text-tertiary'
|
||||
}`}
|
||||
} ${isMonthStart ? 'border-l-2 border-l-text-tertiary/30' : ''}`}
|
||||
>
|
||||
<div>{format(day, 'd')}</div>
|
||||
<div className="text-[8px] uppercase">{format(day, 'EEE')}</div>
|
||||
{dayWidth >= 30 && <div>{format(day, 'd')}</div>}
|
||||
{dayWidth >= 40 && <div className="text-[8px] uppercase">{format(day, 'EEE')}</div>}
|
||||
{dayWidth >= 15 && dayWidth < 30 && day.getDate() % 7 === 1 && <div>{format(day, 'd')}</div>}
|
||||
{dayWidth < 15 && isMonthStart && (
|
||||
<div className="text-[8px] font-semibold whitespace-nowrap">{format(day, 'MMM')}</div>
|
||||
)}
|
||||
{dayWidth < 15 && !isMonthStart && isWeekStart && day.getDate() % 14 <= 7 && (
|
||||
<div className="text-[8px]">{format(day, 'd')}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -665,9 +742,22 @@ function GanttView({ tasks, project, onEditTask }) {
|
||||
return (
|
||||
<div key={task._id} className="flex border-b border-border-light hover:bg-surface-secondary/50 group">
|
||||
<div className="w-[200px] shrink-0 px-4 py-3 border-r border-border flex items-center gap-2">
|
||||
<div className={`w-2 h-2 rounded-full ${prio.color} shrink-0`} />
|
||||
{onTaskColorChange && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
const taskId = task._id || task.id
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
setColorPicker(colorPicker?.taskId === taskId ? null : { taskId, x: rect.left, y: rect.bottom + 4 })
|
||||
}}
|
||||
className={`w-3.5 h-3.5 rounded-full border border-white shadow-sm shrink-0 hover:scale-125 transition-transform ${!task.color ? (statusColors[task.status] || 'bg-gray-300') : ''}`}
|
||||
style={task.color ? { backgroundColor: task.color } : undefined}
|
||||
title="Change color"
|
||||
/>
|
||||
)}
|
||||
{!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>
|
||||
@@ -681,8 +771,8 @@ function GanttView({ tasks, project, onEditTask }) {
|
||||
)}
|
||||
{/* Bar */}
|
||||
<div
|
||||
className={`absolute top-2.5 h-5 rounded-full ${statusColors[task.status] || 'bg-gray-300'} opacity-80 hover:opacity-100 transition-opacity cursor-pointer`}
|
||||
style={barStyle}
|
||||
className={`absolute top-2.5 h-5 rounded-full ${task.color ? '' : (statusColors[task.status] || 'bg-gray-300')} opacity-80 hover:opacity-100 transition-opacity cursor-pointer`}
|
||||
style={{ ...barStyle, ...(task.color ? { backgroundColor: task.color } : {}) }}
|
||||
onClick={() => onEditTask(task)}
|
||||
title={`${task.title}${task.dueDate ? ` — Due ${format(new Date(task.dueDate), 'MMM d')}` : ''}`}
|
||||
/>
|
||||
@@ -692,6 +782,38 @@ function GanttView({ tasks, project, onEditTask }) {
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Color Picker Popover */}
|
||||
{colorPicker && onTaskColorChange && (
|
||||
<div
|
||||
ref={colorPickerRef}
|
||||
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">
|
||||
{GANTT_COLOR_PALETTE.map(c => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => {
|
||||
onTaskColorChange(colorPicker.taskId, c)
|
||||
setColorPicker(null)
|
||||
}}
|
||||
className="w-7 h-7 rounded-full border-2 border-transparent hover:border-gray-400 hover:scale-110 transition-all"
|
||||
style={{ backgroundColor: c }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
onTaskColorChange(colorPicker.taskId, null)
|
||||
setColorPicker(null)
|
||||
}}
|
||||
className="w-full text-[10px] text-text-tertiary hover:text-text-primary text-center py-1 hover:bg-surface-tertiary rounded transition-colors"
|
||||
>
|
||||
Reset to default
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,12 +11,12 @@ import { SkeletonCard } from '../components/SkeletonLoader'
|
||||
|
||||
const EMPTY_PROJECT = {
|
||||
name: '', description: '', brand_id: '', status: 'active',
|
||||
owner_id: '', start_date: '', due_date: '',
|
||||
owner_id: '', start_date: '', due_date: '', team_id: '',
|
||||
}
|
||||
|
||||
export default function Projects() {
|
||||
const navigate = useNavigate()
|
||||
const { teamMembers, brands } = useContext(AppContext)
|
||||
const { teamMembers, brands, teams } = useContext(AppContext)
|
||||
const { permissions } = useAuth()
|
||||
const [projects, setProjects] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -30,7 +30,7 @@ export default function Projects() {
|
||||
const loadProjects = async () => {
|
||||
try {
|
||||
const res = await api.get('/projects')
|
||||
setProjects(res.data || res || [])
|
||||
setProjects(Array.isArray(res) ? res : [])
|
||||
} catch (err) {
|
||||
console.error('Failed to load projects:', err)
|
||||
} finally {
|
||||
@@ -45,6 +45,7 @@ export default function Projects() {
|
||||
description: formData.description,
|
||||
brand_id: formData.brand_id ? Number(formData.brand_id) : null,
|
||||
owner_id: formData.owner_id ? Number(formData.owner_id) : null,
|
||||
team_id: formData.team_id ? Number(formData.team_id) : null,
|
||||
status: formData.status,
|
||||
start_date: formData.start_date || null,
|
||||
due_date: formData.due_date || null,
|
||||
@@ -79,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>
|
||||
|
||||
@@ -99,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" />
|
||||
@@ -111,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
|
||||
@@ -146,6 +147,7 @@ export default function Projects() {
|
||||
assigneeName: project.ownerName || project.owner_name,
|
||||
thumbnailUrl: project.thumbnail_url || project.thumbnailUrl,
|
||||
tags: [project.status, project.priority].filter(Boolean),
|
||||
color: project.color,
|
||||
})}
|
||||
onDateChange={async (projectId, { startDate, endDate }) => {
|
||||
try {
|
||||
@@ -156,6 +158,15 @@ export default function Projects() {
|
||||
loadProjects()
|
||||
}
|
||||
}}
|
||||
onColorChange={async (projectId, color) => {
|
||||
try {
|
||||
await api.patch(`/projects/${projectId}`, { color: color || '' })
|
||||
} catch (err) {
|
||||
console.error('Color update failed:', err)
|
||||
} finally {
|
||||
loadProjects()
|
||||
}
|
||||
}}
|
||||
onItemClick={(project) => {
|
||||
navigate(`/projects/${project._id || project.id}`)
|
||||
}}
|
||||
@@ -226,6 +237,20 @@ export default function Projects() {
|
||||
{teamMembers.map(m => <option key={m._id} value={m._id}>{m.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Team</label>
|
||||
<select
|
||||
value={formData.team_id}
|
||||
onChange={e => setFormData(f => ({ ...f, team_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="">No team</option>
|
||||
{teams.map(t => <option key={t.id || t._id} value={t.id || t._id}>{t.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Start Date</label>
|
||||
<input
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,61 +1,175 @@
|
||||
import { useState } from 'react'
|
||||
import { AlertCircle, Send, CheckCircle2, Upload, X } from 'lucide-react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { AlertCircle, Send, CheckCircle2, Upload, X, Globe } from 'lucide-react'
|
||||
import { api } from '../utils/api'
|
||||
import FormInput from '../components/FormInput'
|
||||
import { useToast } from '../components/ToastContainer'
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: 'request', label: 'Request' },
|
||||
{ value: 'correction', label: 'Correction' },
|
||||
{ value: 'complaint', label: 'Complaint' },
|
||||
{ value: 'suggestion', label: 'Suggestion' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
]
|
||||
// ─── Bilingual translations ────────────────────────────────────
|
||||
const T = {
|
||||
en: {
|
||||
pageTitle: 'Submit an Issue',
|
||||
pageSubtitle: 'Report issues, request corrections, or make suggestions. We\'ll track your submission and keep you updated.',
|
||||
yourInfo: 'Your Information',
|
||||
name: 'Name',
|
||||
namePlaceholder: 'Your full name',
|
||||
nameRequired: 'Name is required',
|
||||
email: 'Email',
|
||||
emailPlaceholder: 'your.email@example.com',
|
||||
emailRequired: 'Email is required',
|
||||
emailInvalid: 'Invalid email address',
|
||||
phone: 'Phone (Optional)',
|
||||
phonePlaceholder: '+966 5X XXX XXXX',
|
||||
teamQuestion: 'Which team should handle your issue?',
|
||||
selectTeam: 'Select a team',
|
||||
issueDetails: 'Issue Details',
|
||||
category: 'Category',
|
||||
categoryPlaceholder: 'e.g., Marketing, IT, Operations',
|
||||
type: 'Type',
|
||||
priority: 'Priority',
|
||||
title: 'Title',
|
||||
titlePlaceholder: 'Brief summary of the issue',
|
||||
titleRequired: 'Title is required',
|
||||
description: 'Description',
|
||||
descriptionPlaceholder: 'Provide detailed information about the issue...',
|
||||
descriptionRequired: 'Description is required',
|
||||
attachment: 'Attachment (Optional)',
|
||||
uploadPrompt: 'Click to upload a file (screenshots, documents, etc.)',
|
||||
submit: 'Submit Issue',
|
||||
submitting: 'Submitting...',
|
||||
submitFailed: 'Failed to submit issue. Please try again.',
|
||||
footerNote: 'You\'ll receive a tracking link to monitor the progress of your issue.',
|
||||
// Success page
|
||||
successTitle: 'Issue Submitted Successfully!',
|
||||
successMessage: 'Thank you for submitting your issue. You can track its progress using the link below.',
|
||||
trackingLink: 'Your Tracking Link',
|
||||
copy: 'Copy',
|
||||
copied: 'Copied to clipboard!',
|
||||
trackIssue: 'Track Your Issue',
|
||||
submitAnother: 'Submit Another Issue',
|
||||
// Options
|
||||
request: 'Request', correction: 'Correction', complaint: 'Complaint', suggestion: 'Suggestion', other: 'Other',
|
||||
low: 'Low', medium: 'Medium', high: 'High', urgent: 'Urgent',
|
||||
},
|
||||
ar: {
|
||||
pageTitle: 'تقديم مشكلة',
|
||||
pageSubtitle: 'أبلغ عن مشاكل، اطلب تصحيحات، أو قدّم اقتراحات. سنتابع طلبك ونبقيك على اطلاع.',
|
||||
yourInfo: 'معلوماتك',
|
||||
name: 'الاسم',
|
||||
namePlaceholder: 'الاسم الكامل',
|
||||
nameRequired: 'الاسم مطلوب',
|
||||
email: 'البريد الإلكتروني',
|
||||
emailPlaceholder: 'your.email@example.com',
|
||||
emailRequired: 'البريد الإلكتروني مطلوب',
|
||||
emailInvalid: 'بريد إلكتروني غير صالح',
|
||||
phone: 'الهاتف (اختياري)',
|
||||
phonePlaceholder: '+966 5X XXX XXXX',
|
||||
teamQuestion: 'أي فريق يجب أن يتعامل مع مشكلتك؟',
|
||||
selectTeam: 'اختر فريقاً',
|
||||
issueDetails: 'تفاصيل المشكلة',
|
||||
category: 'الفئة',
|
||||
categoryPlaceholder: 'مثال: تسويق، تقنية، عمليات',
|
||||
type: 'النوع',
|
||||
priority: 'الأولوية',
|
||||
title: 'العنوان',
|
||||
titlePlaceholder: 'ملخص موجز للمشكلة',
|
||||
titleRequired: 'العنوان مطلوب',
|
||||
description: 'الوصف',
|
||||
descriptionPlaceholder: 'قدّم معلومات مفصلة عن المشكلة...',
|
||||
descriptionRequired: 'الوصف مطلوب',
|
||||
attachment: 'مرفق (اختياري)',
|
||||
uploadPrompt: 'اضغط لرفع ملف (لقطات شاشة، مستندات، إلخ)',
|
||||
submit: 'تقديم المشكلة',
|
||||
submitting: 'جارٍ التقديم...',
|
||||
submitFailed: 'فشل في تقديم المشكلة. يرجى المحاولة مرة أخرى.',
|
||||
footerNote: 'ستتلقى رابط تتبع لمراقبة تقدم مشكلتك.',
|
||||
successTitle: 'تم تقديم المشكلة بنجاح!',
|
||||
successMessage: 'شكراً لتقديم مشكلتك. يمكنك تتبع تقدمها من خلال الرابط أدناه.',
|
||||
trackingLink: 'رابط التتبع',
|
||||
copy: 'نسخ',
|
||||
copied: 'تم النسخ!',
|
||||
trackIssue: 'تتبع مشكلتك',
|
||||
submitAnother: 'تقديم مشكلة أخرى',
|
||||
request: 'طلب', correction: 'تصحيح', complaint: 'شكوى', suggestion: 'اقتراح', other: 'أخرى',
|
||||
low: 'منخفضة', medium: 'متوسطة', high: 'عالية', urgent: 'عاجلة',
|
||||
},
|
||||
}
|
||||
|
||||
const PRIORITY_OPTIONS = [
|
||||
{ value: 'low', label: 'Low' },
|
||||
{ value: 'medium', label: 'Medium' },
|
||||
{ value: 'high', label: 'High' },
|
||||
{ value: 'urgent', label: 'Urgent' },
|
||||
]
|
||||
function detectLang() {
|
||||
const nav = navigator.language || navigator.userLanguage || ''
|
||||
return nav.startsWith('ar') ? 'ar' : 'en'
|
||||
}
|
||||
|
||||
function LangToggle({ lang, setLang }) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setLang(lang === 'en' ? 'ar' : 'en')}
|
||||
className="fixed top-4 end-4 z-50 flex items-center gap-1.5 px-3 py-1.5 bg-surface border border-border rounded-full shadow-sm hover:bg-surface-secondary transition-colors text-sm font-medium text-text-primary"
|
||||
>
|
||||
<Globe className="w-4 h-4" />
|
||||
{lang === 'en' ? 'العربية' : 'English'}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default function PublicIssueSubmit() {
|
||||
const toast = useToast()
|
||||
const [lang, setLang] = useState(detectLang)
|
||||
const t = (key) => T[lang]?.[key] || T.en[key] || key
|
||||
const dir = lang === 'ar' ? 'rtl' : 'ltr'
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.dir = dir
|
||||
document.documentElement.lang = lang
|
||||
return () => { document.documentElement.dir = 'ltr'; document.documentElement.lang = 'en' }
|
||||
}, [lang, dir])
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: 'request', label: t('request') },
|
||||
{ value: 'correction', label: t('correction') },
|
||||
{ value: 'complaint', label: t('complaint') },
|
||||
{ value: 'suggestion', label: t('suggestion') },
|
||||
{ value: 'other', label: t('other') },
|
||||
]
|
||||
|
||||
const PRIORITY_OPTIONS = [
|
||||
{ value: 'low', label: t('low') },
|
||||
{ value: 'medium', label: t('medium') },
|
||||
{ value: 'high', label: t('high') },
|
||||
{ value: 'urgent', label: t('urgent') },
|
||||
]
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const teamParam = urlParams.get('team')
|
||||
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
category: 'Marketing',
|
||||
type: 'request',
|
||||
priority: 'medium',
|
||||
title: '',
|
||||
description: '',
|
||||
name: '', email: '', phone: '', category: 'Marketing',
|
||||
type: 'request', priority: 'medium', title: '', description: '', team_id: teamParam || '',
|
||||
})
|
||||
const [file, setFile] = useState(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
const [trackingToken, setTrackingToken] = useState('')
|
||||
const [errors, setErrors] = useState({})
|
||||
const [teams, setTeams] = useState([])
|
||||
|
||||
useEffect(() => {
|
||||
if (!teamParam) {
|
||||
api.get('/public/teams').then(r => setTeams(Array.isArray(r) ? r : [])).catch(() => {})
|
||||
}
|
||||
}, [])
|
||||
|
||||
const updateForm = (field, value) => {
|
||||
setForm((f) => ({ ...f, [field]: value }))
|
||||
if (errors[field]) {
|
||||
setErrors((e) => ({ ...e, [field]: '' }))
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileChange = (e) => {
|
||||
const selectedFile = e.target.files?.[0]
|
||||
if (selectedFile) {
|
||||
setFile(selectedFile)
|
||||
}
|
||||
if (errors[field]) setErrors((e) => ({ ...e, [field]: '' }))
|
||||
}
|
||||
|
||||
const validate = () => {
|
||||
const newErrors = {}
|
||||
if (!form.name.trim()) newErrors.name = 'Name is required'
|
||||
if (!form.email.trim()) newErrors.email = 'Email is required'
|
||||
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) newErrors.email = 'Invalid email address'
|
||||
if (!form.title.trim()) newErrors.title = 'Title is required'
|
||||
if (!form.description.trim()) newErrors.description = 'Description is required'
|
||||
if (!form.name.trim()) newErrors.name = t('nameRequired')
|
||||
if (!form.email.trim()) newErrors.email = t('emailRequired')
|
||||
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) newErrors.email = t('emailInvalid')
|
||||
if (!form.title.trim()) newErrors.title = t('titleRequired')
|
||||
if (!form.description.trim()) newErrors.description = t('descriptionRequired')
|
||||
setErrors(newErrors)
|
||||
return Object.keys(newErrors).length === 0
|
||||
}
|
||||
@@ -63,7 +177,6 @@ export default function PublicIssueSubmit() {
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
if (!validate() || submitting) return
|
||||
|
||||
try {
|
||||
setSubmitting(true)
|
||||
const formData = new FormData()
|
||||
@@ -75,16 +188,14 @@ export default function PublicIssueSubmit() {
|
||||
formData.append('priority', form.priority)
|
||||
formData.append('title', form.title)
|
||||
formData.append('description', form.description)
|
||||
if (file) {
|
||||
formData.append('file', file)
|
||||
}
|
||||
|
||||
if (form.team_id) formData.append('team_id', form.team_id)
|
||||
if (file) formData.append('file', file)
|
||||
const result = await api.upload('/public/issues', formData)
|
||||
setTrackingToken(result.token)
|
||||
setSubmitted(true)
|
||||
} catch (err) {
|
||||
console.error('Submit error:', err)
|
||||
alert('Failed to submit issue. Please try again.')
|
||||
toast.error(t('submitFailed'))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
@@ -93,64 +204,40 @@ export default function PublicIssueSubmit() {
|
||||
if (submitted) {
|
||||
const trackingUrl = `${window.location.origin}/track/${trackingToken}`
|
||||
return (
|
||||
<div className="min-h-screen bg-surface-secondary py-8 px-4">
|
||||
<div className="min-h-screen bg-surface-secondary py-8 px-4" dir={dir}>
|
||||
<LangToggle lang={lang} setLang={setLang} />
|
||||
<div className="max-w-lg mx-auto bg-surface rounded-xl border border-border p-6">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 bg-emerald-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<CheckCircle2 className="w-10 h-10 text-emerald-600" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-text-primary mb-2">Issue Submitted Successfully!</h1>
|
||||
<p className="text-sm text-text-tertiary mb-6">
|
||||
Thank you for submitting your issue. You can track its progress using the link below.
|
||||
</p>
|
||||
<h1 className="text-2xl font-bold text-text-primary mb-2">{t('successTitle')}</h1>
|
||||
<p className="text-sm text-text-tertiary mb-6">{t('successMessage')}</p>
|
||||
|
||||
<div className="bg-surface-secondary rounded-lg p-4 mb-6">
|
||||
<label className="block text-xs font-semibold text-text-tertiary uppercase mb-2">Your Tracking Link</label>
|
||||
<label className="block text-xs font-semibold text-text-tertiary uppercase mb-2">{t('trackingLink')}</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={trackingUrl}
|
||||
readOnly
|
||||
className="flex-1 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 transition-colors"
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(trackingUrl)
|
||||
alert('Copied to clipboard!')
|
||||
}}
|
||||
className="px-4 py-2 bg-brand-primary text-white rounded-lg font-medium hover:bg-brand-primary-light transition-colors"
|
||||
>
|
||||
Copy
|
||||
<input type="text" value={trackingUrl} readOnly
|
||||
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg bg-surface text-text-primary focus:outline-none" dir="ltr" />
|
||||
<button onClick={() => { navigator.clipboard.writeText(trackingUrl); toast.success(t('copied')) }}
|
||||
className="px-4 py-2 bg-brand-primary text-white rounded-lg font-medium hover:bg-brand-primary-light transition-colors">
|
||||
{t('copy')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<a
|
||||
href={`/track/${trackingToken}`}
|
||||
className="block w-full px-4 py-2 bg-brand-primary text-white rounded-lg font-medium hover:bg-brand-primary-light transition-colors"
|
||||
>
|
||||
Track Your Issue
|
||||
<a href={`/track/${trackingToken}`}
|
||||
className="block w-full px-4 py-2 bg-brand-primary text-white rounded-lg font-medium hover:bg-brand-primary-light transition-colors">
|
||||
{t('trackIssue')}
|
||||
</a>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSubmitted(false)
|
||||
setTrackingToken('')
|
||||
setForm({
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
category: 'Marketing',
|
||||
type: 'request',
|
||||
priority: 'medium',
|
||||
title: '',
|
||||
description: '',
|
||||
})
|
||||
<button onClick={() => {
|
||||
setSubmitted(false); setTrackingToken('')
|
||||
setForm({ name: '', email: '', phone: '', category: 'Marketing', type: 'request', priority: 'medium', title: '', description: '', team_id: teamParam || '' })
|
||||
setFile(null)
|
||||
}}
|
||||
className="block w-full px-3 py-1.5 border border-border text-text-secondary rounded-lg hover:bg-surface-tertiary transition-colors"
|
||||
>
|
||||
Submit Another Issue
|
||||
className="block w-full px-3 py-1.5 border border-border text-text-secondary rounded-lg hover:bg-surface-tertiary transition-colors">
|
||||
{t('submitAnother')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -160,177 +247,125 @@ export default function PublicIssueSubmit() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-surface-secondary py-8 px-4">
|
||||
<div className="min-h-screen bg-surface-secondary py-8 px-4" dir={dir}>
|
||||
<LangToggle lang={lang} setLang={setLang} />
|
||||
<div className="max-w-lg mx-auto bg-surface rounded-xl border border-border p-6">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-6">
|
||||
<div className="w-12 h-12 bg-brand-primary/10 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<AlertCircle className="w-6 h-6 text-brand-primary" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-text-primary mb-2">Submit an Issue</h1>
|
||||
<p className="text-sm text-text-tertiary">
|
||||
Report issues, request corrections, or make suggestions. We'll track your submission and keep you updated.
|
||||
</p>
|
||||
<h1 className="text-2xl font-bold text-text-primary mb-2">{t('pageTitle')}</h1>
|
||||
<p className="text-sm text-text-tertiary">{t('pageSubtitle')}</p>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Contact Information */}
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-text-primary mb-2">Your Information</h2>
|
||||
<h2 className="text-sm font-semibold text-text-primary mb-2">{t('yourInfo')}</h2>
|
||||
<div className="space-y-3">
|
||||
<FormInput
|
||||
label="Name"
|
||||
value={form.name}
|
||||
onChange={(e) => updateForm('name', e.target.value)}
|
||||
placeholder="Your full name"
|
||||
required
|
||||
error={errors.name}
|
||||
/>
|
||||
<FormInput
|
||||
label="Email"
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(e) => updateForm('email', e.target.value)}
|
||||
placeholder="your.email@example.com"
|
||||
required
|
||||
error={errors.email}
|
||||
/>
|
||||
<FormInput
|
||||
label="Phone (Optional)"
|
||||
value={form.phone}
|
||||
onChange={(e) => updateForm('phone', e.target.value)}
|
||||
placeholder="+966 5X XXX XXXX"
|
||||
/>
|
||||
<FormInput label={t('name')} value={form.name} onChange={(e) => updateForm('name', e.target.value)}
|
||||
placeholder={t('namePlaceholder')} required error={errors.name} />
|
||||
<FormInput label={t('email')} type="email" value={form.email} onChange={(e) => updateForm('email', e.target.value)}
|
||||
placeholder={t('emailPlaceholder')} required error={errors.email} />
|
||||
<FormInput label={t('phone')} value={form.phone} onChange={(e) => updateForm('phone', e.target.value)}
|
||||
placeholder={t('phonePlaceholder')} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Team Selection */}
|
||||
{!teamParam && teams.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-text-primary mb-2">{t('teamQuestion')}</h2>
|
||||
<select value={form.team_id} onChange={(e) => updateForm('team_id', e.target.value)}
|
||||
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 transition-colors">
|
||||
<option value="">{t('selectTeam')}</option>
|
||||
{teams.map((team) => <option key={team.id} value={team.id}>{team.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Issue Details */}
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-text-primary mb-2">Issue Details</h2>
|
||||
|
||||
<h2 className="text-sm font-semibold text-text-primary mb-2">{t('issueDetails')}</h2>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1.5">
|
||||
Category <span className="text-red-500">*</span>
|
||||
{t('category')} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.category}
|
||||
onChange={(e) => updateForm('category', e.target.value)}
|
||||
placeholder="e.g., Marketing, IT, Operations"
|
||||
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 transition-colors"
|
||||
/>
|
||||
<input type="text" value={form.category} onChange={(e) => updateForm('category', e.target.value)}
|
||||
placeholder={t('categoryPlaceholder')}
|
||||
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 transition-colors" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1.5">
|
||||
Type <span className="text-red-500">*</span>
|
||||
{t('type')} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={form.type}
|
||||
onChange={(e) => updateForm('type', e.target.value)}
|
||||
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 transition-colors"
|
||||
>
|
||||
{TYPE_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
<select value={form.type} onChange={(e) => updateForm('type', e.target.value)}
|
||||
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 transition-colors">
|
||||
{TYPE_OPTIONS.map((opt) => <option key={opt.value} value={opt.value}>{opt.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1.5">
|
||||
Priority <span className="text-red-500">*</span>
|
||||
{t('priority')} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={form.priority}
|
||||
onChange={(e) => updateForm('priority', e.target.value)}
|
||||
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 transition-colors"
|
||||
>
|
||||
{PRIORITY_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
<select value={form.priority} onChange={(e) => updateForm('priority', e.target.value)}
|
||||
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 transition-colors">
|
||||
{PRIORITY_OPTIONS.map((opt) => <option key={opt.value} value={opt.value}>{opt.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<FormInput
|
||||
label="Title"
|
||||
value={form.title}
|
||||
onChange={(e) => updateForm('title', e.target.value)}
|
||||
placeholder="Brief summary of the issue"
|
||||
required
|
||||
error={errors.title}
|
||||
/>
|
||||
|
||||
<FormInput
|
||||
label="Description"
|
||||
type="textarea"
|
||||
value={form.description}
|
||||
<FormInput label={t('title')} value={form.title} onChange={(e) => updateForm('title', e.target.value)}
|
||||
placeholder={t('titlePlaceholder')} required error={errors.title} />
|
||||
<FormInput label={t('description')} type="textarea" value={form.description}
|
||||
onChange={(e) => updateForm('description', e.target.value)}
|
||||
placeholder="Provide detailed information about the issue..."
|
||||
rows={6}
|
||||
required
|
||||
error={errors.description}
|
||||
/>
|
||||
placeholder={t('descriptionPlaceholder')} rows={6} required error={errors.description} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File Upload */}
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-text-primary mb-2">Attachment (Optional)</h2>
|
||||
<h2 className="text-sm font-semibold text-text-primary mb-2">{t('attachment')}</h2>
|
||||
<label className="block cursor-pointer">
|
||||
<input type="file" onChange={handleFileChange} className="hidden" />
|
||||
<input type="file" onChange={(e) => { if (e.target.files?.[0]) setFile(e.target.files[0]) }} className="hidden" />
|
||||
<div className="border-2 border-dashed border-border rounded-lg p-4 text-center hover:bg-surface-secondary/50 transition-colors">
|
||||
<Upload className="w-6 h-6 mx-auto mb-2 text-text-tertiary" />
|
||||
{file ? (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<p className="text-sm text-text-primary font-medium">{file.name}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setFile(null)
|
||||
}}
|
||||
className="p-1 hover:bg-surface-tertiary rounded"
|
||||
>
|
||||
<button type="button" onClick={(e) => { e.stopPropagation(); setFile(null) }} className="p-1 hover:bg-surface-tertiary rounded">
|
||||
<X className="w-4 h-4 text-red-600" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-text-tertiary">Click to upload a file (screenshots, documents, etc.)</p>
|
||||
<p className="text-sm text-text-tertiary">{t('uploadPrompt')}</p>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="w-full px-4 py-2 bg-brand-primary text-white rounded-lg font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
{/* Submit */}
|
||||
<button type="submit" disabled={submitting}
|
||||
className="w-full px-4 py-2 bg-brand-primary text-white rounded-lg font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center justify-center gap-2">
|
||||
{submitting ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
|
||||
Submitting...
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
{t('submitting')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="w-5 h-5" />
|
||||
Submit Issue
|
||||
{t('submit')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Footer Note */}
|
||||
<p className="text-xs text-text-tertiary text-center mt-4">
|
||||
You'll receive a tracking link to monitor the progress of your issue.
|
||||
</p>
|
||||
<p className="text-xs text-text-tertiary text-center mt-4">{t('footerNote')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,54 +1,126 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { AlertCircle, Clock, CheckCircle2, XCircle, MessageCircle, Upload, FileText, Send } from 'lucide-react'
|
||||
import { AlertCircle, Clock, CheckCircle2, XCircle, MessageCircle, Upload, FileText, Send, Globe } from 'lucide-react'
|
||||
import { api } from '../utils/api'
|
||||
import { useToast } from '../components/ToastContainer'
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
new: { label: 'New', bg: 'bg-purple-100', text: 'text-purple-700', dot: 'bg-purple-500', icon: AlertCircle },
|
||||
acknowledged: { label: 'Acknowledged', bg: 'bg-blue-100', text: 'text-blue-700', dot: 'bg-blue-500', icon: CheckCircle2 },
|
||||
in_progress: { label: 'In Progress', bg: 'bg-amber-100', text: 'text-amber-700', dot: 'bg-amber-500', icon: Clock },
|
||||
resolved: { label: 'Resolved', bg: 'bg-emerald-100', text: 'text-emerald-700', dot: 'bg-emerald-500', icon: CheckCircle2 },
|
||||
declined: { label: 'Declined', bg: 'bg-gray-100', text: 'text-gray-700', dot: 'bg-gray-500', icon: XCircle },
|
||||
// ─── Bilingual translations ────────────────────────────────────
|
||||
const T = {
|
||||
en: {
|
||||
description: 'Description',
|
||||
submitted: 'Submitted',
|
||||
lastUpdated: 'Last Updated',
|
||||
resolution: 'Resolution',
|
||||
declined: 'Declined',
|
||||
progressUpdates: 'Progress Updates',
|
||||
noUpdates: 'No updates yet. We\'ll post updates here as we work on your issue.',
|
||||
team: 'Team', you: 'You',
|
||||
attachments: 'Attachments',
|
||||
download: 'Download',
|
||||
addComment: 'Add a Comment',
|
||||
yourName: 'Your Name (Optional)',
|
||||
yourNamePlaceholder: 'Your name',
|
||||
message: 'Message',
|
||||
messagePlaceholder: 'Add additional information or ask a question...',
|
||||
sendComment: 'Send Comment',
|
||||
sending: 'Sending...',
|
||||
uploadFile: 'Upload File',
|
||||
uploading: 'Uploading...',
|
||||
bookmarkNote: 'Bookmark this page to check your issue status anytime.',
|
||||
notFoundTitle: 'Issue Not Found',
|
||||
notFoundMessage: 'The tracking link you used is invalid or the issue has been removed.',
|
||||
submitNew: 'Submit a New Issue',
|
||||
failedComment: 'Failed to add comment',
|
||||
failedUpload: 'Failed to upload file',
|
||||
priority: 'Priority',
|
||||
// Status
|
||||
new: 'New', acknowledged: 'Acknowledged', in_progress: 'In Progress', resolved: 'Resolved', declined_status: 'Declined',
|
||||
// Priority
|
||||
low: 'Low', medium: 'Medium', high: 'High', urgent: 'Urgent',
|
||||
},
|
||||
ar: {
|
||||
description: 'الوصف',
|
||||
submitted: 'تم التقديم',
|
||||
lastUpdated: 'آخر تحديث',
|
||||
resolution: 'الحل',
|
||||
declined: 'مرفوض',
|
||||
progressUpdates: 'تحديثات التقدم',
|
||||
noUpdates: 'لا توجد تحديثات بعد. سننشر التحديثات هنا أثناء العمل على مشكلتك.',
|
||||
team: 'الفريق', you: 'أنت',
|
||||
attachments: 'المرفقات',
|
||||
download: 'تحميل',
|
||||
addComment: 'إضافة تعليق',
|
||||
yourName: 'اسمك (اختياري)',
|
||||
yourNamePlaceholder: 'اسمك',
|
||||
message: 'الرسالة',
|
||||
messagePlaceholder: 'أضف معلومات إضافية أو اطرح سؤالاً...',
|
||||
sendComment: 'إرسال التعليق',
|
||||
sending: 'جارٍ الإرسال...',
|
||||
uploadFile: 'رفع ملف',
|
||||
uploading: 'جارٍ الرفع...',
|
||||
bookmarkNote: 'احفظ هذه الصفحة للاطلاع على حالة مشكلتك في أي وقت.',
|
||||
notFoundTitle: 'المشكلة غير موجودة',
|
||||
notFoundMessage: 'رابط التتبع الذي استخدمته غير صالح أو تمت إزالة المشكلة.',
|
||||
submitNew: 'تقديم مشكلة جديدة',
|
||||
failedComment: 'فشل في إضافة التعليق',
|
||||
failedUpload: 'فشل في رفع الملف',
|
||||
priority: 'الأولوية',
|
||||
new: 'جديد', acknowledged: 'تم الاستلام', in_progress: 'قيد التنفيذ', resolved: 'تم الحل', declined_status: 'مرفوض',
|
||||
low: 'منخفضة', medium: 'متوسطة', high: 'عالية', urgent: 'عاجلة',
|
||||
},
|
||||
}
|
||||
|
||||
const PRIORITY_CONFIG = {
|
||||
low: { label: 'Low', color: 'text-gray-700' },
|
||||
medium: { label: 'Medium', color: 'text-blue-700' },
|
||||
high: { label: 'High', color: 'text-orange-700' },
|
||||
urgent: { label: 'Urgent', color: 'text-red-700' },
|
||||
function detectLang() {
|
||||
const nav = navigator.language || navigator.userLanguage || ''
|
||||
return nav.startsWith('ar') ? 'ar' : 'en'
|
||||
}
|
||||
|
||||
function LangToggle({ lang, setLang }) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setLang(lang === 'en' ? 'ar' : 'en')}
|
||||
className="fixed top-4 end-4 z-50 flex items-center gap-1.5 px-3 py-1.5 bg-surface border border-border rounded-full shadow-sm hover:bg-surface-secondary transition-colors text-sm font-medium text-text-primary"
|
||||
>
|
||||
<Globe className="w-4 h-4" />
|
||||
{lang === 'en' ? 'العربية' : 'English'}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default function PublicIssueTracker() {
|
||||
const { token } = useParams()
|
||||
const toast = useToast()
|
||||
const [lang, setLang] = useState(detectLang)
|
||||
const t = (key) => T[lang]?.[key] || T.en[key] || key
|
||||
const dir = lang === 'ar' ? 'rtl' : 'ltr'
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.dir = dir
|
||||
document.documentElement.lang = lang
|
||||
return () => { document.documentElement.dir = 'ltr'; document.documentElement.lang = 'en' }
|
||||
}, [lang, dir])
|
||||
|
||||
const [issue, setIssue] = useState(null)
|
||||
const [updates, setUpdates] = useState([])
|
||||
const [attachments, setAttachments] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
// Comment form
|
||||
const [commentName, setCommentName] = useState('')
|
||||
const [commentMessage, setCommentMessage] = useState('')
|
||||
const [submittingComment, setSubmittingComment] = useState(false)
|
||||
|
||||
// File upload
|
||||
const [uploadingFile, setUploadingFile] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadIssue()
|
||||
}, [token])
|
||||
useEffect(() => { loadIssue() }, [token])
|
||||
|
||||
const loadIssue = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setLoading(true); setError(null)
|
||||
const data = await api.get(`/public/issues/${token}`)
|
||||
setIssue(data.issue)
|
||||
setUpdates(data.updates || [])
|
||||
setAttachments(data.attachments || [])
|
||||
} catch (err) {
|
||||
console.error('Failed to load issue:', err)
|
||||
setError(err.response?.status === 404 ? 'Issue not found' : 'Failed to load issue')
|
||||
setError(err.response?.status === 404 ? 'notFound' : 'error')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -57,27 +129,18 @@ export default function PublicIssueTracker() {
|
||||
const handleAddComment = async (e) => {
|
||||
e.preventDefault()
|
||||
if (!commentMessage.trim() || submittingComment) return
|
||||
|
||||
try {
|
||||
setSubmittingComment(true)
|
||||
await api.post(`/public/issues/${token}/comment`, {
|
||||
name: commentName.trim() || 'Anonymous',
|
||||
message: commentMessage,
|
||||
})
|
||||
await api.post(`/public/issues/${token}/comment`, { name: commentName.trim() || 'Anonymous', message: commentMessage })
|
||||
setCommentMessage('')
|
||||
await loadIssue()
|
||||
} catch (err) {
|
||||
console.error('Failed to add comment:', err)
|
||||
alert('Failed to add comment')
|
||||
} finally {
|
||||
setSubmittingComment(false)
|
||||
}
|
||||
} catch { toast.error(t('failedComment')) }
|
||||
finally { setSubmittingComment(false) }
|
||||
}
|
||||
|
||||
const handleFileUpload = async (e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
try {
|
||||
setUploadingFile(true)
|
||||
const formData = new FormData()
|
||||
@@ -85,52 +148,52 @@ export default function PublicIssueTracker() {
|
||||
formData.append('name', commentName.trim() || 'Anonymous')
|
||||
await api.upload(`/public/issues/${token}/attachments`, formData)
|
||||
await loadIssue()
|
||||
e.target.value = '' // Reset input
|
||||
} catch (err) {
|
||||
console.error('Failed to upload file:', err)
|
||||
alert('Failed to upload file')
|
||||
} finally {
|
||||
setUploadingFile(false)
|
||||
}
|
||||
e.target.value = ''
|
||||
} catch { toast.error(t('failedUpload')) }
|
||||
finally { setUploadingFile(false) }
|
||||
}
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
const dateFmt = (dateStr) => {
|
||||
if (!dateStr) return ''
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })
|
||||
return new Date(dateStr).toLocaleDateString(lang === 'ar' ? 'ar-SA' : 'en-US', { month: 'long', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
|
||||
const formatDateTime = (dateStr) => {
|
||||
const dateTimeFmt = (dateStr) => {
|
||||
if (!dateStr) return ''
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
return new Date(dateStr).toLocaleString(lang === 'ar' ? 'ar-SA' : 'en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes) => {
|
||||
const fileSize = (bytes) => {
|
||||
if (bytes < 1024) return bytes + ' B'
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||||
}
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
new: { label: t('new'), bg: 'bg-purple-100', text: 'text-purple-700', dot: 'bg-purple-500', icon: AlertCircle },
|
||||
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-text-secondary', dot: 'bg-gray-500', icon: XCircle },
|
||||
}
|
||||
|
||||
const PRIORITY_CONFIG = {
|
||||
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' },
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-surface-secondary py-8 px-4">
|
||||
<div className="min-h-screen bg-surface-secondary py-8 px-4" dir={dir}>
|
||||
<LangToggle lang={lang} setLang={setLang} />
|
||||
<div className="max-w-2xl mx-auto space-y-6 animate-pulse">
|
||||
<div className="bg-surface rounded-xl border border-border p-6 space-y-4">
|
||||
<div className="h-5 bg-surface-tertiary rounded w-24"></div>
|
||||
<div className="h-7 bg-surface-tertiary rounded w-3/4"></div>
|
||||
<div className="h-4 bg-surface-tertiary rounded w-full"></div>
|
||||
<div className="h-4 bg-surface-tertiary rounded w-2/3"></div>
|
||||
</div>
|
||||
<div className="bg-surface rounded-xl border border-border p-6 space-y-4">
|
||||
<div className="h-5 bg-surface-tertiary rounded w-40"></div>
|
||||
<div className="h-20 bg-surface-tertiary rounded"></div>
|
||||
<div className="h-5 bg-surface-tertiary rounded w-24" />
|
||||
<div className="h-7 bg-surface-tertiary rounded w-3/4" />
|
||||
<div className="h-4 bg-surface-tertiary rounded w-full" />
|
||||
<div className="h-4 bg-surface-tertiary rounded w-2/3" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -139,20 +202,16 @@ export default function PublicIssueTracker() {
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen bg-surface-secondary py-8 px-4 ">
|
||||
<div className="min-h-screen bg-surface-secondary py-8 px-4" dir={dir}>
|
||||
<LangToggle lang={lang} setLang={setLang} />
|
||||
<div className="max-w-md mx-auto bg-surface rounded-xl border border-border p-6 text-center">
|
||||
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<XCircle className="w-10 h-10 text-red-600" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-text-primary mb-2">Issue Not Found</h1>
|
||||
<p className="text-sm text-text-tertiary mb-6">
|
||||
The tracking link you used is invalid or the issue has been removed.
|
||||
</p>
|
||||
<a
|
||||
href="/submit-issue"
|
||||
className="inline-block px-4 py-2 bg-brand-primary text-white rounded-lg font-medium hover:bg-brand-primary-light transition-colors"
|
||||
>
|
||||
Submit a New Issue
|
||||
<h1 className="text-2xl font-bold text-text-primary mb-2">{t('notFoundTitle')}</h1>
|
||||
<p className="text-sm text-text-tertiary mb-6">{t('notFoundMessage')}</p>
|
||||
<a href="/submit-issue" className="inline-block px-4 py-2 bg-brand-primary text-white rounded-lg font-medium hover:bg-brand-primary-light transition-colors">
|
||||
{t('submitNew')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -162,22 +221,22 @@ export default function PublicIssueTracker() {
|
||||
if (!issue) return null
|
||||
|
||||
const statusConfig = STATUS_CONFIG[issue.status] || STATUS_CONFIG.new
|
||||
const StatusIcon = statusConfig.icon
|
||||
const priorityConfig = PRIORITY_CONFIG[issue.priority] || PRIORITY_CONFIG.medium
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-surface-secondary py-8 px-4 ">
|
||||
<div className="min-h-screen bg-surface-secondary py-8 px-4" dir={dir}>
|
||||
<LangToggle lang={lang} setLang={setLang} />
|
||||
<div className="max-w-2xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="bg-surface rounded-xl border border-border p-6 mb-6">
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className={`text-xs px-2 py-1 rounded-full font-medium flex items-center gap-1.5 ${statusConfig.bg} ${statusConfig.text}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${statusConfig.dot}`}></span>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${statusConfig.dot}`} />
|
||||
{statusConfig.label}
|
||||
</span>
|
||||
<span className={`text-xs font-medium ${priorityConfig.color}`}>
|
||||
{priorityConfig.label} Priority
|
||||
{priorityConfig.label} {t('priority')}
|
||||
</span>
|
||||
<span className="text-xs text-text-tertiary">•</span>
|
||||
<span className="text-xs text-text-tertiary capitalize">{issue.type}</span>
|
||||
@@ -186,18 +245,18 @@ export default function PublicIssueTracker() {
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<h2 className="text-sm font-semibold text-text-primary mb-2">Description</h2>
|
||||
<h2 className="text-sm font-semibold text-text-primary mb-2">{t('description')}</h2>
|
||||
<p className="text-sm text-text-tertiary whitespace-pre-wrap">{issue.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-sm pt-4 border-t border-border">
|
||||
<div>
|
||||
<span className="text-text-tertiary">Submitted:</span>
|
||||
<span className="text-text-primary font-medium ml-2">{formatDate(issue.created_at)}</span>
|
||||
<span className="text-text-tertiary">{t('submitted')}:</span>
|
||||
<span className="text-text-primary font-medium ms-2">{dateFmt(issue.created_at)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-text-tertiary">Last Updated:</span>
|
||||
<span className="text-text-primary font-medium ml-2">{formatDate(issue.updated_at)}</span>
|
||||
<span className="text-text-tertiary">{t('lastUpdated')}:</span>
|
||||
<span className="text-text-primary font-medium ms-2">{dateFmt(issue.updated_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -206,21 +265,19 @@ export default function PublicIssueTracker() {
|
||||
{(issue.status === 'resolved' || issue.status === 'declined') && issue.resolution_summary && (
|
||||
<div className={`rounded-2xl shadow-sm p-6 mb-6 ${issue.status === 'resolved' ? 'bg-emerald-50 border-2 border-emerald-200' : 'bg-gray-50 border-2 border-gray-200'}`}>
|
||||
<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" />
|
||||
)}
|
||||
{issue.status === 'resolved'
|
||||
? <CheckCircle2 className="w-6 h-6 text-emerald-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'}`}>
|
||||
{issue.status === 'resolved' ? 'Resolution' : 'Declined'}
|
||||
<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'}`}>
|
||||
{formatDate(issue.resolved_at)}
|
||||
<p className={`text-sm mt-2 ${issue.status === 'resolved' ? 'text-emerald-600' : 'text-text-secondary'}`}>
|
||||
{dateFmt(issue.resolved_at)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -232,26 +289,25 @@ export default function PublicIssueTracker() {
|
||||
<div className="bg-surface rounded-2xl shadow-sm p-8 mb-6">
|
||||
<h2 className="text-xl font-bold text-text-primary mb-6 flex items-center gap-2">
|
||||
<MessageCircle className="w-6 h-6" />
|
||||
Progress Updates
|
||||
{t('progressUpdates')}
|
||||
</h2>
|
||||
|
||||
{updates.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<Clock className="w-12 h-12 text-text-tertiary mx-auto mb-3" />
|
||||
<p className="text-text-secondary">No updates yet. We'll post updates here as we work on your issue.</p>
|
||||
<p className="text-text-secondary">{t('noUpdates')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{updates.map((update, idx) => (
|
||||
<div key={update.Id || update.id || idx} className="border-l-4 border-brand-primary pl-4 py-2">
|
||||
<div key={update.Id || update.id || idx} className="border-s-4 border-brand-primary ps-4 py-2">
|
||||
<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'}`}>
|
||||
{update.author_type === 'staff' ? 'Team' : 'You'}
|
||||
<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>
|
||||
<span className="text-sm text-text-tertiary">{formatDateTime(update.created_at)}</span>
|
||||
<span className="text-sm text-text-tertiary">{dateTimeFmt(update.created_at)}</span>
|
||||
</div>
|
||||
<p className="text-text-secondary whitespace-pre-wrap">{update.message}</p>
|
||||
</div>
|
||||
@@ -265,7 +321,7 @@ export default function PublicIssueTracker() {
|
||||
<div className="bg-surface rounded-2xl shadow-sm p-8 mb-6">
|
||||
<h2 className="text-xl font-bold text-text-primary mb-6 flex items-center gap-2">
|
||||
<FileText className="w-6 h-6" />
|
||||
Attachments
|
||||
{t('attachments')}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{attachments.map((att) => (
|
||||
@@ -274,18 +330,12 @@ export default function PublicIssueTracker() {
|
||||
<FileText className="w-5 h-5 text-text-tertiary shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-text-primary truncate">{att.original_name}</p>
|
||||
<p className="text-xs text-text-tertiary">
|
||||
{formatFileSize(att.size)} • {att.uploaded_by}
|
||||
</p>
|
||||
<p className="text-xs text-text-tertiary">{fileSize(att.size)} • {att.uploaded_by}</p>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href={`/api/uploads/${att.filename}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-brand-primary hover:underline ml-2"
|
||||
>
|
||||
Download
|
||||
<a href={`/api/uploads/${att.filename}`} target="_blank" rel="noopener noreferrer"
|
||||
className="text-xs text-brand-primary hover:underline ms-2">
|
||||
{t('download')}
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
@@ -293,55 +343,39 @@ export default function PublicIssueTracker() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Comment Section */}
|
||||
{/* Add Comment */}
|
||||
{issue.status !== 'resolved' && issue.status !== 'declined' && (
|
||||
<div className="bg-surface rounded-2xl shadow-sm p-8">
|
||||
<h2 className="text-xl font-bold text-text-primary mb-6 flex items-center gap-2">
|
||||
<MessageCircle className="w-6 h-6" />
|
||||
Add a Comment
|
||||
{t('addComment')}
|
||||
</h2>
|
||||
|
||||
<form onSubmit={handleAddComment} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-2">Your Name (Optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={commentName}
|
||||
onChange={(e) => setCommentName(e.target.value)}
|
||||
placeholder="Your name"
|
||||
className="w-full px-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||
/>
|
||||
<label className="block text-sm font-medium text-text-primary mb-2">{t('yourName')}</label>
|
||||
<input type="text" value={commentName} onChange={(e) => setCommentName(e.target.value)}
|
||||
placeholder={t('yourNamePlaceholder')}
|
||||
className="w-full px-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-2">
|
||||
Message <span className="text-red-500">*</span>
|
||||
{t('message')} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={commentMessage}
|
||||
onChange={(e) => setCommentMessage(e.target.value)}
|
||||
placeholder="Add additional information or ask a question..."
|
||||
rows={4}
|
||||
required
|
||||
className="w-full px-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||
/>
|
||||
<textarea value={commentMessage} onChange={(e) => setCommentMessage(e.target.value)}
|
||||
placeholder={t('messagePlaceholder')} rows={4} required
|
||||
className="w-full px-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20" />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!commentMessage.trim() || submittingComment}
|
||||
className="px-6 py-3 bg-brand-primary text-white rounded-lg font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center gap-2"
|
||||
>
|
||||
<button type="submit" disabled={!commentMessage.trim() || submittingComment}
|
||||
className="px-6 py-3 bg-brand-primary text-white rounded-lg font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center gap-2">
|
||||
<Send className="w-4 h-4" />
|
||||
{submittingComment ? 'Sending...' : 'Send Comment'}
|
||||
{submittingComment ? t('sending') : t('sendComment')}
|
||||
</button>
|
||||
|
||||
<label className="cursor-pointer">
|
||||
<input type="file" onChange={handleFileUpload} disabled={uploadingFile} className="hidden" />
|
||||
<div className="px-6 py-3 bg-surface-secondary text-text-primary rounded-lg font-medium hover:bg-surface-tertiary transition-colors flex items-center gap-2">
|
||||
<Upload className="w-4 h-4" />
|
||||
{uploadingFile ? 'Uploading...' : 'Upload File'}
|
||||
{uploadingFile ? t('uploading') : t('uploadFile')}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
@@ -351,9 +385,7 @@ export default function PublicIssueTracker() {
|
||||
|
||||
{/* Footer */}
|
||||
<div className="text-center mt-8">
|
||||
<p className="text-sm text-text-tertiary">
|
||||
Bookmark this page to check your issue status anytime.
|
||||
</p>
|
||||
<p className="text-sm text-text-tertiary">{t('bookmarkNote')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { CheckCircle, XCircle, FileText, Image as ImageIcon, Film, Music, User, Sparkles } from 'lucide-react'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import { useToast } from '../components/ToastContainer'
|
||||
import Modal from '../components/Modal'
|
||||
|
||||
export default function PublicPostReview() {
|
||||
const { token } = useParams()
|
||||
const { t } = useLanguage()
|
||||
const toast = useToast()
|
||||
const [post, setPost] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [success, setSuccess] = useState('')
|
||||
const [reviewerName, setReviewerName] = useState('')
|
||||
const [feedback, setFeedback] = useState('')
|
||||
const [pendingAction, setPendingAction] = useState(null)
|
||||
|
||||
useEffect(() => { loadPost() }, [token])
|
||||
|
||||
const loadPost = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/public/review-post/${token}`)
|
||||
if (!res.ok) {
|
||||
const err = await res.json()
|
||||
setError(err.error || t('review.loadFailed'))
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
const data = await res.json()
|
||||
setPost(data)
|
||||
if (data.approvers?.length === 1 && data.approvers[0].name) {
|
||||
setReviewerName(data.approvers[0].name)
|
||||
}
|
||||
} catch {
|
||||
setError(t('review.loadFailed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAction = (action) => {
|
||||
if (!reviewerName.trim()) {
|
||||
toast.error(t('review.enterName'))
|
||||
return
|
||||
}
|
||||
if (action === 'reject' && !feedback.trim()) {
|
||||
toast.error(t('review.feedbackRequiredError'))
|
||||
return
|
||||
}
|
||||
setPendingAction(action)
|
||||
}
|
||||
|
||||
const executeAction = async (action) => {
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const res = await fetch(`/api/public/review-post/${token}/${action}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ approved_by_name: reviewerName, feedback: feedback || undefined }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json()
|
||||
setError(err.error || t('review.actionFailed'))
|
||||
setSubmitting(false)
|
||||
return
|
||||
}
|
||||
const data = await res.json()
|
||||
setSuccess(data.message || t('review.actionCompleted'))
|
||||
setTimeout(() => loadPost(), 1500)
|
||||
} catch {
|
||||
setError(t('review.actionFailed'))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-surface-secondary flex items-center justify-center">
|
||||
<div className="max-w-3xl w-full mx-auto px-4 space-y-6 animate-pulse">
|
||||
<div className="bg-surface rounded-2xl overflow-hidden">
|
||||
<div className="h-24 bg-surface-tertiary" />
|
||||
<div className="p-8 space-y-4">
|
||||
<div className="h-6 bg-surface-tertiary rounded w-2/3" />
|
||||
<div className="h-4 bg-surface-tertiary rounded w-1/2" />
|
||||
<div className="h-32 bg-surface-tertiary rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
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-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-text-primary mb-2">{t('review.notAvailable')}</h2>
|
||||
<p className="text-text-secondary">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (success) {
|
||||
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>
|
||||
<h2 className="text-2xl font-bold text-text-primary mb-2">{t('review.thankYou')}</h2>
|
||||
<p className="text-text-secondary">{success}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!post) return null
|
||||
|
||||
const images = (post.attachments || []).filter(a => (a.mime_type || '').startsWith('image/'))
|
||||
const audio = (post.attachments || []).filter(a => (a.mime_type || '').startsWith('audio/'))
|
||||
const videos = (post.attachments || []).filter(a => (a.mime_type || '').startsWith('video/'))
|
||||
const others = (post.attachments || []).filter(a => {
|
||||
const m = a.mime_type || ''
|
||||
return !m.startsWith('image/') && !m.startsWith('audio/') && !m.startsWith('video/')
|
||||
})
|
||||
|
||||
const platforms = Array.isArray(post.platforms) ? post.platforms : []
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-surface-secondary py-12 px-4">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="bg-surface rounded-2xl shadow-sm overflow-hidden mb-6">
|
||||
<div className="bg-brand-primary px-8 py-6">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<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-2xl font-bold text-white">{t('review.postReview')}</h1>
|
||||
<p className="text-white/80 text-sm">Rawaj</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-8">
|
||||
{/* Post Info */}
|
||||
<div className="mb-6">
|
||||
<h2 className="text-2xl font-bold text-text-primary mb-2">{post.title}</h2>
|
||||
{post.description && (
|
||||
<p className="text-text-secondary whitespace-pre-wrap mb-3">{post.description}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-3 text-sm text-text-tertiary flex-wrap">
|
||||
{post.brand_name && (
|
||||
<span className="px-2 py-0.5 bg-brand-primary/10 text-brand-primary rounded-full text-xs font-medium">{post.brand_name}</span>
|
||||
)}
|
||||
{platforms.length > 0 && (
|
||||
<span className="px-2 py-0.5 bg-surface-tertiary rounded-full text-xs">{platforms.join(', ')}</span>
|
||||
)}
|
||||
{post.creator_name && <span className="font-medium text-text-secondary">{t('review.createdBy')} <strong>{post.creator_name}</strong></span>}
|
||||
{post.scheduled_date && <span>• {new Date(post.scheduled_date).toLocaleDateString()}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Images */}
|
||||
{images.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<ImageIcon className="w-4 h-4 text-text-tertiary" />
|
||||
<h3 className="text-sm font-semibold text-text-tertiary uppercase">{t('posts.images')}</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{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" 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>
|
||||
</div>
|
||||
)}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Videos */}
|
||||
{videos.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Film className="w-4 h-4 text-text-tertiary" />
|
||||
<h3 className="text-sm font-semibold text-text-tertiary uppercase">{t('posts.videos')}</h3>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{videos.map((att, idx) => (
|
||||
<div key={idx} className="bg-surface-secondary rounded-xl overflow-hidden border border-border">
|
||||
{att.original_name && (
|
||||
<div className="px-4 py-2 bg-surface border-b border-border">
|
||||
<span className="text-sm font-medium text-text-secondary">{att.original_name}</span>
|
||||
</div>
|
||||
)}
|
||||
<video src={att.url} controls className="w-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Audio */}
|
||||
{audio.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Music className="w-4 h-4 text-text-tertiary" />
|
||||
<h3 className="text-sm font-semibold text-text-tertiary uppercase">{t('posts.audio')}</h3>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{audio.map((att, idx) => (
|
||||
<div key={idx} className="flex items-center gap-3 p-3 bg-surface-secondary rounded-xl border border-border">
|
||||
<Music className="w-5 h-5 text-text-tertiary shrink-0" />
|
||||
<span className="text-sm text-text-secondary truncate flex-1">{att.original_name}</span>
|
||||
<audio src={att.url} controls className="h-8 max-w-[200px]" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Other files */}
|
||||
{others.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<h3 className="text-sm font-semibold text-text-tertiary uppercase mb-3">{t('posts.otherFiles')}</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{others.map((att, idx) => (
|
||||
<a key={idx} href={att.url} target="_blank" rel="noopener noreferrer"
|
||||
className="flex items-center gap-3 p-4 bg-surface-secondary rounded-xl border border-border hover:border-brand-primary transition-colors">
|
||||
<FileText className="w-8 h-8 text-text-tertiary shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-text-primary truncate">{att.original_name}</p>
|
||||
{att.size && <p className="text-xs text-text-tertiary">{(att.size / 1024).toFixed(1)} KB</p>}
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Review Form */}
|
||||
{post.status === 'in_review' && (
|
||||
<div className="border-t border-border pt-6">
|
||||
<h3 className="text-lg font-semibold text-text-primary mb-4">{t('review.yourReview')}</h3>
|
||||
|
||||
<div className="space-y-4 mb-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('review.reviewer')}</label>
|
||||
{post.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">{post.approvers[0].name}</span>
|
||||
</div>
|
||||
) : post.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">
|
||||
<option value="">{t('review.selectYourName')}</option>
|
||||
{post.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" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('review.feedbackRequired')}</label>
|
||||
<textarea value={feedback} onChange={e => setFeedback(e.target.value)} rows={4}
|
||||
placeholder={t('review.feedbackPlaceholder')}
|
||||
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" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<button onClick={() => handleAction('approve')} disabled={submitting}
|
||||
className="flex items-center justify-center gap-2 px-4 py-3 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('review.approve')}
|
||||
</button>
|
||||
<button onClick={() => handleAction('reject')} disabled={submitting}
|
||||
className="flex items-center justify-center gap-2 px-4 py-3 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('review.reject')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Already Reviewed */}
|
||||
{post.status !== 'in_review' && (
|
||||
<div className="border-t border-border pt-6">
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4 text-center">
|
||||
<p className="text-blue-900 font-medium">{t('review.alreadyReviewed')}</p>
|
||||
<p className="text-blue-700 text-sm mt-1">
|
||||
{t('review.statusLabel')}: <span className="font-semibold capitalize">{post.status.replace('_', ' ')}</span>
|
||||
</p>
|
||||
{post.approved_by_name && (
|
||||
<p className="text-blue-700 text-sm mt-1">
|
||||
{t('review.reviewedBy')}: <span className="font-semibold">{post.approved_by_name}</span>
|
||||
</p>
|
||||
)}
|
||||
{post.feedback && (
|
||||
<p className="text-blue-700 text-sm mt-2 italic">"{post.feedback}"</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center text-text-tertiary text-sm">
|
||||
<p>{t('review.poweredBy')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
isOpen={!!pendingAction}
|
||||
onClose={() => setPendingAction(null)}
|
||||
title={pendingAction === 'approve' ? t('review.confirmApprovePost') : t('review.confirmRejectPost')}
|
||||
isConfirm
|
||||
danger={pendingAction === 'reject'}
|
||||
onConfirm={() => { const a = pendingAction; setPendingAction(null); executeAction(a) }}
|
||||
confirmText={pendingAction === 'approve' ? t('review.approve') : t('review.reject')}
|
||||
>
|
||||
{pendingAction === 'approve' ? t('review.confirmApprovePostDesc') : t('review.confirmRejectPostDesc')}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { CheckCircle, XCircle, AlertCircle, FileText, Image as ImageIcon, Film, Sparkles, Globe } 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'
|
||||
|
||||
const STATUS_ICONS = {
|
||||
copy: FileText,
|
||||
@@ -11,14 +14,22 @@ const STATUS_ICONS = {
|
||||
|
||||
export default function PublicReview() {
|
||||
const { token } = useParams()
|
||||
const { t } = useLanguage()
|
||||
const toast = useToast()
|
||||
const [artefact, setArtefact] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
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)
|
||||
|
||||
useEffect(() => {
|
||||
loadArtefact()
|
||||
@@ -29,32 +40,43 @@ export default function PublicReview() {
|
||||
const res = await fetch(`/api/public/review/${token}`)
|
||||
if (!res.ok) {
|
||||
const err = await res.json()
|
||||
setError(err.error || 'Failed to load artefact')
|
||||
setError(err.error || t('review.loadFailed'))
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
const data = await res.json()
|
||||
setArtefact(data)
|
||||
// Auto-set reviewer name from the selected approver
|
||||
if (data.approvers?.length > 0 && data.approvers[0].name) {
|
||||
setReviewerName(data.approvers[0].name)
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to load artefact')
|
||||
setError(t('review.loadFailed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAction = async (action) => {
|
||||
const handleAction = (action) => {
|
||||
if (!reviewerName.trim()) {
|
||||
alert('Please enter your name')
|
||||
toast.error(t('review.enterName'))
|
||||
return
|
||||
}
|
||||
|
||||
if (action === 'approve' && !confirm('Approve this artefact?')) return
|
||||
if (action === 'reject' && !confirm('Reject this artefact?')) return
|
||||
if (action === 'revision' && !feedback.trim()) {
|
||||
alert('Please provide feedback for revision request')
|
||||
toast.error(t('review.feedbackRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
if (action === 'approve' || action === 'reject') {
|
||||
setPendingAction(action)
|
||||
return
|
||||
}
|
||||
|
||||
executeAction(action)
|
||||
}
|
||||
|
||||
const executeAction = async (action) => {
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const res = await fetch(`/api/public/review/${token}/${action}`, {
|
||||
@@ -68,23 +90,58 @@ export default function PublicReview() {
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json()
|
||||
setError(err.error || 'Action failed')
|
||||
setError(err.error || t('review.actionFailed'))
|
||||
setSubmitting(false)
|
||||
return
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
setSuccess(data.message || 'Action completed successfully')
|
||||
setSuccess(data.message || t('review.actionCompleted'))
|
||||
setTimeout(() => {
|
||||
loadArtefact()
|
||||
}, 1500)
|
||||
} catch (err) {
|
||||
setError('Action failed')
|
||||
setError(t('review.actionFailed'))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
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\/([^\/]+)/,
|
||||
@@ -129,7 +186,7 @@ export default function PublicReview() {
|
||||
<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-text-primary mb-2">Review Not Available</h2>
|
||||
<h2 className="text-2xl font-bold text-text-primary mb-2">{t('review.notAvailable')}</h2>
|
||||
<p className="text-text-secondary">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -140,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">Thank You!</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>
|
||||
@@ -166,8 +228,8 @@ export default function PublicReview() {
|
||||
<Sparkles className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Content Review</h1>
|
||||
<p className="text-white/80 text-sm">Samaya Digital Hub</p>
|
||||
<h1 className="text-2xl font-bold text-white">{t('review.contentReview')}</h1>
|
||||
<p className="text-white/80 text-sm">Rawaj</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -183,10 +245,11 @@ export default function PublicReview() {
|
||||
{artefact.description && (
|
||||
<p className="text-text-secondary mb-2">{artefact.description}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-3 text-sm text-text-tertiary">
|
||||
<div className="flex items-center gap-3 text-sm text-text-tertiary flex-wrap">
|
||||
<span className="px-2 py-0.5 bg-surface-tertiary rounded-full capitalize">{artefact.type}</span>
|
||||
{artefact.brand_name && <span>• {artefact.brand_name}</span>}
|
||||
{artefact.version_number && <span>• Version {artefact.version_number}</span>}
|
||||
{artefact.version_number && <span>• {t('review.version')} {artefact.version_number}</span>}
|
||||
{artefact.creator_name && <span className="font-medium text-text-secondary">{t('review.createdBy')} <strong>{artefact.creator_name}</strong></span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -196,7 +259,7 @@ export default function PublicReview() {
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Globe className="w-4 h-4 text-text-tertiary" />
|
||||
<h3 className="text-sm font-semibold text-text-tertiary uppercase">Content Languages</h3>
|
||||
<h3 className="text-sm font-semibold text-text-tertiary uppercase">{t('review.contentLanguages')}</h3>
|
||||
</div>
|
||||
|
||||
{/* Language tabs */}
|
||||
@@ -222,7 +285,7 @@ export default function PublicReview() {
|
||||
{/* Selected language content */}
|
||||
<div className="bg-surface-secondary rounded-xl p-6 border border-border">
|
||||
<div className="mb-2 text-xs font-semibold text-text-tertiary uppercase">
|
||||
{artefact.texts[selectedLanguage].language_label} Content
|
||||
{artefact.texts[selectedLanguage].language_label} {t('review.content')}
|
||||
</div>
|
||||
<div className="text-text-primary whitespace-pre-wrap leading-relaxed">
|
||||
{artefact.texts[selectedLanguage].content}
|
||||
@@ -234,7 +297,7 @@ export default function PublicReview() {
|
||||
{/* Legacy content field (for backward compatibility) */}
|
||||
{artefact.content && (!artefact.texts || artefact.texts.length === 0) && (
|
||||
<div className="mb-6">
|
||||
<h3 className="text-sm font-semibold text-text-tertiary uppercase mb-2">Content</h3>
|
||||
<h3 className="text-sm font-semibold text-text-tertiary uppercase mb-2">{t('review.content')}</h3>
|
||||
<div className="bg-surface-secondary rounded-xl p-4 border border-border">
|
||||
<pre className="text-text-primary whitespace-pre-wrap font-sans text-sm leading-relaxed">
|
||||
{artefact.content}
|
||||
@@ -248,7 +311,7 @@ export default function PublicReview() {
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<ImageIcon className="w-4 h-4 text-text-tertiary" />
|
||||
<h3 className="text-sm font-semibold text-text-tertiary uppercase">Design Files</h3>
|
||||
<h3 className="text-sm font-semibold text-text-tertiary uppercase">{t('review.designFiles')}</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{artefact.attachments.map((att, idx) => (
|
||||
@@ -263,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">
|
||||
@@ -280,7 +344,7 @@ export default function PublicReview() {
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Film className="w-4 h-4 text-text-tertiary" />
|
||||
<h3 className="text-sm font-semibold text-text-tertiary uppercase">Videos</h3>
|
||||
<h3 className="text-sm font-semibold text-text-tertiary uppercase">{t('review.videos')}</h3>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{artefact.attachments.map((att, idx) => (
|
||||
@@ -289,7 +353,7 @@ export default function PublicReview() {
|
||||
<div>
|
||||
<div className="px-4 py-2 bg-surface border-b border-border flex items-center gap-2">
|
||||
<Globe className="w-4 h-4 text-text-tertiary" />
|
||||
<span className="text-sm font-medium text-text-secondary">Google Drive Video</span>
|
||||
<span className="text-sm font-medium text-text-secondary">{t('review.googleDriveVideo')}</span>
|
||||
</div>
|
||||
<iframe
|
||||
src={getDriveEmbedUrl(att.drive_url)}
|
||||
@@ -321,7 +385,7 @@ export default function PublicReview() {
|
||||
{/* OTHER TYPE: Generic Attachments */}
|
||||
{artefact.type === 'other' && artefact.attachments && artefact.attachments.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<h3 className="text-sm font-semibold text-text-tertiary uppercase mb-3">Attachments</h3>
|
||||
<h3 className="text-sm font-semibold text-text-tertiary uppercase mb-3">{t('review.attachments')}</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{artefact.attachments.map((att, idx) => (
|
||||
<div key={idx}>
|
||||
@@ -336,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>
|
||||
@@ -368,7 +433,7 @@ export default function PublicReview() {
|
||||
{/* Comments */}
|
||||
{artefact.comments && artefact.comments.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<h3 className="text-sm font-semibold text-text-tertiary uppercase mb-3">Previous Comments</h3>
|
||||
<h3 className="text-sm font-semibold text-text-tertiary uppercase mb-3">{t('review.previousComments')}</h3>
|
||||
<div className="space-y-3">
|
||||
{artefact.comments.map((comment, idx) => (
|
||||
<div key={idx} className="bg-surface-secondary rounded-lg p-3 border border-border">
|
||||
@@ -392,28 +457,26 @@ export default function PublicReview() {
|
||||
{/* Review Form */}
|
||||
{artefact.status === 'pending_review' && (
|
||||
<div className="border-t border-border pt-6">
|
||||
<h3 className="text-lg font-semibold text-text-primary mb-4">Your Review</h3>
|
||||
<h3 className="text-lg font-semibold text-text-primary mb-4">{t('review.yourReview')}</h3>
|
||||
|
||||
<div className="space-y-4 mb-6">
|
||||
{/* Reviewer identity */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Your Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={reviewerName}
|
||||
onChange={e => setReviewerName(e.target.value)}
|
||||
placeholder="Enter your name"
|
||||
className="w-full px-4 py-2 border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface transition-colors"
|
||||
/>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('review.reviewer')}</label>
|
||||
<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 || reviewerName || '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Feedback (optional)</label>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('review.feedbackOptional')}</label>
|
||||
<textarea
|
||||
value={feedback}
|
||||
onChange={e => setFeedback(e.target.value)}
|
||||
rows={4}
|
||||
placeholder="Share your thoughts, suggestions, or required changes..."
|
||||
className="w-full px-4 py-2 border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface transition-colors"
|
||||
placeholder={t('review.feedbackPlaceholder')}
|
||||
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>
|
||||
@@ -425,7 +488,7 @@ export default function PublicReview() {
|
||||
className="flex items-center justify-center gap-2 px-4 py-3 bg-emerald-600 text-white rounded-xl font-medium hover:bg-emerald-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
|
||||
>
|
||||
<CheckCircle className="w-5 h-5" />
|
||||
Approve
|
||||
{t('review.approve')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleAction('revision')}
|
||||
@@ -433,7 +496,7 @@ export default function PublicReview() {
|
||||
className="flex items-center justify-center gap-2 px-4 py-3 bg-amber-500 text-white rounded-xl font-medium hover:bg-amber-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
|
||||
>
|
||||
<AlertCircle className="w-5 h-5" />
|
||||
Request Revision
|
||||
{t('review.requestRevision')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleAction('reject')}
|
||||
@@ -441,9 +504,51 @@ export default function PublicReview() {
|
||||
className="flex items-center justify-center gap-2 px-4 py-3 bg-red-600 text-white rounded-xl font-medium hover:bg-red-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
|
||||
>
|
||||
<XCircle className="w-5 h-5" />
|
||||
Reject
|
||||
{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>
|
||||
)}
|
||||
|
||||
@@ -452,14 +557,14 @@ export default function PublicReview() {
|
||||
<div className="border-t border-border pt-6">
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4 text-center">
|
||||
<p className="text-blue-900 font-medium">
|
||||
This artefact has already been reviewed.
|
||||
{t('review.alreadyReviewed')}
|
||||
</p>
|
||||
<p className="text-blue-700 text-sm mt-1">
|
||||
Status: <span className="font-semibold capitalize">{artefact.status.replace('_', ' ')}</span>
|
||||
{t('review.statusLabel')}: <span className="font-semibold capitalize">{artefact.status.replace('_', ' ')}</span>
|
||||
</p>
|
||||
{artefact.approved_by_name && (
|
||||
<p className="text-blue-700 text-sm mt-1">
|
||||
Reviewed by: <span className="font-semibold">{artefact.approved_by_name}</span>
|
||||
{t('review.reviewedBy')}: <span className="font-semibold">{artefact.approved_by_name}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -470,9 +575,26 @@ export default function PublicReview() {
|
||||
|
||||
{/* Footer */}
|
||||
<div className="text-center text-text-tertiary text-sm">
|
||||
<p>Powered by Samaya Digital Hub</p>
|
||||
<p>{t('review.poweredBy')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Approve / Reject Confirmation */}
|
||||
<Modal
|
||||
isOpen={!!pendingAction}
|
||||
onClose={() => setPendingAction(null)}
|
||||
title={pendingAction === 'approve' ? t('review.confirmApprove') : t('review.confirmReject')}
|
||||
isConfirm
|
||||
danger={pendingAction === 'reject'}
|
||||
onConfirm={() => {
|
||||
const action = pendingAction
|
||||
setPendingAction(null)
|
||||
executeAction(action)
|
||||
}}
|
||||
confirmText={pendingAction === 'approve' ? t('review.approve') : t('review.reject')}
|
||||
>
|
||||
{pendingAction === 'approve' ? t('review.confirmApproveDesc') : t('review.confirmRejectDesc')}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,542 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
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'
|
||||
|
||||
export default function PublicTranslationReview() {
|
||||
const { token } = useParams()
|
||||
const { t } = useLanguage()
|
||||
const toast = useToast()
|
||||
const [translation, setTranslation] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [success, setSuccess] = useState('')
|
||||
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()
|
||||
}, [token])
|
||||
|
||||
const loadTranslation = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/public/review-translation/${token}`)
|
||||
if (!res.ok) {
|
||||
const err = await res.json()
|
||||
setError(err.error || t('review.loadFailed'))
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
const data = await res.json()
|
||||
setTranslation(data)
|
||||
if (data.approvers?.length === 1 && data.approvers[0].name) {
|
||||
setReviewerName(data.approvers[0].name)
|
||||
}
|
||||
} catch (err) {
|
||||
setError(t('review.loadFailed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAction = async (action) => {
|
||||
if ((action === 'approve' || action === 'reject') && !reviewerName.trim()) {
|
||||
toast.error(t('review.nameRequired'))
|
||||
return
|
||||
}
|
||||
if ((action === 'reject' || action === 'revision') && !feedback.trim()) {
|
||||
toast.error(t('review.feedbackRequiredError'))
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const res = await fetch(`/api/public/review-translation/${token}/${action}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
approved_by_name: reviewerName || 'Anonymous',
|
||||
feedback: feedback || '',
|
||||
}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json()
|
||||
throw new Error(err.error || 'Action failed')
|
||||
}
|
||||
if (action === 'approve') setSuccess(t('review.approveSuccess'))
|
||||
else if (action === 'reject') setSuccess(t('review.rejectSuccess'))
|
||||
else setSuccess(t('review.revisionSuccess'))
|
||||
setPendingAction(null)
|
||||
} catch (err) {
|
||||
toast.error(err.message)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
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">
|
||||
<div className="w-8 h-8 border-4 border-brand-primary border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-surface-secondary">
|
||||
<div className="text-center">
|
||||
<AlertCircle className="w-12 h-12 text-red-500 mx-auto mb-3" />
|
||||
<h2 className="text-lg font-semibold text-text-primary mb-1">{t('review.errorTitle')}</h2>
|
||||
<p className="text-text-secondary">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-surface-secondary">
|
||||
<div className="text-center">
|
||||
<CheckCircle className="w-12 h-12 text-emerald-500 mx-auto mb-3" />
|
||||
<h2 className="text-lg font-semibold text-text-primary mb-1">{success}</h2>
|
||||
<p className="text-text-secondary">{t('review.thankYou')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!translation) return null
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-surface-secondary">
|
||||
<div className="max-w-4xl mx-auto px-4 py-8">
|
||||
{/* Header */}
|
||||
<div className="bg-surface rounded-xl border border-border p-6 mb-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-brand-primary/10 flex items-center justify-center shrink-0">
|
||||
<Languages className="w-6 h-6 text-brand-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<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>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Source Content */}
|
||||
<div className="bg-surface rounded-xl border border-border p-6 mb-6">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Globe className="w-5 h-5 text-blue-600" />
|
||||
<h3 className="text-lg font-semibold text-text-primary">
|
||||
{t('translations.sourceContent')}
|
||||
</h3>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-blue-50 text-blue-600 font-medium">
|
||||
{translation.source_language}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<p className="text-sm text-blue-900 whitespace-pre-wrap leading-relaxed">{translation.source_content}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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')}
|
||||
</h3>
|
||||
<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>
|
||||
</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 — 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>
|
||||
|
||||
{/* Reviewer identity */}
|
||||
<div className="mb-4">
|
||||
{translation.approvers?.length === 1 ? (
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<User className="w-4 h-4 text-text-tertiary" />
|
||||
<span className="text-sm text-text-primary">{translation.approvers[0].name}</span>
|
||||
</div>
|
||||
) : translation.approvers?.length > 1 ? (
|
||||
<div className="mb-3">
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('review.selectYourName')}</label>
|
||||
<select
|
||||
value={reviewerName}
|
||||
onChange={e => setReviewerName(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="">{t('review.selectApprover')}</option>
|
||||
{translation.approvers.map(a => (
|
||||
<option key={a.id} value={a.name}>{a.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-3">
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('review.yourName')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={reviewerName}
|
||||
onChange={e => setReviewerName(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"
|
||||
placeholder={t('review.enterYourName')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Feedback */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('review.feedback')}</label>
|
||||
<textarea
|
||||
value={feedback}
|
||||
onChange={e => setFeedback(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"
|
||||
placeholder={t('review.feedbackPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => handleAction('approve')}
|
||||
disabled={submitting || !reviewerName.trim()}
|
||||
className="flex-1 flex items-center justify-center gap-2 py-3 bg-emerald-600 text-white rounded-lg font-medium hover:bg-emerald-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<CheckCircle className="w-5 h-5" />
|
||||
{t('review.approve')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleAction('revision')}
|
||||
disabled={submitting || !feedback.trim()}
|
||||
className="flex-1 flex items-center justify-center gap-2 py-3 bg-amber-500 text-white rounded-lg font-medium hover:bg-amber-600 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<AlertCircle className="w-5 h-5" />
|
||||
{t('review.requestRevision')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPendingAction('reject')}
|
||||
disabled={submitting}
|
||||
className="flex-1 flex items-center justify-center gap-2 py-3 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<XCircle className="w-5 h-5" />
|
||||
{t('review.reject')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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">
|
||||
<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')} <strong>{translation.approved_by_name}</strong>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reject confirmation modal */}
|
||||
<Modal
|
||||
isOpen={pendingAction === 'reject'}
|
||||
onClose={() => setPendingAction(null)}
|
||||
title={t('review.confirmReject')}
|
||||
isConfirm
|
||||
danger
|
||||
onConfirm={() => handleAction('reject')}
|
||||
confirmText={t('review.reject')}
|
||||
confirmDisabled={!feedback.trim() || !reviewerName.trim()}
|
||||
>
|
||||
<p className="text-sm text-text-secondary mb-3">{t('review.rejectConfirmDesc')}</p>
|
||||
{!feedback.trim() && (
|
||||
<p className="text-sm text-amber-600">{t('review.feedbackRequiredForReject')}</p>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
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()
|
||||
const token = searchParams.get('token')
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirm, setConfirm] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState(false)
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<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" />
|
||||
<p className="text-slate-300 mb-4">{t('resetPassword.invalidToken')}</p>
|
||||
<Link to="/login" className="text-sm text-blue-400 hover:text-blue-300 transition-colors">
|
||||
{t('resetPassword.goToLogin')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
||||
if (password !== confirm) {
|
||||
setError(t('resetPassword.passwordMismatch'))
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
await api.post('/auth/reset-password', { token, password })
|
||||
setSuccess(true)
|
||||
} catch (err) {
|
||||
setError(err.message || t('resetPassword.error'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<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-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>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-800/50 backdrop-blur-sm rounded-2xl border border-slate-700/50 p-8 shadow-2xl">
|
||||
{success ? (
|
||||
<div className="text-center space-y-4">
|
||||
<div className="w-12 h-12 bg-green-500/20 rounded-full flex items-center justify-center mx-auto">
|
||||
<CheckCircle className="w-6 h-6 text-green-400" />
|
||||
</div>
|
||||
<p className="text-slate-300 text-sm">{t('resetPassword.success')}</p>
|
||||
<Link
|
||||
to="/login"
|
||||
className="inline-flex items-center gap-2 text-sm text-blue-400 hover:text-blue-300 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
{t('resetPassword.goToLogin')}
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-2">{t('resetPassword.newPassword')}</label>
|
||||
<div className="relative">
|
||||
<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 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}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-2">{t('resetPassword.confirmPassword')}</label>
|
||||
<div className="relative">
|
||||
<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 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}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
|
||||
<AlertCircle className="w-5 h-5 text-red-400 shrink-0" />
|
||||
<p className="text-sm text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
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">
|
||||
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
{t('resetPassword.resetting')}
|
||||
</span>
|
||||
) : (
|
||||
t('resetPassword.submit')
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,19 +1,37 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Settings as SettingsIcon, Play, CheckCircle, Languages, Coins, Upload } from 'lucide-react'
|
||||
import { useState, useEffect, useContext } from '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'
|
||||
import { CURRENCIES } from '../i18n/LanguageContext'
|
||||
import { AppContext } from '../App'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import Modal from '../components/Modal'
|
||||
|
||||
const ROLE_COLORS = [
|
||||
'#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6',
|
||||
'#EC4899', '#06B6D4', '#F97316', '#6366F1', '#14B8A6',
|
||||
]
|
||||
|
||||
export default function Settings() {
|
||||
const { t, lang, setLang, currency, setCurrency } = useLanguage()
|
||||
const toast = useToast()
|
||||
const { user } = useAuth()
|
||||
const { roles, loadRoles } = useContext(AppContext)
|
||||
const [restarting, setRestarting] = useState(false)
|
||||
const [success, setSuccess] = useState(false)
|
||||
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 () => {
|
||||
@@ -25,7 +43,7 @@ export default function Settings() {
|
||||
setSizeSaved(true)
|
||||
setTimeout(() => setSizeSaved(false), 2000)
|
||||
} catch (err) {
|
||||
alert(err.message || 'Failed to save')
|
||||
toast.error(err.message || t('settings.saveFailed'))
|
||||
} finally {
|
||||
setSizeSaving(false)
|
||||
}
|
||||
@@ -42,7 +60,7 @@ export default function Settings() {
|
||||
}, 1500)
|
||||
} catch (err) {
|
||||
console.error('Failed to restart tutorial:', err)
|
||||
alert('Failed to restart tutorial')
|
||||
toast.error(t('settings.restartTutorialFailed'))
|
||||
} finally {
|
||||
setRestarting(false)
|
||||
}
|
||||
@@ -50,19 +68,12 @@ export default function Settings() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in max-w-3xl">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-primary flex items-center gap-3">
|
||||
<SettingsIcon className="w-7 h-7 text-brand-primary" />
|
||||
{t('settings.title')}
|
||||
</h1>
|
||||
<p className="text-sm text-text-tertiary mt-1">{t('settings.preferences')}</p>
|
||||
</div>
|
||||
<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 */}
|
||||
@@ -74,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>
|
||||
@@ -90,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}>
|
||||
@@ -104,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>
|
||||
@@ -123,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
|
||||
@@ -142,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">
|
||||
@@ -174,6 +185,195 @@ 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>
|
||||
)
|
||||
}
|
||||
|
||||
function RolesSection({ roles, loadRoles, t, toast }) {
|
||||
const [editingRole, setEditingRole] = useState(null)
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const [modalForm, setModalForm] = useState({ name: '', color: ROLE_COLORS[0] })
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const openAddModal = () => {
|
||||
setModalForm({ name: '', color: ROLE_COLORS[roles.length % ROLE_COLORS.length] })
|
||||
setShowAddModal(true)
|
||||
}
|
||||
|
||||
const handleCreate = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await api.post('/roles', { name: modalForm.name, color: modalForm.color })
|
||||
await loadRoles()
|
||||
setShowAddModal(false)
|
||||
} catch (err) {
|
||||
toast.error(err.message || t('common.error'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async (role) => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await api.patch(`/roles/${role.Id || role.id}`, { name: role.name, color: role.color })
|
||||
await loadRoles()
|
||||
setEditingRole(null)
|
||||
} catch (err) {
|
||||
toast.error(err.message || t('common.error'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (role) => {
|
||||
if (!confirm(t('settings.deleteRoleConfirm'))) return
|
||||
try {
|
||||
await api.delete(`/roles/${role.Id || role.id}`)
|
||||
await loadRoles()
|
||||
} catch (err) {
|
||||
toast.error(err.message || t('common.error'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<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">
|
||||
<h3 className="font-semibold text-text-primary flex items-center gap-2">
|
||||
<Tag className="w-5 h-5 text-brand-primary" />
|
||||
{t('settings.roles')}
|
||||
</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"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
{t('settings.addRole')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<p className="text-sm text-text-tertiary mb-4">{t('settings.rolesDesc')}</p>
|
||||
<div className="space-y-2">
|
||||
{roles.map(role => (
|
||||
<div key={role.Id || role.id} className="flex items-center gap-3 p-3 rounded-lg border border-border hover:bg-surface-secondary transition-colors">
|
||||
{editingRole && (editingRole.Id || editingRole.id) === (role.Id || role.id) ? (
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<input type="color" value={editingRole.color || '#94A3B8'} onChange={e => setEditingRole({ ...editingRole, color: e.target.value })}
|
||||
className="w-8 h-8 rounded-lg border border-border cursor-pointer" />
|
||||
<input type="text" value={editingRole.name} onChange={e => setEditingRole({ ...editingRole, name: e.target.value })}
|
||||
placeholder={t('settings.roleName')} autoFocus
|
||||
className="flex-1 px-3 py-1.5 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary" />
|
||||
<button onClick={() => handleSave(editingRole)} disabled={!editingRole.name || saving}
|
||||
className="px-3 py-1.5 text-sm font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light disabled:opacity-50 transition-colors">
|
||||
{saving ? '...' : t('common.save')}
|
||||
</button>
|
||||
<button onClick={() => setEditingRole(null)} className="p-1.5 text-text-tertiary hover:text-text-primary rounded-lg hover:bg-surface-tertiary transition-colors">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-4 h-4 rounded-full shrink-0" style={{ backgroundColor: role.color || '#94A3B8' }} />
|
||||
<span className="flex-1 text-sm font-medium text-text-primary">{role.name}</span>
|
||||
<button onClick={() => setEditingRole({ ...role })} className="p-1.5 text-text-tertiary hover:text-brand-primary rounded-lg hover:bg-surface-tertiary transition-colors">
|
||||
<Pencil className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => handleDelete(role)} className="p-1.5 text-text-tertiary hover:text-red-500 rounded-lg hover:bg-red-50 transition-colors">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{roles.length === 0 && (
|
||||
<p className="text-sm text-text-tertiary text-center py-6">{t('settings.noRoles')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal isOpen={showAddModal} onClose={() => setShowAddModal(false)} title={t('settings.addRole')} size="sm">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('settings.roleName')}</label>
|
||||
<input type="text" value={modalForm.name} onChange={e => setModalForm(f => ({ ...f, name: 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('settings.roleName')} autoFocus />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('settings.roleColor') || 'Color'}</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input type="color" value={modalForm.color} onChange={e => setModalForm(f => ({ ...f, color: e.target.value }))}
|
||||
className="w-10 h-10 rounded-lg border border-border cursor-pointer" />
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{ROLE_COLORS.map(c => (
|
||||
<button key={c} type="button" onClick={() => setModalForm(f => ({ ...f, color: c }))}
|
||||
className={`w-6 h-6 rounded-full border-2 transition-colors ${modalForm.color === c ? 'border-text-primary scale-110' : 'border-transparent'}`}
|
||||
style={{ backgroundColor: c }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={handleCreate} disabled={!modalForm.name || saving}
|
||||
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 ${saving ? 'btn-loading' : ''}`}>
|
||||
{t('settings.addRole')}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { useState, useEffect, useContext, useMemo } from 'react'
|
||||
import { Plus, CheckSquare, Trash2, Search, LayoutGrid, List, CalendarDays, X, SlidersHorizontal } from 'lucide-react'
|
||||
import { Plus, CheckSquare, Search, LayoutGrid, List, CalendarDays, X, SlidersHorizontal } from 'lucide-react'
|
||||
import { AppContext } from '../App'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import { api, PRIORITY_CONFIG, getBrandColor } from '../utils/api'
|
||||
import TaskCard from '../components/TaskCard'
|
||||
import KanbanBoard from '../components/KanbanBoard'
|
||||
import KanbanCard from '../components/KanbanCard'
|
||||
import TaskDetailPanel from '../components/TaskDetailPanel'
|
||||
import BulkSelectBar from '../components/BulkSelectBar'
|
||||
import Modal from '../components/Modal'
|
||||
import TaskCalendarView from '../components/TaskCalendarView'
|
||||
import DatePresetPicker from '../components/DatePresetPicker'
|
||||
import EmptyState from '../components/EmptyState'
|
||||
@@ -29,8 +33,6 @@ export default function Tasks() {
|
||||
// UI state
|
||||
const [viewMode, setViewMode] = useState('board')
|
||||
const [selectedTask, setSelectedTask] = useState(null)
|
||||
const [draggedTask, setDraggedTask] = useState(null)
|
||||
const [dragOverCol, setDragOverCol] = useState(null)
|
||||
|
||||
// Filters
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
@@ -45,6 +47,11 @@ export default function Tasks() {
|
||||
const [filterOverdue, setFilterOverdue] = useState(false)
|
||||
const [activePreset, setActivePreset] = useState('')
|
||||
const [showFilters, setShowFilters] = useState(false)
|
||||
const [selectedIds, setSelectedIds] = useState(new Set())
|
||||
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false)
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const [createForm, setCreateForm] = useState({ title: '', project_id: '', brand_id: '', priority: 'medium', assigned_to: '' })
|
||||
const [createSaving, setCreateSaving] = useState(false)
|
||||
|
||||
// Assignable users & team
|
||||
const [assignableUsers, setAssignableUsers] = useState([])
|
||||
@@ -54,17 +61,17 @@ export default function Tasks() {
|
||||
|
||||
useEffect(() => { loadTasks() }, [currentUser])
|
||||
useEffect(() => {
|
||||
api.get('/users/assignable').then(res => setAssignableUsers(res.data || res || [])).catch(() => {})
|
||||
api.get('/projects').then(res => setProjects(res.data || res || [])).catch(() => {})
|
||||
api.get('/users/assignable').then(res => setAssignableUsers(Array.isArray(res) ? res : [])).catch(() => {})
|
||||
api.get('/projects').then(res => setProjects(Array.isArray(res) ? res : [])).catch(() => {})
|
||||
if (isSuperadmin) {
|
||||
api.get('/team').then(res => setUsers(res.data || res || [])).catch(() => {})
|
||||
api.get('/team').then(res => setUsers(Array.isArray(res) ? res : [])).catch(() => {})
|
||||
}
|
||||
}, [isSuperadmin])
|
||||
|
||||
const loadTasks = async () => {
|
||||
try {
|
||||
const res = await api.get('/tasks')
|
||||
setTasks(res.data || res || [])
|
||||
setTasks(Array.isArray(res) ? res : [])
|
||||
} catch (err) {
|
||||
console.error('Failed to load tasks:', err)
|
||||
} finally {
|
||||
@@ -177,12 +184,68 @@ export default function Tasks() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateTask = async () => {
|
||||
setCreateSaving(true)
|
||||
try {
|
||||
const data = {
|
||||
title: createForm.title,
|
||||
priority: createForm.priority,
|
||||
status: 'todo',
|
||||
project_id: createForm.project_id ? Number(createForm.project_id) : null,
|
||||
brand_id: createForm.brand_id ? Number(createForm.brand_id) : null,
|
||||
assigned_to: createForm.assigned_to ? Number(createForm.assigned_to) : null,
|
||||
is_personal: false,
|
||||
}
|
||||
const created = await api.post('/tasks', data)
|
||||
setShowCreateModal(false)
|
||||
toast.success(t('tasks.created'))
|
||||
loadTasks()
|
||||
// Open detail panel for further editing
|
||||
if (created) setSelectedTask(created)
|
||||
} catch (err) {
|
||||
console.error('Create task failed:', err)
|
||||
toast.error(t('common.saveFailed'))
|
||||
} finally {
|
||||
setCreateSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
try {
|
||||
await api.post('/tasks/bulk-delete', { ids: [...selectedIds] })
|
||||
toast.success(t('tasks.deleted'))
|
||||
setSelectedIds(new Set())
|
||||
setShowBulkDeleteConfirm(false)
|
||||
loadTasks()
|
||||
} catch (err) {
|
||||
console.error('Bulk delete failed:', err)
|
||||
toast.error(t('common.deleteFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSelect = (id) => {
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedIds.size === sortedListTasks.length) setSelectedIds(new Set())
|
||||
else setSelectedIds(new Set(sortedListTasks.map(t => t._id || t.id)))
|
||||
}
|
||||
|
||||
const handleMove = async (taskId, newStatus) => {
|
||||
// Optimistic update — move the card instantly
|
||||
const prev = tasks
|
||||
setTasks(tasks.map(t => (t._id || t.id) === taskId ? { ...t, status: newStatus } : t))
|
||||
try {
|
||||
await api.patch(`/tasks/${taskId}`, { status: newStatus })
|
||||
toast.success(t('tasks.statusUpdated'))
|
||||
loadTasks()
|
||||
} catch (err) {
|
||||
setTasks(prev)
|
||||
if (err.message?.includes('403')) toast.error(t('tasks.canOnlyEditOwn'))
|
||||
else toast.error(t('common.updateFailed'))
|
||||
}
|
||||
@@ -192,45 +255,6 @@ export default function Tasks() {
|
||||
setSelectedTask(task)
|
||||
}
|
||||
|
||||
// ─── Drag and drop (Kanban) ─────────────────────────
|
||||
const handleDragStart = (e, task) => {
|
||||
setDraggedTask(task)
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
setTimeout(() => { if (e.target) e.target.style.opacity = '0.4' }, 0)
|
||||
}
|
||||
const handleDragEnd = (e) => {
|
||||
e.target.style.opacity = '1'
|
||||
setDraggedTask(null)
|
||||
setDragOverCol(null)
|
||||
}
|
||||
const handleDragOver = (e, colStatus) => {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
setDragOverCol(colStatus)
|
||||
}
|
||||
const handleDragLeave = (e) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget)) setDragOverCol(null)
|
||||
}
|
||||
const handleDrop = (e, colStatus) => {
|
||||
e.preventDefault()
|
||||
setDragOverCol(null)
|
||||
if (draggedTask && draggedTask.status !== colStatus) {
|
||||
handleMove(draggedTask._id || draggedTask.id, colStatus)
|
||||
}
|
||||
setDraggedTask(null)
|
||||
}
|
||||
|
||||
// ─── Kanban columns ──────────────────────────────────
|
||||
const todoTasks = filteredTasks.filter(t => t.status === 'todo')
|
||||
const inProgressTasks = filteredTasks.filter(t => t.status === 'in_progress')
|
||||
const doneTasks = filteredTasks.filter(t => t.status === 'done')
|
||||
|
||||
const columns = [
|
||||
{ label: t('tasks.todo'), status: 'todo', items: todoTasks, color: 'bg-gray-400' },
|
||||
{ label: t('tasks.in_progress'), status: 'in_progress', items: inProgressTasks, color: 'bg-blue-400' },
|
||||
{ label: t('tasks.done'), status: 'done', items: doneTasks, color: 'bg-emerald-400' },
|
||||
]
|
||||
|
||||
// ─── List view sorting ────────────────────────────────
|
||||
const [sortBy, setSortBy] = useState('due_date')
|
||||
const [sortDir, setSortDir] = useState('asc')
|
||||
@@ -301,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>
|
||||
)}
|
||||
@@ -326,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'
|
||||
}`}
|
||||
>
|
||||
@@ -360,7 +384,7 @@ export default function Tasks() {
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => { setSelectedTask({ title: '', description: '', priority: 'medium', start_date: '', due_date: '', status: 'todo', assigned_to: '', project_id: '' }) }}
|
||||
onClick={() => { setCreateForm({ title: '', project_id: '', brand_id: '', priority: 'medium', assigned_to: '' }); setShowCreateModal(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 shrink-0"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
@@ -375,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 => (
|
||||
@@ -387,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 => (
|
||||
@@ -416,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}`)}
|
||||
@@ -429,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>
|
||||
@@ -442,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 => (
|
||||
@@ -455,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 => (
|
||||
@@ -477,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>
|
||||
@@ -485,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>
|
||||
@@ -496,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')}
|
||||
@@ -529,99 +553,89 @@ export default function Tasks() {
|
||||
<>
|
||||
{/* ─── Board View ──────────────────────── */}
|
||||
{viewMode === 'board' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{columns.map(col => {
|
||||
const isOver = dragOverCol === col.status && draggedTask?.status !== col.status
|
||||
<KanbanBoard
|
||||
columns={[
|
||||
{ id: 'todo', label: t('tasks.todo'), color: 'bg-gray-400' },
|
||||
{ id: 'in_progress', label: t('tasks.in_progress'), color: 'bg-blue-400' },
|
||||
{ id: 'done', label: t('tasks.done'), color: 'bg-emerald-400' },
|
||||
]}
|
||||
items={filteredTasks}
|
||||
getItemId={(t) => t._id || t.id}
|
||||
onMove={handleMove}
|
||||
emptyLabel={t('tasks.noTasks')}
|
||||
renderCard={(task) => {
|
||||
const dueDate = task.due_date || task.dueDate
|
||||
const isOverdue = dueDate && new Date(dueDate) < new Date() && task.status !== 'done'
|
||||
const assignee = task.assigned_name || task.assignedName
|
||||
const brandName = task.brand_name || task.brandName
|
||||
const projectName = task.project_name || task.projectName
|
||||
return (
|
||||
<div key={col.status}>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className={`w-2.5 h-2.5 rounded-full ${col.color}`} />
|
||||
<h4 className="text-sm font-semibold text-text-primary">{col.label}</h4>
|
||||
<span className="text-xs font-medium text-text-tertiary bg-surface-tertiary px-2 py-0.5 rounded-full">
|
||||
{col.items.length}
|
||||
<KanbanCard
|
||||
title={task.title}
|
||||
thumbnail={task.thumbnail_url}
|
||||
brandName={brandName}
|
||||
assigneeName={assignee}
|
||||
date={dueDate}
|
||||
dateOverdue={isOverdue}
|
||||
onClick={() => openTask(task)}
|
||||
tags={projectName && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-surface-tertiary text-text-tertiary">
|
||||
{projectName}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={`kanban-column rounded-xl p-2 space-y-2 min-h-[200px] border-2 transition-colors ${
|
||||
isOver
|
||||
? 'bg-brand-primary/5 border-brand-primary/40 border-dashed'
|
||||
: 'bg-surface-secondary border-border-light border-solid'
|
||||
}`}
|
||||
onDragOver={(e) => handleDragOver(e, col.status)}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={(e) => handleDrop(e, col.status)}
|
||||
>
|
||||
{col.items.length === 0 ? (
|
||||
<div className={`py-8 text-center text-xs ${isOver ? 'text-brand-primary font-medium' : 'text-text-tertiary'}`}>
|
||||
{isOver ? t('posts.dropHere') : t('tasks.noTasks')}
|
||||
</div>
|
||||
) : (
|
||||
col.items.map(task => {
|
||||
const canEdit = canEditResource('task', task)
|
||||
const canDelete = canDeleteResource('task', task)
|
||||
return (
|
||||
<div
|
||||
key={task._id || task.id}
|
||||
draggable={canEdit}
|
||||
onDragStart={(e) => canEdit && handleDragStart(e, task)}
|
||||
onDragEnd={handleDragEnd}
|
||||
className={canEdit ? 'cursor-grab active:cursor-grabbing' : ''}
|
||||
>
|
||||
<div className="relative group" onClick={() => openTask(task)}>
|
||||
<TaskCard task={task} onMove={canEdit ? handleMove : undefined} showProject />
|
||||
{canDelete && (
|
||||
<div className="absolute top-2 right-2 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handlePanelDelete(task._id || task.id) }}
|
||||
className="p-1 hover:bg-red-50 rounded text-text-tertiary hover:text-red-500"
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
/>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ─── List View ───────────────────────── */}
|
||||
{viewMode === 'list' && (
|
||||
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
||||
<>
|
||||
{selectedIds.size > 0 && (
|
||||
<BulkSelectBar
|
||||
selectedCount={selectedIds.size}
|
||||
onClear={() => setSelectedIds(new Set())}
|
||||
onDelete={() => setShowBulkDeleteConfirm(true)}
|
||||
/>
|
||||
)}
|
||||
<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">
|
||||
<th className="w-8 px-3 py-2.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sortedListTasks.length > 0 && selectedIds.size === sortedListTasks.length}
|
||||
onChange={toggleSelectAll}
|
||||
className="w-3.5 h-3.5 rounded border-border text-brand-primary cursor-pointer"
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
</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' ? '↑' : '↓')}
|
||||
@@ -637,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
|
||||
@@ -645,6 +659,14 @@ export default function Tasks() {
|
||||
onClick={() => openTask(task)}
|
||||
className="border-b border-border-light hover:bg-surface-secondary/30 cursor-pointer transition-colors group"
|
||||
>
|
||||
<td className="px-3 py-2.5" onClick={e => e.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.has(task._id || task.id)}
|
||||
onChange={() => toggleSelect(task._id || task.id)}
|
||||
className="w-3.5 h-3.5 rounded border-border text-brand-primary cursor-pointer"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<div className={`w-2.5 h-2.5 rounded-full ${priority.color}`} title={priority.label} />
|
||||
</td>
|
||||
@@ -653,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>
|
||||
@@ -686,6 +708,7 @@ export default function Tasks() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ─── Calendar View ───────────────────── */}
|
||||
@@ -695,7 +718,60 @@ export default function Tasks() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ─── Task Detail Side Panel ──────────────── */}
|
||||
{/* ─── Create Task Modal ──────────────────── */}
|
||||
<Modal isOpen={showCreateModal} onClose={() => setShowCreateModal(false)} title={t('tasks.newTask')} size="md">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.taskTitle')} *</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('tasks.project')}</label>
|
||||
<select value={createForm.project_id} onChange={e => setCreateForm(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} value={p._id || p.id}>{p.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.priority')}</label>
|
||||
<select value={createForm.priority} onChange={e => setCreateForm(f => ({ ...f, priority: 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">
|
||||
{Object.entries(PRIORITY_CONFIG).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.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>
|
||||
{assignableUsers.map(u => <option key={u._id || u.id} value={u._id || u.id}>{u.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<button onClick={handleCreateTask} 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('tasks.newTask')}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* ─── Bulk Delete Confirmation Modal ─────── */}
|
||||
<Modal
|
||||
isOpen={showBulkDeleteConfirm}
|
||||
onClose={() => setShowBulkDeleteConfirm(false)}
|
||||
title={t('common.bulkDeleteConfirm').replace('{count}', selectedIds.size)}
|
||||
isConfirm
|
||||
danger
|
||||
confirmText={t('common.deleteSelected')}
|
||||
onConfirm={handleBulkDelete}
|
||||
>
|
||||
{t('common.bulkDeleteDesc')}
|
||||
</Modal>
|
||||
|
||||
{/* ─── Task Detail Side Panel (edit only) ─── */}
|
||||
{selectedTask && (
|
||||
<TaskDetailPanel
|
||||
task={selectedTask}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useContext } from 'react'
|
||||
import { Plus, Users, ArrowLeft, User as UserIcon, Edit2, LayoutGrid, Network } from 'lucide-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 } from '../App'
|
||||
import { AppContext, PERMISSION_LEVELS } from '../App'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import { api } from '../utils/api'
|
||||
@@ -10,10 +10,26 @@ import StatusBadge from '../components/StatusBadge'
|
||||
import BrandBadge from '../components/BrandBadge'
|
||||
import TeamMemberPanel from '../components/TeamMemberPanel'
|
||||
import TeamPanel from '../components/TeamPanel'
|
||||
import Modal from '../components/Modal'
|
||||
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-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 = {
|
||||
name: '', email: '', password: '', permission_level: 'contributor',
|
||||
role_id: '', brands: [], phone: '', modules: [...ALL_MODULES], team_ids: [], preferred_language: 'en',
|
||||
}
|
||||
|
||||
export default function Team() {
|
||||
const { t } = useLanguage()
|
||||
const { teamMembers, loadTeam, currentUser, teams, loadTeams, brands } = useContext(AppContext)
|
||||
const { t, lang } = useLanguage()
|
||||
const toast = useToast()
|
||||
const { teamMembers, loadTeam, currentUser, teams, loadTeams, brands, roles } = useContext(AppContext)
|
||||
const { user } = useAuth()
|
||||
const [panelMember, setPanelMember] = useState(null)
|
||||
const [panelIsEditingSelf, setPanelIsEditingSelf] = useState(false)
|
||||
@@ -25,11 +41,87 @@ export default function Team() {
|
||||
const [teamFilter, setTeamFilter] = useState(null)
|
||||
const [viewMode, setViewMode] = useState('grid') // 'grid' | 'teams'
|
||||
|
||||
// Add member modal state
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const [addForm, setAddForm] = useState({ ...EMPTY_MEMBER })
|
||||
const [addConfirmPassword, setAddConfirmPassword] = useState('')
|
||||
const [addPasswordError, setAddPasswordError] = useState('')
|
||||
const [addSaving, setAddSaving] = useState(false)
|
||||
const [showAddBrandsDropdown, setShowAddBrandsDropdown] = useState(false)
|
||||
const addBrandsRef = useRef(null)
|
||||
|
||||
const canManageTeam = user?.role === 'superadmin' || user?.role === 'manager'
|
||||
|
||||
const copyIssueLink = (teamId) => {
|
||||
const base = `${window.location.origin}/submit-issue`
|
||||
const url = teamId ? `${base}?team=${teamId}` : base
|
||||
navigator.clipboard.writeText(url)
|
||||
toast.success(t('issues.linkCopied'))
|
||||
}
|
||||
|
||||
// Close brands dropdown on outside click
|
||||
useEffect(() => {
|
||||
const handler = (e) => {
|
||||
if (addBrandsRef.current && !addBrandsRef.current.contains(e.target)) setShowAddBrandsDropdown(false)
|
||||
}
|
||||
if (showAddBrandsDropdown) document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [showAddBrandsDropdown])
|
||||
|
||||
const openNew = () => {
|
||||
setPanelMember({ role: 'content_writer' })
|
||||
setPanelIsEditingSelf(false)
|
||||
setAddForm({ ...EMPTY_MEMBER })
|
||||
setAddConfirmPassword('')
|
||||
setAddPasswordError('')
|
||||
setShowAddModal(true)
|
||||
}
|
||||
|
||||
const handleAddMember = async () => {
|
||||
setAddPasswordError('')
|
||||
if (addForm.password && addForm.password !== addConfirmPassword) {
|
||||
setAddPasswordError(t('team.passwordsDoNotMatch'))
|
||||
return
|
||||
}
|
||||
setAddSaving(true)
|
||||
try {
|
||||
const payload = {
|
||||
name: addForm.name,
|
||||
email: addForm.email,
|
||||
role: addForm.permission_level,
|
||||
role_id: addForm.role_id || null,
|
||||
brands: addForm.brands,
|
||||
phone: addForm.phone,
|
||||
modules: addForm.modules,
|
||||
preferred_language: addForm.preferred_language || 'en',
|
||||
}
|
||||
if (addForm.password) payload.password = addForm.password
|
||||
const created = await api.post('/users/team', payload)
|
||||
const memberId = created?.id || created?.Id
|
||||
|
||||
// Sync team memberships
|
||||
if (addForm.team_ids.length > 0 && memberId) {
|
||||
for (const teamId of addForm.team_ids) {
|
||||
await api.post(`/teams/${teamId}/members`, { user_id: memberId })
|
||||
}
|
||||
}
|
||||
|
||||
await loadTeam()
|
||||
await loadTeams()
|
||||
setShowAddModal(false)
|
||||
toast.success(t('team.memberAdded') || 'Member added')
|
||||
} catch (err) {
|
||||
console.error('Add member failed:', err)
|
||||
toast.error(err.message || t('common.failedToSave'))
|
||||
} finally {
|
||||
setAddSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const updateAdd = (field, value) => setAddForm(f => ({ ...f, [field]: value }))
|
||||
const toggleAddBrand = (name) => {
|
||||
setAddForm(f => ({
|
||||
...f,
|
||||
brands: f.brands.includes(name) ? f.brands.filter(b => b !== name) : [...f.brands, name],
|
||||
}))
|
||||
}
|
||||
|
||||
const openEdit = (member) => {
|
||||
@@ -40,18 +132,17 @@ export default function Team() {
|
||||
|
||||
const handlePanelSave = async (memberId, data, isEditingSelf) => {
|
||||
try {
|
||||
if (isEditingSelf) {
|
||||
if (isEditingSelf && user?.role !== 'superadmin') {
|
||||
await api.patch('/users/me/profile', {
|
||||
name: data.name,
|
||||
team_role: data.role,
|
||||
brands: data.brands,
|
||||
phone: data.phone,
|
||||
})
|
||||
} else {
|
||||
const payload = {
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
team_role: data.role,
|
||||
role: data.role,
|
||||
role_id: data.role_id,
|
||||
brands: data.brands,
|
||||
phone: data.phone,
|
||||
modules: data.modules,
|
||||
@@ -67,7 +158,7 @@ export default function Team() {
|
||||
}
|
||||
|
||||
// Sync team memberships if team_ids provided
|
||||
if (data.team_ids !== undefined && memberId && !isEditingSelf) {
|
||||
if (data.team_ids !== undefined && memberId) {
|
||||
const member = teamMembers.find(m => (m.id || m._id) === memberId)
|
||||
const currentTeamIds = member?.teams ? member.teams.map(t => t.id) : []
|
||||
const targetTeamIds = data.team_ids || []
|
||||
@@ -83,11 +174,11 @@ export default function Team() {
|
||||
}
|
||||
}
|
||||
|
||||
loadTeam()
|
||||
loadTeams()
|
||||
await loadTeam()
|
||||
await loadTeams()
|
||||
} catch (err) {
|
||||
console.error('Save failed:', err)
|
||||
alert(err.message || 'Failed to save')
|
||||
toast.error(err.message || t('common.failedToSave'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,11 +189,11 @@ export default function Team() {
|
||||
} else {
|
||||
await api.post('/teams', data)
|
||||
}
|
||||
loadTeams()
|
||||
loadTeam()
|
||||
await loadTeams()
|
||||
await loadTeam()
|
||||
} catch (err) {
|
||||
console.error('Team save failed:', err)
|
||||
alert(err.message || 'Failed to save team')
|
||||
toast.error(err.message || t('team.failedToSaveTeam'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,8 +202,8 @@ export default function Team() {
|
||||
await api.delete(`/teams/${teamId}`)
|
||||
setPanelTeam(null)
|
||||
if (teamFilter === teamId) setTeamFilter(null)
|
||||
loadTeams()
|
||||
loadTeam()
|
||||
await loadTeams()
|
||||
await loadTeam()
|
||||
} catch (err) {
|
||||
console.error('Team delete failed:', err)
|
||||
}
|
||||
@@ -124,7 +215,7 @@ export default function Team() {
|
||||
setSelectedMember(null)
|
||||
}
|
||||
setPanelMember(null)
|
||||
loadTeam()
|
||||
await loadTeam()
|
||||
}
|
||||
|
||||
const openMemberDetail = async (member) => {
|
||||
@@ -147,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">
|
||||
@@ -162,14 +255,14 @@ 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()}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-xl font-bold text-text-primary">{selectedMember.name}</h2>
|
||||
<p className="text-sm text-text-secondary capitalize">{(selectedMember.team_role || selectedMember.role)?.replace('_', ' ')}</p>
|
||||
<p className="text-sm text-text-secondary capitalize">{selectedMember.role_name || selectedMember.team_role || ''}</p>
|
||||
{selectedMember.email && (
|
||||
<p className="text-sm text-text-tertiary mt-1">{selectedMember.email}</p>
|
||||
)}
|
||||
@@ -190,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>
|
||||
@@ -211,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>
|
||||
@@ -236,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>
|
||||
@@ -303,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'}`}
|
||||
@@ -321,13 +414,23 @@ export default function Team() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{/* Copy generic issue link */}
|
||||
<button
|
||||
onClick={() => copyIssueLink()}
|
||||
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" />
|
||||
{t('issues.copyPublicLink')}
|
||||
</button>
|
||||
|
||||
{/* Edit own profile button */}
|
||||
<button
|
||||
onClick={() => {
|
||||
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')}
|
||||
@@ -337,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')}
|
||||
@@ -367,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')}
|
||||
@@ -380,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})
|
||||
@@ -430,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">
|
||||
@@ -445,15 +548,24 @@ export default function Team() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => copyIssueLink(tid)}
|
||||
className="px-2 py-1.5 text-sm text-text-tertiary hover:text-blue-600 hover:bg-blue-100 rounded-lg transition-colors"
|
||||
title={t('team.copyIssueLink')}
|
||||
>
|
||||
<Link2 className="w-4 h-4" />
|
||||
</button>
|
||||
{canManageTeam && (
|
||||
<button
|
||||
onClick={() => setPanelTeam(team)}
|
||||
className="px-3 py-1.5 text-sm font-medium text-blue-600 hover:bg-blue-100 rounded-lg transition-colors"
|
||||
className="px-2 py-1.5 text-sm font-medium text-blue-600 hover:bg-blue-100 rounded-lg transition-colors"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Team members */}
|
||||
{members.length === 0 ? (
|
||||
@@ -473,7 +585,7 @@ export default function Team() {
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-text-primary">{member.name}</p>
|
||||
<p className="text-xs text-text-tertiary capitalize">{(member.team_role || member.role)?.replace('_', ' ')}</p>
|
||||
<p className="text-xs text-text-tertiary capitalize">{member.role_name || member.team_role || ''}</p>
|
||||
</div>
|
||||
{member.brands && member.brands.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 shrink-0">
|
||||
@@ -491,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" />
|
||||
@@ -517,7 +629,7 @@ export default function Team() {
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-text-primary">{member.name}</p>
|
||||
<p className="text-xs text-text-tertiary capitalize">{(member.team_role || member.role)?.replace('_', ' ')}</p>
|
||||
<p className="text-xs text-text-tertiary capitalize">{member.role_name || member.team_role || ''}</p>
|
||||
</div>
|
||||
{member.brands && member.brands.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 shrink-0">
|
||||
@@ -535,7 +647,172 @@ export default function Team() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Team Member Panel */}
|
||||
{/* Add Member Modal */}
|
||||
<Modal isOpen={showAddModal} onClose={() => setShowAddModal(false)} title={t('team.addMember')} size="md">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.fullName')} *</label>
|
||||
<input type="text" value={addForm.name} onChange={e => updateAdd('name', 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('team.fullName')} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.email')} *</label>
|
||||
<input type="email" value={addForm.email} onChange={e => updateAdd('email', 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="email@example.com" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.password')}</label>
|
||||
<input type="password" value={addForm.password} onChange={e => updateAdd('password', 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="••••••••" />
|
||||
{!addForm.password && <p className="text-xs text-text-tertiary mt-1">{t('team.defaultPassword')}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.confirmPassword')}</label>
|
||||
<input type="password" value={addConfirmPassword}
|
||||
onChange={e => { setAddConfirmPassword(e.target.value); setAddPasswordError('') }}
|
||||
disabled={!addForm.password}
|
||||
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 disabled:opacity-50" placeholder="••••••••" />
|
||||
{addPasswordError && <p className="text-xs text-red-500 mt-1">{addPasswordError}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{user?.role === 'superadmin' && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.permissionLevel')}</label>
|
||||
<select value={addForm.permission_level} onChange={e => updateAdd('permission_level', 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">
|
||||
{PERMISSION_LEVELS.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('team.role')}</label>
|
||||
<select value={addForm.role_id || ''} onChange={e => updateAdd('role_id', e.target.value ? Number(e.target.value) : null)}
|
||||
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>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.phone')}</label>
|
||||
<input type="text" value={addForm.phone} onChange={e => updateAdd('phone', 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="+966 ..." />
|
||||
</div>
|
||||
|
||||
{/* Brands multi-select */}
|
||||
<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-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>
|
||||
<ChevronDown className={`w-4 h-4 text-text-tertiary shrink-0 transition-transform ${showAddBrandsDropdown ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{addForm.brands.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||
{addForm.brands.map(b => (
|
||||
<span key={b} className="inline-flex items-center gap-1 text-[10px] px-2 py-0.5 rounded-full bg-brand-primary/10 text-brand-primary font-medium">
|
||||
{b}
|
||||
<button type="button" onClick={() => toggleAddBrand(b)} className="hover:text-red-500"><X className="w-2.5 h-2.5" /></button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{showAddBrandsDropdown && (
|
||||
<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-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>
|
||||
<span className="text-sm text-text-primary">{brand.icon ? `${brand.icon} ` : ''}{name}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modules */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.modules')}</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{ALL_MODULES.map(mod => {
|
||||
const active = addForm.modules.includes(mod)
|
||||
const colors = MODULE_COLORS[mod]
|
||||
return (
|
||||
<button key={mod} type="button"
|
||||
onClick={() => updateAdd('modules', active ? addForm.modules.filter(m => m !== mod) : [...addForm.modules, mod])}
|
||||
className={`text-xs px-3 py-1.5 rounded-full border font-medium transition-colors ${active ? colors.on : colors.off}`}>
|
||||
{MODULE_LABELS[mod]}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Teams */}
|
||||
{teams.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('teams.teams')}</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{teams.map(team => {
|
||||
const tid = team.id || team._id
|
||||
const active = addForm.team_ids.includes(tid)
|
||||
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-text-tertiary border-gray-200'}`}>
|
||||
{team.name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preferred Language */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('users.preferredLanguage')}</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{[{ value: 'en', label: 'English', flag: '🇬🇧' }, { value: 'ar', label: 'العربية', flag: '🇸🇦' }].map(l => (
|
||||
<button
|
||||
key={l.value}
|
||||
type="button"
|
||||
onClick={() => updateAdd('preferred_language', l.value)}
|
||||
className={`p-2 rounded-lg border-2 text-center transition-all ${
|
||||
addForm.preferred_language === l.value
|
||||
? 'border-brand-primary bg-brand-primary/5'
|
||||
: 'border-border hover:border-brand-primary/30'
|
||||
}`}
|
||||
>
|
||||
<span className="text-lg">{l.flag}</span>
|
||||
<span className="text-xs font-medium text-text-primary ms-1.5">{l.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button onClick={handleAddMember}
|
||||
disabled={!addForm.name || !addForm.email || addSaving}
|
||||
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 ${addSaving ? 'btn-loading' : ''}`}>
|
||||
{t('team.addMember')}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Team Member Panel (edit only) */}
|
||||
{panelMember && (
|
||||
<TeamMemberPanel
|
||||
member={panelMember}
|
||||
|
||||
@@ -0,0 +1,551 @@
|
||||
import { useState, useEffect, useContext, useMemo } from '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'
|
||||
import { api } from '../utils/api'
|
||||
import Modal from '../components/Modal'
|
||||
import BulkSelectBar from '../components/BulkSelectBar'
|
||||
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 SORT_OPTIONS = [
|
||||
{ value: 'updated_at', dir: 'desc', labelKey: 'translations.sortRecentlyUpdated' },
|
||||
{ value: 'created_at', dir: 'desc', labelKey: 'translations.sortNewest' },
|
||||
{ value: 'created_at', dir: 'asc', labelKey: 'translations.sortOldest' },
|
||||
{ value: 'title', dir: 'asc', labelKey: 'translations.sortTitleAZ' },
|
||||
]
|
||||
|
||||
export default function Translations() {
|
||||
const { t } = useLanguage()
|
||||
const { brands, teamMembers } = useContext(AppContext)
|
||||
const { user, canDeleteResource } = useAuth()
|
||||
const toast = useToast()
|
||||
|
||||
const [translations, setTranslations] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [filters, setFilters] = useState({ brand: '', status: '', creator: '' })
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const [selectedTranslation, setSelectedTranslation] = useState(null)
|
||||
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())
|
||||
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false)
|
||||
|
||||
// View + sort
|
||||
const [viewMode, setViewMode] = useState('list')
|
||||
const [sortOption, setSortOption] = useState(0)
|
||||
const [listSortBy, setListSortBy] = useState('updated_at')
|
||||
const [listSortDir, setListSortDir] = useState('desc')
|
||||
|
||||
const [assignableUsers, setAssignableUsers] = useState([])
|
||||
|
||||
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 () => {
|
||||
try {
|
||||
const res = await api.get('/translations')
|
||||
setTranslations(Array.isArray(res) ? res : [])
|
||||
} catch (err) {
|
||||
console.error('Failed to load translations:', err)
|
||||
toast.error(t('translations.loadFailed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!newTranslation.title) {
|
||||
toast.error(t('translations.titleRequired'))
|
||||
return
|
||||
}
|
||||
if (!newTranslation.source_content) {
|
||||
toast.error(t('translations.sourceContentRequired'))
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
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: '', source_language: 'EN', source_content: '', brand_id: '', post_id: '', approver_ids: [] })
|
||||
loadTranslations()
|
||||
setSelectedTranslation(created)
|
||||
} catch (err) {
|
||||
console.error('Create failed:', err)
|
||||
toast.error(t('translations.createFailed'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
try {
|
||||
await api.delete(`/translations/${id}`)
|
||||
toast.success(t('translations.deleted'))
|
||||
setSelectedTranslation(null)
|
||||
loadTranslations()
|
||||
} catch (err) {
|
||||
toast.error(t('translations.deleteFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
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] })
|
||||
toast.success(t('translations.deleted'))
|
||||
setSelectedIds(new Set())
|
||||
setShowBulkDeleteConfirm(false)
|
||||
loadTranslations()
|
||||
} catch (err) {
|
||||
toast.error(t('translations.deleteFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSelect = (id) => {
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedIds.size === sortedTranslations.length) setSelectedIds(new Set())
|
||||
else setSelectedIds(new Set(sortedTranslations.map(t => t.Id)))
|
||||
}
|
||||
|
||||
const filteredTranslations = useMemo(() => {
|
||||
return translations.filter(t => {
|
||||
if (filters.brand && String(t.brand_id) !== filters.brand) return false
|
||||
if (filters.status && t.status !== filters.status) return false
|
||||
if (filters.creator && String(t.created_by_user_id) !== filters.creator) return false
|
||||
if (searchTerm && !t.title?.toLowerCase().includes(searchTerm.toLowerCase())) return false
|
||||
return true
|
||||
})
|
||||
}, [translations, filters, searchTerm])
|
||||
|
||||
const sortedTranslations = useMemo(() => {
|
||||
const sBy = viewMode === 'grid' ? SORT_OPTIONS[sortOption].value : listSortBy
|
||||
const sDir = viewMode === 'grid' ? SORT_OPTIONS[sortOption].dir : listSortDir
|
||||
return [...filteredTranslations].sort((a, b) => {
|
||||
let cmp = 0
|
||||
if (sBy === 'updated_at') {
|
||||
cmp = (a.UpdatedAt || a.updated_at || '').localeCompare(b.UpdatedAt || b.updated_at || '')
|
||||
} else if (sBy === 'created_at') {
|
||||
cmp = (a.CreatedAt || a.created_at || '').localeCompare(b.CreatedAt || b.created_at || '')
|
||||
} else if (sBy === 'title') {
|
||||
cmp = (a.title || '').localeCompare(b.title || '')
|
||||
} else if (sBy === 'status') {
|
||||
cmp = (a.status || '').localeCompare(b.status || '')
|
||||
}
|
||||
return sDir === 'asc' ? cmp : -cmp
|
||||
})
|
||||
}, [filteredTranslations, viewMode, sortOption, listSortBy, listSortDir])
|
||||
|
||||
const toggleListSort = (col) => {
|
||||
if (listSortBy === col) setListSortDir(d => d === 'asc' ? 'desc' : 'asc')
|
||||
else { setListSortBy(col); setListSortDir('asc') }
|
||||
}
|
||||
|
||||
const SortIcon = ({ col }) => {
|
||||
if (listSortBy !== col) return null
|
||||
return listSortDir === 'asc'
|
||||
? <ChevronUp className="w-3 h-3 inline ms-0.5" />
|
||||
: <ChevronDown className="w-3 h-3 inline ms-0.5" />
|
||||
}
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return '—'
|
||||
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
|
||||
const getLangLabel = (code) => AVAILABLE_LANGUAGES.find(l => l.code === code)?.label || code
|
||||
|
||||
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('translations.title')}</h1>
|
||||
<p className="text-sm text-text-secondary mt-1">{t('translations.subtitle')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center bg-surface-tertiary rounded-lg p-0.5">
|
||||
{[
|
||||
{ mode: 'grid', icon: LayoutGrid, label: t('translations.grid') },
|
||||
{ mode: 'list', icon: List, label: t('translations.list') },
|
||||
].map(({ mode, icon: Icon, label }) => (
|
||||
<button
|
||||
key={mode}
|
||||
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-surface text-text-primary shadow-sm'
|
||||
: 'text-text-tertiary hover:text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors shadow-sm"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span className="font-medium">{t('translations.newTranslation')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="relative flex-1 min-w-[200px] max-w-md">
|
||||
<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 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>
|
||||
|
||||
<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-surface text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 transition-colors"
|
||||
>
|
||||
<option value="">{t('translations.allBrands')}</option>
|
||||
{brands.map(b => <option key={b._id} value={b._id}>{b.name}</option>)}
|
||||
</select>
|
||||
|
||||
<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-surface text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 transition-colors"
|
||||
>
|
||||
<option value="">{t('translations.allStatuses')}</option>
|
||||
<option value="draft">{t('translations.status.draft')}</option>
|
||||
<option value="pending_review">{t('translations.status.pendingReview')}</option>
|
||||
<option value="approved">{t('translations.status.approved')}</option>
|
||||
<option value="rejected">{t('translations.status.rejected')}</option>
|
||||
<option value="revision_requested">{t('translations.status.revisionRequested')}</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={filters.creator}
|
||||
onChange={e => setFilters(f => ({ ...f, creator: e.target.value }))}
|
||||
className="text-sm border border-border rounded-lg px-3 py-2 bg-surface text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 transition-colors"
|
||||
>
|
||||
<option value="">{t('translations.allCreators')}</option>
|
||||
{teamMembers.map(m => <option key={m._id} value={m._id}>{m.name}</option>)}
|
||||
</select>
|
||||
|
||||
{viewMode === 'grid' && (
|
||||
<select
|
||||
value={sortOption}
|
||||
onChange={e => setSortOption(Number(e.target.value))}
|
||||
className="text-sm border border-border rounded-lg px-3 py-2 bg-surface text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 transition-colors"
|
||||
>
|
||||
{SORT_OPTIONS.map((opt, i) => <option key={i} value={i}>{t(opt.labelKey)}</option>)}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bulk select bar */}
|
||||
{selectedIds.size > 0 && (
|
||||
<BulkSelectBar
|
||||
count={selectedIds.size}
|
||||
onClear={() => setSelectedIds(new Set())}
|
||||
onDelete={() => setShowBulkDeleteConfirm(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
{loading ? (
|
||||
viewMode === 'grid' ? <SkeletonCard count={6} /> : <SkeletonTable rows={5} cols={7} />
|
||||
) : viewMode === 'grid' ? (
|
||||
/* Grid View */
|
||||
sortedTranslations.length === 0 ? (
|
||||
<div className="text-center py-16">
|
||||
<Languages className="w-12 h-12 text-text-tertiary mx-auto mb-3" />
|
||||
<p className="text-text-secondary">{t('translations.noTranslations')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{sortedTranslations.map(tr => (
|
||||
<div
|
||||
key={tr.Id}
|
||||
onClick={() => setSelectedTranslation(tr)}
|
||||
className="bg-surface rounded-xl border border-border p-5 hover:shadow-md transition-all cursor-pointer group"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<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 ${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">
|
||||
{getLangLabel(tr.source_language)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
/* List View */
|
||||
sortedTranslations.length === 0 ? (
|
||||
<div className="text-center py-16">
|
||||
<Languages className="w-12 h-12 text-text-tertiary mx-auto mb-3" />
|
||||
<p className="text-text-secondary">{t('translations.noTranslations')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<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-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-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-start text-xs font-semibold text-text-secondary uppercase">
|
||||
{t('translations.sourceLanguage')}
|
||||
</th>
|
||||
<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-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>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedTranslations.map(tr => (
|
||||
<tr
|
||||
key={tr.Id}
|
||||
onClick={() => setSelectedTranslation(tr)}
|
||||
className="border-b border-border last:border-0 hover:bg-surface-secondary cursor-pointer transition-colors"
|
||||
>
|
||||
<td className="px-4 py-3" onClick={e => e.stopPropagation()}>
|
||||
<input type="checkbox" checked={selectedIds.has(tr.Id)} onChange={() => toggleSelect(tr.Id)} className="rounded border-border" />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm font-medium text-text-primary">{tr.title}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-blue-50 text-blue-600 font-medium">
|
||||
{getLangLabel(tr.source_language)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<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>
|
||||
<td className="px-4 py-3 text-sm text-text-secondary">{tr.brand_name || '—'}</td>
|
||||
<td className="px-4 py-3 text-sm text-text-secondary">{tr.creator_name || '—'}</td>
|
||||
<td className="px-4 py-3 text-sm text-text-secondary">{tr.translation_count || 0}</td>
|
||||
<td className="px-4 py-3 text-sm text-text-tertiary">{formatDate(tr.UpdatedAt || tr.updated_at)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Detail Panel */}
|
||||
{selectedTranslation && (
|
||||
<TranslationDetailPanel
|
||||
translation={selectedTranslation}
|
||||
onClose={() => setSelectedTranslation(null)}
|
||||
onUpdate={loadTranslations}
|
||||
onDelete={handleDelete}
|
||||
assignableUsers={assignableUsers}
|
||||
posts={posts}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Create Modal */}
|
||||
<Modal isOpen={showCreateModal} onClose={() => setShowCreateModal(false)} title={t('translations.createTranslation')} size="md">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('translations.titleLabel')} *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newTranslation.title}
|
||||
onChange={e => setNewTranslation(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('translations.titlePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('translations.sourceLanguage')} *</label>
|
||||
<select
|
||||
value={newTranslation.source_language}
|
||||
onChange={e => setNewTranslation(f => ({ ...f, source_language: 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>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('translations.sourceContent')} *</label>
|
||||
<textarea
|
||||
value={newTranslation.source_content}
|
||||
onChange={e => setNewTranslation(f => ({ ...f, source_content: 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-[120px] resize-y"
|
||||
placeholder={t('translations.sourceContentPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('translations.brand')}</label>
|
||||
<select
|
||||
value={newTranslation.brand_id}
|
||||
onChange={e => setNewTranslation(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"
|
||||
>
|
||||
<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('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
|
||||
selected={newTranslation.approver_ids}
|
||||
onChange={ids => setNewTranslation(f => ({ ...f, approver_ids: ids }))}
|
||||
users={assignableUsers}
|
||||
/>
|
||||
</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')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={saving || !newTranslation.title || !newTranslation.source_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"
|
||||
>
|
||||
{saving ? t('translations.creating') : t('common.create')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Bulk delete confirm */}
|
||||
<Modal
|
||||
isOpen={showBulkDeleteConfirm}
|
||||
onClose={() => setShowBulkDeleteConfirm(false)}
|
||||
title={t('translations.deleteTranslation')}
|
||||
isConfirm
|
||||
danger
|
||||
onConfirm={handleBulkDelete}
|
||||
confirmText={t('common.delete')}
|
||||
>
|
||||
{t('translations.bulkDeleteDesc', { count: selectedIds.size })}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,316 +0,0 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Plus, Shield, Edit2, Trash2, UserCheck } from 'lucide-react'
|
||||
import { api } from '../utils/api'
|
||||
import Modal from '../components/Modal'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { SkeletonTable } from '../components/SkeletonLoader'
|
||||
|
||||
const ROLES = [
|
||||
{ value: 'superadmin', label: 'Superadmin', color: 'bg-purple-100 text-purple-700', icon: '👑' },
|
||||
{ value: 'manager', label: 'Manager', color: 'bg-blue-100 text-blue-700', icon: '📊' },
|
||||
{ value: 'contributor', label: 'Contributor', color: 'bg-green-100 text-green-700', icon: '✏️' },
|
||||
]
|
||||
|
||||
const EMPTY_FORM = {
|
||||
name: '', email: '', password: '', role: 'contributor', avatar: '',
|
||||
}
|
||||
|
||||
function RoleBadge({ role }) {
|
||||
const roleInfo = ROLES.find(r => r.value === role) || ROLES[2]
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 text-xs font-medium px-2.5 py-1 rounded-full ${roleInfo.color}`}>
|
||||
<span>{roleInfo.icon}</span>
|
||||
{roleInfo.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Users() {
|
||||
const { user: currentUser } = useAuth()
|
||||
const [users, setUsers] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [editingUser, setEditingUser] = useState(null)
|
||||
const [form, setForm] = useState(EMPTY_FORM)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [userToDelete, setUserToDelete] = useState(null)
|
||||
|
||||
useEffect(() => { loadUsers() }, [])
|
||||
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
const res = await api.get('/users')
|
||||
setUsers(res)
|
||||
} catch (err) {
|
||||
console.error('Failed to load users:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const data = {
|
||||
name: form.name,
|
||||
email: form.email,
|
||||
role: form.role,
|
||||
avatar: form.avatar || null,
|
||||
}
|
||||
if (form.password) data.password = form.password
|
||||
|
||||
if (editingUser) {
|
||||
await api.patch(`/users/${editingUser.id}`, data)
|
||||
} else {
|
||||
if (!form.password) {
|
||||
alert('Password is required for new users')
|
||||
return
|
||||
}
|
||||
data.password = form.password
|
||||
await api.post('/users', data)
|
||||
}
|
||||
setShowModal(false)
|
||||
setEditingUser(null)
|
||||
setForm(EMPTY_FORM)
|
||||
loadUsers()
|
||||
} catch (err) {
|
||||
console.error('Save failed:', err)
|
||||
alert('Failed to save user: ' + err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const openEdit = (user) => {
|
||||
setEditingUser(user)
|
||||
setForm({
|
||||
name: user.name || '',
|
||||
email: user.email || '',
|
||||
password: '',
|
||||
role: user.role || 'contributor',
|
||||
avatar: user.avatar || '',
|
||||
})
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
const openNew = () => {
|
||||
setEditingUser(null)
|
||||
setForm(EMPTY_FORM)
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!userToDelete) return
|
||||
try {
|
||||
await api.delete(`/users/${userToDelete.id}`)
|
||||
loadUsers()
|
||||
setUserToDelete(null)
|
||||
} catch (err) {
|
||||
console.error('Delete failed:', err)
|
||||
alert('Failed to delete user')
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="h-12 bg-surface-tertiary rounded-xl animate-pulse"></div>
|
||||
<SkeletonTable rows={5} cols={5} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 flex items-center gap-3">
|
||||
<Shield className="w-7 h-7 text-purple-600" />
|
||||
User Management
|
||||
</h1>
|
||||
<p className="text-sm text-text-tertiary mt-1">{users.length} user{users.length !== 1 ? 's' : ''}</p>
|
||||
</div>
|
||||
<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"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add User
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Users List */}
|
||||
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface-secondary">
|
||||
<th className="text-left px-5 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">User</th>
|
||||
<th className="text-left px-5 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Email</th>
|
||||
<th className="text-left px-5 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Role</th>
|
||||
<th className="text-left px-5 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Created</th>
|
||||
<th className="text-right px-5 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider w-24">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border-light">
|
||||
{users.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="py-12 text-center text-sm text-text-tertiary">
|
||||
No users found
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
users.map(user => {
|
||||
const isCurrentUser = currentUser?.id === user.id
|
||||
const roleInfo = ROLES.find(r => r.value === user.role) || ROLES[2]
|
||||
return (
|
||||
<tr key={user.id} className="hover:bg-surface-secondary group">
|
||||
<td className="px-5 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-full bg-gradient-to-br ${user.role === 'superadmin' ? 'from-purple-500 to-pink-500' : 'from-blue-500 to-indigo-500'} flex items-center justify-center text-white font-bold text-sm`}>
|
||||
{user.name?.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium text-text-primary">{user.name}</p>
|
||||
{isCurrentUser && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-100 text-emerald-700 font-medium">
|
||||
You
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-4 text-sm text-text-secondary">{user.email}</td>
|
||||
<td className="px-5 py-4">
|
||||
<RoleBadge role={user.role} />
|
||||
</td>
|
||||
<td className="px-5 py-4 text-sm text-text-tertiary">
|
||||
{user.createdAt ? new Date(user.createdAt).toLocaleDateString() : '—'}
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button
|
||||
onClick={() => openEdit(user)}
|
||||
className="p-1.5 hover:bg-surface-tertiary rounded-lg text-text-tertiary hover:text-text-primary"
|
||||
title="Edit user"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
{!isCurrentUser && (
|
||||
<button
|
||||
onClick={() => { setUserToDelete(user); setShowDeleteConfirm(true) }}
|
||||
className="p-1.5 hover:bg-red-50 rounded-lg text-text-tertiary hover:text-red-500"
|
||||
title="Delete user"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Add/Edit User Modal */}
|
||||
<Modal
|
||||
isOpen={showModal}
|
||||
onClose={() => { setShowModal(false); setEditingUser(null) }}
|
||||
title={editingUser ? 'Edit User' : 'Add New User'}
|
||||
size="md"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={e => setForm(f => ({ ...f, name: 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="Full name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Email *</label>
|
||||
<input
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={e => setForm(f => ({ ...f, email: 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="user@company.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">
|
||||
Password {editingUser && '(leave blank to keep current)'}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={e => setForm(f => ({ ...f, password: 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="••••••••"
|
||||
required={!editingUser}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Role *</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{ROLES.map(r => (
|
||||
<button
|
||||
key={r.value}
|
||||
type="button"
|
||||
onClick={() => setForm(f => ({ ...f, role: r.value }))}
|
||||
className={`p-3 rounded-lg border-2 text-center transition-all ${
|
||||
form.role === r.value
|
||||
? 'border-brand-primary bg-brand-primary/5'
|
||||
: 'border-border hover:border-brand-primary/30'
|
||||
}`}
|
||||
>
|
||||
<div className="text-2xl mb-1">{r.icon}</div>
|
||||
<div className="text-xs font-medium text-text-primary">{r.label}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
|
||||
<button
|
||||
onClick={() => { setShowModal(false); setEditingUser(null) }}
|
||||
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!form.name || !form.email || (!editingUser && !form.password)}
|
||||
className="px-5 py-2 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"
|
||||
>
|
||||
{editingUser ? 'Save Changes' : 'Add User'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<Modal
|
||||
isOpen={showDeleteConfirm}
|
||||
onClose={() => { setShowDeleteConfirm(false); setUserToDelete(null) }}
|
||||
title="Delete User?"
|
||||
isConfirm
|
||||
danger
|
||||
confirmText="Delete User"
|
||||
onConfirm={confirmDelete}
|
||||
>
|
||||
Are you sure you want to delete <strong>{userToDelete?.name}</strong>? This action cannot be undone.
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -37,8 +37,10 @@ const normalize = (data) => {
|
||||
const handleResponse = async (r, label) => {
|
||||
if (!r.ok) {
|
||||
if (r.status === 401) {
|
||||
// Unauthorized (not logged in) - redirect to login if not already there
|
||||
if (!window.location.pathname.includes('/login')) {
|
||||
// Unauthorized — redirect to login unless on a public page
|
||||
const p = window.location.pathname;
|
||||
const isPublic = p.startsWith('/review/') || p.startsWith('/review-post/') || p.startsWith('/submit-issue') || p.startsWith('/track/');
|
||||
if (!p.includes('/login') && !isPublic) {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
@@ -77,11 +79,32 @@ export const api = {
|
||||
credentials: 'include',
|
||||
}).then(r => handleResponse(r, `DELETE ${path}`)),
|
||||
|
||||
upload: (path, formData) => fetch(`${API}${path}`, {
|
||||
upload: (path, formData, opts = {}) => {
|
||||
if (opts.onUploadProgress) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
xhr.open('POST', `${API}${path}`)
|
||||
xhr.withCredentials = true
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (e.lengthComputable) opts.onUploadProgress({ loaded: e.loaded, total: e.total })
|
||||
}
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
try { resolve(normalize(JSON.parse(xhr.responseText))) } catch { resolve(xhr.responseText) }
|
||||
} else {
|
||||
reject(new Error(`Upload failed: ${xhr.status}`))
|
||||
}
|
||||
}
|
||||
xhr.onerror = () => reject(new Error('Upload failed'))
|
||||
xhr.send(formData)
|
||||
})
|
||||
}
|
||||
return fetch(`${API}${path}`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: formData,
|
||||
}).then(r => handleResponse(r, `UPLOAD ${path}`)),
|
||||
}).then(r => handleResponse(r, `UPLOAD ${path}`))
|
||||
},
|
||||
};
|
||||
|
||||
// Brand color palette — dynamically assigned from a rotating palette
|
||||
@@ -139,16 +162,21 @@ export const STATUS_CONFIG = {
|
||||
completed: { label: 'Completed', bg: 'bg-blue-50', text: 'text-blue-700', dot: 'bg-blue-400' },
|
||||
cancelled: { label: 'Cancelled', bg: 'bg-red-50', text: 'text-red-700', dot: 'bg-red-400' },
|
||||
planning: { label: 'Planning', bg: 'bg-gray-100', text: 'text-gray-600', dot: 'bg-gray-400' },
|
||||
// Issue-specific statuses
|
||||
new: { label: 'New', bg: 'bg-purple-100', text: 'text-purple-700', dot: 'bg-purple-500' },
|
||||
acknowledged: { label: 'Acknowledged', bg: 'bg-blue-100', text: 'text-blue-700', dot: 'bg-blue-500' },
|
||||
resolved: { label: 'Resolved', bg: 'bg-emerald-100', text: 'text-emerald-700', dot: 'bg-emerald-500' },
|
||||
declined: { label: 'Declined', bg: 'bg-surface-tertiary', text: 'text-text-secondary', dot: 'bg-text-tertiary' },
|
||||
};
|
||||
|
||||
export const getStatusConfig = (status) => STATUS_CONFIG[status] || STATUS_CONFIG['draft'];
|
||||
|
||||
// Priority config
|
||||
export const PRIORITY_CONFIG = {
|
||||
low: { label: 'Low', color: 'bg-gray-400' },
|
||||
medium: { label: 'Medium', color: 'bg-amber-400' },
|
||||
high: { label: 'High', color: 'bg-orange-500' },
|
||||
urgent: { label: 'Urgent', color: 'bg-red-500' },
|
||||
low: { label: 'Low', color: 'bg-gray-400', bg: 'bg-surface-tertiary', text: 'text-text-secondary', dot: 'bg-text-tertiary' },
|
||||
medium: { label: 'Medium', color: 'bg-amber-400', bg: 'bg-blue-100', text: 'text-blue-700', dot: 'bg-blue-500' },
|
||||
high: { label: 'High', color: 'bg-orange-500', bg: 'bg-orange-100', text: 'text-orange-700', dot: 'bg-orange-500' },
|
||||
urgent: { label: 'Urgent', color: 'bg-red-500', bg: 'bg-red-100', text: 'text-red-700', dot: 'bg-red-500' },
|
||||
};
|
||||
|
||||
// Shared helper: extract initials from a name string
|
||||
|
||||
@@ -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
|
||||
}
|
||||