Compare commits

...

13 Commits

Author SHA1 Message Date
fahed 94ce012837 feat: artefact version auto-advance, post_id on artefacts, test-email endpoint
Deploy / deploy (push) Successful in 13s
- Auto-advance artefact to next working version on rejection/revision
- Add post_id field to artefact creation
- Add request timeout (20s) to NocoDB client
- Add POST /api/admin/test-email for diagnosing SMTP issues
- Fix FK column creation logging

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 12:27:23 +03:00
fahed a67b2afb0d fix: post thumbnail from linked design artefact, not old PostAttachments
Deploy / deploy (push) Successful in 12s
- Post.thumbnail_url now synced from linked design artefact's first attachment
- syncPostThumbnail() called on: artefact attachment upload, artefact link/unlink
- Removed old PostAttachments-based thumbMap from GET /posts and GET /campaigns/:id/posts
- Added thumbnail_url to Posts TEXT_COLUMNS
- Caption link picker filters by copy_type='caption', body by copy_type='body'

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 17:25:39 +03:00
fahed 378d91648b refactor: unify post composition — all assets are artefacts
PostDetail now uses Artefacts exclusively for all 4 asset types:
- Caption copy = Artefact with type='copy', copy_type='caption'
- Body copy = Artefact with type='copy', copy_type='body'
- Design = Artefact with type='design'
- Video = Artefact with type='video'

Removed TranslationDetailPanel from PostDetail entirely.
Same ArtefactDetailPanel, same workflow, same version management
for all asset types. Link picker searches artefacts only.

Server: copy_type added to Artefacts schema, accepted in POST/PATCH.
post-composition.js rewritten to use Artefacts table for all pieces.
Content preview fetched from ArtefactVersionTexts.

Translations entity still exists for the standalone Copy page.

Also: version creation rules, submit-review content validation,
reviewer mandatory for translations, artefact creation simplified.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 17:15:21 +03:00
fahed 16a94a2f19 polish: cleanup unused code, i18n gaps, a11y, error handling
- Removed unused ApproverMultiSelect imports (ArtefactDetailPanel, TranslationDetailPanel)
- Removed stale editProjectId/editCampaignId state from ArtefactDetailPanel
- Added 3 missing i18n keys (selectVersionFirst, pendingReviewInfo, noReviewInfo)
- Added error toast on link picker API failure (PostDetail)
- Added ARIA attributes to PortalSelect (role=combobox, aria-expanded, listbox, option)
- Deleted test screenshots from project root
- Simplified artefact creation modal: title + type only (removed brand/project/campaign/approver/description)
- Cleaned up ArtefactDetailPanel props (removed unused projects/campaigns)
- Translation submit-review: requires source_content before allowing review
- Artefact submit-review: requires at least one attachment for design/video
- Translation reviewer moved to Review tab (single select, mandatory)
- Server blocks translation submit without reviewer

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 15:48:19 +03:00
fahed eb23931ce0 refactor: PostDetail cards as status dashboard, panel for creation
Reverted inline asset creation — the copy workflow (original → approve →
translate → approve translations) needs the full panel, not a card form.

PostDetail cards now:
- "Create new" → creates asset (type pre-set, post linked) → opens panel
- "Open" → opens panel for editing/reviewing
- Card shows: title, status, preview, approval info (status dashboard)

Panel handles: write copy, add translations, upload files, select reviewer,
submit for review — the full workflow in its proper workspace.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 15:10:40 +03:00
fahed 49e1a796ed fix: code review — security, dead code, performance, consistency
Critical fixes:
- XSS: escapeHtml() on all user-supplied text in email notifications
- Budget PATCH: added mutex lock + availability validation (prevents corruption)
- batchResolveNames: fixed wrong signature for budget request earmark names

Dead code cleanup:
- Deleted 8 unused PostComposition* files (replaced by PostDetail full page)

Performance:
- budget-helpers: single-fetch with computeFromEntries(), optional prefetch param
- post-composition: parallelized text + thumbnail fetches with Promise.all

Consistency:
- PostDetail.jsx: native <select> → PortalSelect (matches all panels)
- Finance.jsx: 11 hardcoded English table headers → t() with i18n keys
- PostCalendar.jsx: day names, Month/Week labels → t() with i18n keys
- App.jsx Suspense: "Loading..." → brand spinner (can't use i18n in fallback)
- UploadZone: proper useRef pattern, no vanilla JS document.createElement
- All file inputs: className="hidden" → absolute w-0 h-0 opacity-0
- ArtefactDetailPanel: removed campaign/project selects (inherited from post)
- TranslationDetailPanel: removed brand/linked post selects (inherited from post)
- ApproverMultiSelect: portal-based dropdown (fixes clipping in modals)
- Thumbnail fix: post-composition constructs URL from filename (was undefined)
- Upload fix: UploadZone with drag-and-drop for design + video artefacts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 14:17:08 +03:00
fahed ce4d6025d7 feat: post composition redesign + budget allocation + brand identity (Rawaj)
Post Workflow:
- PostDetail full page (/posts/:id) replaces slide panel approach
- Post = 1 Caption Copy + 1 Body Copy + 1 Design + 0-1 Video
- copy_type field on Translations (caption/body)
- Composition endpoint returns rich data (content preview, languages, thumbnails)
- Stage auto-advances on translation/artefact changes (both link and unlink)
- "Translations" renamed to "Copy" in navigation
- GET /api/posts/:id, /api/translations/:id, /api/artefacts/:id routes added
- PostProduction: "New Post" creates → navigates to full page
- CampaignDetail: click post → navigates to full page
- Inline link picker (no modals) with search + rich item display
- PostComposition sub-components for caption, copy, designs, video, formats, readiness

Budget Allocation:
- Single source of truth: BudgetEntries (Campaign.budget deprecated)
- Budget mutex for race conditions
- Validation at all levels (main → campaign → track, expenses)
- CEO approval workflow: BudgetRequests table, public approval page
- Finance page: request budget UI, budget requests section
- Settings: CEO email field
- All emails branded with "Rawaj —" prefix

Brand Identity:
- Name: Rawaj (رواج) — trending/virality
- Deep teal palette (#0d9488), forest-tinted dark mode
- DM Sans font, custom SVG logo
- Consistent across login, sidebar, emails, public pages

Approval Workflow:
- Single reviewer per artefact (not multi-select)
- Reviewer redirect on public review page
- Server blocks submit-review without reviewer
- Review URLs use APP_URL (not server URL)

UI/UX:
- Scroll clipping fix: Modal, TabbedModal, SlidePanel restructured
  to avoid overflow-y-auto clipping native select dropdowns
- section-card overflow-hidden → overflow-clip
- All page titles via Header.jsx (removed duplicate h1s)
- CampaignDetail redesigned: prominent budget card, compact team

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:02:29 +03:00
fahed e1d1c392eb feat: comprehensive UI overhaul + budget allocation redesign
Audit & Quality:
- RTL: replaced 121 LTR-only utilities (text-left, pl-, left-) with logical properties
- A11y: focus traps + ARIA on Modal/SlidePanel/TabbedModal, clickable divs→buttons
- Theming: bg-white→bg-surface (170 instances), text-gray→semantic tokens
- Performance: useMemo on filters, loading="lazy" on 24 images
- CSS: prefers-reduced-motion, removed dead animations

Component Splits:
- PostDetailPanel: 1332→623 lines + 4 sub-components
- ArtefactDetailPanel: 972→590 lines + 1 sub-component

Brand Identity — Rawaj (رواج):
- New name, DM Sans font, deep teal palette (#0d9488)
- Custom SVG logo, forest-tinted dark mode
- All emails branded with app name in subject line

Design Refinement:
- Dashboard: merged posts+deadlines into tabbed ActivityFeed, inline stats
- Quieter: removed card lift, brand glow, gradient text, mesh backgrounds
- CampaignDetail: prominent budget card, compact team avatars, Lucide icons
- Consistent page titles via Header.jsx, standardized section headers
- Finance page fully i18n'd (20+ hardcoded strings replaced)

Budget Allocation Redesign:
- Single source of truth: BudgetEntries (Campaign.budget deprecated)
- Validation at all levels: main→campaign→track, expenses blocked if insufficient
- Budget request workflow with CEO approval via public link
- BudgetRequests table, CRUD routes, public approval page
- Budget mutex for race condition prevention
- Idempotent migration for existing campaign budgets

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 15:36:19 +03:00
fahed 3c857856c5 docs: add UX/UI overhaul implementation plan (6 phases, 20 tasks)
Fixes from plan review: corrected nocodb API patterns (nocodb.list/create/
update/get/delete), fixed REQUIRED_TABLES format (array not object),
added KanbanBoard statusField prop, fixed variable shadowing, corrected
api.get return value, added missing i18n keys, clarified route removal.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 17:38:45 +03:00
fahed 94f448344e chore: add .superpowers/ to gitignore
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 17:28:03 +03:00
fahed ba3900bc33 refactor: simplify translations — shared utils, deduplicated code
- Extract shared constants to client/src/utils/translations.js
  (AVAILABLE_LANGUAGES, TRANSLATION_STATUS_COLORS, isTextSelected, groupTextsByLanguage)
- TranslationDetailPanel: deduplicate copy button JSX, hoist hasSelected
- PublicTranslationReview: memoize textsByLanguage, use shared isTextSelected
- Translations page: import from shared module
- Server: translation schema updates, post_id linking
- Add reassign-user utility script
- Add new translation i18n keys to en.json and ar.json

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 17:27:57 +03:00
fahed 7ace32a070 docs: address spec review — add data models, routing, migration plan
Resolves 15 reviewer issues: ContentItems table schema, Copy vs
Translation distinction (is_original flag), explicit stage tracking,
routing scheme with old-route redirects, campaign brief column types,
tasks global view, animation approach (CSS only), phased rollout,
public routes unchanged, My Tasks widget data sources.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 17:23:36 +03:00
fahed 18785ed901 docs: add UX/UI overhaul design spec
Deploy / deploy (push) Successful in 13s
Covers nav reorganization (17→9 items), unified Content pipeline page,
campaign brief enhancement, dashboard redesign, consistency standards,
and premium polish details.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 17:20:20 +03:00
102 changed files with 9976 additions and 2918 deletions
+1
View File
@@ -8,3 +8,4 @@ dist/
.env
.env.*
server/uploads/
.superpowers/
@@ -0,0 +1,4 @@
[ 433ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:5173/api/auth/me:0
[ 434ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:5173/api/auth/me:0
[ 516ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:5173/api/auth/me:0
[ 520ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:5173/api/auth/me:0
@@ -0,0 +1,2 @@
[ 101ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:5173/api/auth/me:0
[ 107ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:5173/api/auth/me:0
@@ -0,0 +1,145 @@
[ 3110815ms] [ERROR] %o
%s
%s
ReferenceError: Upload is not defined
at ArtefactDetailVersionsTab (http://localhost:5173/src/components/ArtefactDetailVersionsTab.jsx?t=1773656331074:286:42)
at Object.react_stack_bottom_frame (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:18509:20)
at renderWithHooks (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:5654:24)
at updateFunctionComponent (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:7475:21)
at beginWork (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:8525:20)
at runWithFiberInDEV (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:997:72)
at performUnitOfWork (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:12561:98)
at workLoopSync (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:12424:43)
at renderRootSync (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:12408:13)
at performWorkOnRoot (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:11827:37) The above error occurred in the <ArtefactDetailVersionsTab> component. React will try to recreate this component tree from scratch using the error boundary you provided, ErrorBoundary. @ http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:7000
[ 3110816ms] [ERROR] ErrorBoundary caught: ReferenceError: Upload is not defined
at ArtefactDetailVersionsTab (http://localhost:5173/src/components/ArtefactDetailVersionsTab.jsx?t=1773656331074:286:42)
at Object.react_stack_bottom_frame (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:18509:20)
at renderWithHooks (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:5654:24)
at updateFunctionComponent (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:7475:21)
at beginWork (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:8525:20)
at runWithFiberInDEV (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:997:72)
at performUnitOfWork (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:12561:98)
at workLoopSync (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:12424:43)
at renderRootSync (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:12408:13)
at performWorkOnRoot (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:11827:37) {componentStack:
at ArtefactDetailVersionsTab (http://localhos…vite/deps/react-router-dom.js?v=50a373cd:10250:3)} @ http://localhost:5173/src/components/ErrorBoundary.jsx:12
[ 7975521ms] [ERROR] Failed to load team: TypeError: Failed to fetch
at Object.get (http://localhost:5173/src/utils/api.js:59:18)
at loadTeam (http://localhost:5173/src/App.jsx?t=1773661195572:114:30)
at loadInitialData (http://localhost:5173/src/App.jsx?t=1773661195572:143:11)
at http://localhost:5173/src/App.jsx?t=1773661195572:93:7
at Object.react_stack_bottom_frame (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:18567:20)
at runWithFiberInDEV (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:997:72)
at commitHookEffectListMount (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:9411:163)
at commitHookPassiveMountEffects (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:9465:60)
at commitPassiveMountOnFiber (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:11040:29)
at recursivelyTraversePassiveMountEffects (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:11010:13) @ http://localhost:5173/src/App.jsx?t=1773661195572:118
[ 7975522ms] [ERROR] Failed to load teams: TypeError: Failed to fetch
at Object.get (http://localhost:5173/src/utils/api.js:59:18)
at loadTeams (http://localhost:5173/src/App.jsx?t=1773661195572:125:30)
at loadInitialData (http://localhost:5173/src/App.jsx?t=1773661195572:145:11)
at http://localhost:5173/src/App.jsx?t=1773661195572:93:7
at Object.react_stack_bottom_frame (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:18567:20)
at runWithFiberInDEV (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:997:72)
at commitHookEffectListMount (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:9411:163)
at commitHookPassiveMountEffects (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:9465:60)
at commitPassiveMountOnFiber (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:11040:29)
at recursivelyTraversePassiveMountEffects (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:11010:13) @ http://localhost:5173/src/App.jsx?t=1773661195572:127
[ 7975522ms] [ERROR] Failed to load roles: TypeError: Failed to fetch
at Object.get (http://localhost:5173/src/utils/api.js:59:18)
at loadRoles (http://localhost:5173/src/App.jsx?t=1773661195572:133:30)
at loadInitialData (http://localhost:5173/src/App.jsx?t=1773661195572:146:11)
at http://localhost:5173/src/App.jsx?t=1773661195572:93:7
at Object.react_stack_bottom_frame (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:18567:20)
at runWithFiberInDEV (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:997:72)
at commitHookEffectListMount (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:9411:163)
at commitHookPassiveMountEffects (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:9465:60)
at commitPassiveMountOnFiber (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:11040:29)
at recursivelyTraversePassiveMountEffects (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:11010:13) @ http://localhost:5173/src/App.jsx?t=1773661195572:135
[11275011ms] [ERROR] %o
%s
%s
ReferenceError: PortalSelect is not defined
at Artefacts (http://localhost:5173/src/pages/Artefacts.jsx?t=1773664494925:936:11)
at Object.react_stack_bottom_frame (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:18509:20)
at renderWithHooks (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:5654:24)
at updateFunctionComponent (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:7475:21)
at beginWork (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:8484:199)
at runWithFiberInDEV (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:997:72)
at performUnitOfWork (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:12561:98)
at workLoopSync (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:12424:43)
at renderRootSync (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:12408:13)
at performWorkOnRoot (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:11827:37) The above error occurred in the <Artefacts> component. React will try to recreate this component tree from scratch using the error boundary you provided, ErrorBoundary. @ http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:7000
[11275012ms] [ERROR] ErrorBoundary caught: ReferenceError: PortalSelect is not defined
at Artefacts (http://localhost:5173/src/pages/Artefacts.jsx?t=1773664494925:936:11)
at Object.react_stack_bottom_frame (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:18509:20)
at renderWithHooks (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:5654:24)
at updateFunctionComponent (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:7475:21)
at beginWork (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:8484:199)
at runWithFiberInDEV (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:997:72)
at performUnitOfWork (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:12561:98)
at workLoopSync (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:12424:43)
at renderRootSync (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:12408:13)
at performWorkOnRoot (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:11827:37) {componentStack:
at Artefacts (http://localhost:5173/src/pages…vite/deps/react-router-dom.js?v=50a373cd:10250:3)} @ http://localhost:5173/src/components/ErrorBoundary.jsx:12
[11282373ms] [ERROR] %o
%s
%s
ReferenceError: PortalSelect is not defined
at Artefacts (http://localhost:5173/src/pages/Artefacts.jsx?t=1773664502312:936:11)
at Object.react_stack_bottom_frame (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:18509:20)
at renderWithHooks (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:5654:24)
at updateFunctionComponent (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:7475:21)
at beginWork (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:8484:199)
at runWithFiberInDEV (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:997:72)
at performUnitOfWork (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:12561:98)
at workLoopSync (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:12424:43)
at renderRootSync (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:12408:13)
at performWorkOnRoot (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:11827:37) The above error occurred in the <Artefacts> component. React will try to recreate this component tree from scratch using the error boundary you provided, ErrorBoundary. @ http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:7000
[11282374ms] [ERROR] ErrorBoundary caught: ReferenceError: PortalSelect is not defined
at Artefacts (http://localhost:5173/src/pages/Artefacts.jsx?t=1773664502312:936:11)
at Object.react_stack_bottom_frame (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:18509:20)
at renderWithHooks (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:5654:24)
at updateFunctionComponent (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:7475:21)
at beginWork (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:8484:199)
at runWithFiberInDEV (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:997:72)
at performUnitOfWork (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:12561:98)
at workLoopSync (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:12424:43)
at renderRootSync (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:12408:13)
at performWorkOnRoot (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:11827:37) {componentStack:
at Artefacts (http://localhost:5173/src/pages…vite/deps/react-router-dom.js?v=50a373cd:10250:3)} @ http://localhost:5173/src/components/ErrorBoundary.jsx:12
[11301530ms] [ERROR] %o
%s
%s
ReferenceError: PortalSelect is not defined
at Artefacts (http://localhost:5173/src/pages/Artefacts.jsx?t=1773664521350:936:11)
at Object.react_stack_bottom_frame (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:18509:20)
at renderWithHooks (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:5654:24)
at updateFunctionComponent (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:7475:21)
at beginWork (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:8484:199)
at runWithFiberInDEV (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:997:72)
at performUnitOfWork (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:12561:98)
at workLoopSync (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:12424:43)
at renderRootSync (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:12408:13)
at performWorkOnRoot (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:11827:37) The above error occurred in the <Artefacts> component. React will try to recreate this component tree from scratch using the error boundary you provided, ErrorBoundary. @ http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:7000
[11301531ms] [ERROR] ErrorBoundary caught: ReferenceError: PortalSelect is not defined
at Artefacts (http://localhost:5173/src/pages/Artefacts.jsx?t=1773664521350:936:11)
at Object.react_stack_bottom_frame (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:18509:20)
at renderWithHooks (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:5654:24)
at updateFunctionComponent (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:7475:21)
at beginWork (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:8484:199)
at runWithFiberInDEV (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:997:72)
at performUnitOfWork (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:12561:98)
at workLoopSync (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:12424:43)
at renderRootSync (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:12408:13)
at performWorkOnRoot (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:11827:37) {componentStack:
at Artefacts (http://localhost:5173/src/pages…vite/deps/react-router-dom.js?v=50a373cd:10250:3)} @ http://localhost:5173/src/components/ErrorBoundary.jsx:12
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

+1 -1
View File
@@ -7,7 +7,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=IBM+Plex+Sans+Arabic:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<title>Digital Hub</title>
<title>Rawaj</title>
</head>
<body>
<div id="root"></div>
+6 -2
View File
@@ -15,6 +15,7 @@ import { useKeyboardShortcuts, DEFAULT_SHORTCUTS } from './hooks/useKeyboardShor
// Lazy-loaded page components
const Dashboard = lazy(() => import('./pages/Dashboard'))
const PostProduction = lazy(() => import('./pages/PostProduction'))
const PostDetail = lazy(() => import('./pages/PostDetail'))
const Assets = lazy(() => import('./pages/Assets'))
const Campaigns = lazy(() => import('./pages/Campaigns'))
const CampaignDetail = lazy(() => import('./pages/CampaignDetail'))
@@ -37,6 +38,7 @@ const PublicIssueSubmit = lazy(() => import('./pages/PublicIssueSubmit'))
const PublicIssueTracker = lazy(() => import('./pages/PublicIssueTracker'))
const Translations = lazy(() => import('./pages/Translations'))
const PublicTranslationReview = lazy(() => import('./pages/PublicTranslationReview'))
const PublicBudgetApproval = lazy(() => import('./pages/PublicBudgetApproval'))
const ForgotPassword = lazy(() => import('./pages/ForgotPassword'))
const ResetPassword = lazy(() => import('./pages/ResetPassword'))
@@ -161,7 +163,7 @@ function AppContent() {
<AppContext.Provider value={{ currentUser: user, teamMembers, brands, loadTeam, getBrandName, teams, loadTeams, roles, loadRoles }}>
{/* Profile completion prompt */}
{showProfilePrompt && (
<div className="fixed top-4 right-4 z-50 bg-amber-50 border-2 border-amber-400 rounded-xl shadow-lg p-4 max-w-md animate-fade-in">
<div className="fixed top-4 end-4 z-50 bg-amber-50 border-2 border-amber-400 rounded-xl shadow-lg p-4 max-w-md animate-fade-in">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-full bg-amber-400 flex items-center justify-center text-white shrink-0">
@@ -288,7 +290,7 @@ function AppContent() {
{showTutorial && <Tutorial onComplete={handleTutorialComplete} />}
<ErrorBoundary>
<Suspense fallback={<div className="min-h-screen bg-surface-secondary flex items-center justify-center"><div className="animate-pulse text-text-tertiary">Loading...</div></div>}>
<Suspense fallback={<div className="min-h-screen flex items-center justify-center"><div className="w-8 h-8 border-2 border-brand-primary/30 border-t-brand-primary rounded-full animate-spin" /></div>}>
<Routes>
<Route path="/login" element={user ? <Navigate to="/" replace /> : <Login />} />
<Route path="/forgot-password" element={user ? <Navigate to="/" replace /> : <ForgotPassword />} />
@@ -298,9 +300,11 @@ function AppContent() {
<Route path="/submit-issue" element={<PublicIssueSubmit />} />
<Route path="/track/:token" element={<PublicIssueTracker />} />
<Route path="/review-translation/:token" element={<PublicTranslationReview />} />
<Route path="/approve-budget/:token" element={<PublicBudgetApproval />} />
<Route path="/" element={user ? <Layout /> : <Navigate to="/login" replace />}>
<Route index element={<Dashboard />} />
{hasModule('marketing') && <>
<Route path="posts/:id" element={<PostDetail />} />
<Route path="posts" element={<PostProduction />} />
<Route path="calendar" element={<PostCalendar />} />
<Route path="artefacts" element={<Artefacts />} />
+54 -26
View File
@@ -1,30 +1,51 @@
import { useState, useRef, useEffect } from 'react'
import { useState, useRef, useEffect, useCallback } from 'react'
import { createPortal } from 'react-dom'
import { Check, ChevronDown, X } from 'lucide-react'
export default function ApproverMultiSelect({ users = [], selected = [], onChange }) {
const [open, setOpen] = useState(false)
const [dropUp, setDropUp] = useState(false)
const wrapperRef = useRef(null)
const triggerRef = useRef(null)
const dropdownRef = useRef(null)
const [pos, setPos] = useState({ top: 0, left: 0, width: 0 })
const updatePosition = useCallback(() => {
if (!triggerRef.current) return
const rect = triggerRef.current.getBoundingClientRect()
const spaceBelow = window.innerHeight - rect.bottom
const dropdownHeight = Math.min(users.length * 40 + 8, 220)
const showAbove = spaceBelow < dropdownHeight && rect.top > dropdownHeight
setPos({
top: showAbove ? rect.top - dropdownHeight - 4 : rect.bottom + 4,
left: rect.left,
width: rect.width,
})
}, [users.length])
// Close dropdown when clicking outside
useEffect(() => {
if (!open) return
const handleClick = (e) => {
if (wrapperRef.current && !wrapperRef.current.contains(e.target)) {
setOpen(false)
}
if (triggerRef.current?.contains(e.target)) return
if (dropdownRef.current?.contains(e.target)) return
setOpen(false)
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [open])
const handleEsc = (e) => { if (e.key === 'Escape') setOpen(false) }
const handleScroll = () => updatePosition()
// Detect if dropdown should open upward
useEffect(() => {
if (!open || !wrapperRef.current) return
const rect = wrapperRef.current.getBoundingClientRect()
const spaceBelow = window.innerHeight - rect.bottom
setDropUp(spaceBelow < 220)
}, [open])
document.addEventListener('mousedown', handleClick)
document.addEventListener('keydown', handleEsc)
window.addEventListener('scroll', handleScroll, true)
return () => {
document.removeEventListener('mousedown', handleClick)
document.removeEventListener('keydown', handleEsc)
window.removeEventListener('scroll', handleScroll, true)
}
}, [open, updatePosition])
const handleOpen = () => {
updatePosition()
setOpen(!open)
}
const toggle = (userId) => {
const id = String(userId)
@@ -39,9 +60,10 @@ export default function ApproverMultiSelect({ users = [], selected = [], onChang
const selectedUsers = selected.map(id => users.find(u => String(u._id || u.id || u.Id) === String(id))).filter(Boolean)
return (
<div className="relative" ref={wrapperRef}>
<>
<div
onClick={() => setOpen(!open)}
ref={triggerRef}
onClick={handleOpen}
className={`w-full min-h-[38px] px-3 py-1.5 text-sm border rounded-lg bg-surface cursor-pointer flex items-center flex-wrap gap-1.5 transition-colors ${
open ? 'border-brand-primary ring-2 ring-brand-primary/20' : 'border-border'
}`}
@@ -58,16 +80,21 @@ export default function ApproverMultiSelect({ users = [], selected = [], onChang
<button
type="button"
onClick={e => { e.stopPropagation(); remove(u._id || u.id || u.Id) }}
className="hover:text-amber-950"
className="hover:text-amber-950 transition-colors"
>
<X className="w-3 h-3" />
</button>
</span>
))}
<ChevronDown className={`w-4 h-4 text-text-tertiary ml-auto shrink-0 transition-transform ${open ? 'rotate-180' : ''}`} />
<ChevronDown className={`w-4 h-4 text-text-tertiary ms-auto shrink-0 transition-transform ${open ? 'rotate-180' : ''}`} />
</div>
{open && (
<div className={`absolute z-50 w-full bg-surface border border-border rounded-lg shadow-lg max-h-48 overflow-y-auto ${dropUp ? 'bottom-full mb-1' : 'top-full mt-1'}`}>
{open && createPortal(
<div
ref={dropdownRef}
className="fixed z-[99999] bg-surface border border-border rounded-lg shadow-lg max-h-[220px] overflow-y-auto"
style={{ top: pos.top, left: pos.left, width: pos.width }}
>
{users.map(u => {
const uid = String(u._id || u.id || u.Id)
const isSelected = selected.includes(uid)
@@ -76,7 +103,7 @@ export default function ApproverMultiSelect({ users = [], selected = [], onChang
key={uid}
type="button"
onClick={() => toggle(uid)}
className={`w-full text-left px-3 py-2 text-sm hover:bg-surface-secondary flex items-center justify-between ${
className={`w-full text-start px-3 py-2 text-sm hover:bg-surface-secondary flex items-center justify-between transition-colors ${
isSelected ? 'text-brand-primary font-medium' : 'text-text-primary'
}`}
>
@@ -88,8 +115,9 @@ export default function ApproverMultiSelect({ users = [], selected = [], onChang
{users.length === 0 && (
<div className="px-3 py-4 text-sm text-text-tertiary text-center">No users available</div>
)}
</div>
</div>,
document.body
)}
</div>
</>
)
}
+127 -523
View File
@@ -1,13 +1,13 @@
import { useState, useEffect, useContext } from 'react'
import { Plus, Copy, Check, ExternalLink, Upload, Globe, Trash2, FileText, Image as ImageIcon, Film, Sparkles, MessageSquare, Save, FileEdit, Layers, ShieldCheck } from 'lucide-react'
import { Copy, Check, ExternalLink, Trash2, FileText, Image as ImageIcon, Film, Sparkles, MessageSquare, Save, FileEdit, Layers, ShieldCheck } from 'lucide-react'
import { AppContext } from '../App'
import { useLanguage } from '../i18n/LanguageContext'
import { api } from '../utils/api'
import Modal from './Modal'
import TabbedModal from './TabbedModal'
import { useToast } from './ToastContainer'
import ArtefactVersionTimeline from './ArtefactVersionTimeline'
import ApproverMultiSelect from './ApproverMultiSelect'
import PortalSelect from './PortalSelect'
import { ArtefactDetailVersionsTab } from './ArtefactDetailVersionsTab'
const STATUS_COLORS = {
draft: 'bg-surface-tertiary text-text-secondary',
@@ -17,13 +17,6 @@ const STATUS_COLORS = {
revision_requested: 'bg-orange-100 text-orange-700',
}
const AVAILABLE_LANGUAGES = [
{ code: 'AR', label: '\u0627\u0644\u0639\u0631\u0628\u064A\u0629' },
{ code: 'EN', label: 'English' },
{ code: 'FR', label: 'Fran\u00E7ais' },
{ code: 'ID', label: 'Bahasa Indonesia' },
]
const TYPE_ICONS = {
copy: FileText,
design: ImageIcon,
@@ -31,7 +24,11 @@ const TYPE_ICONS = {
other: Sparkles,
}
export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDelete, projects = [], campaigns = [], assignableUsers = [] }) {
const parseApproverIds = (a) =>
a.approvers?.map(u => String(u.id)) ||
(a.approver_ids ? a.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : [])
export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDelete, assignableUsers = [] }) {
const { t } = useLanguage()
const { brands } = useContext(AppContext)
const toast = useToast()
@@ -42,40 +39,19 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
const [submitting, setSubmitting] = useState(false)
const [freshReviewUrl, setFreshReviewUrl] = useState('')
const [copied, setCopied] = useState(false)
const [activeTab, setActiveTab] = useState('details')
const [activeTab, setActiveTab] = useState(artefact.type === 'copy' ? 'versions' : 'details')
// Editable fields
// Editable fields — seeded from artefact prop; component is keyed by artefact._id at call site
const [editTitle, setEditTitle] = useState(artefact.title || '')
const [editDescription, setEditDescription] = useState(artefact.description || '')
const [editProjectId, setEditProjectId] = useState(artefact.project_id || '')
const [editCampaignId, setEditCampaignId] = useState(artefact.campaign_id || '')
const [editApproverIds, setEditApproverIds] = useState(
artefact.approvers?.map(a => String(a.id)) || (artefact.approver_ids ? artefact.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : [])
)
const [editApproverIds, setEditApproverIds] = useState(() => parseApproverIds(artefact))
const reviewUrl = freshReviewUrl || (artefact.approval_token ? `${window.location.origin}/review/${artefact.approval_token}` : '')
const [savingDraft, setSavingDraft] = useState(false)
const [deleting, setDeleting] = useState(false)
const [confirmDeleteLangId, setConfirmDeleteLangId] = useState(null)
const [confirmDeleteAttId, setConfirmDeleteAttId] = useState(null)
const [showDeleteArtefactConfirm, setShowDeleteArtefactConfirm] = useState(false)
// Language management (for copy type)
const [showLanguageModal, setShowLanguageModal] = useState(false)
const [languageForm, setLanguageForm] = useState({ language_code: '', language_label: '', content: '' })
const [savingLanguage, setSavingLanguage] = useState(false)
// New version modal
const [showNewVersionModal, setShowNewVersionModal] = useState(false)
const [newVersionNotes, setNewVersionNotes] = useState('')
const [copyFromPrevious, setCopyFromPrevious] = useState(false)
const [creatingVersion, setCreatingVersion] = useState(false)
// File upload (for design/video)
const [uploading, setUploading] = useState(false)
// Video inline (Drive link input)
const [driveUrl, setDriveUrl] = useState('')
const [dragOver, setDragOver] = useState(false)
const [uploadProgress, setUploadProgress] = useState(0)
// Comments
@@ -87,16 +63,6 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
loadVersions()
}, [artefact.Id])
useEffect(() => {
setEditTitle(artefact.title || '')
setEditDescription(artefact.description || '')
setEditProjectId(artefact.project_id || '')
setEditCampaignId(artefact.campaign_id || '')
setEditApproverIds(
artefact.approvers?.map(a => String(a.id)) || (artefact.approver_ids ? artefact.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : [])
)
}, [artefact.Id])
const loadVersions = async () => {
try {
const res = await api.get(`/artefacts/${artefact.Id}/versions`)
@@ -137,57 +103,16 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
loadVersionData(version.Id)
}
const handleCreateVersion = async () => {
setCreatingVersion(true)
try {
await api.post(`/artefacts/${artefact.Id}/versions`, {
notes: newVersionNotes || `Version ${(versions[versions.length - 1]?.version_number || 0) + 1}`,
copy_from_previous: artefact.type === 'copy' ? copyFromPrevious : false,
})
toast.success(t('artefacts.versionCreated'))
setShowNewVersionModal(false)
setNewVersionNotes('')
setCopyFromPrevious(false)
loadVersions()
onUpdate()
} catch (err) {
console.error('Create version failed:', err)
toast.error(t('artefacts.failedCreateVersion'))
} finally {
setCreatingVersion(false)
}
}
const handleAddLanguage = async () => {
if (!languageForm.language_code || !languageForm.language_label || !languageForm.content) {
toast.error(t('artefacts.allFieldsRequired'))
return
}
setSavingLanguage(true)
try {
await api.post(`/artefacts/${artefact.Id}/versions/${selectedVersion.Id}/texts`, languageForm)
toast.success(t('artefacts.languageAdded'))
setShowLanguageModal(false)
setLanguageForm({ language_code: '', language_label: '', content: '' })
loadVersionData(selectedVersion.Id)
} catch (err) {
console.error('Add language failed:', err)
toast.error(t('artefacts.failedAddLanguage'))
} finally {
setSavingLanguage(false)
}
const handleAddLanguage = async (languageForm) => {
await api.post(`/artefacts/${artefact.Id}/versions/${selectedVersion.Id}/texts`, languageForm)
toast.success(t('artefacts.languageAdded'))
loadVersionData(selectedVersion.Id)
}
const handleDeleteLanguage = async (textId) => {
try {
await api.delete(`/artefact-version-texts/${textId}`)
toast.success(t('artefacts.languageDeleted'))
loadVersionData(selectedVersion.Id)
} catch (err) {
toast.error(t('artefacts.failedDeleteLanguage'))
}
await api.delete(`/artefact-version-texts/${textId}`)
toast.success(t('artefacts.languageDeleted'))
loadVersionData(selectedVersion.Id)
}
const handleFileUpload = async (fileOrEvent) => {
@@ -215,16 +140,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
}
}
const handleVideoDrop = (e) => {
e.preventDefault()
setDragOver(false)
const file = e.dataTransfer.files?.[0]
if (file && file.type.startsWith('video/')) {
handleFileUpload(file)
}
}
const handleAddDriveVideo = async () => {
const handleAddDriveVideo = async (driveUrl) => {
if (!driveUrl.trim()) {
toast.error(t('artefacts.enterDriveUrl'))
return
@@ -236,7 +152,6 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
drive_url: driveUrl,
})
toast.success(t('artefacts.videoLinkAdded'))
setDriveUrl('')
loadVersionData(selectedVersion.Id)
} catch (err) {
console.error('Add Drive link failed:', err)
@@ -247,13 +162,9 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
}
const handleDeleteAttachment = async (attId) => {
try {
await api.delete(`/artefact-attachments/${attId}`)
toast.success(t('artefacts.attachmentDeleted'))
loadVersionData(selectedVersion.Id)
} catch (err) {
toast.error(t('artefacts.failedDeleteAttachment'))
}
await api.delete(`/artefact-attachments/${attId}`)
toast.success(t('artefacts.attachmentDeleted'))
loadVersionData(selectedVersion.Id)
}
const handleSubmitReview = async () => {
@@ -325,6 +236,12 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
}
}
const handleUpdateLanguage = async (textId, content) => {
await api.patch(`/artefact-version-texts/${textId}`, { content })
toast.success(t('artefacts.languageAdded'))
loadVersionData(selectedVersion.Id)
}
const handleDeleteArtefact = async () => {
setDeleting(true)
try {
@@ -358,10 +275,10 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
const TypeIcon = TYPE_ICONS[artefact.type] || Sparkles
const tabs = [
{ key: 'details', label: t('artefacts.details') || 'Details', icon: FileEdit },
{ key: 'versions', label: t('artefacts.versions') || 'Versions', icon: Layers, badge: versions.length },
{ key: 'discussion', label: t('artefacts.comments') || 'Discussion', icon: MessageSquare, badge: comments.length },
{ key: 'review', label: t('artefacts.review') || 'Review', icon: ShieldCheck },
{ key: 'details', label: t('artefacts.details'), icon: FileEdit },
{ key: 'versions', label: t('artefacts.versions'), icon: Layers, badge: versions.length },
{ key: 'discussion', label: t('artefacts.comments'), icon: MessageSquare, badge: comments.length },
{ key: 'review', label: t('artefacts.review'), icon: ShieldCheck },
]
if (loading) {
@@ -380,32 +297,30 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
onClose={onClose}
size="xl"
header={
<>
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-lg bg-brand-primary/10 flex items-center justify-center shrink-0">
<TypeIcon className="w-5 h-5 text-brand-primary" />
</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('_', ' ')}
<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>
<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>
</>
</div>
}
tabs={tabs}
activeTab={activeTab}
@@ -425,15 +340,17 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
</button>
)}
</div>
<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>
{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>
)}
</>
}
>
@@ -452,262 +369,53 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
/>
</div>
{/* Project & Campaign dropdowns */}
<div className="grid grid-cols-2 gap-4">
<div>
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1.5">{t('artefacts.project')}</h4>
<select
value={editProjectId}
onChange={e => {
setEditProjectId(e.target.value)
handleUpdateField('project_id', e.target.value)
}}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 bg-surface"
>
<option value=""></option>
{projects.map(p => <option key={p.Id || p._id || p.id} value={p.Id || p._id || p.id}>{p.name || p.title}</option>)}
</select>
</div>
<div>
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1.5">{t('artefacts.campaign')}</h4>
<select
value={editCampaignId}
onChange={e => {
setEditCampaignId(e.target.value)
handleUpdateField('campaign_id', e.target.value)
}}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 bg-surface"
>
<option value=""></option>
{campaigns.map(c => <option key={c.Id || c._id || c.id} value={c.Id || c._id || c.id}>{c.name || c.title}</option>)}
</select>
</div>
</div>
{/* Approvers */}
<div>
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1.5">{t('artefacts.approversLabel')}</h4>
<ApproverMultiSelect
users={assignableUsers}
selected={editApproverIds}
onChange={ids => {
setEditApproverIds(ids)
handleUpdateField('approver_ids', ids.length > 0 ? ids.join(',') : '')
}}
/>
{/* 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' && (
<div className="p-6 space-y-5">
{/* Version Timeline */}
<div>
<div className="flex items-center justify-between mb-3">
<h4 className="text-xs font-semibold text-text-tertiary uppercase">{t('artefacts.versions')}</h4>
<button
onClick={() => setShowNewVersionModal(true)}
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors"
>
<Plus className="w-3 h-3" />
{t('artefacts.newVersion')}
</button>
</div>
<ArtefactVersionTimeline
versions={versions}
activeVersionId={selectedVersion?.Id}
onSelectVersion={handleSelectVersion}
artefactType={artefact.type}
/>
</div>
{/* Type-specific content */}
{versionData && selectedVersion && (
<div className="border-t border-border pt-5">
{/* COPY TYPE: Language entries */}
{artefact.type === 'copy' && (
<div>
<div className="flex items-center justify-between mb-3">
<h4 className="text-xs font-semibold text-text-tertiary uppercase">{t('artefacts.languages')}</h4>
<button
onClick={() => setShowLanguageModal(true)}
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors"
>
<Plus className="w-3 h-3" />
{t('artefacts.addLanguage')}
</button>
</div>
{versionData.texts && versionData.texts.length > 0 ? (
<div className="space-y-3">
{versionData.texts.map(text => (
<div key={text.Id} className="bg-surface-secondary rounded-lg p-4 border border-border">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
<span className="px-2 py-0.5 bg-surface border border-border rounded text-xs font-mono font-medium">
{text.language_code}
</span>
<span className="text-sm font-medium text-text-primary">{text.language_label}</span>
</div>
<button
onClick={() => setConfirmDeleteLangId(text.Id)}
className="text-red-600 hover:text-red-700"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
<div className="bg-surface rounded border border-border p-3 text-sm text-text-primary whitespace-pre-wrap font-sans">
{text.content}
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8 bg-surface-secondary rounded-lg border-2 border-dashed border-border">
<Globe className="w-8 h-8 text-text-tertiary mx-auto mb-2" />
<p className="text-sm text-text-secondary">{t('artefacts.noLanguages')}</p>
</div>
)}
</div>
)}
{/* DESIGN TYPE: Image gallery */}
{artefact.type === 'design' && (
<div>
<div className="flex items-center justify-between mb-3">
<h4 className="text-xs font-semibold text-text-tertiary uppercase">{t('artefacts.imagesLabel')}</h4>
<label className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors cursor-pointer">
<Upload className="w-3 h-3" />
{uploading ? t('artefacts.uploading') : t('artefacts.uploadImage')}
<input
type="file"
className="hidden"
accept="image/*"
onChange={handleFileUpload}
disabled={uploading}
/>
</label>
</div>
{versionData.attachments && versionData.attachments.length > 0 ? (
<div className="grid grid-cols-2 gap-3">
{versionData.attachments.map(att => (
<div key={att.Id} className="relative group">
<img
src={att.url}
alt={att.original_name}
className="w-full h-48 object-cover rounded-lg border border-border"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors rounded-lg flex items-center justify-center">
<button
onClick={() => setConfirmDeleteAttId(att.Id)}
className="opacity-0 group-hover:opacity-100 transition-opacity px-3 py-2 bg-red-600 text-white rounded-lg text-xs font-medium hover:bg-red-700"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
<div className="mt-1 px-2 py-1 bg-surface-secondary rounded text-xs text-text-secondary truncate">
{att.original_name}
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8 bg-surface-secondary rounded-lg border-2 border-dashed border-border">
<ImageIcon className="w-8 h-8 text-text-tertiary mx-auto mb-2" />
<p className="text-sm text-text-secondary">{t('artefacts.noImages')}</p>
</div>
)}
</div>
)}
{/* VIDEO TYPE: Files and Drive links — all inline */}
{artefact.type === 'video' && (
<div>
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-3">{t('artefacts.videosLabel')}</h4>
{/* Existing attachments */}
{versionData.attachments && versionData.attachments.length > 0 && (
<div className="space-y-3 mb-4">
{versionData.attachments.map(att => (
<div key={att.Id} className="bg-surface-secondary rounded-lg p-4 border border-border">
{att.drive_url ? (
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-text-primary">{t('artefacts.googleDriveVideo')}</span>
<button onClick={() => setConfirmDeleteAttId(att.Id)} className="text-red-600 hover:text-red-700">
<Trash2 className="w-4 h-4" />
</button>
</div>
<iframe src={getDriveEmbedUrl(att.drive_url)} className="w-full h-64 rounded border border-border" allow="autoplay" />
</div>
) : (
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-text-primary truncate">{att.original_name}</span>
<button onClick={() => setConfirmDeleteAttId(att.Id)} className="text-red-600 hover:text-red-700">
<Trash2 className="w-4 h-4" />
</button>
</div>
<video src={att.url} controls className="w-full rounded border border-border" />
</div>
)}
</div>
))}
</div>
)}
{/* Drag-and-drop / click-to-upload zone */}
<label
className={`flex flex-col items-center gap-2 px-6 py-6 border-2 border-dashed rounded-lg cursor-pointer transition-colors ${
dragOver ? 'border-brand-primary bg-brand-primary/5' : 'border-border hover:border-brand-primary/30'
} ${uploading ? 'pointer-events-none opacity-60' : ''}`}
onDragOver={(e) => { e.preventDefault(); setDragOver(true) }}
onDragLeave={() => setDragOver(false)}
onDrop={handleVideoDrop}
>
{uploading ? (
<>
<div className="w-full max-w-xs bg-surface-tertiary rounded-full h-2 overflow-hidden">
<div className="bg-brand-primary h-full rounded-full transition-all duration-300" style={{ width: `${uploadProgress}%` }} />
</div>
<span className="text-sm text-text-secondary">{t('artefacts.uploading')} {uploadProgress}%</span>
</>
) : (
<>
<Upload className="w-7 h-7 text-text-tertiary" />
<span className="text-sm font-medium text-text-primary">{t('artefacts.dropOrClickVideo')}</span>
<span className="text-xs text-text-tertiary">{t('artefacts.videoFormats')}</span>
</>
)}
<input type="file" className="hidden" accept="video/*" onChange={handleFileUpload} disabled={uploading} />
</label>
{/* Google Drive URL inline input */}
<div className="flex items-center gap-2 mt-3">
<Globe className="w-4 h-4 text-text-tertiary shrink-0" />
<input
type="text"
value={driveUrl}
onChange={e => setDriveUrl(e.target.value)}
placeholder="https://drive.google.com/file/d/..."
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
onKeyDown={e => { if (e.key === 'Enter' && driveUrl.trim()) handleAddDriveVideo() }}
/>
<button
onClick={handleAddDriveVideo}
disabled={uploading || !driveUrl.trim()}
className="px-3 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 shrink-0"
>
{t('artefacts.addLink')}
</button>
</div>
</div>
)}
</div>
)}
</div>
<ArtefactDetailVersionsTab
artefact={artefact}
versions={versions}
selectedVersion={selectedVersion}
versionData={versionData}
uploading={uploading}
uploadProgress={uploadProgress}
onSelectVersion={handleSelectVersion}
onAddLanguage={handleAddLanguage}
onUpdateLanguage={handleUpdateLanguage}
onDeleteLanguage={handleDeleteLanguage}
onFileUpload={handleFileUpload}
onDeleteAttachment={handleDeleteAttachment}
onAddDriveVideo={handleAddDriveVideo}
getDriveEmbedUrl={getDriveEmbedUrl}
/>
)}
{/* Discussion Tab */}
@@ -762,7 +470,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
</>
) : (
<div className="text-center py-8 text-sm text-text-tertiary">
{t('artefacts.selectVersionFirst') || 'Select a version first to view comments.'}
{t('artefacts.selectVersionFirst')}
</div>
)}
</div>
@@ -771,11 +479,28 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
{/* Review Tab */}
{activeTab === 'review' && (
<div className="p-6 space-y-5">
{/* Reviewer Selection (single) */}
{['draft', 'revision_requested', 'rejected'].includes(artefact.status) && (
<div>
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1.5">{t('artefacts.reviewer')}</h4>
<PortalSelect
value={editApproverIds[0] || ''}
onChange={val => {
const ids = val ? [val] : []
setEditApproverIds(ids)
handleUpdateField('approver_ids', val || '')
}}
options={[{ value: '', label: t('artefacts.selectReviewer') }, ...assignableUsers.map(u => ({ value: u.id || u.Id, label: u.name }))]}
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
/>
</div>
)}
{/* Submit for Review */}
{['draft', 'revision_requested', 'rejected'].includes(artefact.status) && (
<button
onClick={handleSubmitReview}
disabled={submitting}
disabled={submitting || editApproverIds.length === 0}
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-amber-500 text-white rounded-lg hover:bg-amber-600 transition-colors font-medium disabled:opacity-50"
>
<ExternalLink className="w-4 h-4" />
@@ -824,137 +549,16 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
</div>
)}
{/* Empty state when no review actions available */}
{!['draft', 'revision_requested', 'rejected'].includes(artefact.status) && !reviewUrl && !artefact.feedback && !(artefact.status === 'approved' && artefact.approved_by_name) && (
{/* Empty state: pending_review or unknown status with no review info */}
{artefact.status === 'pending_review' && !reviewUrl && !artefact.feedback && (
<div className="text-center py-8 text-sm text-text-tertiary">
{artefact.status === 'pending_review'
? t('artefacts.pendingReviewInfo') || 'This artefact is currently pending review.'
: t('artefacts.noReviewInfo') || 'No review information available.'}
{t('artefacts.pendingReviewInfo')}
</div>
)}
</div>
)}
</TabbedModal>
{/* Language Modal */}
<Modal isOpen={showLanguageModal} onClose={() => setShowLanguageModal(false)} title={t('artefacts.addLanguage')} size="md">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('artefacts.languageLabel')} *</label>
<select
value={languageForm.language_code}
onChange={e => {
const lang = AVAILABLE_LANGUAGES.find(l => l.code === e.target.value)
if (lang) setLanguageForm(f => ({ ...f, language_code: lang.code, language_label: lang.label }))
else setLanguageForm(f => ({ ...f, language_code: '', language_label: '' }))
}}
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
>
<option value="">{t('artefacts.selectLanguage')}</option>
{AVAILABLE_LANGUAGES
.filter(lang => !(versionData?.texts || []).some(t => t.language_code === lang.code))
.map(lang => (
<option key={lang.code} value={lang.code}>{lang.label} ({lang.code})</option>
))
}
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('artefacts.contentLabel')} *</label>
<textarea
value={languageForm.content}
onChange={e => setLanguageForm(f => ({ ...f, content: e.target.value }))}
rows={8}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 font-sans"
placeholder={t('artefacts.enterContent')}
/>
</div>
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
<button
onClick={() => setShowLanguageModal(false)}
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
>
{t('common.cancel')}
</button>
<button
onClick={handleAddLanguage}
disabled={savingLanguage}
className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 shadow-sm"
>
{savingLanguage ? t('header.saving') : t('common.save')}
</button>
</div>
</div>
</Modal>
{/* New Version Modal */}
<Modal isOpen={showNewVersionModal} onClose={() => setShowNewVersionModal(false)} title={t('artefacts.createNewVersion')} size="sm">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('artefacts.versionNotes')}</label>
<textarea
value={newVersionNotes}
onChange={e => setNewVersionNotes(e.target.value)}
rows={3}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
placeholder={t('artefacts.whatChanged')}
/>
</div>
{artefact.type === 'copy' && versions.length > 0 && (
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={copyFromPrevious}
onChange={e => setCopyFromPrevious(e.target.checked)}
className="w-4 h-4 text-brand-primary border-border rounded focus:ring-brand-primary"
/>
<span className="text-sm text-text-secondary">{t('artefacts.copyLanguages')}</span>
</label>
)}
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
<button
onClick={() => setShowNewVersionModal(false)}
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
>
{t('common.cancel')}
</button>
<button
onClick={handleCreateVersion}
disabled={creatingVersion}
className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 shadow-sm"
>
{creatingVersion ? t('artefacts.creatingVersion') : t('artefacts.createVersion')}
</button>
</div>
</div>
</Modal>
{/* Delete Language Confirmation */}
<Modal
isOpen={!!confirmDeleteLangId}
onClose={() => setConfirmDeleteLangId(null)}
title={t('artefacts.deleteLanguage')}
isConfirm
danger
onConfirm={() => handleDeleteLanguage(confirmDeleteLangId)}
confirmText={t('common.delete')}
>
{t('artefacts.deleteLanguageDesc')}
</Modal>
{/* Delete Attachment Confirmation */}
<Modal
isOpen={!!confirmDeleteAttId}
onClose={() => setConfirmDeleteAttId(null)}
title={t('artefacts.deleteAttachment')}
isConfirm
danger
onConfirm={() => handleDeleteAttachment(confirmDeleteAttId)}
confirmText={t('common.delete')}
>
{t('artefacts.deleteAttachmentDesc')}
</Modal>
{/* Delete Artefact Confirmation */}
<Modal
isOpen={showDeleteArtefactConfirm}
@@ -0,0 +1,398 @@
import { useState } from 'react'
import { Trash2, Globe, Image as ImageIcon, Pencil } from 'lucide-react'
import PortalSelect from './PortalSelect'
import UploadZone from './UploadZone'
import { useLanguage } from '../i18n/LanguageContext'
import Modal from './Modal'
import ArtefactVersionTimeline from './ArtefactVersionTimeline'
const AVAILABLE_LANGUAGES = [
{ code: 'AR', label: '\u0627\u0644\u0639\u0631\u0628\u064A\u0629' },
{ code: 'EN', label: 'English' },
{ code: 'FR', label: 'Fran\u00E7ais' },
{ code: 'ID', label: 'Bahasa Indonesia' },
]
export function ArtefactDetailVersionsTab({
artefact,
versions,
selectedVersion,
versionData,
uploading,
uploadProgress,
onSelectVersion,
onAddLanguage,
onUpdateLanguage,
onDeleteLanguage,
onFileUpload,
onDeleteAttachment,
onAddDriveVideo,
getDriveEmbedUrl,
}) {
const { t } = useLanguage()
const [showLanguageModal, setShowLanguageModal] = useState(false)
const [languageForm, setLanguageForm] = useState({ language_code: '', language_label: '', content: '' })
const [savingLanguage, setSavingLanguage] = useState(false)
const [confirmDeleteLangId, setConfirmDeleteLangId] = useState(null)
const [confirmDeleteAttId, setConfirmDeleteAttId] = useState(null)
const [editingLangText, setEditingLangText] = useState(null) // { Id, language_code, language_label, content }
const [editLangContent, setEditLangContent] = useState('')
const [savingEditLang, setSavingEditLang] = useState(false)
const [dragOver, setDragOver] = useState(false)
const [driveUrl, setDriveUrl] = useState('')
const handleAddLanguage = async () => {
if (!languageForm.language_code || !languageForm.language_label || !languageForm.content) return
setSavingLanguage(true)
try {
await onAddLanguage(languageForm)
setShowLanguageModal(false)
setLanguageForm({ language_code: '', language_label: '', content: '' })
} finally {
setSavingLanguage(false)
}
}
const handleDeleteLanguage = async (textId) => {
await onDeleteLanguage(textId)
setConfirmDeleteLangId(null)
}
const handleDeleteAttachment = async (attId) => {
await onDeleteAttachment(attId)
setConfirmDeleteAttId(null)
}
const handleSaveEditLang = async () => {
if (!editingLangText || !editLangContent.trim()) return
setSavingEditLang(true)
try {
await onUpdateLanguage(editingLangText.Id, editLangContent)
setEditingLangText(null)
} finally {
setSavingEditLang(false)
}
}
const handleVideoDrop = (e) => {
e.preventDefault()
setDragOver(false)
const file = e.dataTransfer.files?.[0]
if (file && file.type.startsWith('video/')) {
onFileUpload(file)
}
}
const handleAddDriveVideo = async () => {
if (!driveUrl.trim()) return
await onAddDriveVideo(driveUrl)
setDriveUrl('')
}
return (
<>
<div className="p-6 space-y-5">
{/* Version Timeline — only shown when there are multiple rounds */}
{versions.length > 1 && (
<div>
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-3">{t('artefacts.versions')}</h4>
<ArtefactVersionTimeline
versions={versions}
activeVersionId={selectedVersion?.Id}
onSelectVersion={onSelectVersion}
artefactType={artefact.type}
/>
</div>
)}
{/* Type-specific content */}
{versionData && selectedVersion && (
<div className="border-t border-border pt-5">
{/* COPY TYPE: Language entries */}
{artefact.type === 'copy' && (
<div>
<div className="flex items-center justify-between mb-3">
<h4 className="text-xs font-semibold text-text-tertiary uppercase">{t('artefacts.languages')}</h4>
<button
onClick={() => setShowLanguageModal(true)}
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors"
>
<Globe className="w-3 h-3" />
{t('artefacts.addLanguage')}
</button>
</div>
{versionData.texts && versionData.texts.length > 0 ? (
<div className="space-y-3">
{versionData.texts.map(text => (
<div key={text.Id} className="bg-surface-secondary rounded-lg p-4 border border-border">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
<span className="px-2 py-0.5 bg-surface border border-border rounded text-xs font-mono font-medium">
{text.language_code}
</span>
<span className="text-sm font-medium text-text-primary">{text.language_label}</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => { setEditingLangText(text); setEditLangContent(text.content || '') }}
className="p-1 text-text-tertiary hover:text-brand-primary rounded transition-colors"
title={t('common.edit')}
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button
onClick={() => setConfirmDeleteLangId(text.Id)}
className="p-1 text-red-500 hover:text-red-700 rounded transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
<div className="bg-surface rounded border border-border p-3 text-sm text-text-primary whitespace-pre-wrap font-sans">
{text.content}
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8 bg-surface-secondary rounded-lg border-2 border-dashed border-border">
<Globe className="w-8 h-8 text-text-tertiary mx-auto mb-2" />
<p className="text-sm text-text-secondary">{t('artefacts.noLanguages')}</p>
</div>
)}
</div>
)}
{/* DESIGN TYPE: Image gallery */}
{artefact.type === 'design' && (
<div>
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-3">{t('artefacts.imagesLabel')}</h4>
{versionData.attachments && versionData.attachments.length > 0 && (
<div className="grid grid-cols-2 gap-3 mb-3">
{versionData.attachments.map(att => (
<div key={att.Id} className="relative group">
<img
src={att.url}
alt={att.original_name}
className="w-full h-48 object-cover rounded-lg border border-border"
loading="lazy"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors rounded-lg flex items-center justify-center">
<button
onClick={() => setConfirmDeleteAttId(att.Id)}
className="opacity-0 group-hover:opacity-100 transition-opacity px-3 py-2 bg-red-600 text-white rounded-lg text-xs font-medium hover:bg-red-700"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
<div className="mt-1 px-2 py-1 bg-surface-secondary rounded text-xs text-text-secondary truncate">
{att.original_name}
</div>
</div>
))}
</div>
)}
<UploadZone
onUpload={onFileUpload}
accept="image/*"
uploading={uploading}
progress={uploadProgress}
label={t('artefacts.dropOrClickImage') || 'Drop images here or click to upload'}
hint={t('artefacts.imageFormats') || 'PNG, JPG, WebP'}
compact={versionData.attachments?.length > 0}
/>
</div>
)}
{/* VIDEO TYPE: Files and Drive links -- all inline */}
{artefact.type === 'video' && (
<div>
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-3">{t('artefacts.videosLabel')}</h4>
{/* Existing attachments */}
{versionData.attachments && versionData.attachments.length > 0 && (
<div className="space-y-3 mb-4">
{versionData.attachments.map(att => (
<div key={att.Id} className="bg-surface-secondary rounded-lg p-4 border border-border">
{att.drive_url ? (
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-text-primary">{t('artefacts.googleDriveVideo')}</span>
<button onClick={() => setConfirmDeleteAttId(att.Id)} className="text-red-600 hover:text-red-700">
<Trash2 className="w-4 h-4" />
</button>
</div>
<iframe src={getDriveEmbedUrl(att.drive_url)} className="w-full h-64 rounded border border-border" allow="autoplay" />
</div>
) : (
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-text-primary truncate">{att.original_name}</span>
<button onClick={() => setConfirmDeleteAttId(att.Id)} className="text-red-600 hover:text-red-700">
<Trash2 className="w-4 h-4" />
</button>
</div>
<video src={att.url} controls className="w-full rounded border border-border" />
</div>
)}
</div>
))}
</div>
)}
{/* Drag-and-drop / click-to-upload zone */}
<UploadZone
onUpload={onFileUpload}
accept="video/*"
uploading={uploading}
progress={uploadProgress}
label={t('artefacts.dropOrClickVideo')}
hint={t('artefacts.videoFormats')}
/>
{/* Google Drive URL inline input */}
<div className="flex items-center gap-2 mt-3">
<Globe className="w-4 h-4 text-text-tertiary shrink-0" />
<input
type="text"
value={driveUrl}
onChange={e => setDriveUrl(e.target.value)}
placeholder="https://drive.google.com/file/d/..."
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
onKeyDown={e => { if (e.key === 'Enter' && driveUrl.trim()) handleAddDriveVideo() }}
/>
<button
onClick={handleAddDriveVideo}
disabled={uploading || !driveUrl.trim()}
className="px-3 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 shrink-0"
>
{t('artefacts.addLink')}
</button>
</div>
</div>
)}
</div>
)}
</div>
{/* Language Modal */}
<Modal isOpen={showLanguageModal} onClose={() => setShowLanguageModal(false)} title={t('artefacts.addLanguage')} size="md">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('artefacts.languageLabel')} *</label>
<PortalSelect
value={languageForm.language_code}
onChange={val => {
const lang = AVAILABLE_LANGUAGES.find(l => l.code === val)
if (lang) setLanguageForm(f => ({ ...f, language_code: lang.code, language_label: lang.label }))
else setLanguageForm(f => ({ ...f, language_code: '', language_label: '' }))
}}
options={[
{ value: '', label: t('artefacts.selectLanguage') },
...AVAILABLE_LANGUAGES
.filter(lang => !(versionData?.texts || []).some(t => t.language_code === lang.code))
.map(lang => ({ value: lang.code, label: `${lang.label} (${lang.code})` }))
]}
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('artefacts.contentLabel')} *</label>
<textarea
value={languageForm.content}
onChange={e => setLanguageForm(f => ({ ...f, content: e.target.value }))}
rows={8}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 font-sans"
placeholder={t('artefacts.enterContent')}
/>
</div>
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
<button
onClick={() => setShowLanguageModal(false)}
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
>
{t('common.cancel')}
</button>
<button
onClick={handleAddLanguage}
disabled={savingLanguage}
className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 shadow-sm"
>
{savingLanguage ? t('header.saving') : t('common.save')}
</button>
</div>
</div>
</Modal>
{/* Edit Language Modal */}
<Modal isOpen={!!editingLangText} onClose={() => setEditingLangText(null)} title={t('artefacts.editLanguage')} size="md">
<div className="space-y-4">
{editingLangText && (
<div className="flex items-center gap-2">
<span className="px-2 py-0.5 bg-surface border border-border rounded text-xs font-mono font-medium">
{editingLangText.language_code}
</span>
<span className="text-sm font-medium text-text-primary">{editingLangText.language_label}</span>
</div>
)}
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('artefacts.contentLabel')}</label>
<textarea
value={editLangContent}
onChange={e => setEditLangContent(e.target.value)}
rows={8}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 font-sans"
/>
</div>
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
<button
onClick={() => setEditingLangText(null)}
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
>
{t('common.cancel')}
</button>
<button
onClick={handleSaveEditLang}
disabled={savingEditLang}
className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 shadow-sm"
>
{savingEditLang ? t('header.saving') : t('common.save')}
</button>
</div>
</div>
</Modal>
{/* Delete Language Confirmation */}
<Modal
isOpen={!!confirmDeleteLangId}
onClose={() => setConfirmDeleteLangId(null)}
title={t('artefacts.deleteLanguage')}
isConfirm
danger
onConfirm={() => handleDeleteLanguage(confirmDeleteLangId)}
confirmText={t('common.delete')}
>
{t('artefacts.deleteLanguageDesc')}
</Modal>
{/* Delete Attachment Confirmation */}
<Modal
isOpen={!!confirmDeleteAttId}
onClose={() => setConfirmDeleteAttId(null)}
title={t('artefacts.deleteAttachment')}
isConfirm
danger
onConfirm={() => handleDeleteAttachment(confirmDeleteAttId)}
confirmText={t('common.delete')}
>
{t('artefacts.deleteAttachmentDesc')}
</Modal>
</>
)
}
@@ -85,6 +85,7 @@ export default function ArtefactVersionTimeline({ versions, activeVersionId, onS
src={version.thumbnail}
alt={`Version ${version.version_number}`}
className="w-full h-20 object-cover rounded border border-border"
loading="lazy"
/>
</div>
)}
+2 -1
View File
@@ -24,7 +24,7 @@ export default function AssetCard({ asset, onClick }) {
return (
<div
onClick={() => onClick?.(asset)}
className="bg-white rounded-xl border border-border overflow-hidden card-hover cursor-pointer group"
className="bg-surface rounded-xl border border-border overflow-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'
+3 -3
View File
@@ -41,7 +41,7 @@ export default function CampaignCalendar({ campaigns = [] }) {
}
return (
<div className="bg-white rounded-xl border border-border overflow-hidden">
<div className="bg-surface rounded-xl border border-border overflow-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 : ''}
+16 -20
View File
@@ -6,6 +6,7 @@ import CommentsSection from './CommentsSection'
import Modal from './Modal'
import TabbedModal from './TabbedModal'
import BudgetBar from './BudgetBar'
import PortalSelect from './PortalSelect'
import { AppContext } from '../App'
export default function CampaignDetailPanel({ campaign, onClose, onSave, onDelete, brands, permissions }) {
@@ -130,7 +131,7 @@ export default function CampaignDetailPanel({ campaign, onClose, onSave, onDelet
form.status === 'paused' ? 'bg-amber-100 text-amber-700' :
form.status === 'completed' ? 'bg-blue-100 text-blue-700' :
form.status === 'cancelled' ? 'bg-red-100 text-red-700' :
'bg-gray-100 text-gray-600'
'bg-gray-100 text-text-secondary'
}`}>
{statusOptions.find(s => s.value === form.status)?.label}
</span>
@@ -189,44 +190,39 @@ export default function CampaignDetailPanel({ campaign, onClose, onSave, onDelet
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.brand')}</label>
<select
<PortalSelect
value={form.brand_id}
onChange={e => update('brand_id', e.target.value)}
onChange={val => update('brand_id', val)}
options={[{ value: '', label: 'Select brand' }, ...(brands || []).map(b => ({ value: b.id || b._id, label: `${b.icon || ''} ${lang === 'ar' && b.name_ar ? b.name_ar : b.name}`.trim() }))]}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
<option value="">Select brand</option>
{(brands || []).map(b => <option key={b.id || b._id} value={b.id || b._id}>{b.icon} {lang === 'ar' && b.name_ar ? b.name_ar : b.name}</option>)}
</select>
/>
</div>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.status')}</label>
<select
<PortalSelect
value={form.status}
onChange={e => update('status', e.target.value)}
onChange={val => update('status', val)}
options={statusOptions}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
{statusOptions.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
</select>
/>
</div>
</div>
{/* Team */}
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('common.team')}</label>
<select
<PortalSelect
value={form.team_id}
onChange={e => update('team_id', e.target.value)}
onChange={val => update('team_id', val)}
options={[{ value: '', label: t('common.noTeam') }, ...(teams || []).map(tm => ({ value: tm.id || tm._id, label: tm.name }))]}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
<option value="">{t('common.noTeam')}</option>
{(teams || []).map(t => <option key={t.id || t._id} value={t.id || t._id}>{t.name}</option>)}
</select>
/>
</div>
{/* Platforms */}
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.platforms')}</label>
<div className="flex flex-wrap gap-2 p-2 border border-border rounded-lg bg-white min-h-[38px]">
<div className="flex flex-wrap gap-2 p-2 border border-border rounded-lg bg-surface min-h-[38px]">
{Object.entries(PLATFORMS).map(([k, v]) => {
const checked = (form.platforms || []).includes(k)
return (
@@ -281,7 +277,7 @@ export default function CampaignDetailPanel({ campaign, onClose, onSave, onDelet
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">
{t('campaigns.budget')} ({currencySymbol})
{!permissions?.canSetBudget && <span className="text-[10px] text-text-tertiary ml-1">(Superadmin only)</span>}
{!permissions?.canSetBudget && <span className="text-[10px] text-text-tertiary ms-1">(Superadmin only)</span>}
</label>
<input
type="number"
+2 -2
View File
@@ -116,7 +116,7 @@ export default function CommentsSection({ entityType, entityId }) {
<div key={c.id} className="flex items-start gap-2 group">
<div className="w-7 h-7 rounded-full bg-brand-primary/10 text-brand-primary flex items-center justify-center text-[10px] font-bold shrink-0 mt-0.5">
{c.user_avatar ? (
<img src={c.user_avatar} className="w-full h-full rounded-full object-cover" alt="" />
<img src={c.user_avatar} className="w-full h-full rounded-full object-cover" alt="" loading="lazy" />
) : (
getInitials(c.user_name)
)}
@@ -125,7 +125,7 @@ export default function CommentsSection({ entityType, entityId }) {
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-text-primary">{c.user_name}</span>
<span className="text-[10px] text-text-tertiary">{relativeTime(c.created_at, t)}</span>
<div className="flex items-center gap-0.5 ml-auto opacity-0 group-hover:opacity-100 transition-opacity">
<div className="flex items-center gap-0.5 ms-auto opacity-0 group-hover:opacity-100 transition-opacity">
{canEdit(c) && editingId !== c.id && (
<button
onClick={() => startEdit(c)}
+1 -1
View File
@@ -17,7 +17,7 @@ export default function DatePresetPicker({ onSelect, activePreset, onClear }) {
className={`px-2.5 py-1 text-[11px] font-medium rounded-full border transition-colors ${
activePreset === preset.key
? 'bg-brand-primary/10 border-brand-primary/30 text-brand-primary'
: 'bg-white border-border text-text-tertiary hover:text-text-primary hover:border-border-dark'
: 'bg-surface border-border text-text-tertiary hover:text-text-primary hover:border-border-dark'
}`}
>
{t(preset.labelKey)}
+3 -3
View File
@@ -21,7 +21,7 @@ export default function EmptyState({
{actionLabel && (
<button
onClick={onAction}
className="mt-3 text-sm text-brand-primary hover:text-brand-primary-light font-medium"
className="mt-3 text-sm text-brand-primary hover:text-brand-primary-light font-medium transition-colors"
>
{actionLabel}
</button>
@@ -44,7 +44,7 @@ export default function EmptyState({
{actionLabel && (
<button
onClick={onAction}
className="px-5 py-2.5 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm transition-all hover:-translate-y-0.5"
className="px-5 py-2.5 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light transition-colors"
>
{actionLabel}
</button>
@@ -52,7 +52,7 @@ export default function EmptyState({
{secondaryActionLabel && (
<button
onClick={onSecondaryAction}
className="px-5 py-2.5 bg-white border border-border text-text-primary rounded-lg text-sm font-medium hover:bg-surface-secondary transition-colors"
className="px-5 py-2.5 bg-surface border border-border text-text-primary rounded-lg text-sm font-medium hover:bg-surface-secondary transition-colors"
>
{secondaryActionLabel}
</button>
+3 -3
View File
@@ -28,7 +28,7 @@ export default function FormInput({
? 'border-emerald-300 focus:border-emerald-500 focus:ring-emerald-500/20'
: 'border-border focus:border-brand-primary focus:ring-brand-primary/20'
}
${disabled ? 'bg-surface-tertiary cursor-not-allowed opacity-60' : 'bg-white'}
${disabled ? 'bg-surface-tertiary cursor-not-allowed opacity-60' : 'bg-surface'}
${className}
`.trim()
@@ -39,7 +39,7 @@ export default function FormInput({
{label && (
<label className="block text-sm font-medium text-text-primary">
{label}
{required && <span className="text-red-500 ml-0.5">*</span>}
{required && <span className="text-red-500 ms-0.5">*</span>}
</label>
)}
@@ -57,7 +57,7 @@ export default function FormInput({
{/* Validation icon */}
{(hasError || hasSuccess) && (
<div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none">
<div className="absolute end-3 top-1/2 -translate-y-1/2 pointer-events-none">
{hasError ? (
<AlertCircle className="w-4 h-4 text-red-500" />
) : (
+11 -6
View File
@@ -22,6 +22,7 @@ const PAGE_TITLE_KEYS = {
'/issues': 'header.issues',
'/team': 'header.team',
'/settings': 'header.settings',
'/translations': 'header.copy',
}
const ROLE_INFO = {
@@ -44,6 +45,7 @@ export default function Header() {
function getPageTitle(pathname) {
if (PAGE_TITLE_KEYS[pathname]) return t(PAGE_TITLE_KEYS[pathname])
if (pathname.startsWith('/posts/')) return t('header.postDetails')
if (pathname.startsWith('/projects/')) return t('header.projectDetails')
if (pathname.startsWith('/campaigns/')) return t('header.campaignDetails')
return t('header.page')
@@ -99,7 +101,7 @@ export default function Header() {
return (
<>
<header className="h-16 bg-white border-b border-border flex items-center justify-between px-6 shrink-0 sticky top-0 z-20">
<header className="h-16 bg-surface border-b border-border flex items-center justify-between px-6 shrink-0 sticky top-0 z-20">
{/* Page title */}
<div>
<h2 className="text-xl font-bold text-text-primary">{pageTitle}</h2>
@@ -118,8 +120,8 @@ export default function Header() {
>
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-semibold ${
user?.role === 'superadmin'
? 'bg-gradient-to-br from-purple-500 to-pink-500'
: 'bg-gradient-to-br from-blue-500 to-indigo-500'
? 'bg-brand-primary'
: 'bg-teal-700'
}`}>
{getInitials(user?.name)}
</div>
@@ -135,7 +137,7 @@ export default function Header() {
</button>
{showDropdown && (
<div className="absolute end-0 top-full mt-2 w-64 bg-white rounded-xl shadow-lg border border-border overflow-hidden animate-scale-in">
<div className="absolute end-0 top-full mt-2 w-64 bg-surface rounded-xl shadow-lg border border-border overflow-hidden animate-scale-in" role="menu">
{/* User info */}
<div className="px-4 py-3 border-b border-border-light bg-surface-secondary">
<p className="text-sm font-semibold text-text-primary">{user?.name}</p>
@@ -174,7 +176,7 @@ export default function Header() {
setShowDropdown(false)
logout()
}}
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-red-50 transition-colors text-left group"
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-red-50 transition-colors text-start group"
>
<LogOut className="w-4 h-4 text-text-tertiary group-hover:text-red-500" />
<span className="text-sm text-text-primary group-hover:text-red-500">{t('header.signOut')}</span>
@@ -197,6 +199,7 @@ export default function Header() {
onChange={e => { setPasswordForm(f => ({ ...f, currentPassword: e.target.value })); setPasswordError('') }}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
placeholder="••••••••"
aria-describedby={passwordError ? 'password-error' : undefined}
/>
</div>
<div>
@@ -208,6 +211,7 @@ export default function Header() {
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
placeholder="••••••••"
minLength={6}
aria-describedby={passwordError ? 'password-error' : undefined}
/>
</div>
<div>
@@ -219,11 +223,12 @@ export default function Header() {
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
placeholder="••••••••"
minLength={6}
aria-describedby={passwordError ? 'password-error' : undefined}
/>
</div>
{passwordError && (
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
<div id="password-error" className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg" role="alert">
<AlertCircle className="w-4 h-4 text-red-500 shrink-0" />
<p className="text-sm text-red-500">{passwordError}</p>
</div>
+12 -12
View File
@@ -237,7 +237,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
if (items.length === 0) {
return (
<div className="bg-white rounded-xl border border-border py-16 text-center">
<div className="bg-surface rounded-xl border border-border py-16 text-center">
<Calendar className="w-12 h-12 text-text-tertiary mx-auto mb-3" />
<p className="text-text-secondary font-medium">{t('timeline.noItems')}</p>
<p className="text-sm text-text-tertiary mt-1">{t('timeline.addItems')}</p>
@@ -246,7 +246,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
}
return (
<div className="bg-white rounded-xl border border-border overflow-hidden">
<div className="bg-surface rounded-xl border border-border overflow-clip">
{/* Toolbar */}
<div className="flex items-center justify-between px-4 py-2 border-b border-border bg-surface-secondary">
<div className="flex items-center gap-2">
@@ -287,8 +287,8 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
<div ref={containerRef} dir="ltr" className="overflow-x-auto relative" style={{ cursor: dragState ? 'grabbing' : undefined }}>
<div style={{ minWidth: `${labelWidth + totalDays * pxPerDay}px` }}>
{/* Day header */}
<div className="flex sticky top-0 z-20 bg-white border-b border-border" style={{ height: headerHeight }}>
<div className="shrink-0 border-e border-border bg-surface-secondary flex items-center px-4 sticky left-0 z-30" style={{ width: labelWidth }}>
<div className="flex sticky top-0 z-20 bg-surface border-b border-border" style={{ height: headerHeight }}>
<div className="shrink-0 border-e border-border bg-surface-secondary flex items-center px-4 sticky start-0 z-30" style={{ width: labelWidth }}>
<span className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('timeline.item')}</span>
</div>
<div className="flex relative">
@@ -338,7 +338,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
>
{/* Label column */}
<div
className={`shrink-0 border-e border-border flex ${isExpanded ? 'flex-col justify-center gap-1' : 'items-center gap-2'} px-3 overflow-hidden sticky left-0 z-10 bg-white group-hover/row:bg-surface-secondary/50`}
className={`shrink-0 border-e border-border flex ${isExpanded ? 'flex-col justify-center gap-1' : 'items-center gap-2'} px-3 overflow-hidden sticky start-0 z-10 bg-surface group-hover/row:bg-surface-secondary/50`}
style={{ width: labelWidth }}
>
{isExpanded ? (
@@ -358,7 +358,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
)}
{item.thumbnailUrl ? (
<div className="w-8 h-8 rounded overflow-hidden shrink-0">
<img src={item.thumbnailUrl} alt="" className="w-full h-full object-cover" />
<img src={item.thumbnailUrl} alt="" className="w-full h-full object-cover" loading="lazy" />
</div>
) : item.assigneeName ? (
<div className="w-6 h-6 rounded-full bg-brand-primary/10 text-brand-primary flex items-center justify-center text-[9px] font-bold shrink-0">
@@ -394,7 +394,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
)}
{item.thumbnailUrl ? (
<div className="w-6 h-6 rounded overflow-hidden shrink-0">
<img src={item.thumbnailUrl} alt="" className="w-full h-full object-cover" />
<img src={item.thumbnailUrl} alt="" className="w-full h-full object-cover" loading="lazy" />
</div>
) : item.assigneeName ? (
<div className="w-6 h-6 rounded-full bg-brand-primary/10 text-brand-primary flex items-center justify-center text-[9px] font-bold shrink-0">
@@ -415,7 +415,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
style={{ left: `${todayOffset + pxPerDay / 2}px` }}
>
{idx === 0 && (
<div className="absolute -top-0 left-1 text-[8px] font-bold text-red-500 bg-red-50 px-1 rounded whitespace-nowrap">
<div className="absolute -top-0 start-1 text-[8px] font-bold text-red-500 bg-red-50 px-1 rounded whitespace-nowrap">
{t('timeline.today')}
</div>
)}
@@ -459,7 +459,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
{/* Left resize handle */}
{!readOnly && onDateChange && (
<div
className="absolute left-0 top-0 bottom-0 w-2 cursor-col-resize hover:bg-white/30 z-10"
className="absolute start-0 top-0 bottom-0 w-2 cursor-col-resize hover:bg-white/30 z-10"
onMouseDown={(e) => handleMouseDown(e, item, 'resize-left')}
/>
)}
@@ -520,7 +520,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
{/* Right resize handle */}
{!readOnly && onDateChange && (
<div
className="absolute right-0 top-0 bottom-0 w-2 cursor-col-resize hover:bg-white/30 z-10"
className="absolute end-0 top-0 bottom-0 w-2 cursor-col-resize hover:bg-white/30 z-10"
onMouseDown={(e) => handleMouseDown(e, item, 'resize-right')}
/>
)}
@@ -536,7 +536,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
{colorPicker && onColorChange && (
<div
ref={colorPickerRef}
className="fixed z-50 bg-white rounded-lg shadow-xl border border-border p-2"
className="fixed z-50 bg-surface rounded-lg shadow-xl border border-border p-2"
style={{ left: colorPicker.x, top: colorPicker.y }}
>
<div className="grid grid-cols-4 gap-1.5 mb-2">
@@ -591,7 +591,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
)}
</div>
{!readOnly && onDateChange && (
<div className="text-gray-400 mt-1 text-[10px] italic">
<div className="text-text-tertiary mt-1 text-[10px] italic">
{t('timeline.dragToMove')} · {t('timeline.dragToResize')}
</div>
)}
+26 -41
View File
@@ -1,11 +1,13 @@
import { useState, useEffect, useContext } from 'react'
import { Copy, Eye, Lock, Send, Upload, FileText, Trash2, Check, Clock, CheckCircle2, XCircle, FileEdit, Wrench, MessageSquare, Paperclip } from 'lucide-react'
import { Copy, Eye, Lock, Send, FileText, Trash2, Check, Clock, CheckCircle2, XCircle, FileEdit, Wrench, MessageSquare, Paperclip } from 'lucide-react'
import UploadZone from './UploadZone'
import { api, STATUS_CONFIG, PRIORITY_CONFIG } from '../utils/api'
import TabbedModal from './TabbedModal'
import Modal from './Modal'
import { useToast } from './ToastContainer'
import { AppContext } from '../App'
import { useLanguage } from '../i18n/LanguageContext'
import PortalSelect from './PortalSelect'
export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers, teams = [] }) {
const { brands } = useContext(AppContext)
@@ -284,67 +286,53 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
{/* Assigned To */}
<div>
<label className="block text-sm font-semibold text-text-primary mb-2">{t('issues.assignedTo')}</label>
<select
<PortalSelect
value={assignedTo}
onChange={(e) => handleAssignmentChange(e.target.value)}
onChange={val => handleAssignmentChange(val)}
options={[{ value: '', label: t('issues.unassigned') }, ...teamMembers.map(member => ({ value: member.id || member._id, label: member.name }))]}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
>
<option value="">{t('issues.unassigned')}</option>
{teamMembers.map((member) => (
<option key={member.id || member._id} value={member.id || member._id}>
{member.name}
</option>
))}
</select>
/>
</div>
{/* Team */}
{teams.length > 0 && (
<div>
<label className="block text-sm font-semibold text-text-primary mb-2">{t('issues.team')}</label>
<select
<PortalSelect
value={teamId}
onChange={async (e) => {
const val = e.target.value || null
setTeamId(val || '')
onChange={async (val) => {
const resolvedVal = val || null
setTeamId(resolvedVal || '')
try {
await api.patch(`/issues/${issueId}`, { team_id: val })
await api.patch(`/issues/${issueId}`, { team_id: resolvedVal })
await onUpdate()
await loadIssueDetails()
} catch (err) {
console.error('Failed to update team:', err)
}
}}
options={[{ value: '', label: t('issues.allTeams') }, ...teams.map(team => ({ value: team.id || team._id, label: team.name }))]}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
>
<option value="">{t('issues.allTeams')}</option>
{teams.map((team) => (
<option key={team.id || team._id} value={team.id || team._id}>{team.name}</option>
))}
</select>
/>
</div>
)}
{/* Brand */}
<div>
<label className="block text-sm font-semibold text-text-primary mb-2">{t('issues.brandLabel')}</label>
<select
<PortalSelect
value={issueData.brand_id || ''}
onChange={async (e) => {
const val = e.target.value || null;
onChange={async (val) => {
const resolvedVal = val || null;
try {
await api.patch(`/issues/${issueId}`, { brand_id: val });
await api.patch(`/issues/${issueId}`, { brand_id: resolvedVal });
loadIssueDetails();
onUpdate();
} catch {}
}}
options={[{ value: '', label: t('issues.noBrand') }, ...(brands || []).map(b => ({ value: b._id || b.Id, label: b.name }))]}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
>
<option value="">{t('issues.noBrand')}</option>
{(brands || []).map((b) => (
<option key={b._id || b.Id} value={b._id || b.Id}>{b.name}</option>
))}
</select>
/>
</div>
{/* Internal Notes */}
@@ -501,15 +489,12 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
{activeTab === 'attachments' && (
<div className="p-6 space-y-5">
{/* Upload */}
<label className="block">
<input type="file" onChange={handleFileUpload} disabled={uploadingFile} className="hidden" />
<div className="border-2 border-dashed border-border rounded-lg p-4 text-center cursor-pointer hover:bg-surface-secondary transition-colors">
<Upload className="w-6 h-6 mx-auto mb-2 text-text-tertiary" />
<p className="text-sm text-text-secondary">
{uploadingFile ? t('issues.uploading') : t('issues.clickToUpload')}
</p>
</div>
</label>
<UploadZone
onUpload={handleFileUpload}
uploading={uploadingFile}
label={t('issues.clickToUpload')}
compact
/>
{/* Attachments List */}
<div className="space-y-2">
+2 -2
View File
@@ -10,12 +10,12 @@ export default function KanbanCard({ title, thumbnail, brandName, tags, assignee
return (
<div
onClick={onClick}
className="bg-white rounded-lg p-3 border border-border hover:border-brand-primary/30 cursor-pointer transition-all group overflow-hidden"
className="bg-surface rounded-lg p-3 border border-border hover:border-brand-primary/30 cursor-pointer transition-all group overflow-hidden"
>
{/* Thumbnail */}
{thumbnail && (
<div className="w-[calc(100%+1.5rem)] h-32 -mx-3 -mt-3 mb-2 rounded-t-lg overflow-hidden">
<img src={thumbnail} alt="" className="w-full h-full object-cover" />
<img src={thumbnail} alt="" className="w-full h-full object-cover" loading="lazy" />
</div>
)}
+2 -2
View File
@@ -15,7 +15,7 @@ const ROLE_BADGES = {
strategist: { bg: 'bg-rose-50', text: 'text-rose-700', label: 'Strategist' },
superadmin: { bg: 'bg-red-50', text: 'text-red-700', label: 'Super Admin' },
contributor: { bg: 'bg-slate-50', text: 'text-slate-700', label: 'Contributor' },
default: { bg: 'bg-gray-50', text: 'text-gray-700', label: 'Team Member' },
default: { bg: 'bg-gray-50', text: 'text-text-secondary', label: 'Team Member' },
}
export default function MemberCard({ member, onClick }) {
@@ -33,7 +33,7 @@ export default function MemberCard({ member, onClick }) {
return (
<div
onClick={() => onClick?.(member)}
className="bg-white rounded-xl border border-border p-5 card-hover cursor-pointer text-center"
className="bg-surface rounded-xl border border-border p-5 card-hover cursor-pointer text-center"
>
{/* Avatar */}
<div className={`w-16 h-16 rounded-full bg-gradient-to-br ${avatarColors[colorIndex]} flex items-center justify-center text-white text-xl font-bold mx-auto mb-3`}>
+44 -19
View File
@@ -1,15 +1,38 @@
import { useEffect } from 'react'
import { useEffect, useRef, useCallback } from 'react'
import { createPortal } from 'react-dom'
import { X, AlertTriangle } from 'lucide-react'
import { useLanguage } from '../i18n/LanguageContext'
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>
+123
View File
@@ -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
)}
</>
)
}
+2 -2
View File
@@ -23,11 +23,11 @@ export default function PostCard({ post, onClick, onMove, compact = false, check
return (
<div
onClick={onClick}
className="bg-white rounded-lg p-3 border border-border hover:border-brand-primary/30 cursor-pointer transition-all group overflow-hidden card-hover"
className="bg-surface rounded-lg p-3 border border-border hover:border-brand-primary/30 cursor-pointer transition-all group overflow-hidden card-hover"
>
{post.thumbnail_url && (
<div className="w-[calc(100%+1.5rem)] h-32 -mx-3 -mt-3 mb-2 rounded-t-lg overflow-hidden">
<img src={post.thumbnail_url} alt="" className="w-full h-full object-cover" />
<img src={post.thumbnail_url} alt="" className="w-full h-full object-cover" loading="lazy" />
</div>
)}
@@ -0,0 +1,109 @@
import { Send, CheckCircle2, XCircle, Copy, Check, Clock } from 'lucide-react'
import { useLanguage } from '../i18n/LanguageContext'
import ApproverMultiSelect from './ApproverMultiSelect'
export function PostDetailApproval({
form,
update,
post,
isCreateMode,
reviewUrl,
copied,
submittingReview,
saving,
teamMembers,
onSubmitReview,
onCopyReviewLink,
onStatusAction,
}) {
const { t } = useLanguage()
return (
<div className="p-6 space-y-5 w-full">
<div className="bg-surface-secondary rounded-xl p-4">
<label className="block text-xs font-medium text-text-primary mb-2">{t('posts.approvers')}</label>
<ApproverMultiSelect
users={teamMembers || []}
selected={form.approver_ids || []}
onChange={ids => update('approver_ids', ids)}
/>
</div>
{!isCreateMode && (
<div className="space-y-4">
{/* Approval status cards */}
{form.status === 'approved' && post.approved_by_name && (
<div className="bg-emerald-50 border border-emerald-200 rounded-xl p-4 flex items-start gap-3">
<div className="w-8 h-8 rounded-full bg-emerald-100 flex items-center justify-center shrink-0 mt-0.5">
<CheckCircle2 className="w-4 h-4 text-emerald-600" />
</div>
<div>
<p className="text-sm font-semibold text-emerald-800">{t('posts.approvedBy')} {post.approved_by_name}</p>
{post.feedback && <p className="text-xs text-emerald-700 mt-1.5 leading-relaxed">{post.feedback}</p>}
</div>
</div>
)}
{form.status === 'rejected' && post.approved_by_name && (
<div className="bg-red-50 border border-red-200 rounded-xl p-4 flex items-start gap-3">
<div className="w-8 h-8 rounded-full bg-red-100 flex items-center justify-center shrink-0 mt-0.5">
<XCircle className="w-4 h-4 text-red-600" />
</div>
<div>
<p className="text-sm font-semibold text-red-800">{t('posts.rejectedBy')} {post.approved_by_name}</p>
{post.feedback && <p className="text-xs text-red-700 mt-1.5 leading-relaxed">{post.feedback}</p>}
</div>
</div>
)}
{form.status === 'in_review' && (
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4 text-center">
<div className="w-10 h-10 rounded-full bg-amber-100 flex items-center justify-center mx-auto mb-2">
<Clock className="w-5 h-5 text-amber-600" />
</div>
<p className="text-sm font-semibold text-amber-800">{t('posts.awaitingReview')}</p>
<p className="text-xs text-amber-600 mt-1">{t('posts.awaitingReviewDesc')}</p>
</div>
)}
{/* Review link */}
{reviewUrl && (
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
<div className="text-xs font-semibold text-blue-900 mb-2.5">{t('posts.reviewLinkTitle')}</div>
<div className="flex items-center gap-2">
<input type="text" value={reviewUrl} readOnly className="flex-1 px-3 py-2 text-xs bg-surface border border-blue-200 rounded-lg font-mono" />
<button onClick={onCopyReviewLink} className="p-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors shadow-sm">
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
</button>
</div>
</div>
)}
{/* Action buttons */}
<div className="flex gap-3">
{!reviewUrl && (
<button
onClick={onSubmitReview}
disabled={submittingReview}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-amber-500 text-white rounded-xl hover:bg-amber-600 transition-colors font-medium text-sm disabled:opacity-50 shadow-sm"
>
<Send className="w-4 h-4" />
{submittingReview ? t('posts.submitting') : t('posts.sendToReview')}
</button>
)}
{form.status === 'approved' && (
<button
onClick={() => onStatusAction('scheduled')}
disabled={saving}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-purple-600 text-white rounded-xl hover:bg-purple-700 transition-colors font-medium text-sm disabled:opacity-50 shadow-sm"
>
{t('posts.schedule')}
</button>
)}
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,247 @@
import { useState, useRef } from 'react'
import { X, Upload, FileText, FolderOpen, Image as ImageIcon, Music, Film } from 'lucide-react'
import { useLanguage } from '../i18n/LanguageContext'
export function PostDetailAttachments({
attachments,
uploading,
onFileUpload,
onDeleteAttachment,
onAttachAsset,
}) {
const { t } = useLanguage()
const imageInputRef = useRef(null)
const [dragActive, setDragActive] = useState(false)
const [showAssetPicker, setShowAssetPicker] = useState(false)
const [availableAssets, setAvailableAssets] = useState([])
const [assetSearch, setAssetSearch] = useState('')
const handleDrop = (e) => {
e.preventDefault(); e.stopPropagation(); setDragActive(false)
if (e.dataTransfer.files?.length) onFileUpload(e.dataTransfer.files)
}
const openAssetPicker = async () => {
const { api } = await import('../utils/api')
try {
const data = await api.get('/assets')
setAvailableAssets(Array.isArray(data) ? data : [])
} catch {
setAvailableAssets([])
}
setAssetSearch('')
setShowAssetPicker(true)
}
const handleAttachAsset = async (assetId) => {
await onAttachAsset(assetId)
setShowAssetPicker(false)
}
const images = attachments.filter(a => (a.mime_type || a.mimeType || '').startsWith('image/'))
const audio = attachments.filter(a => (a.mime_type || a.mimeType || '').startsWith('audio/'))
const videos = attachments.filter(a => (a.mime_type || a.mimeType || '').startsWith('video/'))
const others = attachments.filter(a => {
const mime = a.mime_type || a.mimeType || ''
return !mime.startsWith('image/') && !mime.startsWith('audio/') && !mime.startsWith('video/')
})
return (
<div className="space-y-4">
{/* Images */}
<div>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-1.5 text-xs font-medium text-text-secondary">
<ImageIcon className="w-3.5 h-3.5" />
{t('posts.images')}
{images.length > 0 && <span className="text-text-tertiary">({images.length})</span>}
</div>
<label className="flex items-center gap-1 px-2 py-1 text-[11px] font-medium text-brand-primary hover:bg-brand-primary/5 rounded cursor-pointer transition-colors">
<Upload className="w-3 h-3" />
{t('posts.addImage')}
<input ref={imageInputRef} type="file" multiple accept="image/*" className="hidden"
onChange={e => { onFileUpload(e.target.files); e.target.value = '' }} />
</label>
</div>
{images.length > 0 && (
<div className="grid grid-cols-3 gap-2">
{images.map(att => {
const attUrl = att.url || `/api/uploads/${att.filename}`
const name = att.original_name || att.originalName || att.filename
const attId = att.id || att._id
return (
<div key={attId} className="relative group/att border border-border rounded-lg overflow-hidden bg-surface">
<div className="h-20 relative">
<a href={attUrl} target="_blank" rel="noopener noreferrer" className="block h-full">
<img src={attUrl} alt={name} className="absolute inset-0 w-full h-full object-cover" />
</a>
<button onClick={() => onDeleteAttachment(attId)}
className="absolute top-1 end-1 p-1 bg-black/50 hover:bg-red-500 rounded-full text-white opacity-0 group-hover/att:opacity-100 transition-opacity"
title={t('common.delete')}><X className="w-2.5 h-2.5" /></button>
</div>
<div className="px-2 py-1 text-[10px] text-text-tertiary truncate border-t border-border-light">{name}</div>
</div>
)
})}
</div>
)}
</div>
{/* Audio */}
{audio.length > 0 && (
<div>
<div className="flex items-center gap-1.5 text-xs font-medium text-text-secondary mb-2">
<Music className="w-3.5 h-3.5" />
{t('posts.audio')} <span className="text-text-tertiary">({audio.length})</span>
</div>
<div className="space-y-2">
{audio.map(att => {
const attUrl = att.url || `/api/uploads/${att.filename}`
const name = att.original_name || att.originalName || att.filename
const attId = att.id || att._id
return (
<div key={attId} className="flex items-center gap-2 border border-border rounded-lg p-2 bg-surface group/att">
<Music className="w-4 h-4 text-text-tertiary shrink-0" />
<span className="text-xs text-text-secondary truncate flex-1">{name}</span>
<audio src={attUrl} controls className="h-7 max-w-[160px]" />
<button onClick={() => onDeleteAttachment(attId)}
className="p-1 text-text-tertiary hover:text-red-500 opacity-0 group-hover/att:opacity-100 transition-opacity"
title={t('common.delete')}><X className="w-3 h-3" /></button>
</div>
)
})}
</div>
</div>
)}
{/* Videos */}
{videos.length > 0 && (
<div>
<div className="flex items-center gap-1.5 text-xs font-medium text-text-secondary mb-2">
<Film className="w-3.5 h-3.5" />
{t('posts.videos')} <span className="text-text-tertiary">({videos.length})</span>
</div>
<div className="space-y-2">
{videos.map(att => {
const attUrl = att.url || `/api/uploads/${att.filename}`
const name = att.original_name || att.originalName || att.filename
const attId = att.id || att._id
return (
<div key={attId} className="border border-border rounded-lg overflow-hidden bg-surface group/att">
<video src={attUrl} controls className="w-full max-h-40" />
<div className="flex items-center justify-between px-2 py-1 border-t border-border-light">
<span className="text-[10px] text-text-tertiary truncate">{name}</span>
<button onClick={() => onDeleteAttachment(attId)}
className="p-1 text-text-tertiary hover:text-red-500 opacity-0 group-hover/att:opacity-100 transition-opacity"
title={t('common.delete')}><X className="w-3 h-3" /></button>
</div>
</div>
)
})}
</div>
</div>
)}
{/* Other files */}
{others.length > 0 && (
<div>
<div className="flex items-center gap-1.5 text-xs font-medium text-text-secondary mb-2">
<FileText className="w-3.5 h-3.5" />
{t('posts.otherFiles')} <span className="text-text-tertiary">({others.length})</span>
</div>
<div className="grid grid-cols-3 gap-2">
{others.map(att => {
const attUrl = att.url || `/api/uploads/${att.filename}`
const name = att.original_name || att.originalName || att.filename
const attId = att.id || att._id
return (
<div key={attId} className="relative group/att border border-border rounded-lg overflow-hidden bg-surface">
<a href={attUrl} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 p-3 h-16">
<FileText className="w-6 h-6 text-text-tertiary shrink-0" />
<span className="text-xs text-text-secondary truncate">{name}</span>
</a>
<button onClick={() => onDeleteAttachment(attId)}
className="absolute top-1 end-1 p-1 bg-black/50 hover:bg-red-500 rounded-full text-white opacity-0 group-hover/att:opacity-100 transition-opacity"
title={t('common.delete')}><X className="w-2.5 h-2.5" /></button>
</div>
)
})}
</div>
</div>
)}
{/* Drag and drop zone */}
<div
className={`border-2 border-dashed rounded-lg p-3 text-center cursor-pointer transition-colors ${
dragActive ? 'border-brand-primary bg-brand-primary/5' : 'border-border hover:border-brand-primary/40'
}`}
onDragEnter={e => { e.preventDefault(); setDragActive(true) }}
onDragLeave={e => { e.preventDefault(); setDragActive(false) }}
onDragOver={e => e.preventDefault()}
onDrop={handleDrop}
>
<Upload className={`w-4 h-4 text-text-tertiary mx-auto mb-1 ${uploading ? 'animate-pulse' : ''}`} />
<p className="text-[11px] text-text-secondary">
{dragActive ? t('posts.dropFiles') : t('posts.dragToUpload')}
</p>
</div>
<button
type="button"
onClick={openAssetPicker}
className="flex items-center gap-2 px-3 py-2 text-sm text-text-secondary border border-border rounded-lg hover:bg-surface-tertiary transition-colors w-full justify-center"
>
<FolderOpen className="w-4 h-4" />
{t('posts.attachFromAssets')}
</button>
{showAssetPicker && (
<div className="border border-border rounded-lg p-3 bg-surface-secondary">
<div className="flex items-center justify-between mb-2">
<p className="text-xs font-medium text-text-secondary">{t('posts.selectAssets')}</p>
<button onClick={() => setShowAssetPicker(false)} className="p-1 text-text-tertiary hover:text-text-primary">
<X className="w-3.5 h-3.5" />
</button>
</div>
<input
type="text"
value={assetSearch}
onChange={e => setAssetSearch(e.target.value)}
placeholder={t('common.search')}
className="w-full px-3 py-1.5 text-xs border border-border rounded-lg mb-2 focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
/>
<div className="grid grid-cols-4 gap-2 max-h-48 overflow-y-auto">
{availableAssets
.filter(a => !assetSearch || (a.original_name || a.filename || '').toLowerCase().includes(assetSearch.toLowerCase()))
.map(asset => {
const isImage = asset.mime_type?.startsWith('image/')
const assetUrl = `/api/uploads/${asset.filename}`
const name = asset.original_name || asset.filename
return (
<button
key={asset.id || asset._id}
onClick={() => handleAttachAsset(asset.id || asset._id)}
className="block w-full border border-border rounded-lg overflow-hidden bg-surface hover:border-brand-primary hover:ring-2 hover:ring-brand-primary/20 transition-all text-start"
>
<div className="aspect-square relative">
{isImage ? (
<img src={assetUrl} alt={name} className="absolute inset-0 w-full h-full object-cover" />
) : (
<div className="absolute inset-0 flex items-center justify-center bg-surface-tertiary">
<FileText className="w-6 h-6 text-text-tertiary" />
</div>
)}
</div>
<div className="px-1.5 py-1 text-[10px] text-text-tertiary truncate border-t border-border-light">{name}</div>
</button>
)
})}
</div>
{availableAssets.filter(a => !assetSearch || (a.original_name || a.filename || '').toLowerCase().includes(assetSearch.toLowerCase())).length === 0 && (
<p className="text-xs text-text-tertiary text-center py-4">{t('posts.noAssetsFound')}</p>
)}
</div>
)}
</div>
)
}
+62 -771
View File
@@ -1,28 +1,21 @@
import { useState, useEffect, useRef } from 'react'
import { X, Trash2, Upload, FileText, Link2, ExternalLink, FolderOpen, Image as ImageIcon, Music, Film, Send, CheckCircle2, XCircle, Copy, Check, Plus, Globe, Clock, User, FileEdit, Layers, Share2, ShieldCheck, MessageSquare } from 'lucide-react'
import { Trash2, XCircle, FileEdit, Layers, Share2, ShieldCheck, MessageSquare } from 'lucide-react'
import { useLanguage } from '../i18n/LanguageContext'
import { api, PLATFORMS, getBrandColor } from '../utils/api'
import ApproverMultiSelect from './ApproverMultiSelect'
import { api, getBrandColor } from '../utils/api'
import CommentsSection from './CommentsSection'
import Modal from './Modal'
import TabbedModal from './TabbedModal'
import { useToast } from './ToastContainer'
const AVAILABLE_LANGUAGES = [
{ code: 'ar', label: 'Arabic' },
{ code: 'en', label: 'English' },
{ code: 'fr', label: 'French' },
{ code: 'id', label: 'Bahasa Indonesia' },
]
import { PostDetailVersions } from './PostDetailVersions'
import { PostDetailPlatforms } from './PostDetailPlatforms'
import { PostDetailApproval } from './PostDetailApproval'
import { PostDetailAttachments } from './PostDetailAttachments'
const TABS = ['details', 'versions', 'platforms', 'approval', 'discussion']
export default function PostDetailPanel({ post, onClose, onSave, onDelete, brands, teamMembers, campaigns }) {
const { t, lang } = useLanguage()
const toast = useToast()
const imageInputRef = useRef(null)
const audioInputRef = useRef(null)
const videoInputRef = useRef(null)
const versionFileInputRef = useRef(null)
const [activeTab, setActiveTab] = useState('details')
const [form, setForm] = useState({})
@@ -38,24 +31,11 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
// Attachments state (non-versioned, legacy)
const [attachments, setAttachments] = useState([])
const [uploading, setUploading] = useState(false)
const [dragActive, setDragActive] = useState(false)
const [showAssetPicker, setShowAssetPicker] = useState(false)
const [availableAssets, setAvailableAssets] = useState([])
const [assetSearch, setAssetSearch] = useState('')
// Versions state
const [versions, setVersions] = useState([])
const [selectedVersion, setSelectedVersion] = useState(null)
const [versionData, setVersionData] = useState(null)
const [showNewVersionModal, setShowNewVersionModal] = useState(false)
const [newVersionNotes, setNewVersionNotes] = useState('')
const [copyFromPrevious, setCopyFromPrevious] = useState(false)
const [creatingVersion, setCreatingVersion] = useState(false)
const [showLanguageModal, setShowLanguageModal] = useState(false)
const [languageForm, setLanguageForm] = useState({ language_code: '', language_label: '', content: '' })
const [savingLanguage, setSavingLanguage] = useState(false)
const [confirmDeleteLangId, setConfirmDeleteLangId] = useState(null)
const [confirmDeleteAttId, setConfirmDeleteAttId] = useState(null)
const [uploadingVersionFile, setUploadingVersionFile] = useState(false)
const postId = post?._id || post?.id
@@ -136,6 +116,7 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
}
if (data.status === 'published' && data.platforms.length > 0) {
const { PLATFORMS } = await import('../utils/api')
const missingPlatforms = data.platforms.filter(platform => {
const link = (data.publication_links || []).find(l => l.platform === platform)
return !link || !link.url || !link.url.trim()
@@ -237,33 +218,16 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
}
}
const openAssetPicker = async () => {
try {
const data = await api.get('/assets')
setAvailableAssets(Array.isArray(data) ? data : [])
} catch {
setAvailableAssets([])
}
setAssetSearch('')
setShowAssetPicker(true)
}
const handleAttachAsset = async (assetId) => {
if (!postId) return
try {
await api.post(`/posts/${postId}/attachments/from-asset`, { asset_id: assetId })
loadAttachments()
setShowAssetPicker(false)
} catch (err) {
console.error('Attach asset failed:', err)
}
}
const handleDrop = (e) => {
e.preventDefault(); e.stopPropagation(); setDragActive(false)
if (e.dataTransfer.files?.length) handleFileUpload(e.dataTransfer.files)
}
// ─── Versions ──────────────────────────
async function loadVersions() {
if (!postId) return
@@ -299,44 +263,28 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
loadVersionData(version.Id || version.id || version._id)
}
const handleCreateVersion = async () => {
setCreatingVersion(true)
const handleCreateVersion = async ({ notes, copy_from_previous }) => {
try {
await api.post(`/posts/${postId}/versions`, {
notes: newVersionNotes || undefined,
copy_from_previous: copyFromPrevious,
notes: notes || undefined,
copy_from_previous,
})
setShowNewVersionModal(false)
setNewVersionNotes('')
setCopyFromPrevious(false)
loadVersions()
} catch (err) {
console.error('Create version failed:', err)
} finally {
setCreatingVersion(false)
}
}
const handleAddLanguage = async () => {
if (!selectedVersion || !languageForm.language_code || !languageForm.content) return
setSavingLanguage(true)
try {
const vId = selectedVersion.Id || selectedVersion.id || selectedVersion._id
await api.post(`/posts/${postId}/versions/${vId}/texts`, languageForm)
setShowLanguageModal(false)
setLanguageForm({ language_code: '', language_label: '', content: '' })
loadVersionData(vId)
} catch (err) {
console.error('Add language failed:', err)
} finally {
setSavingLanguage(false)
}
const handleAddLanguage = async (languageForm) => {
if (!selectedVersion) return
const vId = selectedVersion.Id || selectedVersion.id || selectedVersion._id
await api.post(`/posts/${postId}/versions/${vId}/texts`, languageForm)
loadVersionData(vId)
}
const handleDeleteLanguage = async (textId) => {
try {
await api.delete(`/post-version-texts/${textId}`)
setConfirmDeleteLangId(null)
const vId = selectedVersion.Id || selectedVersion.id || selectedVersion._id
loadVersionData(vId)
} catch (err) {
@@ -364,7 +312,6 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
const handleDeleteVersionAttachment = async (attId) => {
try {
await api.delete(`/attachments/${attId}`)
setConfirmDeleteAttId(null)
const vId = selectedVersion.Id || selectedVersion.id || selectedVersion._id
loadVersionData(vId)
} catch (err) {
@@ -409,7 +356,7 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
form.status === 'approved' ? 'bg-blue-100 text-blue-700' :
form.status === 'in_review' ? 'bg-amber-100 text-amber-700' :
form.status === 'rejected' ? 'bg-red-100 text-red-700' :
'bg-gray-100 text-gray-600'
'bg-gray-100 text-text-secondary'
}`}>
{statusOptions.find(s => s.value === form.status)?.label}
</span>
@@ -498,7 +445,7 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
value={form.description}
onChange={e => update('description', e.target.value)}
rows={4}
className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary resize-none"
className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary resize-none"
placeholder={t('posts.postDescPlaceholder')}
/>
</div>
@@ -508,7 +455,7 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
type="text"
value={form.notes}
onChange={e => update('notes', e.target.value)}
className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
placeholder={t('posts.additionalNotes')}
/>
</div>
@@ -532,7 +479,13 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
</span>
)}
</div>
{renderAttachments()}
<PostDetailAttachments
attachments={attachments}
uploading={uploading}
onFileUpload={handleFileUpload}
onDeleteAttachment={handleDeleteAttachment}
onAttachAsset={handleAttachAsset}
/>
</div>
)}
</div>
@@ -545,7 +498,7 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
<select
value={form.status}
onChange={e => update('status', e.target.value)}
className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
{statusOptions.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
</select>
@@ -556,7 +509,7 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
type="date"
value={form.scheduled_date}
onChange={e => update('scheduled_date', e.target.value)}
className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
/>
</div>
<div>
@@ -564,7 +517,7 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
<select
value={form.assigned_to}
onChange={e => update('assigned_to', e.target.value)}
className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
<option value="">{t('common.unassigned')}</option>
{(teamMembers || []).map(m => <option key={m._id || m.id} value={m._id || m.id}>{m.name}</option>)}
@@ -578,7 +531,7 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
<select
value={form.brand_id}
onChange={e => update('brand_id', e.target.value)}
className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
<option value="">{t('posts.selectBrand')}</option>
{(brands || []).map(b => <option key={b._id || b.id} value={b._id || b.id}>{b.icon} {lang === 'ar' && b.name_ar ? b.name_ar : b.name}</option>)}
@@ -589,7 +542,7 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
<select
value={form.campaign_id}
onChange={e => update('campaign_id', e.target.value)}
className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
<option value="">{t('posts.noCampaign')}</option>
{(campaigns || []).map(c => <option key={c._id || c.id} value={c._id || c.id}>{c.name}</option>)}
@@ -603,395 +556,46 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
{/* ─── Versions Tab ─── */}
{activeTab === 'versions' && !isCreateMode && (
<div className="flex h-full">
{/* Version Timeline (left sidebar) */}
<div className="w-64 shrink-0 border-e border-border p-4 overflow-y-auto bg-surface-secondary/50">
<div className="flex items-center justify-between mb-4">
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wide">{t('posts.versions')}</h4>
<button
onClick={() => setShowNewVersionModal(true)}
className="flex items-center gap-1 px-2.5 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors shadow-sm"
>
<Plus className="w-3 h-3" />
{t('posts.newVersion')}
</button>
</div>
{versions.length === 0 ? (
<div className="text-center py-10">
<div className="w-12 h-12 rounded-full bg-surface-tertiary flex items-center justify-center mx-auto mb-3">
<Layers className="w-6 h-6 text-text-quaternary" />
</div>
<p className="text-xs text-text-tertiary leading-relaxed px-2">{t('posts.noVersions')}</p>
</div>
) : (
<div className="space-y-1.5">
{versions.map((version, idx) => {
const vId = version.Id || version.id || version._id
const isActive = vId === (selectedVersion?.Id || selectedVersion?.id || selectedVersion?._id)
const isLatest = idx === versions.length - 1
return (
<button
key={vId}
onClick={() => handleSelectVersion(version)}
className={`w-full text-start p-3 rounded-xl border transition-all ${
isActive
? 'border-brand-primary bg-white shadow-sm ring-1 ring-brand-primary/20'
: 'border-transparent hover:bg-white hover:border-border'
}`}
>
<div className="flex items-center gap-2.5">
<div className={`flex-shrink-0 w-7 h-7 rounded-lg flex items-center justify-center text-[11px] font-bold ${
isActive ? 'bg-brand-primary text-white' : 'bg-surface-tertiary text-text-secondary'
}`}>
{version.version_number}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className={`text-sm font-medium ${isActive ? 'text-brand-primary' : 'text-text-primary'}`}>
v{version.version_number}
</span>
{isLatest && (
<span className="text-[9px] px-1.5 py-px bg-emerald-100 text-emerald-700 rounded font-semibold uppercase">
Latest
</span>
)}
</div>
{version.notes && (
<p className="text-[11px] text-text-tertiary line-clamp-1 mt-0.5">{version.notes}</p>
)}
</div>
</div>
{(version.creator_name || version.created_at) && (
<div className="flex items-center gap-2 mt-2 ms-[38px] text-[10px] text-text-quaternary">
{version.creator_name && <span>{version.creator_name}</span>}
{version.creator_name && version.created_at && <span>·</span>}
{version.created_at && <span>{new Date(version.created_at).toLocaleDateString()}</span>}
</div>
)}
</button>
)
})}
</div>
)}
</div>
{/* Version Content (right side) */}
<div className="flex-1 min-w-0 overflow-y-auto p-6">
{selectedVersion && versionData ? (
<div className="space-y-6 w-full">
{/* Languages */}
<div>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Globe className="w-4 h-4 text-text-tertiary" />
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wide">{t('posts.languages')}</h4>
{versionData.texts?.length > 0 && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-surface-tertiary text-text-tertiary font-medium">
{versionData.texts.length}
</span>
)}
</div>
<button
onClick={() => setShowLanguageModal(true)}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-brand-primary border border-brand-primary/30 hover:bg-brand-primary/5 rounded-lg transition-colors"
>
<Plus className="w-3 h-3" />
{t('posts.addLanguage')}
</button>
</div>
{versionData.texts && versionData.texts.length > 0 ? (
<div className="space-y-3">
{versionData.texts.map(text => {
const tId = text.Id || text.id || text._id
return (
<div key={tId} className="rounded-xl border border-border overflow-hidden">
<div className="flex items-center justify-between px-4 py-2.5 bg-surface-secondary">
<div className="flex items-center gap-2">
<span className="px-2 py-0.5 bg-white border border-border rounded text-[11px] font-semibold uppercase text-text-secondary">{text.language_code}</span>
<span className="text-sm font-medium text-text-primary">{text.language_label}</span>
</div>
<button
onClick={() => setConfirmDeleteLangId(tId)}
className="p-1.5 text-text-quaternary hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
<div className="px-4 py-3 text-sm whitespace-pre-wrap text-text-primary leading-relaxed" dir={text.language_code === 'ar' ? 'rtl' : 'ltr'}>
{text.content}
</div>
</div>
)
})}
</div>
) : (
<div className="text-center py-8 rounded-xl border-2 border-dashed border-border">
<Globe className="w-8 h-8 text-text-quaternary mx-auto mb-2" />
<p className="text-sm text-text-tertiary">{t('posts.noLanguages')}</p>
<button
onClick={() => setShowLanguageModal(true)}
className="mt-3 text-xs font-medium text-brand-primary hover:underline"
>
{t('posts.addLanguage')}
</button>
</div>
)}
</div>
{/* Media / Attachments for this version */}
<div>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<ImageIcon className="w-4 h-4 text-text-tertiary" />
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wide">{t('posts.media')}</h4>
{versionData.attachments?.length > 0 && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-surface-tertiary text-text-tertiary font-medium">
{versionData.attachments.length}
</span>
)}
</div>
<label className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-brand-primary border border-brand-primary/30 hover:bg-brand-primary/5 rounded-lg cursor-pointer transition-colors">
<Upload className="w-3 h-3" />
{uploadingVersionFile ? t('posts.uploading') : t('posts.addImage')}
<input
ref={versionFileInputRef}
type="file"
multiple
className="hidden"
onChange={e => { handleVersionFileUpload(e.target.files); e.target.value = '' }}
disabled={uploadingVersionFile}
/>
</label>
</div>
{versionData.attachments && versionData.attachments.length > 0 ? (
<div className="grid grid-cols-2 gap-3">
{versionData.attachments.map(att => {
const attId = att.Id || att.id || att._id
const attUrl = att.url || `/api/uploads/${att.filename}`
const name = att.original_name || att.filename
const mime = att.mime_type || ''
const isImage = mime.startsWith('image/')
const isVideo = mime.startsWith('video/')
return (
<div key={attId} className="relative group rounded-xl border border-border overflow-hidden bg-white hover:shadow-md transition-shadow">
{isImage ? (
<a href={attUrl} target="_blank" rel="noopener noreferrer">
<img src={attUrl} alt={name} className="w-full h-44 object-cover" />
</a>
) : isVideo ? (
<video src={attUrl} controls className="w-full h-44 object-cover" />
) : (
<a href={attUrl} target="_blank" rel="noopener noreferrer" className="flex items-center justify-center h-44 bg-surface-tertiary">
<FileText className="w-10 h-10 text-text-quaternary" />
</a>
)}
<div className="flex items-center justify-between px-3 py-2 border-t border-border bg-surface-secondary/50">
<span className="text-[11px] text-text-secondary truncate">{name}</span>
<button
onClick={() => setConfirmDeleteAttId(attId)}
className="p-1 text-text-quaternary hover:text-red-500 opacity-0 group-hover:opacity-100 transition-all"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
)
})}
</div>
) : (
<div className="text-center py-8 rounded-xl border-2 border-dashed border-border">
<ImageIcon className="w-8 h-8 text-text-quaternary mx-auto mb-2" />
<p className="text-sm text-text-tertiary">{t('posts.noMedia')}</p>
</div>
)}
</div>
</div>
) : versions.length > 0 ? (
<div className="flex items-center justify-center h-40">
<div className="w-6 h-6 border-2 border-brand-primary border-t-transparent rounded-full animate-spin" />
</div>
) : null}
</div>
</div>
<PostDetailVersions
versions={versions}
selectedVersion={selectedVersion}
versionData={versionData}
onSelectVersion={handleSelectVersion}
onCreateVersion={handleCreateVersion}
onAddLanguage={handleAddLanguage}
onDeleteLanguage={handleDeleteLanguage}
onVersionFileUpload={handleVersionFileUpload}
onDeleteVersionAttachment={handleDeleteVersionAttachment}
uploadingVersionFile={uploadingVersionFile}
versionFileInputRef={versionFileInputRef}
/>
)}
{/* ─── Platforms & Links Tab ─── */}
{activeTab === 'platforms' && (
<div className="p-6 space-y-6 w-full">
<div>
<div className="flex items-center gap-2 mb-3">
<Share2 className="w-4 h-4 text-text-tertiary" />
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wide">{t('posts.platforms')}</h4>
</div>
<div className="flex flex-wrap gap-2 p-3 rounded-xl bg-surface-secondary min-h-[44px]">
{Object.entries(PLATFORMS).map(([k, v]) => {
const checked = (form.platforms || []).includes(k)
return (
<label
key={k}
className={`flex items-center gap-1.5 text-xs px-3 py-2 rounded-lg cursor-pointer border transition-all ${
checked
? 'bg-white border-brand-primary/30 text-brand-primary font-medium shadow-sm'
: 'bg-white/50 border-transparent text-text-secondary hover:bg-white hover:shadow-sm'
}`}
>
<input
type="checkbox"
checked={checked}
onChange={() => {
update('platforms', checked
? form.platforms.filter(p => p !== k)
: [...(form.platforms || []), k]
)
}}
className="sr-only"
/>
<span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: v.color || '#888' }} />
{v.label}
</label>
)
})}
</div>
</div>
{(form.platforms || []).length > 0 && (
<div>
<div className="flex items-center gap-2 mb-3">
<Link2 className="w-4 h-4 text-text-tertiary" />
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wide">{t('posts.publicationLinks')}</h4>
</div>
<div className="space-y-2.5">
{(form.platforms || []).map(platformKey => {
const platformInfo = PLATFORMS[platformKey] || { label: platformKey }
const existingLink = (form.publication_links || []).find(l => l.platform === platformKey)
const linkUrl = existingLink?.url || ''
return (
<div key={platformKey} className="flex items-center gap-3 p-3 rounded-xl bg-surface-secondary">
<span className="text-xs font-medium text-text-primary w-28 shrink-0 flex items-center gap-2">
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: platformInfo.color || '#888' }} />
{platformInfo.label}
</span>
<input
type="url"
value={linkUrl}
onChange={e => updatePublicationLink(platformKey, e.target.value)}
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
placeholder="https://..."
/>
{linkUrl && (
<a href={linkUrl} target="_blank" rel="noopener noreferrer" className="p-2 text-text-tertiary hover:text-brand-primary hover:bg-white rounded-lg transition-colors">
<ExternalLink className="w-4 h-4" />
</a>
)}
</div>
)
})}
</div>
{form.status === 'published' && (form.platforms || []).some(p => {
const link = (form.publication_links || []).find(l => l.platform === p)
return !link || !link.url?.trim()
}) && (
<p className="text-xs text-amber-600 mt-3 flex items-center gap-1.5">
<XCircle className="w-3.5 h-3.5" />
{t('posts.publishRequired')}
</p>
)}
</div>
)}
</div>
<PostDetailPlatforms
form={form}
update={update}
updatePublicationLink={updatePublicationLink}
/>
)}
{/* ─── Approval Tab ─── */}
{activeTab === 'approval' && (
<div className="p-6 space-y-5 w-full">
<div className="bg-surface-secondary rounded-xl p-4">
<label className="block text-xs font-medium text-text-primary mb-2">{t('posts.approvers')}</label>
<ApproverMultiSelect
users={teamMembers || []}
selected={form.approver_ids || []}
onChange={ids => update('approver_ids', ids)}
/>
</div>
{!isCreateMode && (
<div className="space-y-4">
{/* Approval status cards */}
{form.status === 'approved' && post.approved_by_name && (
<div className="bg-emerald-50 border border-emerald-200 rounded-xl p-4 flex items-start gap-3">
<div className="w-8 h-8 rounded-full bg-emerald-100 flex items-center justify-center shrink-0 mt-0.5">
<CheckCircle2 className="w-4 h-4 text-emerald-600" />
</div>
<div>
<p className="text-sm font-semibold text-emerald-800">{t('posts.approvedBy')} {post.approved_by_name}</p>
{post.feedback && <p className="text-xs text-emerald-700 mt-1.5 leading-relaxed">{post.feedback}</p>}
</div>
</div>
)}
{form.status === 'rejected' && post.approved_by_name && (
<div className="bg-red-50 border border-red-200 rounded-xl p-4 flex items-start gap-3">
<div className="w-8 h-8 rounded-full bg-red-100 flex items-center justify-center shrink-0 mt-0.5">
<XCircle className="w-4 h-4 text-red-600" />
</div>
<div>
<p className="text-sm font-semibold text-red-800">{t('posts.rejectedBy')} {post.approved_by_name}</p>
{post.feedback && <p className="text-xs text-red-700 mt-1.5 leading-relaxed">{post.feedback}</p>}
</div>
</div>
)}
{form.status === 'in_review' && (
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4 text-center">
<div className="w-10 h-10 rounded-full bg-amber-100 flex items-center justify-center mx-auto mb-2">
<Clock className="w-5 h-5 text-amber-600" />
</div>
<p className="text-sm font-semibold text-amber-800">{t('posts.awaitingReview')}</p>
<p className="text-xs text-amber-600 mt-1">{t('posts.awaitingReviewDesc')}</p>
</div>
)}
{/* Review link */}
{reviewUrl && (
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
<div className="text-xs font-semibold text-blue-900 mb-2.5">{t('posts.reviewLinkTitle')}</div>
<div className="flex items-center gap-2">
<input type="text" value={reviewUrl} readOnly className="flex-1 px-3 py-2 text-xs bg-white border border-blue-200 rounded-lg font-mono" />
<button onClick={copyReviewLink} className="p-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors shadow-sm">
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
</button>
</div>
</div>
)}
{/* Action buttons */}
<div className="flex gap-3">
{!reviewUrl && (
<button
onClick={handleSubmitReview}
disabled={submittingReview}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-amber-500 text-white rounded-xl hover:bg-amber-600 transition-colors font-medium text-sm disabled:opacity-50 shadow-sm"
>
<Send className="w-4 h-4" />
{submittingReview ? t('posts.submitting') : t('posts.sendToReview')}
</button>
)}
{form.status === 'approved' && (
<button
onClick={() => handleStatusAction('scheduled')}
disabled={saving}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-purple-600 text-white rounded-xl hover:bg-purple-700 transition-colors font-medium text-sm disabled:opacity-50 shadow-sm"
>
{t('posts.schedule')}
</button>
)}
</div>
</div>
)}
</div>
<PostDetailApproval
form={form}
update={update}
post={post}
isCreateMode={isCreateMode}
reviewUrl={reviewUrl}
copied={copied}
submittingReview={submittingReview}
saving={saving}
teamMembers={teamMembers}
onSubmitReview={handleSubmitReview}
onCopyReviewLink={copyReviewLink}
onStatusAction={handleStatusAction}
/>
)}
{/* ─── Discussion Tab ─── */}
@@ -1014,319 +618,6 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
>
{t('posts.deleteConfirm')}
</Modal>
{/* New Version Modal */}
<Modal
isOpen={showNewVersionModal}
onClose={() => { setShowNewVersionModal(false); setNewVersionNotes(''); setCopyFromPrevious(false) }}
title={t('posts.createNewVersion')}
size="sm"
>
<div className="space-y-4">
<textarea
value={newVersionNotes}
onChange={e => setNewVersionNotes(e.target.value)}
rows={3}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary resize-none"
placeholder={t('posts.whatChanged')}
/>
{versions.length > 0 && (
<label className="flex items-center gap-2 text-sm text-text-secondary cursor-pointer">
<input
type="checkbox"
checked={copyFromPrevious}
onChange={e => setCopyFromPrevious(e.target.checked)}
className="rounded border-border text-brand-primary focus:ring-brand-primary/20"
/>
{t('posts.copyLanguages')}
</label>
)}
<button
onClick={handleCreateVersion}
disabled={creatingVersion}
className="w-full px-4 py-2.5 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 shadow-sm"
>
{creatingVersion ? t('posts.creatingVersion') : t('posts.createVersion')}
</button>
</div>
</Modal>
{/* Add Language Modal */}
<Modal
isOpen={showLanguageModal}
onClose={() => { setShowLanguageModal(false); setLanguageForm({ language_code: '', language_label: '', content: '' }) }}
title={t('posts.addLanguage')}
size="md"
>
<div className="space-y-4">
<select
value={languageForm.language_code}
onChange={e => {
const lang = AVAILABLE_LANGUAGES.find(l => l.code === e.target.value)
setLanguageForm(f => ({ ...f, language_code: e.target.value, language_label: lang?.label || e.target.value }))
}}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
<option value="">{t('posts.selectLanguage')}</option>
{AVAILABLE_LANGUAGES
.filter(lang => !(versionData?.texts || []).some(t => t.language_code === lang.code))
.map(lang => (
<option key={lang.code} value={lang.code}>{lang.label} ({lang.code.toUpperCase()})</option>
))}
</select>
<textarea
value={languageForm.content}
onChange={e => setLanguageForm(f => ({ ...f, content: e.target.value }))}
rows={8}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary resize-none"
placeholder={t('posts.enterContent')}
dir={languageForm.language_code === 'ar' ? 'rtl' : 'ltr'}
/>
<button
onClick={handleAddLanguage}
disabled={savingLanguage || !languageForm.language_code || !languageForm.content}
className="w-full px-4 py-2.5 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 shadow-sm"
>
{savingLanguage ? t('common.loading') : t('common.save')}
</button>
</div>
</Modal>
{/* Delete Language Confirmation */}
<Modal
isOpen={!!confirmDeleteLangId}
onClose={() => setConfirmDeleteLangId(null)}
title={t('posts.deleteLanguage')}
isConfirm
danger
confirmText={t('common.delete')}
onConfirm={() => handleDeleteLanguage(confirmDeleteLangId)}
>
{t('posts.deleteLanguageConfirm')}
</Modal>
{/* Delete Version Attachment Confirmation */}
<Modal
isOpen={!!confirmDeleteAttId}
onClose={() => setConfirmDeleteAttId(null)}
title={t('posts.deleteAttachment')}
isConfirm
danger
confirmText={t('common.delete')}
onConfirm={() => handleDeleteVersionAttachment(confirmDeleteAttId)}
>
{t('posts.deleteConfirm')}
</Modal>
</>
)
// ─── Render legacy attachments helper ──────────────────────────
function renderAttachments() {
const images = attachments.filter(a => (a.mime_type || a.mimeType || '').startsWith('image/'))
const audio = attachments.filter(a => (a.mime_type || a.mimeType || '').startsWith('audio/'))
const videos = attachments.filter(a => (a.mime_type || a.mimeType || '').startsWith('video/'))
const others = attachments.filter(a => {
const mime = a.mime_type || a.mimeType || ''
return !mime.startsWith('image/') && !mime.startsWith('audio/') && !mime.startsWith('video/')
})
return (
<div className="space-y-4">
{/* Images */}
<div>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-1.5 text-xs font-medium text-text-secondary">
<ImageIcon className="w-3.5 h-3.5" />
{t('posts.images')}
{images.length > 0 && <span className="text-text-tertiary">({images.length})</span>}
</div>
<label className="flex items-center gap-1 px-2 py-1 text-[11px] font-medium text-brand-primary hover:bg-brand-primary/5 rounded cursor-pointer transition-colors">
<Upload className="w-3 h-3" />
{t('posts.addImage')}
<input ref={imageInputRef} type="file" multiple accept="image/*" className="hidden"
onChange={e => { handleFileUpload(e.target.files); e.target.value = '' }} />
</label>
</div>
{images.length > 0 && (
<div className="grid grid-cols-3 gap-2">
{images.map(att => {
const attUrl = att.url || `/api/uploads/${att.filename}`
const name = att.original_name || att.originalName || att.filename
const attId = att.id || att._id
return (
<div key={attId} className="relative group/att border border-border rounded-lg overflow-hidden bg-white">
<div className="h-20 relative">
<a href={attUrl} target="_blank" rel="noopener noreferrer" className="block h-full">
<img src={attUrl} alt={name} className="absolute inset-0 w-full h-full object-cover" />
</a>
<button onClick={() => handleDeleteAttachment(attId)}
className="absolute top-1 right-1 p-1 bg-black/50 hover:bg-red-500 rounded-full text-white opacity-0 group-hover/att:opacity-100 transition-opacity"
title={t('common.delete')}><X className="w-2.5 h-2.5" /></button>
</div>
<div className="px-2 py-1 text-[10px] text-text-tertiary truncate border-t border-border-light">{name}</div>
</div>
)
})}
</div>
)}
</div>
{/* Audio */}
{audio.length > 0 && (
<div>
<div className="flex items-center gap-1.5 text-xs font-medium text-text-secondary mb-2">
<Music className="w-3.5 h-3.5" />
{t('posts.audio')} <span className="text-text-tertiary">({audio.length})</span>
</div>
<div className="space-y-2">
{audio.map(att => {
const attUrl = att.url || `/api/uploads/${att.filename}`
const name = att.original_name || att.originalName || att.filename
const attId = att.id || att._id
return (
<div key={attId} className="flex items-center gap-2 border border-border rounded-lg p-2 bg-white group/att">
<Music className="w-4 h-4 text-text-tertiary shrink-0" />
<span className="text-xs text-text-secondary truncate flex-1">{name}</span>
<audio src={attUrl} controls className="h-7 max-w-[160px]" />
<button onClick={() => handleDeleteAttachment(attId)}
className="p-1 text-text-tertiary hover:text-red-500 opacity-0 group-hover/att:opacity-100 transition-opacity"
title={t('common.delete')}><X className="w-3 h-3" /></button>
</div>
)
})}
</div>
</div>
)}
{/* Videos */}
{videos.length > 0 && (
<div>
<div className="flex items-center gap-1.5 text-xs font-medium text-text-secondary mb-2">
<Film className="w-3.5 h-3.5" />
{t('posts.videos')} <span className="text-text-tertiary">({videos.length})</span>
</div>
<div className="space-y-2">
{videos.map(att => {
const attUrl = att.url || `/api/uploads/${att.filename}`
const name = att.original_name || att.originalName || att.filename
const attId = att.id || att._id
return (
<div key={attId} className="border border-border rounded-lg overflow-hidden bg-white group/att">
<video src={attUrl} controls className="w-full max-h-40" />
<div className="flex items-center justify-between px-2 py-1 border-t border-border-light">
<span className="text-[10px] text-text-tertiary truncate">{name}</span>
<button onClick={() => handleDeleteAttachment(attId)}
className="p-1 text-text-tertiary hover:text-red-500 opacity-0 group-hover/att:opacity-100 transition-opacity"
title={t('common.delete')}><X className="w-3 h-3" /></button>
</div>
</div>
)
})}
</div>
</div>
)}
{/* Other files */}
{others.length > 0 && (
<div>
<div className="flex items-center gap-1.5 text-xs font-medium text-text-secondary mb-2">
<FileText className="w-3.5 h-3.5" />
{t('posts.otherFiles')} <span className="text-text-tertiary">({others.length})</span>
</div>
<div className="grid grid-cols-3 gap-2">
{others.map(att => {
const attUrl = att.url || `/api/uploads/${att.filename}`
const name = att.original_name || att.originalName || att.filename
const attId = att.id || att._id
return (
<div key={attId} className="relative group/att border border-border rounded-lg overflow-hidden bg-white">
<a href={attUrl} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 p-3 h-16">
<FileText className="w-6 h-6 text-text-tertiary shrink-0" />
<span className="text-xs text-text-secondary truncate">{name}</span>
</a>
<button onClick={() => handleDeleteAttachment(attId)}
className="absolute top-1 right-1 p-1 bg-black/50 hover:bg-red-500 rounded-full text-white opacity-0 group-hover/att:opacity-100 transition-opacity"
title={t('common.delete')}><X className="w-2.5 h-2.5" /></button>
</div>
)
})}
</div>
</div>
)}
{/* Drag and drop zone */}
<div
className={`border-2 border-dashed rounded-lg p-3 text-center cursor-pointer transition-colors ${
dragActive ? 'border-brand-primary bg-brand-primary/5' : 'border-border hover:border-brand-primary/40'
}`}
onDragEnter={e => { e.preventDefault(); setDragActive(true) }}
onDragLeave={e => { e.preventDefault(); setDragActive(false) }}
onDragOver={e => e.preventDefault()}
onDrop={handleDrop}
>
<Upload className={`w-4 h-4 text-text-tertiary mx-auto mb-1 ${uploading ? 'animate-pulse' : ''}`} />
<p className="text-[11px] text-text-secondary">
{dragActive ? t('posts.dropFiles') : t('posts.dragToUpload')}
</p>
</div>
<button
type="button"
onClick={openAssetPicker}
className="flex items-center gap-2 px-3 py-2 text-sm text-text-secondary border border-border rounded-lg hover:bg-surface-tertiary transition-colors w-full justify-center"
>
<FolderOpen className="w-4 h-4" />
{t('posts.attachFromAssets')}
</button>
{showAssetPicker && (
<div className="border border-border rounded-lg p-3 bg-surface-secondary">
<div className="flex items-center justify-between mb-2">
<p className="text-xs font-medium text-text-secondary">{t('posts.selectAssets')}</p>
<button onClick={() => setShowAssetPicker(false)} className="p-1 text-text-tertiary hover:text-text-primary">
<X className="w-3.5 h-3.5" />
</button>
</div>
<input
type="text"
value={assetSearch}
onChange={e => setAssetSearch(e.target.value)}
placeholder={t('common.search')}
className="w-full px-3 py-1.5 text-xs border border-border rounded-lg mb-2 focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
/>
<div className="grid grid-cols-4 gap-2 max-h-48 overflow-y-auto">
{availableAssets
.filter(a => !assetSearch || (a.original_name || a.filename || '').toLowerCase().includes(assetSearch.toLowerCase()))
.map(asset => {
const isImage = asset.mime_type?.startsWith('image/')
const assetUrl = `/api/uploads/${asset.filename}`
const name = asset.original_name || asset.filename
return (
<button
key={asset.id || asset._id}
onClick={() => handleAttachAsset(asset.id || asset._id)}
className="block w-full border border-border rounded-lg overflow-hidden bg-white hover:border-brand-primary hover:ring-2 hover:ring-brand-primary/20 transition-all text-left"
>
<div className="aspect-square relative">
{isImage ? (
<img src={assetUrl} alt={name} className="absolute inset-0 w-full h-full object-cover" />
) : (
<div className="absolute inset-0 flex items-center justify-center bg-surface-tertiary">
<FileText className="w-6 h-6 text-text-tertiary" />
</div>
)}
</div>
<div className="px-1.5 py-1 text-[10px] text-text-tertiary truncate border-t border-border-light">{name}</div>
</button>
)
})}
</div>
{availableAssets.filter(a => !assetSearch || (a.original_name || a.filename || '').toLowerCase().includes(assetSearch.toLowerCase())).length === 0 && (
<p className="text-xs text-text-tertiary text-center py-4">{t('posts.noAssetsFound')}</p>
)}
</div>
)}
</div>
)
}
}
@@ -0,0 +1,92 @@
import { Link2, ExternalLink, XCircle, Share2 } from 'lucide-react'
import { useLanguage } from '../i18n/LanguageContext'
import { PLATFORMS } from '../utils/api'
export function PostDetailPlatforms({ form, update, updatePublicationLink }) {
const { t } = useLanguage()
return (
<div className="p-6 space-y-6 w-full">
<div>
<div className="flex items-center gap-2 mb-3">
<Share2 className="w-4 h-4 text-text-tertiary" />
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wide">{t('posts.platforms')}</h4>
</div>
<div className="flex flex-wrap gap-2 p-3 rounded-xl bg-surface-secondary min-h-[44px]">
{Object.entries(PLATFORMS).map(([k, v]) => {
const checked = (form.platforms || []).includes(k)
return (
<label
key={k}
className={`flex items-center gap-1.5 text-xs px-3 py-2 rounded-lg cursor-pointer border transition-all ${
checked
? 'bg-surface border-brand-primary/30 text-brand-primary font-medium shadow-sm'
: 'bg-white/50 border-transparent text-text-secondary hover:bg-surface hover:shadow-sm'
}`}
>
<input
type="checkbox"
checked={checked}
onChange={() => {
update('platforms', checked
? form.platforms.filter(p => p !== k)
: [...(form.platforms || []), k]
)
}}
className="sr-only"
/>
<span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: v.color || '#888' }} />
{v.label}
</label>
)
})}
</div>
</div>
{(form.platforms || []).length > 0 && (
<div>
<div className="flex items-center gap-2 mb-3">
<Link2 className="w-4 h-4 text-text-tertiary" />
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wide">{t('posts.publicationLinks')}</h4>
</div>
<div className="space-y-2.5">
{(form.platforms || []).map(platformKey => {
const platformInfo = PLATFORMS[platformKey] || { label: platformKey }
const existingLink = (form.publication_links || []).find(l => l.platform === platformKey)
const linkUrl = existingLink?.url || ''
return (
<div key={platformKey} className="flex items-center gap-3 p-3 rounded-xl bg-surface-secondary">
<span className="text-xs font-medium text-text-primary w-28 shrink-0 flex items-center gap-2">
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: platformInfo.color || '#888' }} />
{platformInfo.label}
</span>
<input
type="url"
value={linkUrl}
onChange={e => updatePublicationLink(platformKey, e.target.value)}
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
placeholder="https://..."
/>
{linkUrl && (
<a href={linkUrl} target="_blank" rel="noopener noreferrer" className="p-2 text-text-tertiary hover:text-brand-primary hover:bg-surface rounded-lg transition-colors">
<ExternalLink className="w-4 h-4" />
</a>
)}
</div>
)
})}
</div>
{form.status === 'published' && (form.platforms || []).some(p => {
const link = (form.publication_links || []).find(l => l.platform === p)
return !link || !link.url?.trim()
}) && (
<p className="text-xs text-amber-600 mt-3 flex items-center gap-1.5">
<XCircle className="w-3.5 h-3.5" />
{t('posts.publishRequired')}
</p>
)}
</div>
)}
</div>
)
}
@@ -0,0 +1,391 @@
import { useState } from 'react'
import { Trash2, Upload, FileText, Image as ImageIcon, Plus, Globe, Layers } from 'lucide-react'
import { useLanguage } from '../i18n/LanguageContext'
import Modal from './Modal'
const AVAILABLE_LANGUAGES = [
{ code: 'ar', label: 'Arabic' },
{ code: 'en', label: 'English' },
{ code: 'fr', label: 'French' },
{ code: 'id', label: 'Bahasa Indonesia' },
]
export function PostDetailVersions({
versions,
selectedVersion,
versionData,
onSelectVersion,
onCreateVersion,
onAddLanguage,
onDeleteLanguage,
onVersionFileUpload,
onDeleteVersionAttachment,
uploadingVersionFile,
versionFileInputRef,
}) {
const { t } = useLanguage()
const [showNewVersionModal, setShowNewVersionModal] = useState(false)
const [newVersionNotes, setNewVersionNotes] = useState('')
const [copyFromPrevious, setCopyFromPrevious] = useState(false)
const [creatingVersion, setCreatingVersion] = useState(false)
const [showLanguageModal, setShowLanguageModal] = useState(false)
const [languageForm, setLanguageForm] = useState({ language_code: '', language_label: '', content: '' })
const [savingLanguage, setSavingLanguage] = useState(false)
const [confirmDeleteLangId, setConfirmDeleteLangId] = useState(null)
const [confirmDeleteAttId, setConfirmDeleteAttId] = useState(null)
const handleCreateVersion = async () => {
setCreatingVersion(true)
try {
await onCreateVersion({ notes: newVersionNotes || undefined, copy_from_previous: copyFromPrevious })
setShowNewVersionModal(false)
setNewVersionNotes('')
setCopyFromPrevious(false)
} finally {
setCreatingVersion(false)
}
}
const handleAddLanguage = async () => {
if (!selectedVersion || !languageForm.language_code || !languageForm.content) return
setSavingLanguage(true)
try {
await onAddLanguage(languageForm)
setShowLanguageModal(false)
setLanguageForm({ language_code: '', language_label: '', content: '' })
} finally {
setSavingLanguage(false)
}
}
const handleDeleteLanguage = async (textId) => {
await onDeleteLanguage(textId)
setConfirmDeleteLangId(null)
}
const handleDeleteAttachment = async (attId) => {
await onDeleteVersionAttachment(attId)
setConfirmDeleteAttId(null)
}
return (
<>
<div className="flex h-full">
{/* Version Timeline (left sidebar) */}
<div className="w-64 shrink-0 border-e border-border p-4 overflow-y-auto bg-surface-secondary/50">
<div className="flex items-center justify-between mb-4">
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wide">{t('posts.versions')}</h4>
<button
onClick={() => setShowNewVersionModal(true)}
className="flex items-center gap-1 px-2.5 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors shadow-sm"
>
<Plus className="w-3 h-3" />
{t('posts.newVersion')}
</button>
</div>
{versions.length === 0 ? (
<div className="text-center py-10">
<div className="w-12 h-12 rounded-full bg-surface-tertiary flex items-center justify-center mx-auto mb-3">
<Layers className="w-6 h-6 text-text-quaternary" />
</div>
<p className="text-xs text-text-tertiary leading-relaxed px-2">{t('posts.noVersions')}</p>
</div>
) : (
<div className="space-y-1.5">
{versions.map((version, idx) => {
const vId = version.Id || version.id || version._id
const isActive = vId === (selectedVersion?.Id || selectedVersion?.id || selectedVersion?._id)
const isLatest = idx === versions.length - 1
return (
<button
key={vId}
onClick={() => onSelectVersion(version)}
className={`w-full text-start p-3 rounded-xl border transition-all ${
isActive
? 'border-brand-primary bg-surface shadow-sm ring-1 ring-brand-primary/20'
: 'border-transparent hover:bg-surface hover:border-border'
}`}
>
<div className="flex items-center gap-2.5">
<div className={`flex-shrink-0 w-7 h-7 rounded-lg flex items-center justify-center text-[11px] font-bold ${
isActive ? 'bg-brand-primary text-white' : 'bg-surface-tertiary text-text-secondary'
}`}>
{version.version_number}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className={`text-sm font-medium ${isActive ? 'text-brand-primary' : 'text-text-primary'}`}>
v{version.version_number}
</span>
{isLatest && (
<span className="text-[9px] px-1.5 py-px bg-emerald-100 text-emerald-700 rounded font-semibold uppercase">
Latest
</span>
)}
</div>
{version.notes && (
<p className="text-[11px] text-text-tertiary line-clamp-1 mt-0.5">{version.notes}</p>
)}
</div>
</div>
{(version.creator_name || version.created_at) && (
<div className="flex items-center gap-2 mt-2 ms-[38px] text-[10px] text-text-quaternary">
{version.creator_name && <span>{version.creator_name}</span>}
{version.creator_name && version.created_at && <span>·</span>}
{version.created_at && <span>{new Date(version.created_at).toLocaleDateString()}</span>}
</div>
)}
</button>
)
})}
</div>
)}
</div>
{/* Version Content (right side) */}
<div className="flex-1 min-w-0 overflow-y-auto p-6">
{selectedVersion && versionData ? (
<div className="space-y-6 w-full">
{/* Languages */}
<div>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Globe className="w-4 h-4 text-text-tertiary" />
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wide">{t('posts.languages')}</h4>
{versionData.texts?.length > 0 && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-surface-tertiary text-text-tertiary font-medium">
{versionData.texts.length}
</span>
)}
</div>
<button
onClick={() => setShowLanguageModal(true)}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-brand-primary border border-brand-primary/30 hover:bg-brand-primary/5 rounded-lg transition-colors"
>
<Plus className="w-3 h-3" />
{t('posts.addLanguage')}
</button>
</div>
{versionData.texts && versionData.texts.length > 0 ? (
<div className="space-y-3">
{versionData.texts.map(text => {
const tId = text.Id || text.id || text._id
return (
<div key={tId} className="rounded-xl border border-border overflow-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>
</>
)
}
+2 -2
View File
@@ -21,11 +21,11 @@ export default function ProjectCard({ project }) {
return (
<div
onClick={() => navigate(`/projects/${project._id}`)}
className="bg-white rounded-xl border border-border card-hover cursor-pointer overflow-hidden"
className="bg-surface rounded-xl border border-border card-hover cursor-pointer overflow-hidden"
>
{thumbnailUrl ? (
<div className="w-full h-32 overflow-hidden">
<img src={thumbnailUrl} alt="" className="w-full h-full object-cover" />
<img src={thumbnailUrl} alt="" className="w-full h-full object-cover" loading="lazy" />
</div>
) : null}
<div className="p-5">
+22 -27
View File
@@ -5,6 +5,7 @@ import { api, getBrandColor } from '../utils/api'
import CommentsSection from './CommentsSection'
import Modal from './Modal'
import TabbedModal from './TabbedModal'
import PortalSelect from './PortalSelect'
import { AppContext } from '../App'
export default function ProjectEditPanel({ project, onClose, onSave, onDelete, brands, teamMembers }) {
@@ -131,7 +132,7 @@ export default function ProjectEditPanel({ project, onClose, onSave, onDelete, b
form.status === 'paused' ? 'bg-amber-100 text-amber-700' :
form.status === 'completed' ? 'bg-blue-100 text-blue-700' :
form.status === 'cancelled' ? 'bg-red-100 text-red-700' :
'bg-gray-100 text-gray-600'
'bg-gray-100 text-text-secondary'
}`}>
{statusOptions.find(s => s.value === form.status)?.label}
</span>
@@ -186,49 +187,42 @@ export default function ProjectEditPanel({ project, onClose, onSave, onDelete, b
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('projects.brand')}</label>
<select
<PortalSelect
value={form.brand_id}
onChange={e => update('brand_id', e.target.value)}
onChange={val => update('brand_id', val)}
options={[{ value: '', label: 'Select brand' }, ...(brands || []).map(b => ({ value: b._id || b.id, label: `${b.icon || ''} ${lang === 'ar' && b.name_ar ? b.name_ar : b.name}`.trim() }))]}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
<option value="">Select brand</option>
{(brands || []).map(b => <option key={b._id || b.id} value={b._id || b.id}>{b.icon} {lang === 'ar' && b.name_ar ? b.name_ar : b.name}</option>)}
</select>
/>
</div>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('projects.status')}</label>
<select
<PortalSelect
value={form.status}
onChange={e => update('status', e.target.value)}
onChange={val => update('status', val)}
options={statusOptions}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
{statusOptions.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
</select>
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('projects.owner')}</label>
<select
<PortalSelect
value={form.owner_id}
onChange={e => update('owner_id', e.target.value)}
onChange={val => update('owner_id', val)}
options={[{ value: '', label: t('common.unassigned') }, ...(teamMembers || []).map(m => ({ value: m._id || m.id, label: m.name }))]}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
<option value="">{t('common.unassigned')}</option>
{(teamMembers || []).map(m => <option key={m._id || m.id} value={m._id || m.id}>{m.name}</option>)}
</select>
/>
</div>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('common.team')}</label>
<select
<PortalSelect
value={form.team_id}
onChange={e => update('team_id', e.target.value)}
onChange={val => update('team_id', val)}
options={[{ value: '', label: t('common.noTeam') }, ...(teams || []).map(tm => ({ value: tm.id || tm._id, label: tm.name }))]}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
<option value="">{t('common.noTeam')}</option>
{(teams || []).map(t => <option key={t.id || t._id} value={t.id || t._id}>{t.name}</option>)}
</select>
/>
</div>
</div>
@@ -257,11 +251,11 @@ export default function ProjectEditPanel({ project, onClose, onSave, onDelete, b
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('projects.thumbnail')}</label>
{(project.thumbnail_url || project.thumbnailUrl) ? (
<div className="relative group rounded-lg overflow-hidden border border-border">
<img src={project.thumbnail_url || project.thumbnailUrl} alt="" className="w-full h-24 object-cover" />
<img src={project.thumbnail_url || project.thumbnailUrl} alt="" className="w-full h-24 object-cover" loading="lazy" />
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/30 transition-colors flex items-center justify-center gap-2 opacity-0 group-hover:opacity-100">
<button
onClick={() => thumbnailInputRef.current?.click()}
className="px-3 py-1.5 text-xs bg-white/90 hover:bg-white rounded-lg font-medium text-text-primary transition-colors"
className="px-3 py-1.5 text-xs bg-white/90 hover:bg-surface rounded-lg font-medium text-text-primary transition-colors"
>
{t('projects.changeThumbnail')}
</button>
@@ -289,7 +283,8 @@ export default function ProjectEditPanel({ project, onClose, onSave, onDelete, b
ref={thumbnailInputRef}
type="file"
accept="image/*"
className="hidden"
className="absolute w-0 h-0 opacity-0 pointer-events-none"
tabIndex={-1}
onChange={e => { handleThumbnailUpload(e.target.files[0]); e.target.value = '' }}
/>
</div>
+14 -5
View File
@@ -3,8 +3,17 @@ import { NavLink } from 'react-router-dom'
import {
LayoutDashboard, FileEdit, Image, Calendar, Wallet,
FolderKanban, CheckSquare, Users, ChevronLeft, ChevronRight, ChevronDown,
Sparkles, LogOut, User, Settings, Languages, Tag, LayoutList, Receipt, BarChart3, Palette, CalendarDays, AlertCircle
LogOut, User, Settings, Languages, Tag, LayoutList, Receipt, BarChart3, Palette, CalendarDays, AlertCircle
} from 'lucide-react'
function MarkaLogo({ className = '' }) {
return (
<svg viewBox="0 0 32 32" fill="none" className={className}>
<path d="M4 26V6l10 10L4 26z" fill="currentColor" opacity="0.85" />
<path d="M18 26V6l10 10-10 10z" fill="currentColor" opacity="0.5" />
</svg>
)
}
import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../i18n/LanguageContext'
@@ -26,7 +35,7 @@ const moduleGroups = [
{ to: '/artefacts', icon: Palette, labelKey: 'nav.artefacts' },
{ to: '/assets', icon: Image, labelKey: 'nav.assets', tutorial: 'assets' },
{ to: '/brands', icon: Tag, labelKey: 'nav.brands' },
{ to: '/translations', icon: Languages, labelKey: 'nav.translations' },
{ to: '/translations', icon: Languages, labelKey: 'nav.copy' },
],
},
{
@@ -115,8 +124,8 @@ export default function Sidebar({ collapsed, setCollapsed }) {
>
{/* Logo */}
<div className="flex items-center gap-3 px-4 h-16 border-b border-white/10 shrink-0">
<div className="w-9 h-9 rounded-lg bg-gradient-to-br from-indigo-400 via-purple-500 to-pink-500 flex items-center justify-center shrink-0 shadow-lg shadow-indigo-500/30">
<Sparkles className="w-5 h-5 text-white" />
<div className="w-9 h-9 rounded-lg bg-brand-primary flex items-center justify-center shrink-0">
<MarkaLogo className="w-5 h-5 text-white" />
</div>
{!collapsed && (
<div className="animate-fade-in overflow-hidden">
@@ -191,7 +200,7 @@ export default function Sidebar({ collapsed, setCollapsed }) {
<div className="flex items-center gap-3 px-3 py-2 rounded-lg bg-white/5">
<div className="w-8 h-8 rounded-full bg-brand-primary flex items-center justify-center shrink-0">
{currentUser.avatar ? (
<img src={currentUser.avatar} alt={currentUser.name} className="w-full h-full rounded-full object-cover" />
<img src={currentUser.avatar} alt={currentUser.name} className="w-full h-full rounded-full object-cover" loading="lazy" />
) : (
<User className="w-4 h-4 text-white" />
)}
+6 -6
View File
@@ -2,7 +2,7 @@
export function SkeletonCard() {
return (
<div className="bg-white rounded-xl border border-border p-5 animate-pulse">
<div className="bg-surface rounded-xl border border-border p-5 animate-pulse">
<div className="h-4 bg-surface-tertiary rounded w-3/4 mb-3"></div>
<div className="h-3 bg-surface-tertiary rounded w-1/2 mb-2"></div>
<div className="h-3 bg-surface-tertiary rounded w-2/3"></div>
@@ -12,7 +12,7 @@ export function SkeletonCard() {
export function SkeletonStatCard() {
return (
<div className="bg-white rounded-xl border border-border p-5 animate-pulse">
<div className="bg-surface rounded-xl border border-border p-5 animate-pulse">
<div className="flex items-start justify-between mb-4">
<div className="w-10 h-10 bg-surface-tertiary rounded-lg"></div>
<div className="h-3 bg-surface-tertiary rounded w-16"></div>
@@ -25,7 +25,7 @@ export function SkeletonStatCard() {
export function SkeletonTable({ rows = 5, cols = 6 }) {
return (
<div className="bg-white rounded-xl border border-border overflow-hidden animate-pulse">
<div className="bg-surface rounded-xl border border-border overflow-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>
+37 -6
View File
@@ -1,17 +1,48 @@
import { useEffect, useRef, useCallback } from 'react'
import { createPortal } from 'react-dom'
export default function SlidePanel({ onClose, maxWidth = '420px', header, footer, children }) {
const panelRef = useRef(null)
const handleKeyDown = useCallback((e) => {
if (e.key === 'Escape') onClose()
}, [onClose])
useEffect(() => {
if (!panelRef.current) return
const el = panelRef.current
const focusable = el.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
if (focusable.length > 0) focusable[0].focus()
const handleTab = (e) => {
if (e.key !== 'Tab' || focusable.length === 0) return
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (e.shiftKey) {
if (document.activeElement === first) { e.preventDefault(); last.focus() }
} else {
if (document.activeElement === last) { e.preventDefault(); first.focus() }
}
}
el.addEventListener('keydown', handleTab)
return () => el.removeEventListener('keydown', handleTab)
}, [])
return createPortal(
<>
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm animate-backdrop-in z-[9998]" onClick={onClose} />
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm animate-backdrop-in z-[9998]" onClick={onClose} aria-label="Close panel" />
<div
className="fixed top-0 right-0 h-full w-full bg-white shadow-2xl z-[9998] flex flex-col animate-slide-in-right overflow-hidden"
ref={panelRef}
className="fixed top-0 right-0 h-full w-full bg-surface shadow-2xl z-[9998] animate-slide-in-right overflow-y-auto"
style={{ maxWidth }}
role="dialog"
aria-modal="true"
onKeyDown={handleKeyDown}
>
{header}
<div className="flex-1 overflow-y-auto">
{children}
</div>
<div className="sticky top-0 z-10 bg-surface">{header}</div>
<div className="flex-1">{children}</div>
{footer}
</div>
</>,
+6 -6
View File
@@ -7,20 +7,20 @@ export default function StatCard({ icon: Icon, label, value, subtitle, color = '
}
const iconBgMap = {
'brand-primary': 'bg-indigo-50 text-indigo-600 shadow-lg shadow-indigo-500/20',
'brand-secondary': 'bg-pink-50 text-pink-600 shadow-lg shadow-pink-500/20',
'brand-tertiary': 'bg-amber-50 text-amber-600 shadow-lg shadow-amber-500/20',
'brand-quaternary': 'bg-emerald-50 text-emerald-600 shadow-lg shadow-emerald-500/20',
'brand-primary': 'bg-teal-50 text-teal-700',
'brand-secondary': 'bg-pink-50 text-pink-600',
'brand-tertiary': 'bg-amber-50 text-amber-600',
'brand-quaternary': 'bg-teal-50 text-teal-600',
}
const accentClass = accentMap[color] || 'accent-primary'
return (
<div className={`stat-card-premium ${accentClass} bg-white rounded-xl border border-border p-5 card-hover`}>
<div className={`stat-card-premium ${accentClass} bg-surface rounded-xl border border-border p-5 card-hover`}>
<div className="flex items-start justify-between">
<div>
<p className="text-sm font-medium text-text-tertiary">{label}</p>
<p className="text-3xl font-bold text-text-primary mt-1">{value}</p>
<p className="text-2xl font-bold text-text-primary mt-1">{value}</p>
{subtitle && (
<p className="text-xs text-text-tertiary mt-1">{subtitle}</p>
)}
+41 -10
View File
@@ -1,4 +1,4 @@
import { useEffect } from 'react'
import { useEffect, useRef, useCallback } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
@@ -19,26 +19,55 @@ export default function TabbedModal({
footer,
children,
}) {
const modalRef = useRef(null)
useEffect(() => {
document.body.style.overflow = 'hidden'
return () => { document.body.style.overflow = '' }
}, [])
return createPortal(
<div className="fixed inset-0 z-[9999] flex items-start justify-center pt-[5vh] px-4">
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm animate-backdrop-in" onClick={onClose} />
useEffect(() => {
if (!modalRef.current) return
const el = modalRef.current
const focusable = el.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
if (focusable.length > 0) focusable[0].focus()
<div className={`relative bg-white rounded-2xl shadow-2xl w-full ${SIZE_CLASSES[size] || SIZE_CLASSES.md} max-h-[90vh] flex flex-col animate-scale-in`}>
const handleTab = (e) => {
if (e.key !== 'Tab' || focusable.length === 0) return
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (e.shiftKey) {
if (document.activeElement === first) { e.preventDefault(); last.focus() }
} else {
if (document.activeElement === last) { e.preventDefault(); first.focus() }
}
}
el.addEventListener('keydown', handleTab)
return () => el.removeEventListener('keydown', handleTab)
}, [])
const handleKeyDown = useCallback((e) => {
if (e.key === 'Escape') onClose()
}, [onClose])
return createPortal(
<div className="fixed inset-0 z-[9999] flex items-start justify-center pt-[5vh] px-4" onKeyDown={handleKeyDown} ref={modalRef}>
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm animate-backdrop-in" onClick={onClose} aria-label="Close dialog" />
<div className={`relative bg-surface rounded-2xl shadow-2xl w-full ${SIZE_CLASSES[size] || SIZE_CLASSES.md} max-h-[90vh] overflow-y-auto animate-scale-in`} role="dialog" aria-modal="true" aria-labelledby="tabbed-modal-title">
{/* Header */}
<div className="shrink-0">
<div className="sticky top-0 z-10 bg-surface rounded-t-2xl">
<div className="px-6 pt-5 pb-3">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div id="tabbed-modal-title" className="flex-1 min-w-0">
{header}
</div>
<button
onClick={onClose}
className="p-2 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors shrink-0 -mt-1 -me-1"
aria-label="Close dialog"
>
<X className="w-5 h-5" />
</button>
@@ -47,13 +76,15 @@ export default function TabbedModal({
{/* Tabs */}
{tabs.length > 0 && (
<div className="flex gap-0 px-6 border-b border-border overflow-x-auto">
<div className="flex gap-0 px-6 border-b border-border overflow-x-auto" role="tablist">
{tabs.map(tab => {
const TabIcon = tab.icon
return (
<button
key={tab.key}
onClick={() => onTabChange(tab.key)}
role="tab"
aria-selected={activeTab === tab.key}
className={`relative flex items-center gap-2 px-4 py-3 text-[13px] font-medium whitespace-nowrap transition-colors ${
activeTab === tab.key
? 'text-brand-primary'
@@ -80,13 +111,13 @@ export default function TabbedModal({
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto">
<div role="tabpanel">
{children}
</div>
{/* Footer */}
{footer && (
<div className="border-t border-border px-6 py-3.5 flex items-center justify-between shrink-0 rounded-b-2xl bg-white">
<div className="border-t border-border px-6 py-3.5 flex items-center justify-between rounded-b-2xl bg-surface">
{footer}
</div>
)}
+6 -6
View File
@@ -100,7 +100,7 @@ export default function TaskCalendarView({ tasks, onTaskClick }) {
if (p === 'urgent') return 'bg-red-500 text-white'
if (p === 'high') return 'bg-orange-400 text-white'
if (p === 'medium') return 'bg-amber-400 text-amber-900'
return 'bg-gray-300 text-gray-700'
return 'bg-gray-300 text-text-secondary'
}
return (
@@ -124,14 +124,14 @@ export default function TaskCalendarView({ tasks, onTaskClick }) {
<div className="flex bg-surface-tertiary rounded-lg p-0.5">
<button
onClick={() => setCalView('month')}
className={`px-2.5 py-1 text-[11px] font-medium rounded-md transition-colors flex items-center gap-1 ${calView === 'month' ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary'}`}
className={`px-2.5 py-1 text-[11px] font-medium rounded-md transition-colors flex items-center gap-1 ${calView === 'month' ? 'bg-surface shadow-sm text-text-primary' : 'text-text-tertiary'}`}
>
<CalendarIcon className="w-3 h-3" />
Month
</button>
<button
onClick={() => setCalView('week')}
className={`px-2.5 py-1 text-[11px] font-medium rounded-md transition-colors flex items-center gap-1 ${calView === 'week' ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary'}`}
className={`px-2.5 py-1 text-[11px] font-medium rounded-md transition-colors flex items-center gap-1 ${calView === 'week' ? 'bg-surface shadow-sm text-text-primary' : 'text-text-tertiary'}`}
>
<CalendarDays className="w-3 h-3" />
Week
@@ -162,7 +162,7 @@ export default function TaskCalendarView({ tasks, onTaskClick }) {
<div
key={i}
className={`border-r border-b border-border ${calView === 'week' ? 'min-h-[300px]' : 'min-h-[90px]'} p-1 ${
cell.current ? 'bg-white' : 'bg-surface-secondary/50'
cell.current ? 'bg-surface' : 'bg-surface-secondary/50'
}`}
>
<div className={`text-[11px] font-medium mb-0.5 w-6 h-6 flex items-center justify-center rounded-full ${
@@ -175,7 +175,7 @@ export default function TaskCalendarView({ tasks, onTaskClick }) {
<button
key={task._id || task.id}
onClick={() => onTaskClick(task)}
className={`w-full text-left text-[10px] px-1.5 py-0.5 rounded truncate font-medium hover:opacity-80 transition-opacity ${
className={`w-full text-start text-[10px] px-1.5 py-0.5 rounded truncate font-medium hover:opacity-80 transition-opacity ${
task.status === 'done' ? 'bg-emerald-100 text-emerald-700 line-through' : getPillColor(task)
}`}
title={task.title}
@@ -206,7 +206,7 @@ export default function TaskCalendarView({ tasks, onTaskClick }) {
<button
key={task._id || task.id}
onClick={() => onTaskClick(task)}
className="w-full text-left bg-white border border-border rounded-lg p-2 hover:border-brand-primary/30 transition-colors"
className="w-full text-start bg-surface border border-border rounded-lg p-2 hover:border-brand-primary/30 transition-colors"
>
<div className="flex items-center gap-1.5">
<div className={`w-2 h-2 rounded-full ${priority.color} shrink-0`} />
+1 -1
View File
@@ -32,7 +32,7 @@ export default function TaskCard({ task, onMove, showProject = true }) {
const assignedName = task.assigned_name || task.assignedName
return (
<div className={`bg-white rounded-lg border border-border p-3 card-hover group cursor-pointer ${isExternallyAssigned ? 'border-l-[3px] border-l-blue-400' : ''}`}>
<div className={`bg-surface rounded-lg border border-border p-3 card-hover group cursor-pointer ${isExternallyAssigned ? 'border-l-[3px] border-l-blue-400' : ''}`}>
<div className="flex items-start gap-2.5">
{/* Priority dot */}
<div className={`w-2.5 h-2.5 rounded-full ${priority.color} mt-1.5 shrink-0`} title={priority.label} />
+30 -42
View File
@@ -5,6 +5,7 @@ import { useLanguage } from '../i18n/LanguageContext'
import CommentsSection from './CommentsSection'
import Modal from './Modal'
import TabbedModal from './TabbedModal'
import PortalSelect from './PortalSelect'
const API_BASE = '/api'
@@ -199,11 +200,11 @@ export default function TaskDetailPanel({ task, onClose, onSave, onDelete, proje
{/* Thumbnail banner */}
{currentThumbnail && (
<div className="relative -mx-6 -mt-5 mb-3 h-32 overflow-hidden rounded-t-2xl">
<img src={currentThumbnail} alt="" className="w-full h-full object-cover" />
<img src={currentThumbnail} alt="" className="w-full h-full object-cover" loading="lazy" />
<div className="absolute inset-0 bg-gradient-to-t from-white/80 to-transparent" />
<button
onClick={handleRemoveThumbnail}
className="absolute top-2 right-2 p-1 bg-black/40 hover:bg-black/60 rounded-full text-white transition-colors"
className="absolute top-2 end-2 p-1 bg-black/40 hover:bg-black/60 rounded-full text-white transition-colors"
title={t('tasks.removeThumbnail')}
>
<X className="w-3 h-3" />
@@ -218,11 +219,11 @@ export default function TaskDetailPanel({ task, onClose, onSave, onDelete, proje
placeholder={t('tasks.taskTitle')}
/>
<div className="flex items-center gap-2 mt-2">
<span className={`inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full font-medium ${priority.color === 'bg-gray-400' ? 'bg-gray-100 text-gray-600' : priority.color === 'bg-amber-400' ? 'bg-amber-100 text-amber-700' : priority.color === 'bg-orange-500' ? 'bg-orange-100 text-orange-700' : 'bg-red-100 text-red-700'}`}>
<span className={`inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full font-medium ${priority.color === 'bg-gray-400' ? 'bg-gray-100 text-text-secondary' : priority.color === 'bg-amber-400' ? 'bg-amber-100 text-amber-700' : priority.color === 'bg-orange-500' ? 'bg-orange-100 text-orange-700' : 'bg-red-100 text-red-700'}`}>
<div className={`w-1.5 h-1.5 rounded-full ${priority.color}`} />
{priorityOptions.find(p => p.value === form.priority)?.label}
</span>
<span className={`text-[11px] px-2 py-0.5 rounded-full font-medium ${form.status === 'done' ? 'bg-emerald-100 text-emerald-700' : form.status === 'in_progress' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600'}`}>
<span className={`text-[11px] px-2 py-0.5 rounded-full font-medium ${form.status === 'done' ? 'bg-emerald-100 text-emerald-700' : form.status === 'in_progress' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-text-secondary'}`}>
{statusOptions.find(s => s.value === form.status)?.label}
</span>
{isOverdue && !isCreateMode && (
@@ -293,16 +294,12 @@ export default function TaskDetailPanel({ task, onClose, onSave, onDelete, proje
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.project')}</label>
<div className="flex items-center gap-2">
<select
<PortalSelect
value={form.project_id}
onChange={e => update('project_id', e.target.value)}
onChange={val => update('project_id', val)}
options={[{ value: '', label: t('tasks.noProject') }, ...(projects || []).map(p => ({ value: p._id || p.id, label: p.name || p.title }))]}
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
<option value="">{t('tasks.noProject')}</option>
{(projects || []).map(p => (
<option key={p._id || p.id} value={p._id || p.id}>{p.name || p.title}</option>
))}
</select>
/>
{brandName && (
<span className={`text-[10px] px-1.5 py-0.5 rounded shrink-0 ${getBrandColor(brandName).bg} ${getBrandColor(brandName).text}`}>
{brandName}
@@ -314,43 +311,33 @@ export default function TaskDetailPanel({ task, onClose, onSave, onDelete, proje
{/* Assignee */}
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.assignee')}</label>
<select
<PortalSelect
value={form.assigned_to}
onChange={e => update('assigned_to', e.target.value)}
onChange={val => update('assigned_to', val)}
options={[{ value: '', label: t('common.unassigned') }, ...(users || []).map(m => ({ value: m._id || m.team_member_id, label: m.name }))]}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
<option value="">{t('common.unassigned')}</option>
{(users || []).map(m => (
<option key={m._id || m.team_member_id} value={m._id || m.team_member_id}>{m.name}</option>
))}
</select>
/>
</div>
{/* Priority & Status */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.priority')}</label>
<select
<PortalSelect
value={form.priority}
onChange={e => update('priority', e.target.value)}
onChange={val => update('priority', val)}
options={priorityOptions}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
{priorityOptions.map(p => (
<option key={p.value} value={p.value}>{p.label}</option>
))}
</select>
/>
</div>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.status')}</label>
<select
<PortalSelect
value={form.status}
onChange={e => update('status', e.target.value)}
onChange={val => update('status', val)}
options={statusOptions}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
{statusOptions.map(s => (
<option key={s.value} value={s.value}>{s.label}</option>
))}
</select>
/>
</div>
</div>
@@ -401,11 +388,11 @@ export default function TaskDetailPanel({ task, onClose, onSave, onDelete, proje
const isThumbnail = currentThumbnail && attUrl === currentThumbnail
return (
<div key={attId} className="relative group/att border border-border rounded-lg overflow-hidden bg-white">
<div key={attId} className="relative group/att border border-border rounded-lg overflow-hidden bg-surface">
<div className="h-20 relative">
{isImage ? (
<a href={attUrl} target="_blank" rel="noopener noreferrer" className="block h-full">
<img src={attUrl} alt={name} className="absolute inset-0 w-full h-full object-cover" />
<img src={attUrl} alt={name} className="absolute inset-0 w-full h-full object-cover" loading="lazy" />
</a>
) : (
<a href={attUrl} target="_blank" rel="noopener noreferrer" className="absolute inset-0 flex items-center gap-2 p-3">
@@ -414,11 +401,11 @@ export default function TaskDetailPanel({ task, onClose, onSave, onDelete, proje
</a>
)}
{isThumbnail && (
<div className="absolute top-1 left-1 p-0.5 bg-amber-400 rounded-full text-white">
<div className="absolute top-1 start-1 p-0.5 bg-amber-400 rounded-full text-white">
<Star className="w-2.5 h-2.5 fill-current" />
</div>
)}
<div className="absolute top-1 right-1 flex items-center gap-0.5 opacity-0 group-hover/att:opacity-100 transition-opacity">
<div className="absolute top-1 end-1 flex items-center gap-0.5 opacity-0 group-hover/att:opacity-100 transition-opacity">
{isImage && !isThumbnail && (
<button
onClick={() => handleSetThumbnail(att)}
@@ -454,17 +441,17 @@ export default function TaskDetailPanel({ task, onClose, onSave, onDelete, proje
const previewUrl = isImage ? URL.createObjectURL(file) : null
return (
<div key={i} className="relative group/att border border-border rounded-lg overflow-hidden bg-white">
<div key={i} className="relative group/att border border-border rounded-lg overflow-hidden bg-surface">
<div className="h-20 relative">
{isImage ? (
<img src={previewUrl} alt={file.name} className="absolute inset-0 w-full h-full object-cover" />
<img src={previewUrl} alt={file.name} className="absolute inset-0 w-full h-full object-cover" loading="lazy" />
) : (
<div className="absolute inset-0 flex items-center gap-2 p-3">
<FileText className="w-6 h-6 text-text-tertiary shrink-0" />
<span className="text-xs text-text-secondary truncate">{file.name}</span>
</div>
)}
<div className="absolute top-1 right-1 flex items-center gap-0.5 opacity-0 group-hover/att:opacity-100 transition-opacity">
<div className="absolute top-1 end-1 flex items-center gap-0.5 opacity-0 group-hover/att:opacity-100 transition-opacity">
<button
onClick={() => setPendingFiles(prev => prev.filter((_, j) => j !== i))}
className="p-1 bg-black/50 hover:bg-red-500 rounded-full text-white transition-colors"
@@ -494,7 +481,8 @@ export default function TaskDetailPanel({ task, onClose, onSave, onDelete, proje
ref={fileInputRef}
type="file"
multiple
className="hidden"
className="absolute w-0 h-0 opacity-0 pointer-events-none"
tabIndex={-1}
onChange={e => {
setUploadError(null)
const files = Array.from(e.target.files || [])
+16 -18
View File
@@ -6,14 +6,15 @@ import { useToast } from './ToastContainer'
import Modal from './Modal'
import TabbedModal from './TabbedModal'
import StatusBadge from './StatusBadge'
import PortalSelect from './PortalSelect'
import { AppContext, PERMISSION_LEVELS } from '../App'
const ALL_MODULES = ['marketing', 'projects', 'finance']
const MODULE_LABELS = { marketing: 'Marketing', projects: 'Projects', finance: 'Finance' }
const MODULE_COLORS = {
marketing: { on: 'bg-emerald-100 text-emerald-700 border-emerald-300', off: 'bg-gray-100 text-gray-400 border-gray-200' },
projects: { on: 'bg-blue-100 text-blue-700 border-blue-300', off: 'bg-gray-100 text-gray-400 border-gray-200' },
finance: { on: 'bg-amber-100 text-amber-700 border-amber-300', off: 'bg-gray-100 text-gray-400 border-gray-200' },
marketing: { on: 'bg-emerald-100 text-emerald-700 border-emerald-300', off: 'bg-gray-100 text-text-tertiary border-gray-200' },
projects: { on: 'bg-blue-100 text-blue-700 border-blue-300', off: 'bg-gray-100 text-text-tertiary border-gray-200' },
finance: { on: 'bg-amber-100 text-amber-700 border-amber-300', off: 'bg-gray-100 text-text-tertiary border-gray-200' },
}
export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave, onDelete, canManageTeam, userRole, teams, brands: brandsList }) {
@@ -231,13 +232,12 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
{userRole === 'superadmin' && !isEditingSelf && (
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.permissionLevel')}</label>
<select
<PortalSelect
value={form.permission_level}
onChange={e => update('permission_level', e.target.value)}
onChange={val => update('permission_level', val)}
options={PERMISSION_LEVELS.map(p => ({ value: p.value, label: p.label }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
{PERMISSION_LEVELS.map(p => <option key={p.value} value={p.value}>{p.label}</option>)}
</select>
/>
</div>
)}
@@ -252,14 +252,12 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface-tertiary text-text-tertiary cursor-not-allowed"
/>
) : (
<select
<PortalSelect
value={form.role_id || ''}
onChange={e => update('role_id', e.target.value ? Number(e.target.value) : null)}
onChange={val => update('role_id', val ? Number(val) : null)}
options={[{ value: '', label: t('team.selectRole') }, ...roles.map(r => ({ value: r.Id || r.id, label: r.name }))]}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
<option value="">{t('team.selectRole')}</option>
{roles.map(r => <option key={r.Id || r.id} value={r.Id || r.id}>{r.name}</option>)}
</select>
/>
)}
</div>
@@ -285,7 +283,7 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
<button
type="button"
onClick={() => setShowBrandsDropdown(prev => !prev)}
className="w-full flex items-center justify-between px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-white text-left"
className="w-full flex items-center justify-between px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface text-start"
>
<span className={`flex-1 truncate ${(form.brands || []).length === 0 ? 'text-text-tertiary' : 'text-text-primary'}`}>
{(form.brands || []).length === 0
@@ -315,7 +313,7 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
{/* Dropdown */}
{showBrandsDropdown && (
<div className="absolute z-20 mt-1 w-full bg-white border border-border rounded-lg shadow-lg max-h-48 overflow-y-auto">
<div className="absolute z-20 mt-1 w-full bg-surface border border-border rounded-lg shadow-lg max-h-48 overflow-y-auto">
{brandsList && brandsList.length > 0 ? (
brandsList.map(brand => {
const name = lang === 'ar' && brand.name_ar ? brand.name_ar : brand.name
@@ -325,7 +323,7 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
type="button"
key={brand.id || brand._id}
onClick={() => toggleBrand(name)}
className={`w-full flex items-center gap-2.5 px-3 py-2 cursor-pointer hover:bg-surface-secondary transition-colors text-left ${checked ? 'bg-brand-primary/5' : ''}`}
className={`w-full flex items-center gap-2.5 px-3 py-2 cursor-pointer hover:bg-surface-secondary transition-colors text-start ${checked ? 'bg-brand-primary/5' : ''}`}
>
<div className={`w-4 h-4 rounded border flex items-center justify-center shrink-0 transition-colors ${
checked ? 'bg-brand-primary border-brand-primary' : 'border-border'
@@ -393,7 +391,7 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
className={`text-xs px-3 py-1.5 rounded-full border font-medium transition-colors ${
active
? 'bg-blue-100 text-blue-700 border-blue-300'
: 'bg-gray-100 text-gray-400 border-gray-200'
: 'bg-gray-100 text-text-tertiary border-gray-200'
}`}
>
{team.name}
+2 -2
View File
@@ -149,13 +149,13 @@ export default function TeamPanel({ team, onClose, onSave, onDelete, teamMembers
{activeTab === 'members' && (
<div className="p-6">
<div className="relative mb-3">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-text-tertiary" />
<Search className="absolute start-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-text-tertiary" />
<input
type="text"
value={memberSearch}
onChange={e => setMemberSearch(e.target.value)}
placeholder={t('teams.selectMembers')}
className="w-full pl-9 pr-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
className="w-full ps-9 pe-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
/>
</div>
<div className="space-y-1 max-h-80 overflow-y-auto">
+1 -1
View File
@@ -14,7 +14,7 @@ export default function ThemeToggle({ className = '' }) {
{darkMode ? (
<Sun className="w-5 h-5 text-yellow-500" />
) : (
<Moon className="w-5 h-5 text-gray-600" />
<Moon className="w-5 h-5 text-text-secondary" />
)}
</button>
)
+1 -1
View File
@@ -37,7 +37,7 @@ export function ToastProvider({ children }) {
<ToastContext.Provider value={toast}>
{children}
{/* Toast container - fixed position */}
<div className="fixed top-4 right-4 z-[10000] flex flex-col gap-2 pointer-events-none">
<div className="fixed top-4 end-4 z-[10000] flex flex-col gap-2 pointer-events-none">
<div className="flex flex-col gap-2 pointer-events-auto">
{toasts.map(t => (
<Toast
+14 -24
View File
@@ -5,6 +5,7 @@ import { PLATFORMS } from '../utils/api'
import Modal from './Modal'
import TabbedModal from './TabbedModal'
import BudgetBar from './BudgetBar'
import PortalSelect from './PortalSelect'
const TRACK_TYPES = {
organic_social: { label: 'Organic Social' },
@@ -114,7 +115,7 @@ export default function TrackDetailPanel({ track, campaignId, onClose, onSave, o
form.status === 'active' ? 'bg-emerald-100 text-emerald-700' :
form.status === 'paused' ? 'bg-amber-100 text-amber-700' :
form.status === 'completed' ? 'bg-blue-100 text-blue-700' :
'bg-gray-100 text-gray-600'
'bg-gray-100 text-text-secondary'
}`}>
{form.status?.charAt(0).toUpperCase() + form.status?.slice(1)}
</span>
@@ -156,29 +157,21 @@ export default function TrackDetailPanel({ track, campaignId, onClose, onSave, o
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tracks.type')}</label>
<select
<PortalSelect
value={form.type}
onChange={e => update('type', e.target.value)}
onChange={val => update('type', val)}
options={Object.entries(TRACK_TYPES).map(([k, v]) => ({ value: k, label: v.label }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
>
{Object.entries(TRACK_TYPES).map(([k, v]) => (
<option key={k} value={k}>{v.label}</option>
))}
</select>
/>
</div>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tracks.platform')}</label>
<select
<PortalSelect
value={form.platform}
onChange={e => update('platform', e.target.value)}
onChange={val => update('platform', val)}
options={[{ value: '', label: 'All / Multiple' }, ...Object.entries(PLATFORMS).map(([k, v]) => ({ value: k, label: v.label })), { value: 'google_ads', label: 'Google Ads' }]}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
>
<option value="">All / Multiple</option>
{Object.entries(PLATFORMS).map(([k, v]) => (
<option key={k} value={k}>{v.label}</option>
))}
<option value="google_ads">Google Ads</option>
</select>
/>
</div>
</div>
@@ -195,15 +188,12 @@ export default function TrackDetailPanel({ track, campaignId, onClose, onSave, o
</div>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tracks.status')}</label>
<select
<PortalSelect
value={form.status}
onChange={e => update('status', e.target.value)}
onChange={val => update('status', val)}
options={TRACK_STATUSES.map(s => ({ value: s, label: s.charAt(0).toUpperCase() + s.slice(1) }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
>
{TRACK_STATUSES.map(s => (
<option key={s} value={s}>{s.charAt(0).toUpperCase() + s.slice(1)}</option>
))}
</select>
/>
</div>
</div>
+170 -144
View File
@@ -1,35 +1,22 @@
import { useState, useEffect, useContext } from 'react'
import { Plus, Copy, Check, ExternalLink, Trash2, Save, FileEdit, Languages, ShieldCheck, Globe } from 'lucide-react'
import { Plus, Copy, Check, ExternalLink, Trash2, Save, FileEdit, Languages, ShieldCheck, Globe, Lock } from 'lucide-react'
import { AppContext } from '../App'
import { useLanguage } from '../i18n/LanguageContext'
import { api } from '../utils/api'
import { AVAILABLE_LANGUAGES, TRANSLATION_STATUS_COLORS, isTextSelected, groupTextsByLanguage } from '../utils/translations'
import Modal from './Modal'
import TabbedModal from './TabbedModal'
import { useToast } from './ToastContainer'
import ApproverMultiSelect from './ApproverMultiSelect'
import PortalSelect from './PortalSelect'
const STATUS_COLORS = {
draft: 'bg-surface-tertiary text-text-secondary',
pending_review: 'bg-amber-100 text-amber-700',
approved: 'bg-emerald-100 text-emerald-700',
rejected: 'bg-red-100 text-red-700',
revision_requested: 'bg-orange-100 text-orange-700',
}
const AVAILABLE_LANGUAGES = [
{ code: 'AR', label: 'العربية' },
{ code: 'EN', label: 'English' },
{ code: 'FR', label: 'Français' },
{ code: 'ID', label: 'Bahasa Indonesia' },
]
export default function TranslationDetailPanel({ translation, onClose, onUpdate, onDelete, assignableUsers = [] }) {
export default function TranslationDetailPanel({ translation, onClose, onUpdate, onDelete, assignableUsers = [], posts: externalPosts }) {
const { t } = useLanguage()
const { brands } = useContext(AppContext)
const toast = useToast()
const isApproved = translation.status === 'approved'
const [editTitle, setEditTitle] = useState(translation.title || '')
const [editDescription, setEditDescription] = useState(translation.description || '')
const [editSourceContent, setEditSourceContent] = useState(translation.source_content || '')
const [editSourceLanguage, setEditSourceLanguage] = useState(translation.source_language || 'EN')
const [editApproverIds, setEditApproverIds] = useState(
@@ -44,6 +31,13 @@ export default function TranslationDetailPanel({ translation, onClose, onUpdate,
const [submitting, setSubmitting] = useState(false)
const [copied, setCopied] = useState(false)
const [freshReviewUrl, setFreshReviewUrl] = useState('')
const [copiedTextId, setCopiedTextId] = useState(null)
// Post selector
const [posts, setPosts] = useState(externalPosts || [])
const [showCreatePost, setShowCreatePost] = useState(false)
const [newPostTitle, setNewPostTitle] = useState('')
const [creatingPost, setCreatingPost] = useState(false)
// Language add modal
const [showAddLang, setShowAddLang] = useState(false)
@@ -62,9 +56,12 @@ export default function TranslationDetailPanel({ translation, onClose, onUpdate,
loadTexts()
}, [translation.Id])
useEffect(() => {
if (externalPosts) setPosts(externalPosts)
}, [externalPosts])
useEffect(() => {
setEditTitle(translation.title || '')
setEditDescription(translation.description || '')
setEditSourceContent(translation.source_content || '')
setEditSourceLanguage(translation.source_language || 'EN')
setEditApproverIds(
@@ -92,7 +89,6 @@ export default function TranslationDetailPanel({ translation, onClose, onUpdate,
try {
await api.patch(`/translations/${translation.Id}`, {
title: editTitle,
description: editDescription,
source_content: editSourceContent,
source_language: editSourceLanguage,
approver_ids: editApproverIds.length > 0 ? editApproverIds.join(',') : null,
@@ -142,11 +138,7 @@ export default function TranslationDetailPanel({ translation, onClose, onUpdate,
const handleUpdateText = async (textId) => {
try {
const text = texts.find(t => t.Id === textId)
if (!text) return
await api.post(`/translations/${translation.Id}/texts`, {
language_code: text.language_code,
language_label: text.language_label,
await api.patch(`/translations/${translation.Id}/texts/${textId}`, {
content: editingContent,
})
toast.success(t('translations.updated'))
@@ -197,9 +189,35 @@ export default function TranslationDetailPanel({ translation, onClose, onUpdate,
}
}
// Available languages for adding (exclude source + already added)
const usedCodes = new Set([translation.source_language, ...texts.map(t => t.language_code)])
const availableForAdd = AVAILABLE_LANGUAGES.filter(l => !usedCodes.has(l.code))
const handleCreatePost = async () => {
if (!newPostTitle.trim()) return
setCreatingPost(true)
try {
const created = await api.post('/posts', { title: newPostTitle, status: 'draft' })
const postId = created.Id || created.id || created._id
setPosts(prev => [created, ...prev])
await handleFieldUpdate('post_id', postId)
setShowCreatePost(false)
setNewPostTitle('')
} catch (err) {
toast.error(t('translations.postCreateFailed'))
} finally {
setCreatingPost(false)
}
}
const copyTextContent = (content, id) => {
navigator.clipboard.writeText(content)
setCopiedTextId(id)
toast.success(t('translations.copiedToClipboard'))
setTimeout(() => setCopiedTextId(null), 2000)
}
// Available languages (exclude source language only — multiple options per language allowed)
const targetLanguages = AVAILABLE_LANGUAGES.filter(l => l.code !== translation.source_language)
// Group texts by language
const textsByLanguage = groupTextsByLanguage(texts)
const tabs = [
{ key: 'details', label: t('translations.details'), icon: FileEdit },
@@ -222,12 +240,13 @@ export default function TranslationDetailPanel({ translation, onClose, onUpdate,
type="text"
value={editTitle}
onChange={e => setEditTitle(e.target.value)}
className="text-lg font-bold text-text-primary bg-transparent border-none outline-none focus:ring-0 w-full"
readOnly={isApproved}
className={`text-lg font-bold text-text-primary bg-transparent border-none outline-none focus:ring-0 w-full ${isApproved ? 'cursor-default' : ''}`}
placeholder={t('translations.titlePlaceholder')}
/>
</div>
<div className="flex items-center gap-2 flex-wrap">
<span className={`text-xs px-2 py-0.5 rounded-full ${STATUS_COLORS[translation.status] || 'bg-surface-tertiary text-text-secondary'}`}>
<span className={`text-xs px-2 py-0.5 rounded-full ${TRANSLATION_STATUS_COLORS[translation.status] || 'bg-surface-tertiary text-text-secondary'}`}>
{translation.status?.replace('_', ' ')}
</span>
<span className="text-xs px-2 py-0.5 rounded-full bg-blue-50 text-blue-600 font-medium">
@@ -244,7 +263,12 @@ export default function TranslationDetailPanel({ translation, onClose, onUpdate,
tabs={tabs}
activeTab={activeTab}
onTabChange={setActiveTab}
footer={
footer={isApproved ? (
<div className="flex items-center gap-2 w-full justify-center">
<Lock className="w-4 h-4 text-text-tertiary" />
<span className="text-sm text-text-tertiary">{t('translations.approvedReadOnly')}</span>
</div>
) : (
<div className="flex items-center gap-2 w-full justify-between">
<div className="flex items-center gap-2">
<button
@@ -265,20 +289,20 @@ export default function TranslationDetailPanel({ translation, onClose, onUpdate,
{savingDraft ? t('translations.savingDraft') : t('translations.saveDraft')}
</button>
</div>
}
)}
>
{/* Details Tab */}
{activeTab === 'details' && (
<div className="p-6 space-y-5">
<div>
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-2">{t('translations.sourceLanguage')}</h4>
<select
<PortalSelect
value={editSourceLanguage}
onChange={e => setEditSourceLanguage(e.target.value)}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
>
{AVAILABLE_LANGUAGES.map(l => <option key={l.code} value={l.code}>{l.label} ({l.code})</option>)}
</select>
onChange={val => setEditSourceLanguage(val)}
disabled={isApproved}
options={AVAILABLE_LANGUAGES.map(l => ({ value: l.code, label: `${l.label} (${l.code})` }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 disabled:opacity-60 disabled:cursor-default"
/>
</div>
<div>
@@ -286,43 +310,13 @@ export default function TranslationDetailPanel({ translation, onClose, onUpdate,
<textarea
value={editSourceContent}
onChange={e => setEditSourceContent(e.target.value)}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 min-h-[150px] resize-y"
readOnly={isApproved}
className={`w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 min-h-[150px] resize-y ${isApproved ? 'opacity-60 cursor-default' : ''}`}
placeholder={t('translations.sourceContentPlaceholder')}
/>
</div>
<div>
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-2">{t('translations.descriptionLabel')}</h4>
<textarea
value={editDescription}
onChange={e => setEditDescription(e.target.value)}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 min-h-[80px] resize-y"
placeholder={t('translations.descriptionPlaceholder')}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1.5">{t('translations.brand')}</h4>
<select
value={translation.brand_id || ''}
onChange={e => handleFieldUpdate('brand_id', e.target.value)}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
>
<option value=""></option>
{brands.map(b => <option key={b._id} value={b._id}>{b.name}</option>)}
</select>
</div>
</div>
<div>
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1.5">{t('translations.approversLabel')}</h4>
<ApproverMultiSelect
selected={editApproverIds}
onChange={setEditApproverIds}
users={assignableUsers}
/>
</div>
</div>
)}
@@ -340,76 +334,92 @@ export default function TranslationDetailPanel({ translation, onClose, onUpdate,
<p className="text-sm text-blue-800 whitespace-pre-wrap">{translation.source_content}</p>
</div>
{/* Add translation button */}
{/* Add translation option button */}
<div className="flex items-center justify-between">
<h4 className="text-xs font-semibold text-text-tertiary uppercase">{t('translations.translationTexts')}</h4>
{availableForAdd.length > 0 && (
{!isApproved && (
<button
onClick={() => setShowAddLang(true)}
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors"
>
<Plus className="w-3 h-3" />
{t('translations.addTranslation')}
{t('translations.addOption')}
</button>
)}
</div>
{/* Translation texts list */}
{texts.length > 0 ? (
<div className="space-y-3">
{texts.map(text => (
<div key={text.Id} className="bg-surface-secondary rounded-lg p-4 border border-border">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-text-primary">
{text.language_label || text.language_code}
<span className="text-xs text-text-tertiary ml-1">({text.language_code})</span>
</span>
<div className="flex items-center gap-1">
{editingTextId === text.Id ? (
<>
<button
onClick={() => handleUpdateText(text.Id)}
className="text-emerald-600 hover:text-emerald-700 p-1"
>
<Check className="w-4 h-4" />
</button>
<button
onClick={() => setEditingTextId(null)}
className="text-text-tertiary hover:text-text-secondary p-1"
>
</button>
</>
) : (
<>
<button
onClick={() => { setEditingTextId(text.Id); setEditingContent(text.content || '') }}
className="text-text-tertiary hover:text-text-secondary p-1"
>
<FileEdit className="w-4 h-4" />
</button>
<button
onClick={() => setConfirmDeleteTextId(text.Id)}
className="text-red-500 hover:text-red-600 p-1"
>
<Trash2 className="w-4 h-4" />
</button>
</>
)}
{/* 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>
{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-[100px] resize-y"
autoFocus
/>
) : (
<p className="text-sm text-text-secondary whitespace-pre-wrap">{text.content}</p>
)}
</div>
))}
)
})}
</div>
) : (
<div className="text-center py-8 bg-surface-secondary rounded-lg border-2 border-dashed border-border">
@@ -424,13 +434,28 @@ export default function TranslationDetailPanel({ translation, onClose, onUpdate,
{activeTab === 'review' && (
<div className="p-6 space-y-5">
{['draft', 'revision_requested', 'rejected'].includes(translation.status) && (
<button
onClick={handleSubmitReview}
disabled={submitting}
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>
<>
<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 && (
@@ -441,7 +466,7 @@ export default function TranslationDetailPanel({ translation, onClose, onUpdate,
type="text"
value={currentReviewUrl}
readOnly
className="flex-1 px-3 py-2 text-sm bg-white border border-blue-200 rounded-lg text-blue-800"
className="flex-1 px-3 py-2 text-sm bg-surface border border-blue-200 rounded-lg text-blue-800"
/>
<button
onClick={copyReviewLink}
@@ -491,18 +516,19 @@ export default function TranslationDetailPanel({ translation, onClose, onUpdate,
</TabbedModal>
{/* Add Translation Modal */}
<Modal isOpen={showAddLang} onClose={() => setShowAddLang(false)} title={t('translations.addTranslation')} size="md">
<Modal isOpen={showAddLang} onClose={() => setShowAddLang(false)} title={t('translations.addOption')} size="md">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('translations.languageLabel')} *</label>
<select
<PortalSelect
value={langForm.language_code}
onChange={e => setLangForm(f => ({ ...f, language_code: e.target.value }))}
onChange={val => setLangForm(f => ({ ...f, language_code: val }))}
options={[{ value: '', label: t('translations.selectLanguage') }, ...targetLanguages.map(l => {
const count = textsByLanguage[l.code]?.length || 0
return { value: l.code, label: `${l.label} (${l.code})${count > 0 ? `${count} ${t('translations.existing')}` : ''}` }
})]}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
>
<option value="">{t('translations.selectLanguage')}</option>
{availableForAdd.map(l => <option key={l.code} value={l.code}>{l.label} ({l.code})</option>)}
</select>
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('translations.translatedContent')} *</label>
@@ -522,7 +548,7 @@ export default function TranslationDetailPanel({ translation, onClose, onUpdate,
disabled={savingLang || !langForm.language_code || !langForm.content}
className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 shadow-sm"
>
{savingLang ? t('common.loading') : t('translations.addTranslation')}
{savingLang ? t('common.loading') : t('translations.addOption')}
</button>
</div>
</div>
+2 -2
View File
@@ -177,7 +177,7 @@ export default function Tutorial({ onComplete }) {
{/* Tooltip card */}
<div
className="absolute bg-white rounded-xl shadow-2xl border border-border p-6 animate-fade-in pointer-events-auto"
className="absolute bg-surface rounded-xl shadow-2xl border border-border p-6 animate-fade-in pointer-events-auto"
style={{
top: tooltipPosition.top,
left: tooltipPosition.left,
@@ -188,7 +188,7 @@ export default function Tutorial({ onComplete }) {
{/* Close button */}
<button
onClick={handleSkip}
className="absolute top-4 right-4 text-text-tertiary hover:text-text-primary transition-colors"
className="absolute top-4 end-4 text-text-tertiary hover:text-text-primary transition-colors"
>
<X className="w-5 h-5" />
</button>
+92
View File
@@ -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>
)
}
+212 -6
View File
@@ -1,6 +1,6 @@
{
"app.name": "المركز الرقمي",
"app.subtitle": "المنصة",
"app.name": "رواج",
"app.subtitle": "مركز التسويق",
"nav.dashboard": "لوحة التحكم",
"nav.campaigns": "الحملات",
"nav.finance": "المالية والعائد",
@@ -31,6 +31,7 @@
"common.loading": "جاري التحميل...",
"common.unassigned": "غير مُسند",
"common.close": "إغلاق",
"common.created": "تاريخ الإنشاء",
"common.required": "مطلوب",
"common.saveFailed": "فشل الحفظ. حاول مجدداً.",
"common.updateFailed": "فشل التحديث. حاول مجدداً.",
@@ -78,6 +79,29 @@
"posts.saveChanges": "حفظ التغييرات",
"posts.postTitle": "العنوان",
"posts.description": "الوصف",
"post.caption": "التعليق",
"post.captionPlaceholder": "اكتب تعليق المنشور...",
"post.copy": "النص (داخل التصميم)",
"post.designs": "التصاميم",
"post.video": "الفيديو",
"post.formatChecklist": "قائمة الأحجام المطلوبة",
"post.formatsNeeded": "الأحجام المطلوبة بناءً على المنصات المختارة",
"post.selectPlatforms": "اختر المنصات لعرض الأحجام المطلوبة",
"post.readiness": "الجاهزية",
"post.allPiecesReady": "جميع العناصر جاهزة — بانتظار الاعتماد",
"post.waitingOn": "بانتظار",
"post.signOff": "اعتماد وجدولة",
"post.signOffConfirm": "هل تريد اعتماد هذا المنشور وتجهيزه للجدولة؟",
"common.confirm": "تأكيد",
"post.linkExisting": "ربط موجود",
"post.createNew": "إنشاء جديد",
"post.addDesign": "إضافة تصميم",
"post.addVideo": "إضافة فيديو",
"post.linkTranslation": "ربط ترجمة",
"post.selectLanguage": "اللغة...",
"post.noCopyLinked": "لا يوجد نص مرتبط بعد",
"post.noDesignsLinked": "لا توجد تصاميم مرتبطة بعد",
"post.noVideoLinked": "لا يوجد فيديو مرتبط بعد",
"posts.brand": "العلامة التجارية",
"posts.platforms": "المنصات",
"posts.status": "الحالة",
@@ -396,6 +420,16 @@
"campaigns.editCampaign": "تعديل الحملة",
"campaigns.deleteCampaign": "حذف الحملة؟",
"campaigns.deleteConfirm": "هل أنت متأكد من حذف هذه الحملة؟ سيتم حذف جميع البيانات المرتبطة. لا يمكن التراجع.",
"campaigns.tracks": "المسارات",
"campaigns.addTrack": "إضافة مسار",
"campaigns.noTracks": "لا توجد مسارات بعد. أضف مسارات عضوية أو مدفوعة أو SEO لتنظيم هذه الحملة.",
"campaigns.postsLinked": "منشورات مرتبطة",
"campaigns.team": "الفريق",
"campaigns.assignMembers": "تعيين أعضاء",
"campaigns.linkedPosts": "المنشورات المرتبطة",
"campaigns.notFound": "الحملة غير موجودة.",
"common.goBack": "رجوع",
"finance.allocated": "مخصص",
"tracks.details": "التفاصيل",
"tracks.metrics": "المقاييس",
"tracks.trackName": "اسم المسار",
@@ -503,6 +537,59 @@
"budgets.dateExpensed": "التاريخ",
"dashboard.expenses": "المصروفات",
"finance.expenses": "إجمالي المصروفات",
"finance.totalReceived": "إجمالي المستلم",
"finance.totalSpent": "إجمالي المنفق",
"finance.remaining": "المتبقي",
"finance.revenue": "الإيرادات",
"finance.globalROI": "العائد الإجمالي",
"finance.budgetAllocation": "توزيع الميزانية",
"finance.manageBudgets": "إدارة الميزانيات",
"finance.campaigns": "الحملات",
"finance.projects": "المشاريع",
"finance.unallocated": "غير مخصص",
"finance.budgetUtilization": "استخدام الميزانية",
"finance.globalPerformance": "الأداء العام",
"finance.impressions": "مرات الظهور",
"finance.clicks": "النقرات",
"finance.conversions": "التحويلات",
"finance.campaignBreakdown": "توزيع الحملات",
"finance.allocatedFunds": "الأموال المخصصة",
"finance.requestBudget": "طلب ميزانية",
"finance.budgetRequests": "طلبات الميزانية",
"finance.pendingApproval": "بانتظار موافقة المدير التنفيذي",
"finance.justification": "المبرر",
"finance.earmarkFor": "تخصيص لـ",
"finance.submitRequest": "إرسال الطلب",
"finance.cancelRequest": "إلغاء الطلب",
"finance.approved": "تمت الموافقة",
"finance.rejected": "مرفوض",
"finance.cancelled": "ملغي",
"finance.pending": "قيد الانتظار",
"finance.ceoNote": "ملاحظة المدير",
"finance.requestPending": "طلب(ات) ميزانية بانتظار الموافقة",
"finance.insufficientBudget": "ميزانية غير كافية",
"finance.availableBudget": "المتاح",
"finance.requestMore": "طلب المزيد من الأموال",
"finance.noCeoEmail": "لم يتم تكوين بريد المدير التنفيذي. اذهب إلى الإعدادات.",
"finance.amount": "المبلغ",
"finance.justificationPlaceholder": "لماذا هذه الميزانية مطلوبة؟",
"finance.optional": "اختياري",
"settings.budgetApproval": "موافقة الميزانية",
"settings.ceoEmail": "بريد المدير التنفيذي / المعتمد",
"settings.ceoEmailHint": "عنوان البريد الإلكتروني الذي يستلم طلبات الموافقة على الميزانية",
"budgetApproval.title": "موافقة الميزانية",
"budgetApproval.amount": "المبلغ المطلوب",
"budgetApproval.requestedBy": "مقدم الطلب",
"budgetApproval.justification": "المبرر",
"budgetApproval.earmarkedFor": "مخصص لـ",
"budgetApproval.approve": "موافقة",
"budgetApproval.reject": "رفض",
"budgetApproval.addNote": "أضف ملاحظة (اختياري)",
"budgetApproval.approved": "تمت الموافقة على هذا الطلب.",
"budgetApproval.rejected": "تم رفض هذا الطلب.",
"budgetApproval.expired": "انتهت صلاحية هذا الطلب.",
"budgetApproval.alreadyHandled": "تمت معالجة هذا الطلب بالفعل.",
"finance.ofBudget": "من الميزانية",
"settings.uploads": "الرفع",
"settings.maxFileSize": "الحد الأقصى لحجم الملف",
"settings.maxFileSizeHint": "الحد الأقصى المسموح لحجم المرفقات (١-٥٠٠ ميجابايت)",
@@ -520,6 +607,11 @@
"issues.noIssuesInColumn": "لا توجد مشاكل",
"artefacts.details": "التفاصيل",
"artefacts.review": "المراجعة",
"artefacts.selectVersionFirst": "اختر إصداراً لعرض التعليقات.",
"artefacts.pendingReviewInfo": "هذا العنصر قيد المراجعة حالياً.",
"artefacts.noReviewInfo": "لا توجد معلومات مراجعة متاحة.",
"artefacts.rejectedMustCreateNewVersion": "تم رفض هذا العنصر. أنشئ إصداراً جديداً لمعالجة الملاحظات.",
"artefacts.revisionEditCurrentVersion": "طُلب تعديل — عدّل الإصدار الحالي وأعد إرساله للمراجعة.",
"artefacts.grid": "شبكة",
"artefacts.list": "قائمة",
"artefacts.allCreators": "جميع المنشئين",
@@ -629,7 +721,7 @@
"review.alreadyReviewed": "تمت مراجعة هذا المحتوى بالفعل.",
"review.statusLabel": "الحالة",
"review.reviewedBy": "تمت المراجعة بواسطة",
"review.poweredBy": "مدعوم بواسطة Samaya Digital Hub",
"review.poweredBy": "مدعوم بواسطة Rawaj",
"review.loadFailed": "فشل في تحميل المحتوى",
"review.actionFailed": "فشل الإجراء",
"review.actionCompleted": "تم الإجراء بنجاح",
@@ -638,6 +730,11 @@
"review.confirmReject": "هل تريد رفض هذا المحتوى؟",
"review.feedbackRequired": "يرجى تقديم ملاحظات لطلب التعديل",
"review.contentLanguages": "لغات المحتوى",
"review.redirectReview": "لست المراجع المناسب؟ أعد التوجيه لشخص آخر",
"review.redirectDesc": "اختر عضو فريق لإعادة توجيه المراجعة إليه:",
"review.selectNewReviewer": "اختر مراجعاً جديداً...",
"review.redirect": "إعادة توجيه",
"review.redirected": "تم إعادة توجيه المراجعة بنجاح",
"review.content": "المحتوى",
"review.designFiles": "ملفات التصميم",
"review.videos": "الفيديوهات",
@@ -665,6 +762,9 @@
"issues.trackingLinkCopied": "تم نسخ رابط التتبع!",
"issues.deleteAttachment": "حذف المرفق؟",
"issues.deleteAttachmentDesc": "لا يمكن التراجع عن هذا الإجراء.",
"artefacts.editLanguage": "تعديل اللغة",
"artefacts.linkedPost": "المنشور المرتبط",
"artefacts.post": "منشور",
"artefacts.deleteLanguage": "حذف هذه اللغة؟",
"artefacts.deleteLanguageDesc": "سيتم إزالة المحتوى لهذه اللغة.",
"artefacts.deleteAttachment": "حذف هذا المرفق؟",
@@ -694,6 +794,9 @@
"team.selectRole": "اختر دوراً...",
"common.team": "الفريق",
"common.noTeam": "بدون فريق",
"common.none": "بدون",
"common.untitled": "بدون عنوان",
"common.success": "تم بنجاح",
"common.error": "حدث خطأ",
"settings.roles": "الأدوار",
"settings.rolesDesc": "حدد أدوار العمل مثل مصمم، استراتيجي، إلخ. يتم تعيينها لأعضاء الفريق بشكل منفصل عن مستويات الصلاحية.",
@@ -717,6 +820,11 @@
"header.budgets": "الميزانيات",
"header.issues": "البلاغات",
"header.settings": "الإعدادات",
"header.translations": "الترجمات",
"header.copy": "النسخ",
"header.postDetails": "تفاصيل المنشور",
"calendar.unscheduledPosts": "منشورات غير مجدولة",
"calendar.statusLegend": "دليل الحالات",
"header.users": "إدارة المستخدمين",
"header.projectDetails": "تفاصيل المشروع",
"header.campaignDetails": "تفاصيل الحملة",
@@ -814,6 +922,8 @@
"artefacts.descriptionLabel": "الوصف",
"artefacts.descriptionFieldPlaceholder": "أضف وصفاً...",
"artefacts.approversLabel": "المعتمدون",
"artefacts.reviewer": "المراجع",
"artefacts.selectReviewer": "اختر مراجعاً...",
"artefacts.versions": "الإصدارات",
"artefacts.newVersion": "إصدار جديد",
"artefacts.languages": "اللغات",
@@ -822,6 +932,8 @@
"artefacts.imagesLabel": "الصور",
"artefacts.uploadImage": "رفع صورة",
"artefacts.uploading": "جاري الرفع...",
"artefacts.dropOrClickImage": "اسحب الصور هنا أو انقر للرفع",
"artefacts.imageFormats": "PNG, JPG, WebP",
"artefacts.noImages": "لم يتم رفع صور بعد",
"artefacts.videosLabel": "الفيديوهات",
"artefacts.addVideoBtn": "إضافة فيديو",
@@ -922,6 +1034,30 @@
"review.confirmRejectPostDesc": "هل أنت متأكد من رفض هذا المنشور؟ يرجى تقديم ملاحظات توضح السبب.",
"review.feedbackRequired": "الملاحظات (مطلوبة)",
"review.feedbackRequiredError": "يرجى تقديم ملاحظات عند الرفض",
"review.loadFailed": "فشل تحميل المراجعة",
"review.errorTitle": "خطأ",
"review.thankYou": "شكراً لمراجعتك!",
"review.approveSuccess": "تمت الموافقة على الترجمة بنجاح!",
"review.rejectSuccess": "تم رفض الترجمة.",
"review.revisionSuccess": "تم طلب التعديل بنجاح.",
"review.nameRequired": "يرجى إدخال اسمك",
"review.yourReview": "مراجعتك",
"review.selectYourName": "اختر اسمك",
"review.selectApprover": "اختر المراجع...",
"review.yourName": "اسمك",
"review.enterYourName": "أدخل اسمك...",
"review.feedback": "الملاحظات",
"review.feedbackPlaceholder": "شارك أفكارك أو ملاحظاتك...",
"review.approve": "موافقة",
"review.approved": "تمت الموافقة",
"review.rejected": "مرفوض",
"review.requestRevision": "طلب تعديل",
"review.reject": "رفض",
"review.statusLabel": "الحالة",
"review.reviewedBy": "تمت المراجعة بواسطة",
"review.confirmReject": "تأكيد الرفض",
"review.rejectConfirmDesc": "هل أنت متأكد من رفض هذه الترجمة؟ تأكد من تقديم الملاحظات.",
"review.feedbackRequiredForReject": "يرجى تقديم ملاحظات قبل الرفض.",
"posts.versions": "الإصدارات",
"posts.newVersion": "إصدار جديد",
"posts.createNewVersion": "إنشاء إصدار جديد",
@@ -961,7 +1097,6 @@
"translations.status": "الحالة",
"translations.languagesLabel": "اللغات",
"translations.languagesCount": "لغات",
"translations.updated": "تم التحديث",
"translations.grid": "شبكة",
"translations.list": "قائمة",
"translations.allBrands": "جميع العلامات",
@@ -991,7 +1126,7 @@
"translations.draftSaved": "تم حفظ المسودة!",
"translations.failedSaveDraft": "فشل حفظ المسودة",
"translations.saveDraft": "حفظ المسودة",
"translations.saveDraftTooltip": "حفظ التغييرات على العنوان والوصف والمحتوى الأصلي",
"translations.saveDraftTooltip": "حفظ التغييرات على العنوان والمحتوى الأصلي",
"translations.savingDraft": "جارٍ الحفظ...",
"translations.updated": "تم التحديث!",
"translations.failedUpdate": "فشل التحديث",
@@ -1021,5 +1156,76 @@
"translations.approvedByLabel": "وافق عليه",
"translations.pendingReviewInfo": "هذه الترجمة بانتظار المراجعة حاليًا.",
"translations.noReviewInfo": "لا توجد معلومات مراجعة متاحة.",
"translations.failedDelete": "فشل الحذف"
"translations.failedDelete": "فشل الحذف",
"translations.addOption": "إضافة خيار",
"translations.option": "خيار",
"translations.options": "خيارات",
"translations.optionLabel": "الخيار",
"translations.selected": "محدد",
"translations.selectThis": "اختيار",
"translations.optionSelected": "تم اختيار الخيار!",
"translations.suggestAlternative": "اقتراح بديل",
"translations.suggestForLang": "اقترح ترجمة لـ",
"translations.enterSuggestion": "أدخل الترجمة المقترحة...",
"translations.submitSuggestion": "إرسال الاقتراح",
"translations.suggestionAdded": "تمت إضافة الاقتراح!",
"translations.existing": "موجود",
"translations.copyContent": "نسخ إلى الحافظة",
"translations.copiedToClipboard": "تم النسخ!",
"translations.approvedReadOnly": "هذه الترجمة معتمدة ولا يمكن تعديلها.",
"translations.linkedPost": "المنشور المرتبط",
"translations.createPost": "منشور جديد",
"translations.newPostTitle": "عنوان المنشور...",
"translations.postCreated": "تم إنشاء المنشور!",
"translations.postCreateFailed": "فشل إنشاء المنشور",
"nav.copy": "النسخ",
"postDetail.captionCopy": "نص التسمية التوضيحية",
"postDetail.bodyCopy": "النص الرئيسي",
"postDetail.design": "التصميم",
"postDetail.video": "الفيديو",
"postDetail.readiness": "الجاهزية",
"postDetail.noAssets": "لا توجد أصول مرتبطة بعد",
"postDetail.allPiecesApproved": "جميع العناصر معتمدة",
"postDetail.waitingOn": "بانتظار",
"postDetail.notLinked": "غير مرتبط",
"postDetail.linkExisting": "ربط موجود",
"postDetail.createNew": "إنشاء جديد",
"postDetail.open": "فتح",
"postDetail.unlink": "إلغاء الربط",
"postDetail.viewDetails": "عرض التفاصيل",
"postDetail.reviewer": "المراجع",
"postDetail.selectReviewer": "اختر المراجع",
"postDetail.submitForReview": "إرسال للمراجعة",
"postDetail.pendingReviewBy": "بانتظار مراجعة",
"postDetail.approved": "تمت الموافقة",
"postDetail.sourceLanguage": "اللغة المصدر",
"postDetail.content": "المحتوى",
"postDetail.contentPlaceholder": "اكتب النص...",
"postDetail.files": "الملفات",
"postDetail.dragDropFiles": "اسحب وأفلت أو انقر للرفع",
"postDetail.addMoreFiles": "إضافة ملفات أخرى",
"postDetail.createAndSubmit": "إنشاء وإرسال للمراجعة",
"postDetail.create": "إنشاء",
"finance.campaign": "الحملة",
"finance.budgetAssigned": "الميزانية المخصصة",
"finance.trackAllocated": "المسار المخصص",
"finance.spent": "المنفق",
"finance.roi": "العائد",
"finance.workOrder": "أمر العمل",
"finance.budgetAllocated": "الميزانية المخصصة",
"finance.of": "من",
"finance.campaignCount": "{{count}} حملات · توزيع ميزانية على مستوى المسار",
"finance.workOrderCount": "{{count}} أوامر عمل بميزانية مخصصة",
"calendar.sun": "أحد",
"calendar.mon": "إثن",
"calendar.tue": "ثلا",
"calendar.wed": "أرب",
"calendar.thu": "خمي",
"calendar.fri": "جمع",
"calendar.sat": "سبت",
"calendar.month": "شهر",
"calendar.week": "أسبوع",
"calendar.today": "اليوم"
}
+216 -10
View File
@@ -1,6 +1,6 @@
{
"app.name": "Digital Hub",
"app.subtitle": "Platform",
"app.name": "Rawaj",
"app.subtitle": "Marketing Hub",
"nav.dashboard": "Dashboard",
"nav.campaigns": "Campaigns",
"nav.finance": "Finance & ROI",
@@ -31,6 +31,7 @@
"common.loading": "Loading...",
"common.unassigned": "Unassigned",
"common.close": "Close",
"common.created": "Created",
"common.required": "Required",
"common.saveFailed": "Failed to save. Please try again.",
"common.updateFailed": "Failed to update. Please try again.",
@@ -70,7 +71,7 @@
"dashboard.noPostsYet": "No posts yet. Create your first post!",
"dashboard.upcomingDeadlines": "Upcoming Deadlines",
"dashboard.noUpcomingDeadlines": "No upcoming deadlines this week. 🎉",
"dashboard.loadingHub": "Loading Digital Hub...",
"dashboard.loadingHub": "Loading Rawaj...",
"posts.title": "Post Production",
"posts.newPost": "New Post",
"posts.editPost": "Edit Post",
@@ -78,6 +79,29 @@
"posts.saveChanges": "Save Changes",
"posts.postTitle": "Title",
"posts.description": "Description",
"post.caption": "Caption",
"post.captionPlaceholder": "Write your social media caption...",
"post.copy": "Copy (In-Design Text)",
"post.designs": "Designs",
"post.video": "Video",
"post.formatChecklist": "Format Checklist",
"post.formatsNeeded": "Formats needed based on selected platforms",
"post.selectPlatforms": "Select platforms to see required formats",
"post.readiness": "Readiness",
"post.allPiecesReady": "All pieces ready — awaiting sign-off",
"post.waitingOn": "Waiting on",
"post.signOff": "Approve & Schedule",
"post.signOffConfirm": "Mark this post as approved and ready for scheduling?",
"common.confirm": "Confirm",
"post.linkExisting": "Link existing",
"post.createNew": "Create new",
"post.addDesign": "Add Design",
"post.addVideo": "Add Video",
"post.linkTranslation": "Link Translation",
"post.selectLanguage": "Language...",
"post.noCopyLinked": "No copy linked yet",
"post.noDesignsLinked": "No designs linked yet",
"post.noVideoLinked": "No video linked yet",
"posts.brand": "Brand",
"posts.platforms": "Platforms",
"posts.status": "Status",
@@ -271,7 +295,7 @@
"settings.english": "English",
"settings.arabic": "Arabic",
"settings.restartTutorial": "Restart Tutorial",
"settings.tutorialDesc": "Need a refresher? Restart the interactive tutorial to learn about all the features of Digital Hub.",
"settings.tutorialDesc": "Need a refresher? Restart the interactive tutorial to learn about all the features of Rawaj.",
"settings.general": "General",
"settings.onboardingTutorial": "Onboarding Tutorial",
"settings.tutorialRestarted": "Tutorial Restarted!",
@@ -315,7 +339,7 @@
"tutorial.newPost.desc": "Start creating content here. Pick your brand, platforms, and assign it to a team member.",
"tutorial.filters.title": "Filter & Focus",
"tutorial.filters.desc": "Use filters to focus on specific brands, platforms, or team members.",
"login.title": "Digital Hub",
"login.title": "Rawaj",
"login.subtitle": "Sign in to continue",
"login.forgotPassword": "Forgot password?",
"login.defaultCreds": "Default credentials:",
@@ -396,6 +420,16 @@
"campaigns.editCampaign": "Edit Campaign",
"campaigns.deleteCampaign": "Delete Campaign?",
"campaigns.deleteConfirm": "Are you sure you want to delete this campaign? All associated data will be removed. This action cannot be undone.",
"campaigns.tracks": "Tracks",
"campaigns.addTrack": "Add Track",
"campaigns.noTracks": "No tracks yet. Add organic, paid, or SEO tracks to organize this campaign.",
"campaigns.postsLinked": "posts linked",
"campaigns.team": "Team",
"campaigns.assignMembers": "Assign Members",
"campaigns.linkedPosts": "Linked Posts",
"campaigns.notFound": "Campaign not found.",
"common.goBack": "Go back",
"finance.allocated": "allocated",
"tracks.details": "Details",
"tracks.metrics": "Metrics",
"tracks.trackName": "Track Name",
@@ -503,6 +537,59 @@
"budgets.dateExpensed": "Date",
"dashboard.expenses": "Expenses",
"finance.expenses": "Total Expenses",
"finance.totalReceived": "Total Received",
"finance.totalSpent": "Total Spent",
"finance.remaining": "Remaining",
"finance.revenue": "Revenue",
"finance.globalROI": "Global ROI",
"finance.budgetAllocation": "Budget Allocation",
"finance.manageBudgets": "Manage Budgets",
"finance.campaigns": "Campaigns",
"finance.projects": "Projects",
"finance.unallocated": "Unallocated",
"finance.budgetUtilization": "Budget Utilization",
"finance.globalPerformance": "Global Performance",
"finance.impressions": "Impressions",
"finance.clicks": "Clicks",
"finance.conversions": "Conversions",
"finance.campaignBreakdown": "Campaign Breakdown",
"finance.allocatedFunds": "Allocated Funds",
"finance.requestBudget": "Request Budget",
"finance.budgetRequests": "Budget Requests",
"finance.pendingApproval": "pending CEO approval",
"finance.justification": "Justification",
"finance.earmarkFor": "Earmark for",
"finance.submitRequest": "Submit Request",
"finance.cancelRequest": "Cancel Request",
"finance.approved": "Approved",
"finance.rejected": "Rejected",
"finance.cancelled": "Cancelled",
"finance.pending": "Pending",
"finance.ceoNote": "CEO Note",
"finance.requestPending": "budget request(s) pending CEO approval",
"finance.insufficientBudget": "Insufficient budget",
"finance.availableBudget": "Available",
"finance.requestMore": "Request more funds",
"finance.noCeoEmail": "CEO email not configured. Go to Settings.",
"finance.amount": "Amount",
"finance.justificationPlaceholder": "Why is this budget needed?",
"finance.optional": "Optional",
"settings.budgetApproval": "Budget Approval",
"settings.ceoEmail": "CEO / Budget Approver Email",
"settings.ceoEmailHint": "Email address that receives budget approval requests",
"budgetApproval.title": "Budget Approval",
"budgetApproval.amount": "Requested Amount",
"budgetApproval.requestedBy": "Requested by",
"budgetApproval.justification": "Justification",
"budgetApproval.earmarkedFor": "Earmarked for",
"budgetApproval.approve": "Approve",
"budgetApproval.reject": "Reject",
"budgetApproval.addNote": "Add a note (optional)",
"budgetApproval.approved": "This request has been approved.",
"budgetApproval.rejected": "This request has been rejected.",
"budgetApproval.expired": "This request has expired.",
"budgetApproval.alreadyHandled": "This request has already been processed.",
"finance.ofBudget": "of budget",
"settings.uploads": "Uploads",
"settings.maxFileSize": "Maximum File Size",
"settings.maxFileSizeHint": "Maximum allowed file size for attachments (1-500 MB)",
@@ -520,6 +607,11 @@
"issues.noIssuesInColumn": "No issues",
"artefacts.details": "Details",
"artefacts.review": "Review",
"artefacts.selectVersionFirst": "Select a version to view comments.",
"artefacts.pendingReviewInfo": "This artefact is currently pending review.",
"artefacts.noReviewInfo": "No review information available.",
"artefacts.rejectedMustCreateNewVersion": "This artefact was rejected. Create a new version to address the feedback.",
"artefacts.revisionEditCurrentVersion": "Revision requested — edit the current version and resubmit for review.",
"artefacts.grid": "Grid",
"artefacts.list": "List",
"artefacts.allCreators": "All Creators",
@@ -629,7 +721,7 @@
"review.alreadyReviewed": "This artefact has already been reviewed.",
"review.statusLabel": "Status",
"review.reviewedBy": "Reviewed by",
"review.poweredBy": "Powered by Samaya Digital Hub",
"review.poweredBy": "Powered by Rawaj",
"review.loadFailed": "Failed to load artefact",
"review.actionFailed": "Action failed",
"review.actionCompleted": "Action completed successfully",
@@ -638,6 +730,11 @@
"review.confirmReject": "Reject this artefact?",
"review.feedbackRequired": "Please provide feedback for revision request",
"review.contentLanguages": "Content Languages",
"review.redirectReview": "Not the right reviewer? Redirect to someone else",
"review.redirectDesc": "Select a team member to redirect this review to:",
"review.selectNewReviewer": "Select new reviewer...",
"review.redirect": "Redirect",
"review.redirected": "Review redirected successfully",
"review.content": "Content",
"review.designFiles": "Design Files",
"review.videos": "Videos",
@@ -665,6 +762,9 @@
"issues.trackingLinkCopied": "Tracking link copied to clipboard!",
"issues.deleteAttachment": "Delete attachment?",
"issues.deleteAttachmentDesc": "This action cannot be undone.",
"artefacts.editLanguage": "Edit Language",
"artefacts.linkedPost": "Linked Post",
"artefacts.post": "Post",
"artefacts.deleteLanguage": "Delete this language?",
"artefacts.deleteLanguageDesc": "The content for this language will be removed.",
"artefacts.deleteAttachment": "Delete this attachment?",
@@ -694,6 +794,9 @@
"team.selectRole": "Select role...",
"common.team": "Team",
"common.noTeam": "No team",
"common.none": "None",
"common.untitled": "Untitled",
"common.success": "Success",
"common.error": "An error occurred",
"settings.roles": "Roles",
"settings.rolesDesc": "Define job roles like Designer, Strategist, etc. These are assigned to team members separately from permission levels.",
@@ -717,6 +820,11 @@
"header.budgets": "Budgets",
"header.issues": "Issues",
"header.settings": "Settings",
"header.translations": "Translations",
"header.copy": "Copy",
"header.postDetails": "Post Details",
"calendar.unscheduledPosts": "Unscheduled Posts",
"calendar.statusLegend": "Status Legend",
"header.users": "User Management",
"header.projectDetails": "Project Details",
"header.campaignDetails": "Campaign Details",
@@ -814,6 +922,8 @@
"artefacts.descriptionLabel": "Description",
"artefacts.descriptionFieldPlaceholder": "Add a description...",
"artefacts.approversLabel": "Approvers",
"artefacts.reviewer": "Reviewer",
"artefacts.selectReviewer": "Select a reviewer...",
"artefacts.versions": "Versions",
"artefacts.newVersion": "New Version",
"artefacts.languages": "Languages",
@@ -822,6 +932,8 @@
"artefacts.imagesLabel": "Images",
"artefacts.uploadImage": "Upload Image",
"artefacts.uploading": "Uploading...",
"artefacts.dropOrClickImage": "Drop images here or click to upload",
"artefacts.imageFormats": "PNG, JPG, WebP",
"artefacts.noImages": "No images uploaded yet",
"artefacts.videosLabel": "Videos",
"artefacts.addVideoBtn": "Add Video",
@@ -922,6 +1034,30 @@
"review.confirmRejectPostDesc": "Are you sure you want to reject this post? Please provide feedback explaining why.",
"review.feedbackRequired": "Feedback (required)",
"review.feedbackRequiredError": "Please provide feedback when rejecting",
"review.loadFailed": "Failed to load review",
"review.errorTitle": "Error",
"review.thankYou": "Thank you for your review!",
"review.approveSuccess": "Translation approved successfully!",
"review.rejectSuccess": "Translation has been rejected.",
"review.revisionSuccess": "Revision requested successfully.",
"review.nameRequired": "Please provide your name",
"review.yourReview": "Your Review",
"review.selectYourName": "Select your name",
"review.selectApprover": "Select approver...",
"review.yourName": "Your Name",
"review.enterYourName": "Enter your name...",
"review.feedback": "Feedback",
"review.feedbackPlaceholder": "Share your thoughts or feedback...",
"review.approve": "Approve",
"review.approved": "Approved",
"review.rejected": "Rejected",
"review.requestRevision": "Request Revision",
"review.reject": "Reject",
"review.statusLabel": "Status",
"review.reviewedBy": "Reviewed by",
"review.confirmReject": "Confirm Rejection",
"review.rejectConfirmDesc": "Are you sure you want to reject this translation? Please make sure you have provided feedback.",
"review.feedbackRequiredForReject": "Please provide feedback before rejecting.",
"posts.versions": "Versions",
"posts.newVersion": "New Version",
"posts.createNewVersion": "Create New Version",
@@ -961,7 +1097,6 @@
"translations.status": "Status",
"translations.languagesLabel": "Languages",
"translations.languagesCount": "languages",
"translations.updated": "Updated",
"translations.grid": "Grid",
"translations.list": "List",
"translations.allBrands": "All Brands",
@@ -991,9 +1126,9 @@
"translations.draftSaved": "Draft saved!",
"translations.failedSaveDraft": "Failed to save draft",
"translations.saveDraft": "Save Draft",
"translations.saveDraftTooltip": "Save changes to title, description, and source content",
"translations.saveDraftTooltip": "Save changes to title and source content",
"translations.savingDraft": "Saving...",
"translations.updated": "Updated!",
"translations.updated": "Updated",
"translations.failedUpdate": "Failed to update",
"translations.addTranslation": "Add Translation",
"translations.translationAdded": "Translation added!",
@@ -1021,5 +1156,76 @@
"translations.approvedByLabel": "Approved by",
"translations.pendingReviewInfo": "This translation is currently pending review.",
"translations.noReviewInfo": "No review information available.",
"translations.failedDelete": "Failed to delete"
"translations.failedDelete": "Failed to delete",
"translations.addOption": "Add Option",
"translations.option": "option",
"translations.options": "options",
"translations.optionLabel": "Option",
"translations.selected": "Selected",
"translations.selectThis": "Select",
"translations.optionSelected": "Option selected!",
"translations.suggestAlternative": "Suggest alternative",
"translations.suggestForLang": "Suggest a translation for",
"translations.enterSuggestion": "Enter your suggested translation...",
"translations.submitSuggestion": "Submit Suggestion",
"translations.suggestionAdded": "Suggestion added!",
"translations.existing": "existing",
"translations.copyContent": "Copy to clipboard",
"translations.copiedToClipboard": "Copied to clipboard!",
"translations.approvedReadOnly": "This translation is approved and cannot be modified.",
"translations.linkedPost": "Linked Post",
"translations.createPost": "New Post",
"translations.newPostTitle": "Post title...",
"translations.postCreated": "Post created!",
"translations.postCreateFailed": "Failed to create post",
"nav.copy": "Copy",
"postDetail.captionCopy": "Caption Copy",
"postDetail.bodyCopy": "Body Copy",
"postDetail.design": "Design",
"postDetail.video": "Video",
"postDetail.readiness": "Readiness",
"postDetail.noAssets": "No assets linked yet",
"postDetail.allPiecesApproved": "All pieces approved",
"postDetail.waitingOn": "Waiting on",
"postDetail.notLinked": "Not linked",
"postDetail.linkExisting": "Link existing",
"postDetail.createNew": "Create new",
"postDetail.open": "Open",
"postDetail.unlink": "Unlink",
"postDetail.viewDetails": "View details",
"postDetail.reviewer": "Reviewer",
"postDetail.selectReviewer": "Select reviewer",
"postDetail.submitForReview": "Submit for Review",
"postDetail.pendingReviewBy": "Pending review by",
"postDetail.approved": "Approved",
"postDetail.sourceLanguage": "Source Language",
"postDetail.content": "Content",
"postDetail.contentPlaceholder": "Write the copy text...",
"postDetail.files": "Files",
"postDetail.dragDropFiles": "Drag & drop or click to upload",
"postDetail.addMoreFiles": "Add more files",
"postDetail.createAndSubmit": "Create & Submit for Review",
"postDetail.create": "Create",
"finance.campaign": "Campaign",
"finance.budgetAssigned": "Budget Assigned",
"finance.trackAllocated": "Track Allocated",
"finance.spent": "Spent",
"finance.roi": "ROI",
"finance.workOrder": "Work Order",
"finance.budgetAllocated": "Budget Allocated",
"finance.of": "of",
"finance.campaignCount": "{{count}} campaigns · Track-level budget allocation",
"finance.workOrderCount": "{{count}} work orders with assigned budget",
"calendar.sun": "Sun",
"calendar.mon": "Mon",
"calendar.tue": "Tue",
"calendar.wed": "Wed",
"calendar.thu": "Thu",
"calendar.fri": "Fri",
"calendar.sat": "Sat",
"calendar.month": "Month",
"calendar.week": "Week",
"calendar.today": "Today"
}
+91 -124
View File
@@ -1,16 +1,16 @@
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+Arabic:wght@300;400;500;600;700&family=Inter:wght@300;400;500;600;700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500;600;700&family=IBM+Plex+Sans+Arabic:wght@300;400;500;600;700&display=swap');
@import "tailwindcss";
@theme {
--font-sans: 'Inter', 'IBM Plex Sans Arabic', system-ui, -apple-system, sans-serif;
--color-sidebar: #0f172a;
--color-sidebar-hover: #1e293b;
--color-sidebar-active: #020617;
--color-brand-primary: #4f46e5;
--color-brand-primary-light: #6366f1;
--font-sans: 'DM Sans', 'IBM Plex Sans Arabic', system-ui, -apple-system, sans-serif;
--color-sidebar: #0a1f1c;
--color-sidebar-hover: #123b35;
--color-sidebar-active: #061411;
--color-brand-primary: #0d9488;
--color-brand-primary-light: #14b8a6;
--color-brand-secondary: #db2777;
--color-brand-tertiary: #f59e0b;
--color-brand-quaternary: #059669;
--color-brand-quaternary: #0d9488;
--color-surface: #ffffff;
--color-surface-secondary: #f9fafb;
--color-surface-tertiary: #f3f4f6;
@@ -37,40 +37,39 @@
}
/* ═══════════════════════════════════════════════
DARK MODE — Inspired by SpaceTime
Deep layered surfaces, glass edges, ambient glow
DARK MODE — Forest teal tinted surfaces
═══════════════════════════════════════════════ */
.dark {
/* Layered depth: void → surface → surface-2surface-3 */
--color-surface: #15151e;
--color-surface-secondary: #1c1c2a;
--color-surface-tertiary: #24243a;
/* Layered depth: deep forest → surface → elevated */
--color-surface: #0f1a18;
--color-surface-secondary: #162220;
--color-surface-tertiary: #1e2e2b;
--color-border: rgba(255, 255, 255, 0.08);
--color-border-light: rgba(255, 255, 255, 0.04);
/* Text — crisp hierarchy */
--color-text-primary: #eeecf5;
--color-text-secondary: #a8a3c0;
--color-text-tertiary: #706b8a;
/* Text — warm neutrals, teal-tinted */
--color-text-primary: #e8f0ee;
--color-text-secondary: #9db5b0;
--color-text-tertiary: #637e78;
/* Sidebar */
--color-sidebar: #0e0e16;
--color-sidebar-hover: #15151e;
--color-sidebar-active: #0a0a12;
--color-sidebar: #0a1412;
--color-sidebar-hover: #0f1a18;
--color-sidebar-active: #060e0c;
/* Brand — brighter on dark */
--color-brand-primary: #8b5cf6;
--color-brand-primary-light: #a78bfa;
--color-brand-primary: #14b8a6;
--color-brand-primary-light: #2dd4bf;
color-scheme: dark;
background-color: #15151e;
color: #eeecf5;
background-color: #0f1a18;
color: #e8f0ee;
}
/* ─── Ambient background glow ────────────────── */
.dark .bg-mesh {
background-color: #15151e !important;
background-color: #0f1a18 !important;
background-image: none !important;
}
.dark .bg-mesh::before {
@@ -78,9 +77,8 @@
position: fixed;
inset: 0;
background:
radial-gradient(ellipse 70% 50% at 20% 50%, rgba(139, 92, 246, 0.045) 0%, transparent 60%),
radial-gradient(ellipse 50% 40% at 80% 30%, rgba(56, 189, 248, 0.03) 0%, transparent 60%),
radial-gradient(ellipse 60% 40% at 50% 90%, rgba(232, 168, 56, 0.02) 0%, transparent 60%);
radial-gradient(ellipse 70% 50% at 20% 50%, rgba(13, 148, 136, 0.04) 0%, transparent 60%),
radial-gradient(ellipse 50% 40% at 80% 30%, rgba(20, 184, 166, 0.025) 0%, transparent 60%);
pointer-events: none;
z-index: 0;
}
@@ -89,11 +87,11 @@
.dark .bg-white,
.dark .bg-\[\#fff\],
.dark .bg-\[\#ffffff\] {
background-color: #22223a !important;
background-color: #1a2a28 !important;
}
.dark .bg-gray-50 { background-color: #15151e !important; }
.dark .bg-gray-100 { background-color: #1c1c2a !important; }
.dark .bg-gray-200 { background-color: #24243a !important; }
.dark .bg-gray-50 { background-color: #0f1a18 !important; }
.dark .bg-gray-100 { background-color: #162220 !important; }
.dark .bg-gray-200 { background-color: #1e2e2b !important; }
/* ─── Borders ────────────────────────────────── */
.dark .border-gray-100,
@@ -104,12 +102,12 @@
.dark .divide-border-light > :not(:first-child) { border-color: rgba(255, 255, 255, 0.05) !important; }
/* ─── Text ───────────────────────────────────── */
.dark .text-gray-900 { color: #eeecf5 !important; }
.dark .text-gray-800 { color: #d8d5e8 !important; }
.dark .text-gray-700 { color: #c2bedb !important; }
.dark .text-gray-600 { color: #a8a3c0 !important; }
.dark .text-gray-500 { color: #8b85a8 !important; }
.dark .text-gray-400 { color: #706b8a !important; }
.dark .text-gray-900 { color: #e8f0ee !important; }
.dark .text-gray-800 { color: #d0ddd9 !important; }
.dark .text-gray-700 { color: #b5cac5 !important; }
.dark .text-gray-600 { color: #9db5b0 !important; }
.dark .text-gray-500 { color: #7e9a94 !important; }
.dark .text-gray-400 { color: #637e78 !important; }
/* ─── Status badges — translucent glass ──────── */
.dark .bg-emerald-100, .dark .bg-emerald-50 { background-color: rgba(74, 222, 128, 0.12) !important; }
@@ -150,49 +148,49 @@
.dark input:focus,
.dark select:focus,
.dark textarea:focus {
border-color: rgba(139, 92, 246, 0.5);
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.1);
border-color: rgba(20, 184, 166, 0.5);
box-shadow: 0 0 0 3px rgba(20, 184, 166, 0.1);
}
.dark input::placeholder,
.dark textarea::placeholder {
color: #706b8a;
color: #637e78;
}
.dark input:disabled,
.dark select:disabled,
.dark textarea:disabled {
background-color: rgba(255, 255, 255, 0.02) !important;
color: #706b8a !important;
color: #637e78 !important;
opacity: 0.6;
}
/* Dark select arrow */
.dark select {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23706b8a' d='M6 9L1 4h10z'/%3E%3C/svg%3E");
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23637e78' d='M6 9L1 4h10z'/%3E%3C/svg%3E");
}
/* ─── Cards — glass edges ────────────────────── */
.dark .card-hover {
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.04), 0 2px 8px rgba(0, 0, 0, 0.3);
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.04);
}
.dark .card-hover:hover {
box-shadow: 0 0 0 1px rgba(139, 92, 246, 0.15), 0 16px 48px -12px rgba(0, 0, 0, 0.5);
box-shadow: 0 0 0 1px rgba(20, 184, 166, 0.12), 0 4px 16px -4px rgba(0, 0, 0, 0.4);
}
.dark .section-card {
background: #1c1c2a;
background: #162220;
border-color: rgba(255, 255, 255, 0.06);
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.04);
}
.dark .section-card:hover {
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.06), 0 8px 32px -8px rgba(0, 0, 0, 0.4);
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.06), 0 4px 16px -4px rgba(0, 0, 0, 0.3);
}
.dark .section-card-header {
background: linear-gradient(180deg, rgba(36, 36, 58, 0.5) 0%, #1c1c2a 100%);
background: rgba(30, 46, 43, 0.3);
}
/* ─── Sidebar ────────────────────────────────── */
.dark .sidebar {
background: linear-gradient(180deg, #0e0e16 0%, #0a0a12 100%);
background: linear-gradient(180deg, #0a1412 0%, #060e0c 100%);
box-shadow: 2px 0 24px rgba(0, 0, 0, 0.5);
}
@@ -216,22 +214,22 @@
.dark .hover\:bg-red-50:hover { background-color: rgba(251, 113, 133, 0.08) !important; }
.dark .hover\:bg-blue-100:hover { background-color: rgba(96, 165, 250, 0.08) !important; }
/* ─── Brand glow ─────────────────────────────── */
/* ─── Brand accent ────────────────────────────── */
.dark .bg-brand-primary {
box-shadow: 0 0 24px -4px rgba(139, 92, 246, 0.35);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
.dark .bg-brand-primary:hover {
box-shadow: 0 0 32px -4px rgba(139, 92, 246, 0.45);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4);
}
/* ─── White/light text overrides on colored badges ── */
.dark .bg-white\/90 { background-color: rgba(28, 28, 42, 0.9) !important; }
.dark .bg-white\/90 { background-color: rgba(22, 34, 32, 0.9) !important; }
/* ─── Toasts — solid backgrounds, no transparency ── */
.dark .bg-emerald-50.border-emerald-200 { background-color: #132a1e !important; border-color: #1a4a2e !important; }
.dark .bg-red-50.border-red-200 { background-color: #2a1318 !important; border-color: #4a1a22 !important; }
.dark .bg-blue-50.border-blue-200 { background-color: #131d2a !important; border-color: #1a2e4a !important; }
.dark .bg-amber-50.border-amber-200 { background-color: #2a2213 !important; border-color: #4a3a1a !important; }
/* ─── Toasts — solid backgrounds ────────────────── */
.dark .bg-emerald-50.border-emerald-200 { background-color: #0f2a1e !important; border-color: #154a2e !important; }
.dark .bg-red-50.border-red-200 { background-color: #2a1315 !important; border-color: #4a1a20 !important; }
.dark .bg-blue-50.border-blue-200 { background-color: #0f1d2a !important; border-color: #152e4a !important; }
.dark .bg-amber-50.border-amber-200 { background-color: #2a2210 !important; border-color: #4a3a15 !important; }
.dark .text-emerald-800 { color: #6ee7b7 !important; }
.dark .text-red-800 { color: #fca5a5 !important; }
.dark .text-blue-800 { color: #93c5fd !important; }
@@ -239,10 +237,19 @@
/* ─── Selection ──────────────────────────────── */
.dark ::selection {
background: rgba(139, 92, 246, 0.4);
background: rgba(20, 184, 166, 0.4);
color: white;
}
/* Reduced motion — disable animations for accessibility */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 6px;
@@ -315,15 +322,15 @@ textarea {
margin-right: 0;
}
/* Enhanced sidebar with gradient */
/* Enhanced sidebar */
.sidebar {
background: linear-gradient(180deg, #0f172a 0%, #020617 100%);
background: linear-gradient(180deg, #0a1f1c 0%, #061411 100%);
box-shadow: 2px 0 12px rgba(0, 0, 0, 0.08);
}
/* Animation keyframes */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
@@ -347,11 +354,6 @@ textarea {
50% { opacity: 0.7; }
}
@keyframes bounce-subtle {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-4px); }
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
@@ -425,29 +427,24 @@ textarea {
overflow: visible;
}
/* Stagger children */
/* Stagger children — short, max 4 items */
.stagger-children > * {
opacity: 0;
animation: fadeIn 0.3s ease-out forwards;
animation: fadeIn 0.2s ease-out forwards;
}
.stagger-children > *:nth-child(1) { animation-delay: 0ms; }
.stagger-children > *:nth-child(2) { animation-delay: 50ms; }
.stagger-children > *:nth-child(3) { animation-delay: 100ms; }
.stagger-children > *:nth-child(4) { animation-delay: 150ms; }
.stagger-children > *:nth-child(5) { animation-delay: 200ms; }
.stagger-children > *:nth-child(6) { animation-delay: 250ms; }
.stagger-children > *:nth-child(7) { animation-delay: 300ms; }
.stagger-children > *:nth-child(8) { animation-delay: 350ms; }
.stagger-children > *:nth-child(2) { animation-delay: 40ms; }
.stagger-children > *:nth-child(3) { animation-delay: 80ms; }
.stagger-children > *:nth-child(n+4) { animation-delay: 120ms; }
/* Card hover effect - smooth and elegant */
/* Card hover effect - refined, no lift */
.card-hover {
position: relative;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05), 0 1px 2px rgba(0, 0, 0, 0.06);
transition: box-shadow 0.2s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
}
.card-hover:hover {
transform: translateY(-3px);
box-shadow: 0 12px 28px -6px rgba(0, 0, 0, 0.12), 0 6px 16px -8px rgba(0, 0, 0, 0.08);
box-shadow: 0 4px 12px -2px rgba(0, 0, 0, 0.08);
}
/* Stat card accents - subtle colored top borders */
@@ -470,24 +467,12 @@ textarea {
opacity: 1;
}
/* Mesh background - subtle radial gradients */
/* Mesh background — flat, no gradients */
.bg-mesh {
background-color: #f8fafc;
background-image:
radial-gradient(at 20% 20%, rgba(79, 70, 229, 0.04) 0, transparent 50%),
radial-gradient(at 80% 40%, rgba(219, 39, 119, 0.03) 0, transparent 50%),
radial-gradient(at 40% 80%, rgba(5, 150, 105, 0.03) 0, transparent 50%);
}
/* Gradient text */
.text-gradient {
background: linear-gradient(135deg, var(--color-brand-primary) 0%, #7c3aed 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Premium stat card - always-visible gradient top bar */
/* Stat card accent — subtle top border, no gradient */
.stat-card-premium {
position: relative;
overflow: hidden;
@@ -498,20 +483,20 @@ textarea {
top: 0;
left: 0;
right: 0;
height: 3px;
opacity: 1;
height: 2px;
opacity: 0.6;
}
.stat-card-premium.accent-primary::before {
background: linear-gradient(90deg, #4f46e5, #7c3aed);
background: #0d9488;
}
.stat-card-premium.accent-secondary::before {
background: linear-gradient(90deg, #db2777, #ec4899);
background: #db2777;
}
.stat-card-premium.accent-tertiary::before {
background: linear-gradient(90deg, #f59e0b, #fbbf24);
background: #f59e0b;
}
.stat-card-premium.accent-quaternary::before {
background: linear-gradient(90deg, #059669, #34d399);
background: #059669;
}
/* Section card - premium container */
@@ -519,25 +504,24 @@ textarea {
background: white;
border: 1px solid var(--color-border);
border-radius: 1rem;
overflow: hidden;
overflow: clip;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
transition: box-shadow 0.3s ease;
}
.section-card:hover {
box-shadow: 0 4px 20px -4px rgba(0, 0, 0, 0.08);
box-shadow: 0 2px 8px -2px rgba(0, 0, 0, 0.06);
}
.section-card-header {
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--color-border);
background: linear-gradient(180deg, rgba(249, 250, 251, 0.5) 0%, white 100%);
}
/* Sidebar active glow */
.sidebar-active-glow {
box-shadow: inset 3px 0 0 rgba(129, 140, 248, 0.8);
box-shadow: inset 3px 0 0 rgba(20, 184, 166, 0.8);
}
[dir="rtl"] .sidebar-active-glow {
box-shadow: inset -3px 0 0 rgba(129, 140, 248, 0.8);
box-shadow: inset -3px 0 0 rgba(20, 184, 166, 0.8);
}
/* Refined button styles */
@@ -594,23 +578,6 @@ select:not(:disabled):hover {
grid-template-columns: repeat(7, 1fr);
}
/* Ripple effect on buttons (optional enhancement) */
@keyframes ripple {
0% {
transform: scale(0);
opacity: 0.5;
}
100% {
transform: scale(2.5);
opacity: 0;
}
}
/* Badge pulse animation */
.badge-pulse {
animation: pulse-subtle 2s ease-in-out infinite;
}
/* Smooth height transitions */
.transition-height {
transition: height 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+28 -85
View File
@@ -11,7 +11,7 @@ import { useToast } from '../components/ToastContainer'
import ArtefactVersionTimeline from '../components/ArtefactVersionTimeline'
import { SkeletonCard, SkeletonTable } from '../components/SkeletonLoader'
import ArtefactDetailPanel from '../components/ArtefactDetailPanel'
import ApproverMultiSelect from '../components/ApproverMultiSelect'
import PortalSelect from '../components/PortalSelect'
const STATUS_COLORS = {
draft: 'bg-surface-tertiary text-text-secondary',
@@ -56,7 +56,7 @@ export default function Artefacts() {
const [searchTerm, setSearchTerm] = useState('')
const [showCreateModal, setShowCreateModal] = useState(false)
const [selectedArtefact, setSelectedArtefact] = useState(null)
const [newArtefact, setNewArtefact] = useState({ title: '', description: '', type: 'copy', brand_id: '', content: '', project_id: '', campaign_id: '', approver_ids: [] })
const [newArtefact, setNewArtefact] = useState({ title: '', type: 'copy' })
const [saving, setSaving] = useState(false)
// Bulk select
@@ -101,12 +101,12 @@ export default function Artefacts() {
setSaving(true)
try {
const created = await api.post('/artefacts', {
...newArtefact,
approver_ids: newArtefact.approver_ids.length > 0 ? newArtefact.approver_ids.join(',') : null,
title: newArtefact.title,
type: newArtefact.type,
})
toast.success(t('artefacts.created'))
setShowCreateModal(false)
setNewArtefact({ title: '', description: '', type: 'copy', brand_id: '', content: '', project_id: '', campaign_id: '', approver_ids: [] })
setNewArtefact({ title: '', type: 'copy' })
loadArtefacts()
setSelectedArtefact(created)
} catch (err) {
@@ -199,8 +199,8 @@ export default function Artefacts() {
const SortIcon = ({ col }) => {
if (listSortBy !== col) return null
return listSortDir === 'asc'
? <ChevronUp className="w-3 h-3 inline ml-0.5" />
: <ChevronDown className="w-3 h-3 inline ml-0.5" />
? <ChevronUp className="w-3 h-3 inline ms-0.5" />
: <ChevronDown className="w-3 h-3 inline ms-0.5" />
}
const formatDate = (dateStr) => {
@@ -211,11 +211,7 @@ export default function Artefacts() {
return (
<div className="space-y-4 animate-fade-in">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-text-primary">{t('artefacts.title')}</h1>
<p className="text-sm text-text-secondary mt-1">{t('artefacts.subtitle')}</p>
</div>
<div className="flex items-center justify-end">
<div className="flex items-center gap-3">
{/* View switcher */}
<div className="flex items-center bg-surface-tertiary rounded-lg p-0.5">
@@ -228,7 +224,7 @@ export default function Artefacts() {
onClick={() => setViewMode(mode)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
viewMode === mode
? 'bg-white text-text-primary shadow-sm'
? 'bg-surface text-text-primary shadow-sm'
: 'text-text-tertiary hover:text-text-secondary'
}`}
>
@@ -251,13 +247,13 @@ export default function Artefacts() {
{/* Filters */}
<div className="flex flex-wrap items-center gap-3">
<div className="relative flex-1 min-w-[200px] max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<Search className="absolute start-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<input
type="text"
placeholder={t('artefacts.searchArtefacts')}
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface transition-colors"
className="w-full ps-10 pe-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface transition-colors"
/>
</div>
@@ -351,7 +347,7 @@ export default function Artefacts() {
<button
key={artefact.Id}
onClick={() => setSelectedArtefact(artefact)}
className="bg-surface border border-border rounded-xl p-4 hover:border-brand-primary/30 hover:shadow-sm transition-colors text-left"
className="bg-surface border border-border rounded-xl p-4 hover:border-brand-primary/30 hover:shadow-sm transition-colors text-start"
>
<div className="flex items-start gap-3 mb-2">
<div className="w-10 h-10 rounded-lg bg-brand-primary/10 flex items-center justify-center shrink-0">
@@ -418,22 +414,22 @@ export default function Artefacts() {
<th className="w-10 px-3 py-3" onClick={e => e.stopPropagation()}>
<input type="checkbox" checked={selectedIds.size === sortedArtefacts.length && sortedArtefacts.length > 0} onChange={toggleSelectAll} className="rounded border-border" />
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleListSort('title')}>
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleListSort('title')}>
{t('artefacts.titleLabel')} <SortIcon col="title" />
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleListSort('type')}>
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleListSort('type')}>
{t('artefacts.type')} <SortIcon col="type" />
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleListSort('status')}>
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleListSort('status')}>
{t('artefacts.status')} <SortIcon col="status" />
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('artefacts.brand')}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('artefacts.project')}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('artefacts.campaign')}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('artefacts.creator')}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('artefacts.approvers')}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('artefacts.version')}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleListSort('updated_at')}>
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('artefacts.brand')}</th>
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('artefacts.project')}</th>
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('artefacts.campaign')}</th>
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('artefacts.creator')}</th>
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('artefacts.approvers')}</th>
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('artefacts.version')}</th>
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleListSort('updated_at')}>
{t('artefacts.updated')} <SortIcon col="updated_at" />
</th>
</tr>
@@ -484,7 +480,7 @@ export default function Artefacts() {
)}
{/* Create Modal */}
<Modal isOpen={showCreateModal} onClose={() => setShowCreateModal(false)} title={t('artefacts.createArtefact')} size="md">
<Modal isOpen={showCreateModal} onClose={() => setShowCreateModal(false)} title={t('artefacts.createArtefact')} size="sm">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('artefacts.titleLabel')} *</label>
@@ -494,67 +490,16 @@ export default function Artefacts() {
onChange={e => setNewArtefact(f => ({ ...f, title: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
placeholder={t('artefacts.titlePlaceholder')}
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('artefacts.type')}</label>
<select
<PortalSelect
value={newArtefact.type}
onChange={e => setNewArtefact(f => ({ ...f, type: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
{TYPES.map(t => <option key={t} value={t}>{t.charAt(0).toUpperCase() + t.slice(1)}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('artefacts.brand')}</label>
<select
value={newArtefact.brand_id}
onChange={e => setNewArtefact(f => ({ ...f, brand_id: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
<option value=""></option>
{brands.map(b => <option key={b._id} value={b._id}>{b.name}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('artefacts.project')}</label>
<select
value={newArtefact.project_id}
onChange={e => setNewArtefact(f => ({ ...f, project_id: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
<option value=""></option>
{projects.map(p => <option key={p.Id || p._id || p.id} value={p.Id || p._id || p.id}>{p.name || p.title}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('artefacts.campaign')}</label>
<select
value={newArtefact.campaign_id}
onChange={e => setNewArtefact(f => ({ ...f, campaign_id: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
<option value=""></option>
{campaigns.map(c => <option key={c.Id || c._id || c.id} value={c.Id || c._id || c.id}>{c.name || c.title}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('artefacts.approvers')}</label>
<ApproverMultiSelect
users={assignableUsers}
selected={newArtefact.approver_ids}
onChange={ids => setNewArtefact(f => ({ ...f, approver_ids: ids }))}
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('artefacts.description')}</label>
<textarea
value={newArtefact.description}
onChange={e => setNewArtefact(f => ({ ...f, description: e.target.value }))}
rows={3}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
placeholder={t('artefacts.descriptionPlaceholder')}
onChange={val => setNewArtefact(f => ({ ...f, type: val }))}
options={TYPES.map(t => ({ value: t, label: t.charAt(0).toUpperCase() + t.slice(1) }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface text-text-primary"
/>
</div>
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
@@ -595,8 +540,6 @@ export default function Artefacts() {
onClose={() => setSelectedArtefact(null)}
onUpdate={loadArtefacts}
onDelete={canDeleteResource('artefact', selectedArtefact) ? handleDelete : undefined}
projects={projects}
campaigns={campaigns}
assignableUsers={assignableUsers}
/>
)}
+8 -8
View File
@@ -181,20 +181,20 @@ export default function Assets() {
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-3">
<div className="relative flex-1 min-w-[200px] max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<Search className="absolute start-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<input
type="text"
placeholder="Search assets..."
value={filters.search}
onChange={e => setFilters(f => ({ ...f, search: e.target.value }))}
className="w-full pl-10 pr-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-white"
className="w-full ps-10 pe-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface"
/>
</div>
<select
value={filters.brand}
onChange={e => setFilters(f => ({ ...f, brand: e.target.value }))}
className="text-sm border border-border rounded-lg px-3 py-2 bg-white text-text-secondary focus:outline-none"
className="text-sm border border-border rounded-lg px-3 py-2 bg-surface text-text-secondary focus:outline-none"
>
<option value="">All Brands</option>
{brands.map(b => <option key={b} value={b}>{b}</option>)}
@@ -203,7 +203,7 @@ export default function Assets() {
<select
value={filters.tag}
onChange={e => setFilters(f => ({ ...f, tag: e.target.value }))}
className="text-sm border border-border rounded-lg px-3 py-2 bg-white text-text-secondary focus:outline-none"
className="text-sm border border-border rounded-lg px-3 py-2 bg-surface text-text-secondary focus:outline-none"
>
<option value="">All Tags</option>
{allTags.map(t => <option key={t} value={t}>{t}</option>)}
@@ -211,7 +211,7 @@ export default function Assets() {
<button
onClick={() => setShowUpload(true)}
className="flex items-center gap-2 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm ml-auto"
className="flex items-center gap-2 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm ms-auto"
>
<Upload className="w-4 h-4" />
Upload
@@ -260,7 +260,7 @@ export default function Assets() {
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4 stagger-children">
{filteredAssets.map(asset => (
<div key={asset._id || asset.id} className="relative">
<div className="absolute top-2 left-2 z-10" onClick={e => e.stopPropagation()}>
<div className="absolute top-2 start-2 z-10" onClick={e => e.stopPropagation()}>
<input type="checkbox" checked={selectedIds.has(asset._id || asset.id)} onChange={() => toggleSelect(asset._id || asset.id)} className="rounded border-border" />
</div>
<AssetCard asset={asset} onClick={setSelectedAsset} />
@@ -319,7 +319,7 @@ export default function Assets() {
<div className="space-y-4">
{selectedAsset.type === 'image' && selectedAsset.url && (
<div className="rounded-lg overflow-hidden bg-surface-tertiary">
<img src={selectedAsset.url} alt={selectedAsset.name} className="w-full max-h-[400px] object-contain" />
<img src={selectedAsset.url} alt={selectedAsset.name} className="w-full max-h-[400px] object-contain" loading="lazy" />
</div>
)}
{selectedAsset.type === 'video' && selectedAsset.url && (
@@ -374,7 +374,7 @@ export default function Assets() {
download={selectedAsset.name}
target="_blank"
rel="noopener noreferrer"
className="ml-auto px-4 py-2 text-sm font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light"
className="ms-auto px-4 py-2 text-sm font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light"
>
Download
</a>
+7 -5
View File
@@ -143,7 +143,7 @@ export default function Brands() {
{/* Brand Cards Grid */}
{brands.length === 0 ? (
<div className="bg-white rounded-xl border border-border py-16 text-center">
<div className="bg-surface rounded-xl border border-border py-16 text-center">
<Tag className="w-12 h-12 text-text-quaternary mx-auto mb-3" />
<p className="text-sm text-text-tertiary">{t('brands.noBrands')}</p>
</div>
@@ -154,7 +154,7 @@ export default function Brands() {
return (
<div
key={getBrandId(brand)}
className={`bg-white rounded-xl border border-border overflow-hidden hover:shadow-md transition-all aspect-square flex flex-col ${isSuperadminOrManager ? 'cursor-pointer' : ''}`}
className={`bg-surface rounded-xl border border-border overflow-clip hover:shadow-md transition-all aspect-square flex flex-col ${isSuperadminOrManager ? 'cursor-pointer' : ''}`}
onClick={() => isSuperadminOrManager && openEditBrand(brand)}
>
{/* Logo area */}
@@ -164,6 +164,7 @@ export default function Brands() {
src={`${API_BASE}/uploads/${brand.logo}`}
alt={displayName}
className="w-full h-full object-contain p-4"
loading="lazy"
/>
) : (
<div className="text-3xl">
@@ -171,17 +172,17 @@ export default function Brands() {
</div>
)}
{isSuperadminOrManager && (
<div className="absolute top-1.5 right-1.5 flex gap-1 opacity-0 group-hover:opacity-100" onClick={e => e.stopPropagation()}>
<div className="absolute top-1.5 end-1.5 flex gap-1 opacity-0 group-hover:opacity-100" onClick={e => e.stopPropagation()}>
<button
onClick={() => openEditBrand(brand)}
className="p-1 bg-white/90 hover:bg-white rounded-md text-text-tertiary hover:text-text-primary shadow-sm"
className="p-1 bg-white/90 hover:bg-surface rounded-md text-text-tertiary hover:text-text-primary shadow-sm"
title={t('common.edit')}
>
<Edit2 className="w-3 h-3" />
</button>
<button
onClick={() => { setBrandToDelete(brand); setShowDeleteModal(true) }}
className="p-1 bg-white/90 hover:bg-white rounded-md text-text-tertiary hover:text-red-500 shadow-sm"
className="p-1 bg-white/90 hover:bg-surface rounded-md text-text-tertiary hover:text-red-500 shadow-sm"
title={t('common.delete')}
>
<Trash2 className="w-3 h-3" />
@@ -269,6 +270,7 @@ export default function Brands() {
src={`${API_BASE}/uploads/${editingBrand.logo}`}
alt="Logo"
className="h-16 object-contain"
loading="lazy"
/>
</div>
)}
+16 -20
View File
@@ -153,11 +153,7 @@ export default function Budgets() {
return (
<div className="space-y-6 animate-fade-in">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-text-primary">{t('budgets.title')}</h1>
<p className="text-sm text-text-tertiary mt-0.5">{t('budgets.subtitle')}</p>
</div>
<div className="flex items-center justify-end">
{canManageFinance && (
<button
onClick={() => { setEditing(null); setForm(EMPTY_ENTRY); setShowModal(true) }}
@@ -171,19 +167,19 @@ export default function Budgets() {
{/* Filters */}
<div className="flex items-center gap-3 flex-wrap">
<div className="relative flex-1 min-w-[200px] max-w-xs">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<Search className="absolute start-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<input
type="text"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder={t('budgets.searchEntries')}
className="w-full pl-9 pr-3 py-2 text-sm border border-border rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
className="w-full ps-9 pe-3 py-2 text-sm border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
/>
</div>
<select
value={filterCategory}
onChange={e => setFilterCategory(e.target.value)}
className="px-3 py-2 text-sm border border-border rounded-lg bg-white focus:outline-none"
className="px-3 py-2 text-sm border border-border rounded-lg bg-surface focus:outline-none"
>
<option value="">{t('budgets.allCategories')}</option>
{CATEGORIES.map(c => <option key={c.value} value={c.value}>{c.label}</option>)}
@@ -191,7 +187,7 @@ export default function Budgets() {
<select
value={filterDestination}
onChange={e => setFilterDestination(e.target.value)}
className="px-3 py-2 text-sm border border-border rounded-lg bg-white focus:outline-none"
className="px-3 py-2 text-sm border border-border rounded-lg bg-surface focus:outline-none"
>
<option value="">{t('budgets.allDestinations')}</option>
{DESTINATIONS.map(d => <option key={d.value} value={d.value}>{t(d.labelKey)}</option>)}
@@ -206,7 +202,7 @@ export default function Budgets() {
className={`px-3 py-1.5 text-xs font-medium transition-colors ${
filterType === opt.value
? opt.value === 'expense' ? 'bg-red-500 text-white' : opt.value === 'income' ? 'bg-emerald-500 text-white' : 'bg-brand-primary text-white'
: 'bg-white text-text-secondary hover:bg-surface-secondary'
: 'bg-surface text-text-secondary hover:bg-surface-secondary'
}`}
>
{opt.label}
@@ -215,7 +211,7 @@ export default function Budgets() {
</div>
{filteredEntries.length > 0 && (
<div className="ml-auto flex items-center gap-3 text-sm text-text-tertiary">
<div className="ms-auto flex items-center gap-3 text-sm text-text-tertiary">
<span>{filteredEntries.length} {filteredEntries.length === 1 ? 'entry' : 'entries'}</span>
<span className="text-emerald-600 font-semibold">+{totalIncome.toLocaleString()}</span>
{totalExpenseAmt > 0 && <span className="text-red-500 font-semibold">-{totalExpenseAmt.toLocaleString()}</span>}
@@ -235,12 +231,12 @@ export default function Budgets() {
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-surface-secondary">
<th className="px-4 py-3 text-left text-xs font-medium text-text-tertiary">{t('budgets.label')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-text-tertiary">{t('budgets.source')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-text-tertiary">{t('budgets.destination')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-text-tertiary">{t('budgets.linkedTo')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-text-tertiary">{t('budgets.date')}</th>
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">{t('budgets.amount')}</th>
<th className="px-4 py-3 text-start text-xs font-medium text-text-tertiary">{t('budgets.label')}</th>
<th className="px-4 py-3 text-start text-xs font-medium text-text-tertiary">{t('budgets.source')}</th>
<th className="px-4 py-3 text-start text-xs font-medium text-text-tertiary">{t('budgets.destination')}</th>
<th className="px-4 py-3 text-start text-xs font-medium text-text-tertiary">{t('budgets.linkedTo')}</th>
<th className="px-4 py-3 text-start text-xs font-medium text-text-tertiary">{t('budgets.date')}</th>
<th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">{t('budgets.amount')}</th>
{canManageFinance && <th className="px-4 py-3 w-20" />}
</tr>
</thead>
@@ -289,7 +285,7 @@ export default function Budgets() {
<td className="px-4 py-3 text-text-secondary whitespace-nowrap">
{entry.date_received ? format(new Date(entry.date_received), 'MMM d, yyyy') : '--'}
</td>
<td className={`px-4 py-3 text-right font-semibold whitespace-nowrap ${
<td className={`px-4 py-3 text-end font-semibold whitespace-nowrap ${
(entry.type || 'income') === 'expense' ? 'text-red-500' : 'text-emerald-600'
}`}>
{(entry.type || 'income') === 'expense' ? '-' : '+'}{Number(entry.amount).toLocaleString()} {currencySymbol}
@@ -332,7 +328,7 @@ export default function Budgets() {
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium border-2 transition-all ${
form.type === 'income'
? 'border-emerald-500 bg-emerald-50 text-emerald-700'
: 'border-border bg-white text-text-secondary hover:bg-surface-secondary'
: 'border-border bg-surface text-text-secondary hover:bg-surface-secondary'
}`}
>
<TrendingUp className="w-4 h-4" />
@@ -344,7 +340,7 @@ export default function Budgets() {
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium border-2 transition-all ${
form.type === 'expense'
? 'border-red-500 bg-red-50 text-red-700'
: 'border-border bg-white text-text-secondary hover:bg-surface-secondary'
: 'border-border bg-surface text-text-secondary hover:bg-surface-secondary'
}`}
>
<TrendingDown className="w-4 h-4" />
+68 -127
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useContext } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { ArrowLeft, Plus, Edit2, Trash2, DollarSign, Eye, MousePointer, Target, TrendingUp, FileText, Megaphone, Search, Globe, Pencil, Users, X, UserPlus, MessageCircle, Settings } from 'lucide-react'
import { ArrowLeft, Plus, Edit2, Trash2, DollarSign, Eye, MousePointer, Target, TrendingUp, FileText, Megaphone, Search, Globe, Users, X, MessageCircle, Settings } from 'lucide-react'
import { format } from 'date-fns'
import { AppContext } from '../App'
import { useAuth } from '../contexts/AuthContext'
@@ -14,7 +14,6 @@ import BudgetBar from '../components/BudgetBar'
import CommentsSection from '../components/CommentsSection'
import CampaignDetailPanel from '../components/CampaignDetailPanel'
import TrackDetailPanel from '../components/TrackDetailPanel'
import PostDetailPanel from '../components/PostDetailPanel'
const TRACK_TYPES = {
organic_social: { label: 'Organic Social', icon: Megaphone, color: 'text-green-600 bg-green-50', hasBudget: false },
@@ -26,21 +25,11 @@ const TRACK_TYPES = {
const TRACK_STATUSES = ['planned', 'active', 'paused', 'completed']
function MetricBox({ label, value, icon: Icon, color = 'text-text-primary' }) {
return (
<div className="text-center">
<Icon className={`w-4 h-4 mx-auto mb-0.5 ${color}`} />
<div className={`text-base font-bold ${color}`}>{value ?? '—'}</div>
<div className="text-[10px] text-text-tertiary">{label}</div>
</div>
)
}
export default function CampaignDetail() {
const { id } = useParams()
const navigate = useNavigate()
const { brands, getBrandName, teamMembers } = useContext(AppContext)
const { lang, currencySymbol } = useLanguage()
const { t, lang, currencySymbol } = useLanguage()
const { permissions, user } = useAuth()
const isSuperadmin = user?.role === 'superadmin'
const [campaign, setCampaign] = useState(null)
@@ -56,7 +45,6 @@ export default function CampaignDetail() {
const [budgetValue, setBudgetValue] = useState('')
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [trackToDelete, setTrackToDelete] = useState(null)
const [selectedPost, setSelectedPost] = useState(null)
const [showDiscussion, setShowDiscussion] = useState(false)
const [allCampaigns, setAllCampaigns] = useState([])
@@ -163,21 +151,6 @@ export default function CampaignDetail() {
loadAll()
}
const handlePostPanelSave = async (postId, data) => {
if (postId) {
await api.patch(`/posts/${postId}`, data)
} else {
await api.post('/posts', data)
}
loadAll()
}
const handlePostPanelDelete = async (postId) => {
await api.delete(`/posts/${postId}`)
setSelectedPost(null)
loadAll()
}
const deleteTrack = async (trackId) => {
setTrackToDelete(trackId)
setShowDeleteConfirm(true)
@@ -211,7 +184,7 @@ export default function CampaignDetail() {
if (!campaign) {
return (
<div className="text-center py-12 text-text-tertiary">
Campaign not found. <button onClick={() => navigate('/campaigns')} className="text-brand-primary underline">Go back</button>
{t('campaigns.notFound')} <button onClick={() => navigate('/campaigns')} className="text-brand-primary underline">{t('common.goBack')}</button>
</div>
)
}
@@ -244,9 +217,6 @@ export default function CampaignDetail() {
{campaign.start_date && campaign.end_date && (
<span>{format(new Date(campaign.start_date), 'MMM d')} {format(new Date(campaign.end_date), 'MMM d, yyyy')}</span>
)}
<span>
Budget: {campaign.budget > 0 ? `${campaign.budget.toLocaleString()} ${currencySymbol}` : 'Not set'}
</span>
{campaign.platforms && campaign.platforms.length > 0 && (
<PlatformIcons platforms={campaign.platforms} size={16} />
)}
@@ -263,109 +233,73 @@ export default function CampaignDetail() {
}`}
>
<MessageCircle className="w-4 h-4" />
Discussion
{t('campaigns.discussion')}
</button>
{canSetBudget && (
<button
onClick={() => { setBudgetValue(campaign.budget || ''); setEditingBudget(true) }}
className="flex items-center gap-1.5 px-3 py-2 bg-surface-tertiary text-text-secondary hover:bg-surface-tertiary/80 hover:text-text-primary rounded-lg text-sm font-medium transition-colors"
>
<DollarSign className="w-4 h-4" />
Budget
</button>
)}
{canManage && (
<button
onClick={() => setPanelCampaign(campaign)}
className="flex items-center gap-1.5 px-3 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm transition-colors"
>
<Settings className="w-4 h-4" />
Edit
{t('common.edit')}
</button>
)}
</div>
</div>
{/* Assigned Team */}
<div className="bg-white rounded-xl border border-border p-5">
{/* Budget Card */}
<div className="bg-surface rounded-xl border border-border p-5">
<div className="flex items-center justify-between mb-3">
<h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium flex items-center gap-1.5">
<Users className="w-3.5 h-3.5" /> Assigned Team
</h3>
{canAssign && (
<button
onClick={openAssignModal}
className="flex items-center gap-1.5 px-3 py-1.5 bg-brand-primary text-white rounded-lg text-xs font-medium hover:bg-brand-primary-light"
>
<UserPlus className="w-3.5 h-3.5" /> Assign Members
<h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium">{t('campaigns.budget')}</h3>
{canSetBudget && (
<button onClick={() => { setBudgetValue(campaign.budget || ''); setEditingBudget(true) }}
className="text-xs text-brand-primary hover:text-brand-primary-light font-medium">
{t('common.edit')}
</button>
)}
</div>
{assignments.length === 0 ? (
<p className="text-xs text-text-tertiary py-2">No team members assigned yet.</p>
) : (
<div className="flex flex-wrap gap-2">
{assignments.map(a => (
<div key={a.user_id} className="flex items-center gap-2 bg-surface-secondary rounded-full pl-1 pr-2 py-1">
<div className="w-6 h-6 rounded-full bg-brand-primary/10 text-brand-primary flex items-center justify-center text-[10px] font-bold shrink-0">
{a.user_avatar ? (
<img src={a.user_avatar} className="w-full h-full rounded-full object-cover" alt="" />
) : (
getInitials(a.user_name)
)}
</div>
<span className="text-xs font-medium text-text-primary">{a.user_name}</span>
{canAssign && (
<button
onClick={() => removeAssignment(a.user_id)}
className="p-0.5 rounded-full hover:bg-red-100 text-text-tertiary hover:text-red-500"
>
<X className="w-3 h-3" />
</button>
)}
</div>
))}
<div className="flex items-baseline gap-2 mb-3">
<span className="text-2xl font-bold text-text-primary">
{totalAllocated.toLocaleString()} {currencySymbol}
</span>
<span className="text-sm text-text-tertiary">{t('finance.allocated')}</span>
</div>
{totalAllocated > 0 && (
<>
<BudgetBar budget={totalAllocated} spent={totalSpent} height="h-2.5" />
<div className="flex justify-between mt-2 text-xs text-text-tertiary">
<span>{totalSpent.toLocaleString()} {currencySymbol} {t('dashboard.spent')}</span>
<span>{(totalAllocated - totalSpent).toLocaleString()} {currencySymbol} {t('dashboard.remaining')}</span>
</div>
</>
)}
{(totalImpressions > 0 || totalClicks > 0) && (
<div className="flex items-center gap-4 mt-4 pt-3 border-t border-border-light text-xs text-text-secondary">
<span><Eye className="w-3.5 h-3.5 inline me-1" />{totalImpressions.toLocaleString()}</span>
<span><MousePointer className="w-3.5 h-3.5 inline me-1" />{totalClicks.toLocaleString()}</span>
{totalConversions > 0 && <span><Target className="w-3.5 h-3.5 inline me-1" />{totalConversions.toLocaleString()}</span>}
{totalRevenue > 0 && <span><DollarSign className="w-3.5 h-3.5 inline me-1" />{totalRevenue.toLocaleString()} {currencySymbol}</span>}
</div>
)}
</div>
{/* Aggregate Metrics */}
{tracks.length > 0 && (
<div className="bg-white rounded-xl border border-border p-5">
<h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium mb-4">Campaign Totals (from tracks)</h3>
<div className="grid grid-cols-3 md:grid-cols-6 gap-4">
<MetricBox icon={DollarSign} label="Allocated" value={`${totalAllocated.toLocaleString()}`} color="text-blue-600" />
<MetricBox icon={TrendingUp} label="Spent" value={`${totalSpent.toLocaleString()}`} color="text-amber-600" />
<MetricBox icon={Eye} label="Impressions" value={totalImpressions.toLocaleString()} color="text-purple-600" />
<MetricBox icon={MousePointer} label="Clicks" value={totalClicks.toLocaleString()} color="text-green-600" />
<MetricBox icon={Target} label="Conversions" value={totalConversions.toLocaleString()} color="text-red-600" />
<MetricBox icon={DollarSign} label="Revenue" value={`${totalRevenue.toLocaleString()}`} color="text-emerald-600" />
</div>
{totalAllocated > 0 && (
<div className="mt-4">
<BudgetBar budget={totalAllocated} spent={totalSpent} height="h-2" />
</div>
)}
</div>
)}
{/* Tracks */}
<div className="bg-white rounded-xl border border-border overflow-hidden">
<div className="bg-surface rounded-xl border border-border overflow-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
+11 -11
View File
@@ -145,7 +145,7 @@ export default function Campaigns() {
<select
value={filters.brand}
onChange={e => setFilters(f => ({ ...f, brand: e.target.value }))}
className="text-sm border border-border rounded-lg px-3 py-2 bg-white text-text-secondary focus:outline-none"
className="text-sm border border-border rounded-lg px-3 py-2 bg-surface text-text-secondary focus:outline-none"
>
<option value="">All Brands</option>
{brands.map(b => <option key={b.id || b._id} value={b.id || b._id}>{lang === 'ar' && b.name_ar ? b.name_ar : b.name}</option>)}
@@ -154,7 +154,7 @@ export default function Campaigns() {
<select
value={filters.status}
onChange={e => setFilters(f => ({ ...f, status: e.target.value }))}
className="text-sm border border-border rounded-lg px-3 py-2 bg-white text-text-secondary focus:outline-none"
className="text-sm border border-border rounded-lg px-3 py-2 bg-surface text-text-secondary focus:outline-none"
>
<option value="">All Statuses</option>
<option value="planning">Planning</option>
@@ -167,7 +167,7 @@ export default function Campaigns() {
{permissions?.canCreateCampaigns && (
<button
onClick={openNew}
className="flex items-center gap-2 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm ml-auto"
className="flex items-center gap-2 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm ms-auto"
>
<Plus className="w-4 h-4" />
New Campaign
@@ -178,7 +178,7 @@ export default function Campaigns() {
{/* Summary Cards */}
{(totalBudget > 0 || totalSpent > 0) && (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3 stagger-children">
<div className="bg-white rounded-xl border border-border p-4">
<div className="bg-surface rounded-xl border border-border p-4">
<div className="flex items-center gap-2 mb-1">
<DollarSign className="w-4 h-4 text-blue-500" />
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Budget</span>
@@ -186,7 +186,7 @@ export default function Campaigns() {
<div className="text-lg font-bold text-text-primary">{totalBudget.toLocaleString()}</div>
<div className="text-[10px] text-text-tertiary">{currencySymbol} total</div>
</div>
<div className="bg-white rounded-xl border border-border p-4">
<div className="bg-surface rounded-xl border border-border p-4">
<div className="flex items-center gap-2 mb-1">
<TrendingUp className="w-4 h-4 text-amber-500" />
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Spent</span>
@@ -194,28 +194,28 @@ export default function Campaigns() {
<div className="text-lg font-bold text-text-primary">{totalSpent.toLocaleString()}</div>
<div className="text-[10px] text-text-tertiary">{currencySymbol} spent</div>
</div>
<div className="bg-white rounded-xl border border-border p-4">
<div className="bg-surface rounded-xl border border-border p-4">
<div className="flex items-center gap-2 mb-1">
<Eye className="w-4 h-4 text-purple-500" />
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Impressions</span>
</div>
<div className="text-lg font-bold text-text-primary">{totalImpressions.toLocaleString()}</div>
</div>
<div className="bg-white rounded-xl border border-border p-4">
<div className="bg-surface rounded-xl border border-border p-4">
<div className="flex items-center gap-2 mb-1">
<MousePointer className="w-4 h-4 text-green-500" />
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Clicks</span>
</div>
<div className="text-lg font-bold text-text-primary">{totalClicks.toLocaleString()}</div>
</div>
<div className="bg-white rounded-xl border border-border p-4">
<div className="bg-surface rounded-xl border border-border p-4">
<div className="flex items-center gap-2 mb-1">
<Target className="w-4 h-4 text-red-500" />
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Conversions</span>
</div>
<div className="text-lg font-bold text-text-primary">{totalConversions.toLocaleString()}</div>
</div>
<div className="bg-white rounded-xl border border-border p-4">
<div className="bg-surface rounded-xl border border-border p-4">
<div className="flex items-center gap-2 mb-1">
<BarChart3 className="w-4 h-4 text-emerald-500" />
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Revenue</span>
@@ -264,7 +264,7 @@ export default function Campaigns() {
/>
{/* Campaign list */}
<div className="bg-white rounded-xl border border-border overflow-hidden">
<div className="bg-surface rounded-xl border border-border overflow-clip">
<div className="px-5 py-4 border-b border-border">
<h3 className="font-semibold text-text-primary">All Campaigns</h3>
</div>
@@ -308,7 +308,7 @@ export default function Campaigns() {
)}
</div>
</div>
<div className="text-right shrink-0">
<div className="text-end shrink-0">
<StatusBadge status={campaign.status} size="xs" />
<div className="text-xs text-text-tertiary mt-1">
{campaign.startDate && campaign.endDate ? (
+125 -210
View File
@@ -1,12 +1,11 @@
import { useContext, useEffect, useState, useMemo } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { format, isAfter, isBefore, addDays } from 'date-fns'
import { FileText, Megaphone, AlertTriangle, ArrowRight, Clock, Wallet, TrendingUp, TrendingDown, DollarSign, Landmark, CheckSquare, FolderKanban } from 'lucide-react'
import { FileText, Megaphone, AlertTriangle, ArrowRight, Clock, Landmark, CheckSquare, FolderKanban } from 'lucide-react'
import { AppContext } from '../App'
import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../i18n/LanguageContext'
import { api, PRIORITY_CONFIG } from '../utils/api'
import StatCard from '../components/StatCard'
import StatusBadge from '../components/StatusBadge'
import BrandBadge from '../components/BrandBadge'
import DatePresetPicker from '../components/DatePresetPicker'
@@ -18,24 +17,17 @@ function getBudgetBarColor(percentage) {
return 'bg-emerald-500'
}
function FinanceMini({ finance }) {
function BudgetSummary({ finance }) {
const { t, currencySymbol } = useLanguage()
if (!finance) return null
const totalReceived = finance.totalReceived || 0
const spent = finance.spent || 0
const remaining = finance.remaining || 0
const roi = finance.roi || 0
const totalExpenses = finance.totalExpenses || 0
const campaignBudget = finance.totalCampaignBudget || 0
const projectBudget = finance.totalProjectBudget || 0
const unallocated = finance.unallocated ?? (totalReceived - campaignBudget - projectBudget)
const pct = totalReceived > 0 ? (spent / totalReceived) * 100 : 0
const mainAvailable = finance.mainAvailable != null ? finance.mainAvailable : (finance.remaining || 0)
const consumed = totalReceived - mainAvailable
const pct = totalReceived > 0 ? (consumed / totalReceived) * 100 : 0
const barColor = getBudgetBarColor(pct)
const campPct = totalReceived > 0 ? (campaignBudget / totalReceived) * 100 : 0
const projPct = totalReceived > 0 ? (projectBudget / totalReceived) * 100 : 0
return (
<div className="bg-white rounded-xl border border-border p-5">
<div className="bg-surface rounded-xl border border-border p-5">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-text-primary">{t('dashboard.budgetOverview')}</h3>
<Link to="/finance" className="text-sm text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1">
@@ -49,58 +41,15 @@ function FinanceMini({ finance }) {
</div>
) : (
<>
{/* Spending bar */}
<div className="mb-3">
<div className="flex justify-between text-xs text-text-tertiary mb-1">
<span>{spent.toLocaleString()} {currencySymbol} {t('dashboard.spent')}</span>
<span>{totalReceived.toLocaleString()} {currencySymbol} {t('dashboard.received')}</span>
</div>
<div className="h-2.5 bg-surface-tertiary rounded-full overflow-hidden">
<div className={`h-full ${barColor} rounded-full transition-all`} style={{ width: `${Math.min(pct, 100)}%` }} />
</div>
<div className="flex justify-between text-xs text-text-tertiary mb-1">
<span>{consumed.toLocaleString()} {currencySymbol} {t('dashboard.spent')}</span>
<span>{totalReceived.toLocaleString()} {currencySymbol} {t('dashboard.received')}</span>
</div>
{/* Allocation bar */}
{(campaignBudget > 0 || projectBudget > 0) && (
<div className="mb-3">
<div className="text-[10px] text-text-tertiary mb-1 font-medium uppercase tracking-wide">Allocation</div>
<div className="h-2 bg-surface-tertiary rounded-full overflow-hidden flex">
{campPct > 0 && <div className="h-full bg-blue-500" style={{ width: `${campPct}%` }} />}
{projPct > 0 && <div className="h-full bg-purple-500" style={{ width: `${projPct}%` }} />}
</div>
<div className="flex gap-3 mt-1 text-[10px] text-text-tertiary">
{campaignBudget > 0 && <span><span className="inline-block w-1.5 h-1.5 rounded-full bg-blue-500 mr-1" />{campaignBudget.toLocaleString()}</span>}
{projectBudget > 0 && <span><span className="inline-block w-1.5 h-1.5 rounded-full bg-purple-500 mr-1" />{projectBudget.toLocaleString()}</span>}
{unallocated > 0 && <span><span className="inline-block w-1.5 h-1.5 rounded-full bg-gray-300 mr-1" />{unallocated.toLocaleString()} free</span>}
</div>
</div>
)}
{/* Key numbers */}
<div className={`grid ${totalExpenses > 0 ? 'grid-cols-3' : 'grid-cols-2'} gap-3`}>
<div className="text-center p-2 bg-surface-secondary rounded-lg">
<Landmark className="w-4 h-4 mx-auto mb-1 text-emerald-500" />
<div className={`text-sm font-bold ${remaining >= 0 ? 'text-emerald-600' : 'text-red-600'}`}>
{remaining.toLocaleString()}
</div>
<div className="text-[10px] text-text-tertiary">{t('dashboard.remaining')}</div>
</div>
{totalExpenses > 0 && (
<div className="text-center p-2 bg-surface-secondary rounded-lg">
<TrendingDown className="w-4 h-4 mx-auto mb-1 text-red-500" />
<div className="text-sm font-bold text-red-600">
{totalExpenses.toLocaleString()}
</div>
<div className="text-[10px] text-text-tertiary">{t('dashboard.expenses')}</div>
</div>
)}
<div className="text-center p-2 bg-surface-secondary rounded-lg">
<TrendingUp className="w-4 h-4 mx-auto mb-1 text-blue-500" />
<div className={`text-sm font-bold ${roi >= 0 ? 'text-emerald-600' : 'text-red-600'}`}>
{roi.toFixed(0)}%
</div>
<div className="text-[10px] text-text-tertiary">{t('dashboard.roi')}</div>
</div>
<div className="h-2.5 bg-surface-tertiary rounded-full overflow-hidden">
<div className={`h-full ${barColor} rounded-full transition-all`} style={{ width: `${Math.min(pct, 100)}%` }} />
</div>
<div className={`mt-3 text-sm font-semibold ${mainAvailable >= 0 ? 'text-emerald-600' : 'text-red-600'}`}>
{mainAvailable.toLocaleString()} {currencySymbol} {t('dashboard.remaining')}
</div>
</>
)}
@@ -146,13 +95,6 @@ function ActiveCampaignsList({ campaigns, finance }) {
</div>
)}
</div>
<div className="text-right shrink-0">
{cd.tracks_impressions > 0 && (
<div className="text-[10px] text-text-tertiary">
{cd.tracks_impressions.toLocaleString()} imp. / {cd.tracks_clicks.toLocaleString()} clicks
</div>
)}
</div>
</Link>
)
})}
@@ -162,12 +104,12 @@ function ActiveCampaignsList({ campaigns, finance }) {
}
function MyTasksList({ tasks, currentUserId, navigate, t }) {
const myTasks = tasks
const myTasks = useMemo(() => tasks
.filter(task => {
const assignedId = task.assigned_to_id || task.assignedTo
return assignedId === currentUserId && task.status !== 'done'
})
.slice(0, 5)
.slice(0, 5), [tasks, currentUserId])
return (
<div className="section-card">
@@ -187,10 +129,10 @@ function MyTasksList({ tasks, currentUserId, navigate, t }) {
</div>
) : (
myTasks.map(task => (
<div
<button
key={task._id || task.id}
onClick={() => navigate('/tasks')}
className="flex items-center gap-3 px-5 py-3 hover:bg-surface-secondary transition-colors cursor-pointer"
className="w-full flex items-center gap-3 px-5 py-3 hover:bg-surface-secondary transition-colors text-start"
>
<div className={`w-2 h-2 rounded-full shrink-0 ${(PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium).color}`} />
<div className="flex-1 min-w-0">
@@ -203,7 +145,7 @@ function MyTasksList({ tasks, currentUserId, navigate, t }) {
{format(new Date(task.dueDate), 'MMM d')}
</div>
)}
</div>
</button>
))
)}
</div>
@@ -261,10 +203,84 @@ function ProjectProgress({ projects, tasks, t }) {
)
}
function ActivityFeed({ posts, deadlines, navigate, t }) {
const [tab, setTab] = useState('posts')
const hasPosts = posts.length > 0
const hasDeadlines = deadlines.length > 0
return (
<div className="section-card">
<div className="section-card-header flex items-center justify-between">
<div className="flex items-center gap-1">
<button
onClick={() => setTab('posts')}
className={`px-3 py-1 text-sm font-medium rounded-md transition-colors ${
tab === 'posts' ? 'bg-brand-primary/10 text-brand-primary' : 'text-text-tertiary hover:text-text-secondary'
}`}
>
{t('dashboard.recentPosts')}
</button>
<button
onClick={() => setTab('deadlines')}
className={`px-3 py-1 text-sm font-medium rounded-md transition-colors ${
tab === 'deadlines' ? 'bg-brand-primary/10 text-brand-primary' : 'text-text-tertiary hover:text-text-secondary'
}`}
>
{t('dashboard.upcomingDeadlines')}
</button>
</div>
<Link to={tab === 'posts' ? '/posts' : '/tasks'} className="text-sm text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1">
{t('dashboard.viewAll')} <ArrowRight className="w-3.5 h-3.5" />
</Link>
</div>
<div className="divide-y divide-border-light">
{tab === 'posts' ? (
!hasPosts ? (
<div className="py-10 text-center text-sm text-text-tertiary">{t('dashboard.noPostsYet')}</div>
) : (
posts.slice(0, 6).map(post => (
<button key={post._id} onClick={() => navigate('/posts')} className="w-full flex items-center gap-3 px-5 py-3 hover:bg-surface-secondary transition-colors text-start">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{post.title}</p>
<div className="flex items-center gap-2 mt-1">
{post.brand && <BrandBadge brand={post.brand} />}
</div>
</div>
<StatusBadge status={post.status} size="xs" />
</button>
))
)
) : (
!hasDeadlines ? (
<div className="py-10 text-center text-sm text-text-tertiary">{t('dashboard.noUpcomingDeadlines')}</div>
) : (
deadlines.map(task => (
<button key={task._id} onClick={() => navigate('/tasks')} className="w-full flex items-center gap-3 px-5 py-3 hover:bg-surface-secondary transition-colors text-start">
<div className={`w-2 h-2 rounded-full ${(PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium).color}`} />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{task.title}</p>
<div className="flex items-center gap-2 mt-0.5">
<StatusBadge status={task.status} size="xs" />
{task.assignedName && <span className="text-xs text-text-tertiary truncate">{task.assignedName}</span>}
</div>
</div>
<div className="flex items-center gap-1 text-xs text-text-tertiary shrink-0">
<Clock className="w-3.5 h-3.5" />
{format(new Date(task.dueDate), 'MMM d')}
</div>
</button>
))
)
)}
</div>
</div>
)
}
export default function Dashboard() {
const { t, currencySymbol } = useLanguage()
const navigate = useNavigate()
const { currentUser, teamMembers } = useContext(AppContext)
const { currentUser } = useContext(AppContext)
const { hasModule } = useAuth()
const [posts, setPosts] = useState([])
const [campaigns, setCampaigns] = useState([])
@@ -273,7 +289,6 @@ export default function Dashboard() {
const [finance, setFinance] = useState(null)
const [loading, setLoading] = useState(true)
// Date filtering
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
const [activePreset, setActivePreset] = useState('')
@@ -285,7 +300,6 @@ export default function Dashboard() {
const loadData = async () => {
try {
const fetches = []
// Only fetch data for modules the user has access to
if (hasModule('marketing')) {
fetches.push(api.get('/posts?limit=50&sort=-createdAt').then(r => ({ key: 'posts', data: Array.isArray(r) ? r : [] })))
fetches.push(api.get('/campaigns').then(r => ({ key: 'campaigns', data: Array.isArray(r) ? r : [] })))
@@ -315,7 +329,6 @@ export default function Dashboard() {
}
}
// Filtered data based on date range
const filteredPosts = useMemo(() => {
if (!dateFrom && !dateTo) return posts
return posts.filter(p => {
@@ -343,7 +356,7 @@ export default function Dashboard() {
t.dueDate && new Date(t.dueDate) < new Date() && t.status !== 'done'
).length
const upcomingDeadlines = filteredTasks
const upcomingDeadlines = useMemo(() => filteredTasks
.filter(t => {
if (!t.dueDate || t.status === 'done') return false
const due = new Date(t.dueDate)
@@ -351,60 +364,27 @@ export default function Dashboard() {
return isAfter(due, now) && isBefore(due, addDays(now, 7))
})
.sort((a, b) => new Date(a.dueDate) - new Date(b.dueDate))
.slice(0, 8)
.slice(0, 6), [filteredTasks])
const statCards = []
// Inline stat values no card component needed
const stats = []
if (hasModule('marketing')) {
statCards.push({
icon: FileText,
label: t('dashboard.totalPosts'),
value: filteredPosts.length || 0,
subtitle: `${filteredPosts.filter(p => p.status === 'published').length} ${t('dashboard.published')}`,
color: 'brand-primary',
})
statCards.push({
icon: Megaphone,
label: t('dashboard.activeCampaigns'),
value: activeCampaigns,
subtitle: `${campaigns.length} ${t('dashboard.total')}`,
color: 'brand-secondary',
})
}
if (hasModule('finance')) {
statCards.push({
icon: Landmark,
label: t('dashboard.budgetRemaining'),
value: `${(finance?.remaining ?? 0).toLocaleString()}`,
subtitle: finance?.totalReceived ? `${(finance.spent || 0).toLocaleString()} ${t('dashboard.spent')} ${t('dashboard.of')} ${finance.totalReceived.toLocaleString()} ${currencySymbol}` : t('dashboard.noBudget'),
color: 'brand-tertiary',
})
stats.push({ label: t('dashboard.totalPosts'), value: filteredPosts.length, detail: `${filteredPosts.filter(p => p.status === 'published').length} ${t('dashboard.published')}`, icon: FileText, accent: 'text-indigo-600' })
stats.push({ label: t('dashboard.activeCampaigns'), value: activeCampaigns, detail: `${campaigns.length} ${t('dashboard.total')}`, icon: Megaphone, accent: 'text-pink-600' })
}
if (hasModule('projects')) {
statCards.push({
icon: AlertTriangle,
label: t('dashboard.overdueTasks'),
value: overdueTasks,
subtitle: overdueTasks > 0 ? t('dashboard.needsAttention') : t('dashboard.allOnTrack'),
color: 'brand-quaternary',
})
stats.push({ label: t('dashboard.overdueTasks'), value: overdueTasks, detail: overdueTasks > 0 ? t('dashboard.needsAttention') : t('dashboard.allOnTrack'), icon: AlertTriangle, accent: overdueTasks > 0 ? 'text-red-600' : 'text-emerald-600' })
}
if (loading) {
return <SkeletonDashboard />
}
if (loading) return <SkeletonDashboard />
return (
<div className="space-y-6 animate-fade-in">
{/* Welcome + Date presets */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<h1 className="text-2xl font-bold text-gradient">
{t('dashboard.welcomeBack')}, {currentUser?.name || 'there'}
</h1>
<p className="text-text-secondary mt-1">
{t('dashboard.happeningToday')}
</p>
</div>
<p className="text-lg font-medium text-text-primary">
{t('dashboard.welcomeBack')}, {currentUser?.name || 'there'}
</p>
<DatePresetPicker
activePreset={activePreset}
onSelect={(from, to, key) => { setDateFrom(from); setDateTo(to); setActivePreset(key) }}
@@ -412,11 +392,18 @@ export default function Dashboard() {
/>
</div>
{/* Stats */}
{statCards.length > 0 && (
<div className={`grid grid-cols-1 sm:grid-cols-2 ${statCards.length >= 4 ? 'lg:grid-cols-4' : statCards.length === 3 ? 'lg:grid-cols-3' : 'lg:grid-cols-2'} gap-4 stagger-children`}>
{statCards.map((card, i) => (
<StatCard key={i} {...card} />
{/* Stats — compact inline row, no cards */}
{stats.length > 0 && (
<div className="flex flex-wrap gap-6">
{stats.map((s, i) => (
<div key={i} className="flex items-center gap-3">
<s.icon className={`w-5 h-5 ${s.accent}`} />
<div>
<span className="text-2xl font-bold text-text-primary">{s.value}</span>
<span className="text-sm text-text-tertiary ms-1.5">{s.label}</span>
<p className="text-xs text-text-tertiary">{s.detail}</p>
</div>
</div>
))}
</div>
)}
@@ -432,7 +419,7 @@ export default function Dashboard() {
{/* Budget + Active Campaigns */}
{(hasModule('finance') || hasModule('marketing')) && (
<div className={`grid grid-cols-1 ${hasModule('finance') && hasModule('marketing') ? 'lg:grid-cols-3' : ''} gap-6`}>
{hasModule('finance') && <FinanceMini finance={finance} />}
{hasModule('finance') && <BudgetSummary finance={finance} />}
{hasModule('marketing') && (
<div className={hasModule('finance') ? 'lg:col-span-2' : ''}>
<ActiveCampaignsList campaigns={campaigns} finance={finance} />
@@ -441,86 +428,14 @@ export default function Dashboard() {
</div>
)}
{/* Recent Posts + Upcoming Deadlines */}
{/* Activity — merged posts + deadlines */}
{(hasModule('marketing') || hasModule('projects')) && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Recent Posts */}
{hasModule('marketing') && (
<div className="section-card">
<div className="section-card-header flex items-center justify-between">
<h3 className="font-semibold text-text-primary">{t('dashboard.recentPosts')}</h3>
<Link to="/posts" className="text-sm text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1">
{t('dashboard.viewAll')} <ArrowRight className="w-3.5 h-3.5" />
</Link>
</div>
<div className="divide-y divide-border-light">
{filteredPosts.length === 0 ? (
<div className="py-12 text-center text-sm text-text-tertiary">
{t('dashboard.noPostsYet')}
</div>
) : (
filteredPosts.slice(0, 8).map((post) => (
<div
key={post._id}
onClick={() => navigate('/posts')}
className="flex items-center gap-3 px-5 py-3 hover:bg-surface-secondary transition-colors cursor-pointer"
>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{post.title}</p>
<div className="flex items-center gap-2 mt-1">
{post.brand && <BrandBadge brand={post.brand} />}
</div>
</div>
<StatusBadge status={post.status} size="xs" />
</div>
))
)}
</div>
</div>
)}
{/* Upcoming Deadlines */}
{hasModule('projects') && (
<div className="section-card">
<div className="section-card-header flex items-center justify-between">
<h3 className="font-semibold text-text-primary">{t('dashboard.upcomingDeadlines')}</h3>
<Link to="/tasks" className="text-sm text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1">
{t('dashboard.viewAll')} <ArrowRight className="w-3.5 h-3.5" />
</Link>
</div>
<div className="divide-y divide-border-light">
{upcomingDeadlines.length === 0 ? (
<div className="py-12 text-center text-sm text-text-tertiary">
{t('dashboard.noUpcomingDeadlines')}
</div>
) : (
upcomingDeadlines.map((task) => (
<div
key={task._id}
onClick={() => navigate('/tasks')}
className="flex items-center gap-3 px-5 py-3 hover:bg-surface-secondary transition-colors cursor-pointer"
>
<div className={`w-2 h-2 rounded-full ${(PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium).color}`} />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{task.title}</p>
<div className="flex items-center gap-2 mt-0.5">
<StatusBadge status={task.status} size="xs" />
{task.assignedName && (
<span className="text-xs text-text-tertiary truncate">{task.assignedName}</span>
)}
</div>
</div>
<div className="flex items-center gap-1 text-xs text-text-tertiary shrink-0">
<Clock className="w-3.5 h-3.5" />
{format(new Date(task.dueDate), 'MMM d')}
</div>
</div>
))
)}
</div>
</div>
)}
</div>
<ActivityFeed
posts={hasModule('marketing') ? filteredPosts : []}
deadlines={hasModule('projects') ? upcomingDeadlines : []}
navigate={navigate}
t={t}
/>
)}
</div>
)
+262 -46
View File
@@ -1,14 +1,16 @@
import { useState, useEffect, useContext } from 'react'
import { DollarSign, TrendingUp, TrendingDown, Wallet, Landmark, Eye, MousePointer, Target, Briefcase, ArrowRight, Receipt } from 'lucide-react'
import { DollarSign, TrendingUp, TrendingDown, Wallet, Landmark, Eye, MousePointer, Target, Briefcase, ArrowRight, Receipt, Plus, X } from 'lucide-react'
import { Link } from 'react-router-dom'
import { AppContext } from '../App'
import { api } from '../utils/api'
import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../i18n/LanguageContext'
import StatusBadge from '../components/StatusBadge'
import Modal from '../components/Modal'
import { useToast } from '../components/ToastContainer'
import { SkeletonStatCard, SkeletonTable } from '../components/SkeletonLoader'
function FinanceStatCard({ icon: Icon, label, value, sub, color = 'text-text-primary', bgColor = 'bg-white' }) {
function FinanceStatCard({ icon: Icon, label, value, sub, color = 'text-text-primary', bgColor = 'bg-surface' }) {
return (
<div className={`${bgColor} rounded-xl border border-border p-5`}>
<div className="flex items-center gap-2 mb-2">
@@ -40,19 +42,36 @@ function ProgressRing({ pct, size = 80, stroke = 8, color = '#10b981' }) {
)
}
const BUDGET_REQUEST_STATUS_COLORS = {
pending: 'bg-amber-100 text-amber-800',
approved: 'bg-emerald-100 text-emerald-800',
rejected: 'bg-red-100 text-red-800',
cancelled: 'bg-gray-100 text-gray-600',
}
export default function Finance() {
const { brands } = useContext(AppContext)
const { permissions } = useAuth()
const { currencySymbol } = useLanguage()
const { permissions, user } = useAuth()
const { t, currencySymbol } = useLanguage()
const toast = useToast()
const [summary, setSummary] = useState(null)
const [loading, setLoading] = useState(true)
const [budgetRequests, setBudgetRequests] = useState([])
const [showRequestModal, setShowRequestModal] = useState(false)
const [requestForm, setRequestForm] = useState({ amount: '', justification: '', earmark_type: '', earmark_id: '' })
const [submittingRequest, setSubmittingRequest] = useState(false)
const isSuperadmin = user?.role === 'superadmin'
useEffect(() => { loadAll() }, [])
const loadAll = async () => {
try {
const sum = await api.get('/finance/summary')
const fetches = [api.get('/finance/summary')]
if (isSuperadmin) fetches.push(api.get('/budget-requests').catch(() => []))
const [sum, reqs] = await Promise.all(fetches)
setSummary(sum.data || sum || {})
if (reqs) setBudgetRequests(Array.isArray(reqs) ? reqs : [])
} catch (err) {
console.error('Failed to load finance:', err)
} finally {
@@ -60,6 +79,41 @@ export default function Finance() {
}
}
const handleSubmitRequest = async () => {
if (!requestForm.amount || !requestForm.justification.trim()) return
setSubmittingRequest(true)
try {
const body = {
amount: Number(requestForm.amount),
justification: requestForm.justification.trim(),
}
if (requestForm.earmark_type === 'campaign' && requestForm.earmark_id) {
body.earmarked_campaign_id = Number(requestForm.earmark_id)
} else if (requestForm.earmark_type === 'project' && requestForm.earmark_id) {
body.earmarked_project_id = Number(requestForm.earmark_id)
}
await api.post('/budget-requests', body)
toast.success(t('finance.requestBudget') + ' — ' + t('common.success'))
setShowRequestModal(false)
setRequestForm({ amount: '', justification: '', earmark_type: '', earmark_id: '' })
loadAll()
} catch (err) {
toast.error(err.message || t('common.error'))
} finally {
setSubmittingRequest(false)
}
}
const handleCancelRequest = async (id) => {
try {
await api.patch(`/budget-requests/${id}/cancel`)
toast.success(t('common.success'))
loadAll()
} catch (err) {
toast.error(err.message || t('common.error'))
}
}
if (loading) {
return (
<div className="space-y-4">
@@ -86,18 +140,35 @@ export default function Finance() {
const projectPct = totalReceived > 0 ? (totalProjectBudget / totalReceived) * 100 : 0
const unallocatedPct = totalReceived > 0 ? (Math.max(0, unallocated) / totalReceived) * 100 : 0
const campaigns = s.campaigns || []
const projects = s.projects || []
const pendingCount = budgetRequests.filter(r => r.status === 'pending').length
return (
<div className="space-y-6 animate-fade-in">
{/* Request Budget button (superadmin) */}
{isSuperadmin && (
<div className="flex justify-end">
<button
onClick={() => setShowRequestModal(true)}
className="flex items-center gap-2 px-4 py-2.5 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light transition-colors shadow-sm"
>
<Plus className="w-4 h-4" />
{t('finance.requestBudget')}
</button>
</div>
)}
{/* Top metrics */}
<div className={`grid grid-cols-2 ${totalExpenses > 0 ? 'lg:grid-cols-6' : 'lg:grid-cols-5'} gap-4 stagger-children`}>
<FinanceStatCard icon={Wallet} label="Total Received" value={`${totalReceived.toLocaleString()} ${currencySymbol}`} color="text-blue-600" />
<FinanceStatCard icon={TrendingUp} label="Total Spent" value={`${totalSpent.toLocaleString()} ${currencySymbol}`} sub={`${spendPct.toFixed(1)}% of budget`} color="text-amber-600" />
<FinanceStatCard icon={Wallet} label={t('finance.totalReceived')} value={`${totalReceived.toLocaleString()} ${currencySymbol}`} color="text-blue-600" />
<FinanceStatCard icon={TrendingUp} label={t('finance.totalSpent')} value={`${totalSpent.toLocaleString()} ${currencySymbol}`} sub={`${spendPct.toFixed(1)}% ${t('finance.ofBudget')}`} color="text-amber-600" />
{totalExpenses > 0 && (
<FinanceStatCard icon={Receipt} label="Expenses" value={`${totalExpenses.toLocaleString()} ${currencySymbol}`} color="text-red-600" />
<FinanceStatCard icon={Receipt} label={t('finance.expenses')} value={`${totalExpenses.toLocaleString()} ${currencySymbol}`} color="text-red-600" />
)}
<FinanceStatCard icon={Landmark} label="Remaining" value={`${remaining.toLocaleString()} ${currencySymbol}`} color={remaining >= 0 ? 'text-emerald-600' : 'text-red-600'} />
<FinanceStatCard icon={DollarSign} label="Revenue" value={`${totalRevenue.toLocaleString()} ${currencySymbol}`} color="text-purple-600" />
<FinanceStatCard icon={roi >= 0 ? TrendingUp : TrendingDown} label="Global ROI"
<FinanceStatCard icon={Landmark} label={t('finance.remaining')} value={`${remaining.toLocaleString()} ${currencySymbol}`} color={remaining >= 0 ? 'text-emerald-600' : 'text-red-600'} />
<FinanceStatCard icon={DollarSign} label={t('finance.revenue')} value={`${totalRevenue.toLocaleString()} ${currencySymbol}`} color="text-purple-600" />
<FinanceStatCard icon={roi >= 0 ? TrendingUp : TrendingDown} label={t('finance.globalROI')}
value={`${roi.toFixed(1)}%`}
color={roi >= 0 ? 'text-emerald-600' : 'text-red-600'} />
</div>
@@ -106,9 +177,9 @@ export default function Finance() {
{totalReceived > 0 && (
<div className="section-card p-5">
<div className="flex items-center justify-between mb-3">
<h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium">Budget Allocation</h3>
<h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium">{t('finance.budgetAllocation')}</h3>
<Link to="/budgets" className="text-xs text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1">
Manage Budgets <ArrowRight className="w-3 h-3" />
{t('finance.manageBudgets')} <ArrowRight className="w-3 h-3" />
</Link>
</div>
<div className="h-4 bg-surface-tertiary rounded-full overflow-hidden flex">
@@ -122,17 +193,17 @@ export default function Finance() {
<div className="flex items-center gap-4 mt-2.5 text-xs">
<div className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-full bg-blue-500" />
<span className="text-text-secondary">Campaigns: <span className="font-semibold text-text-primary">{totalCampaignBudget.toLocaleString()} {currencySymbol}</span></span>
<span className="text-text-secondary">{t('finance.campaigns')}: <span className="font-semibold text-text-primary">{totalCampaignBudget.toLocaleString()} {currencySymbol}</span></span>
<span className="text-text-tertiary">({campaignPct.toFixed(0)}%)</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-full bg-purple-500" />
<span className="text-text-secondary">Projects: <span className="font-semibold text-text-primary">{totalProjectBudget.toLocaleString()} {currencySymbol}</span></span>
<span className="text-text-secondary">{t('finance.projects')}: <span className="font-semibold text-text-primary">{totalProjectBudget.toLocaleString()} {currencySymbol}</span></span>
<span className="text-text-tertiary">({projectPct.toFixed(0)}%)</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-full bg-gray-300" />
<span className="text-text-secondary">Unallocated: <span className="font-semibold text-text-primary">{Math.max(0, unallocated).toLocaleString()} {currencySymbol}</span></span>
<span className="text-text-secondary">{t('finance.unallocated')}: <span className="font-semibold text-text-primary">{Math.max(0, unallocated).toLocaleString()} {currencySymbol}</span></span>
<span className="text-text-tertiary">({unallocatedPct.toFixed(0)}%)</span>
</div>
</div>
@@ -143,7 +214,7 @@ export default function Finance() {
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{/* Utilization ring */}
<div className="section-card p-5 flex flex-col items-center justify-center">
<h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium mb-4">Budget Utilization</h3>
<h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium mb-4">{t('finance.budgetUtilization')}</h3>
<ProgressRing
pct={spendPct}
size={120}
@@ -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 &middot; 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>
)
}
+16 -7
View File
@@ -1,9 +1,18 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { useLanguage } from '../i18n/LanguageContext'
import { Megaphone, Mail, AlertCircle, CheckCircle, ArrowLeft } from 'lucide-react'
import { Mail, AlertCircle, CheckCircle, ArrowLeft } from 'lucide-react'
import { api } from '../utils/api'
function MarkaLogo({ className = '' }) {
return (
<svg viewBox="0 0 32 32" fill="none" className={className}>
<path d="M4 26V6l10 10L4 26z" fill="currentColor" opacity="0.85" />
<path d="M18 26V6l10 10-10 10z" fill="currentColor" opacity="0.5" />
</svg>
)
}
export default function ForgotPassword() {
const { t } = useLanguage()
const [email, setEmail] = useState('')
@@ -27,11 +36,11 @@ export default function ForgotPassword() {
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-blue-900 to-slate-900 flex items-center justify-center px-4">
<div className="min-h-screen bg-slate-900 flex items-center justify-center px-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-purple-600 rounded-2xl flex items-center justify-center mx-auto mb-4 shadow-lg">
<Megaphone className="w-8 h-8 text-white" />
<div className="w-16 h-16 bg-brand-primary rounded-2xl flex items-center justify-center mx-auto mb-4">
<MarkaLogo className="w-9 h-9 text-white" />
</div>
<h1 className="text-3xl font-bold text-white mb-2">{t('forgotPassword.title')}</h1>
<p className="text-slate-400">{t('forgotPassword.subtitle')}</p>
@@ -57,13 +66,13 @@ export default function ForgotPassword() {
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">{t('auth.email')}</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<Mail className="absolute start-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
dir="auto"
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
className="w-full ps-11 pe-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
placeholder={t('forgotPassword.emailPlaceholder')}
required
autoFocus
@@ -81,7 +90,7 @@ export default function ForgotPassword() {
<button
type="submit"
disabled={loading}
className="w-full py-3 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed"
className="w-full py-3 bg-brand-primary hover:bg-brand-primary-light text-white font-semibold rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? (
<span className="flex items-center justify-center gap-2">
+15 -23
View File
@@ -196,8 +196,8 @@ export default function Issues() {
const SortIcon = ({ col }) => {
if (sortBy !== col) return null
return sortDir === 'asc'
? <ChevronUp className="w-3 h-3 inline ml-0.5" />
: <ChevronDown className="w-3 h-3 inline ml-0.5" />
? <ChevronUp className="w-3 h-3 inline ms-0.5" />
: <ChevronDown className="w-3 h-3 inline ms-0.5" />
}
if (loading) {
@@ -211,15 +211,7 @@ export default function Issues() {
return (
<div className="space-y-4 animate-fade-in">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-text-primary flex items-center gap-2">
<AlertCircle className="w-7 h-7" />
{t('issues.title')}
</h1>
<p className="text-text-secondary mt-1">{t('issues.subtitle')}</p>
</div>
<div className="flex items-center justify-end">
<div className="flex items-center gap-3">
<button
onClick={copyPublicLink}
@@ -241,7 +233,7 @@ export default function Issues() {
onClick={() => setViewMode(mode)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
viewMode === mode
? 'bg-white text-text-primary shadow-sm'
? 'bg-surface text-text-primary shadow-sm'
: 'text-text-tertiary hover:text-text-secondary'
}`}
>
@@ -276,13 +268,13 @@ export default function Issues() {
<div className="flex items-center gap-3 flex-wrap">
{/* Search */}
<div className="relative flex-1 min-w-[200px] max-w-xs">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<Search className="absolute start-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<input
type="text"
placeholder={t('issues.searchPlaceholder')}
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 bg-surface"
className="w-full ps-10 pe-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 bg-surface"
/>
</div>
@@ -413,21 +405,21 @@ export default function Issues() {
<th className="w-10 px-3 py-3" onClick={e => e.stopPropagation()}>
<input type="checkbox" checked={selectedIds.size === sortedIssues.length && sortedIssues.length > 0} onChange={toggleSelectAll} className="rounded border-border" />
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleSort('title')}>
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleSort('title')}>
{t('issues.tableTitle')} <SortIcon col="title" />
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('issues.tableSubmitter')}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('issues.tableBrand')}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('issues.tableCategory')}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('issues.tableType')}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleSort('priority')}>
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('issues.tableSubmitter')}</th>
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('issues.tableBrand')}</th>
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('issues.tableCategory')}</th>
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('issues.tableType')}</th>
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleSort('priority')}>
{t('issues.tablePriority')} <SortIcon col="priority" />
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleSort('status')}>
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleSort('status')}>
{t('issues.tableStatus')} <SortIcon col="status" />
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('issues.tableAssignedTo')}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleSort('created_at')}>
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('issues.tableAssignedTo')}</th>
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleSort('created_at')}>
{t('issues.tableCreated')} <SortIcon col="created_at" />
</th>
</tr>
+33 -21
View File
@@ -2,9 +2,18 @@ import { useState, useEffect } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../i18n/LanguageContext'
import { Megaphone, Lock, Mail, AlertCircle, User, CheckCircle } from 'lucide-react'
import { Lock, Mail, AlertCircle, User, CheckCircle } from 'lucide-react'
import { api } from '../utils/api'
function MarkaLogo({ className = '' }) {
return (
<svg viewBox="0 0 32 32" fill="none" className={className}>
<path d="M4 26V6l10 10L4 26z" fill="currentColor" opacity="0.85" />
<path d="M18 26V6l10 10-10 10z" fill="currentColor" opacity="0.5" />
</svg>
)
}
export default function Login() {
const navigate = useNavigate()
const { login } = useAuth()
@@ -63,19 +72,19 @@ export default function Login() {
if (needsSetup === null) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-blue-900 to-slate-900 flex items-center justify-center">
<div className="min-h-screen bg-slate-900 flex items-center justify-center">
<div className="w-8 h-8 border-2 border-white/30 border-t-white rounded-full animate-spin" />
</div>
)
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-blue-900 to-slate-900 flex items-center justify-center px-4">
<div className="min-h-screen bg-slate-900 flex items-center justify-center px-4">
<div className="w-full max-w-md">
{/* Logo & Title */}
<div className="text-center mb-8">
<div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-purple-600 rounded-2xl flex items-center justify-center mx-auto mb-4 shadow-lg">
<Megaphone className="w-8 h-8 text-white" />
<div className="w-16 h-16 bg-brand-primary rounded-2xl flex items-center justify-center mx-auto mb-4">
<MarkaLogo className="w-9 h-9 text-white" />
</div>
<h1 className="text-3xl font-bold text-white mb-2">
{needsSetup ? t('login.initialSetup') : t('login.title')}
@@ -101,15 +110,16 @@ export default function Login() {
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">{t('login.fullName')}</label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<User className="absolute start-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<input
type="text"
value={setupName}
onChange={(e) => setSetupName(e.target.value)}
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
className="w-full ps-11 pe-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
placeholder={t('login.fullNamePlaceholder')}
required
autoFocus
aria-describedby={error ? 'setup-error' : undefined}
/>
</div>
</div>
@@ -118,13 +128,13 @@ export default function Login() {
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">{t('login.email')}</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<Mail className="absolute start-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<input
type="email"
value={setupEmail}
onChange={(e) => setSetupEmail(e.target.value)}
dir="auto"
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
className="w-full ps-11 pe-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
placeholder="admin@company.com"
required
/>
@@ -135,12 +145,12 @@ export default function Login() {
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">{t('login.password')}</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<Lock className="absolute start-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<input
type="password"
value={setupPassword}
onChange={(e) => setSetupPassword(e.target.value)}
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
className="w-full ps-11 pe-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
placeholder={t('login.passwordPlaceholder')}
required
minLength={6}
@@ -152,12 +162,12 @@ export default function Login() {
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">{t('login.confirmPassword')}</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<Lock className="absolute start-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<input
type="password"
value={setupConfirm}
onChange={(e) => setSetupConfirm(e.target.value)}
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
className="w-full ps-11 pe-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
placeholder={t('login.confirmPasswordPlaceholder')}
required
minLength={6}
@@ -167,7 +177,7 @@ export default function Login() {
{/* Error */}
{error && (
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
<div id="setup-error" className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg" role="alert">
<AlertCircle className="w-5 h-5 text-red-400 shrink-0" />
<p className="text-sm text-red-400">{error}</p>
</div>
@@ -177,7 +187,7 @@ export default function Login() {
<button
type="submit"
disabled={loading}
className="w-full py-3 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed"
className="w-full py-3 bg-brand-primary hover:bg-brand-primary-light text-white font-semibold rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? (
<span className="flex items-center justify-center gap-2">
@@ -197,16 +207,17 @@ export default function Login() {
{t('auth.email')}
</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<Mail className="absolute start-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
dir="auto"
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
className="w-full ps-11 pe-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
placeholder="user@company.com"
required
autoFocus
aria-describedby={error ? 'login-error' : undefined}
/>
</div>
</div>
@@ -217,21 +228,22 @@ export default function Login() {
{t('auth.password')}
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<Lock className="absolute start-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
className="w-full ps-11 pe-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
placeholder="••••••••"
required
aria-describedby={error ? 'login-error' : undefined}
/>
</div>
</div>
{/* Error */}
{error && (
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
<div id="login-error" className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg" role="alert">
<AlertCircle className="w-5 h-5 text-red-400 shrink-0" />
<p className="text-sm text-red-400">{error}</p>
</div>
@@ -241,7 +253,7 @@ export default function Login() {
<button
type="submit"
disabled={loading}
className="w-full py-3 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed"
className="w-full py-3 bg-brand-primary hover:bg-brand-primary-light text-white font-semibold rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? (
<span className="flex items-center justify-center gap-2">
+14 -22
View File
@@ -6,7 +6,7 @@ import { api, PLATFORMS } from '../utils/api'
import PostDetailPanel from '../components/PostDetailPanel'
import { SkeletonCalendar } from '../components/SkeletonLoader'
const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
const DAY_KEYS = ['calendar.sun', 'calendar.mon', 'calendar.tue', 'calendar.wed', 'calendar.thu', 'calendar.fri', 'calendar.sat']
const STATUS_COLORS = {
draft: 'bg-surface-tertiary text-text-secondary',
@@ -158,14 +158,6 @@ export default function PostCalendar() {
return (
<div className="space-y-4 animate-fade-in">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-text-primary">Content Calendar</h1>
<p className="text-sm text-text-secondary mt-1">Schedule and plan your posts</p>
</div>
</div>
{/* Filters */}
<div className="flex flex-wrap items-center gap-3">
<select
@@ -202,7 +194,7 @@ export default function PostCalendar() {
</div>
{/* Calendar */}
<div className="bg-surface rounded-xl border border-border overflow-hidden">
<div className="bg-surface rounded-xl border border-border overflow-clip">
{/* Nav */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<div className="flex items-center gap-3">
@@ -220,30 +212,30 @@ export default function PostCalendar() {
<div className="flex bg-surface-tertiary rounded-lg p-0.5">
<button
onClick={() => setCalView('month')}
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors flex items-center gap-1.5 ${calView === 'month' ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary'}`}
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors flex items-center gap-1.5 ${calView === 'month' ? 'bg-surface shadow-sm text-text-primary' : 'text-text-tertiary'}`}
>
<CalendarIcon className="w-3.5 h-3.5" />
Month
{t('calendar.month')}
</button>
<button
onClick={() => setCalView('week')}
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors flex items-center gap-1.5 ${calView === 'week' ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary'}`}
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors flex items-center gap-1.5 ${calView === 'week' ? 'bg-surface shadow-sm text-text-primary' : 'text-text-tertiary'}`}
>
<CalendarDays className="w-3.5 h-3.5" />
Week
{t('calendar.week')}
</button>
</div>
<button onClick={goToday} className="px-4 py-2 text-sm font-medium text-brand-primary hover:bg-brand-primary/5 rounded-lg transition-colors">
Today
{t('calendar.today')}
</button>
</div>
</div>
{/* Day headers */}
<div className="grid grid-cols-7 border-b border-border bg-surface-secondary">
{DAYS.map(d => (
<div key={d} className="text-center text-xs font-semibold text-text-tertiary uppercase py-3">
{d}
{DAY_KEYS.map(k => (
<div key={k} className="text-center text-xs font-semibold text-text-tertiary uppercase py-3">
{t(k)}
</div>
))}
</div>
@@ -271,7 +263,7 @@ export default function PostCalendar() {
<button
key={post.Id || post._id}
onClick={() => handlePostClick(post)}
className={`w-full text-left text-[10px] px-2 py-1 rounded font-medium hover:opacity-80 transition-opacity truncate ${
className={`w-full text-start text-[10px] px-2 py-1 rounded font-medium hover:opacity-80 transition-opacity truncate ${
STATUS_COLORS[post.status] || 'bg-surface-tertiary text-text-secondary'
}`}
title={post.title}
@@ -294,13 +286,13 @@ export default function PostCalendar() {
{/* Unscheduled Posts */}
{unscheduled.length > 0 && (
<div className="bg-surface rounded-xl border border-border p-6">
<h3 className="text-sm font-semibold text-text-tertiary uppercase mb-3">Unscheduled Posts</h3>
<h3 className="text-sm font-semibold text-text-tertiary uppercase mb-3">{t('calendar.unscheduledPosts')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{unscheduled.map(post => (
<button
key={post.Id || post._id}
onClick={() => handlePostClick(post)}
className="text-left bg-surface-secondary border border-border rounded-lg p-3 hover:border-brand-primary/30 transition-colors"
className="text-start bg-surface-secondary border border-border rounded-lg p-3 hover:border-brand-primary/30 transition-colors"
>
<div className="flex items-center gap-2 mb-1">
<span className={`text-xs px-2 py-0.5 rounded-full ${STATUS_COLORS[post.status] || 'bg-surface-tertiary text-text-secondary'}`}>
@@ -319,7 +311,7 @@ export default function PostCalendar() {
{/* Legend */}
<div className="bg-surface rounded-xl border border-border p-4">
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-3">Status Legend</h4>
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-3">{t('calendar.statusLegend')}</h4>
<div className="flex flex-wrap gap-3">
{Object.entries(STATUS_COLORS).map(([status, color]) => (
<div key={status} className="flex items-center gap-2">
+626
View File
@@ -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>
)
}
+31 -122
View File
@@ -1,4 +1,5 @@
import { useState, useEffect, useContext } from 'react'
import { useState, useEffect, useContext, useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import { Plus, LayoutGrid, List, Search, X, FileText, Filter } from 'lucide-react'
import { AppContext } from '../App'
import { useAuth } from '../contexts/AuthContext'
@@ -7,12 +8,11 @@ import { api, PLATFORMS } from '../utils/api'
import KanbanBoard from '../components/KanbanBoard'
import KanbanCard from '../components/KanbanCard'
import PostCard from '../components/PostCard'
import PostDetailPanel from '../components/PostDetailPanel'
import DatePresetPicker from '../components/DatePresetPicker'
import { SkeletonKanbanBoard, SkeletonTable } from '../components/SkeletonLoader'
import EmptyState from '../components/EmptyState'
import BulkSelectBar from '../components/BulkSelectBar'
import Modal from '../components/Modal'
import BulkSelectBar from '../components/BulkSelectBar'
import { useToast } from '../components/ToastContainer'
const EMPTY_POST = {
@@ -23,13 +23,13 @@ const EMPTY_POST = {
export default function PostProduction() {
const { t, lang } = useLanguage()
const navigate = useNavigate()
const { teamMembers, brands, getBrandName } = useContext(AppContext)
const { canEditResource } = useAuth()
const toast = useToast()
const [posts, setPosts] = useState([])
const [loading, setLoading] = useState(true)
const [view, setView] = useState('kanban')
const [panelPost, setPanelPost] = useState(null)
const [campaigns, setCampaigns] = useState([])
const [filters, setFilters] = useState({ brand: '', platform: '', assignedTo: '', campaign: '', periodFrom: '', periodTo: '' })
const [searchTerm, setSearchTerm] = useState('')
@@ -38,9 +38,6 @@ export default function PostProduction() {
const [selectedIds, setSelectedIds] = useState(new Set())
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false)
const [showFilters, setShowFilters] = useState(false)
const [showCreateModal, setShowCreateModal] = useState(false)
const [createForm, setCreateForm] = useState({ ...EMPTY_POST })
const [createSaving, setCreateSaving] = useState(false)
useEffect(() => {
loadPosts()
@@ -78,20 +75,6 @@ export default function PostProduction() {
}
}
const handlePanelSave = async (postId, data) => {
let result
if (postId) {
result = await api.patch(`/posts/${postId}`, data)
toast.success(t('posts.updated'))
} else {
result = await api.post('/posts', data)
toast.success(t('posts.created'))
}
loadPosts()
// Update panel with fresh server data so form stays in sync
if (result && postId) setPanelPost(result)
}
const handlePanelDelete = async (postId) => {
try {
await api.delete(`/posts/${postId}`)
@@ -131,43 +114,22 @@ export default function PostProduction() {
}
const openEdit = (post) => {
if (!canEditResource('post', post)) {
toast.error(t('posts.canOnlyEditOwn'))
return
}
setPanelPost(post)
const postId = post._id || post.id || post.Id
navigate(`/posts/${postId}`)
}
const openNew = () => {
setCreateForm({ ...EMPTY_POST })
setShowCreateModal(true)
}
const handleCreate = async () => {
setCreateSaving(true)
const openNew = async () => {
try {
const data = {
title: createForm.title,
brand_id: createForm.brand_id ? Number(createForm.brand_id) : null,
campaign_id: createForm.campaign_id ? Number(createForm.campaign_id) : null,
assigned_to: createForm.assigned_to ? Number(createForm.assigned_to) : null,
status: 'draft',
}
const created = await api.post('/posts', data)
setShowCreateModal(false)
const result = await api.post('/posts', { title: '', status: 'draft', platforms: [] })
const newId = result._id || result.id || result.Id
toast.success(t('posts.created'))
loadPosts()
// Open the detail panel for further editing
if (created) setPanelPost(created)
} catch (err) {
console.error('Create post failed:', err)
navigate(`/posts/${newId}`)
} catch {
toast.error(t('common.saveFailed'))
} finally {
setCreateSaving(false)
}
}
const filteredPosts = posts.filter(p => {
const filteredPosts = useMemo(() => posts.filter(p => {
if (filters.brand && String(p.brandId || p.brand_id) !== filters.brand) return false
if (filters.platform && !(p.platforms || []).includes(filters.platform) && p.platform !== filters.platform) return false
if (filters.assignedTo && String(p.assignedTo || p.assigned_to) !== filters.assignedTo) return false
@@ -181,7 +143,7 @@ export default function PostProduction() {
if (filters.periodTo && d > filters.periodTo) return false
}
return true
})
}), [posts, filters, searchTerm])
if (loading) {
return view === 'kanban' ? <SkeletonKanbanBoard /> : <SkeletonTable rows={8} cols={6} />
@@ -193,20 +155,20 @@ export default function PostProduction() {
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-3">
<div className="relative flex-1 min-w-[200px] max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<Search className="absolute start-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<input
type="text"
placeholder={t('posts.searchPosts')}
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-white"
className="w-full ps-10 pe-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface"
/>
</div>
<button
data-tutorial="filters"
onClick={() => setShowFilters(f => !f)}
className={`relative flex items-center gap-1.5 px-3 py-2 text-sm border rounded-lg transition-colors ${showFilters ? 'border-brand-primary bg-brand-primary/5 text-brand-primary' : 'border-border bg-white text-text-secondary hover:border-brand-primary/40'}`}
className={`relative flex items-center gap-1.5 px-3 py-2 text-sm border rounded-lg transition-colors ${showFilters ? 'border-brand-primary bg-brand-primary/5 text-brand-primary' : 'border-border bg-surface text-text-secondary hover:border-brand-primary/40'}`}
>
<Filter className="w-4 h-4" />
{t('common.filter')}
@@ -215,16 +177,16 @@ export default function PostProduction() {
)}
</button>
<div className="flex bg-surface-tertiary rounded-lg p-0.5 ml-auto">
<div className="flex bg-surface-tertiary rounded-lg p-0.5 ms-auto">
<button
onClick={() => setView('kanban')}
className={`p-2 rounded-md ${view === 'kanban' ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary'}`}
className={`p-2 rounded-md ${view === 'kanban' ? 'bg-surface shadow-sm text-text-primary' : 'text-text-tertiary'}`}
>
<LayoutGrid className="w-4 h-4" />
</button>
<button
onClick={() => setView('list')}
className={`p-2 rounded-md ${view === 'list' ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary'}`}
className={`p-2 rounded-md ${view === 'list' ? 'bg-surface shadow-sm text-text-primary' : 'text-text-tertiary'}`}
>
<List className="w-4 h-4" />
</button>
@@ -245,7 +207,7 @@ export default function PostProduction() {
<select
value={filters.brand}
onChange={e => setFilters(f => ({ ...f, brand: e.target.value }))}
className="text-xs border border-border rounded-lg px-2.5 py-1.5 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
className="text-xs border border-border rounded-lg px-2.5 py-1.5 bg-surface text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
>
<option value="">{t('posts.allBrands')}</option>
{brands.map(b => <option key={b._id} value={b._id}>{lang === 'ar' && b.name_ar ? b.name_ar : b.name}</option>)}
@@ -254,7 +216,7 @@ export default function PostProduction() {
<select
value={filters.platform}
onChange={e => setFilters(f => ({ ...f, platform: e.target.value }))}
className="text-xs border border-border rounded-lg px-2.5 py-1.5 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
className="text-xs border border-border rounded-lg px-2.5 py-1.5 bg-surface text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
>
<option value="">{t('posts.allPlatforms')}</option>
{Object.entries(PLATFORMS).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
@@ -263,7 +225,7 @@ export default function PostProduction() {
<select
value={filters.assignedTo}
onChange={e => setFilters(f => ({ ...f, assignedTo: e.target.value }))}
className="text-xs border border-border rounded-lg px-2.5 py-1.5 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
className="text-xs border border-border rounded-lg px-2.5 py-1.5 bg-surface text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
>
<option value="">{t('posts.allPeople')}</option>
{teamMembers.map(m => <option key={m._id} value={String(m._id)}>{m.name}</option>)}
@@ -281,7 +243,7 @@ export default function PostProduction() {
value={filters.periodFrom}
onChange={e => { setFilters(f => ({ ...f, periodFrom: e.target.value })); setActivePreset('') }}
title={t('posts.periodFrom')}
className="text-xs border border-border rounded-lg px-2 py-1.5 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
className="text-xs border border-border rounded-lg px-2 py-1.5 bg-surface text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
/>
<span className="text-xs text-text-tertiary"></span>
<input
@@ -289,7 +251,7 @@ export default function PostProduction() {
value={filters.periodTo}
onChange={e => { setFilters(f => ({ ...f, periodTo: e.target.value })); setActivePreset('') }}
title={t('posts.periodTo')}
className="text-xs border border-border rounded-lg px-2 py-1.5 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
className="text-xs border border-border rounded-lg px-2 py-1.5 bg-surface text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
/>
</div>
</div>
@@ -334,7 +296,7 @@ export default function PostProduction() {
}}
/>
) : (
<div className="bg-white rounded-xl border border-border overflow-hidden">
<div className="bg-surface rounded-xl border border-border overflow-clip">
{filteredPosts.length === 0 ? (
<EmptyState
icon={FileText}
@@ -361,12 +323,12 @@ export default function PostProduction() {
<th className="w-10 px-3 py-3" onClick={e => e.stopPropagation()}>
<input type="checkbox" checked={selectedIds.size === filteredPosts.length && filteredPosts.length > 0} onChange={toggleSelectAll} className="rounded border-border" />
</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.postTitle')}</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.brand')}</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.status')}</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.platforms')}</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.assignTo')}</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.scheduledDate')}</th>
<th className="text-start px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.postTitle')}</th>
<th className="text-start px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.brand')}</th>
<th className="text-start px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.status')}</th>
<th className="text-start px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.platforms')}</th>
<th className="text-start px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.assignTo')}</th>
<th className="text-start px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.scheduledDate')}</th>
</tr>
</thead>
<tbody className="divide-y divide-border-light">
@@ -401,59 +363,6 @@ export default function PostProduction() {
{t('common.bulkDeleteDesc')}
</Modal>
{/* Create Post Modal */}
<Modal isOpen={showCreateModal} onClose={() => setShowCreateModal(false)} title={t('posts.newPost')} size="md">
<div className="space-y-4">
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.postTitle')} *</label>
<input type="text" value={createForm.title} onChange={e => setCreateForm(f => ({ ...f, title: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary" autoFocus />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.brand')}</label>
<select value={createForm.brand_id} onChange={e => setCreateForm(f => ({ ...f, brand_id: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary">
<option value="">{t('posts.allBrands')}</option>
{brands.map(b => <option key={b._id} value={b._id}>{lang === 'ar' && b.name_ar ? b.name_ar : b.name}</option>)}
</select>
</div>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.campaign')}</label>
<select value={createForm.campaign_id} onChange={e => setCreateForm(f => ({ ...f, campaign_id: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary">
<option value=""></option>
{campaigns.map(c => <option key={c._id || c.id} value={c._id || c.id}>{c.name}</option>)}
</select>
</div>
</div>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.assignedTo')}</label>
<select value={createForm.assigned_to} onChange={e => setCreateForm(f => ({ ...f, assigned_to: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary">
<option value="">{t('common.unassigned')}</option>
{teamMembers.map(m => <option key={m._id} value={String(m._id)}>{m.name}</option>)}
</select>
</div>
<button onClick={handleCreate} disabled={!createForm.title || createSaving}
className={`w-full px-4 py-2.5 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm ${createSaving ? 'btn-loading' : ''}`}>
{t('posts.newPost')}
</button>
</div>
</Modal>
{/* Post Detail Panel (edit only) */}
{panelPost && (
<PostDetailPanel
post={panelPost}
onClose={() => setPanelPost(null)}
onSave={handlePanelSave}
onDelete={handlePanelDelete}
brands={brands}
teamMembers={teamMembers}
campaigns={campaigns}
/>
)}
</div>
)
}
+19 -19
View File
@@ -223,14 +223,14 @@ export default function ProjectDetail() {
</button>
{/* Project header */}
<div className="bg-white rounded-xl border border-border overflow-hidden">
<div className="bg-surface rounded-xl border border-border overflow-clip">
{/* Thumbnail banner */}
{(project.thumbnail_url || project.thumbnailUrl) && (
<div className="relative w-full h-40 overflow-hidden">
<img src={project.thumbnail_url || project.thumbnailUrl} alt="" className="w-full h-full object-cover" />
<img src={project.thumbnail_url || project.thumbnailUrl} alt="" className="w-full h-full object-cover" loading="lazy" />
<div className="absolute inset-0 bg-gradient-to-t from-black/30 to-transparent" />
{canEditProject && (
<div className="absolute top-2 right-2 flex items-center gap-1">
<div className="absolute top-2 end-2 flex items-center gap-1">
<button
onClick={() => thumbnailInputRef.current?.click()}
className="px-2 py-1 text-xs bg-black/40 hover:bg-black/60 rounded text-white transition-colors"
@@ -341,7 +341,7 @@ export default function ProjectDetail() {
key={v.id}
onClick={() => setView(v.id)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
view === v.id ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary hover:text-text-secondary'
view === v.id ? 'bg-surface shadow-sm text-text-primary' : 'text-text-tertiary hover:text-text-secondary'
}`}
>
<v.icon className="w-4 h-4" />
@@ -411,21 +411,21 @@ export default function ProjectDetail() {
{/* ─── LIST VIEW ─── */}
{view === 'list' && (
<div className="bg-white rounded-xl border border-border overflow-hidden">
<div className="bg-surface rounded-xl border border-border overflow-clip">
<table className="w-full">
<thead>
<tr className="border-b border-border bg-surface-secondary">
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider w-8"></th>
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Task</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Status</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Priority</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Assignee</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Due</th>
<th className="text-start px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider w-8"></th>
<th className="text-start px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Task</th>
<th className="text-start px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Status</th>
<th className="text-start px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Priority</th>
<th className="text-start px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Assignee</th>
<th className="text-start px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Due</th>
</tr>
</thead>
<tbody className="divide-y divide-border-light">
{tasks.length === 0 ? (
<tr><td colSpan={6} className="py-12 text-center text-sm text-text-tertiary">No tasks yet</td></tr>
<tr><td colSpan={6} className="py-12 text-center text-sm text-text-tertiary">{t('tasks.noTasks')}</td></tr>
) : (
tasks.map(task => {
const prio = PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium
@@ -470,7 +470,7 @@ export default function ProjectDetail() {
{/* ─── DISCUSSION SIDEBAR ─── */}
{showDiscussion && (
<div className="w-[340px] shrink-0 bg-white rounded-xl border border-border flex flex-col self-start sticky top-4" style={{ maxHeight: 'calc(100vh - 6rem)' }}>
<div className="w-[340px] shrink-0 bg-surface rounded-xl border border-border flex flex-col self-start sticky top-4" style={{ maxHeight: 'calc(100vh - 6rem)' }}>
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-1.5">
<MessageCircle className="w-4 h-4" />
@@ -539,7 +539,7 @@ function TaskKanbanCard({ task, canEdit, canDelete, onClick, onDelete, onStatusC
onDragStart={(e) => canEdit && onDragStart(e, task)}
onDragEnd={onDragEnd}
onClick={onClick}
className={`bg-white rounded-lg border border-border p-3 group hover:border-brand-primary/30 hover:shadow-sm transition-all cursor-pointer ${canEdit ? 'active:cursor-grabbing' : ''}`}
className={`bg-surface rounded-lg border border-border p-3 group hover:border-brand-primary/30 hover:shadow-sm transition-all cursor-pointer ${canEdit ? 'active:cursor-grabbing' : ''}`}
>
<div className="flex items-start gap-2">
<div className={`w-2 h-2 rounded-full ${priority.color} mt-1.5 shrink-0`} title={priority.label} />
@@ -572,7 +572,7 @@ function TaskKanbanCard({ task, canEdit, canDelete, onClick, onDelete, onStatusC
)}
{canDelete && (
<button onClick={(e) => { e.stopPropagation(); onDelete() }}
className="text-[10px] text-red-400 hover:bg-red-50 px-2 py-0.5 rounded-full flex items-center gap-1 ml-auto">
className="text-[10px] text-red-400 hover:bg-red-50 px-2 py-0.5 rounded-full flex items-center gap-1 ms-auto">
<Trash2 className="w-3 h-3" />
</button>
)}
@@ -614,7 +614,7 @@ function GanttView({ tasks, project, onEditTask, onTaskColorChange }) {
if (tasks.length === 0) {
return (
<div className="bg-white rounded-xl border border-border py-16 text-center">
<div className="bg-surface rounded-xl border border-border py-16 text-center">
<GanttChart className="w-12 h-12 text-text-tertiary mx-auto mb-3" />
<p className="text-text-secondary font-medium">No tasks to display</p>
<p className="text-sm text-text-tertiary mt-1">Add tasks with due dates to see the timeline</p>
@@ -666,7 +666,7 @@ function GanttView({ tasks, project, onEditTask, onTaskColorChange }) {
}
return (
<div className="bg-white rounded-xl border border-border overflow-hidden">
<div className="bg-surface rounded-xl border border-border overflow-clip">
{/* Zoom toolbar */}
<div className="flex items-center justify-between px-4 py-2 border-b border-border bg-surface-secondary">
<div className="flex items-center gap-2">
@@ -757,7 +757,7 @@ function GanttView({ tasks, project, onEditTask, onTaskColorChange }) {
)}
{!onTaskColorChange && <div className={`w-2 h-2 rounded-full ${prio.color} shrink-0`} />}
<button onClick={() => onEditTask(task)}
className="text-xs font-medium text-text-primary truncate hover:text-brand-primary text-left">
className="text-xs font-medium text-text-primary truncate hover:text-brand-primary text-start">
{task.title}
</button>
</div>
@@ -787,7 +787,7 @@ function GanttView({ tasks, project, onEditTask, onTaskColorChange }) {
{colorPicker && onTaskColorChange && (
<div
ref={colorPickerRef}
className="fixed z-50 bg-white rounded-lg shadow-xl border border-border p-2"
className="fixed z-50 bg-surface rounded-lg shadow-xl border border-border p-2"
style={{ left: colorPicker.x, top: colorPicker.y }}
>
<div className="grid grid-cols-4 gap-1.5 mb-2">
+4 -4
View File
@@ -80,13 +80,13 @@ export default function Projects() {
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-3">
<div className="relative flex-1 min-w-[200px] max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<Search className="absolute start-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<input
type="text"
placeholder="Search projects..."
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-white"
className="w-full ps-10 pe-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface"
/>
</div>
@@ -100,7 +100,7 @@ export default function Projects() {
key={v.id}
onClick={() => setView(v.id)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
view === v.id ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary hover:text-text-secondary'
view === v.id ? 'bg-surface shadow-sm text-text-primary' : 'text-text-tertiary hover:text-text-secondary'
}`}
>
<v.icon className="w-4 h-4" />
@@ -112,7 +112,7 @@ export default function Projects() {
{permissions?.canCreateProjects && (
<button
onClick={() => setShowModal(true)}
className="flex items-center gap-2 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm ml-auto"
className="flex items-center gap-2 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm ms-auto"
>
<Plus className="w-4 h-4" />
New Project
+246
View File
@@ -0,0 +1,246 @@
import { useState, useEffect } from 'react'
import { useParams } from 'react-router-dom'
import { CheckCircle, XCircle, DollarSign, User, FileText, Clock, Sparkles } from 'lucide-react'
import { useLanguage } from '../i18n/LanguageContext'
export default function PublicBudgetApproval() {
const { token } = useParams()
const { t, currencySymbol } = useLanguage()
const [request, setRequest] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [expired, setExpired] = useState(false)
const [success, setSuccess] = useState('')
const [note, setNote] = useState('')
const [submitting, setSubmitting] = useState(false)
useEffect(() => { loadRequest() }, [token])
const loadRequest = async () => {
try {
const res = await fetch(`/api/budget-approval/${token}`)
if (!res.ok) {
const err = await res.json()
if (res.status === 410 || err.error?.toLowerCase().includes('expired')) {
setExpired(true)
} else {
setError(err.error || t('budgetApproval.loadFailed') || 'Failed to load request')
}
setLoading(false)
return
}
const data = await res.json()
setRequest(data)
} catch {
setError(t('budgetApproval.loadFailed') || 'Failed to load request')
} finally {
setLoading(false)
}
}
const handleAction = async (action) => {
setSubmitting(true)
try {
const res = await fetch(`/api/budget-approval/${token}/respond`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action, note: note.trim() || undefined }),
})
if (!res.ok) {
const err = await res.json()
setError(err.error || t('budgetApproval.actionFailed') || 'Action failed')
setSubmitting(false)
return
}
setSuccess(action === 'approve'
? (t('budgetApproval.approved') || 'Budget request approved')
: (t('budgetApproval.rejected') || 'Budget request rejected'))
} catch {
setError(t('budgetApproval.actionFailed') || 'Action failed')
} finally {
setSubmitting(false)
}
}
// Loading state
if (loading) {
return (
<div className="min-h-screen bg-slate-900 flex items-center justify-center">
<div className="w-12 h-12 border-4 border-brand-primary border-t-transparent rounded-full animate-spin" />
</div>
)
}
// Expired state
if (expired) {
return (
<div className="min-h-screen bg-slate-900 flex items-center justify-center p-4">
<div className="max-w-md w-full bg-white rounded-2xl shadow-2xl p-8 text-center">
<div className="w-16 h-16 rounded-full bg-amber-100 flex items-center justify-center mx-auto mb-4">
<Clock className="w-8 h-8 text-amber-600" />
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">{t('budgetApproval.expired') || 'Request Expired'}</h2>
<p className="text-gray-500">{t('budgetApproval.expiredDesc') || 'This budget approval request has expired.'}</p>
</div>
</div>
)
}
// Error state
if (error) {
return (
<div className="min-h-screen bg-slate-900 flex items-center justify-center p-4">
<div className="max-w-md w-full bg-white rounded-2xl shadow-2xl p-8 text-center">
<div className="w-16 h-16 rounded-full bg-red-100 flex items-center justify-center mx-auto mb-4">
<XCircle className="w-8 h-8 text-red-600" />
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">{t('budgetApproval.error') || 'Error'}</h2>
<p className="text-gray-500">{error}</p>
</div>
</div>
)
}
// Success state
if (success) {
const isApproved = success.toLowerCase().includes('approved') || success.toLowerCase().includes('approve')
return (
<div className="min-h-screen bg-slate-900 flex items-center justify-center p-4">
<div className="max-w-md w-full bg-white rounded-2xl shadow-2xl p-8 text-center">
<div className={`w-16 h-16 rounded-full ${isApproved ? 'bg-emerald-100' : 'bg-red-100'} flex items-center justify-center mx-auto mb-4`}>
{isApproved
? <CheckCircle className="w-8 h-8 text-emerald-600" />
: <XCircle className="w-8 h-8 text-red-600" />
}
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">{t('budgetApproval.thankYou') || 'Thank You'}</h2>
<p className="text-gray-500">{success}</p>
</div>
</div>
)
}
if (!request) return null
// Already handled (not pending)
if (request.status && request.status !== 'pending') {
const statusLabel = request.status.charAt(0).toUpperCase() + request.status.slice(1)
return (
<div className="min-h-screen bg-slate-900 flex items-center justify-center p-4">
<div className="max-w-md w-full bg-white rounded-2xl shadow-2xl p-8 text-center">
<div className="w-16 h-16 rounded-full bg-blue-100 flex items-center justify-center mx-auto mb-4">
<FileText className="w-8 h-8 text-blue-600" />
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">{t('budgetApproval.alreadyHandled') || 'Already Handled'}</h2>
<p className="text-gray-500">
{t('budgetApproval.statusIs') || 'This request has been'}: <span className="font-semibold">{statusLabel}</span>
</p>
</div>
</div>
)
}
// Active state show request details + approve/reject
return (
<div className="min-h-screen bg-slate-900 flex items-center justify-center p-4 py-12">
<div className="max-w-lg w-full">
{/* Header card */}
<div className="bg-white rounded-2xl shadow-2xl overflow-hidden">
<div className="bg-brand-primary px-8 py-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-white/20 flex items-center justify-center">
<Sparkles className="w-6 h-6 text-white" />
</div>
<div>
<h1 className="text-xl font-bold text-white">{t('budgetApproval.title') || 'Budget Approval'}</h1>
<p className="text-white/80 text-sm">Rawaj</p>
</div>
</div>
</div>
<div className="p-8 space-y-6">
{/* Amount */}
<div className="text-center">
<div className="inline-flex items-center gap-2 bg-emerald-50 px-6 py-4 rounded-2xl">
<DollarSign className="w-6 h-6 text-emerald-600" />
<span className="text-3xl font-bold text-emerald-700">
{Number(request.amount).toLocaleString()} {currencySymbol}
</span>
</div>
</div>
{/* Requested by */}
{request.requested_by_name && (
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-slate-100 flex items-center justify-center shrink-0">
<User className="w-4 h-4 text-slate-500" />
</div>
<div>
<p className="text-xs text-gray-400 uppercase tracking-wider">{t('budgetApproval.requestedBy') || 'Requested by'}</p>
<p className="text-sm font-semibold text-gray-900">{request.requested_by_name}</p>
</div>
</div>
)}
{/* Justification */}
<div>
<p className="text-xs text-gray-400 uppercase tracking-wider mb-1">{t('budgetApproval.justification') || 'Justification'}</p>
<p className="text-sm text-gray-700 whitespace-pre-wrap bg-gray-50 rounded-xl p-4 border border-gray-100">
{request.justification}
</p>
</div>
{/* Earmarked for */}
{request.earmark_name && (
<div>
<p className="text-xs text-gray-400 uppercase tracking-wider mb-1">{t('budgetApproval.earmarkedFor') || 'Earmarked for'}</p>
<p className="text-sm font-medium text-gray-700">
{request.earmark_type && <span className="text-gray-400 capitalize">{request.earmark_type}: </span>}
{request.earmark_name}
</p>
</div>
)}
{/* Note textarea */}
<div>
<label className="block text-xs text-gray-400 uppercase tracking-wider mb-1">
{t('budgetApproval.note') || 'Note'} ({t('common.optional') || 'optional'})
</label>
<textarea
value={note}
onChange={e => setNote(e.target.value)}
rows={3}
placeholder={t('budgetApproval.notePlaceholder') || 'Add a note...'}
className="w-full px-4 py-2.5 text-sm border border-gray-200 rounded-xl bg-white text-gray-900 focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
/>
</div>
{/* Action buttons */}
<div className="grid grid-cols-2 gap-3 pt-2">
<button
onClick={() => handleAction('approve')}
disabled={submitting}
className="flex items-center justify-center gap-2 px-4 py-3.5 bg-emerald-600 text-white rounded-xl font-medium hover:bg-emerald-700 transition-colors disabled:opacity-50 shadow-sm"
>
<CheckCircle className="w-5 h-5" />
{t('budgetApproval.approve') || 'Approve'}
</button>
<button
onClick={() => handleAction('reject')}
disabled={submitting}
className="flex items-center justify-center gap-2 px-4 py-3.5 bg-red-600 text-white rounded-xl font-medium hover:bg-red-700 transition-colors disabled:opacity-50 shadow-sm"
>
<XCircle className="w-5 h-5" />
{t('budgetApproval.reject') || 'Reject'}
</button>
</div>
</div>
</div>
<div className="text-center text-slate-500 text-sm mt-6">
<p>{t('review.poweredBy') || 'Powered by Rawaj'}</p>
</div>
</div>
</div>
)
}
+7 -7
View File
@@ -174,11 +174,11 @@ export default function PublicIssueTracker() {
acknowledged: { label: t('acknowledged'), bg: 'bg-blue-100', text: 'text-blue-700', dot: 'bg-blue-500', icon: CheckCircle2 },
in_progress: { label: t('in_progress'), bg: 'bg-amber-100', text: 'text-amber-700', dot: 'bg-amber-500', icon: Clock },
resolved: { label: t('resolved'), bg: 'bg-emerald-100', text: 'text-emerald-700', dot: 'bg-emerald-500', icon: CheckCircle2 },
declined: { label: t('declined_status'), bg: 'bg-gray-100', text: 'text-gray-700', dot: 'bg-gray-500', icon: XCircle },
declined: { label: t('declined_status'), bg: 'bg-gray-100', text: 'text-text-secondary', dot: 'bg-gray-500', icon: XCircle },
}
const PRIORITY_CONFIG = {
low: { label: t('low'), color: 'text-gray-700' },
low: { label: t('low'), color: 'text-text-secondary' },
medium: { label: t('medium'), color: 'text-blue-700' },
high: { label: t('high'), color: 'text-orange-700' },
urgent: { label: t('urgent'), color: 'text-red-700' },
@@ -267,16 +267,16 @@ export default function PublicIssueTracker() {
<div className="flex items-start gap-3">
{issue.status === 'resolved'
? <CheckCircle2 className="w-6 h-6 text-emerald-600 shrink-0 mt-1" />
: <XCircle className="w-6 h-6 text-gray-600 shrink-0 mt-1" />}
: <XCircle className="w-6 h-6 text-text-secondary shrink-0 mt-1" />}
<div className="flex-1">
<h2 className={`text-lg font-bold mb-2 ${issue.status === 'resolved' ? 'text-emerald-900' : 'text-gray-900'}`}>
<h2 className={`text-lg font-bold mb-2 ${issue.status === 'resolved' ? 'text-emerald-900' : 'text-text-primary'}`}>
{issue.status === 'resolved' ? t('resolution') : t('declined')}
</h2>
<p className={`${issue.status === 'resolved' ? 'text-emerald-800' : 'text-gray-800'} whitespace-pre-wrap`}>
<p className={`${issue.status === 'resolved' ? 'text-emerald-800' : 'text-text-primary'} whitespace-pre-wrap`}>
{issue.resolution_summary}
</p>
{issue.resolved_at && (
<p className={`text-sm mt-2 ${issue.status === 'resolved' ? 'text-emerald-600' : 'text-gray-600'}`}>
<p className={`text-sm mt-2 ${issue.status === 'resolved' ? 'text-emerald-600' : 'text-text-secondary'}`}>
{dateFmt(issue.resolved_at)}
</p>
)}
@@ -303,7 +303,7 @@ export default function PublicIssueTracker() {
<div className="flex items-start justify-between gap-3 mb-2">
<div className="flex items-center gap-2">
<span className="font-semibold text-text-primary">{update.author_name}</span>
<span className={`text-xs px-2 py-0.5 rounded-full ${update.author_type === 'staff' ? 'bg-purple-100 text-purple-700' : 'bg-gray-200 text-gray-700'}`}>
<span className={`text-xs px-2 py-0.5 rounded-full ${update.author_type === 'staff' ? 'bg-purple-100 text-purple-700' : 'bg-gray-200 text-text-secondary'}`}>
{update.author_type === 'staff' ? t('team') : t('you')}
</span>
</div>
+2 -2
View File
@@ -146,7 +146,7 @@ export default function PublicPostReview() {
</div>
<div>
<h1 className="text-2xl font-bold text-white">{t('review.postReview')}</h1>
<p className="text-white/80 text-sm">Samaya Digital Hub</p>
<p className="text-white/80 text-sm">Rawaj</p>
</div>
</div>
</div>
@@ -181,7 +181,7 @@ export default function PublicPostReview() {
{images.map((att, idx) => (
<a key={idx} href={att.url} target="_blank" rel="noopener noreferrer"
className="block rounded-xl overflow-hidden border-2 border-border hover:border-brand-primary transition-colors shadow-sm">
<img src={att.url} alt={att.original_name || `Image ${idx + 1}`} className="w-full h-64 object-cover" />
<img src={att.url} alt={att.original_name || `Image ${idx + 1}`} className="w-full h-64 object-cover" loading="lazy" />
{att.original_name && (
<div className="bg-surface-secondary px-4 py-2 border-t border-border">
<p className="text-sm text-text-secondary truncate">{att.original_name}</p>
+100 -32
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'
import { useParams } from 'react-router-dom'
import { CheckCircle, XCircle, AlertCircle, FileText, Image as ImageIcon, Film, Sparkles, Globe, User } from 'lucide-react'
import { CheckCircle, XCircle, AlertCircle, FileText, Image as ImageIcon, Film, Sparkles, Globe, User, ArrowRightLeft } from 'lucide-react'
import { useLanguage } from '../i18n/LanguageContext'
import { useToast } from '../components/ToastContainer'
import Modal from '../components/Modal'
@@ -21,8 +21,13 @@ export default function PublicReview() {
const [error, setError] = useState('')
const [submitting, setSubmitting] = useState(false)
const [success, setSuccess] = useState('')
const [successType, setSuccessType] = useState('review') // 'review' | 'redirect'
const [reviewerName, setReviewerName] = useState('')
const [feedback, setFeedback] = useState('')
const [showRedirect, setShowRedirect] = useState(false)
const [redirectTo, setRedirectTo] = useState('')
const [teamMembers, setTeamMembers] = useState([])
const [redirecting, setRedirecting] = useState(false)
const [selectedLanguage, setSelectedLanguage] = useState(0)
const [pendingAction, setPendingAction] = useState(null)
@@ -41,8 +46,8 @@ export default function PublicReview() {
}
const data = await res.json()
setArtefact(data)
// Auto-set reviewer name if there's exactly one approver
if (data.approvers?.length === 1 && data.approvers[0].name) {
// Auto-set reviewer name from the selected approver
if (data.approvers?.length > 0 && data.approvers[0].name) {
setReviewerName(data.approvers[0].name)
}
} catch (err) {
@@ -102,6 +107,41 @@ export default function PublicReview() {
}
}
const handleOpenRedirect = async () => {
try {
const res = await fetch(`/api/public/review-redirect/${token}/team`)
const data = await res.json()
setTeamMembers(Array.isArray(data) ? data : [])
setShowRedirect(true)
} catch {
toast.error(t('review.actionFailed'))
}
}
const handleRedirect = async () => {
if (!redirectTo) return
setRedirecting(true)
try {
const res = await fetch(`/api/public/review-redirect/${token}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ new_approver_id: Number(redirectTo) }),
})
const data = await res.json()
if (!res.ok) {
toast.error(data.error || t('review.actionFailed'))
return
}
setSuccessType('redirect')
setSuccess(data.message || t('review.redirected'))
setShowRedirect(false)
} catch {
toast.error(t('review.actionFailed'))
} finally {
setRedirecting(false)
}
}
const extractDriveFileId = (url) => {
const patterns = [
/\/file\/d\/([^\/]+)/,
@@ -157,10 +197,15 @@ export default function PublicReview() {
return (
<div className="min-h-screen bg-surface-secondary flex items-center justify-center p-4">
<div className="max-w-md w-full bg-surface rounded-2xl shadow-sm p-8 text-center">
<div className="w-16 h-16 rounded-full bg-emerald-100 flex items-center justify-center mx-auto mb-4">
<CheckCircle className="w-8 h-8 text-emerald-600" />
<div className={`w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4 ${successType === 'redirect' ? 'bg-blue-100' : 'bg-emerald-100'}`}>
{successType === 'redirect'
? <ArrowRightLeft className="w-8 h-8 text-blue-600" />
: <CheckCircle className="w-8 h-8 text-emerald-600" />
}
</div>
<h2 className="text-2xl font-bold text-text-primary mb-2">{t('review.thankYou')}</h2>
<h2 className="text-2xl font-bold text-text-primary mb-2">
{successType === 'redirect' ? t('review.redirected') : t('review.thankYou')}
</h2>
<p className="text-text-secondary">{success}</p>
</div>
</div>
@@ -184,7 +229,7 @@ export default function PublicReview() {
</div>
<div>
<h1 className="text-2xl font-bold text-white">{t('review.contentReview')}</h1>
<p className="text-white/80 text-sm">Samaya Digital Hub</p>
<p className="text-white/80 text-sm">Rawaj</p>
</div>
</div>
</div>
@@ -281,6 +326,7 @@ export default function PublicReview() {
src={att.url}
alt={att.original_name || `Design ${idx + 1}`}
className="w-full h-64 object-cover"
loading="lazy"
/>
{att.original_name && (
<div className="bg-surface-secondary px-4 py-2 border-t border-border">
@@ -354,6 +400,7 @@ export default function PublicReview() {
src={att.url}
alt={att.original_name}
className="w-full h-48 object-cover"
loading="lazy"
/>
<div className="bg-surface-secondary px-3 py-2 border-t border-border">
<p className="text-xs text-text-secondary truncate">{att.original_name}</p>
@@ -416,31 +463,10 @@ export default function PublicReview() {
{/* Reviewer identity */}
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('review.reviewer')}</label>
{artefact.approvers?.length === 1 ? (
<div className="flex items-center gap-2 px-4 py-2 bg-surface-secondary border border-border rounded-lg">
<User className="w-4 h-4 text-text-tertiary" />
<span className="text-sm text-text-primary">{artefact.approvers[0].name}</span>
</div>
) : artefact.approvers?.length > 1 ? (
<select
value={reviewerName}
onChange={e => setReviewerName(e.target.value)}
className="w-full px-4 py-2 text-sm border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary transition-colors"
>
<option value="">{t('review.selectYourName')}</option>
{artefact.approvers.map(a => (
<option key={a.id} value={a.name}>{a.name}</option>
))}
</select>
) : (
<input
type="text"
value={reviewerName}
onChange={e => setReviewerName(e.target.value)}
placeholder={t('review.enterYourName')}
className="w-full px-4 py-2 text-sm border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary transition-colors"
/>
)}
<div 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>
@@ -481,6 +507,48 @@ export default function PublicReview() {
{t('review.reject')}
</button>
</div>
{/* Redirect to another reviewer */}
<div className="pt-3 border-t border-border-light">
{!showRedirect ? (
<button
onClick={handleOpenRedirect}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 text-sm text-text-secondary hover:text-text-primary hover:bg-surface-secondary rounded-lg transition-colors"
>
<ArrowRightLeft className="w-4 h-4" />
{t('review.redirectReview')}
</button>
) : (
<div className="space-y-3">
<p className="text-sm text-text-secondary">{t('review.redirectDesc')}</p>
<select
value={redirectTo}
onChange={e => setRedirectTo(e.target.value)}
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface text-text-primary"
>
<option value="">{t('review.selectNewReviewer')}</option>
{teamMembers.map(u => (
<option key={u.id} value={u.id}>{u.name}</option>
))}
</select>
<div className="flex gap-2">
<button
onClick={() => setShowRedirect(false)}
className="flex-1 px-3 py-2 text-sm text-text-secondary hover:bg-surface-secondary rounded-lg transition-colors"
>
{t('common.cancel')}
</button>
<button
onClick={handleRedirect}
disabled={!redirectTo || redirecting}
className="flex-1 px-3 py-2 text-sm bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors disabled:opacity-50"
>
{redirecting ? '...' : t('review.redirect')}
</button>
</div>
</div>
)}
</div>
</div>
)}
+279 -29
View File
@@ -1,7 +1,8 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useMemo } from 'react'
import { useParams } from 'react-router-dom'
import { CheckCircle, XCircle, AlertCircle, Languages, Globe, User } from 'lucide-react'
import { CheckCircle, XCircle, AlertCircle, Languages, Globe, User, Check, PenLine, Copy, Lock } from 'lucide-react'
import { useLanguage } from '../i18n/LanguageContext'
import { AVAILABLE_LANGUAGES, isTextSelected, groupTextsByLanguage } from '../utils/translations'
import { useToast } from '../components/ToastContainer'
import Modal from '../components/Modal'
@@ -17,6 +18,10 @@ export default function PublicTranslationReview() {
const [reviewerName, setReviewerName] = useState('')
const [feedback, setFeedback] = useState('')
const [pendingAction, setPendingAction] = useState(null)
const [suggestingLang, setSuggestingLang] = useState(null)
const [suggestionContent, setSuggestionContent] = useState('')
const [selectingId, setSelectingId] = useState(null)
const [copiedId, setCopiedId] = useState(null)
useEffect(() => {
loadTranslation()
@@ -44,12 +49,12 @@ export default function PublicTranslationReview() {
}
const handleAction = async (action) => {
if (action === 'approve' && !reviewerName.trim()) {
if ((action === 'approve' || action === 'reject') && !reviewerName.trim()) {
toast.error(t('review.nameRequired'))
return
}
if (action === 'reject' && !feedback.trim()) {
toast.error(t('review.feedbackRequired'))
if ((action === 'reject' || action === 'revision') && !feedback.trim()) {
toast.error(t('review.feedbackRequiredError'))
return
}
@@ -78,6 +83,86 @@ export default function PublicTranslationReview() {
}
}
const handleSelect = async (textId) => {
setSelectingId(textId)
try {
const res = await fetch(`/api/public/review-translation/${token}/select`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text_id: textId }),
})
if (!res.ok) {
const err = await res.json()
throw new Error(err.error || 'Selection failed')
}
setTranslation(prev => ({
...prev,
texts: prev.texts.map(txt => ({
...txt,
is_selected: txt.language_code === prev.texts.find(t => (t.Id || t.id) === textId)?.language_code
? (txt.Id || txt.id) === textId
: txt.is_selected,
})),
}))
toast.success(t('translations.optionSelected'))
} catch (err) {
toast.error(err.message)
} finally {
setSelectingId(null)
}
}
const handleSuggest = async (langCode) => {
if (!suggestionContent.trim()) return
setSubmitting(true)
try {
const langDef = AVAILABLE_LANGUAGES.find(l => l.code === langCode)
const res = await fetch(`/api/public/review-translation/${token}/suggest`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
language_code: langCode,
language_label: langDef?.label || langCode,
content: suggestionContent,
suggested_by: reviewerName || 'Reviewer',
}),
})
if (!res.ok) {
const err = await res.json()
throw new Error(err.error || 'Suggestion failed')
}
const newText = await res.json()
setTranslation(prev => ({
...prev,
texts: [...(prev.texts || []), newText],
}))
setSuggestingLang(null)
setSuggestionContent('')
toast.success(t('translations.suggestionAdded'))
} catch (err) {
toast.error(err.message)
} finally {
setSubmitting(false)
}
}
const copyContent = (text, id) => {
navigator.clipboard.writeText(text)
setCopiedId(id)
toast.success(t('translations.copiedToClipboard'))
setTimeout(() => setCopiedId(null), 2000)
}
// Group texts by language (memoized)
const textsByLanguage = useMemo(
() => translation?.texts ? groupTextsByLanguage(translation.texts) : {},
[translation?.texts]
)
const isPendingReview = translation?.status === 'pending_review'
const isApproved = translation?.status === 'approved'
const isRejected = translation?.status === 'rejected'
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-surface-secondary">
@@ -122,10 +207,20 @@ export default function PublicTranslationReview() {
<Languages className="w-6 h-6 text-brand-primary" />
</div>
<div className="flex-1 min-w-0">
<h2 className="text-2xl font-bold text-text-primary mb-1">{translation.title}</h2>
{translation.description && (
<p className="text-text-secondary mb-2">{translation.description}</p>
)}
<div className="flex items-center gap-2 mb-1">
<h2 className="text-2xl font-bold text-text-primary">{translation.title}</h2>
{isApproved && (
<span className="text-xs px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-700 font-medium flex items-center gap-1">
<Lock className="w-3 h-3" />
{t('review.approved')}
</span>
)}
{isRejected && (
<span className="text-xs px-2 py-0.5 rounded-full bg-red-100 text-red-700 font-medium">
{t('review.rejected')}
</span>
)}
</div>
<div className="flex items-center gap-2 text-sm text-text-tertiary flex-wrap">
{translation.brand_name && <span>{translation.brand_name}</span>}
{translation.creator_name && <span className="font-medium text-text-secondary">{t('review.createdBy')} <strong>{translation.creator_name}</strong></span>}
@@ -150,30 +245,139 @@ export default function PublicTranslationReview() {
</div>
</div>
{/* Translations */}
{translation.texts && translation.texts.length > 0 && (
{/* Translation Options by Language */}
{Object.keys(textsByLanguage).length > 0 && (
<div className="bg-surface rounded-xl border border-border p-6 mb-6">
<h3 className="text-lg font-semibold text-text-primary mb-4">
{t('translations.translationTexts')} ({translation.texts.length})
{t('translations.translationTexts')}
</h3>
<div className="space-y-4">
{translation.texts.map((text, idx) => (
<div key={text.Id || idx} className="bg-surface-secondary rounded-lg p-4 border border-border">
<div className="flex items-center gap-2 mb-2">
<span className="text-sm font-semibold text-text-primary">
{text.language_label || text.language_code}
</span>
<span className="text-xs text-text-tertiary">({text.language_code})</span>
<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>
<p className="text-sm text-text-secondary whitespace-pre-wrap leading-relaxed">{text.content}</p>
</div>
))}
)
})}
</div>
</div>
)}
{/* Review Actions */}
{translation.status === 'pending_review' && (
{/* Review Actions — only pending_review */}
{isPendingReview && (
<div className="bg-surface rounded-xl border border-border p-6">
<h3 className="text-lg font-semibold text-text-primary mb-4">{t('review.yourReview')}</h3>
@@ -253,17 +457,63 @@ export default function PublicTranslationReview() {
</div>
)}
{/* Already reviewed */}
{translation.status !== 'pending_review' && (
{/* Approved state — read-only with copy buttons */}
{isApproved && (
<div className="bg-surface rounded-xl border border-border p-6">
<div className="flex items-center gap-3 mb-3">
<CheckCircle className="w-6 h-6 text-emerald-500" />
<div>
<p className="text-text-primary font-semibold">{t('review.approved')}</p>
{translation.approved_by_name && (
<p className="text-sm text-text-secondary">
{t('review.reviewedBy')} <strong>{translation.approved_by_name}</strong>
</p>
)}
</div>
</div>
{translation.feedback && (
<div className="bg-surface-secondary rounded-lg p-3 mt-3">
<p className="text-xs font-medium text-text-tertiary mb-1">{t('review.feedback')}</p>
<p className="text-sm text-text-secondary whitespace-pre-wrap">{translation.feedback}</p>
</div>
)}
</div>
)}
{/* Rejected state */}
{isRejected && (
<div className="bg-surface rounded-xl border border-border p-6">
<div className="flex items-center gap-3 mb-3">
<XCircle className="w-6 h-6 text-red-500" />
<div>
<p className="text-text-primary font-semibold">{t('review.rejected')}</p>
{translation.approved_by_name && (
<p className="text-sm text-text-secondary">
{t('review.reviewedBy')} <strong>{translation.approved_by_name}</strong>
</p>
)}
</div>
</div>
{translation.feedback && (
<div className="bg-red-50 border border-red-200 rounded-lg p-3 mt-3">
<p className="text-xs font-medium text-red-700 mb-1">{t('review.feedback')}</p>
<p className="text-sm text-red-800 whitespace-pre-wrap">{translation.feedback}</p>
</div>
)}
</div>
)}
{/* Other statuses (revision_requested, draft) */}
{!isPendingReview && !isApproved && !isRejected && (
<div className="bg-surface rounded-xl border border-border p-6">
<div className="text-center py-4">
<CheckCircle className="w-10 h-10 text-emerald-500 mx-auto mb-2" />
<AlertCircle className="w-10 h-10 text-amber-500 mx-auto mb-2" />
<p className="text-text-primary font-medium">
{t('review.statusLabel')}: <span className="font-semibold capitalize">{translation.status.replace('_', ' ')}</span>
</p>
{translation.approved_by_name && (
<p className="text-sm text-text-secondary mt-1">
{t('review.reviewedBy')}: <span className="font-semibold">{translation.approved_by_name}</span>
{t('review.reviewedBy')} <strong>{translation.approved_by_name}</strong>
</p>
)}
</div>
+19 -10
View File
@@ -1,9 +1,18 @@
import { useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { useLanguage } from '../i18n/LanguageContext'
import { Megaphone, Lock, AlertCircle, CheckCircle, ArrowLeft } from 'lucide-react'
import { Lock, AlertCircle, CheckCircle, ArrowLeft } from 'lucide-react'
import { api } from '../utils/api'
function MarkaLogo({ className = '' }) {
return (
<svg viewBox="0 0 32 32" fill="none" className={className}>
<path d="M4 26V6l10 10L4 26z" fill="currentColor" opacity="0.85" />
<path d="M18 26V6l10 10-10 10z" fill="currentColor" opacity="0.5" />
</svg>
)
}
export default function ResetPassword() {
const { t } = useLanguage()
const [searchParams] = useSearchParams()
@@ -16,7 +25,7 @@ export default function ResetPassword() {
if (!token) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-blue-900 to-slate-900 flex items-center justify-center px-4">
<div className="min-h-screen bg-slate-900 flex items-center justify-center px-4">
<div className="w-full max-w-md text-center">
<div className="bg-slate-800/50 backdrop-blur-sm rounded-2xl border border-slate-700/50 p-8 shadow-2xl">
<AlertCircle className="w-12 h-12 text-red-400 mx-auto mb-4" />
@@ -51,11 +60,11 @@ export default function ResetPassword() {
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-blue-900 to-slate-900 flex items-center justify-center px-4">
<div className="min-h-screen bg-slate-900 flex items-center justify-center px-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-purple-600 rounded-2xl flex items-center justify-center mx-auto mb-4 shadow-lg">
<Megaphone className="w-8 h-8 text-white" />
<div className="w-16 h-16 bg-brand-primary rounded-2xl flex items-center justify-center mx-auto mb-4">
<MarkaLogo className="w-9 h-9 text-white" />
</div>
<h1 className="text-3xl font-bold text-white mb-2">{t('resetPassword.title')}</h1>
<p className="text-slate-400">{t('resetPassword.subtitle')}</p>
@@ -81,12 +90,12 @@ export default function ResetPassword() {
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">{t('resetPassword.newPassword')}</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<Lock className="absolute start-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
className="w-full ps-11 pe-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
placeholder="••••••••"
required
minLength={6}
@@ -98,12 +107,12 @@ export default function ResetPassword() {
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">{t('resetPassword.confirmPassword')}</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<Lock className="absolute start-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<input
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
className="w-full ps-11 pe-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
placeholder="••••••••"
required
minLength={6}
@@ -121,7 +130,7 @@ export default function ResetPassword() {
<button
type="submit"
disabled={loading}
className="w-full py-3 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed"
className="w-full py-3 bg-brand-primary hover:bg-brand-primary-light text-white font-semibold rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? (
<span className="flex items-center justify-center gap-2">
+71 -15
View File
@@ -1,5 +1,5 @@
import { useState, useEffect, useContext } from 'react'
import { Settings as SettingsIcon, Play, CheckCircle, Languages, Coins, Upload, Tag, Plus, Pencil, Trash2, X } from 'lucide-react'
import { Settings as SettingsIcon, Play, CheckCircle, Languages, Coins, Upload, Tag, Plus, Pencil, Trash2, X, Mail } from 'lucide-react'
import { api } from '../utils/api'
import { useLanguage } from '../i18n/LanguageContext'
import { useToast } from '../components/ToastContainer'
@@ -23,9 +23,15 @@ export default function Settings() {
const [maxSizeMB, setMaxSizeMB] = useState(50)
const [sizeSaving, setSizeSaving] = useState(false)
const [sizeSaved, setSizeSaved] = useState(false)
const [ceoEmail, setCeoEmail] = useState('')
const [ceoSaving, setCeoSaving] = useState(false)
const [ceoSaved, setCeoSaved] = useState(false)
useEffect(() => {
api.get('/settings/app').then(s => setMaxSizeMB(s.uploadMaxSizeMB || 50)).catch(() => {})
api.get('/settings/app').then(s => {
setMaxSizeMB(s.uploadMaxSizeMB || 50)
if (s.ceoEmail) setCeoEmail(s.ceoEmail)
}).catch(() => {})
}, [])
const handleSaveMaxSize = async () => {
@@ -65,9 +71,9 @@ export default function Settings() {
<p className="text-sm text-text-tertiary">{t('settings.preferences')}</p>
{/* General Settings */}
<div className="bg-white rounded-xl border border-border overflow-hidden">
<div className="bg-surface rounded-xl border border-border overflow-clip">
<div className="px-6 py-4 border-b border-border">
<h2 className="text-lg font-semibold text-text-primary">{t('settings.general')}</h2>
<h3 className="font-semibold text-text-primary">{t('settings.general')}</h3>
</div>
<div className="p-6 space-y-4">
{/* Language Selector */}
@@ -79,7 +85,7 @@ export default function Settings() {
<select
value={lang}
onChange={(e) => setLang(e.target.value)}
className="w-full max-w-xs px-4 py-2.5 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-white"
className="w-full max-w-xs px-4 py-2.5 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface"
>
<option value="en">{t('settings.english')}</option>
<option value="ar">{t('settings.arabic')}</option>
@@ -95,7 +101,7 @@ export default function Settings() {
<select
value={currency}
onChange={(e) => setCurrency(e.target.value)}
className="w-full max-w-xs px-4 py-2.5 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-white"
className="w-full max-w-xs px-4 py-2.5 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface"
>
{CURRENCIES.map(c => (
<option key={c.code} value={c.code}>
@@ -109,12 +115,12 @@ export default function Settings() {
</div>
{/* Uploads Section */}
<div className="bg-white rounded-xl border border-border overflow-hidden">
<div className="bg-surface rounded-xl border border-border overflow-clip">
<div className="px-6 py-4 border-b border-border">
<h2 className="text-lg font-semibold text-text-primary flex items-center gap-2">
<h3 className="font-semibold text-text-primary flex items-center gap-2">
<Upload className="w-5 h-5 text-brand-primary" />
{t('settings.uploads')}
</h2>
</h3>
</div>
<div className="p-6 space-y-4">
<div>
@@ -128,7 +134,7 @@ export default function Settings() {
max="500"
value={maxSizeMB}
onChange={(e) => setMaxSizeMB(Number(e.target.value))}
className="w-24 px-3 py-2.5 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-white"
className="w-24 px-3 py-2.5 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface"
/>
<span className="text-sm text-text-secondary">{t('settings.mb')}</span>
<button
@@ -147,9 +153,9 @@ export default function Settings() {
</div>
{/* Tutorial Section */}
<div className="bg-white rounded-xl border border-border overflow-hidden">
<div className="bg-surface rounded-xl border border-border overflow-clip">
<div className="px-6 py-4 border-b border-border">
<h2 className="text-lg font-semibold text-text-primary">{t('settings.onboardingTutorial')}</h2>
<h3 className="font-semibold text-text-primary">{t('settings.onboardingTutorial')}</h3>
</div>
<div className="p-6 space-y-4">
<p className="text-sm text-text-secondary">
@@ -180,6 +186,56 @@ export default function Settings() {
</div>
</div>
{/* Budget Approval (Superadmin only) */}
{user?.role === 'superadmin' && (
<div className="bg-surface rounded-xl border border-border overflow-clip">
<div className="px-6 py-4 border-b border-border">
<h3 className="font-semibold text-text-primary flex items-center gap-2">
<Mail className="w-5 h-5 text-brand-primary" />
{t('settings.budgetApproval') || 'Budget Approval'}
</h3>
</div>
<div className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-2">
{t('settings.ceoEmail')}
</label>
<div className="flex items-center gap-3">
<input
type="email"
value={ceoEmail}
onChange={(e) => setCeoEmail(e.target.value)}
placeholder="ceo@company.com"
className="flex-1 max-w-sm px-4 py-2.5 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface"
/>
<button
onClick={async () => {
setCeoSaving(true)
setCeoSaved(false)
try {
await api.patch('/settings/app', { ceoEmail })
setCeoSaved(true)
setTimeout(() => setCeoSaved(false), 2000)
} catch (err) {
toast.error(err.message || t('settings.saveFailed'))
} finally {
setCeoSaving(false)
}
}}
disabled={ceoSaving}
className="px-4 py-2.5 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 transition-colors"
>
{ceoSaved ? (
<span className="flex items-center gap-1.5"><CheckCircle className="w-4 h-4" />{t('settings.saved')}</span>
) : ceoSaving ? '...' : t('common.save')}
</button>
</div>
<p className="text-xs text-text-tertiary mt-1.5">{t('settings.ceoEmailHint')}</p>
</div>
</div>
</div>
)}
{/* Roles Management (Superadmin only) */}
{user?.role === 'superadmin' && <RolesSection roles={roles} loadRoles={loadRoles} t={t} toast={toast} />}
</div>
@@ -235,12 +291,12 @@ function RolesSection({ roles, loadRoles, t, toast }) {
return (
<>
<div className="bg-white dark:bg-surface-primary rounded-xl border border-border overflow-hidden">
<div className="bg-surface dark:bg-surface-primary rounded-xl border border-border overflow-clip">
<div className="px-6 py-4 border-b border-border flex items-center justify-between">
<h2 className="text-lg font-semibold text-text-primary flex items-center gap-2">
<h3 className="font-semibold text-text-primary flex items-center gap-2">
<Tag className="w-5 h-5 text-brand-primary" />
{t('settings.roles')}
</h2>
</h3>
<button
onClick={openAddModal}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors"
+23 -23
View File
@@ -325,16 +325,16 @@ export default function Tasks() {
<div className="flex items-center gap-3 flex-1 min-w-0">
{/* Search */}
<div className="relative flex-1 max-w-xs">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<Search className="absolute start-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<input
type="text"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder={t('tasks.search')}
className="w-full pl-9 pr-3 py-1.5 text-sm border border-border rounded-lg bg-surface-primary text-text-primary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
className="w-full ps-9 pe-3 py-1.5 text-sm border border-border rounded-lg bg-surface-primary text-text-primary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
/>
{searchQuery && (
<button onClick={() => setSearchQuery('')} className="absolute right-2 top-1/2 -translate-y-1/2 p-0.5 rounded text-text-tertiary hover:text-text-primary">
<button onClick={() => setSearchQuery('')} className="absolute end-2 top-1/2 -translate-y-1/2 p-0.5 rounded text-text-tertiary hover:text-text-primary">
<X className="w-3.5 h-3.5" />
</button>
)}
@@ -350,7 +350,7 @@ export default function Tasks() {
onClick={() => setViewMode(mode)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
viewMode === mode
? 'bg-white text-text-primary shadow-sm'
? 'bg-surface text-text-primary shadow-sm'
: 'text-text-tertiary hover:text-text-secondary'
}`}
>
@@ -399,7 +399,7 @@ export default function Tasks() {
<select
value={filterProject}
onChange={e => setFilterProject(e.target.value)}
className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-white text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
>
<option value="">{t('tasks.allProjects')}</option>
{taskProjects.map(p => (
@@ -411,7 +411,7 @@ export default function Tasks() {
<select
value={filterBrand}
onChange={e => setFilterBrand(e.target.value)}
className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-white text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
>
<option value="">{t('tasks.allBrands')}</option>
{taskBrands.map(b => (
@@ -440,7 +440,7 @@ export default function Tasks() {
className={`px-2.5 py-1 text-[11px] font-medium rounded-full border transition-colors ${
active
? 'bg-brand-primary/10 border-brand-primary/20 text-brand-primary'
: 'bg-white border-border text-text-tertiary'
: 'bg-surface border-border text-text-tertiary'
}`}
>
{t(`tasks.${s}`)}
@@ -453,7 +453,7 @@ export default function Tasks() {
<select
value={filterPriority}
onChange={e => setFilterPriority(e.target.value)}
className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-white text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
>
<option value="">{t('tasks.allPriorities')}</option>
<option value="low">{t('tasks.priority.low')}</option>
@@ -466,7 +466,7 @@ export default function Tasks() {
<select
value={filterAssignee}
onChange={e => setFilterAssignee(e.target.value)}
className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-white text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
>
<option value="">{t('tasks.allAssignees')}</option>
{(assignableUsers || []).map(m => (
@@ -479,7 +479,7 @@ export default function Tasks() {
<select
value={filterCreator}
onChange={e => setFilterCreator(e.target.value)}
className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-white text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
>
<option value="">{t('tasks.allCreators')}</option>
{users.map(m => (
@@ -501,7 +501,7 @@ export default function Tasks() {
type="date"
value={filterDateFrom}
onChange={e => { setFilterDateFrom(e.target.value); setActivePreset('') }}
className="px-2 py-1.5 text-xs border border-border rounded-lg bg-white text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
className="px-2 py-1.5 text-xs border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
title={t('posts.periodFrom')}
/>
<span className="text-text-tertiary text-xs">-</span>
@@ -509,7 +509,7 @@ export default function Tasks() {
type="date"
value={filterDateTo}
onChange={e => { setFilterDateTo(e.target.value); setActivePreset('') }}
className="px-2 py-1.5 text-xs border border-border rounded-lg bg-white text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
className="px-2 py-1.5 text-xs border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
title={t('posts.periodTo')}
/>
</div>
@@ -520,7 +520,7 @@ export default function Tasks() {
className={`px-2.5 py-1 text-[11px] font-medium rounded-full border transition-colors ${
filterOverdue
? 'bg-red-50 border-red-200 text-red-600'
: 'bg-white border-border text-text-tertiary'
: 'bg-surface border-border text-text-tertiary'
}`}
>
{t('tasks.overdue')}
@@ -599,7 +599,7 @@ export default function Tasks() {
onDelete={() => setShowBulkDeleteConfirm(true)}
/>
)}
<div className="bg-white rounded-xl border border-border overflow-hidden">
<div className="bg-surface rounded-xl border border-border overflow-clip">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-surface-secondary/50">
@@ -614,28 +614,28 @@ export default function Tasks() {
</th>
<th className="w-8 px-3 py-2.5"></th>
<th
className="text-left px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase cursor-pointer hover:text-text-primary"
className="text-start px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase cursor-pointer hover:text-text-primary"
onClick={() => toggleSort('title')}
>
{t('tasks.taskTitle')} {sortBy === 'title' && (sortDir === 'asc' ? '↑' : '↓')}
</th>
<th className="text-left px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase">{t('tasks.project')}</th>
<th className="text-left px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase">{t('tasks.brand')}</th>
<th className="text-start px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase">{t('tasks.project')}</th>
<th className="text-start px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase">{t('tasks.brand')}</th>
<th
className="text-left px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase cursor-pointer hover:text-text-primary"
className="text-start px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase cursor-pointer hover:text-text-primary"
onClick={() => toggleSort('status')}
>
{t('tasks.status')} {sortBy === 'status' && (sortDir === 'asc' ? '↑' : '↓')}
</th>
<th className="text-left px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase">{t('tasks.assignee')}</th>
<th className="text-start px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase">{t('tasks.assignee')}</th>
<th
className="text-left px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase cursor-pointer hover:text-text-primary"
className="text-start px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase cursor-pointer hover:text-text-primary"
onClick={() => toggleSort('due_date')}
>
{t('tasks.dueDate')} {sortBy === 'due_date' && (sortDir === 'asc' ? '↑' : '↓')}
</th>
<th
className="text-left px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase cursor-pointer hover:text-text-primary"
className="text-start px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase cursor-pointer hover:text-text-primary"
onClick={() => toggleSort('priority')}
>
{t('tasks.priority')} {sortBy === 'priority' && (sortDir === 'asc' ? '↑' : '↓')}
@@ -651,7 +651,7 @@ export default function Tasks() {
const brandName = task.brand_name || task.brandName
const assignedName = task.assigned_name || task.assignedName
const statusLabels = { todo: t('tasks.todo'), in_progress: t('tasks.in_progress'), done: t('tasks.done') }
const statusColors = { todo: 'bg-gray-100 text-gray-600', in_progress: 'bg-blue-100 text-blue-700', done: 'bg-emerald-100 text-emerald-700' }
const statusColors = { todo: 'bg-gray-100 text-text-secondary', in_progress: 'bg-blue-100 text-blue-700', done: 'bg-emerald-100 text-emerald-700' }
return (
<tr
@@ -675,7 +675,7 @@ export default function Tasks() {
{task.title}
</span>
{(task.comment_count || task.commentCount) > 0 && (
<span className="ml-2 text-[10px] text-text-tertiary">💬 {task.comment_count || task.commentCount}</span>
<span className="ms-2 text-[10px] text-text-tertiary">💬 {task.comment_count || task.commentCount}</span>
)}
</td>
<td className="px-3 py-2.5 text-text-tertiary text-xs">{projectName || '—'}</td>
+28 -26
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useContext, useRef } from 'react'
import { useState, useEffect, useContext, useRef, useMemo } from 'react'
import { Plus, Users, ArrowLeft, User as UserIcon, Edit2, LayoutGrid, Network, Link2, ChevronDown, Check, X } from 'lucide-react'
import { getInitials } from '../utils/api'
import { AppContext, PERMISSION_LEVELS } from '../App'
@@ -16,9 +16,9 @@ import { useToast } from '../components/ToastContainer'
const ALL_MODULES = ['marketing', 'projects', 'finance']
const MODULE_LABELS = { marketing: 'Marketing', projects: 'Projects', finance: 'Finance' }
const MODULE_COLORS = {
marketing: { on: 'bg-emerald-100 text-emerald-700 border-emerald-300', off: 'bg-gray-100 text-gray-400 border-gray-200' },
projects: { on: 'bg-blue-100 text-blue-700 border-blue-300', off: 'bg-gray-100 text-gray-400 border-gray-200' },
finance: { on: 'bg-amber-100 text-amber-700 border-amber-300', off: 'bg-gray-100 text-gray-400 border-gray-200' },
marketing: { on: 'bg-emerald-100 text-emerald-700 border-emerald-300', off: 'bg-gray-100 text-text-tertiary border-gray-200' },
projects: { on: 'bg-blue-100 text-blue-700 border-blue-300', off: 'bg-gray-100 text-text-tertiary border-gray-200' },
finance: { on: 'bg-amber-100 text-amber-700 border-amber-300', off: 'bg-gray-100 text-text-tertiary border-gray-200' },
}
const EMPTY_MEMBER = {
@@ -238,9 +238,11 @@ export default function Team() {
// Member detail view
if (selectedMember) {
const todoCount = memberTasks.filter(t => t.status === 'todo').length
const inProgressCount = memberTasks.filter(t => t.status === 'in_progress').length
const doneCount = memberTasks.filter(t => t.status === 'done').length
const { todoCount, inProgressCount, doneCount } = useMemo(() => ({
todoCount: memberTasks.filter(t => t.status === 'todo').length,
inProgressCount: memberTasks.filter(t => t.status === 'in_progress').length,
doneCount: memberTasks.filter(t => t.status === 'done').length,
}), [memberTasks])
return (
<div className="space-y-6 animate-fade-in">
@@ -253,7 +255,7 @@ export default function Team() {
</button>
{/* Member profile */}
<div className="bg-white rounded-xl border border-border p-6">
<div className="bg-surface rounded-xl border border-border p-6">
<div className="flex items-start gap-4">
<div className={`w-16 h-16 rounded-full bg-gradient-to-br from-indigo-400 to-purple-500 flex items-center justify-center text-white text-xl font-bold`}>
{selectedMember.name?.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase()}
@@ -281,19 +283,19 @@ export default function Team() {
{/* Workload stats */}
<div className="grid grid-cols-4 gap-4">
<div className="bg-white rounded-xl border border-border p-4 text-center">
<div className="bg-surface rounded-xl border border-border p-4 text-center">
<p className="text-2xl font-bold text-text-primary">{memberTasks.length}</p>
<p className="text-xs text-text-tertiary">{t('team.totalTasks')}</p>
</div>
<div className="bg-white rounded-xl border border-border p-4 text-center">
<div className="bg-surface rounded-xl border border-border p-4 text-center">
<p className="text-2xl font-bold text-amber-500">{todoCount}</p>
<p className="text-xs text-text-tertiary">{t('team.toDo')}</p>
</div>
<div className="bg-white rounded-xl border border-border p-4 text-center">
<div className="bg-surface rounded-xl border border-border p-4 text-center">
<p className="text-2xl font-bold text-blue-500">{inProgressCount}</p>
<p className="text-xs text-text-tertiary">{t('team.inProgress')}</p>
</div>
<div className="bg-white rounded-xl border border-border p-4 text-center">
<div className="bg-surface rounded-xl border border-border p-4 text-center">
<p className="text-2xl font-bold text-emerald-500">{doneCount}</p>
<p className="text-xs text-text-tertiary">{t('tasks.done')}</p>
</div>
@@ -302,7 +304,7 @@ export default function Team() {
{/* Tasks & Posts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Tasks */}
<div className="bg-white rounded-xl border border-border">
<div className="bg-surface rounded-xl border border-border">
<div className="px-5 py-4 border-b border-border">
<h3 className="font-semibold text-text-primary">{t('tasks.title')} ({memberTasks.length})</h3>
</div>
@@ -327,7 +329,7 @@ export default function Team() {
</div>
{/* Posts */}
<div className="bg-white rounded-xl border border-border">
<div className="bg-surface rounded-xl border border-border">
<div className="px-5 py-4 border-b border-border">
<h3 className="font-semibold text-text-primary">{t('nav.posts')} ({memberPosts.length})</h3>
</div>
@@ -394,7 +396,7 @@ export default function Team() {
{displayedMembers.length} {displayedMembers.length !== 1 ? t('team.membersPlural') : t('team.member')}
</p>
{/* View toggle */}
<div className="flex items-center bg-white border border-border rounded-lg overflow-hidden">
<div className="flex items-center bg-surface border border-border rounded-lg overflow-hidden">
<button
onClick={() => setViewMode('grid')}
className={`p-2 transition-colors ${viewMode === 'grid' ? 'bg-brand-primary text-white' : 'text-text-tertiary hover:text-text-primary'}`}
@@ -415,7 +417,7 @@ export default function Team() {
{/* Copy generic issue link */}
<button
onClick={() => copyIssueLink()}
className="flex items-center gap-2 px-4 py-2 bg-white border border-border text-text-primary rounded-lg text-sm font-medium hover:bg-surface-secondary transition-colors"
className="flex items-center gap-2 px-4 py-2 bg-surface border border-border text-text-primary rounded-lg text-sm font-medium hover:bg-surface-secondary transition-colors"
title={t('team.copyGenericIssueLink')}
>
<Link2 className="w-4 h-4" />
@@ -428,7 +430,7 @@ export default function Team() {
const self = teamMembers.find(m => m._id === user?.id || m.id === user?.id)
if (self) openEdit(self)
}}
className="flex items-center gap-2 px-4 py-2 bg-white border border-border text-text-primary rounded-lg text-sm font-medium hover:bg-surface-secondary transition-colors"
className="flex items-center gap-2 px-4 py-2 bg-surface border border-border text-text-primary rounded-lg text-sm font-medium hover:bg-surface-secondary transition-colors"
>
<UserIcon className="w-4 h-4" />
{t('team.myProfile')}
@@ -438,7 +440,7 @@ export default function Team() {
{canManageTeam && (
<button
onClick={() => setPanelTeam({})}
className="flex items-center gap-2 px-4 py-2 bg-white border border-border text-text-primary rounded-lg text-sm font-medium hover:bg-surface-secondary transition-colors"
className="flex items-center gap-2 px-4 py-2 bg-surface border border-border text-text-primary rounded-lg text-sm font-medium hover:bg-surface-secondary transition-colors"
>
<Users className="w-4 h-4" />
{t('teams.createTeam')}
@@ -468,7 +470,7 @@ export default function Team() {
<button
onClick={() => setTeamFilter(null)}
className={`text-xs px-3 py-1.5 rounded-full border font-medium transition-colors ${
!teamFilter ? 'bg-brand-primary text-white border-brand-primary' : 'bg-white text-text-secondary border-border hover:bg-surface-tertiary'
!teamFilter ? 'bg-brand-primary text-white border-brand-primary' : 'bg-surface text-text-secondary border-border hover:bg-surface-tertiary'
}`}
>
{t('common.all')}
@@ -481,7 +483,7 @@ export default function Team() {
<button
onClick={() => setTeamFilter(active ? null : tid)}
className={`text-xs px-3 py-1.5 rounded-full border font-medium transition-colors ${
active ? 'bg-blue-600 text-white border-blue-600' : 'bg-white text-text-secondary border-border hover:bg-surface-tertiary'
active ? 'bg-blue-600 text-white border-blue-600' : 'bg-surface text-text-secondary border-border hover:bg-surface-tertiary'
}`}
>
{team.name} ({team.member_count || 0})
@@ -531,7 +533,7 @@ export default function Team() {
const tid = team.id || team._id
const members = teamMembers.filter(m => m.teams?.some(t => t.id === tid))
return (
<div key={tid} className="bg-white rounded-xl border border-border overflow-hidden">
<div key={tid} className="bg-surface rounded-xl border border-border overflow-clip">
{/* Team header */}
<div className="flex items-center justify-between px-5 py-4 bg-gradient-to-r from-blue-50 to-indigo-50 border-b border-border">
<div className="flex items-center gap-3">
@@ -601,7 +603,7 @@ export default function Team() {
{/* Unassigned members */}
{unassignedMembers.length > 0 && (
<div className="bg-white rounded-xl border border-border overflow-hidden">
<div className="bg-surface rounded-xl border border-border overflow-clip">
<div className="flex items-center gap-3 px-5 py-4 bg-gray-50 border-b border-border">
<div className="w-10 h-10 rounded-lg bg-gray-400 flex items-center justify-center text-white">
<UserIcon className="w-5 h-5" />
@@ -707,7 +709,7 @@ export default function Team() {
<div ref={addBrandsRef} className="relative">
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.brands')}</label>
<button type="button" onClick={() => setShowAddBrandsDropdown(p => !p)}
className="w-full flex items-center justify-between px-3 py-2 text-sm border border-border rounded-lg bg-white text-left focus:outline-none focus:ring-2 focus:ring-brand-primary/20">
className="w-full flex items-center justify-between px-3 py-2 text-sm border border-border rounded-lg bg-surface text-start focus:outline-none focus:ring-2 focus:ring-brand-primary/20">
<span className={`flex-1 truncate ${addForm.brands.length === 0 ? 'text-text-tertiary' : 'text-text-primary'}`}>
{addForm.brands.length === 0 ? t('team.selectBrands') : addForm.brands.join(', ')}
</span>
@@ -724,13 +726,13 @@ export default function Team() {
</div>
)}
{showAddBrandsDropdown && (
<div className="absolute z-20 mt-1 w-full bg-white border border-border rounded-lg shadow-lg max-h-48 overflow-y-auto">
<div className="absolute z-20 mt-1 w-full bg-surface border border-border rounded-lg shadow-lg max-h-48 overflow-y-auto">
{brands.map(brand => {
const name = lang === 'ar' && brand.name_ar ? brand.name_ar : brand.name
const checked = addForm.brands.includes(name)
return (
<button key={brand.id || brand._id} type="button" onClick={() => toggleAddBrand(name)}
className={`w-full flex items-center gap-2.5 px-3 py-2 hover:bg-surface-secondary transition-colors text-left ${checked ? 'bg-brand-primary/5' : ''}`}>
className={`w-full flex items-center gap-2.5 px-3 py-2 hover:bg-surface-secondary transition-colors text-start ${checked ? 'bg-brand-primary/5' : ''}`}>
<div className={`w-4 h-4 rounded border flex items-center justify-center shrink-0 ${checked ? 'bg-brand-primary border-brand-primary' : 'border-border'}`}>
{checked && <Check className="w-3 h-3 text-white" />}
</div>
@@ -771,7 +773,7 @@ export default function Team() {
return (
<button key={tid} type="button"
onClick={() => updateAdd('team_ids', active ? addForm.team_ids.filter(id => id !== tid) : [...addForm.team_ids, tid])}
className={`text-xs px-3 py-1.5 rounded-full border font-medium transition-colors ${active ? 'bg-blue-100 text-blue-700 border-blue-300' : 'bg-gray-100 text-gray-400 border-gray-200'}`}>
className={`text-xs px-3 py-1.5 rounded-full border font-medium transition-colors ${active ? 'bg-blue-100 text-blue-700 border-blue-300' : 'bg-gray-100 text-text-tertiary border-gray-200'}`}>
{team.name}
</button>
)
+93 -45
View File
@@ -1,5 +1,5 @@
import { useState, useEffect, useContext, useMemo } from 'react'
import { Plus, Search, LayoutGrid, List, ChevronUp, ChevronDown, Languages, Globe } from 'lucide-react'
import { Plus, Search, LayoutGrid, List, ChevronUp, ChevronDown, Languages, Globe, FileEdit } from 'lucide-react'
import { AppContext } from '../App'
import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../i18n/LanguageContext'
@@ -10,21 +10,8 @@ import { useToast } from '../components/ToastContainer'
import { SkeletonCard, SkeletonTable } from '../components/SkeletonLoader'
import TranslationDetailPanel from '../components/TranslationDetailPanel'
import ApproverMultiSelect from '../components/ApproverMultiSelect'
import { AVAILABLE_LANGUAGES, TRANSLATION_STATUS_COLORS } from '../utils/translations'
const STATUS_COLORS = {
draft: 'bg-surface-tertiary text-text-secondary',
pending_review: 'bg-amber-100 text-amber-700',
approved: 'bg-emerald-100 text-emerald-700',
rejected: 'bg-red-100 text-red-700',
revision_requested: 'bg-orange-100 text-orange-700',
}
const AVAILABLE_LANGUAGES = [
{ code: 'AR', label: 'العربية' },
{ code: 'EN', label: 'English' },
{ code: 'FR', label: 'Français' },
{ code: 'ID', label: 'Bahasa Indonesia' },
]
const SORT_OPTIONS = [
{ value: 'updated_at', dir: 'desc', labelKey: 'translations.sortRecentlyUpdated' },
@@ -45,8 +32,12 @@ export default function Translations() {
const [searchTerm, setSearchTerm] = useState('')
const [showCreateModal, setShowCreateModal] = useState(false)
const [selectedTranslation, setSelectedTranslation] = useState(null)
const [newTranslation, setNewTranslation] = useState({ title: '', description: '', source_language: 'EN', source_content: '', brand_id: '', approver_ids: [] })
const [newTranslation, setNewTranslation] = useState({ title: '', source_language: 'EN', source_content: '', brand_id: '', post_id: '', approver_ids: [] })
const [saving, setSaving] = useState(false)
const [posts, setPosts] = useState([])
const [showCreatePost, setShowCreatePost] = useState(false)
const [newPostTitle, setNewPostTitle] = useState('')
const [creatingPost, setCreatingPost] = useState(false)
// Bulk select
const [selectedIds, setSelectedIds] = useState(new Set())
@@ -63,6 +54,7 @@ export default function Translations() {
useEffect(() => {
loadTranslations()
api.get('/users/assignable').then(res => setAssignableUsers(Array.isArray(res) ? res : [])).catch(() => {})
api.get('/posts').then(res => setPosts(Array.isArray(res) ? res : [])).catch(() => {})
}, [])
const loadTranslations = async () => {
@@ -91,10 +83,11 @@ export default function Translations() {
const created = await api.post('/translations', {
...newTranslation,
approver_ids: newTranslation.approver_ids.length > 0 ? newTranslation.approver_ids.join(',') : null,
post_id: newTranslation.post_id || null,
})
toast.success(t('translations.created'))
setShowCreateModal(false)
setNewTranslation({ title: '', description: '', source_language: 'EN', source_content: '', brand_id: '', approver_ids: [] })
setNewTranslation({ title: '', source_language: 'EN', source_content: '', brand_id: '', post_id: '', approver_ids: [] })
loadTranslations()
setSelectedTranslation(created)
} catch (err) {
@@ -116,6 +109,24 @@ export default function Translations() {
}
}
const handleCreatePost = async () => {
if (!newPostTitle.trim()) return
setCreatingPost(true)
try {
const created = await api.post('/posts', { title: newPostTitle, status: 'draft' })
const postId = created.Id || created.id || created._id
setPosts(prev => [created, ...prev])
setNewTranslation(f => ({ ...f, post_id: String(postId) }))
setShowCreatePost(false)
setNewPostTitle('')
toast.success(t('translations.postCreated'))
} catch (err) {
toast.error(t('translations.postCreateFailed'))
} finally {
setCreatingPost(false)
}
}
const handleBulkDelete = async () => {
try {
await api.post('/translations/bulk-delete', { ids: [...selectedIds] })
@@ -178,8 +189,8 @@ export default function Translations() {
const SortIcon = ({ col }) => {
if (listSortBy !== col) return null
return listSortDir === 'asc'
? <ChevronUp className="w-3 h-3 inline ml-0.5" />
: <ChevronDown className="w-3 h-3 inline ml-0.5" />
? <ChevronUp className="w-3 h-3 inline ms-0.5" />
: <ChevronDown className="w-3 h-3 inline ms-0.5" />
}
const formatDate = (dateStr) => {
@@ -208,7 +219,7 @@ export default function Translations() {
onClick={() => setViewMode(mode)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
viewMode === mode
? 'bg-white text-text-primary shadow-sm'
? 'bg-surface text-text-primary shadow-sm'
: 'text-text-tertiary hover:text-text-secondary'
}`}
>
@@ -231,13 +242,13 @@ export default function Translations() {
{/* Filters */}
<div className="flex flex-wrap items-center gap-3">
<div className="relative flex-1 min-w-[200px] max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<Search className="absolute start-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<input
type="text"
placeholder={t('translations.searchTranslations')}
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface transition-colors"
className="w-full ps-10 pe-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface transition-colors"
/>
</div>
@@ -314,7 +325,7 @@ export default function Translations() {
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-text-primary line-clamp-1">{tr.title}</h3>
<div className="flex items-center gap-2 mt-1">
<span className={`text-xs px-2 py-0.5 rounded-full ${STATUS_COLORS[tr.status] || 'bg-surface-tertiary text-text-secondary'}`}>
<span className={`text-xs px-2 py-0.5 rounded-full ${TRANSLATION_STATUS_COLORS[tr.status] || 'bg-surface-tertiary text-text-secondary'}`}>
{tr.status?.replace('_', ' ')}
</span>
<span className="text-xs px-2 py-0.5 rounded-full bg-blue-50 text-blue-600 font-medium">
@@ -323,11 +334,9 @@ export default function Translations() {
</div>
</div>
</div>
{tr.description && (
<p className="text-sm text-text-secondary line-clamp-2 mb-3">{tr.description}</p>
)}
<div className="flex items-center gap-2 text-xs text-text-tertiary flex-wrap">
{tr.brand_name && <span>{tr.brand_name}</span>}
{tr.post_name && <span className="flex items-center gap-1"><FileEdit className="w-3 h-3" />{tr.post_name}</span>}
{tr.creator_name && <span>by {tr.creator_name}</span>}
<span>{tr.translation_count || 0} {t('translations.languagesCount')}</span>
</div>
@@ -343,26 +352,26 @@ export default function Translations() {
<p className="text-text-secondary">{t('translations.noTranslations')}</p>
</div>
) : (
<div className="bg-surface rounded-xl border border-border overflow-hidden">
<div className="bg-surface rounded-xl border border-border overflow-clip">
<table className="w-full">
<thead>
<tr className="border-b border-border bg-surface-secondary">
<th className="px-4 py-3 text-left w-10">
<th className="px-4 py-3 text-start w-10">
<input type="checkbox" checked={selectedIds.size === sortedTranslations.length && sortedTranslations.length > 0} onChange={toggleSelectAll} className="rounded border-border" />
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer" onClick={() => toggleListSort('title')}>
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase cursor-pointer" onClick={() => toggleListSort('title')}>
{t('translations.titleLabel')} <SortIcon col="title" />
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">
{t('translations.sourceLanguage')}
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer" onClick={() => toggleListSort('status')}>
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase cursor-pointer" onClick={() => toggleListSort('status')}>
{t('translations.status')} <SortIcon col="status" />
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('translations.brand')}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('translations.creator')}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('translations.languagesLabel')}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer" onClick={() => toggleListSort('updated_at')}>
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('translations.brand')}</th>
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('translations.creator')}</th>
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('translations.languagesLabel')}</th>
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase cursor-pointer" onClick={() => toggleListSort('updated_at')}>
{t('translations.updated')} <SortIcon col="updated_at" />
</th>
</tr>
@@ -384,7 +393,7 @@ export default function Translations() {
</span>
</td>
<td className="px-4 py-3">
<span className={`text-xs px-2 py-0.5 rounded-full ${STATUS_COLORS[tr.status] || 'bg-surface-tertiary text-text-secondary'}`}>
<span className={`text-xs px-2 py-0.5 rounded-full ${TRANSLATION_STATUS_COLORS[tr.status] || 'bg-surface-tertiary text-text-secondary'}`}>
{tr.status?.replace('_', ' ')}
</span>
</td>
@@ -408,6 +417,7 @@ export default function Translations() {
onUpdate={loadTranslations}
onDelete={handleDelete}
assignableUsers={assignableUsers}
posts={posts}
/>
)}
@@ -454,6 +464,53 @@ export default function Translations() {
{brands.map(b => <option key={b._id} value={b._id}>{b.name}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('translations.linkedPost')}</label>
{showCreatePost ? (
<div className="flex items-center gap-2">
<input
type="text"
value={newPostTitle}
onChange={e => setNewPostTitle(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleCreatePost()}
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
placeholder={t('translations.newPostTitle')}
autoFocus
/>
<button
onClick={handleCreatePost}
disabled={creatingPost || !newPostTitle.trim()}
className="px-3 py-2 bg-brand-primary text-white text-sm rounded-lg hover:bg-brand-primary-light disabled:opacity-50"
>
{creatingPost ? '...' : t('common.create')}
</button>
<button
onClick={() => setShowCreatePost(false)}
className="px-2 py-2 text-sm text-text-secondary hover:text-text-primary"
>
{t('common.cancel')}
</button>
</div>
) : (
<div className="flex items-center gap-2">
<select
value={newTranslation.post_id}
onChange={e => setNewTranslation(f => ({ ...f, post_id: e.target.value }))}
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
>
<option value=""></option>
{posts.map(p => <option key={p.Id || p.id || p._id} value={p.Id || p.id || p._id}>{p.title}</option>)}
</select>
<button
onClick={() => setShowCreatePost(true)}
className="flex items-center gap-1 px-3 py-2 text-sm text-brand-primary hover:text-brand-primary/80 font-medium whitespace-nowrap"
>
<Plus className="w-3.5 h-3.5" />
{t('translations.createPost')}
</button>
</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('translations.approvers')}</label>
<ApproverMultiSelect
@@ -462,15 +519,6 @@ export default function Translations() {
users={assignableUsers}
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('translations.description')}</label>
<textarea
value={newTranslation.description}
onChange={e => setNewTranslation(f => ({ ...f, description: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 min-h-[60px] resize-y"
placeholder={t('translations.descriptionPlaceholder')}
/>
</div>
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
<button onClick={() => setShowCreateModal(false)} className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg">
{t('common.cancel')}
+39
View File
@@ -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
}
+30
View File
@@ -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
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,635 @@
# Budget Allocation Redesign — Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the dual budget system with a single source of truth (BudgetEntries), add validation at all levels, and implement a CEO approval workflow for new income.
**Architecture:** BudgetEntries table is the only source for all budget calculations. Campaign/project allocations are income entries with a FK set. A new BudgetRequests table + public approval page handles CEO approval for new income. Budget mutex prevents race conditions.
**Tech Stack:** Express.js (server), React (client), NocoDB (database), nodemailer (emails)
**Spec:** `docs/superpowers/specs/2026-03-15-budget-allocation-redesign.md`
---
## Chunk 1: Server — Budget Model Fix + Validation
### Task 1: Add budget mutex utility
**Files:**
- Create: `server/budget-mutex.js`
- [ ] **Step 1: Create the mutex module**
```javascript
// server/budget-mutex.js
let _lock = null;
async function acquireBudgetLock() {
while (_lock) await _lock;
let resolve;
_lock = new Promise(r => { resolve = r; });
return () => { _lock = null; resolve(); };
}
module.exports = { acquireBudgetLock };
```
- [ ] **Step 2: Commit**
```bash
git add server/budget-mutex.js
git commit -m "feat: add budget mutex for race condition prevention"
```
### Task 2: Add budget availability helper
**Files:**
- Create: `server/budget-helpers.js`
This module computes `mainAvailable` and `campaignAvailable` from BudgetEntries — the single source of truth. Every route that modifies budget will call these.
- [ ] **Step 1: Create the helper module**
```javascript
// server/budget-helpers.js
const nocodb = require('./nocodb');
const QUERY_LIMITS = { max: 10000 };
async function getMainAvailable() {
const entries = await nocodb.list('BudgetEntries', { limit: QUERY_LIMITS.max });
const income = entries.filter(e => (e.type || 'income') === 'income');
const expenses = entries.filter(e => e.type === 'expense');
const totalReceived = income.reduce((s, e) => s + (e.amount || 0), 0);
const totalExpenses = expenses.reduce((s, e) => s + (e.amount || 0), 0);
const totalCampaignBudget = income.filter(e => e.campaign_id).reduce((s, e) => s + (e.amount || 0), 0);
const totalProjectBudget = income.filter(e => e.project_id).reduce((s, e) => s + (e.amount || 0), 0);
return {
totalReceived,
totalExpenses,
totalCampaignBudget,
totalProjectBudget,
available: totalReceived - totalExpenses - totalCampaignBudget - totalProjectBudget,
};
}
async function getCampaignAvailable(campaignId) {
const entries = await nocodb.list('BudgetEntries', {
where: `(campaign_id,eq,${campaignId})~and(type,neq,expense)`,
limit: QUERY_LIMITS.max,
});
const allocated = entries.reduce((s, e) => s + (e.amount || 0), 0);
const tracks = await nocodb.list('CampaignTracks', {
where: `(campaign_id,eq,${campaignId})`,
limit: QUERY_LIMITS.max,
});
const trackAllocated = tracks.reduce((s, t) => s + (t.budget_allocated || 0), 0);
return { allocated, trackAllocated, available: allocated - trackAllocated };
}
async function getCampaignAllocatedFromEntries(campaignId) {
const entries = await nocodb.list('BudgetEntries', {
where: `(campaign_id,eq,${campaignId})~and(type,neq,expense)`,
limit: QUERY_LIMITS.max,
});
return entries.reduce((s, e) => s + (e.amount || 0), 0);
}
module.exports = { getMainAvailable, getCampaignAvailable, getCampaignAllocatedFromEntries };
```
- [ ] **Step 2: Commit**
```bash
git add server/budget-helpers.js
git commit -m "feat: add budget availability helpers (single source of truth)"
```
### Task 3: Fix finance summary endpoint
**Files:**
- Modify: `server/server.js` — the `GET /api/finance/summary` handler (~lines 2405-2488)
- [ ] **Step 1: Rewrite the finance summary to use BudgetEntries only**
Replace the entire handler body. Key changes:
- `totalReceived` = sum of ALL income entries (same for all roles — remove the superadmin/manager fork)
- `totalCampaignBudget` = sum of income entries with `campaign_id` set (not `Campaign.budget`)
- `remaining` = mainAvailable (no track double-counting)
- Keep track aggregations (spent, revenue, impressions) for the campaign breakdown table
- Add `mainAvailable` to the response
The handler still filters by user's campaign access for managers. Managers see a subset of campaigns but the SAME calculation logic.
- [ ] **Step 2: Verify build**
```bash
cd client && npx vite build --logLevel error
```
- [ ] **Step 3: Commit**
```bash
git add server/server.js
git commit -m "fix: finance summary uses BudgetEntries as single source of truth"
```
### Task 4: Add budget validation to campaign creation
**Files:**
- Modify: `server/server.js``POST /api/campaigns` (~line 2097)
- [ ] **Step 1: Add validation + auto-create BudgetEntry**
In the campaign creation handler, after creating the campaign:
1. Import `{ acquireBudgetLock }` from `./budget-mutex`
2. Import `{ getMainAvailable }` from `./budget-helpers`
3. If `budget > 0`:
- Acquire lock
- Check `mainAvailable >= budget`
- If insufficient: delete the just-created campaign, release lock, return 400
- If OK: create BudgetEntry `{ type: 'income', amount: budget, campaign_id: created.Id, label: 'Campaign allocation', source: 'Campaign creation', date_received: new Date().toISOString().slice(0,10) }`
- Release lock
- [ ] **Step 2: Add validation to campaign PATCH for budget changes**
Modify: `server/server.js``PATCH /api/campaigns/:id`
If `budget` field is being updated:
1. Get current allocated = sum of income BudgetEntries for this campaign
2. If increasing: check `mainAvailable >= (newBudget - currentAllocated)`
3. If decreasing: check `newBudget >= sum(tracks.budget_allocated)` for this campaign
4. Update (or create) the BudgetEntry to match new budget amount
- [ ] **Step 3: Commit**
```bash
git add server/server.js
git commit -m "feat: validate campaign budget against main available, auto-create BudgetEntry"
```
### Task 5: Add budget validation to track creation/edit
**Files:**
- Modify: `server/server.js``POST /api/campaigns/:id/tracks` (~line 2504) and `PATCH /api/campaigns/:id/tracks/:trackId`
- [ ] **Step 1: Add campaignAvailable check to track POST**
Before creating the track:
1. Import `{ getCampaignAvailable }` from `./budget-helpers`
2. If `budget_allocated > 0`: check `campaignAvailable >= budget_allocated`
3. If insufficient: return 400 `{ error: 'Insufficient campaign budget', available: campaignAvailable }`
- [ ] **Step 2: Add same check to track PATCH**
If `budget_allocated` is being updated:
1. Get current track's `budget_allocated`
2. Delta = newAmount - currentAmount
3. If delta > 0: check `campaignAvailable >= delta`
- [ ] **Step 3: Commit**
```bash
git add server/server.js
git commit -m "feat: validate track budget against campaign available"
```
### Task 6: Add budget validation to expense creation
**Files:**
- Modify: `server/server.js``POST /api/budget` (~line 2343)
- [ ] **Step 1: Add mainAvailable check for expenses**
In the budget entry creation handler:
1. Validate `amount > 0`
2. If `type === 'expense'`: acquire lock, check `mainAvailable >= amount`, release
3. If insufficient: return 400
- [ ] **Step 2: Commit**
```bash
git add server/server.js
git commit -m "feat: validate expense entries against available budget"
```
### Task 7: Handle campaign/project deletion — release budget
**Files:**
- Modify: `server/server.js``DELETE /api/campaigns/:id` (~line 2174) and `DELETE /api/projects/:id`
- [ ] **Step 1: Null out BudgetEntry FKs on campaign delete**
In the campaign delete handler, before deleting the campaign:
```javascript
// Release budget entries back to main
const budgetEntries = await nocodb.list('BudgetEntries', { where: `(campaign_id,eq,${id})`, limit: QUERY_LIMITS.max });
for (const e of budgetEntries) await nocodb.update('BudgetEntries', e.Id, { campaign_id: null });
```
- [ ] **Step 2: Same for project delete**
```javascript
const budgetEntries = await nocodb.list('BudgetEntries', { where: `(project_id,eq,${id})`, limit: QUERY_LIMITS.max });
for (const e of budgetEntries) await nocodb.update('BudgetEntries', e.Id, { project_id: null });
```
- [ ] **Step 3: Commit**
```bash
git add server/server.js
git commit -m "feat: release budget on campaign/project deletion"
```
### Task 8: Migration — create BudgetEntries for existing campaigns
**Files:**
- Modify: `server/server.js` — add migration in `startServer()` function
- [ ] **Step 1: Add migration after ensureTextColumns**
```javascript
// Migrate Campaign.budget → BudgetEntries (one-time, idempotent)
async function migrateCampaignBudgets() {
const campaigns = await nocodb.list('Campaigns', { limit: 10000 });
const entries = await nocodb.list('BudgetEntries', { limit: 10000 });
for (const c of campaigns) {
if (!c.budget || c.budget <= 0) continue;
const existing = entries.find(e => e.campaign_id && Number(e.campaign_id) === c.Id && (e.type || 'income') === 'income');
if (existing) continue;
await nocodb.create('BudgetEntries', {
label: `Campaign allocation: ${c.name}`,
amount: c.budget,
type: 'income',
campaign_id: c.Id,
source: 'Migrated from Campaign.budget',
date_received: c.CreatedAt ? c.CreatedAt.slice(0, 10) : new Date().toISOString().slice(0, 10),
category: 'marketing',
});
console.log(` ✓ Migrated budget $${c.budget} for campaign "${c.name}"`);
}
}
```
Call `await migrateCampaignBudgets()` in `startServer()` after table creation.
- [ ] **Step 2: Commit**
```bash
git add server/server.js
git commit -m "feat: migrate Campaign.budget to BudgetEntries (idempotent)"
```
---
## Chunk 2: Server — Budget Request Workflow + CEO Approval
### Task 9: Add BudgetRequests table schema + CEO email setting
**Files:**
- Modify: `server/server.js` — REQUIRED_TABLES, TEXT_COLUMNS, appSettings
- [ ] **Step 1: Add BudgetRequests to REQUIRED_TABLES**
```javascript
BudgetRequests: [
{ title: 'amount', uidt: 'Decimal' },
{ title: 'justification', uidt: 'LongText' },
{ title: 'status', uidt: 'SingleLineText' },
{ title: 'requested_by_user_id', uidt: 'Number' },
{ title: 'approval_token', uidt: 'SingleLineText' },
{ title: 'response_note', uidt: 'LongText' },
{ title: 'earmarked_campaign_id', uidt: 'Number' },
{ title: 'earmarked_project_id', uidt: 'Number' },
{ title: 'created_budget_entry_id', uidt: 'Number' },
],
```
Add to TEXT_COLUMNS:
```javascript
BudgetRequests: [
{ name: 'token_expires_at', uidt: 'SingleLineText' },
{ name: 'resolved_at', uidt: 'SingleLineText' },
],
```
- [ ] **Step 2: Add ceoEmail to appSettings default**
In the `defaultSettings` object (wherever `uploadMaxSizeMB` is initialized), add:
```javascript
ceoEmail: ''
```
In `PATCH /api/settings/app`, add handling for `ceoEmail`:
```javascript
if (ceoEmail !== undefined) {
appSettings.ceoEmail = String(ceoEmail).trim();
}
```
- [ ] **Step 3: Commit**
```bash
git add server/server.js
git commit -m "feat: add BudgetRequests table schema + ceoEmail setting"
```
### Task 10: Add budget request CRUD routes
**Files:**
- Modify: `server/server.js`
- [ ] **Step 1: Add GET /api/budget-requests**
```javascript
app.get('/api/budget-requests', requireAuth, requireRole('superadmin'), async (req, res) => {
try {
const requests = await nocodb.list('BudgetRequests', { limit: 200, sort: '-CreatedAt' });
// Enrich with requester name, campaign/project names
res.json(requests);
} catch (err) {
res.status(500).json({ error: 'Failed to load budget requests' });
}
});
```
- [ ] **Step 2: Add POST /api/budget-requests**
```javascript
app.post('/api/budget-requests', requireAuth, requireRole('superadmin'), async (req, res) => {
const { amount, justification, earmarked_campaign_id, earmarked_project_id } = req.body;
if (!amount || amount <= 0) return res.status(400).json({ error: 'Amount must be positive' });
if (!justification?.trim()) return res.status(400).json({ error: 'Justification is required' });
const ceoEmail = appSettings.ceoEmail;
if (!ceoEmail) return res.status(400).json({ error: 'CEO email not configured. Go to Settings.' });
const token = require('crypto').randomUUID();
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString();
try {
const created = await nocodb.create('BudgetRequests', {
amount,
justification: justification.trim(),
status: 'pending',
requested_by_user_id: req.session.userId,
approval_token: token,
token_expires_at: expiresAt,
earmarked_campaign_id: earmarked_campaign_id ? Number(earmarked_campaign_id) : null,
earmarked_project_id: earmarked_project_id ? Number(earmarked_project_id) : null,
});
// Send email to CEO
const appUrl = process.env.APP_URL || process.env.CORS_ORIGIN || 'http://localhost:3001';
const approvalUrl = `${appUrl}/approve-budget/${token}`;
const requesterName = req.session.userName || 'Team member';
// Use notifications.js pattern for email
const { sendMail } = require('./mail');
await sendMail({
to: ceoEmail,
subject: `Rawaj — Budget Request: ${amount}`,
html: renderBudgetRequestEmail({ amount, requesterName, justification: justification.trim(), approvalUrl }),
text: `${requesterName} is requesting ${amount}. Justification: ${justification.trim()}\n\nReview: ${approvalUrl}`,
});
res.status(201).json(created);
} catch (err) {
console.error('Budget request error:', err);
res.status(500).json({ error: 'Failed to create budget request' });
}
});
```
Add `renderBudgetRequestEmail` helper near the route (uses the same branded template pattern as notifications.js).
- [ ] **Step 3: Add PATCH /api/budget-requests/:id/cancel**
```javascript
app.patch('/api/budget-requests/:id/cancel', requireAuth, requireRole('superadmin'), async (req, res) => {
try {
const request = await nocodb.get('BudgetRequests', req.params.id);
if (!request) return res.status(404).json({ error: 'Not found' });
if (request.status !== 'pending') return res.status(400).json({ error: 'Can only cancel pending requests' });
await nocodb.update('BudgetRequests', request.Id, { status: 'cancelled', resolved_at: new Date().toISOString() });
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: 'Failed to cancel request' });
}
});
```
- [ ] **Step 4: Commit**
```bash
git add server/server.js
git commit -m "feat: add budget request CRUD routes"
```
### Task 11: Add public approval endpoints
**Files:**
- Modify: `server/server.js`
- [ ] **Step 1: Add GET /api/budget-approval/:token (public, no auth)**
Returns request details for the approval page. Validates token exists and hasn't expired.
- [ ] **Step 2: Add POST /api/budget-approval/:token/respond (public, no auth)**
Body: `{ action: 'approve' | 'reject', note?: string }`
On approve:
1. Check status === 'pending' and token not expired (idempotent: if already approved, return 200 with existing result)
2. Auto-create income BudgetEntry with campaign_id/project_id from earmarked fields
3. Update request: status='approved', resolved_at=now, created_budget_entry_id=entry.Id
4. Send notification email to requester (superadmin)
On reject:
1. Update request: status='rejected', response_note=note, resolved_at=now
2. Send notification email to requester
- [ ] **Step 3: Add notification helpers for budget approval/rejection**
Add to `server/notifications.js`:
- `notifyBudgetApproved({ request, entryId })` — emails the requesting superadmin
- `notifyBudgetRejected({ request, note })` — emails the requesting superadmin
- [ ] **Step 4: Commit**
```bash
git add server/server.js server/notifications.js
git commit -m "feat: add public budget approval endpoints + notifications"
```
---
## Chunk 3: Client — Finance Page + Budget Request UI
### Task 12: Update Finance page to show budget requests
**Files:**
- Modify: `client/src/pages/Finance.jsx`
- [ ] **Step 1: Add budget requests fetch and state**
Add `budgetRequests` state, fetch from `GET /api/budget-requests` alongside the finance summary.
- [ ] **Step 2: Add "Request Budget" button in header (superadmin only)**
Next to the page title, show a teal button that opens a modal.
- [ ] **Step 3: Add budget request modal**
Modal with fields: amount (number input), justification (textarea), optional earmark dropdown (campaign or project). Submit calls `POST /api/budget-requests`.
- [ ] **Step 4: Add budget requests section**
Below the existing finance sections, add a "Budget Requests" list:
- Pending: amber badge, shows cancel button
- Approved: green badge, shows linked entry amount
- Rejected: red badge, shows CEO's note
- Cancelled: gray badge
If any pending requests exist, show a banner at the top: "N budget request(s) pending CEO approval"
- [ ] **Step 5: Add i18n keys for budget requests**
Add to both `en.json` and `ar.json`:
- `finance.requestBudget`, `finance.budgetRequests`, `finance.pendingApproval`, `finance.justification`, `finance.earmarkFor`, `finance.submitRequest`, `finance.cancelRequest`, `finance.approved`, `finance.rejected`, `finance.cancelled`, `finance.pending`, `finance.ceoNote`, `finance.requestPending`
- [ ] **Step 6: Commit**
```bash
git add client/src/pages/Finance.jsx client/src/i18n/en.json client/src/i18n/ar.json
git commit -m "feat: budget requests UI on Finance page"
```
### Task 13: Update Settings page — CEO email field
**Files:**
- Modify: `client/src/pages/Settings.jsx`
- [ ] **Step 1: Add CEO email field in settings (superadmin only)**
In the settings form, add a section "Budget Approval":
- Label: "CEO / Budget Approver Email"
- Input: email type, bound to `appSettings.ceoEmail`
- Save alongside existing settings via `PATCH /api/settings/app`
- [ ] **Step 2: Add i18n keys**
`settings.ceoEmail`, `settings.ceoEmailHint`, `settings.budgetApproval`
- [ ] **Step 3: Commit**
```bash
git add client/src/pages/Settings.jsx client/src/i18n/en.json client/src/i18n/ar.json
git commit -m "feat: CEO email setting for budget approval"
```
### Task 14: Update Dashboard BudgetSummary
**Files:**
- Modify: `client/src/pages/Dashboard.jsx`
- [ ] **Step 1: Update BudgetSummary to use new response shape**
The finance summary response now has `mainAvailable` instead of computing `remaining` from the old formula. Update the component to use the new field. The `spent` field from tracks is no longer subtracted from main — it lives within campaign allocations.
- [ ] **Step 2: Commit**
```bash
git add client/src/pages/Dashboard.jsx
git commit -m "fix: dashboard budget uses new single-source response"
```
---
## Chunk 4: Client — Public Approval Page + Campaign Budget Validation UI
### Task 15: Create public budget approval page
**Files:**
- Create: `client/src/pages/PublicBudgetApproval.jsx`
- Modify: `client/src/App.jsx` — add route `/approve-budget/:token`
- [ ] **Step 1: Create the page component**
Follow the same pattern as `PublicReview.jsx`:
1. Fetch request via `GET /api/budget-approval/:token`
2. Show: amount, requester name, justification, earmarked for (if set)
3. Approve / Reject buttons + optional note textarea
4. Submit via `POST /api/budget-approval/:token/respond`
5. States: loading, active, success (with approved/rejected message), already-handled, expired, error
Use the teal brand color for the approve button, red for reject.
- [ ] **Step 2: Add route in App.jsx**
```jsx
<Route path="/approve-budget/:token" element={<PublicBudgetApproval />} />
```
Add this alongside other public routes (before the auth-protected layout).
- [ ] **Step 3: Add i18n keys**
`budgetApproval.title`, `budgetApproval.amount`, `budgetApproval.requestedBy`, `budgetApproval.justification`, `budgetApproval.earmarkedFor`, `budgetApproval.approve`, `budgetApproval.reject`, `budgetApproval.addNote`, `budgetApproval.approved`, `budgetApproval.rejected`, `budgetApproval.expired`, `budgetApproval.alreadyHandled`
- [ ] **Step 4: Commit**
```bash
git add client/src/pages/PublicBudgetApproval.jsx client/src/App.jsx client/src/i18n/en.json client/src/i18n/ar.json
git commit -m "feat: public budget approval page"
```
### Task 16: Add budget validation feedback to campaign creation UI
**Files:**
- Modify: `client/src/pages/Campaigns.jsx` (or wherever campaign creation modal lives)
- [ ] **Step 1: Show available budget near the budget input**
When user enters a budget amount for a new campaign, fetch `mainAvailable` from the finance summary (or a lightweight endpoint) and show: "Available: $X". If the entered amount exceeds available, show error inline and disable the submit button.
- [ ] **Step 2: Handle 400 error from server**
If campaign creation returns 400 with `{ error: 'Insufficient budget', available: X }`, show a toast or inline error with the available amount and a suggestion to request more.
- [ ] **Step 3: Same for track creation in CampaignDetail**
When adding a track, show campaign available budget. Handle 400 insufficient errors.
- [ ] **Step 4: Commit**
```bash
git add client/src/pages/Campaigns.jsx client/src/pages/CampaignDetail.jsx
git commit -m "feat: budget validation UI for campaigns and tracks"
```
### Task 17: Final verification
- [ ] **Step 1: Build check**
```bash
cd client && npx vite build --logLevel error
```
- [ ] **Step 2: Manual test checklist**
1. Create income via budget request → CEO approves → funds appear
2. Create campaign with budget > available → blocked
3. Create campaign with budget ≤ available → succeeds, BudgetEntry created
4. Create track exceeding campaign budget → blocked
5. Delete campaign → funds return to main
6. Create expense > available → blocked
7. Reduce campaign budget below track allocations → blocked
8. Finance summary shows correct numbers (same for superadmin and manager)
- [ ] **Step 3: Commit any final fixes**
@@ -0,0 +1,405 @@
# Post Composition Redesign — Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Transform Posts from flat entities into composition orchestrators that assemble Caption + Copy (Translations) + Designs (Artefacts) + Video into a publishable unit with auto-computed readiness.
**Architecture:** Add `caption` and `stage` fields to Posts table. New `/api/posts/:id/composition` endpoint aggregates linked Translations + Artefacts with approval statuses. PostDetailPanel is rewritten as a composition workspace (single scroll, no tabs). Platform→format mapping is a client-side constant.
**Tech Stack:** Express.js, NocoDB, React, Tailwind CSS
**Spec:** `docs/superpowers/specs/2026-03-15-post-composition-redesign.md`
---
## File Structure
**Server:**
- Modify: `server/server.js` — add `caption`/`stage` to Posts TEXT_COLUMNS, new composition endpoint, update POST/PATCH handlers
- Create: `server/post-composition.js` — helper to compute composition, readiness, and stage auto-advance
**Client — New:**
- Create: `client/src/components/PostCompositionPanel.jsx` — the new composition workspace (replaces PostDetailPanel usage)
- Create: `client/src/components/PostCompositionCaption.jsx` — caption section
- Create: `client/src/components/PostCompositionCopy.jsx` — linked translations section
- Create: `client/src/components/PostCompositionDesigns.jsx` — linked design artefacts section
- Create: `client/src/components/PostCompositionVideo.jsx` — linked video artefact section
- Create: `client/src/components/PostCompositionFormats.jsx` — platform format checklist
- Create: `client/src/components/PostCompositionReadiness.jsx` — readiness summary + sign-off
- Create: `client/src/utils/platformFormats.js` — PLATFORM_FORMATS constant
**Client — Modify:**
- Modify: `client/src/pages/PostProduction.jsx` — use PostCompositionPanel instead of PostDetailPanel
- Modify: `client/src/pages/CampaignDetail.jsx` — same
- Modify: `client/src/i18n/en.json` / `ar.json` — new i18n keys
**Client — Keep (unchanged):**
- `PostDetailVersions.jsx`, `PostDetailPlatforms.jsx`, `PostDetailApproval.jsx`, `PostDetailAttachments.jsx` — kept for backward compat, old PostDetailPanel still importable
---
## Chunk 1: Server — Schema + Composition Endpoint
### Task 1: Add caption and stage to Posts schema
**Files:**
- Modify: `server/server.js` — TEXT_COLUMNS for Posts (~line 520)
- [ ] **Step 1: Add new columns to TEXT_COLUMNS**
Add to the Posts array in TEXT_COLUMNS:
```javascript
{ name: 'caption', uidt: 'LongText' },
{ name: 'stage', uidt: 'SingleLineText' },
```
- [ ] **Step 2: Update POST /api/posts to accept caption and stage**
In the POST handler, add `caption` and `stage` to the create payload:
```javascript
caption: caption || '',
stage: stage || 'copy',
```
- [ ] **Step 3: Update PATCH /api/posts/:id to accept caption**
Add `caption` to the allowed update fields.
- [ ] **Step 4: Commit**
```bash
git add server/server.js
git commit -m "feat: add caption and stage fields to Posts schema"
```
### Task 2: Create post-composition helper
**Files:**
- Create: `server/post-composition.js`
- [ ] **Step 1: Create the helper module**
```javascript
// server/post-composition.js
const nocodb = require('./nocodb');
// Compute full composition for a post
async function getPostComposition(postId) {
const post = await nocodb.get('Posts', postId);
if (!post) return null;
// Linked translations (copy)
const allTranslations = await nocodb.list('Translations', {
where: `(post_id,eq,${postId})`,
limit: 100,
});
const copy = allTranslations.map(t => ({
id: t.Id,
language: t.language,
status: t.status || 'draft',
is_original: t.is_original,
title: t.title,
}));
// Linked artefacts (designs + video)
const allArtefacts = await nocodb.list('Artefacts', {
where: `(post_id,eq,${postId})`,
limit: 100,
});
const designs = allArtefacts
.filter(a => (a.type || 'design') === 'design')
.map(a => ({
id: a.Id,
title: a.title,
status: a.status || 'draft',
thumbnail_url: a.thumbnail_url || null,
}));
const videoArtefact = allArtefacts.find(a => a.type === 'video');
const video = videoArtefact ? {
id: videoArtefact.Id,
title: videoArtefact.title,
status: videoArtefact.status || 'draft',
thumbnail_url: videoArtefact.thumbnail_url || null,
} : null;
// Platforms and formats
let platforms = [];
try { platforms = JSON.parse(post.platforms || '[]'); } catch { platforms = post.platform ? [post.platform] : []; }
// Readiness
const waitingOn = [];
const copyNotApproved = copy.filter(c => c.status !== 'approved');
if (copyNotApproved.length > 0) waitingOn.push(...copyNotApproved.map(c => `Copy (${c.language})`));
const designsNotApproved = designs.filter(d => d.status !== 'approved');
if (designsNotApproved.length > 0) waitingOn.push(...designsNotApproved.map(d => `Design: ${d.title}`));
if (video && video.status !== 'approved') waitingOn.push('Video');
const piecesReady = copy.length > 0 && waitingOn.length === 0;
return {
caption: post.caption || '',
copy,
designs,
video,
platforms,
pieces_ready: piecesReady,
waiting_on: waitingOn,
};
}
// Auto-compute stage from linked pieces
function computeStage(composition) {
const { copy, designs, video, pieces_ready } = composition;
if (pieces_ready) return 'post';
if (designs.length > 0 || video) return 'design';
if (copy.length > 1 || copy.some(c => !c.is_original)) return 'translate';
return 'copy';
}
module.exports = { getPostComposition, computeStage };
```
- [ ] **Step 2: Commit**
```bash
git add server/post-composition.js
git commit -m "feat: add post composition helper (readiness, stage auto-compute)"
```
### Task 3: Add composition API endpoint
**Files:**
- Modify: `server/server.js` — add GET /api/posts/:id/composition
- [ ] **Step 1: Add the endpoint**
After the existing GET /api/posts/:id route, add:
```javascript
app.get('/api/posts/:id/composition', requireAuth, async (req, res) => {
try {
const { getPostComposition } = require('./post-composition');
const composition = await getPostComposition(req.params.id);
if (!composition) return res.status(404).json({ error: 'Post not found' });
res.json(composition);
} catch (err) {
console.error('Composition error:', err);
res.status(500).json({ error: 'Failed to load composition' });
}
});
```
- [ ] **Step 2: Auto-update stage on PATCH /api/posts/:id**
In the existing PATCH handler, after saving, re-compute and update stage:
```javascript
const { getPostComposition, computeStage } = require('./post-composition');
const composition = await getPostComposition(req.params.id);
if (composition) {
const newStage = computeStage(composition);
await nocodb.update('Posts', Number(req.params.id), { stage: newStage });
}
```
- [ ] **Step 3: Also auto-update post stage when Translation or Artefact status changes**
In PATCH /api/translations/:id and PATCH /api/artefacts/:id — if the record has a `post_id`, re-compute the post's stage after saving.
- [ ] **Step 4: Commit**
```bash
git add server/server.js
git commit -m "feat: add /posts/:id/composition endpoint + stage auto-update"
```
---
## Chunk 2: Client — Platform Formats + Composition Sub-Components
### Task 4: Create platform formats constant
**Files:**
- Create: `client/src/utils/platformFormats.js`
- [ ] **Step 1: Create the file**
```javascript
export const PLATFORM_FORMATS = {
instagram: [
{ key: 'ig_feed', label: 'Feed (1:1)', ratio: '1:1' },
{ key: 'ig_story', label: 'Story (9:16)', ratio: '9:16' },
{ key: 'ig_reel', label: 'Reel (9:16)', ratio: '9:16' },
],
tiktok: [
{ key: 'tt_video', label: 'TikTok (9:16)', ratio: '9:16' },
],
youtube: [
{ key: 'yt_video', label: 'YouTube (16:9)', ratio: '16:9' },
{ key: 'yt_short', label: 'Short (9:16)', ratio: '9:16' },
{ key: 'yt_thumb', label: 'Thumbnail (16:9)', ratio: '16:9' },
],
facebook: [
{ key: 'fb_post', label: 'Post (1:1)', ratio: '1:1' },
{ key: 'fb_story', label: 'Story (9:16)', ratio: '9:16' },
],
twitter: [
{ key: 'tw_post', label: 'Post (16:9)', ratio: '16:9' },
],
linkedin: [
{ key: 'li_post', label: 'Post (1:1)', ratio: '1:1' },
],
snapchat: [
{ key: 'sc_snap', label: 'Snap (9:16)', ratio: '9:16' },
],
}
export function getFormatsForPlatforms(platforms = []) {
const formats = []
const seen = new Set()
for (const p of platforms) {
for (const f of (PLATFORM_FORMATS[p] || [])) {
if (!seen.has(f.key)) { seen.add(f.key); formats.push(f) }
}
}
return formats
}
```
- [ ] **Step 2: Commit**
```bash
git add client/src/utils/platformFormats.js
git commit -m "feat: add platform format mapping constant"
```
### Task 5: Create composition sub-components
**Files:**
- Create: `client/src/components/PostCompositionCaption.jsx`
- Create: `client/src/components/PostCompositionCopy.jsx`
- Create: `client/src/components/PostCompositionDesigns.jsx`
- Create: `client/src/components/PostCompositionVideo.jsx`
- Create: `client/src/components/PostCompositionFormats.jsx`
- Create: `client/src/components/PostCompositionReadiness.jsx`
Each is a small focused component (~40-80 lines) rendering one section of the composition workspace.
- [ ] **Step 1: Caption section**
PostCompositionCaption.jsx — textarea for the social media caption. Props: `caption`, `onChange`, `disabled`.
- [ ] **Step 2: Copy section**
PostCompositionCopy.jsx — shows linked translations as language pills with status icons. Props: `copy` (array from composition), `onLink` (opens translation picker), `onCreate` (creates new translation for this post). Each pill is clickable to open the TranslationDetailPanel.
- [ ] **Step 3: Designs section**
PostCompositionDesigns.jsx — shows linked design artefacts as thumbnail cards with status badges. Props: `designs` (array), `onLink`, `onCreate`, `onOpen` (opens ArtefactDetailPanel). Shows "+ Add Design" button.
- [ ] **Step 4: Video section**
PostCompositionVideo.jsx — shows linked video artefact (0 or 1) as a card. Props: `video` (object or null), `onLink`, `onCreate`, `onOpen`.
- [ ] **Step 5: Formats checklist**
PostCompositionFormats.jsx — reads `platforms` from the post, computes needed formats via `getFormatsForPlatforms()`, renders as a checkbox list. This is informational only — checkboxes are manual (designer checks off what they've produced). No database storage for checked state (tracked visually only).
- [ ] **Step 6: Readiness summary**
PostCompositionReadiness.jsx — shows bullet list of what's ready and what's blocking. Props: `piecesReady`, `waitingOn` (array of strings), `onSignOff` (callback for approve/schedule button). Sign-off button disabled until `piecesReady` is true.
- [ ] **Step 7: Commit**
```bash
git add client/src/components/PostComposition*.jsx
git commit -m "feat: add composition sub-components (caption, copy, designs, video, formats, readiness)"
```
---
## Chunk 3: Client — Main Composition Panel + Page Integration
### Task 6: Create PostCompositionPanel
**Files:**
- Create: `client/src/components/PostCompositionPanel.jsx`
- [ ] **Step 1: Build the panel**
This is a SlidePanel-based component (like existing detail panels) but with a composition layout instead of tabs:
```
Header (title input, status, brand, campaign, platforms, assigned_to, close/save/delete)
─────────
Scrollable body:
PostCompositionCaption
PostCompositionCopy
PostCompositionDesigns
PostCompositionVideo
PostCompositionFormats
PostCompositionReadiness
CommentsSection
```
Key behavior:
- On mount: fetches composition via `GET /api/posts/:id/composition`
- Caption changes are saved with the post (dirty tracking + save button)
- Copy/Design/Video sections have "Link existing" and "Create new" actions
- "Link existing" opens a small picker modal (list of unlinked translations/artefacts)
- "Create new" calls the create API with `post_id` pre-set, then refreshes composition
- Readiness section shows sign-off button (sets post status to `approved`)
- Each section is a collapsible card (use CollapsibleSection component)
- [ ] **Step 2: Add i18n keys**
Add to en.json and ar.json:
- `post.caption`, `post.captionPlaceholder`, `post.copy`, `post.copyInDesign`, `post.designs`, `post.video`, `post.formatChecklist`, `post.formatsNeeded`, `post.readiness`, `post.allPiecesReady`, `post.waitingOn`, `post.signOff`, `post.approveAndSchedule`, `post.linkExisting`, `post.createNew`, `post.addDesign`, `post.addVideo`, `post.linkTranslation`, `post.noCopyLinked`, `post.noDesignsLinked`, `post.noVideoLinked`
- [ ] **Step 3: Commit**
```bash
git add client/src/components/PostCompositionPanel.jsx client/src/i18n/en.json client/src/i18n/ar.json
git commit -m "feat: add PostCompositionPanel — composition workspace"
```
### Task 7: Wire up PostCompositionPanel in pages
**Files:**
- Modify: `client/src/pages/PostProduction.jsx`
- Modify: `client/src/pages/CampaignDetail.jsx`
- [ ] **Step 1: Update PostProduction.jsx**
Replace `PostDetailPanel` import and usage with `PostCompositionPanel`. The panel receives the same props (post, onClose, onSave, onDelete, brands, teamMembers, campaigns) plus the new composition data fetching happens inside the panel.
- [ ] **Step 2: Update CampaignDetail.jsx**
Same — replace PostDetailPanel with PostCompositionPanel for post detail views.
- [ ] **Step 3: Commit**
```bash
git add client/src/pages/PostProduction.jsx client/src/pages/CampaignDetail.jsx
git commit -m "feat: wire PostCompositionPanel into PostProduction and CampaignDetail"
```
### Task 8: Final verification
- [ ] **Step 1: Build check**
```bash
cd client && npx vite build --logLevel error
```
- [ ] **Step 2: Manual test checklist**
1. Open a post → composition panel shows caption, copy, designs, video, formats, readiness
2. Edit caption → save → caption persists
3. Link an existing translation → appears in copy section with status
4. Link an existing artefact → appears in designs section with thumbnail
5. Create new design artefact from panel → auto-linked to post
6. Select platforms → format checklist updates
7. Approve all pieces → readiness shows "All pieces ready"
8. Sign off → post status changes to approved
9. Stage auto-advances as pieces are linked
- [ ] **Step 3: Commit any fixes**
@@ -0,0 +1,382 @@
# UX/UI Overhaul — Design Spec
## Overview
Comprehensive UX/UI improvement focused on three pillars: **consistency**, **navigation reorganization**, and **premium polish**. The biggest structural change is unifying Posts, Translations, and Artefacts into a single "Content" page with a pipeline model reflecting the real production workflow.
## 1. Navigation Reorganization
### Current State
- 4 collapsible module groups (Marketing, Projects, Finance, Issues)
- 17 clickable items, some requiring 2 clicks (expand group → click item)
- Issues is its own module group with a single item
### New Structure: Flat Nav with Dividers (9 items)
```
Dashboard
──────────────────
Campaigns
Content ← NEW (replaces Posts, Translations, Artefacts, Calendar)
──────────────────
Projects ← absorbs Tasks as a tab/view
Issues
──────────────────
Finance ← absorbs Budgets as a tab
──────────────────
Team
Settings ← absorbs Brands, Assets as tabs
```
### Rules
- No collapsible groups — every page is 1 click
- Subtle divider lines (1px, white at 6% opacity) separate conceptual groups
- Active state: existing `sidebar-active-glow` style
- Collapsed sidebar: icons only, same as current behavior
### What Moves Where
| Current Location | New Location |
|-----------------|-------------|
| Posts (page) | Content → Posts tab |
| Calendar (page) | Content → Posts tab → Calendar view toggle |
| Translations (page) | Content → Copy tab (originals, `is_original=true`) + Translations tab (translated versions, `is_original=false`) |
| Artefacts (page) | Content → Design tab |
| Assets (page) | Settings → Assets tab |
| Brands (page) | Settings → Brands tab |
| Tasks (page) | Projects → Tasks tab inside project detail. Unlinked tasks accessible via a global "My Tasks" widget on Dashboard + a "All Tasks" view inside the Projects page (not just per-project). The standalone `/tasks` route redirects to `/projects?tab=tasks`. |
| Budgets (page) | Finance → Budgets tab |
## 2. Content Page — Unified Pipeline
### Concept
The content production pipeline is: **Copy → Translate → Design → Post → Publish**. Currently these are 4 separate pages with no visible connection. The Content page unifies them under one roof with tabs.
### Tabs
| Tab | Purpose | Views Available |
|-----|---------|----------------|
| **Pipeline** | Bird's-eye view of all content items by stage | Kanban (stages as columns) |
| **Copy** | Write original copy (usually AR) | List |
| **Translations** | Translate/review/correct copy in other languages | List, grouped by language |
| **Design** | Create artefacts (images/videos) from approved copy | Grid, List |
| **Posts** | Assemble final posts for publishing | Kanban, List, Calendar |
### Pipeline Tab
- Kanban columns: `Copy``Translate``Design``Post``Published`
- Each card shows: title, stage badge, brand, assignee avatar, approval status icon (✅/⏳/❌)
- Drag between columns advances stage (triggers approval flow if applicable)
- Campaign grouping: cards from the same campaign share a visual group (shared top border or header)
- "New Content" button creates a content item starting at Copy stage
### Content Item — Data Model
A **Content Item** is a new NocoDB table (`ContentItems`) that threads all pipeline stages together.
**ContentItems table schema:**
| Column | NocoDB Type | Description |
|--------|-----------|-------------|
| `Id` | AutoNumber (PK) | NocoDB default |
| `title` | SingleLineText | Post concept / idea name |
| `stage` | SingleLineText | Current pipeline stage: `copy`, `translate`, `design`, `post`, `published` |
| `campaign_id` | Number (FK) | Optional link to Campaigns table |
| `brand_id` | Number (FK) | Brand association |
| `assignee_id` | Number (FK) | Current stage assignee |
| `created_by` | SingleLineText | Creator user ID |
**Stage is stored explicitly**, not derived. Advancing stage is a manual action (drag on kanban or "Advance" button) that also triggers the approval flow for the new stage. This keeps queries simple and avoids complex derivation logic.
**FK linkage — existing tables get a `content_item_id` column:**
- `Translations` table → `content_item_id` (Number, FK) — for both original copy and translations
- `Artefacts` table → `content_item_id` (Number, FK) — for designs/videos
- `Posts` table → `content_item_id` (Number, FK) — for the assembled post
These use the existing `FK_COLUMNS` pattern (Number columns, added via `ensureFKColumns()`).
**Copy vs Translation distinction:** Both live in the `Translations` table. A copy entry has `is_original: true` (new Boolean column). The Copy tab filters to `is_original = true`, the Translations tab filters to `is_original = false`. No new table needed.
**Server FK_COLUMNS additions:**
```js
FK_COLUMNS = {
...existing,
Translations: [...existing, 'content_item_id'],
Artefacts: [...existing, 'content_item_id'],
Posts: [...existing, 'content_item_id'],
}
```
**Server TEXT_COLUMNS additions:**
```js
TEXT_COLUMNS = {
...existing,
Translations: [...existing, { name: 'is_original', uidt: 'Checkbox' }],
}
```
### Detail Panel
- Pipeline breadcrumb at top: `Copy ✅ → Translate ✅ → Design ⏳ → Post ○`
- Clicking a stage in the breadcrumb scrolls to that section in the panel
- "Advance to next stage" action button when current stage is approved
- All linked items visible in one panel (copy text, translation list, design thumbnails, post config)
### Approval Flow Per Stage
| Stage | Approval Type |
|-------|--------------|
| Copy | Formal approve/reject |
| Translate | Review & correct (lighter — suggestions, not gates) |
| Design | Formal approve/reject |
| Post | Formal approve/reject (final gate before publish) |
### Standalone Posts
Not every content item needs a campaign or full pipeline. Users can:
- Create a post directly from the Posts tab (skips Copy/Translate/Design)
- Create copy without linking to a campaign
- The pipeline is the recommended flow, not enforced
### Routing Scheme
| Route | Content |
|-------|---------|
| `/content` | Default → Pipeline tab |
| `/content/pipeline` | Pipeline kanban |
| `/content/copy` | Copy tab (original texts) |
| `/content/translations` | Translations tab |
| `/content/design` | Design tab (artefacts) |
| `/content/posts` | Posts tab (kanban/list/calendar) |
**Old route redirects:** `/posts``/content/posts`, `/translations``/content/translations`, `/artefacts``/content/design`, `/calendar``/content/posts?view=calendar`. Redirects ensure existing bookmarks and shared links continue to work.
**Public review routes remain unchanged:** `/review/:token`, `/review-post/:token`, `/review-translation/:token` are not affected by this reorganization.
## 3. Campaign Brief Enhancement
### Current State
Campaigns exist but are mostly containers for posts. The campaign detail has a calendar timeline view.
### Enhanced Campaign Brief
Campaign detail page becomes a proper strategic document.
**New columns on Campaigns table:**
| Column | NocoDB Type | Description |
|--------|-----------|-------------|
| `goals` | SingleLineText | Comma-separated from: `awareness`, `engagement`, `conversions`, `brand_building`, `lead_generation` |
| `target_audience` | LongText | Free text description of target audience |
| `key_messages` | LongText | Free text key messages / talking points |
| `reach_target` | Number | Target reach count |
| `engagement_target` | Number | Target engagement rate (stored as percentage × 100) |
| `conversion_target` | Number | Target conversion count |
| `approval_status` | SingleLineText | `draft`, `pending_approval`, `approved`, `rejected` |
| `approved_by` | SingleLineText | User ID who approved |
| `approved_at` | SingleLineText | ISO timestamp of approval |
**Campaign detail sections:**
- **Brief section**: goals (multi-select chips), target audience, key messages
- **Metrics targets**: reach, engagement rate, conversions (numeric inputs)
- **Timeline**: keep existing calendar timeline (no changes)
- **Budget**: link to finance/budget allocation
- **Approval gate**: campaign must be approved (`approval_status = approved`) before "Create Content" button appears
- **Content items**: cards showing linked content items with pipeline progress indicators
### Campaign → Content Flow
- Approved campaign shows "Create Content" button
- Creates a content item pre-linked to the campaign
- Campaign detail shows all its content items with stage progress
## 4. Dashboard Redesign
### Current Problem
Dashboard feels messy and doesn't answer "what needs my attention?"
### New Layout
**Top row — 4 metric cards:**
| Card | Content |
|------|---------|
| Active Campaigns | Count + pending approval count |
| Content in Pipeline | Total + breakdown by stage |
| Awaiting Your Approval | Items needing your review |
| Published This Period | Week/month toggle |
**Middle — Two columns:**
- **Left: Pipeline funnel** — horizontal bar/funnel showing item count per stage. Click any stage → navigates to Content page filtered to that stage.
- **Right: My Tasks** — items assigned to current user needing action, sorted by urgency (overdue first, then by due date)
**Bottom row — Two sections:**
- **Left: Recent Activity** — feed of actions (approvals, new content, status changes, comments). Shows avatar + action + item + timestamp.
- **Right: Upcoming Deadlines** — content items and campaigns with approaching due dates. Color-coded by urgency (red = overdue, amber = this week, gray = later).
### Principle
Dashboard answers: **"What needs my attention right now?"** — not just display stats.
### "My Tasks" Widget — Data Sources
The "My Tasks" widget aggregates items assigned to the current user from:
- **Content items** where `assignee_id = currentUser` and stage needs action
- **Approval requests** pending the user's review (from any stage)
- **Tasks** assigned to the user (from Projects)
- **Issues** assigned to the user
Sorted by: overdue first, then by due date ascending, then by creation date.
## 5. Consistency Standards
### Page Header Pattern
Every page uses the same header layout:
```
[Page Title] [Search] [Filters] [View Toggle] [+ Create]
```
- Same order, same position on every page
- Search: expandable icon → input field (consistent interaction)
- Filters: always visible inline (no toggle button to show/hide)
- View toggle: far right, before Create button
- Create button: always primary style, always rightmost
### Filter Bar
- Always inline, always visible, consistent height
- Same dropdown component across all pages
- "Clear all" link appears when any filter is active
- Active filters show as pills/badges
### Detail Panels (SlidePanel)
- Width: 480px on all pages
- Header: `[← Back] Title [⋯ Actions]`
- Pipeline breadcrumb at top (for content items)
- Tab order: Details → Activity → Approval (consistent)
- Save: always top-right in header
- Delete: moved from standalone header button to ⋯ overflow menu (intentional change from current Artefacts pattern — reduces accidental deletes, consistent placement)
### Cards (Kanban/Grid)
- Single `KanbanCard` component used everywhere (already exists — enforce it)
- Required elements: title, status badge, brand badge, assignee avatar, date
- Hover: lift + shadow via existing `card-hover` class
### Empty States
- Always use shared `EmptyState` component
- Always include primary action button ("Create your first X")
- Consistent icon size, spacing, copy tone
### Loading States
- Skeleton loaders that match actual content dimensions
- No bare full-page spinners — always skeletons
- Skeleton shapes match the view (card skeletons for grid, row skeletons for list)
## 6. Premium Polish
### Transitions & Animations
| Element | Animation |
|---------|-----------|
| Route changes | Fade + slide-up (150ms ease-out) |
| Detail panel open | CSS `cubic-bezier(0.34, 1.56, 0.64, 1)` slide-in from right (simulates spring overshoot without a library) |
| Kanban drag | Card lifts with shadow + slight rotation (2deg), drop zone pulses |
| Status badge change | Color crossfade (not instant swap) |
| View toggle (list↔grid) | Crossfade between views |
| Toasts | CSS `cubic-bezier(0.34, 1.56, 0.64, 1)` slide-in from top-right, stack with spacing |
| Tab active indicator | Slides to follow selection |
### Hover & Interaction States
| Element | Hover Effect |
|---------|-------------|
| Cards | Lift 2px + deeper shadow + subtle brand-color border glow (10% opacity) |
| Buttons | Scale 1.02 on hover, 0.98 on press |
| Nav items | Background fills from left (not instant) |
| Avatars | Ring glow in role color |
### Visual Depth & Glass Effects
- Detail panel header: frosted glass (`backdrop-blur-xl`), stays visible while body scrolls
- Modal backdrop: deeper blur (12px, up from current 4px)
- Pipeline cards: layered multi-shadow for realistic depth
- Sidebar: subtle inner glow at top edge
- Status badges: glass morphism (translucent bg + subtle border)
### Typography
- Page titles: `text-3xl font-light tracking-tight` (up from text-2xl)
- Consistent use of `text-text-secondary` variable (no raw gray-* classes)
- Numbers/metrics: `tabular-nums`, slightly heavier weight
- Tighter heading letter-spacing across the board
### Empty States — Premium
- Illustrated line-art SVG icons from [Iconoir](https://iconoir.com/) library (matching app's line-art aesthetic) instead of plain Lucide icons. Fallback: compose simple illustrations from multiple Lucide icons if Iconoir doesn't have a match.
- Subtle gradient background behind icon circle
- Friendly, helpful copy ("No content yet — start by writing some copy")
### Dashboard Widgets — Premium
- Pipeline funnel: animated fill on first load (bars grow from left)
- Metric cards: gradient left-border accent (stage color), number count-up animation
- Activity feed: staggered fade-in (50ms between items)
- Metric card hover: gentle pulse on accent border
## 7. Animation Approach
All animations use **CSS only** — no motion library dependency (no framer-motion, no react-spring). Spring-like effects approximated with `cubic-bezier(0.34, 1.56, 0.64, 1)`. All animations respect `prefers-reduced-motion: reduce` — when enabled, transitions are instant (duration: 0ms) and all decorative animations are disabled.
## 8. Migration & Compatibility
### Route Redirects
Old routes redirect to new locations via React Router `<Navigate>`:
- `/posts``/content/posts`
- `/calendar``/content/posts?view=calendar`
- `/translations``/content/translations`
- `/artefacts``/content/design`
- `/assets``/settings?tab=assets`
- `/brands``/settings?tab=brands`
- `/tasks``/projects?tab=tasks`
- `/budgets``/finance?tab=budgets`
### Data Migration
- New `ContentItems` table created via `ensureRequiredTables()` on server restart
- New columns (`content_item_id`, `is_original`) added via `FK_COLUMNS` / `TEXT_COLUMNS` — auto-created on restart
- Campaign brief columns added via `TEXT_COLUMNS` — auto-created on restart
- **No data migration needed for existing records** — existing posts/translations/artefacts continue to work without a content_item_id (they're standalone)
### Rollout Strategy
Phased implementation — each phase is independently deployable:
1. **Phase 1**: Nav reorganization + consistency standards (no data model changes)
2. **Phase 2**: Content page with tabs (restructure existing pages as tab sub-views)
3. **Phase 3**: Content Item model + pipeline (new table, FK linkages, pipeline kanban)
4. **Phase 4**: Campaign brief enhancement
5. **Phase 5**: Dashboard redesign
6. **Phase 6**: Premium polish (animations, glass effects, typography)
### Public Routes
Unchanged: `/review/:token`, `/review-post/:token`, `/review-translation/:token` continue to work as-is.
## 9. Files Impacted
### Navigation
- `client/src/components/Sidebar.jsx` — rewrite nav structure
- `client/src/App.jsx` — update routes (remove standalone pages, add Content route)
### Content Page (new)
- `client/src/pages/Content.jsx` — new unified page with tabs
- `client/src/components/ContentPipelineBoard.jsx` — new pipeline kanban
- `client/src/components/ContentDetailPanel.jsx` — new detail panel with pipeline breadcrumb
- Existing pages (`PostProduction.jsx`, `Translations.jsx`, `Artefacts.jsx`) become tab sub-components or are refactored into Content
### Campaigns
- `client/src/pages/Campaigns.jsx` — add brief fields (goals, metrics, audience)
- `client/src/pages/CampaignDetail.jsx` — add brief section, content items list, approval gate
- `server/server.js` — add campaign brief fields to schema, approval endpoint
### Dashboard
- `client/src/pages/Dashboard.jsx` — full redesign with new widget layout
### Consistency
- `client/src/components/SlidePanel.jsx` — standardize width, header layout
- `client/src/components/EmptyState.jsx` — add illustrated SVG variants
- `client/src/components/SkeletonLoader.jsx` — match actual content dimensions
- All page files — standardize header pattern, filter bar, loading states
### Polish
- `client/src/index.css` — new animations, glass effects, spring transitions, typography updates
- `client/src/components/KanbanBoard.jsx` — enhanced drag animations
- `client/src/components/KanbanCard.jsx` — premium hover states
- `client/src/components/StatusBadge.jsx` — glass morphism variant
- `client/src/components/Modal.jsx` — deeper backdrop blur
- `client/src/components/Toast.jsx` — spring animations
### Server
- `server/server.js` — content item model, campaign brief fields, pipeline stage tracking
- New `ContentItems` table in `REQUIRED_TABLES`
- New columns via `FK_COLUMNS`: `content_item_id` on Translations, Artefacts, Posts
- New columns via `TEXT_COLUMNS`: `is_original` on Translations, campaign brief fields on Campaigns (goals, target_audience, key_messages, reach_target, engagement_target, conversion_target, approval_status, approved_by, approved_at)
### i18n
- `client/src/i18n/en.json` — new keys for Content page, pipeline stages, dashboard widgets, campaign brief
- `client/src/i18n/ar.json` — same keys in Arabic
@@ -0,0 +1,245 @@
# Budget Allocation Redesign — Single Source of Truth + CEO Approval Workflow
**Date:** 2026-03-15
**Status:** Draft
## Problem
The current budget system has two parallel sources of truth:
- `Campaign.budget` field (set directly on campaigns)
- `BudgetEntries` table (income/expense records linked to campaigns/projects)
These don't sync. The finance summary uses `Campaign.budget` for `totalCampaignBudget` but `BudgetEntries` for `totalReceived` (superadmin only). Managers see a completely different `totalReceived` calculation. There's no validation preventing over-allocation, and no approval workflow for incoming funds.
## Design
### Budget Hierarchy
```
Main Budget (sum of approved income BudgetEntries)
├─ Expenses (BudgetEntries with type='expense', deducted from main)
├─ Campaign allocations (income BudgetEntries with campaign_id set)
├─ Project allocations (income BudgetEntries with project_id set)
└─ Available = Main Budget - expenses - campaign allocations - project allocations
Campaign "Summer Sale" ($10K allocated)
├─ Track "Facebook Ads" ($3K from campaign)
├─ Track "Google Ads" ($5K from campaign)
└─ Campaign Available = $2K
```
Campaign allocation entries are a **subset** of income entries — they are income entries that happen to have a `campaign_id` set. An earmarked CEO-approved income entry counts as both `totalReceived` and `totalCampaignBudget` (which is correct — the money enters the system AND is allocated).
### Single Source of Truth
**BudgetEntries is the only source.** `Campaign.budget` field is deprecated — kept in schema but ignored in all calculations.
All calculations:
- `totalReceived` = sum of all income BudgetEntries (same for all roles)
- `totalExpenses` = sum of all expense BudgetEntries
- `totalCampaignBudget` = sum of income BudgetEntries where `campaign_id` is set
- `totalProjectBudget` = sum of income BudgetEntries where `project_id` is set
- `mainAvailable` = totalReceived - totalExpenses - totalCampaignBudget - totalProjectBudget
- `campaignAvailable(id)` = campaign's allocated budget - sum of its tracks' `budget_allocated`
- `remaining` = mainAvailable (same thing — no more double-counting tracks)
### Validation Rules
| Action | Guard | Error message |
|--------|-------|---------------|
| All amounts | amount > 0 | "Amount must be positive" |
| Create expense entry | mainAvailable >= amount | "Insufficient budget. Available: $X" |
| Set campaign budget (at creation or edit) | mainAvailable >= amount (or >= increase delta) | "Insufficient budget. Available: $X. Request more funds." |
| Decrease campaign budget | newBudget >= sum(tracks.budget_allocated) | "Cannot reduce below track allocations ($X assigned to tracks)" |
| Set project budget | mainAvailable >= amount (or >= increase delta) | "Insufficient budget. Available: $X. Request more funds." |
| Set track budget_allocated | campaignAvailable >= amount (or >= increase delta) | "Insufficient campaign budget. Available: $X of $Y allocated" |
| Create income entry | Must go through budget request workflow (superadmin only) | N/A — handled by request workflow |
**Race condition mitigation:** Budget-modifying operations (campaign/project/expense creation, budget changes) acquire an in-memory mutex before reading availability and releasing after the write. Single-server app — no distributed lock needed.
### Campaign/Project Deletion
When a campaign or project is deleted:
- All linked BudgetEntries have their `campaign_id` / `project_id` set to null
- This returns the allocated funds to the main available balance
- Tracks under the campaign are already deleted by the existing cascade logic
### Budget Request Workflow (CEO Approval)
**Who can request:** Superadmin only.
**Who approves:** CEO — external email address configured in Settings page.
#### Flow
```
1. Superadmin opens Finance page → clicks "Request Budget"
2. Fills form: amount, justification note
Optional: earmarked for specific campaign or project
3. System creates BudgetRequest (status: pending)
4. System generates approval token + public URL (expires in 7 days)
5. System emails CEO: amount, requester name, justification, approve/reject links
(no internal budget details exposed)
6a. CEO clicks Approve:
→ BudgetRequest.status = 'approved', resolved_at = now
→ Auto-creates income BudgetEntry (amount, source: "CEO Approved — {justification}")
→ If earmarked: BudgetEntry gets campaign_id or project_id
→ Email notification to superadmin: "Your budget request for $X has been approved"
6b. CEO clicks Reject:
→ BudgetRequest.status = 'rejected', resolved_at = now
→ CEO can add a response note
→ Email notification to superadmin: "Your budget request for $X has been rejected"
```
Idempotent: if CEO clicks approve/reject twice, return 200 with existing result — no duplicate entries.
#### New Table: BudgetRequests
| Column | Type | Description |
|--------|------|-------------|
| amount | Decimal | Requested amount (must be > 0) |
| justification | LongText | Why the money is needed |
| status | SingleLineText | pending / approved / rejected / cancelled |
| requested_by_user_id | Number | FK to Users |
| approval_token | SingleLineText | UUID for public approval URL |
| token_expires_at | DateTime | Token expiry (7 days from creation) |
| response_note | LongText | CEO's note on approval/rejection |
| resolved_at | DateTime | When CEO acted (null while pending) |
| earmarked_campaign_id | Number | Optional FK — intended campaign |
| earmarked_project_id | Number | Optional FK — intended project |
| created_budget_entry_id | Number | FK to the auto-created BudgetEntry (set on approval) |
#### API Endpoints
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | /api/budget-requests | Superadmin | List all requests (sorted by CreatedAt desc) |
| POST | /api/budget-requests | Superadmin | Create new request, sends email to CEO |
| PATCH | /api/budget-requests/:id/cancel | Superadmin | Cancel a pending request |
| GET | /api/budget-approval/:token | Public | Get request details for approval page |
| POST | /api/budget-approval/:token/respond | Public | Approve or reject (body: `{ action: 'approve'|'reject', note?: string }`) |
#### Public Approval Page: `/approve-budget/:token`
Minimal page showing:
- Requested amount
- Requester name
- Justification note
- Earmarked for (campaign/project name, if set)
- Two buttons: Approve / Reject
- Optional text field for response note
- States: loading, active, success, already-handled, expired
Same pattern as existing public review pages (`PublicReview.jsx`, `PublicPostReview.jsx`).
Token validation: check `token_expires_at > now` and `status === 'pending'`. Expired tokens show "This request has expired" with no action buttons.
### Settings: CEO Email
Add to Settings page (superadmin only):
- Field: "CEO / Budget Approver Email"
- Stored in `AppSettings` table (key-value: `{ key: 'ceo_email', value: 'ceo@company.com' }`)
**AppSettings table schema:**
| Column | Type | Description |
|--------|------|-------------|
| key | SingleLineText | Setting key (unique) |
| value | LongText | Setting value |
Add to `REQUIRED_TABLES`. Read via `GET /api/settings/:key` (superadmin), write via `PATCH /api/settings/:key` (superadmin).
### Finance Page Changes
Add a "Budget Requests" section to the Finance page:
- Shows all requests with status badge (pending/approved/rejected/cancelled)
- Pending requests show a subtle banner at top: "1 budget request pending CEO approval"
- Each row: amount, justification (truncated), status, date, earmarked for, resolved_at
- Superadmin sees "Request Budget" button in the page header
### Campaign Creation Change
When creating/editing a campaign with a budget:
1. Acquire budget mutex
2. Server calculates `mainAvailable`
3. If `budget > mainAvailable`: return 400 with `{ error: 'Insufficient budget', available: mainAvailable }`
4. If OK: create campaign, then auto-create BudgetEntry (type=income, campaign_id=new campaign ID, amount=budget)
5. `Campaign.budget` field is still written for backward compat but NOT used in calculations
6. Release mutex
When increasing a campaign's budget:
- Delta = newBudget - currentAllocated (where currentAllocated = sum of income BudgetEntries with this campaign_id)
- Acquire mutex, check `mainAvailable >= delta`, update BudgetEntry amount, release
When decreasing a campaign's budget:
- Check `newBudget >= sum(tracks.budget_allocated for this campaign)`
- If not: return 400 "Cannot reduce below track allocations"
- Update BudgetEntry amount (freed funds return to main automatically)
### Track Creation/Edit Change
When creating/editing a track with `budget_allocated`:
1. Calculate `campaignAllocated` = sum of income BudgetEntries with this campaign_id
2. Calculate `tracksTotalAllocated` = sum of all tracks' `budget_allocated` for this campaign (excluding current track if editing)
3. `campaignAvailable = campaignAllocated - tracksTotalAllocated`
4. If `budget_allocated > campaignAvailable`: return 400
5. If OK: save normally
### Finance Summary Endpoint Fix
`GET /api/finance/summary` — rewrite calculation:
```javascript
// Single source of truth — BudgetEntries only
const incomeEntries = budgetEntries.filter(e => (e.type || 'income') === 'income');
const expenseEntries = budgetEntries.filter(e => e.type === 'expense');
const totalReceived = incomeEntries.reduce((s, e) => s + (e.amount || 0), 0); // SAME for all roles
const totalExpenses = expenseEntries.reduce((s, e) => s + (e.amount || 0), 0);
const totalCampaignBudget = incomeEntries
.filter(e => e.campaign_id)
.reduce((s, e) => s + (e.amount || 0), 0); // FROM ENTRIES, not Campaign.budget
const totalProjectBudget = incomeEntries
.filter(e => e.project_id)
.reduce((s, e) => s + (e.amount || 0), 0);
const mainAvailable = totalReceived - totalExpenses - totalCampaignBudget - totalProjectBudget;
// Track spending stays within campaign allocation — not subtracted from main
const remaining = mainAvailable; // Simple. No double-counting.
```
### Migration
Existing data:
1. For each campaign with `budget > 0` that has NO corresponding income BudgetEntry with that campaign_id: auto-create an income BudgetEntry linked to that campaign
2. Skip campaigns with `budget = 0` or `budget = null`
3. Log migrations to console for audit
4. Run once on server startup (idempotent — skip if matching entry already exists)
### Email Templates
Budget request email to CEO:
- Subject: `Rawaj — Budget Request: $X`
- Header: Rawaj branded (dark forest `#0a1f1c`)
- Body: "{requester} is requesting $X. Justification: {note}"
- CTA: "Review Request" button → public approval page
- No internal budget details
Approval/rejection notification to superadmin:
- Subject: `Rawaj — Budget Request Approved/Rejected: $X`
- Body: result + CEO's response note if any
## Out of Scope
- Multi-currency support
- Budget periods/fiscal years
- Partial approval (CEO can't approve a different amount)
- Delegation (CEO can't forward approval to someone else)
- Audit log (beyond the BudgetRequests table itself)
- Currency precision (uses NocoDB Decimal as-is)
@@ -0,0 +1,226 @@
# Post Composition Redesign — Post as Orchestrator
**Date:** 2026-03-15
**Status:** Draft
## Problem
The current Post model is flat — a post has a title, description, status, attachments, and platforms. There's no structure showing that a social media post is actually a **composition** of distinct production pieces (caption, in-design copy, design assets, video). The Post detail panel is a disorganized form with tabs that don't map to how content is actually produced.
Additionally, ContentItems (from the UX overhaul pipeline) duplicates Post metadata and adds confusion about where to create content.
## Design
### Post = Orchestrator
A Post is a container that assembles independently-produced pieces into a publishable unit:
```
Post "Summer Sale Launch"
├─ Caption (text field on Post, one base version, minor platform tweaks)
├─ Copy (in-design text): linked Translation(s) — approved via Translation flow
├─ Design(s): linked Artefact(s) — approved via Artefact flow
├─ Video: linked Artefact (optional) — approved via Artefact flow
├─ Platforms: [IG, TikTok, YouTube]
└─ Format checklist: auto-derived from platforms
```
### Composition Pieces
| Piece | Storage | Approval | Notes |
|-------|---------|----------|-------|
| **Caption** | `Post.caption` field (text) | Part of final Post sign-off | The text posted WITH the content (IG caption, tweet text, etc.). One base version with minor platform tweaks (hashtags). Multilingual via existing Translation system if needed. |
| **In-design copy** | Translation record (`post_id` FK, `is_original=true`) | Translation approval flow | Text that goes INSIDE the design (overlaid on image/video). Already exists. |
| **Design(s)** | Artefact(s) linked via `post_id` FK, `type='design'` | Artefact approval flow | 1..N designs per post (carousel = multiple). Each artefact can have versions. |
| **Video** | Artefact linked via `post_id` FK, `type='video'` | Artefact approval flow | 0..1 video per post. Has its own versions/approval. |
| **Format specs** | Derived from `Post.platforms` | None (production checklist) | System maps platforms → required formats (IG→1:1, TikTok→9:16, etc.). Designer uses as a guide. |
### Platform → Format Mapping
```javascript
const PLATFORM_FORMATS = {
instagram: [
{ key: 'ig_feed', label: 'Feed (1:1)', ratio: '1:1' },
{ key: 'ig_story', label: 'Story (9:16)', ratio: '9:16' },
{ key: 'ig_reel', label: 'Reel (9:16)', ratio: '9:16' },
],
tiktok: [
{ key: 'tt_video', label: 'TikTok (9:16)', ratio: '9:16' },
],
youtube: [
{ key: 'yt_video', label: 'YouTube (16:9)', ratio: '16:9' },
{ key: 'yt_short', label: 'Short (9:16)', ratio: '9:16' },
{ key: 'yt_thumb', label: 'Thumbnail (16:9)', ratio: '16:9' },
],
facebook: [
{ key: 'fb_post', label: 'Post (1:1 or 16:9)', ratio: '1:1' },
{ key: 'fb_story', label: 'Story (9:16)', ratio: '9:16' },
],
twitter: [
{ key: 'tw_post', label: 'Post (16:9)', ratio: '16:9' },
],
linkedin: [
{ key: 'li_post', label: 'Post (1:1 or 1.91:1)', ratio: '1:1' },
],
snapchat: [
{ key: 'sc_snap', label: 'Snap (9:16)', ratio: '9:16' },
],
}
```
This is a **checklist** for the designer, not enforced entities. The Post detail panel shows "Formats needed" based on selected platforms. No separate database records per format.
### Post Status & Readiness
**Post status field** (unchanged): `idea` | `in_progress` | `in_review` | `approved` | `rejected` | `scheduled` | `published`
**Readiness is auto-computed** from pieces:
- `pieces_ready`: true when ALL linked Translations are approved AND ALL linked Artefacts are approved
- Displayed as: "Ready for sign-off" or "Waiting on: Copy (AR), Video"
**Final publish flow:**
1. All pieces get approved through their own flows
2. Post auto-shows "All pieces ready — awaiting sign-off"
3. Someone manually moves Post to `approved` or `scheduled`
4. Published when scheduled date arrives (or manually)
### ContentItems Merge
ContentItems table is removed. Its fields map to Post:
- `ContentItems.stage``Post.stage` (copy / translate / design / post / published)
- `ContentItems.title` → already `Post.title`
- `ContentItems.campaign_id` → already `Post.campaign_id`
- `ContentItems.brand_id` → already `Post.brand_id`
- `ContentItems.assignee_id` → already `Post.assigned_to`
Stage auto-advances based on what exists:
- Post created → stage = `copy`
- Translation linked → stage = `translate` (if multiple languages)
- Artefact (design) linked → stage = `design`
- All pieces approved → stage = `post`
- Published → stage = `published`
### Post Detail Panel — Composition View
Replace the current tabbed panel with a **composition workspace**:
```
┌─────────────────────────────────────────┐
│ Header: Title, Status, Brand, Campaign │
│ Platforms: [IG] [TikTok] [YouTube] │
├─────────────────────────────────────────┤
│ │
│ CAPTION │
│ ┌─────────────────────────────────────┐ │
│ │ Textarea: "🔥 Summer deals..." │ │
│ │ Platform hashtags: #summer #sale │ │
│ └─────────────────────────────────────┘ │
│ │
│ COPY (in-design text) │
│ ┌────────┐ ┌────────┐ ┌────────┐ │
│ │ EN ✓ │ │ AR ✓ │ │ FR ⏳ │ │
│ └────────┘ └────────┘ └────────┘ │
│ [Link Translation] or [Create New] │
│ │
│ DESIGNS │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ Slide 1 │ │ Slide 2 │ │
│ │ [thumbnail] │ │ [thumbnail] │ │
│ │ ✓ Approved │ │ ✓ Approved │ │
│ └──────────────┘ └──────────────┘ │
│ [Link Artefact] or [Create New] │
│ │
│ VIDEO (optional) │
│ ┌──────────────────────────────────┐ │
│ │ [video thumbnail] Reel v2 │ │
│ │ ⏳ In Review │ │
│ └──────────────────────────────────┘ │
│ [Link Artefact] or [Create New] │
│ │
│ FORMAT CHECKLIST │
│ ☑ IG Feed 1:1 ☑ IG Story 9:16 │
│ ☑ TikTok 9:16 ☐ YT 16:9 │
│ │
│ READINESS │
│ ● Copy: 2/3 languages approved │
│ ● Design: 2/2 approved │
│ ● Video: In review │
│ [Approve & Schedule] (disabled until │
│ all pieces ready) │
│ │
│ DISCUSSION │
│ [comments section] │
└─────────────────────────────────────────┘
```
This is a **single scrollable view**, not tabs. Each section is a collapsible card. The readiness summary at the bottom gives a clear picture of what's blocking publication.
### Schema Changes
**Post table — add:**
- `caption` (LongText) — the social media caption
- `stage` (SingleLineText) — pipeline stage: copy/translate/design/post/published
**Post table — remove:**
- `description` (deprecated — copy lives in Translations)
**Artefact table — ensure:**
- `post_id` FK already exists
- `type` field already exists (design/video/copy)
**Translation table — ensure:**
- `post_id` FK already exists
**ContentItems table:**
- Delete after migration
### Migration
1. For each ContentItem: if no Post exists with matching title + campaign_id, create a Post from it
2. Move `stage` values to the new Post.stage field
3. Relink any Translations/Artefacts that referenced ContentItem IDs
4. Drop ContentItems table (or leave empty, mark deprecated)
### API Changes
**POST /api/posts** — add `caption` field
**PATCH /api/posts/:id** — add `caption` field, auto-update `stage` based on linked pieces
**GET /api/posts/:id** — include linked Translations (with approval status), linked Artefacts (with type + approval status), computed `pieces_ready` boolean, computed `waiting_on` array
**New helper endpoint:**
**GET /api/posts/:id/composition** — returns the full composition view:
```json
{
"caption": "🔥 Summer deals...",
"copy": [
{ "id": 1, "language": "EN", "status": "approved" },
{ "id": 2, "language": "AR", "status": "approved" },
{ "id": 3, "language": "FR", "status": "in_review" }
],
"designs": [
{ "id": 10, "title": "Slide 1", "status": "approved", "thumbnail_url": "..." },
{ "id": 11, "title": "Slide 2", "status": "approved", "thumbnail_url": "..." }
],
"video": { "id": 20, "title": "Reel v2", "status": "in_review", "thumbnail_url": "..." },
"platforms": ["instagram", "tiktok", "youtube"],
"formats_needed": ["ig_feed", "ig_story", "ig_reel", "tt_video", "yt_video", "yt_short", "yt_thumb"],
"pieces_ready": false,
"waiting_on": ["Copy (FR)", "Video"]
}
```
### What Stays the Same
- Artefact approval flow (unchanged)
- Translation approval flow (unchanged)
- Post review via public link (unchanged — now reviews the full composition)
- Campaign/brand/platform selection on Posts (unchanged)
- KanbanBoard for pipeline view (unchanged — works with `stage` or `status`)
## Out of Scope
- Auto-publishing to social media platforms
- Caption AI generation
- Design template system
- Format-specific cropping tool
- Per-platform caption variations (just one caption with manual tweaks)
+2 -1
View File
@@ -1,3 +1,4 @@
{
"uploadMaxSizeMB": 500
"uploadMaxSizeMB": 500,
"ceoEmail": "fahed@softhouse.io"
}
+50
View File
@@ -0,0 +1,50 @@
// server/budget-helpers.js — Budget availability calculations
// Single source of truth: BudgetEntries table
const nocodb = require('./nocodb');
function computeFromEntries(entries) {
const income = entries.filter(e => (e.type || 'income') === 'income');
const expenses = entries.filter(e => e.type === 'expense');
const totalReceived = income.reduce((s, e) => s + (e.amount || 0), 0);
const totalExpenses = expenses.reduce((s, e) => s + (e.amount || 0), 0);
const totalCampaignBudget = income.filter(e => e.campaign_id).reduce((s, e) => s + (e.amount || 0), 0);
const totalProjectBudget = income.filter(e => e.project_id).reduce((s, e) => s + (e.amount || 0), 0);
return { totalReceived, totalExpenses, totalCampaignBudget, totalProjectBudget, available: totalReceived - totalExpenses - totalCampaignBudget - totalProjectBudget };
}
async function getMainAvailable(prefetchedEntries) {
const entries = prefetchedEntries || await nocodb.list('BudgetEntries', { limit: 10000 });
return computeFromEntries(entries);
}
async function getCampaignAvailable(campaignId, prefetchedEntries) {
const entries = prefetchedEntries || await nocodb.list('BudgetEntries', { limit: 10000 });
const campaignIncome = entries.filter(e =>
e.campaign_id && Number(e.campaign_id) === Number(campaignId) && (e.type || 'income') === 'income'
);
const allocated = campaignIncome.reduce((s, e) => s + (e.amount || 0), 0);
const tracks = await nocodb.list('CampaignTracks', {
where: `(campaign_id,eq,${campaignId})`,
limit: 10000,
});
const trackAllocated = tracks.reduce((s, t) => s + (t.budget_allocated || 0), 0);
return { allocated, trackAllocated, available: allocated - trackAllocated };
}
async function getCampaignAllocatedFromEntries(campaignId, prefetchedEntries) {
const entries = prefetchedEntries || await nocodb.list('BudgetEntries', { limit: 10000 });
return entries
.filter(e => e.campaign_id && Number(e.campaign_id) === Number(campaignId) && (e.type || 'income') === 'income')
.reduce((s, e) => s + (e.amount || 0), 0);
}
async function getAllBudgetData() {
const entries = await nocodb.list('BudgetEntries', { limit: 10000 });
const main = computeFromEntries(entries);
return { entries, ...main };
}
module.exports = { getMainAvailable, getCampaignAvailable, getCampaignAllocatedFromEntries, getAllBudgetData, computeFromEntries };
+13
View File
@@ -0,0 +1,13 @@
// server/budget-mutex.js — In-memory mutex for budget-modifying operations
// Prevents race conditions when multiple requests check availability simultaneously
let _lock = null;
async function acquireBudgetLock() {
while (_lock) await _lock;
let resolve;
_lock = new Promise(r => { resolve = r; });
return () => { _lock = null; resolve(); };
}
module.exports = { acquireBudgetLock };
+27 -12
View File
@@ -40,29 +40,44 @@ function buildWhere(conditions) {
.join('~and');
}
const REQUEST_TIMEOUT_MS = 20_000;
async function request(method, url, body) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
const opts = {
method,
headers: {
'xc-token': NOCODB_TOKEN,
'Content-Type': 'application/json',
},
signal: controller.signal,
};
if (body !== undefined) opts.body = JSON.stringify(body);
const res = await fetch(url, opts);
if (!res.ok) {
let details;
try { details = await res.json(); } catch {}
throw new NocoDBError(
`NocoDB ${method} ${url} failed: ${res.status}`,
res.status,
details
);
try {
const res = await fetch(url, opts);
clearTimeout(timer);
if (!res.ok) {
let details;
try { details = await res.json(); } catch {}
throw new NocoDBError(
`NocoDB ${method} ${url} failed: ${res.status}`,
res.status,
details
);
}
// DELETE returns empty or {msg}
const text = await res.text();
return text ? JSON.parse(text) : {};
} catch (err) {
clearTimeout(timer);
if (err.name === 'AbortError') {
throw new NocoDBError(`NocoDB ${method} ${url} timed out after ${REQUEST_TIMEOUT_MS}ms`, 408);
}
throw err;
}
// DELETE returns empty or {msg}
const text = await res.text();
return text ? JSON.parse(text) : {};
}
// ─── Link Resolution ─────────────────────────────────────────
+79 -9
View File
@@ -3,9 +3,14 @@ const { sendMail } = require('./mail');
const nocodb = require('./nocodb');
const { parseApproverIds } = require('./helpers');
function escapeHtml(str) {
if (!str) return '';
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
const APP_URL = process.env.APP_URL || process.env.CORS_ORIGIN || 'http://localhost:3001';
const APP_NAME_EN = "Samaya's Digital Hub";
const APP_NAME_AR = 'المركز الرقمي لسمايا';
const APP_NAME_EN = 'Rawaj';
const APP_NAME_AR = 'رواج';
// ─── TRANSLATIONS ───────────────────────────────────────────────
@@ -94,6 +99,21 @@ const t = {
view: { en: 'View', ar: 'عرض' },
viewTask: { en: 'View Task', ar: 'عرض المهمة' },
viewIssue:{ en: 'View Issue', ar: 'عرض المشكلة' },
// Budget
budgetRequest: { en: 'Budget Request', ar: 'طلب ميزانية' },
budgetRequestHeading: { en: 'Budget Request', ar: 'طلب ميزانية' },
budgetRequestBody: { en: (name, amount) => `<strong>${name}</strong> is requesting <strong>${amount}</strong>.`,
ar: (name, amount) => `يطلب <strong>${name}</strong> مبلغ <strong>${amount}</strong>.` },
budgetJustification: { en: 'Justification', ar: 'المبرر' },
budgetEarmarkedFor: { en: 'Earmarked for', ar: 'مخصص لـ' },
reviewRequest: { en: 'Review Request', ar: 'مراجعة الطلب' },
budgetApproved: { en: 'Budget Request Approved', ar: 'تمت الموافقة على طلب الميزانية' },
budgetApprovedBody: { en: (amount) => `Your budget request for <strong>${amount}</strong> has been approved. Funds are now available.`,
ar: (amount) => `تمت الموافقة على طلب الميزانية بمبلغ <strong>${amount}</strong>. الأموال متاحة الآن.` },
budgetRejected: { en: 'Budget Request Rejected', ar: 'تم رفض طلب الميزانية' },
budgetRejectedBody: { en: (amount) => `Your budget request for <strong>${amount}</strong> has been rejected.`,
ar: (amount) => `تم رفض طلب الميزانية بمبلغ <strong>${amount}</strong>.` },
};
function tr(key, lang) { return t[key]?.[lang] || t[key]?.en || key; }
@@ -111,7 +131,7 @@ function renderEmail({ heading, bodyHtml, ctaText, ctaUrl, lang = 'en' }) {
<html dir="${dir}" lang="${lang}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="margin:0;padding:0;background:#f4f5f7;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif">
<div style="max-width:600px;margin:0 auto;padding:20px;direction:${dir};text-align:${align}">
<div style="background:#1e293b;color:#fff;padding:16px 24px;border-radius:12px 12px 0 0;font-size:16px;font-weight:600;text-align:${align}">
<div style="background:#0a1f1c;color:#fff;padding:16px 24px;border-radius:12px 12px 0 0;font-size:16px;font-weight:600;text-align:${align}">
${appName}
</div>
<div style="background:#fff;padding:32px 24px;border-radius:0 0 12px 12px;border:1px solid #e2e8f0;border-top:none">
@@ -121,7 +141,7 @@ function renderEmail({ heading, bodyHtml, ctaText, ctaUrl, lang = 'en' }) {
</div>
${ctaText && ctaUrl ? `
<div style="margin:24px 0 8px">
<a href="${ctaUrl}" style="display:inline-block;background:#3b82f6;color:#fff;padding:12px 24px;border-radius:8px;text-decoration:none;font-weight:600;font-size:14px">${ctaText}</a>
<a href="${ctaUrl}" style="display:inline-block;background:#0d9488;color:#fff;padding:12px 24px;border-radius:8px;text-decoration:none;font-weight:600;font-size:14px">${ctaText}</a>
</div>` : ''}
</div>
<div style="text-align:center;padding:16px;color:#94a3b8;font-size:12px">
@@ -151,8 +171,10 @@ async function getMultipleUsers(userIds) {
}
function send({ to, subject, heading, bodyHtml, ctaText, ctaUrl, lang }) {
const appName = lang === 'ar' ? APP_NAME_AR : APP_NAME_EN;
const fullSubject = `${appName}${subject}`;
const { html, text } = renderEmail({ heading, bodyHtml, ctaText, ctaUrl, lang });
sendMail({ to, subject, html, text })
sendMail({ to, subject: fullSubject, html, text })
.then(() => console.log(`[notifications] Sent "${subject}" to ${to}`))
.catch(err => console.error(`[notifications] FAILED "${subject}" to ${to}:`, err.message));
}
@@ -222,7 +244,7 @@ function notifyRejected({ type, record, approverName, feedback }) {
heading: tr('rejectedHeading', l)(typeLabel),
bodyHtml: `
<p>${tr('rejectedBody', l)(title, approverName || (l === 'ar' ? 'مراجع' : 'a reviewer'))}</p>
${feedback ? `<blockquote ${BLOCKQUOTE}>${feedback}</blockquote>` : ''}`,
${feedback ? `<blockquote ${BLOCKQUOTE}>${escapeHtml(feedback)}</blockquote>` : ''}`,
ctaText: `${tr('view', l)} ${typeLabel}`,
ctaUrl: `${APP_URL}/${type === 'post' ? 'posts' : type === 'translation' ? 'translations' : 'artefacts'}`,
});
@@ -246,7 +268,7 @@ function notifyRevisionRequested({ type, record, approverName, feedback }) {
heading: tr('revisionRequested', l),
bodyHtml: `
<p>${tr('revisionRequestedBody', l)(title, approverName || (l === 'ar' ? 'مراجع' : 'a reviewer'))}</p>
${feedback ? `<blockquote ${BLOCKQUOTE}>${feedback}</blockquote>` : ''}`,
${feedback ? `<blockquote ${BLOCKQUOTE}>${escapeHtml(feedback)}</blockquote>` : ''}`,
ctaText: `${tr('view', l)} ${tr(entityType, l)}`,
ctaUrl: `${APP_URL}/${entityPath}`,
});
@@ -269,7 +291,7 @@ function notifyTaskAssigned({ task, assignerName }) {
bodyHtml: `
<p>${tr('taskAssignedBody', l)(assignerName || (l === 'ar' ? 'أحدهم' : 'Someone'))}</p>
<p style="font-size:16px;font-weight:600;color:#1e293b">${title}</p>
${task.description ? `<p style="color:#64748b">${task.description.substring(0, 200)}</p>` : ''}
${task.description ? `<p style="color:#64748b">${escapeHtml(task.description.substring(0, 200))}</p>` : ''}
${task.priority ? `<p>${tr('priority', l)}: <strong>${task.priority}</strong></p>` : ''}
${task.due_date ? `<p>${tr('dueDate', l)}: <strong>${task.due_date}</strong></p>` : ''}`,
ctaText: tr('viewTask', l),
@@ -334,7 +356,7 @@ function notifyIssueStatusUpdate({ issue, oldStatus, newStatus }) {
bodyHtml: `
<p>${tr('issueUpdateBody', 'en')(title)}</p>
<p><span style="color:#94a3b8">${oldStatus || 'new'}</span> <strong style="color:#3b82f6">${newStatus}</strong></p>
${issue.resolution_summary ? `<p style="margin-top:12px"><strong>${tr('resolution', 'en')}:</strong> ${issue.resolution_summary}</p>` : ''}`,
${issue.resolution_summary ? `<p style="margin-top:12px"><strong>${tr('resolution', 'en')}:</strong> ${escapeHtml(issue.resolution_summary)}</p>` : ''}`,
ctaText: issue.tracking_token ? tr('trackIssue', 'en') : null,
ctaUrl: issue.tracking_token ? `${APP_URL}/track/${issue.tracking_token}` : null,
});
@@ -387,7 +409,52 @@ function notifyUserInvited({ email, name, password, inviterName, lang = 'en' })
});
}
// 11. Budget request → email CEO
function notifyBudgetRequest({ ceoEmail, amount, requesterName, justification, earmarkedFor, approvalUrl }) {
const earmarkHtml = earmarkedFor ? `<p><strong>${tr('budgetEarmarkedFor', 'en')}:</strong> ${earmarkedFor}</p>` : '';
send({
to: ceoEmail, lang: 'en',
subject: `${tr('budgetRequest', 'en')}: ${amount}`,
heading: tr('budgetRequestHeading', 'en'),
bodyHtml: `
<p>${tr('budgetRequestBody', 'en')(requesterName, amount)}</p>
<p><strong>${tr('budgetJustification', 'en')}:</strong> ${escapeHtml(justification)}</p>
${earmarkHtml}`,
ctaText: tr('reviewRequest', 'en'),
ctaUrl: approvalUrl,
});
}
// 12. Budget approved → notify requester
function notifyBudgetApproved({ request, requesterEmail, requesterLang }) {
const l = requesterLang || 'en';
send({
to: requesterEmail, lang: l,
subject: `${tr('budgetApproved', l)}: ${request.amount}`,
heading: tr('budgetApproved', l),
bodyHtml: `
<p>${tr('budgetApprovedBody', l)(String(request.amount))}</p>
${request.response_note ? `<blockquote ${BLOCKQUOTE}>${escapeHtml(request.response_note)}</blockquote>` : ''}`,
ctaText: null, ctaUrl: null,
});
}
// 13. Budget rejected → notify requester
function notifyBudgetRejected({ request, requesterEmail, requesterLang }) {
const l = requesterLang || 'en';
send({
to: requesterEmail, lang: l,
subject: `${tr('budgetRejected', l)}: ${request.amount}`,
heading: tr('budgetRejected', l),
bodyHtml: `
<p>${tr('budgetRejectedBody', l)(String(request.amount))}</p>
${request.response_note ? `<blockquote ${BLOCKQUOTE}>${escapeHtml(request.response_note)}</blockquote>` : ''}`,
ctaText: null, ctaUrl: null,
});
}
module.exports = {
renderEmail,
notifyReviewSubmitted,
notifyApproved,
notifyRejected,
@@ -398,4 +465,7 @@ module.exports = {
notifyIssueStatusUpdate,
notifyCampaignCreated,
notifyUserInvited,
notifyBudgetRequest,
notifyBudgetApproved,
notifyBudgetRejected,
};
+126
View File
@@ -0,0 +1,126 @@
const nocodb = require('./nocodb');
async function getPostComposition(postId) {
const post = await nocodb.get('Posts', postId);
if (!post) return null;
const artefacts = await nocodb.list('Artefacts', {
where: `(post_id,eq,${postId})`, limit: 100,
});
const caption = artefacts.find(a => a.type === 'copy' && a.copy_type === 'caption') || null;
const bodyCopy = artefacts.find(a => a.type === 'copy' && (a.copy_type === 'body' || !a.copy_type)) || null;
const design = artefacts.find(a => (a.type || 'design') === 'design') || null;
const video = artefacts.find(a => a.type === 'video') || null;
let platforms = [];
try { platforms = JSON.parse(post.platforms || '[]'); } catch { platforms = post.platform ? [post.platform] : []; }
const waitingOn = [];
if (caption && caption.status !== 'approved') waitingOn.push('Caption');
if (bodyCopy && bodyCopy.status !== 'approved') waitingOn.push('Copy');
if (design && design.status !== 'approved') waitingOn.push('Design');
if (video && video.status !== 'approved') waitingOn.push('Video');
const hasPieces = caption || bodyCopy || design || video;
const piecesReady = hasPieces && waitingOn.length === 0;
// Get texts from ArtefactVersionTexts for copy artefacts (content preview + languages)
const getTexts = async (artefactId) => {
try {
const versions = await nocodb.list('ArtefactVersions', { where: `(artefact_id,eq,${artefactId})`, sort: '-version_number', limit: 1 });
if (versions.length === 0) return { texts: [], contentPreview: '' };
const texts = await nocodb.list('ArtefactVersionTexts', { where: `(version_id,eq,${versions[0].Id})`, limit: 20 });
const languages = texts.map(tt => ({ language: tt.language_code || tt.language, status: tt.status || 'draft' }));
const contentPreview = texts.length > 0 ? (texts[0].content || '').slice(0, 120) : '';
return { texts: languages, contentPreview };
} catch { return { texts: [], contentPreview: '' }; }
};
const [captionTexts, bodyTexts] = await Promise.all([
caption ? getTexts(caption.Id) : { texts: [], contentPreview: '' },
bodyCopy ? getTexts(bodyCopy.Id) : { texts: [], contentPreview: '' },
]);
// Get first attachment for design/video thumbnail
const getFirstAttachment = async (artefactId) => {
try {
const versions = await nocodb.list('ArtefactVersions', { where: `(artefact_id,eq,${artefactId})`, sort: '-version_number', limit: 1 });
if (versions.length === 0) return null;
const attachments = await nocodb.list('ArtefactAttachments', { where: `(version_id,eq,${versions[0].Id})`, limit: 1 });
if (attachments.length === 0) return null;
const att = attachments[0];
return att.drive_url || (att.filename ? `/api/uploads/${att.filename}` : null);
} catch { return null; }
};
const [designThumb, videoThumb] = await Promise.all([
design ? (design.thumbnail_url || getFirstAttachment(design.Id)) : null,
video ? (video.thumbnail_url || getFirstAttachment(video.Id)) : null,
]);
// Resolve approver names for each piece
const resolveApprover = async (record) => {
if (!record || !record.approver_ids) return { approver_ids: null, approver_name: null };
const ids = record.approver_ids.split(',').map(s => s.trim()).filter(Boolean);
if (ids.length === 0) return { approver_ids: null, approver_name: null };
try {
const user = await nocodb.get('Users', Number(ids[0]));
return { approver_ids: record.approver_ids, approver_name: user ? (user.display_name || user.name || user.email) : null };
} catch { return { approver_ids: record.approver_ids, approver_name: null }; }
};
const [captionApprover, bodyApprover, designApprover, videoApprover] = await Promise.all([
resolveApprover(caption),
resolveApprover(bodyCopy),
resolveApprover(design),
resolveApprover(video),
]);
return {
caption: caption ? { id: caption.Id, title: caption.title, status: caption.status, content_preview: captionTexts.contentPreview, languages: captionTexts.texts, ...captionApprover } : null,
body_copy: bodyCopy ? { id: bodyCopy.Id, title: bodyCopy.title, status: bodyCopy.status, content_preview: bodyTexts.contentPreview, languages: bodyTexts.texts, ...bodyApprover } : null,
design: design ? { id: design.Id, title: design.title, status: design.status, thumbnail_url: designThumb, current_version: design.current_version, ...designApprover } : null,
video: video ? { id: video.Id, title: video.title, status: video.status, thumbnail_url: videoThumb, current_version: video.current_version, ...videoApprover } : null,
platforms,
pieces_ready: piecesReady,
waiting_on: waitingOn,
stage: post.stage || 'copy',
};
}
function computeStage(composition) {
const { caption, body_copy, design, video, pieces_ready } = composition;
if (pieces_ready) return 'post';
if (design || video) return 'design';
if (caption || body_copy) return 'translate';
return 'copy';
}
// Sync the post's thumbnail_url from its linked design artefact
async function syncPostThumbnail(postId) {
try {
const artefacts = await nocodb.list('Artefacts', {
where: `(post_id,eq,${postId})`, limit: 100,
});
const design = artefacts.find(a => (a.type || 'design') === 'design');
let thumb = null;
if (design) {
thumb = design.thumbnail_url || null;
if (!thumb) {
const versions = await nocodb.list('ArtefactVersions', { where: `(artefact_id,eq,${design.Id})`, sort: '-version_number', limit: 1 });
if (versions.length > 0) {
const attachments = await nocodb.list('ArtefactAttachments', { where: `(version_id,eq,${versions[0].Id})`, limit: 1 });
if (attachments.length > 0) {
const att = attachments[0];
thumb = att.drive_url || (att.filename ? `/api/uploads/${att.filename}` : null);
}
}
}
}
await nocodb.update('Posts', Number(postId), { thumbnail_url: thumb || null });
} catch (e) {
console.error('syncPostThumbnail error:', e);
}
}
module.exports = { getPostComposition, computeStage, syncPostThumbnail };
+37
View File
@@ -0,0 +1,37 @@
const n = require('./nocodb');
const OLD_ID = 6;
const NEW_ID = 1;
const TABLES_AND_FIELDS = [
{ table: 'Posts', fields: ['assigned_to_id', 'created_by_user_id'] },
{ table: 'Tasks', fields: ['assigned_to_id', 'created_by_user_id'] },
{ table: 'Projects', fields: ['owner_id', 'created_by_user_id'] },
{ table: 'Campaigns', fields: ['created_by_user_id'] },
{ table: 'Assets', fields: ['uploader_id'] },
{ table: 'Comments', fields: ['user_id'] },
{ table: 'CampaignAssignments', fields: ['member_id', 'assigner_id'] },
{ table: 'Artefacts', fields: ['created_by_user_id'] },
{ table: 'ArtefactVersions', fields: ['created_by_user_id'] },
{ table: 'Issues', fields: ['assigned_to_id'] },
{ table: 'TeamMembers', fields: ['user_id'] },
{ table: 'BudgetEntries', fields: [] },
];
(async () => {
for (const { table, fields } of TABLES_AND_FIELDS) {
for (const field of fields) {
try {
const records = await n.list(table, { where: `(${field},eq,${OLD_ID})`, limit: 200 });
if (records.length === 0) continue;
console.log(`${table}.${field}: ${records.length} records to update`);
for (const r of records) {
await n.update(table, r.Id, { [field]: NEW_ID });
}
console.log(` -> done`);
} catch (err) {
console.log(`${table}.${field}: skipped (${err.message})`);
}
}
}
console.log('Reassignment complete.');
})();

Some files were not shown because too many files have changed in this diff Show More