Compare commits

..

10 Commits

Author SHA1 Message Date
fahed af91dba268 feat: admin migration endpoint — Translations with post_id → Artefacts
Deploy / deploy (push) Successful in 12s
Converts old caption/body copy Translations to Artefacts (type=copy)
with ArtefactVersions + ArtefactVersionTexts. Idempotent: skips posts
that already have copy Artefacts. Dry-run by default.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 12:55:14 +03:00
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
98 changed files with 8930 additions and 2783 deletions
@@ -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.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=IBM+Plex+Sans+Arabic:wght@300;400;500;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=IBM+Plex+Sans+Arabic:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<title>Digital Hub</title> <title>Rawaj</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+6 -2
View File
@@ -15,6 +15,7 @@ import { useKeyboardShortcuts, DEFAULT_SHORTCUTS } from './hooks/useKeyboardShor
// Lazy-loaded page components // Lazy-loaded page components
const Dashboard = lazy(() => import('./pages/Dashboard')) const Dashboard = lazy(() => import('./pages/Dashboard'))
const PostProduction = lazy(() => import('./pages/PostProduction')) const PostProduction = lazy(() => import('./pages/PostProduction'))
const PostDetail = lazy(() => import('./pages/PostDetail'))
const Assets = lazy(() => import('./pages/Assets')) const Assets = lazy(() => import('./pages/Assets'))
const Campaigns = lazy(() => import('./pages/Campaigns')) const Campaigns = lazy(() => import('./pages/Campaigns'))
const CampaignDetail = lazy(() => import('./pages/CampaignDetail')) const CampaignDetail = lazy(() => import('./pages/CampaignDetail'))
@@ -37,6 +38,7 @@ const PublicIssueSubmit = lazy(() => import('./pages/PublicIssueSubmit'))
const PublicIssueTracker = lazy(() => import('./pages/PublicIssueTracker')) const PublicIssueTracker = lazy(() => import('./pages/PublicIssueTracker'))
const Translations = lazy(() => import('./pages/Translations')) const Translations = lazy(() => import('./pages/Translations'))
const PublicTranslationReview = lazy(() => import('./pages/PublicTranslationReview')) const PublicTranslationReview = lazy(() => import('./pages/PublicTranslationReview'))
const PublicBudgetApproval = lazy(() => import('./pages/PublicBudgetApproval'))
const ForgotPassword = lazy(() => import('./pages/ForgotPassword')) const ForgotPassword = lazy(() => import('./pages/ForgotPassword'))
const ResetPassword = lazy(() => import('./pages/ResetPassword')) const ResetPassword = lazy(() => import('./pages/ResetPassword'))
@@ -161,7 +163,7 @@ function AppContent() {
<AppContext.Provider value={{ currentUser: user, teamMembers, brands, loadTeam, getBrandName, teams, loadTeams, roles, loadRoles }}> <AppContext.Provider value={{ currentUser: user, teamMembers, brands, loadTeam, getBrandName, teams, loadTeams, roles, loadRoles }}>
{/* Profile completion prompt */} {/* Profile completion prompt */}
{showProfilePrompt && ( {showProfilePrompt && (
<div className="fixed top-4 right-4 z-50 bg-amber-50 border-2 border-amber-400 rounded-xl shadow-lg p-4 max-w-md animate-fade-in"> <div className="fixed top-4 end-4 z-50 bg-amber-50 border-2 border-amber-400 rounded-xl shadow-lg p-4 max-w-md animate-fade-in">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-full bg-amber-400 flex items-center justify-center text-white shrink-0"> <div className="w-10 h-10 rounded-full bg-amber-400 flex items-center justify-center text-white shrink-0">
@@ -288,7 +290,7 @@ function AppContent() {
{showTutorial && <Tutorial onComplete={handleTutorialComplete} />} {showTutorial && <Tutorial onComplete={handleTutorialComplete} />}
<ErrorBoundary> <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> <Routes>
<Route path="/login" element={user ? <Navigate to="/" replace /> : <Login />} /> <Route path="/login" element={user ? <Navigate to="/" replace /> : <Login />} />
<Route path="/forgot-password" element={user ? <Navigate to="/" replace /> : <ForgotPassword />} /> <Route path="/forgot-password" element={user ? <Navigate to="/" replace /> : <ForgotPassword />} />
@@ -298,9 +300,11 @@ function AppContent() {
<Route path="/submit-issue" element={<PublicIssueSubmit />} /> <Route path="/submit-issue" element={<PublicIssueSubmit />} />
<Route path="/track/:token" element={<PublicIssueTracker />} /> <Route path="/track/:token" element={<PublicIssueTracker />} />
<Route path="/review-translation/:token" element={<PublicTranslationReview />} /> <Route path="/review-translation/:token" element={<PublicTranslationReview />} />
<Route path="/approve-budget/:token" element={<PublicBudgetApproval />} />
<Route path="/" element={user ? <Layout /> : <Navigate to="/login" replace />}> <Route path="/" element={user ? <Layout /> : <Navigate to="/login" replace />}>
<Route index element={<Dashboard />} /> <Route index element={<Dashboard />} />
{hasModule('marketing') && <> {hasModule('marketing') && <>
<Route path="posts/:id" element={<PostDetail />} />
<Route path="posts" element={<PostProduction />} /> <Route path="posts" element={<PostProduction />} />
<Route path="calendar" element={<PostCalendar />} /> <Route path="calendar" element={<PostCalendar />} />
<Route path="artefacts" element={<Artefacts />} /> <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' import { Check, ChevronDown, X } from 'lucide-react'
export default function ApproverMultiSelect({ users = [], selected = [], onChange }) { export default function ApproverMultiSelect({ users = [], selected = [], onChange }) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [dropUp, setDropUp] = useState(false) const triggerRef = useRef(null)
const wrapperRef = 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(() => { useEffect(() => {
if (!open) return if (!open) return
const handleClick = (e) => { const handleClick = (e) => {
if (wrapperRef.current && !wrapperRef.current.contains(e.target)) { if (triggerRef.current?.contains(e.target)) return
setOpen(false) if (dropdownRef.current?.contains(e.target)) return
} setOpen(false)
} }
document.addEventListener('mousedown', handleClick) const handleEsc = (e) => { if (e.key === 'Escape') setOpen(false) }
return () => document.removeEventListener('mousedown', handleClick) const handleScroll = () => updatePosition()
}, [open])
// Detect if dropdown should open upward document.addEventListener('mousedown', handleClick)
useEffect(() => { document.addEventListener('keydown', handleEsc)
if (!open || !wrapperRef.current) return window.addEventListener('scroll', handleScroll, true)
const rect = wrapperRef.current.getBoundingClientRect() return () => {
const spaceBelow = window.innerHeight - rect.bottom document.removeEventListener('mousedown', handleClick)
setDropUp(spaceBelow < 220) document.removeEventListener('keydown', handleEsc)
}, [open]) window.removeEventListener('scroll', handleScroll, true)
}
}, [open, updatePosition])
const handleOpen = () => {
updatePosition()
setOpen(!open)
}
const toggle = (userId) => { const toggle = (userId) => {
const id = String(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) const selectedUsers = selected.map(id => users.find(u => String(u._id || u.id || u.Id) === String(id))).filter(Boolean)
return ( return (
<div className="relative" ref={wrapperRef}> <>
<div <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 ${ 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' open ? 'border-brand-primary ring-2 ring-brand-primary/20' : 'border-border'
}`} }`}
@@ -58,16 +80,21 @@ export default function ApproverMultiSelect({ users = [], selected = [], onChang
<button <button
type="button" type="button"
onClick={e => { e.stopPropagation(); remove(u._id || u.id || u.Id) }} 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" /> <X className="w-3 h-3" />
</button> </button>
</span> </span>
))} ))}
<ChevronDown className={`w-4 h-4 text-text-tertiary ml-auto shrink-0 transition-transform ${open ? 'rotate-180' : ''}`} /> <ChevronDown className={`w-4 h-4 text-text-tertiary ms-auto shrink-0 transition-transform ${open ? 'rotate-180' : ''}`} />
</div> </div>
{open && (
<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 => { {users.map(u => {
const uid = String(u._id || u.id || u.Id) const uid = String(u._id || u.id || u.Id)
const isSelected = selected.includes(uid) const isSelected = selected.includes(uid)
@@ -76,7 +103,7 @@ export default function ApproverMultiSelect({ users = [], selected = [], onChang
key={uid} key={uid}
type="button" type="button"
onClick={() => toggle(uid)} onClick={() => toggle(uid)}
className={`w-full text-left px-3 py-2 text-sm hover:bg-surface-secondary flex items-center justify-between ${ className={`w-full text-start px-3 py-2 text-sm hover:bg-surface-secondary flex items-center justify-between transition-colors ${
isSelected ? 'text-brand-primary font-medium' : 'text-text-primary' isSelected ? 'text-brand-primary font-medium' : 'text-text-primary'
}`} }`}
> >
@@ -88,8 +115,9 @@ export default function ApproverMultiSelect({ users = [], selected = [], onChang
{users.length === 0 && ( {users.length === 0 && (
<div className="px-3 py-4 text-sm text-text-tertiary text-center">No users available</div> <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 { useState, useEffect, useContext } from 'react'
import { Plus, Copy, Check, ExternalLink, Upload, Globe, Trash2, FileText, Image as ImageIcon, Film, Sparkles, MessageSquare, Save, FileEdit, Layers, ShieldCheck } from 'lucide-react' import { Copy, Check, ExternalLink, Trash2, FileText, Image as ImageIcon, Film, Sparkles, MessageSquare, Save, FileEdit, Layers, ShieldCheck } from 'lucide-react'
import { AppContext } from '../App' import { AppContext } from '../App'
import { useLanguage } from '../i18n/LanguageContext' import { useLanguage } from '../i18n/LanguageContext'
import { api } from '../utils/api' import { api } from '../utils/api'
import Modal from './Modal' import Modal from './Modal'
import TabbedModal from './TabbedModal' import TabbedModal from './TabbedModal'
import { useToast } from './ToastContainer' import { useToast } from './ToastContainer'
import ArtefactVersionTimeline from './ArtefactVersionTimeline' import PortalSelect from './PortalSelect'
import ApproverMultiSelect from './ApproverMultiSelect' import { ArtefactDetailVersionsTab } from './ArtefactDetailVersionsTab'
const STATUS_COLORS = { const STATUS_COLORS = {
draft: 'bg-surface-tertiary text-text-secondary', draft: 'bg-surface-tertiary text-text-secondary',
@@ -17,13 +17,6 @@ const STATUS_COLORS = {
revision_requested: 'bg-orange-100 text-orange-700', revision_requested: 'bg-orange-100 text-orange-700',
} }
const AVAILABLE_LANGUAGES = [
{ code: 'AR', label: '\u0627\u0644\u0639\u0631\u0628\u064A\u0629' },
{ code: 'EN', label: 'English' },
{ code: 'FR', label: 'Fran\u00E7ais' },
{ code: 'ID', label: 'Bahasa Indonesia' },
]
const TYPE_ICONS = { const TYPE_ICONS = {
copy: FileText, copy: FileText,
design: ImageIcon, design: ImageIcon,
@@ -31,7 +24,11 @@ const TYPE_ICONS = {
other: Sparkles, 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 { t } = useLanguage()
const { brands } = useContext(AppContext) const { brands } = useContext(AppContext)
const toast = useToast() const toast = useToast()
@@ -42,40 +39,19 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [freshReviewUrl, setFreshReviewUrl] = useState('') const [freshReviewUrl, setFreshReviewUrl] = useState('')
const [copied, setCopied] = useState(false) 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 [editTitle, setEditTitle] = useState(artefact.title || '')
const [editDescription, setEditDescription] = useState(artefact.description || '') const [editDescription, setEditDescription] = useState(artefact.description || '')
const [editProjectId, setEditProjectId] = useState(artefact.project_id || '') const [editApproverIds, setEditApproverIds] = useState(() => parseApproverIds(artefact))
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 reviewUrl = freshReviewUrl || (artefact.approval_token ? `${window.location.origin}/review/${artefact.approval_token}` : '') const reviewUrl = freshReviewUrl || (artefact.approval_token ? `${window.location.origin}/review/${artefact.approval_token}` : '')
const [savingDraft, setSavingDraft] = useState(false) const [savingDraft, setSavingDraft] = useState(false)
const [deleting, setDeleting] = useState(false) const [deleting, setDeleting] = useState(false)
const [confirmDeleteLangId, setConfirmDeleteLangId] = useState(null)
const [confirmDeleteAttId, setConfirmDeleteAttId] = useState(null)
const [showDeleteArtefactConfirm, setShowDeleteArtefactConfirm] = useState(false) const [showDeleteArtefactConfirm, setShowDeleteArtefactConfirm] = useState(false)
// Language management (for copy type)
const [showLanguageModal, setShowLanguageModal] = useState(false)
const [languageForm, setLanguageForm] = useState({ language_code: '', language_label: '', content: '' })
const [savingLanguage, setSavingLanguage] = useState(false)
// New version modal
const [showNewVersionModal, setShowNewVersionModal] = useState(false)
const [newVersionNotes, setNewVersionNotes] = useState('')
const [copyFromPrevious, setCopyFromPrevious] = useState(false)
const [creatingVersion, setCreatingVersion] = useState(false)
// File upload (for design/video) // File upload (for design/video)
const [uploading, setUploading] = useState(false) const [uploading, setUploading] = useState(false)
// Video inline (Drive link input)
const [driveUrl, setDriveUrl] = useState('')
const [dragOver, setDragOver] = useState(false)
const [uploadProgress, setUploadProgress] = useState(0) const [uploadProgress, setUploadProgress] = useState(0)
// Comments // Comments
@@ -87,16 +63,6 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
loadVersions() loadVersions()
}, [artefact.Id]) }, [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 () => { const loadVersions = async () => {
try { try {
const res = await api.get(`/artefacts/${artefact.Id}/versions`) const res = await api.get(`/artefacts/${artefact.Id}/versions`)
@@ -137,57 +103,16 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
loadVersionData(version.Id) loadVersionData(version.Id)
} }
const handleCreateVersion = async () => { const handleAddLanguage = async (languageForm) => {
setCreatingVersion(true) await api.post(`/artefacts/${artefact.Id}/versions/${selectedVersion.Id}/texts`, languageForm)
try { toast.success(t('artefacts.languageAdded'))
await api.post(`/artefacts/${artefact.Id}/versions`, { loadVersionData(selectedVersion.Id)
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 handleDeleteLanguage = async (textId) => { const handleDeleteLanguage = async (textId) => {
try { await api.delete(`/artefact-version-texts/${textId}`)
await api.delete(`/artefact-version-texts/${textId}`) toast.success(t('artefacts.languageDeleted'))
toast.success(t('artefacts.languageDeleted')) loadVersionData(selectedVersion.Id)
loadVersionData(selectedVersion.Id)
} catch (err) {
toast.error(t('artefacts.failedDeleteLanguage'))
}
} }
const handleFileUpload = async (fileOrEvent) => { const handleFileUpload = async (fileOrEvent) => {
@@ -215,16 +140,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
} }
} }
const handleVideoDrop = (e) => { const handleAddDriveVideo = async (driveUrl) => {
e.preventDefault()
setDragOver(false)
const file = e.dataTransfer.files?.[0]
if (file && file.type.startsWith('video/')) {
handleFileUpload(file)
}
}
const handleAddDriveVideo = async () => {
if (!driveUrl.trim()) { if (!driveUrl.trim()) {
toast.error(t('artefacts.enterDriveUrl')) toast.error(t('artefacts.enterDriveUrl'))
return return
@@ -236,7 +152,6 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
drive_url: driveUrl, drive_url: driveUrl,
}) })
toast.success(t('artefacts.videoLinkAdded')) toast.success(t('artefacts.videoLinkAdded'))
setDriveUrl('')
loadVersionData(selectedVersion.Id) loadVersionData(selectedVersion.Id)
} catch (err) { } catch (err) {
console.error('Add Drive link failed:', err) console.error('Add Drive link failed:', err)
@@ -247,13 +162,9 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
} }
const handleDeleteAttachment = async (attId) => { const handleDeleteAttachment = async (attId) => {
try { await api.delete(`/artefact-attachments/${attId}`)
await api.delete(`/artefact-attachments/${attId}`) toast.success(t('artefacts.attachmentDeleted'))
toast.success(t('artefacts.attachmentDeleted')) loadVersionData(selectedVersion.Id)
loadVersionData(selectedVersion.Id)
} catch (err) {
toast.error(t('artefacts.failedDeleteAttachment'))
}
} }
const handleSubmitReview = async () => { const handleSubmitReview = async () => {
@@ -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 () => { const handleDeleteArtefact = async () => {
setDeleting(true) setDeleting(true)
try { try {
@@ -358,10 +275,10 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
const TypeIcon = TYPE_ICONS[artefact.type] || Sparkles const TypeIcon = TYPE_ICONS[artefact.type] || Sparkles
const tabs = [ const tabs = [
{ key: 'details', label: t('artefacts.details') || 'Details', icon: FileEdit }, { key: 'details', label: t('artefacts.details'), icon: FileEdit },
{ key: 'versions', label: t('artefacts.versions') || 'Versions', icon: Layers, badge: versions.length }, { key: 'versions', label: t('artefacts.versions'), icon: Layers, badge: versions.length },
{ key: 'discussion', label: t('artefacts.comments') || 'Discussion', icon: MessageSquare, badge: comments.length }, { key: 'discussion', label: t('artefacts.comments'), icon: MessageSquare, badge: comments.length },
{ key: 'review', label: t('artefacts.review') || 'Review', icon: ShieldCheck }, { key: 'review', label: t('artefacts.review'), icon: ShieldCheck },
] ]
if (loading) { if (loading) {
@@ -380,32 +297,30 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
onClose={onClose} onClose={onClose}
size="xl" size="xl"
header={ header={
<> <div className="flex items-start gap-3">
<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">
<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" />
<TypeIcon className="w-5 h-5 text-brand-primary" /> </div>
</div> <div className="flex-1 min-w-0">
<div className="flex-1 min-w-0"> <input
<input type="text"
type="text" value={editTitle}
value={editTitle} onChange={e => setEditTitle(e.target.value)}
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"
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">
<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'}`}>
<span className={`text-xs px-2 py-0.5 rounded-full ${STATUS_COLORS[artefact.status] || 'bg-surface-tertiary text-text-secondary'}`}> {artefact.status?.replace('_', ' ')}
{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>
<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> </div>
</> </div>
} }
tabs={tabs} tabs={tabs}
activeTab={activeTab} activeTab={activeTab}
@@ -425,15 +340,17 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
</button> </button>
)} )}
</div> </div>
<button {activeTab === 'details' && (
onClick={handleSaveDraft} <button
disabled={savingDraft} onClick={handleSaveDraft}
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" disabled={savingDraft}
title={t('artefacts.saveDraftTooltip')} 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')} <Save className="w-3.5 h-3.5" />
</button> {savingDraft ? t('artefacts.savingDraft') : t('artefacts.saveDraft')}
</button>
)}
</> </>
} }
> >
@@ -452,262 +369,53 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
/> />
</div> </div>
{/* Project & Campaign dropdowns */} {/* Metadata row */}
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4 pt-1">
<div> {/* Brand */}
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1.5">{t('artefacts.project')}</h4> {(artefact.brand_id || artefact.brandId) && (
<select <div>
value={editProjectId} <h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1">{t('posts.brand')}</h4>
onChange={e => { <p className="text-sm text-text-primary">
setEditProjectId(e.target.value) {brands.find(b => String(b._id) === String(artefact.brand_id || artefact.brandId))?.name || `#${artefact.brand_id || artefact.brandId}`}
handleUpdateField('project_id', e.target.value) </p>
}} </div>
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" )}
> {/* Created date */}
<option value=""></option> {artefact.CreatedAt && (
{projects.map(p => <option key={p.Id || p._id || p.id} value={p.Id || p._id || p.id}>{p.name || p.title}</option>)} <div>
</select> <h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1">{t('common.created')}</h4>
</div> <p className="text-sm text-text-secondary">{new Date(artefact.CreatedAt).toLocaleDateString()}</p>
<div> </div>
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1.5">{t('artefacts.campaign')}</h4> )}
<select {/* Linked post */}
value={editCampaignId} {(artefact.post_id || artefact.postId) && (
onChange={e => { <div>
setEditCampaignId(e.target.value) <h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1">{t('artefacts.linkedPost')}</h4>
handleUpdateField('campaign_id', e.target.value) <p className="text-sm text-text-secondary">{t('artefacts.post')} #{artefact.post_id || artefact.postId}</p>
}} </div>
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(',') : '')
}}
/>
</div> </div>
</div> </div>
)} )}
{/* Versions Tab */} {/* Versions Tab */}
{activeTab === 'versions' && ( {activeTab === 'versions' && (
<div className="p-6 space-y-5"> <ArtefactDetailVersionsTab
{/* Version Timeline */} artefact={artefact}
<div> versions={versions}
<div className="flex items-center justify-between mb-3"> selectedVersion={selectedVersion}
<h4 className="text-xs font-semibold text-text-tertiary uppercase">{t('artefacts.versions')}</h4> versionData={versionData}
<button uploading={uploading}
onClick={() => setShowNewVersionModal(true)} uploadProgress={uploadProgress}
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors" onSelectVersion={handleSelectVersion}
> onAddLanguage={handleAddLanguage}
<Plus className="w-3 h-3" /> onUpdateLanguage={handleUpdateLanguage}
{t('artefacts.newVersion')} onDeleteLanguage={handleDeleteLanguage}
</button> onFileUpload={handleFileUpload}
</div> onDeleteAttachment={handleDeleteAttachment}
<ArtefactVersionTimeline onAddDriveVideo={handleAddDriveVideo}
versions={versions} getDriveEmbedUrl={getDriveEmbedUrl}
activeVersionId={selectedVersion?.Id} />
onSelectVersion={handleSelectVersion}
artefactType={artefact.type}
/>
</div>
{/* Type-specific content */}
{versionData && selectedVersion && (
<div className="border-t border-border pt-5">
{/* COPY TYPE: Language entries */}
{artefact.type === 'copy' && (
<div>
<div className="flex items-center justify-between mb-3">
<h4 className="text-xs font-semibold text-text-tertiary uppercase">{t('artefacts.languages')}</h4>
<button
onClick={() => setShowLanguageModal(true)}
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors"
>
<Plus className="w-3 h-3" />
{t('artefacts.addLanguage')}
</button>
</div>
{versionData.texts && versionData.texts.length > 0 ? (
<div className="space-y-3">
{versionData.texts.map(text => (
<div key={text.Id} className="bg-surface-secondary rounded-lg p-4 border border-border">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
<span className="px-2 py-0.5 bg-surface border border-border rounded text-xs font-mono font-medium">
{text.language_code}
</span>
<span className="text-sm font-medium text-text-primary">{text.language_label}</span>
</div>
<button
onClick={() => setConfirmDeleteLangId(text.Id)}
className="text-red-600 hover:text-red-700"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
<div className="bg-surface rounded border border-border p-3 text-sm text-text-primary whitespace-pre-wrap font-sans">
{text.content}
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8 bg-surface-secondary rounded-lg border-2 border-dashed border-border">
<Globe className="w-8 h-8 text-text-tertiary mx-auto mb-2" />
<p className="text-sm text-text-secondary">{t('artefacts.noLanguages')}</p>
</div>
)}
</div>
)}
{/* DESIGN TYPE: Image gallery */}
{artefact.type === 'design' && (
<div>
<div className="flex items-center justify-between mb-3">
<h4 className="text-xs font-semibold text-text-tertiary uppercase">{t('artefacts.imagesLabel')}</h4>
<label className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors cursor-pointer">
<Upload className="w-3 h-3" />
{uploading ? t('artefacts.uploading') : t('artefacts.uploadImage')}
<input
type="file"
className="hidden"
accept="image/*"
onChange={handleFileUpload}
disabled={uploading}
/>
</label>
</div>
{versionData.attachments && versionData.attachments.length > 0 ? (
<div className="grid grid-cols-2 gap-3">
{versionData.attachments.map(att => (
<div key={att.Id} className="relative group">
<img
src={att.url}
alt={att.original_name}
className="w-full h-48 object-cover rounded-lg border border-border"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors rounded-lg flex items-center justify-center">
<button
onClick={() => setConfirmDeleteAttId(att.Id)}
className="opacity-0 group-hover:opacity-100 transition-opacity px-3 py-2 bg-red-600 text-white rounded-lg text-xs font-medium hover:bg-red-700"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
<div className="mt-1 px-2 py-1 bg-surface-secondary rounded text-xs text-text-secondary truncate">
{att.original_name}
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8 bg-surface-secondary rounded-lg border-2 border-dashed border-border">
<ImageIcon className="w-8 h-8 text-text-tertiary mx-auto mb-2" />
<p className="text-sm text-text-secondary">{t('artefacts.noImages')}</p>
</div>
)}
</div>
)}
{/* VIDEO TYPE: Files and Drive links — all inline */}
{artefact.type === 'video' && (
<div>
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-3">{t('artefacts.videosLabel')}</h4>
{/* Existing attachments */}
{versionData.attachments && versionData.attachments.length > 0 && (
<div className="space-y-3 mb-4">
{versionData.attachments.map(att => (
<div key={att.Id} className="bg-surface-secondary rounded-lg p-4 border border-border">
{att.drive_url ? (
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-text-primary">{t('artefacts.googleDriveVideo')}</span>
<button onClick={() => setConfirmDeleteAttId(att.Id)} className="text-red-600 hover:text-red-700">
<Trash2 className="w-4 h-4" />
</button>
</div>
<iframe src={getDriveEmbedUrl(att.drive_url)} className="w-full h-64 rounded border border-border" allow="autoplay" />
</div>
) : (
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-text-primary truncate">{att.original_name}</span>
<button onClick={() => setConfirmDeleteAttId(att.Id)} className="text-red-600 hover:text-red-700">
<Trash2 className="w-4 h-4" />
</button>
</div>
<video src={att.url} controls className="w-full rounded border border-border" />
</div>
)}
</div>
))}
</div>
)}
{/* Drag-and-drop / click-to-upload zone */}
<label
className={`flex flex-col items-center gap-2 px-6 py-6 border-2 border-dashed rounded-lg cursor-pointer transition-colors ${
dragOver ? 'border-brand-primary bg-brand-primary/5' : 'border-border hover:border-brand-primary/30'
} ${uploading ? 'pointer-events-none opacity-60' : ''}`}
onDragOver={(e) => { e.preventDefault(); setDragOver(true) }}
onDragLeave={() => setDragOver(false)}
onDrop={handleVideoDrop}
>
{uploading ? (
<>
<div className="w-full max-w-xs bg-surface-tertiary rounded-full h-2 overflow-hidden">
<div className="bg-brand-primary h-full rounded-full transition-all duration-300" style={{ width: `${uploadProgress}%` }} />
</div>
<span className="text-sm text-text-secondary">{t('artefacts.uploading')} {uploadProgress}%</span>
</>
) : (
<>
<Upload className="w-7 h-7 text-text-tertiary" />
<span className="text-sm font-medium text-text-primary">{t('artefacts.dropOrClickVideo')}</span>
<span className="text-xs text-text-tertiary">{t('artefacts.videoFormats')}</span>
</>
)}
<input type="file" className="hidden" accept="video/*" onChange={handleFileUpload} disabled={uploading} />
</label>
{/* Google Drive URL inline input */}
<div className="flex items-center gap-2 mt-3">
<Globe className="w-4 h-4 text-text-tertiary shrink-0" />
<input
type="text"
value={driveUrl}
onChange={e => setDriveUrl(e.target.value)}
placeholder="https://drive.google.com/file/d/..."
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
onKeyDown={e => { if (e.key === 'Enter' && driveUrl.trim()) handleAddDriveVideo() }}
/>
<button
onClick={handleAddDriveVideo}
disabled={uploading || !driveUrl.trim()}
className="px-3 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 shrink-0"
>
{t('artefacts.addLink')}
</button>
</div>
</div>
)}
</div>
)}
</div>
)} )}
{/* Discussion Tab */} {/* Discussion Tab */}
@@ -762,7 +470,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
</> </>
) : ( ) : (
<div className="text-center py-8 text-sm text-text-tertiary"> <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>
)} )}
</div> </div>
@@ -771,11 +479,28 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
{/* Review Tab */} {/* Review Tab */}
{activeTab === 'review' && ( {activeTab === 'review' && (
<div className="p-6 space-y-5"> <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 */} {/* Submit for Review */}
{['draft', 'revision_requested', 'rejected'].includes(artefact.status) && ( {['draft', 'revision_requested', 'rejected'].includes(artefact.status) && (
<button <button
onClick={handleSubmitReview} 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" 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" /> <ExternalLink className="w-4 h-4" />
@@ -824,137 +549,16 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
</div> </div>
)} )}
{/* Empty state when no review actions available */} {/* Empty state: pending_review or unknown status with no review info */}
{!['draft', 'revision_requested', 'rejected'].includes(artefact.status) && !reviewUrl && !artefact.feedback && !(artefact.status === 'approved' && artefact.approved_by_name) && ( {artefact.status === 'pending_review' && !reviewUrl && !artefact.feedback && (
<div className="text-center py-8 text-sm text-text-tertiary"> <div className="text-center py-8 text-sm text-text-tertiary">
{artefact.status === 'pending_review' {t('artefacts.pendingReviewInfo')}
? t('artefacts.pendingReviewInfo') || 'This artefact is currently pending review.'
: t('artefacts.noReviewInfo') || 'No review information available.'}
</div> </div>
)} )}
</div> </div>
)} )}
</TabbedModal> </TabbedModal>
{/* Language Modal */}
<Modal isOpen={showLanguageModal} onClose={() => setShowLanguageModal(false)} title={t('artefacts.addLanguage')} size="md">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('artefacts.languageLabel')} *</label>
<select
value={languageForm.language_code}
onChange={e => {
const lang = AVAILABLE_LANGUAGES.find(l => l.code === e.target.value)
if (lang) setLanguageForm(f => ({ ...f, language_code: lang.code, language_label: lang.label }))
else setLanguageForm(f => ({ ...f, language_code: '', language_label: '' }))
}}
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
>
<option value="">{t('artefacts.selectLanguage')}</option>
{AVAILABLE_LANGUAGES
.filter(lang => !(versionData?.texts || []).some(t => t.language_code === lang.code))
.map(lang => (
<option key={lang.code} value={lang.code}>{lang.label} ({lang.code})</option>
))
}
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('artefacts.contentLabel')} *</label>
<textarea
value={languageForm.content}
onChange={e => setLanguageForm(f => ({ ...f, content: e.target.value }))}
rows={8}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 font-sans"
placeholder={t('artefacts.enterContent')}
/>
</div>
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
<button
onClick={() => setShowLanguageModal(false)}
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
>
{t('common.cancel')}
</button>
<button
onClick={handleAddLanguage}
disabled={savingLanguage}
className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 shadow-sm"
>
{savingLanguage ? t('header.saving') : t('common.save')}
</button>
</div>
</div>
</Modal>
{/* New Version Modal */}
<Modal isOpen={showNewVersionModal} onClose={() => setShowNewVersionModal(false)} title={t('artefacts.createNewVersion')} size="sm">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('artefacts.versionNotes')}</label>
<textarea
value={newVersionNotes}
onChange={e => setNewVersionNotes(e.target.value)}
rows={3}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
placeholder={t('artefacts.whatChanged')}
/>
</div>
{artefact.type === 'copy' && versions.length > 0 && (
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={copyFromPrevious}
onChange={e => setCopyFromPrevious(e.target.checked)}
className="w-4 h-4 text-brand-primary border-border rounded focus:ring-brand-primary"
/>
<span className="text-sm text-text-secondary">{t('artefacts.copyLanguages')}</span>
</label>
)}
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
<button
onClick={() => setShowNewVersionModal(false)}
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
>
{t('common.cancel')}
</button>
<button
onClick={handleCreateVersion}
disabled={creatingVersion}
className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 shadow-sm"
>
{creatingVersion ? t('artefacts.creatingVersion') : t('artefacts.createVersion')}
</button>
</div>
</div>
</Modal>
{/* Delete Language Confirmation */}
<Modal
isOpen={!!confirmDeleteLangId}
onClose={() => setConfirmDeleteLangId(null)}
title={t('artefacts.deleteLanguage')}
isConfirm
danger
onConfirm={() => handleDeleteLanguage(confirmDeleteLangId)}
confirmText={t('common.delete')}
>
{t('artefacts.deleteLanguageDesc')}
</Modal>
{/* Delete Attachment Confirmation */}
<Modal
isOpen={!!confirmDeleteAttId}
onClose={() => setConfirmDeleteAttId(null)}
title={t('artefacts.deleteAttachment')}
isConfirm
danger
onConfirm={() => handleDeleteAttachment(confirmDeleteAttId)}
confirmText={t('common.delete')}
>
{t('artefacts.deleteAttachmentDesc')}
</Modal>
{/* Delete Artefact Confirmation */} {/* Delete Artefact Confirmation */}
<Modal <Modal
isOpen={showDeleteArtefactConfirm} isOpen={showDeleteArtefactConfirm}
@@ -0,0 +1,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} src={version.thumbnail}
alt={`Version ${version.version_number}`} alt={`Version ${version.version_number}`}
className="w-full h-20 object-cover rounded border border-border" className="w-full h-20 object-cover rounded border border-border"
loading="lazy"
/> />
</div> </div>
)} )}
+2 -1
View File
@@ -24,7 +24,7 @@ export default function AssetCard({ asset, onClick }) {
return ( return (
<div <div
onClick={() => onClick?.(asset)} onClick={() => onClick?.(asset)}
className="bg-white rounded-xl border border-border overflow-hidden card-hover cursor-pointer group" className="bg-surface rounded-xl border border-border overflow-clip card-hover cursor-pointer group"
> >
{/* Thumbnail */} {/* Thumbnail */}
<div className="aspect-square bg-surface-tertiary flex items-center justify-center overflow-hidden relative"> <div className="aspect-square bg-surface-tertiary flex items-center justify-center overflow-hidden relative">
@@ -33,6 +33,7 @@ export default function AssetCard({ asset, onClick }) {
src={asset.url} src={asset.url}
alt={asset.name} alt={asset.name}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
loading="lazy"
onError={(e) => { onError={(e) => {
e.target.style.display = 'none' e.target.style.display = 'none'
e.target.nextSibling.style.display = 'flex' e.target.nextSibling.style.display = 'flex'
+3 -3
View File
@@ -41,7 +41,7 @@ export default function CampaignCalendar({ campaigns = [] }) {
} }
return ( return (
<div className="bg-white rounded-xl border border-border overflow-hidden"> <div className="bg-surface rounded-xl border border-border overflow-clip">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border"> <div className="flex items-center justify-between px-6 py-4 border-b border-border">
<h3 className="text-lg font-semibold text-text-primary"> <h3 className="text-lg font-semibold text-text-primary">
@@ -109,8 +109,8 @@ export default function CampaignCalendar({ campaigns = [] }) {
<div <div
key={campaign._id || ci} key={campaign._id || ci}
className={`h-5 text-[10px] font-medium text-white flex items-center px-1 truncate ${CAMPAIGN_COLORS[colorIndex]} ${ className={`h-5 text-[10px] font-medium text-white flex items-center px-1 truncate ${CAMPAIGN_COLORS[colorIndex]} ${
isStart ? 'rounded-l-full ml-0' : '-ml-1' isStart ? 'rounded-l-full ms-0' : '-ms-1'
} ${isEnd ? 'rounded-r-full mr-0' : '-mr-1'}`} } ${isEnd ? 'rounded-r-full me-0' : '-me-1'}`}
title={campaign.name} title={campaign.name}
> >
{isStart ? campaign.name : ''} {isStart ? campaign.name : ''}
+16 -20
View File
@@ -6,6 +6,7 @@ import CommentsSection from './CommentsSection'
import Modal from './Modal' import Modal from './Modal'
import TabbedModal from './TabbedModal' import TabbedModal from './TabbedModal'
import BudgetBar from './BudgetBar' import BudgetBar from './BudgetBar'
import PortalSelect from './PortalSelect'
import { AppContext } from '../App' import { AppContext } from '../App'
export default function CampaignDetailPanel({ campaign, onClose, onSave, onDelete, brands, permissions }) { 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 === 'paused' ? 'bg-amber-100 text-amber-700' :
form.status === 'completed' ? 'bg-blue-100 text-blue-700' : form.status === 'completed' ? 'bg-blue-100 text-blue-700' :
form.status === 'cancelled' ? 'bg-red-100 text-red-700' : form.status === 'cancelled' ? 'bg-red-100 text-red-700' :
'bg-gray-100 text-gray-600' 'bg-gray-100 text-text-secondary'
}`}> }`}>
{statusOptions.find(s => s.value === form.status)?.label} {statusOptions.find(s => s.value === form.status)?.label}
</span> </span>
@@ -189,44 +190,39 @@ export default function CampaignDetailPanel({ campaign, onClose, onSave, onDelet
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.brand')}</label> <label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.brand')}</label>
<select <PortalSelect
value={form.brand_id} 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" 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>
<div> <div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.status')}</label> <label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.status')}</label>
<select <PortalSelect
value={form.status} 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" 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> </div>
{/* Team */} {/* Team */}
<div> <div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('common.team')}</label> <label className="block text-xs font-medium text-text-tertiary mb-1">{t('common.team')}</label>
<select <PortalSelect
value={form.team_id} 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" 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>
{/* Platforms */} {/* Platforms */}
<div> <div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.platforms')}</label> <label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.platforms')}</label>
<div className="flex flex-wrap gap-2 p-2 border border-border rounded-lg bg-white min-h-[38px]"> <div className="flex flex-wrap gap-2 p-2 border border-border rounded-lg bg-surface min-h-[38px]">
{Object.entries(PLATFORMS).map(([k, v]) => { {Object.entries(PLATFORMS).map(([k, v]) => {
const checked = (form.platforms || []).includes(k) const checked = (form.platforms || []).includes(k)
return ( return (
@@ -281,7 +277,7 @@ export default function CampaignDetailPanel({ campaign, onClose, onSave, onDelet
<div> <div>
<label className="block text-xs font-medium text-text-tertiary mb-1"> <label className="block text-xs font-medium text-text-tertiary mb-1">
{t('campaigns.budget')} ({currencySymbol}) {t('campaigns.budget')} ({currencySymbol})
{!permissions?.canSetBudget && <span className="text-[10px] text-text-tertiary ml-1">(Superadmin only)</span>} {!permissions?.canSetBudget && <span className="text-[10px] text-text-tertiary ms-1">(Superadmin only)</span>}
</label> </label>
<input <input
type="number" type="number"
+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 key={c.id} className="flex items-start gap-2 group">
<div className="w-7 h-7 rounded-full bg-brand-primary/10 text-brand-primary flex items-center justify-center text-[10px] font-bold shrink-0 mt-0.5"> <div className="w-7 h-7 rounded-full bg-brand-primary/10 text-brand-primary flex items-center justify-center text-[10px] font-bold shrink-0 mt-0.5">
{c.user_avatar ? ( {c.user_avatar ? (
<img src={c.user_avatar} className="w-full h-full rounded-full object-cover" alt="" /> <img src={c.user_avatar} className="w-full h-full rounded-full object-cover" alt="" loading="lazy" />
) : ( ) : (
getInitials(c.user_name) getInitials(c.user_name)
)} )}
@@ -125,7 +125,7 @@ export default function CommentsSection({ entityType, entityId }) {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs font-medium text-text-primary">{c.user_name}</span> <span className="text-xs font-medium text-text-primary">{c.user_name}</span>
<span className="text-[10px] text-text-tertiary">{relativeTime(c.created_at, t)}</span> <span className="text-[10px] text-text-tertiary">{relativeTime(c.created_at, t)}</span>
<div className="flex items-center gap-0.5 ml-auto opacity-0 group-hover:opacity-100 transition-opacity"> <div className="flex items-center gap-0.5 ms-auto opacity-0 group-hover:opacity-100 transition-opacity">
{canEdit(c) && editingId !== c.id && ( {canEdit(c) && editingId !== c.id && (
<button <button
onClick={() => startEdit(c)} onClick={() => startEdit(c)}
+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 ${ className={`px-2.5 py-1 text-[11px] font-medium rounded-full border transition-colors ${
activePreset === preset.key activePreset === preset.key
? 'bg-brand-primary/10 border-brand-primary/30 text-brand-primary' ? 'bg-brand-primary/10 border-brand-primary/30 text-brand-primary'
: 'bg-white border-border text-text-tertiary hover:text-text-primary hover:border-border-dark' : 'bg-surface border-border text-text-tertiary hover:text-text-primary hover:border-border-dark'
}`} }`}
> >
{t(preset.labelKey)} {t(preset.labelKey)}
+3 -3
View File
@@ -21,7 +21,7 @@ export default function EmptyState({
{actionLabel && ( {actionLabel && (
<button <button
onClick={onAction} onClick={onAction}
className="mt-3 text-sm text-brand-primary hover:text-brand-primary-light font-medium" className="mt-3 text-sm text-brand-primary hover:text-brand-primary-light font-medium transition-colors"
> >
{actionLabel} {actionLabel}
</button> </button>
@@ -44,7 +44,7 @@ export default function EmptyState({
{actionLabel && ( {actionLabel && (
<button <button
onClick={onAction} onClick={onAction}
className="px-5 py-2.5 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm transition-all hover:-translate-y-0.5" className="px-5 py-2.5 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light transition-colors"
> >
{actionLabel} {actionLabel}
</button> </button>
@@ -52,7 +52,7 @@ export default function EmptyState({
{secondaryActionLabel && ( {secondaryActionLabel && (
<button <button
onClick={onSecondaryAction} onClick={onSecondaryAction}
className="px-5 py-2.5 bg-white border border-border text-text-primary rounded-lg text-sm font-medium hover:bg-surface-secondary transition-colors" className="px-5 py-2.5 bg-surface border border-border text-text-primary rounded-lg text-sm font-medium hover:bg-surface-secondary transition-colors"
> >
{secondaryActionLabel} {secondaryActionLabel}
</button> </button>
+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-emerald-300 focus:border-emerald-500 focus:ring-emerald-500/20'
: 'border-border focus:border-brand-primary focus:ring-brand-primary/20' : 'border-border focus:border-brand-primary focus:ring-brand-primary/20'
} }
${disabled ? 'bg-surface-tertiary cursor-not-allowed opacity-60' : 'bg-white'} ${disabled ? 'bg-surface-tertiary cursor-not-allowed opacity-60' : 'bg-surface'}
${className} ${className}
`.trim() `.trim()
@@ -39,7 +39,7 @@ export default function FormInput({
{label && ( {label && (
<label className="block text-sm font-medium text-text-primary"> <label className="block text-sm font-medium text-text-primary">
{label} {label}
{required && <span className="text-red-500 ml-0.5">*</span>} {required && <span className="text-red-500 ms-0.5">*</span>}
</label> </label>
)} )}
@@ -57,7 +57,7 @@ export default function FormInput({
{/* Validation icon */} {/* Validation icon */}
{(hasError || hasSuccess) && ( {(hasError || hasSuccess) && (
<div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none"> <div className="absolute end-3 top-1/2 -translate-y-1/2 pointer-events-none">
{hasError ? ( {hasError ? (
<AlertCircle className="w-4 h-4 text-red-500" /> <AlertCircle className="w-4 h-4 text-red-500" />
) : ( ) : (
+11 -6
View File
@@ -22,6 +22,7 @@ const PAGE_TITLE_KEYS = {
'/issues': 'header.issues', '/issues': 'header.issues',
'/team': 'header.team', '/team': 'header.team',
'/settings': 'header.settings', '/settings': 'header.settings',
'/translations': 'header.copy',
} }
const ROLE_INFO = { const ROLE_INFO = {
@@ -44,6 +45,7 @@ export default function Header() {
function getPageTitle(pathname) { function getPageTitle(pathname) {
if (PAGE_TITLE_KEYS[pathname]) return t(PAGE_TITLE_KEYS[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('/projects/')) return t('header.projectDetails')
if (pathname.startsWith('/campaigns/')) return t('header.campaignDetails') if (pathname.startsWith('/campaigns/')) return t('header.campaignDetails')
return t('header.page') return t('header.page')
@@ -99,7 +101,7 @@ export default function Header() {
return ( return (
<> <>
<header className="h-16 bg-white border-b border-border flex items-center justify-between px-6 shrink-0 sticky top-0 z-20"> <header className="h-16 bg-surface border-b border-border flex items-center justify-between px-6 shrink-0 sticky top-0 z-20">
{/* Page title */} {/* Page title */}
<div> <div>
<h2 className="text-xl font-bold text-text-primary">{pageTitle}</h2> <h2 className="text-xl font-bold text-text-primary">{pageTitle}</h2>
@@ -118,8 +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 ${ <div className={`w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-semibold ${
user?.role === 'superadmin' user?.role === 'superadmin'
? 'bg-gradient-to-br from-purple-500 to-pink-500' ? 'bg-brand-primary'
: 'bg-gradient-to-br from-blue-500 to-indigo-500' : 'bg-teal-700'
}`}> }`}>
{getInitials(user?.name)} {getInitials(user?.name)}
</div> </div>
@@ -135,7 +137,7 @@ export default function Header() {
</button> </button>
{showDropdown && ( {showDropdown && (
<div className="absolute end-0 top-full mt-2 w-64 bg-white rounded-xl shadow-lg border border-border overflow-hidden animate-scale-in"> <div className="absolute end-0 top-full mt-2 w-64 bg-surface rounded-xl shadow-lg border border-border overflow-hidden animate-scale-in" role="menu">
{/* User info */} {/* User info */}
<div className="px-4 py-3 border-b border-border-light bg-surface-secondary"> <div className="px-4 py-3 border-b border-border-light bg-surface-secondary">
<p className="text-sm font-semibold text-text-primary">{user?.name}</p> <p className="text-sm font-semibold text-text-primary">{user?.name}</p>
@@ -174,7 +176,7 @@ export default function Header() {
setShowDropdown(false) setShowDropdown(false)
logout() logout()
}} }}
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-red-50 transition-colors text-left group" className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-red-50 transition-colors text-start group"
> >
<LogOut className="w-4 h-4 text-text-tertiary group-hover:text-red-500" /> <LogOut className="w-4 h-4 text-text-tertiary group-hover:text-red-500" />
<span className="text-sm text-text-primary group-hover:text-red-500">{t('header.signOut')}</span> <span className="text-sm text-text-primary group-hover:text-red-500">{t('header.signOut')}</span>
@@ -197,6 +199,7 @@ export default function Header() {
onChange={e => { setPasswordForm(f => ({ ...f, currentPassword: e.target.value })); setPasswordError('') }} onChange={e => { setPasswordForm(f => ({ ...f, currentPassword: e.target.value })); setPasswordError('') }}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary" className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
placeholder="••••••••" placeholder="••••••••"
aria-describedby={passwordError ? 'password-error' : undefined}
/> />
</div> </div>
<div> <div>
@@ -208,6 +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" className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
placeholder="••••••••" placeholder="••••••••"
minLength={6} minLength={6}
aria-describedby={passwordError ? 'password-error' : undefined}
/> />
</div> </div>
<div> <div>
@@ -219,11 +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" className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
placeholder="••••••••" placeholder="••••••••"
minLength={6} minLength={6}
aria-describedby={passwordError ? 'password-error' : undefined}
/> />
</div> </div>
{passwordError && ( {passwordError && (
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg"> <div id="password-error" className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg" role="alert">
<AlertCircle className="w-4 h-4 text-red-500 shrink-0" /> <AlertCircle className="w-4 h-4 text-red-500 shrink-0" />
<p className="text-sm text-red-500">{passwordError}</p> <p className="text-sm text-red-500">{passwordError}</p>
</div> </div>
+12 -12
View File
@@ -237,7 +237,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
if (items.length === 0) { if (items.length === 0) {
return ( return (
<div className="bg-white rounded-xl border border-border py-16 text-center"> <div className="bg-surface rounded-xl border border-border py-16 text-center">
<Calendar className="w-12 h-12 text-text-tertiary mx-auto mb-3" /> <Calendar className="w-12 h-12 text-text-tertiary mx-auto mb-3" />
<p className="text-text-secondary font-medium">{t('timeline.noItems')}</p> <p className="text-text-secondary font-medium">{t('timeline.noItems')}</p>
<p className="text-sm text-text-tertiary mt-1">{t('timeline.addItems')}</p> <p className="text-sm text-text-tertiary mt-1">{t('timeline.addItems')}</p>
@@ -246,7 +246,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
} }
return ( return (
<div className="bg-white rounded-xl border border-border overflow-hidden"> <div className="bg-surface rounded-xl border border-border overflow-clip">
{/* Toolbar */} {/* Toolbar */}
<div className="flex items-center justify-between px-4 py-2 border-b border-border bg-surface-secondary"> <div className="flex items-center justify-between px-4 py-2 border-b border-border bg-surface-secondary">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -287,8 +287,8 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
<div ref={containerRef} dir="ltr" className="overflow-x-auto relative" style={{ cursor: dragState ? 'grabbing' : undefined }}> <div ref={containerRef} dir="ltr" className="overflow-x-auto relative" style={{ cursor: dragState ? 'grabbing' : undefined }}>
<div style={{ minWidth: `${labelWidth + totalDays * pxPerDay}px` }}> <div style={{ minWidth: `${labelWidth + totalDays * pxPerDay}px` }}>
{/* Day header */} {/* Day header */}
<div className="flex sticky top-0 z-20 bg-white border-b border-border" style={{ height: headerHeight }}> <div className="flex sticky top-0 z-20 bg-surface border-b border-border" style={{ height: headerHeight }}>
<div className="shrink-0 border-e border-border bg-surface-secondary flex items-center px-4 sticky left-0 z-30" style={{ width: labelWidth }}> <div className="shrink-0 border-e border-border bg-surface-secondary flex items-center px-4 sticky start-0 z-30" style={{ width: labelWidth }}>
<span className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('timeline.item')}</span> <span className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('timeline.item')}</span>
</div> </div>
<div className="flex relative"> <div className="flex relative">
@@ -338,7 +338,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
> >
{/* Label column */} {/* Label column */}
<div <div
className={`shrink-0 border-e border-border flex ${isExpanded ? 'flex-col justify-center gap-1' : 'items-center gap-2'} px-3 overflow-hidden sticky left-0 z-10 bg-white group-hover/row:bg-surface-secondary/50`} className={`shrink-0 border-e border-border flex ${isExpanded ? 'flex-col justify-center gap-1' : 'items-center gap-2'} px-3 overflow-hidden sticky start-0 z-10 bg-surface group-hover/row:bg-surface-secondary/50`}
style={{ width: labelWidth }} style={{ width: labelWidth }}
> >
{isExpanded ? ( {isExpanded ? (
@@ -358,7 +358,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
)} )}
{item.thumbnailUrl ? ( {item.thumbnailUrl ? (
<div className="w-8 h-8 rounded overflow-hidden shrink-0"> <div className="w-8 h-8 rounded overflow-hidden shrink-0">
<img src={item.thumbnailUrl} alt="" className="w-full h-full object-cover" /> <img src={item.thumbnailUrl} alt="" className="w-full h-full object-cover" loading="lazy" />
</div> </div>
) : item.assigneeName ? ( ) : item.assigneeName ? (
<div className="w-6 h-6 rounded-full bg-brand-primary/10 text-brand-primary flex items-center justify-center text-[9px] font-bold shrink-0"> <div className="w-6 h-6 rounded-full bg-brand-primary/10 text-brand-primary flex items-center justify-center text-[9px] font-bold shrink-0">
@@ -394,7 +394,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
)} )}
{item.thumbnailUrl ? ( {item.thumbnailUrl ? (
<div className="w-6 h-6 rounded overflow-hidden shrink-0"> <div className="w-6 h-6 rounded overflow-hidden shrink-0">
<img src={item.thumbnailUrl} alt="" className="w-full h-full object-cover" /> <img src={item.thumbnailUrl} alt="" className="w-full h-full object-cover" loading="lazy" />
</div> </div>
) : item.assigneeName ? ( ) : item.assigneeName ? (
<div className="w-6 h-6 rounded-full bg-brand-primary/10 text-brand-primary flex items-center justify-center text-[9px] font-bold shrink-0"> <div className="w-6 h-6 rounded-full bg-brand-primary/10 text-brand-primary flex items-center justify-center text-[9px] font-bold shrink-0">
@@ -415,7 +415,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
style={{ left: `${todayOffset + pxPerDay / 2}px` }} style={{ left: `${todayOffset + pxPerDay / 2}px` }}
> >
{idx === 0 && ( {idx === 0 && (
<div className="absolute -top-0 left-1 text-[8px] font-bold text-red-500 bg-red-50 px-1 rounded whitespace-nowrap"> <div className="absolute -top-0 start-1 text-[8px] font-bold text-red-500 bg-red-50 px-1 rounded whitespace-nowrap">
{t('timeline.today')} {t('timeline.today')}
</div> </div>
)} )}
@@ -459,7 +459,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
{/* Left resize handle */} {/* Left resize handle */}
{!readOnly && onDateChange && ( {!readOnly && onDateChange && (
<div <div
className="absolute left-0 top-0 bottom-0 w-2 cursor-col-resize hover:bg-white/30 z-10" className="absolute start-0 top-0 bottom-0 w-2 cursor-col-resize hover:bg-white/30 z-10"
onMouseDown={(e) => handleMouseDown(e, item, 'resize-left')} onMouseDown={(e) => handleMouseDown(e, item, 'resize-left')}
/> />
)} )}
@@ -520,7 +520,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
{/* Right resize handle */} {/* Right resize handle */}
{!readOnly && onDateChange && ( {!readOnly && onDateChange && (
<div <div
className="absolute right-0 top-0 bottom-0 w-2 cursor-col-resize hover:bg-white/30 z-10" className="absolute end-0 top-0 bottom-0 w-2 cursor-col-resize hover:bg-white/30 z-10"
onMouseDown={(e) => handleMouseDown(e, item, 'resize-right')} onMouseDown={(e) => handleMouseDown(e, item, 'resize-right')}
/> />
)} )}
@@ -536,7 +536,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
{colorPicker && onColorChange && ( {colorPicker && onColorChange && (
<div <div
ref={colorPickerRef} ref={colorPickerRef}
className="fixed z-50 bg-white rounded-lg shadow-xl border border-border p-2" className="fixed z-50 bg-surface rounded-lg shadow-xl border border-border p-2"
style={{ left: colorPicker.x, top: colorPicker.y }} style={{ left: colorPicker.x, top: colorPicker.y }}
> >
<div className="grid grid-cols-4 gap-1.5 mb-2"> <div className="grid grid-cols-4 gap-1.5 mb-2">
@@ -591,7 +591,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
)} )}
</div> </div>
{!readOnly && onDateChange && ( {!readOnly && onDateChange && (
<div className="text-gray-400 mt-1 text-[10px] italic"> <div className="text-text-tertiary mt-1 text-[10px] italic">
{t('timeline.dragToMove')} · {t('timeline.dragToResize')} {t('timeline.dragToMove')} · {t('timeline.dragToResize')}
</div> </div>
)} )}
+26 -41
View File
@@ -1,11 +1,13 @@
import { useState, useEffect, useContext } from 'react' 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 { api, STATUS_CONFIG, PRIORITY_CONFIG } from '../utils/api'
import TabbedModal from './TabbedModal' import TabbedModal from './TabbedModal'
import Modal from './Modal' import Modal from './Modal'
import { useToast } from './ToastContainer' import { useToast } from './ToastContainer'
import { AppContext } from '../App' import { AppContext } from '../App'
import { useLanguage } from '../i18n/LanguageContext' import { useLanguage } from '../i18n/LanguageContext'
import PortalSelect from './PortalSelect'
export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers, teams = [] }) { export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers, teams = [] }) {
const { brands } = useContext(AppContext) const { brands } = useContext(AppContext)
@@ -284,67 +286,53 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
{/* Assigned To */} {/* Assigned To */}
<div> <div>
<label className="block text-sm font-semibold text-text-primary mb-2">{t('issues.assignedTo')}</label> <label className="block text-sm font-semibold text-text-primary mb-2">{t('issues.assignedTo')}</label>
<select <PortalSelect
value={assignedTo} 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" 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> </div>
{/* Team */} {/* Team */}
{teams.length > 0 && ( {teams.length > 0 && (
<div> <div>
<label className="block text-sm font-semibold text-text-primary mb-2">{t('issues.team')}</label> <label className="block text-sm font-semibold text-text-primary mb-2">{t('issues.team')}</label>
<select <PortalSelect
value={teamId} value={teamId}
onChange={async (e) => { onChange={async (val) => {
const val = e.target.value || null const resolvedVal = val || null
setTeamId(val || '') setTeamId(resolvedVal || '')
try { try {
await api.patch(`/issues/${issueId}`, { team_id: val }) await api.patch(`/issues/${issueId}`, { team_id: resolvedVal })
await onUpdate() await onUpdate()
await loadIssueDetails() await loadIssueDetails()
} catch (err) { } catch (err) {
console.error('Failed to update team:', 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" 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> </div>
)} )}
{/* Brand */} {/* Brand */}
<div> <div>
<label className="block text-sm font-semibold text-text-primary mb-2">{t('issues.brandLabel')}</label> <label className="block text-sm font-semibold text-text-primary mb-2">{t('issues.brandLabel')}</label>
<select <PortalSelect
value={issueData.brand_id || ''} value={issueData.brand_id || ''}
onChange={async (e) => { onChange={async (val) => {
const val = e.target.value || null; const resolvedVal = val || null;
try { try {
await api.patch(`/issues/${issueId}`, { brand_id: val }); await api.patch(`/issues/${issueId}`, { brand_id: resolvedVal });
loadIssueDetails(); loadIssueDetails();
onUpdate(); onUpdate();
} catch {} } 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" 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> </div>
{/* Internal Notes */} {/* Internal Notes */}
@@ -501,15 +489,12 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
{activeTab === 'attachments' && ( {activeTab === 'attachments' && (
<div className="p-6 space-y-5"> <div className="p-6 space-y-5">
{/* Upload */} {/* Upload */}
<label className="block"> <UploadZone
<input type="file" onChange={handleFileUpload} disabled={uploadingFile} className="hidden" /> onUpload={handleFileUpload}
<div className="border-2 border-dashed border-border rounded-lg p-4 text-center cursor-pointer hover:bg-surface-secondary transition-colors"> uploading={uploadingFile}
<Upload className="w-6 h-6 mx-auto mb-2 text-text-tertiary" /> label={t('issues.clickToUpload')}
<p className="text-sm text-text-secondary"> compact
{uploadingFile ? t('issues.uploading') : t('issues.clickToUpload')} />
</p>
</div>
</label>
{/* Attachments List */} {/* Attachments List */}
<div className="space-y-2"> <div className="space-y-2">
+2 -2
View File
@@ -10,12 +10,12 @@ export default function KanbanCard({ title, thumbnail, brandName, tags, assignee
return ( return (
<div <div
onClick={onClick} onClick={onClick}
className="bg-white rounded-lg p-3 border border-border hover:border-brand-primary/30 cursor-pointer transition-all group overflow-hidden" className="bg-surface rounded-lg p-3 border border-border hover:border-brand-primary/30 cursor-pointer transition-all group overflow-hidden"
> >
{/* Thumbnail */} {/* Thumbnail */}
{thumbnail && ( {thumbnail && (
<div className="w-[calc(100%+1.5rem)] h-32 -mx-3 -mt-3 mb-2 rounded-t-lg overflow-hidden"> <div className="w-[calc(100%+1.5rem)] h-32 -mx-3 -mt-3 mb-2 rounded-t-lg overflow-hidden">
<img src={thumbnail} alt="" className="w-full h-full object-cover" /> <img src={thumbnail} alt="" className="w-full h-full object-cover" loading="lazy" />
</div> </div>
)} )}
+2 -2
View File
@@ -15,7 +15,7 @@ const ROLE_BADGES = {
strategist: { bg: 'bg-rose-50', text: 'text-rose-700', label: 'Strategist' }, strategist: { bg: 'bg-rose-50', text: 'text-rose-700', label: 'Strategist' },
superadmin: { bg: 'bg-red-50', text: 'text-red-700', label: 'Super Admin' }, superadmin: { bg: 'bg-red-50', text: 'text-red-700', label: 'Super Admin' },
contributor: { bg: 'bg-slate-50', text: 'text-slate-700', label: 'Contributor' }, contributor: { bg: 'bg-slate-50', text: 'text-slate-700', label: 'Contributor' },
default: { bg: 'bg-gray-50', text: 'text-gray-700', label: 'Team Member' }, default: { bg: 'bg-gray-50', text: 'text-text-secondary', label: 'Team Member' },
} }
export default function MemberCard({ member, onClick }) { export default function MemberCard({ member, onClick }) {
@@ -33,7 +33,7 @@ export default function MemberCard({ member, onClick }) {
return ( return (
<div <div
onClick={() => onClick?.(member)} onClick={() => onClick?.(member)}
className="bg-white rounded-xl border border-border p-5 card-hover cursor-pointer text-center" className="bg-surface rounded-xl border border-border p-5 card-hover cursor-pointer text-center"
> >
{/* Avatar */} {/* Avatar */}
<div className={`w-16 h-16 rounded-full bg-gradient-to-br ${avatarColors[colorIndex]} flex items-center justify-center text-white text-xl font-bold mx-auto mb-3`}> <div className={`w-16 h-16 rounded-full bg-gradient-to-br ${avatarColors[colorIndex]} flex items-center justify-center text-white text-xl font-bold mx-auto mb-3`}>
+44 -19
View File
@@ -1,15 +1,38 @@
import { useEffect } from 'react' import { useEffect, useRef, useCallback } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { X, AlertTriangle } from 'lucide-react' import { X, AlertTriangle } from 'lucide-react'
import { useLanguage } from '../i18n/LanguageContext' import { useLanguage } from '../i18n/LanguageContext'
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({ export default function Modal({
isOpen, isOpen,
onClose, onClose,
title, title,
children, children,
size = 'md', size = 'md',
// Confirmation mode props
isConfirm = false, isConfirm = false,
confirmText, confirmText,
cancelText, cancelText,
@@ -17,10 +40,11 @@ export default function Modal({
danger = false, danger = false,
}) { }) {
const { t } = useLanguage() const { t } = useLanguage()
const modalRef = useRef(null)
// Default translations
const finalConfirmText = confirmText || (danger ? t('common.delete') : t('common.save')) const finalConfirmText = confirmText || (danger ? t('common.delete') : t('common.save'))
const finalCancelText = cancelText || t('common.cancel') const finalCancelText = cancelText || t('common.cancel')
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
document.body.style.overflow = 'hidden' document.body.style.overflow = 'hidden'
@@ -30,6 +54,12 @@ export default function Modal({
return () => { document.body.style.overflow = '' } return () => { document.body.style.overflow = '' }
}, [isOpen]) }, [isOpen])
const handleKeyDown = useCallback((e) => {
if (e.key === 'Escape') onClose()
}, [onClose])
useFocusTrap(modalRef, isOpen)
if (!isOpen) return null if (!isOpen) return null
const sizeClasses = { const sizeClasses = {
@@ -39,25 +69,23 @@ export default function Modal({
xl: 'max-w-4xl', xl: 'max-w-4xl',
} }
// Confirmation dialog
if (isConfirm) { if (isConfirm) {
return createPortal( return createPortal(
<div className="fixed inset-0 z-[9999] flex items-center justify-center px-4"> <div className="fixed inset-0 z-[9999] flex items-center justify-center px-4" onKeyDown={handleKeyDown} ref={modalRef}>
{/* Backdrop */}
<div <div
className="fixed inset-0 bg-black/40 backdrop-blur-sm animate-backdrop-in" className="fixed inset-0 bg-black/40 backdrop-blur-sm animate-backdrop-in"
onClick={onClose} onClick={onClose}
aria-label="Close dialog"
/> />
{/* Modal content */} <div className="relative bg-surface rounded-2xl shadow-2xl w-full max-w-md animate-scale-in" role="dialog" aria-modal="true" aria-labelledby="modal-title">
<div className="relative bg-white rounded-2xl shadow-2xl w-full max-w-md animate-scale-in">
<div className="p-6"> <div className="p-6">
{danger && ( {danger && (
<div className="w-12 h-12 rounded-full bg-red-100 flex items-center justify-center mx-auto mb-4"> <div className="w-12 h-12 rounded-full bg-red-100 flex items-center justify-center mx-auto mb-4">
<AlertTriangle className="w-6 h-6 text-red-600" /> <AlertTriangle className="w-6 h-6 text-red-600" />
</div> </div>
)} )}
<h3 className="text-lg font-semibold text-text-primary text-center mb-2">{title}</h3> <h3 id="modal-title" className="text-lg font-semibold text-text-primary text-center mb-2">{title}</h3>
<div className="text-sm text-text-secondary text-center mb-6"> <div className="text-sm text-text-secondary text-center mb-6">
{children} {children}
</div> </div>
@@ -89,30 +117,27 @@ export default function Modal({
) )
} }
// Regular modal
return createPortal( return createPortal(
<div className="fixed inset-0 z-[9999] flex items-start justify-center pt-[10vh] px-4"> <div className="fixed inset-0 z-[9999] flex items-start justify-center pt-[10vh] px-4" onKeyDown={handleKeyDown} ref={modalRef}>
{/* Backdrop */}
<div <div
className="fixed inset-0 bg-black/40 backdrop-blur-sm animate-backdrop-in" className="fixed inset-0 bg-black/40 backdrop-blur-sm animate-backdrop-in"
onClick={onClose} onClick={onClose}
aria-label="Close dialog"
/> />
{/* Modal content */} <div className={`relative bg-surface rounded-2xl shadow-2xl w-full ${sizeClasses[size]} max-h-[80vh] overflow-y-auto animate-scale-in`} role="dialog" aria-modal="true" aria-labelledby="modal-title">
<div className={`relative bg-white rounded-2xl shadow-2xl w-full ${sizeClasses[size]} max-h-[80vh] flex flex-col animate-scale-in`}> <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">
{/* Header */} <h3 id="modal-title" className="text-lg font-semibold text-text-primary">{title}</h3>
<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>
<button <button
onClick={onClose} onClick={onClose}
className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors" className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors"
aria-label="Close dialog"
> >
<X className="w-5 h-5" /> <X className="w-5 h-5" />
</button> </button>
</div> </div>
{/* Body */} <div className="px-6 py-4">
<div className="px-6 py-4 overflow-y-auto flex-1">
{children} {children}
</div> </div>
</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 ( return (
<div <div
onClick={onClick} onClick={onClick}
className="bg-white rounded-lg p-3 border border-border hover:border-brand-primary/30 cursor-pointer transition-all group overflow-hidden card-hover" className="bg-surface rounded-lg p-3 border border-border hover:border-brand-primary/30 cursor-pointer transition-all group overflow-hidden card-hover"
> >
{post.thumbnail_url && ( {post.thumbnail_url && (
<div className="w-[calc(100%+1.5rem)] h-32 -mx-3 -mt-3 mb-2 rounded-t-lg overflow-hidden"> <div className="w-[calc(100%+1.5rem)] h-32 -mx-3 -mt-3 mb-2 rounded-t-lg overflow-hidden">
<img src={post.thumbnail_url} alt="" className="w-full h-full object-cover" /> <img src={post.thumbnail_url} alt="" className="w-full h-full object-cover" loading="lazy" />
</div> </div>
)} )}
@@ -0,0 +1,109 @@
import { Send, CheckCircle2, XCircle, Copy, Check, Clock } from 'lucide-react'
import { useLanguage } from '../i18n/LanguageContext'
import ApproverMultiSelect from './ApproverMultiSelect'
export function PostDetailApproval({
form,
update,
post,
isCreateMode,
reviewUrl,
copied,
submittingReview,
saving,
teamMembers,
onSubmitReview,
onCopyReviewLink,
onStatusAction,
}) {
const { t } = useLanguage()
return (
<div className="p-6 space-y-5 w-full">
<div className="bg-surface-secondary rounded-xl p-4">
<label className="block text-xs font-medium text-text-primary mb-2">{t('posts.approvers')}</label>
<ApproverMultiSelect
users={teamMembers || []}
selected={form.approver_ids || []}
onChange={ids => update('approver_ids', ids)}
/>
</div>
{!isCreateMode && (
<div className="space-y-4">
{/* Approval status cards */}
{form.status === 'approved' && post.approved_by_name && (
<div className="bg-emerald-50 border border-emerald-200 rounded-xl p-4 flex items-start gap-3">
<div className="w-8 h-8 rounded-full bg-emerald-100 flex items-center justify-center shrink-0 mt-0.5">
<CheckCircle2 className="w-4 h-4 text-emerald-600" />
</div>
<div>
<p className="text-sm font-semibold text-emerald-800">{t('posts.approvedBy')} {post.approved_by_name}</p>
{post.feedback && <p className="text-xs text-emerald-700 mt-1.5 leading-relaxed">{post.feedback}</p>}
</div>
</div>
)}
{form.status === 'rejected' && post.approved_by_name && (
<div className="bg-red-50 border border-red-200 rounded-xl p-4 flex items-start gap-3">
<div className="w-8 h-8 rounded-full bg-red-100 flex items-center justify-center shrink-0 mt-0.5">
<XCircle className="w-4 h-4 text-red-600" />
</div>
<div>
<p className="text-sm font-semibold text-red-800">{t('posts.rejectedBy')} {post.approved_by_name}</p>
{post.feedback && <p className="text-xs text-red-700 mt-1.5 leading-relaxed">{post.feedback}</p>}
</div>
</div>
)}
{form.status === 'in_review' && (
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4 text-center">
<div className="w-10 h-10 rounded-full bg-amber-100 flex items-center justify-center mx-auto mb-2">
<Clock className="w-5 h-5 text-amber-600" />
</div>
<p className="text-sm font-semibold text-amber-800">{t('posts.awaitingReview')}</p>
<p className="text-xs text-amber-600 mt-1">{t('posts.awaitingReviewDesc')}</p>
</div>
)}
{/* Review link */}
{reviewUrl && (
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
<div className="text-xs font-semibold text-blue-900 mb-2.5">{t('posts.reviewLinkTitle')}</div>
<div className="flex items-center gap-2">
<input type="text" value={reviewUrl} readOnly className="flex-1 px-3 py-2 text-xs bg-surface border border-blue-200 rounded-lg font-mono" />
<button onClick={onCopyReviewLink} className="p-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors shadow-sm">
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
</button>
</div>
</div>
)}
{/* Action buttons */}
<div className="flex gap-3">
{!reviewUrl && (
<button
onClick={onSubmitReview}
disabled={submittingReview}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-amber-500 text-white rounded-xl hover:bg-amber-600 transition-colors font-medium text-sm disabled:opacity-50 shadow-sm"
>
<Send className="w-4 h-4" />
{submittingReview ? t('posts.submitting') : t('posts.sendToReview')}
</button>
)}
{form.status === 'approved' && (
<button
onClick={() => onStatusAction('scheduled')}
disabled={saving}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-purple-600 text-white rounded-xl hover:bg-purple-700 transition-colors font-medium text-sm disabled:opacity-50 shadow-sm"
>
{t('posts.schedule')}
</button>
)}
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,247 @@
import { useState, useRef } from 'react'
import { X, Upload, FileText, FolderOpen, Image as ImageIcon, Music, Film } from 'lucide-react'
import { useLanguage } from '../i18n/LanguageContext'
export function PostDetailAttachments({
attachments,
uploading,
onFileUpload,
onDeleteAttachment,
onAttachAsset,
}) {
const { t } = useLanguage()
const imageInputRef = useRef(null)
const [dragActive, setDragActive] = useState(false)
const [showAssetPicker, setShowAssetPicker] = useState(false)
const [availableAssets, setAvailableAssets] = useState([])
const [assetSearch, setAssetSearch] = useState('')
const handleDrop = (e) => {
e.preventDefault(); e.stopPropagation(); setDragActive(false)
if (e.dataTransfer.files?.length) onFileUpload(e.dataTransfer.files)
}
const openAssetPicker = async () => {
const { api } = await import('../utils/api')
try {
const data = await api.get('/assets')
setAvailableAssets(Array.isArray(data) ? data : [])
} catch {
setAvailableAssets([])
}
setAssetSearch('')
setShowAssetPicker(true)
}
const handleAttachAsset = async (assetId) => {
await onAttachAsset(assetId)
setShowAssetPicker(false)
}
const images = attachments.filter(a => (a.mime_type || a.mimeType || '').startsWith('image/'))
const audio = attachments.filter(a => (a.mime_type || a.mimeType || '').startsWith('audio/'))
const videos = attachments.filter(a => (a.mime_type || a.mimeType || '').startsWith('video/'))
const others = attachments.filter(a => {
const mime = a.mime_type || a.mimeType || ''
return !mime.startsWith('image/') && !mime.startsWith('audio/') && !mime.startsWith('video/')
})
return (
<div className="space-y-4">
{/* Images */}
<div>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-1.5 text-xs font-medium text-text-secondary">
<ImageIcon className="w-3.5 h-3.5" />
{t('posts.images')}
{images.length > 0 && <span className="text-text-tertiary">({images.length})</span>}
</div>
<label className="flex items-center gap-1 px-2 py-1 text-[11px] font-medium text-brand-primary hover:bg-brand-primary/5 rounded cursor-pointer transition-colors">
<Upload className="w-3 h-3" />
{t('posts.addImage')}
<input ref={imageInputRef} type="file" multiple accept="image/*" className="hidden"
onChange={e => { onFileUpload(e.target.files); e.target.value = '' }} />
</label>
</div>
{images.length > 0 && (
<div className="grid grid-cols-3 gap-2">
{images.map(att => {
const attUrl = att.url || `/api/uploads/${att.filename}`
const name = att.original_name || att.originalName || att.filename
const attId = att.id || att._id
return (
<div key={attId} className="relative group/att border border-border rounded-lg overflow-hidden bg-surface">
<div className="h-20 relative">
<a href={attUrl} target="_blank" rel="noopener noreferrer" className="block h-full">
<img src={attUrl} alt={name} className="absolute inset-0 w-full h-full object-cover" />
</a>
<button onClick={() => onDeleteAttachment(attId)}
className="absolute top-1 end-1 p-1 bg-black/50 hover:bg-red-500 rounded-full text-white opacity-0 group-hover/att:opacity-100 transition-opacity"
title={t('common.delete')}><X className="w-2.5 h-2.5" /></button>
</div>
<div className="px-2 py-1 text-[10px] text-text-tertiary truncate border-t border-border-light">{name}</div>
</div>
)
})}
</div>
)}
</div>
{/* Audio */}
{audio.length > 0 && (
<div>
<div className="flex items-center gap-1.5 text-xs font-medium text-text-secondary mb-2">
<Music className="w-3.5 h-3.5" />
{t('posts.audio')} <span className="text-text-tertiary">({audio.length})</span>
</div>
<div className="space-y-2">
{audio.map(att => {
const attUrl = att.url || `/api/uploads/${att.filename}`
const name = att.original_name || att.originalName || att.filename
const attId = att.id || att._id
return (
<div key={attId} className="flex items-center gap-2 border border-border rounded-lg p-2 bg-surface group/att">
<Music className="w-4 h-4 text-text-tertiary shrink-0" />
<span className="text-xs text-text-secondary truncate flex-1">{name}</span>
<audio src={attUrl} controls className="h-7 max-w-[160px]" />
<button onClick={() => onDeleteAttachment(attId)}
className="p-1 text-text-tertiary hover:text-red-500 opacity-0 group-hover/att:opacity-100 transition-opacity"
title={t('common.delete')}><X className="w-3 h-3" /></button>
</div>
)
})}
</div>
</div>
)}
{/* Videos */}
{videos.length > 0 && (
<div>
<div className="flex items-center gap-1.5 text-xs font-medium text-text-secondary mb-2">
<Film className="w-3.5 h-3.5" />
{t('posts.videos')} <span className="text-text-tertiary">({videos.length})</span>
</div>
<div className="space-y-2">
{videos.map(att => {
const attUrl = att.url || `/api/uploads/${att.filename}`
const name = att.original_name || att.originalName || att.filename
const attId = att.id || att._id
return (
<div key={attId} className="border border-border rounded-lg overflow-hidden bg-surface group/att">
<video src={attUrl} controls className="w-full max-h-40" />
<div className="flex items-center justify-between px-2 py-1 border-t border-border-light">
<span className="text-[10px] text-text-tertiary truncate">{name}</span>
<button onClick={() => onDeleteAttachment(attId)}
className="p-1 text-text-tertiary hover:text-red-500 opacity-0 group-hover/att:opacity-100 transition-opacity"
title={t('common.delete')}><X className="w-3 h-3" /></button>
</div>
</div>
)
})}
</div>
</div>
)}
{/* Other files */}
{others.length > 0 && (
<div>
<div className="flex items-center gap-1.5 text-xs font-medium text-text-secondary mb-2">
<FileText className="w-3.5 h-3.5" />
{t('posts.otherFiles')} <span className="text-text-tertiary">({others.length})</span>
</div>
<div className="grid grid-cols-3 gap-2">
{others.map(att => {
const attUrl = att.url || `/api/uploads/${att.filename}`
const name = att.original_name || att.originalName || att.filename
const attId = att.id || att._id
return (
<div key={attId} className="relative group/att border border-border rounded-lg overflow-hidden bg-surface">
<a href={attUrl} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 p-3 h-16">
<FileText className="w-6 h-6 text-text-tertiary shrink-0" />
<span className="text-xs text-text-secondary truncate">{name}</span>
</a>
<button onClick={() => onDeleteAttachment(attId)}
className="absolute top-1 end-1 p-1 bg-black/50 hover:bg-red-500 rounded-full text-white opacity-0 group-hover/att:opacity-100 transition-opacity"
title={t('common.delete')}><X className="w-2.5 h-2.5" /></button>
</div>
)
})}
</div>
</div>
)}
{/* Drag and drop zone */}
<div
className={`border-2 border-dashed rounded-lg p-3 text-center cursor-pointer transition-colors ${
dragActive ? 'border-brand-primary bg-brand-primary/5' : 'border-border hover:border-brand-primary/40'
}`}
onDragEnter={e => { e.preventDefault(); setDragActive(true) }}
onDragLeave={e => { e.preventDefault(); setDragActive(false) }}
onDragOver={e => e.preventDefault()}
onDrop={handleDrop}
>
<Upload className={`w-4 h-4 text-text-tertiary mx-auto mb-1 ${uploading ? 'animate-pulse' : ''}`} />
<p className="text-[11px] text-text-secondary">
{dragActive ? t('posts.dropFiles') : t('posts.dragToUpload')}
</p>
</div>
<button
type="button"
onClick={openAssetPicker}
className="flex items-center gap-2 px-3 py-2 text-sm text-text-secondary border border-border rounded-lg hover:bg-surface-tertiary transition-colors w-full justify-center"
>
<FolderOpen className="w-4 h-4" />
{t('posts.attachFromAssets')}
</button>
{showAssetPicker && (
<div className="border border-border rounded-lg p-3 bg-surface-secondary">
<div className="flex items-center justify-between mb-2">
<p className="text-xs font-medium text-text-secondary">{t('posts.selectAssets')}</p>
<button onClick={() => setShowAssetPicker(false)} className="p-1 text-text-tertiary hover:text-text-primary">
<X className="w-3.5 h-3.5" />
</button>
</div>
<input
type="text"
value={assetSearch}
onChange={e => setAssetSearch(e.target.value)}
placeholder={t('common.search')}
className="w-full px-3 py-1.5 text-xs border border-border rounded-lg mb-2 focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
/>
<div className="grid grid-cols-4 gap-2 max-h-48 overflow-y-auto">
{availableAssets
.filter(a => !assetSearch || (a.original_name || a.filename || '').toLowerCase().includes(assetSearch.toLowerCase()))
.map(asset => {
const isImage = asset.mime_type?.startsWith('image/')
const assetUrl = `/api/uploads/${asset.filename}`
const name = asset.original_name || asset.filename
return (
<button
key={asset.id || asset._id}
onClick={() => handleAttachAsset(asset.id || asset._id)}
className="block w-full border border-border rounded-lg overflow-hidden bg-surface hover:border-brand-primary hover:ring-2 hover:ring-brand-primary/20 transition-all text-start"
>
<div className="aspect-square relative">
{isImage ? (
<img src={assetUrl} alt={name} className="absolute inset-0 w-full h-full object-cover" />
) : (
<div className="absolute inset-0 flex items-center justify-center bg-surface-tertiary">
<FileText className="w-6 h-6 text-text-tertiary" />
</div>
)}
</div>
<div className="px-1.5 py-1 text-[10px] text-text-tertiary truncate border-t border-border-light">{name}</div>
</button>
)
})}
</div>
{availableAssets.filter(a => !assetSearch || (a.original_name || a.filename || '').toLowerCase().includes(assetSearch.toLowerCase())).length === 0 && (
<p className="text-xs text-text-tertiary text-center py-4">{t('posts.noAssetsFound')}</p>
)}
</div>
)}
</div>
)
}
+62 -771
View File
@@ -1,28 +1,21 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import { X, Trash2, Upload, FileText, Link2, ExternalLink, FolderOpen, Image as ImageIcon, Music, Film, Send, CheckCircle2, XCircle, Copy, Check, Plus, Globe, Clock, User, FileEdit, Layers, Share2, ShieldCheck, MessageSquare } from 'lucide-react' import { Trash2, XCircle, FileEdit, Layers, Share2, ShieldCheck, MessageSquare } from 'lucide-react'
import { useLanguage } from '../i18n/LanguageContext' import { useLanguage } from '../i18n/LanguageContext'
import { api, PLATFORMS, getBrandColor } from '../utils/api' import { api, getBrandColor } from '../utils/api'
import ApproverMultiSelect from './ApproverMultiSelect'
import CommentsSection from './CommentsSection' import CommentsSection from './CommentsSection'
import Modal from './Modal' import Modal from './Modal'
import TabbedModal from './TabbedModal' import TabbedModal from './TabbedModal'
import { useToast } from './ToastContainer' import { useToast } from './ToastContainer'
import { PostDetailVersions } from './PostDetailVersions'
const AVAILABLE_LANGUAGES = [ import { PostDetailPlatforms } from './PostDetailPlatforms'
{ code: 'ar', label: 'Arabic' }, import { PostDetailApproval } from './PostDetailApproval'
{ code: 'en', label: 'English' }, import { PostDetailAttachments } from './PostDetailAttachments'
{ code: 'fr', label: 'French' },
{ code: 'id', label: 'Bahasa Indonesia' },
]
const TABS = ['details', 'versions', 'platforms', 'approval', 'discussion'] const TABS = ['details', 'versions', 'platforms', 'approval', 'discussion']
export default function PostDetailPanel({ post, onClose, onSave, onDelete, brands, teamMembers, campaigns }) { export default function PostDetailPanel({ post, onClose, onSave, onDelete, brands, teamMembers, campaigns }) {
const { t, lang } = useLanguage() const { t, lang } = useLanguage()
const toast = useToast() const toast = useToast()
const imageInputRef = useRef(null)
const audioInputRef = useRef(null)
const videoInputRef = useRef(null)
const versionFileInputRef = useRef(null) const versionFileInputRef = useRef(null)
const [activeTab, setActiveTab] = useState('details') const [activeTab, setActiveTab] = useState('details')
const [form, setForm] = useState({}) const [form, setForm] = useState({})
@@ -38,24 +31,11 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
// Attachments state (non-versioned, legacy) // Attachments state (non-versioned, legacy)
const [attachments, setAttachments] = useState([]) const [attachments, setAttachments] = useState([])
const [uploading, setUploading] = useState(false) const [uploading, setUploading] = useState(false)
const [dragActive, setDragActive] = useState(false)
const [showAssetPicker, setShowAssetPicker] = useState(false)
const [availableAssets, setAvailableAssets] = useState([])
const [assetSearch, setAssetSearch] = useState('')
// Versions state // Versions state
const [versions, setVersions] = useState([]) const [versions, setVersions] = useState([])
const [selectedVersion, setSelectedVersion] = useState(null) const [selectedVersion, setSelectedVersion] = useState(null)
const [versionData, setVersionData] = useState(null) const [versionData, setVersionData] = useState(null)
const [showNewVersionModal, setShowNewVersionModal] = useState(false)
const [newVersionNotes, setNewVersionNotes] = useState('')
const [copyFromPrevious, setCopyFromPrevious] = useState(false)
const [creatingVersion, setCreatingVersion] = useState(false)
const [showLanguageModal, setShowLanguageModal] = useState(false)
const [languageForm, setLanguageForm] = useState({ language_code: '', language_label: '', content: '' })
const [savingLanguage, setSavingLanguage] = useState(false)
const [confirmDeleteLangId, setConfirmDeleteLangId] = useState(null)
const [confirmDeleteAttId, setConfirmDeleteAttId] = useState(null)
const [uploadingVersionFile, setUploadingVersionFile] = useState(false) const [uploadingVersionFile, setUploadingVersionFile] = useState(false)
const postId = post?._id || post?.id const postId = post?._id || post?.id
@@ -136,6 +116,7 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
} }
if (data.status === 'published' && data.platforms.length > 0) { if (data.status === 'published' && data.platforms.length > 0) {
const { PLATFORMS } = await import('../utils/api')
const missingPlatforms = data.platforms.filter(platform => { const missingPlatforms = data.platforms.filter(platform => {
const link = (data.publication_links || []).find(l => l.platform === platform) const link = (data.publication_links || []).find(l => l.platform === platform)
return !link || !link.url || !link.url.trim() return !link || !link.url || !link.url.trim()
@@ -237,33 +218,16 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
} }
} }
const openAssetPicker = async () => {
try {
const data = await api.get('/assets')
setAvailableAssets(Array.isArray(data) ? data : [])
} catch {
setAvailableAssets([])
}
setAssetSearch('')
setShowAssetPicker(true)
}
const handleAttachAsset = async (assetId) => { const handleAttachAsset = async (assetId) => {
if (!postId) return if (!postId) return
try { try {
await api.post(`/posts/${postId}/attachments/from-asset`, { asset_id: assetId }) await api.post(`/posts/${postId}/attachments/from-asset`, { asset_id: assetId })
loadAttachments() loadAttachments()
setShowAssetPicker(false)
} catch (err) { } catch (err) {
console.error('Attach asset failed:', err) console.error('Attach asset failed:', err)
} }
} }
const handleDrop = (e) => {
e.preventDefault(); e.stopPropagation(); setDragActive(false)
if (e.dataTransfer.files?.length) handleFileUpload(e.dataTransfer.files)
}
// ─── Versions ────────────────────────── // ─── Versions ──────────────────────────
async function loadVersions() { async function loadVersions() {
if (!postId) return if (!postId) return
@@ -299,44 +263,28 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
loadVersionData(version.Id || version.id || version._id) loadVersionData(version.Id || version.id || version._id)
} }
const handleCreateVersion = async () => { const handleCreateVersion = async ({ notes, copy_from_previous }) => {
setCreatingVersion(true)
try { try {
await api.post(`/posts/${postId}/versions`, { await api.post(`/posts/${postId}/versions`, {
notes: newVersionNotes || undefined, notes: notes || undefined,
copy_from_previous: copyFromPrevious, copy_from_previous,
}) })
setShowNewVersionModal(false)
setNewVersionNotes('')
setCopyFromPrevious(false)
loadVersions() loadVersions()
} catch (err) { } catch (err) {
console.error('Create version failed:', err) console.error('Create version failed:', err)
} finally {
setCreatingVersion(false)
} }
} }
const handleAddLanguage = async () => { const handleAddLanguage = async (languageForm) => {
if (!selectedVersion || !languageForm.language_code || !languageForm.content) return if (!selectedVersion) return
setSavingLanguage(true) const vId = selectedVersion.Id || selectedVersion.id || selectedVersion._id
try { await api.post(`/posts/${postId}/versions/${vId}/texts`, languageForm)
const vId = selectedVersion.Id || selectedVersion.id || selectedVersion._id loadVersionData(vId)
await api.post(`/posts/${postId}/versions/${vId}/texts`, languageForm)
setShowLanguageModal(false)
setLanguageForm({ language_code: '', language_label: '', content: '' })
loadVersionData(vId)
} catch (err) {
console.error('Add language failed:', err)
} finally {
setSavingLanguage(false)
}
} }
const handleDeleteLanguage = async (textId) => { const handleDeleteLanguage = async (textId) => {
try { try {
await api.delete(`/post-version-texts/${textId}`) await api.delete(`/post-version-texts/${textId}`)
setConfirmDeleteLangId(null)
const vId = selectedVersion.Id || selectedVersion.id || selectedVersion._id const vId = selectedVersion.Id || selectedVersion.id || selectedVersion._id
loadVersionData(vId) loadVersionData(vId)
} catch (err) { } catch (err) {
@@ -364,7 +312,6 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
const handleDeleteVersionAttachment = async (attId) => { const handleDeleteVersionAttachment = async (attId) => {
try { try {
await api.delete(`/attachments/${attId}`) await api.delete(`/attachments/${attId}`)
setConfirmDeleteAttId(null)
const vId = selectedVersion.Id || selectedVersion.id || selectedVersion._id const vId = selectedVersion.Id || selectedVersion.id || selectedVersion._id
loadVersionData(vId) loadVersionData(vId)
} catch (err) { } catch (err) {
@@ -409,7 +356,7 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
form.status === 'approved' ? 'bg-blue-100 text-blue-700' : form.status === 'approved' ? 'bg-blue-100 text-blue-700' :
form.status === 'in_review' ? 'bg-amber-100 text-amber-700' : form.status === 'in_review' ? 'bg-amber-100 text-amber-700' :
form.status === 'rejected' ? 'bg-red-100 text-red-700' : form.status === 'rejected' ? 'bg-red-100 text-red-700' :
'bg-gray-100 text-gray-600' 'bg-gray-100 text-text-secondary'
}`}> }`}>
{statusOptions.find(s => s.value === form.status)?.label} {statusOptions.find(s => s.value === form.status)?.label}
</span> </span>
@@ -498,7 +445,7 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
value={form.description} value={form.description}
onChange={e => update('description', e.target.value)} onChange={e => update('description', e.target.value)}
rows={4} rows={4}
className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary resize-none" className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary resize-none"
placeholder={t('posts.postDescPlaceholder')} placeholder={t('posts.postDescPlaceholder')}
/> />
</div> </div>
@@ -508,7 +455,7 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
type="text" type="text"
value={form.notes} value={form.notes}
onChange={e => update('notes', e.target.value)} onChange={e => update('notes', e.target.value)}
className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary" className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
placeholder={t('posts.additionalNotes')} placeholder={t('posts.additionalNotes')}
/> />
</div> </div>
@@ -532,7 +479,13 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
</span> </span>
)} )}
</div> </div>
{renderAttachments()} <PostDetailAttachments
attachments={attachments}
uploading={uploading}
onFileUpload={handleFileUpload}
onDeleteAttachment={handleDeleteAttachment}
onAttachAsset={handleAttachAsset}
/>
</div> </div>
)} )}
</div> </div>
@@ -545,7 +498,7 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
<select <select
value={form.status} value={form.status}
onChange={e => update('status', e.target.value)} onChange={e => update('status', e.target.value)}
className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary" className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
> >
{statusOptions.map(s => <option key={s.value} value={s.value}>{s.label}</option>)} {statusOptions.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
</select> </select>
@@ -556,7 +509,7 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
type="date" type="date"
value={form.scheduled_date} value={form.scheduled_date}
onChange={e => update('scheduled_date', e.target.value)} onChange={e => update('scheduled_date', e.target.value)}
className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary" className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
/> />
</div> </div>
<div> <div>
@@ -564,7 +517,7 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
<select <select
value={form.assigned_to} value={form.assigned_to}
onChange={e => update('assigned_to', e.target.value)} onChange={e => update('assigned_to', e.target.value)}
className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary" className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
> >
<option value="">{t('common.unassigned')}</option> <option value="">{t('common.unassigned')}</option>
{(teamMembers || []).map(m => <option key={m._id || m.id} value={m._id || m.id}>{m.name}</option>)} {(teamMembers || []).map(m => <option key={m._id || m.id} value={m._id || m.id}>{m.name}</option>)}
@@ -578,7 +531,7 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
<select <select
value={form.brand_id} value={form.brand_id}
onChange={e => update('brand_id', e.target.value)} onChange={e => update('brand_id', e.target.value)}
className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary" className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
> >
<option value="">{t('posts.selectBrand')}</option> <option value="">{t('posts.selectBrand')}</option>
{(brands || []).map(b => <option key={b._id || b.id} value={b._id || b.id}>{b.icon} {lang === 'ar' && b.name_ar ? b.name_ar : b.name}</option>)} {(brands || []).map(b => <option key={b._id || b.id} value={b._id || b.id}>{b.icon} {lang === 'ar' && b.name_ar ? b.name_ar : b.name}</option>)}
@@ -589,7 +542,7 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
<select <select
value={form.campaign_id} value={form.campaign_id}
onChange={e => update('campaign_id', e.target.value)} onChange={e => update('campaign_id', e.target.value)}
className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary" className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
> >
<option value="">{t('posts.noCampaign')}</option> <option value="">{t('posts.noCampaign')}</option>
{(campaigns || []).map(c => <option key={c._id || c.id} value={c._id || c.id}>{c.name}</option>)} {(campaigns || []).map(c => <option key={c._id || c.id} value={c._id || c.id}>{c.name}</option>)}
@@ -603,395 +556,46 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
{/* ─── Versions Tab ─── */} {/* ─── Versions Tab ─── */}
{activeTab === 'versions' && !isCreateMode && ( {activeTab === 'versions' && !isCreateMode && (
<div className="flex h-full"> <PostDetailVersions
{/* Version Timeline (left sidebar) */} versions={versions}
<div className="w-64 shrink-0 border-e border-border p-4 overflow-y-auto bg-surface-secondary/50"> selectedVersion={selectedVersion}
<div className="flex items-center justify-between mb-4"> versionData={versionData}
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wide">{t('posts.versions')}</h4> onSelectVersion={handleSelectVersion}
<button onCreateVersion={handleCreateVersion}
onClick={() => setShowNewVersionModal(true)} onAddLanguage={handleAddLanguage}
className="flex items-center gap-1 px-2.5 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors shadow-sm" onDeleteLanguage={handleDeleteLanguage}
> onVersionFileUpload={handleVersionFileUpload}
<Plus className="w-3 h-3" /> onDeleteVersionAttachment={handleDeleteVersionAttachment}
{t('posts.newVersion')} uploadingVersionFile={uploadingVersionFile}
</button> versionFileInputRef={versionFileInputRef}
</div> />
{versions.length === 0 ? (
<div className="text-center py-10">
<div className="w-12 h-12 rounded-full bg-surface-tertiary flex items-center justify-center mx-auto mb-3">
<Layers className="w-6 h-6 text-text-quaternary" />
</div>
<p className="text-xs text-text-tertiary leading-relaxed px-2">{t('posts.noVersions')}</p>
</div>
) : (
<div className="space-y-1.5">
{versions.map((version, idx) => {
const vId = version.Id || version.id || version._id
const isActive = vId === (selectedVersion?.Id || selectedVersion?.id || selectedVersion?._id)
const isLatest = idx === versions.length - 1
return (
<button
key={vId}
onClick={() => handleSelectVersion(version)}
className={`w-full text-start p-3 rounded-xl border transition-all ${
isActive
? 'border-brand-primary bg-white shadow-sm ring-1 ring-brand-primary/20'
: 'border-transparent hover:bg-white hover:border-border'
}`}
>
<div className="flex items-center gap-2.5">
<div className={`flex-shrink-0 w-7 h-7 rounded-lg flex items-center justify-center text-[11px] font-bold ${
isActive ? 'bg-brand-primary text-white' : 'bg-surface-tertiary text-text-secondary'
}`}>
{version.version_number}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className={`text-sm font-medium ${isActive ? 'text-brand-primary' : 'text-text-primary'}`}>
v{version.version_number}
</span>
{isLatest && (
<span className="text-[9px] px-1.5 py-px bg-emerald-100 text-emerald-700 rounded font-semibold uppercase">
Latest
</span>
)}
</div>
{version.notes && (
<p className="text-[11px] text-text-tertiary line-clamp-1 mt-0.5">{version.notes}</p>
)}
</div>
</div>
{(version.creator_name || version.created_at) && (
<div className="flex items-center gap-2 mt-2 ms-[38px] text-[10px] text-text-quaternary">
{version.creator_name && <span>{version.creator_name}</span>}
{version.creator_name && version.created_at && <span>·</span>}
{version.created_at && <span>{new Date(version.created_at).toLocaleDateString()}</span>}
</div>
)}
</button>
)
})}
</div>
)}
</div>
{/* Version Content (right side) */}
<div className="flex-1 min-w-0 overflow-y-auto p-6">
{selectedVersion && versionData ? (
<div className="space-y-6 w-full">
{/* Languages */}
<div>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Globe className="w-4 h-4 text-text-tertiary" />
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wide">{t('posts.languages')}</h4>
{versionData.texts?.length > 0 && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-surface-tertiary text-text-tertiary font-medium">
{versionData.texts.length}
</span>
)}
</div>
<button
onClick={() => setShowLanguageModal(true)}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-brand-primary border border-brand-primary/30 hover:bg-brand-primary/5 rounded-lg transition-colors"
>
<Plus className="w-3 h-3" />
{t('posts.addLanguage')}
</button>
</div>
{versionData.texts && versionData.texts.length > 0 ? (
<div className="space-y-3">
{versionData.texts.map(text => {
const tId = text.Id || text.id || text._id
return (
<div key={tId} className="rounded-xl border border-border overflow-hidden">
<div className="flex items-center justify-between px-4 py-2.5 bg-surface-secondary">
<div className="flex items-center gap-2">
<span className="px-2 py-0.5 bg-white border border-border rounded text-[11px] font-semibold uppercase text-text-secondary">{text.language_code}</span>
<span className="text-sm font-medium text-text-primary">{text.language_label}</span>
</div>
<button
onClick={() => setConfirmDeleteLangId(tId)}
className="p-1.5 text-text-quaternary hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
<div className="px-4 py-3 text-sm whitespace-pre-wrap text-text-primary leading-relaxed" dir={text.language_code === 'ar' ? 'rtl' : 'ltr'}>
{text.content}
</div>
</div>
)
})}
</div>
) : (
<div className="text-center py-8 rounded-xl border-2 border-dashed border-border">
<Globe className="w-8 h-8 text-text-quaternary mx-auto mb-2" />
<p className="text-sm text-text-tertiary">{t('posts.noLanguages')}</p>
<button
onClick={() => setShowLanguageModal(true)}
className="mt-3 text-xs font-medium text-brand-primary hover:underline"
>
{t('posts.addLanguage')}
</button>
</div>
)}
</div>
{/* Media / Attachments for this version */}
<div>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<ImageIcon className="w-4 h-4 text-text-tertiary" />
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wide">{t('posts.media')}</h4>
{versionData.attachments?.length > 0 && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-surface-tertiary text-text-tertiary font-medium">
{versionData.attachments.length}
</span>
)}
</div>
<label className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-brand-primary border border-brand-primary/30 hover:bg-brand-primary/5 rounded-lg cursor-pointer transition-colors">
<Upload className="w-3 h-3" />
{uploadingVersionFile ? t('posts.uploading') : t('posts.addImage')}
<input
ref={versionFileInputRef}
type="file"
multiple
className="hidden"
onChange={e => { handleVersionFileUpload(e.target.files); e.target.value = '' }}
disabled={uploadingVersionFile}
/>
</label>
</div>
{versionData.attachments && versionData.attachments.length > 0 ? (
<div className="grid grid-cols-2 gap-3">
{versionData.attachments.map(att => {
const attId = att.Id || att.id || att._id
const attUrl = att.url || `/api/uploads/${att.filename}`
const name = att.original_name || att.filename
const mime = att.mime_type || ''
const isImage = mime.startsWith('image/')
const isVideo = mime.startsWith('video/')
return (
<div key={attId} className="relative group rounded-xl border border-border overflow-hidden bg-white hover:shadow-md transition-shadow">
{isImage ? (
<a href={attUrl} target="_blank" rel="noopener noreferrer">
<img src={attUrl} alt={name} className="w-full h-44 object-cover" />
</a>
) : isVideo ? (
<video src={attUrl} controls className="w-full h-44 object-cover" />
) : (
<a href={attUrl} target="_blank" rel="noopener noreferrer" className="flex items-center justify-center h-44 bg-surface-tertiary">
<FileText className="w-10 h-10 text-text-quaternary" />
</a>
)}
<div className="flex items-center justify-between px-3 py-2 border-t border-border bg-surface-secondary/50">
<span className="text-[11px] text-text-secondary truncate">{name}</span>
<button
onClick={() => setConfirmDeleteAttId(attId)}
className="p-1 text-text-quaternary hover:text-red-500 opacity-0 group-hover:opacity-100 transition-all"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
)
})}
</div>
) : (
<div className="text-center py-8 rounded-xl border-2 border-dashed border-border">
<ImageIcon className="w-8 h-8 text-text-quaternary mx-auto mb-2" />
<p className="text-sm text-text-tertiary">{t('posts.noMedia')}</p>
</div>
)}
</div>
</div>
) : versions.length > 0 ? (
<div className="flex items-center justify-center h-40">
<div className="w-6 h-6 border-2 border-brand-primary border-t-transparent rounded-full animate-spin" />
</div>
) : null}
</div>
</div>
)} )}
{/* ─── Platforms & Links Tab ─── */} {/* ─── Platforms & Links Tab ─── */}
{activeTab === 'platforms' && ( {activeTab === 'platforms' && (
<div className="p-6 space-y-6 w-full"> <PostDetailPlatforms
<div> form={form}
<div className="flex items-center gap-2 mb-3"> update={update}
<Share2 className="w-4 h-4 text-text-tertiary" /> updatePublicationLink={updatePublicationLink}
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wide">{t('posts.platforms')}</h4> />
</div>
<div className="flex flex-wrap gap-2 p-3 rounded-xl bg-surface-secondary min-h-[44px]">
{Object.entries(PLATFORMS).map(([k, v]) => {
const checked = (form.platforms || []).includes(k)
return (
<label
key={k}
className={`flex items-center gap-1.5 text-xs px-3 py-2 rounded-lg cursor-pointer border transition-all ${
checked
? 'bg-white border-brand-primary/30 text-brand-primary font-medium shadow-sm'
: 'bg-white/50 border-transparent text-text-secondary hover:bg-white hover:shadow-sm'
}`}
>
<input
type="checkbox"
checked={checked}
onChange={() => {
update('platforms', checked
? form.platforms.filter(p => p !== k)
: [...(form.platforms || []), k]
)
}}
className="sr-only"
/>
<span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: v.color || '#888' }} />
{v.label}
</label>
)
})}
</div>
</div>
{(form.platforms || []).length > 0 && (
<div>
<div className="flex items-center gap-2 mb-3">
<Link2 className="w-4 h-4 text-text-tertiary" />
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wide">{t('posts.publicationLinks')}</h4>
</div>
<div className="space-y-2.5">
{(form.platforms || []).map(platformKey => {
const platformInfo = PLATFORMS[platformKey] || { label: platformKey }
const existingLink = (form.publication_links || []).find(l => l.platform === platformKey)
const linkUrl = existingLink?.url || ''
return (
<div key={platformKey} className="flex items-center gap-3 p-3 rounded-xl bg-surface-secondary">
<span className="text-xs font-medium text-text-primary w-28 shrink-0 flex items-center gap-2">
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: platformInfo.color || '#888' }} />
{platformInfo.label}
</span>
<input
type="url"
value={linkUrl}
onChange={e => updatePublicationLink(platformKey, e.target.value)}
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
placeholder="https://..."
/>
{linkUrl && (
<a href={linkUrl} target="_blank" rel="noopener noreferrer" className="p-2 text-text-tertiary hover:text-brand-primary hover:bg-white rounded-lg transition-colors">
<ExternalLink className="w-4 h-4" />
</a>
)}
</div>
)
})}
</div>
{form.status === 'published' && (form.platforms || []).some(p => {
const link = (form.publication_links || []).find(l => l.platform === p)
return !link || !link.url?.trim()
}) && (
<p className="text-xs text-amber-600 mt-3 flex items-center gap-1.5">
<XCircle className="w-3.5 h-3.5" />
{t('posts.publishRequired')}
</p>
)}
</div>
)}
</div>
)} )}
{/* ─── Approval Tab ─── */} {/* ─── Approval Tab ─── */}
{activeTab === 'approval' && ( {activeTab === 'approval' && (
<div className="p-6 space-y-5 w-full"> <PostDetailApproval
<div className="bg-surface-secondary rounded-xl p-4"> form={form}
<label className="block text-xs font-medium text-text-primary mb-2">{t('posts.approvers')}</label> update={update}
<ApproverMultiSelect post={post}
users={teamMembers || []} isCreateMode={isCreateMode}
selected={form.approver_ids || []} reviewUrl={reviewUrl}
onChange={ids => update('approver_ids', ids)} copied={copied}
/> submittingReview={submittingReview}
</div> saving={saving}
teamMembers={teamMembers}
{!isCreateMode && ( onSubmitReview={handleSubmitReview}
<div className="space-y-4"> onCopyReviewLink={copyReviewLink}
{/* Approval status cards */} onStatusAction={handleStatusAction}
{form.status === 'approved' && post.approved_by_name && ( />
<div className="bg-emerald-50 border border-emerald-200 rounded-xl p-4 flex items-start gap-3">
<div className="w-8 h-8 rounded-full bg-emerald-100 flex items-center justify-center shrink-0 mt-0.5">
<CheckCircle2 className="w-4 h-4 text-emerald-600" />
</div>
<div>
<p className="text-sm font-semibold text-emerald-800">{t('posts.approvedBy')} {post.approved_by_name}</p>
{post.feedback && <p className="text-xs text-emerald-700 mt-1.5 leading-relaxed">{post.feedback}</p>}
</div>
</div>
)}
{form.status === 'rejected' && post.approved_by_name && (
<div className="bg-red-50 border border-red-200 rounded-xl p-4 flex items-start gap-3">
<div className="w-8 h-8 rounded-full bg-red-100 flex items-center justify-center shrink-0 mt-0.5">
<XCircle className="w-4 h-4 text-red-600" />
</div>
<div>
<p className="text-sm font-semibold text-red-800">{t('posts.rejectedBy')} {post.approved_by_name}</p>
{post.feedback && <p className="text-xs text-red-700 mt-1.5 leading-relaxed">{post.feedback}</p>}
</div>
</div>
)}
{form.status === 'in_review' && (
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4 text-center">
<div className="w-10 h-10 rounded-full bg-amber-100 flex items-center justify-center mx-auto mb-2">
<Clock className="w-5 h-5 text-amber-600" />
</div>
<p className="text-sm font-semibold text-amber-800">{t('posts.awaitingReview')}</p>
<p className="text-xs text-amber-600 mt-1">{t('posts.awaitingReviewDesc')}</p>
</div>
)}
{/* Review link */}
{reviewUrl && (
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
<div className="text-xs font-semibold text-blue-900 mb-2.5">{t('posts.reviewLinkTitle')}</div>
<div className="flex items-center gap-2">
<input type="text" value={reviewUrl} readOnly className="flex-1 px-3 py-2 text-xs bg-white border border-blue-200 rounded-lg font-mono" />
<button onClick={copyReviewLink} className="p-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors shadow-sm">
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
</button>
</div>
</div>
)}
{/* Action buttons */}
<div className="flex gap-3">
{!reviewUrl && (
<button
onClick={handleSubmitReview}
disabled={submittingReview}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-amber-500 text-white rounded-xl hover:bg-amber-600 transition-colors font-medium text-sm disabled:opacity-50 shadow-sm"
>
<Send className="w-4 h-4" />
{submittingReview ? t('posts.submitting') : t('posts.sendToReview')}
</button>
)}
{form.status === 'approved' && (
<button
onClick={() => handleStatusAction('scheduled')}
disabled={saving}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-purple-600 text-white rounded-xl hover:bg-purple-700 transition-colors font-medium text-sm disabled:opacity-50 shadow-sm"
>
{t('posts.schedule')}
</button>
)}
</div>
</div>
)}
</div>
)} )}
{/* ─── Discussion Tab ─── */} {/* ─── Discussion Tab ─── */}
@@ -1014,319 +618,6 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
> >
{t('posts.deleteConfirm')} {t('posts.deleteConfirm')}
</Modal> </Modal>
{/* New Version Modal */}
<Modal
isOpen={showNewVersionModal}
onClose={() => { setShowNewVersionModal(false); setNewVersionNotes(''); setCopyFromPrevious(false) }}
title={t('posts.createNewVersion')}
size="sm"
>
<div className="space-y-4">
<textarea
value={newVersionNotes}
onChange={e => setNewVersionNotes(e.target.value)}
rows={3}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary resize-none"
placeholder={t('posts.whatChanged')}
/>
{versions.length > 0 && (
<label className="flex items-center gap-2 text-sm text-text-secondary cursor-pointer">
<input
type="checkbox"
checked={copyFromPrevious}
onChange={e => setCopyFromPrevious(e.target.checked)}
className="rounded border-border text-brand-primary focus:ring-brand-primary/20"
/>
{t('posts.copyLanguages')}
</label>
)}
<button
onClick={handleCreateVersion}
disabled={creatingVersion}
className="w-full px-4 py-2.5 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 shadow-sm"
>
{creatingVersion ? t('posts.creatingVersion') : t('posts.createVersion')}
</button>
</div>
</Modal>
{/* Add Language Modal */}
<Modal
isOpen={showLanguageModal}
onClose={() => { setShowLanguageModal(false); setLanguageForm({ language_code: '', language_label: '', content: '' }) }}
title={t('posts.addLanguage')}
size="md"
>
<div className="space-y-4">
<select
value={languageForm.language_code}
onChange={e => {
const lang = AVAILABLE_LANGUAGES.find(l => l.code === e.target.value)
setLanguageForm(f => ({ ...f, language_code: e.target.value, language_label: lang?.label || e.target.value }))
}}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
<option value="">{t('posts.selectLanguage')}</option>
{AVAILABLE_LANGUAGES
.filter(lang => !(versionData?.texts || []).some(t => t.language_code === lang.code))
.map(lang => (
<option key={lang.code} value={lang.code}>{lang.label} ({lang.code.toUpperCase()})</option>
))}
</select>
<textarea
value={languageForm.content}
onChange={e => setLanguageForm(f => ({ ...f, content: e.target.value }))}
rows={8}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary resize-none"
placeholder={t('posts.enterContent')}
dir={languageForm.language_code === 'ar' ? 'rtl' : 'ltr'}
/>
<button
onClick={handleAddLanguage}
disabled={savingLanguage || !languageForm.language_code || !languageForm.content}
className="w-full px-4 py-2.5 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 shadow-sm"
>
{savingLanguage ? t('common.loading') : t('common.save')}
</button>
</div>
</Modal>
{/* Delete Language Confirmation */}
<Modal
isOpen={!!confirmDeleteLangId}
onClose={() => setConfirmDeleteLangId(null)}
title={t('posts.deleteLanguage')}
isConfirm
danger
confirmText={t('common.delete')}
onConfirm={() => handleDeleteLanguage(confirmDeleteLangId)}
>
{t('posts.deleteLanguageConfirm')}
</Modal>
{/* Delete Version Attachment Confirmation */}
<Modal
isOpen={!!confirmDeleteAttId}
onClose={() => setConfirmDeleteAttId(null)}
title={t('posts.deleteAttachment')}
isConfirm
danger
confirmText={t('common.delete')}
onConfirm={() => handleDeleteVersionAttachment(confirmDeleteAttId)}
>
{t('posts.deleteConfirm')}
</Modal>
</> </>
) )
// ─── Render legacy attachments helper ──────────────────────────
function renderAttachments() {
const images = attachments.filter(a => (a.mime_type || a.mimeType || '').startsWith('image/'))
const audio = attachments.filter(a => (a.mime_type || a.mimeType || '').startsWith('audio/'))
const videos = attachments.filter(a => (a.mime_type || a.mimeType || '').startsWith('video/'))
const others = attachments.filter(a => {
const mime = a.mime_type || a.mimeType || ''
return !mime.startsWith('image/') && !mime.startsWith('audio/') && !mime.startsWith('video/')
})
return (
<div className="space-y-4">
{/* Images */}
<div>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-1.5 text-xs font-medium text-text-secondary">
<ImageIcon className="w-3.5 h-3.5" />
{t('posts.images')}
{images.length > 0 && <span className="text-text-tertiary">({images.length})</span>}
</div>
<label className="flex items-center gap-1 px-2 py-1 text-[11px] font-medium text-brand-primary hover:bg-brand-primary/5 rounded cursor-pointer transition-colors">
<Upload className="w-3 h-3" />
{t('posts.addImage')}
<input ref={imageInputRef} type="file" multiple accept="image/*" className="hidden"
onChange={e => { handleFileUpload(e.target.files); e.target.value = '' }} />
</label>
</div>
{images.length > 0 && (
<div className="grid grid-cols-3 gap-2">
{images.map(att => {
const attUrl = att.url || `/api/uploads/${att.filename}`
const name = att.original_name || att.originalName || att.filename
const attId = att.id || att._id
return (
<div key={attId} className="relative group/att border border-border rounded-lg overflow-hidden bg-white">
<div className="h-20 relative">
<a href={attUrl} target="_blank" rel="noopener noreferrer" className="block h-full">
<img src={attUrl} alt={name} className="absolute inset-0 w-full h-full object-cover" />
</a>
<button onClick={() => handleDeleteAttachment(attId)}
className="absolute top-1 right-1 p-1 bg-black/50 hover:bg-red-500 rounded-full text-white opacity-0 group-hover/att:opacity-100 transition-opacity"
title={t('common.delete')}><X className="w-2.5 h-2.5" /></button>
</div>
<div className="px-2 py-1 text-[10px] text-text-tertiary truncate border-t border-border-light">{name}</div>
</div>
)
})}
</div>
)}
</div>
{/* Audio */}
{audio.length > 0 && (
<div>
<div className="flex items-center gap-1.5 text-xs font-medium text-text-secondary mb-2">
<Music className="w-3.5 h-3.5" />
{t('posts.audio')} <span className="text-text-tertiary">({audio.length})</span>
</div>
<div className="space-y-2">
{audio.map(att => {
const attUrl = att.url || `/api/uploads/${att.filename}`
const name = att.original_name || att.originalName || att.filename
const attId = att.id || att._id
return (
<div key={attId} className="flex items-center gap-2 border border-border rounded-lg p-2 bg-white group/att">
<Music className="w-4 h-4 text-text-tertiary shrink-0" />
<span className="text-xs text-text-secondary truncate flex-1">{name}</span>
<audio src={attUrl} controls className="h-7 max-w-[160px]" />
<button onClick={() => handleDeleteAttachment(attId)}
className="p-1 text-text-tertiary hover:text-red-500 opacity-0 group-hover/att:opacity-100 transition-opacity"
title={t('common.delete')}><X className="w-3 h-3" /></button>
</div>
)
})}
</div>
</div>
)}
{/* Videos */}
{videos.length > 0 && (
<div>
<div className="flex items-center gap-1.5 text-xs font-medium text-text-secondary mb-2">
<Film className="w-3.5 h-3.5" />
{t('posts.videos')} <span className="text-text-tertiary">({videos.length})</span>
</div>
<div className="space-y-2">
{videos.map(att => {
const attUrl = att.url || `/api/uploads/${att.filename}`
const name = att.original_name || att.originalName || att.filename
const attId = att.id || att._id
return (
<div key={attId} className="border border-border rounded-lg overflow-hidden bg-white group/att">
<video src={attUrl} controls className="w-full max-h-40" />
<div className="flex items-center justify-between px-2 py-1 border-t border-border-light">
<span className="text-[10px] text-text-tertiary truncate">{name}</span>
<button onClick={() => handleDeleteAttachment(attId)}
className="p-1 text-text-tertiary hover:text-red-500 opacity-0 group-hover/att:opacity-100 transition-opacity"
title={t('common.delete')}><X className="w-3 h-3" /></button>
</div>
</div>
)
})}
</div>
</div>
)}
{/* Other files */}
{others.length > 0 && (
<div>
<div className="flex items-center gap-1.5 text-xs font-medium text-text-secondary mb-2">
<FileText className="w-3.5 h-3.5" />
{t('posts.otherFiles')} <span className="text-text-tertiary">({others.length})</span>
</div>
<div className="grid grid-cols-3 gap-2">
{others.map(att => {
const attUrl = att.url || `/api/uploads/${att.filename}`
const name = att.original_name || att.originalName || att.filename
const attId = att.id || att._id
return (
<div key={attId} className="relative group/att border border-border rounded-lg overflow-hidden bg-white">
<a href={attUrl} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 p-3 h-16">
<FileText className="w-6 h-6 text-text-tertiary shrink-0" />
<span className="text-xs text-text-secondary truncate">{name}</span>
</a>
<button onClick={() => handleDeleteAttachment(attId)}
className="absolute top-1 right-1 p-1 bg-black/50 hover:bg-red-500 rounded-full text-white opacity-0 group-hover/att:opacity-100 transition-opacity"
title={t('common.delete')}><X className="w-2.5 h-2.5" /></button>
</div>
)
})}
</div>
</div>
)}
{/* Drag and drop zone */}
<div
className={`border-2 border-dashed rounded-lg p-3 text-center cursor-pointer transition-colors ${
dragActive ? 'border-brand-primary bg-brand-primary/5' : 'border-border hover:border-brand-primary/40'
}`}
onDragEnter={e => { e.preventDefault(); setDragActive(true) }}
onDragLeave={e => { e.preventDefault(); setDragActive(false) }}
onDragOver={e => e.preventDefault()}
onDrop={handleDrop}
>
<Upload className={`w-4 h-4 text-text-tertiary mx-auto mb-1 ${uploading ? 'animate-pulse' : ''}`} />
<p className="text-[11px] text-text-secondary">
{dragActive ? t('posts.dropFiles') : t('posts.dragToUpload')}
</p>
</div>
<button
type="button"
onClick={openAssetPicker}
className="flex items-center gap-2 px-3 py-2 text-sm text-text-secondary border border-border rounded-lg hover:bg-surface-tertiary transition-colors w-full justify-center"
>
<FolderOpen className="w-4 h-4" />
{t('posts.attachFromAssets')}
</button>
{showAssetPicker && (
<div className="border border-border rounded-lg p-3 bg-surface-secondary">
<div className="flex items-center justify-between mb-2">
<p className="text-xs font-medium text-text-secondary">{t('posts.selectAssets')}</p>
<button onClick={() => setShowAssetPicker(false)} className="p-1 text-text-tertiary hover:text-text-primary">
<X className="w-3.5 h-3.5" />
</button>
</div>
<input
type="text"
value={assetSearch}
onChange={e => setAssetSearch(e.target.value)}
placeholder={t('common.search')}
className="w-full px-3 py-1.5 text-xs border border-border rounded-lg mb-2 focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
/>
<div className="grid grid-cols-4 gap-2 max-h-48 overflow-y-auto">
{availableAssets
.filter(a => !assetSearch || (a.original_name || a.filename || '').toLowerCase().includes(assetSearch.toLowerCase()))
.map(asset => {
const isImage = asset.mime_type?.startsWith('image/')
const assetUrl = `/api/uploads/${asset.filename}`
const name = asset.original_name || asset.filename
return (
<button
key={asset.id || asset._id}
onClick={() => handleAttachAsset(asset.id || asset._id)}
className="block w-full border border-border rounded-lg overflow-hidden bg-white hover:border-brand-primary hover:ring-2 hover:ring-brand-primary/20 transition-all text-left"
>
<div className="aspect-square relative">
{isImage ? (
<img src={assetUrl} alt={name} className="absolute inset-0 w-full h-full object-cover" />
) : (
<div className="absolute inset-0 flex items-center justify-center bg-surface-tertiary">
<FileText className="w-6 h-6 text-text-tertiary" />
</div>
)}
</div>
<div className="px-1.5 py-1 text-[10px] text-text-tertiary truncate border-t border-border-light">{name}</div>
</button>
)
})}
</div>
{availableAssets.filter(a => !assetSearch || (a.original_name || a.filename || '').toLowerCase().includes(assetSearch.toLowerCase())).length === 0 && (
<p className="text-xs text-text-tertiary text-center py-4">{t('posts.noAssetsFound')}</p>
)}
</div>
)}
</div>
)
}
} }
@@ -0,0 +1,92 @@
import { Link2, ExternalLink, XCircle, Share2 } from 'lucide-react'
import { useLanguage } from '../i18n/LanguageContext'
import { PLATFORMS } from '../utils/api'
export function PostDetailPlatforms({ form, update, updatePublicationLink }) {
const { t } = useLanguage()
return (
<div className="p-6 space-y-6 w-full">
<div>
<div className="flex items-center gap-2 mb-3">
<Share2 className="w-4 h-4 text-text-tertiary" />
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wide">{t('posts.platforms')}</h4>
</div>
<div className="flex flex-wrap gap-2 p-3 rounded-xl bg-surface-secondary min-h-[44px]">
{Object.entries(PLATFORMS).map(([k, v]) => {
const checked = (form.platforms || []).includes(k)
return (
<label
key={k}
className={`flex items-center gap-1.5 text-xs px-3 py-2 rounded-lg cursor-pointer border transition-all ${
checked
? 'bg-surface border-brand-primary/30 text-brand-primary font-medium shadow-sm'
: 'bg-white/50 border-transparent text-text-secondary hover:bg-surface hover:shadow-sm'
}`}
>
<input
type="checkbox"
checked={checked}
onChange={() => {
update('platforms', checked
? form.platforms.filter(p => p !== k)
: [...(form.platforms || []), k]
)
}}
className="sr-only"
/>
<span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: v.color || '#888' }} />
{v.label}
</label>
)
})}
</div>
</div>
{(form.platforms || []).length > 0 && (
<div>
<div className="flex items-center gap-2 mb-3">
<Link2 className="w-4 h-4 text-text-tertiary" />
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wide">{t('posts.publicationLinks')}</h4>
</div>
<div className="space-y-2.5">
{(form.platforms || []).map(platformKey => {
const platformInfo = PLATFORMS[platformKey] || { label: platformKey }
const existingLink = (form.publication_links || []).find(l => l.platform === platformKey)
const linkUrl = existingLink?.url || ''
return (
<div key={platformKey} className="flex items-center gap-3 p-3 rounded-xl bg-surface-secondary">
<span className="text-xs font-medium text-text-primary w-28 shrink-0 flex items-center gap-2">
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: platformInfo.color || '#888' }} />
{platformInfo.label}
</span>
<input
type="url"
value={linkUrl}
onChange={e => updatePublicationLink(platformKey, e.target.value)}
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
placeholder="https://..."
/>
{linkUrl && (
<a href={linkUrl} target="_blank" rel="noopener noreferrer" className="p-2 text-text-tertiary hover:text-brand-primary hover:bg-surface rounded-lg transition-colors">
<ExternalLink className="w-4 h-4" />
</a>
)}
</div>
)
})}
</div>
{form.status === 'published' && (form.platforms || []).some(p => {
const link = (form.publication_links || []).find(l => l.platform === p)
return !link || !link.url?.trim()
}) && (
<p className="text-xs text-amber-600 mt-3 flex items-center gap-1.5">
<XCircle className="w-3.5 h-3.5" />
{t('posts.publishRequired')}
</p>
)}
</div>
)}
</div>
)
}
@@ -0,0 +1,391 @@
import { useState } from 'react'
import { Trash2, Upload, FileText, Image as ImageIcon, Plus, Globe, Layers } from 'lucide-react'
import { useLanguage } from '../i18n/LanguageContext'
import Modal from './Modal'
const AVAILABLE_LANGUAGES = [
{ code: 'ar', label: 'Arabic' },
{ code: 'en', label: 'English' },
{ code: 'fr', label: 'French' },
{ code: 'id', label: 'Bahasa Indonesia' },
]
export function PostDetailVersions({
versions,
selectedVersion,
versionData,
onSelectVersion,
onCreateVersion,
onAddLanguage,
onDeleteLanguage,
onVersionFileUpload,
onDeleteVersionAttachment,
uploadingVersionFile,
versionFileInputRef,
}) {
const { t } = useLanguage()
const [showNewVersionModal, setShowNewVersionModal] = useState(false)
const [newVersionNotes, setNewVersionNotes] = useState('')
const [copyFromPrevious, setCopyFromPrevious] = useState(false)
const [creatingVersion, setCreatingVersion] = useState(false)
const [showLanguageModal, setShowLanguageModal] = useState(false)
const [languageForm, setLanguageForm] = useState({ language_code: '', language_label: '', content: '' })
const [savingLanguage, setSavingLanguage] = useState(false)
const [confirmDeleteLangId, setConfirmDeleteLangId] = useState(null)
const [confirmDeleteAttId, setConfirmDeleteAttId] = useState(null)
const handleCreateVersion = async () => {
setCreatingVersion(true)
try {
await onCreateVersion({ notes: newVersionNotes || undefined, copy_from_previous: copyFromPrevious })
setShowNewVersionModal(false)
setNewVersionNotes('')
setCopyFromPrevious(false)
} finally {
setCreatingVersion(false)
}
}
const handleAddLanguage = async () => {
if (!selectedVersion || !languageForm.language_code || !languageForm.content) return
setSavingLanguage(true)
try {
await onAddLanguage(languageForm)
setShowLanguageModal(false)
setLanguageForm({ language_code: '', language_label: '', content: '' })
} finally {
setSavingLanguage(false)
}
}
const handleDeleteLanguage = async (textId) => {
await onDeleteLanguage(textId)
setConfirmDeleteLangId(null)
}
const handleDeleteAttachment = async (attId) => {
await onDeleteVersionAttachment(attId)
setConfirmDeleteAttId(null)
}
return (
<>
<div className="flex h-full">
{/* Version Timeline (left sidebar) */}
<div className="w-64 shrink-0 border-e border-border p-4 overflow-y-auto bg-surface-secondary/50">
<div className="flex items-center justify-between mb-4">
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wide">{t('posts.versions')}</h4>
<button
onClick={() => setShowNewVersionModal(true)}
className="flex items-center gap-1 px-2.5 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors shadow-sm"
>
<Plus className="w-3 h-3" />
{t('posts.newVersion')}
</button>
</div>
{versions.length === 0 ? (
<div className="text-center py-10">
<div className="w-12 h-12 rounded-full bg-surface-tertiary flex items-center justify-center mx-auto mb-3">
<Layers className="w-6 h-6 text-text-quaternary" />
</div>
<p className="text-xs text-text-tertiary leading-relaxed px-2">{t('posts.noVersions')}</p>
</div>
) : (
<div className="space-y-1.5">
{versions.map((version, idx) => {
const vId = version.Id || version.id || version._id
const isActive = vId === (selectedVersion?.Id || selectedVersion?.id || selectedVersion?._id)
const isLatest = idx === versions.length - 1
return (
<button
key={vId}
onClick={() => onSelectVersion(version)}
className={`w-full text-start p-3 rounded-xl border transition-all ${
isActive
? 'border-brand-primary bg-surface shadow-sm ring-1 ring-brand-primary/20'
: 'border-transparent hover:bg-surface hover:border-border'
}`}
>
<div className="flex items-center gap-2.5">
<div className={`flex-shrink-0 w-7 h-7 rounded-lg flex items-center justify-center text-[11px] font-bold ${
isActive ? 'bg-brand-primary text-white' : 'bg-surface-tertiary text-text-secondary'
}`}>
{version.version_number}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className={`text-sm font-medium ${isActive ? 'text-brand-primary' : 'text-text-primary'}`}>
v{version.version_number}
</span>
{isLatest && (
<span className="text-[9px] px-1.5 py-px bg-emerald-100 text-emerald-700 rounded font-semibold uppercase">
Latest
</span>
)}
</div>
{version.notes && (
<p className="text-[11px] text-text-tertiary line-clamp-1 mt-0.5">{version.notes}</p>
)}
</div>
</div>
{(version.creator_name || version.created_at) && (
<div className="flex items-center gap-2 mt-2 ms-[38px] text-[10px] text-text-quaternary">
{version.creator_name && <span>{version.creator_name}</span>}
{version.creator_name && version.created_at && <span>·</span>}
{version.created_at && <span>{new Date(version.created_at).toLocaleDateString()}</span>}
</div>
)}
</button>
)
})}
</div>
)}
</div>
{/* Version Content (right side) */}
<div className="flex-1 min-w-0 overflow-y-auto p-6">
{selectedVersion && versionData ? (
<div className="space-y-6 w-full">
{/* Languages */}
<div>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Globe className="w-4 h-4 text-text-tertiary" />
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wide">{t('posts.languages')}</h4>
{versionData.texts?.length > 0 && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-surface-tertiary text-text-tertiary font-medium">
{versionData.texts.length}
</span>
)}
</div>
<button
onClick={() => setShowLanguageModal(true)}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-brand-primary border border-brand-primary/30 hover:bg-brand-primary/5 rounded-lg transition-colors"
>
<Plus className="w-3 h-3" />
{t('posts.addLanguage')}
</button>
</div>
{versionData.texts && versionData.texts.length > 0 ? (
<div className="space-y-3">
{versionData.texts.map(text => {
const tId = text.Id || text.id || text._id
return (
<div key={tId} className="rounded-xl border border-border overflow-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 ( return (
<div <div
onClick={() => navigate(`/projects/${project._id}`)} onClick={() => navigate(`/projects/${project._id}`)}
className="bg-white rounded-xl border border-border card-hover cursor-pointer overflow-hidden" className="bg-surface rounded-xl border border-border card-hover cursor-pointer overflow-hidden"
> >
{thumbnailUrl ? ( {thumbnailUrl ? (
<div className="w-full h-32 overflow-hidden"> <div className="w-full h-32 overflow-hidden">
<img src={thumbnailUrl} alt="" className="w-full h-full object-cover" /> <img src={thumbnailUrl} alt="" className="w-full h-full object-cover" loading="lazy" />
</div> </div>
) : null} ) : null}
<div className="p-5"> <div className="p-5">
+22 -27
View File
@@ -5,6 +5,7 @@ import { api, getBrandColor } from '../utils/api'
import CommentsSection from './CommentsSection' import CommentsSection from './CommentsSection'
import Modal from './Modal' import Modal from './Modal'
import TabbedModal from './TabbedModal' import TabbedModal from './TabbedModal'
import PortalSelect from './PortalSelect'
import { AppContext } from '../App' import { AppContext } from '../App'
export default function ProjectEditPanel({ project, onClose, onSave, onDelete, brands, teamMembers }) { 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 === 'paused' ? 'bg-amber-100 text-amber-700' :
form.status === 'completed' ? 'bg-blue-100 text-blue-700' : form.status === 'completed' ? 'bg-blue-100 text-blue-700' :
form.status === 'cancelled' ? 'bg-red-100 text-red-700' : form.status === 'cancelled' ? 'bg-red-100 text-red-700' :
'bg-gray-100 text-gray-600' 'bg-gray-100 text-text-secondary'
}`}> }`}>
{statusOptions.find(s => s.value === form.status)?.label} {statusOptions.find(s => s.value === form.status)?.label}
</span> </span>
@@ -186,49 +187,42 @@ export default function ProjectEditPanel({ project, onClose, onSave, onDelete, b
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('projects.brand')}</label> <label className="block text-xs font-medium text-text-tertiary mb-1">{t('projects.brand')}</label>
<select <PortalSelect
value={form.brand_id} 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" 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>
<div> <div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('projects.status')}</label> <label className="block text-xs font-medium text-text-tertiary mb-1">{t('projects.status')}</label>
<select <PortalSelect
value={form.status} 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" 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> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('projects.owner')}</label> <label className="block text-xs font-medium text-text-tertiary mb-1">{t('projects.owner')}</label>
<select <PortalSelect
value={form.owner_id} 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" 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>
<div> <div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('common.team')}</label> <label className="block text-xs font-medium text-text-tertiary mb-1">{t('common.team')}</label>
<select <PortalSelect
value={form.team_id} 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" 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>
</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> <label className="block text-xs font-medium text-text-tertiary mb-1">{t('projects.thumbnail')}</label>
{(project.thumbnail_url || project.thumbnailUrl) ? ( {(project.thumbnail_url || project.thumbnailUrl) ? (
<div className="relative group rounded-lg overflow-hidden border border-border"> <div className="relative group rounded-lg overflow-hidden border border-border">
<img src={project.thumbnail_url || project.thumbnailUrl} alt="" className="w-full h-24 object-cover" /> <img src={project.thumbnail_url || project.thumbnailUrl} alt="" className="w-full h-24 object-cover" loading="lazy" />
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/30 transition-colors flex items-center justify-center gap-2 opacity-0 group-hover:opacity-100"> <div className="absolute inset-0 bg-black/0 group-hover:bg-black/30 transition-colors flex items-center justify-center gap-2 opacity-0 group-hover:opacity-100">
<button <button
onClick={() => thumbnailInputRef.current?.click()} onClick={() => thumbnailInputRef.current?.click()}
className="px-3 py-1.5 text-xs bg-white/90 hover:bg-white rounded-lg font-medium text-text-primary transition-colors" className="px-3 py-1.5 text-xs bg-white/90 hover:bg-surface rounded-lg font-medium text-text-primary transition-colors"
> >
{t('projects.changeThumbnail')} {t('projects.changeThumbnail')}
</button> </button>
@@ -289,7 +283,8 @@ export default function ProjectEditPanel({ project, onClose, onSave, onDelete, b
ref={thumbnailInputRef} ref={thumbnailInputRef}
type="file" type="file"
accept="image/*" 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 = '' }} onChange={e => { handleThumbnailUpload(e.target.files[0]); e.target.value = '' }}
/> />
</div> </div>
+14 -5
View File
@@ -3,8 +3,17 @@ import { NavLink } from 'react-router-dom'
import { import {
LayoutDashboard, FileEdit, Image, Calendar, Wallet, LayoutDashboard, FileEdit, Image, Calendar, Wallet,
FolderKanban, CheckSquare, Users, ChevronLeft, ChevronRight, ChevronDown, FolderKanban, CheckSquare, Users, ChevronLeft, ChevronRight, ChevronDown,
Sparkles, LogOut, User, Settings, Languages, Tag, LayoutList, Receipt, BarChart3, Palette, CalendarDays, AlertCircle LogOut, User, Settings, Languages, Tag, LayoutList, Receipt, BarChart3, Palette, CalendarDays, AlertCircle
} from 'lucide-react' } from 'lucide-react'
function MarkaLogo({ className = '' }) {
return (
<svg viewBox="0 0 32 32" fill="none" className={className}>
<path d="M4 26V6l10 10L4 26z" fill="currentColor" opacity="0.85" />
<path d="M18 26V6l10 10-10 10z" fill="currentColor" opacity="0.5" />
</svg>
)
}
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../i18n/LanguageContext' import { useLanguage } from '../i18n/LanguageContext'
@@ -26,7 +35,7 @@ const moduleGroups = [
{ to: '/artefacts', icon: Palette, labelKey: 'nav.artefacts' }, { to: '/artefacts', icon: Palette, labelKey: 'nav.artefacts' },
{ to: '/assets', icon: Image, labelKey: 'nav.assets', tutorial: 'assets' }, { to: '/assets', icon: Image, labelKey: 'nav.assets', tutorial: 'assets' },
{ to: '/brands', icon: Tag, labelKey: 'nav.brands' }, { 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 */} {/* Logo */}
<div className="flex items-center gap-3 px-4 h-16 border-b border-white/10 shrink-0"> <div className="flex items-center gap-3 px-4 h-16 border-b border-white/10 shrink-0">
<div className="w-9 h-9 rounded-lg bg-gradient-to-br from-indigo-400 via-purple-500 to-pink-500 flex items-center justify-center shrink-0 shadow-lg shadow-indigo-500/30"> <div className="w-9 h-9 rounded-lg bg-brand-primary flex items-center justify-center shrink-0">
<Sparkles className="w-5 h-5 text-white" /> <MarkaLogo className="w-5 h-5 text-white" />
</div> </div>
{!collapsed && ( {!collapsed && (
<div className="animate-fade-in overflow-hidden"> <div className="animate-fade-in overflow-hidden">
@@ -191,7 +200,7 @@ export default function Sidebar({ collapsed, setCollapsed }) {
<div className="flex items-center gap-3 px-3 py-2 rounded-lg bg-white/5"> <div className="flex items-center gap-3 px-3 py-2 rounded-lg bg-white/5">
<div className="w-8 h-8 rounded-full bg-brand-primary flex items-center justify-center shrink-0"> <div className="w-8 h-8 rounded-full bg-brand-primary flex items-center justify-center shrink-0">
{currentUser.avatar ? ( {currentUser.avatar ? (
<img src={currentUser.avatar} alt={currentUser.name} className="w-full h-full rounded-full object-cover" /> <img src={currentUser.avatar} alt={currentUser.name} className="w-full h-full rounded-full object-cover" loading="lazy" />
) : ( ) : (
<User className="w-4 h-4 text-white" /> <User className="w-4 h-4 text-white" />
)} )}
+6 -6
View File
@@ -2,7 +2,7 @@
export function SkeletonCard() { export function SkeletonCard() {
return ( return (
<div className="bg-white rounded-xl border border-border p-5 animate-pulse"> <div className="bg-surface rounded-xl border border-border p-5 animate-pulse">
<div className="h-4 bg-surface-tertiary rounded w-3/4 mb-3"></div> <div className="h-4 bg-surface-tertiary rounded w-3/4 mb-3"></div>
<div className="h-3 bg-surface-tertiary rounded w-1/2 mb-2"></div> <div className="h-3 bg-surface-tertiary rounded w-1/2 mb-2"></div>
<div className="h-3 bg-surface-tertiary rounded w-2/3"></div> <div className="h-3 bg-surface-tertiary rounded w-2/3"></div>
@@ -12,7 +12,7 @@ export function SkeletonCard() {
export function SkeletonStatCard() { export function SkeletonStatCard() {
return ( return (
<div className="bg-white rounded-xl border border-border p-5 animate-pulse"> <div className="bg-surface rounded-xl border border-border p-5 animate-pulse">
<div className="flex items-start justify-between mb-4"> <div className="flex items-start justify-between mb-4">
<div className="w-10 h-10 bg-surface-tertiary rounded-lg"></div> <div className="w-10 h-10 bg-surface-tertiary rounded-lg"></div>
<div className="h-3 bg-surface-tertiary rounded w-16"></div> <div className="h-3 bg-surface-tertiary rounded w-16"></div>
@@ -25,7 +25,7 @@ export function SkeletonStatCard() {
export function SkeletonTable({ rows = 5, cols = 6 }) { export function SkeletonTable({ rows = 5, cols = 6 }) {
return ( return (
<div className="bg-white rounded-xl border border-border overflow-hidden animate-pulse"> <div className="bg-surface rounded-xl border border-border overflow-clip animate-pulse">
<div className="border-b border-border bg-surface-secondary p-4"> <div className="border-b border-border bg-surface-secondary p-4">
<div className="flex gap-4"> <div className="flex gap-4">
{[...Array(cols)].map((_, i) => ( {[...Array(cols)].map((_, i) => (
@@ -60,7 +60,7 @@ export function SkeletonKanbanBoard() {
</div> </div>
<div className="bg-surface-secondary rounded-xl p-2 space-y-2 min-h-[400px]"> <div className="bg-surface-secondary rounded-xl p-2 space-y-2 min-h-[400px]">
{[...Array(3)].map((_, cardIdx) => ( {[...Array(3)].map((_, cardIdx) => (
<div key={cardIdx} className="bg-white rounded-lg border border-border p-3"> <div key={cardIdx} className="bg-surface rounded-lg border border-border p-3">
<div className="h-4 bg-surface-tertiary rounded w-full mb-2"></div> <div className="h-4 bg-surface-tertiary rounded w-full mb-2"></div>
<div className="h-3 bg-surface-tertiary rounded w-3/4 mb-3"></div> <div className="h-3 bg-surface-tertiary rounded w-3/4 mb-3"></div>
<div className="flex gap-2"> <div className="flex gap-2">
@@ -78,7 +78,7 @@ export function SkeletonKanbanBoard() {
export function SkeletonCalendar() { export function SkeletonCalendar() {
return ( return (
<div className="bg-white rounded-xl border border-border overflow-hidden animate-pulse"> <div className="bg-surface rounded-xl border border-border overflow-clip animate-pulse">
<div className="flex items-center justify-between px-6 py-4 border-b border-border"> <div className="flex items-center justify-between px-6 py-4 border-b border-border">
<div className="h-6 bg-surface-tertiary rounded w-40"></div> <div className="h-6 bg-surface-tertiary rounded w-40"></div>
<div className="h-8 bg-surface-tertiary rounded w-20"></div> <div className="h-8 bg-surface-tertiary rounded w-20"></div>
@@ -138,7 +138,7 @@ export function SkeletonDashboard() {
{/* Content cards */} {/* Content cards */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{[...Array(2)].map((_, i) => ( {[...Array(2)].map((_, i) => (
<div key={i} className="bg-white rounded-xl border border-border animate-pulse"> <div key={i} className="bg-surface rounded-xl border border-border animate-pulse">
<div className="px-5 py-4 border-b border-border"> <div className="px-5 py-4 border-b border-border">
<div className="h-5 bg-surface-tertiary rounded w-32"></div> <div className="h-5 bg-surface-tertiary rounded w-32"></div>
</div> </div>
+37 -6
View File
@@ -1,17 +1,48 @@
import { useEffect, useRef, useCallback } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
export default function SlidePanel({ onClose, maxWidth = '420px', header, footer, children }) { export default function SlidePanel({ onClose, maxWidth = '420px', header, footer, children }) {
const panelRef = useRef(null)
const handleKeyDown = useCallback((e) => {
if (e.key === 'Escape') onClose()
}, [onClose])
useEffect(() => {
if (!panelRef.current) return
const el = panelRef.current
const focusable = el.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
if (focusable.length > 0) focusable[0].focus()
const handleTab = (e) => {
if (e.key !== 'Tab' || focusable.length === 0) return
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (e.shiftKey) {
if (document.activeElement === first) { e.preventDefault(); last.focus() }
} else {
if (document.activeElement === last) { e.preventDefault(); first.focus() }
}
}
el.addEventListener('keydown', handleTab)
return () => el.removeEventListener('keydown', handleTab)
}, [])
return createPortal( return createPortal(
<> <>
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm animate-backdrop-in z-[9998]" onClick={onClose} /> <div className="fixed inset-0 bg-black/40 backdrop-blur-sm animate-backdrop-in z-[9998]" onClick={onClose} aria-label="Close panel" />
<div <div
className="fixed top-0 right-0 h-full w-full bg-white shadow-2xl z-[9998] flex flex-col animate-slide-in-right overflow-hidden" ref={panelRef}
className="fixed top-0 right-0 h-full w-full bg-surface shadow-2xl z-[9998] animate-slide-in-right overflow-y-auto"
style={{ maxWidth }} style={{ maxWidth }}
role="dialog"
aria-modal="true"
onKeyDown={handleKeyDown}
> >
{header} <div className="sticky top-0 z-10 bg-surface">{header}</div>
<div className="flex-1 overflow-y-auto"> <div className="flex-1">{children}</div>
{children}
</div>
{footer} {footer}
</div> </div>
</>, </>,
+6 -6
View File
@@ -7,20 +7,20 @@ export default function StatCard({ icon: Icon, label, value, subtitle, color = '
} }
const iconBgMap = { const iconBgMap = {
'brand-primary': 'bg-indigo-50 text-indigo-600 shadow-lg shadow-indigo-500/20', 'brand-primary': 'bg-teal-50 text-teal-700',
'brand-secondary': 'bg-pink-50 text-pink-600 shadow-lg shadow-pink-500/20', 'brand-secondary': 'bg-pink-50 text-pink-600',
'brand-tertiary': 'bg-amber-50 text-amber-600 shadow-lg shadow-amber-500/20', 'brand-tertiary': 'bg-amber-50 text-amber-600',
'brand-quaternary': 'bg-emerald-50 text-emerald-600 shadow-lg shadow-emerald-500/20', 'brand-quaternary': 'bg-teal-50 text-teal-600',
} }
const accentClass = accentMap[color] || 'accent-primary' const accentClass = accentMap[color] || 'accent-primary'
return ( return (
<div className={`stat-card-premium ${accentClass} bg-white rounded-xl border border-border p-5 card-hover`}> <div className={`stat-card-premium ${accentClass} bg-surface rounded-xl border border-border p-5 card-hover`}>
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div> <div>
<p className="text-sm font-medium text-text-tertiary">{label}</p> <p className="text-sm font-medium text-text-tertiary">{label}</p>
<p className="text-3xl font-bold text-text-primary mt-1">{value}</p> <p className="text-2xl font-bold text-text-primary mt-1">{value}</p>
{subtitle && ( {subtitle && (
<p className="text-xs text-text-tertiary mt-1">{subtitle}</p> <p className="text-xs text-text-tertiary mt-1">{subtitle}</p>
)} )}
+41 -10
View File
@@ -1,4 +1,4 @@
import { useEffect } from 'react' import { useEffect, useRef, useCallback } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { X } from 'lucide-react' import { X } from 'lucide-react'
@@ -19,26 +19,55 @@ export default function TabbedModal({
footer, footer,
children, children,
}) { }) {
const modalRef = useRef(null)
useEffect(() => { useEffect(() => {
document.body.style.overflow = 'hidden' document.body.style.overflow = 'hidden'
return () => { document.body.style.overflow = '' } return () => { document.body.style.overflow = '' }
}, []) }, [])
return createPortal( useEffect(() => {
<div className="fixed inset-0 z-[9999] flex items-start justify-center pt-[5vh] px-4"> if (!modalRef.current) return
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm animate-backdrop-in" onClick={onClose} /> const el = modalRef.current
const focusable = el.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
if (focusable.length > 0) focusable[0].focus()
<div className={`relative bg-white rounded-2xl shadow-2xl w-full ${SIZE_CLASSES[size] || SIZE_CLASSES.md} max-h-[90vh] flex flex-col animate-scale-in`}> const handleTab = (e) => {
if (e.key !== 'Tab' || focusable.length === 0) return
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (e.shiftKey) {
if (document.activeElement === first) { e.preventDefault(); last.focus() }
} else {
if (document.activeElement === last) { e.preventDefault(); first.focus() }
}
}
el.addEventListener('keydown', handleTab)
return () => el.removeEventListener('keydown', handleTab)
}, [])
const handleKeyDown = useCallback((e) => {
if (e.key === 'Escape') onClose()
}, [onClose])
return createPortal(
<div className="fixed inset-0 z-[9999] flex items-start justify-center pt-[5vh] px-4" onKeyDown={handleKeyDown} ref={modalRef}>
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm animate-backdrop-in" onClick={onClose} aria-label="Close dialog" />
<div className={`relative bg-surface rounded-2xl shadow-2xl w-full ${SIZE_CLASSES[size] || SIZE_CLASSES.md} max-h-[90vh] overflow-y-auto animate-scale-in`} role="dialog" aria-modal="true" aria-labelledby="tabbed-modal-title">
{/* Header */} {/* 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="px-6 pt-5 pb-3">
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0"> <div id="tabbed-modal-title" className="flex-1 min-w-0">
{header} {header}
</div> </div>
<button <button
onClick={onClose} onClick={onClose}
className="p-2 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors shrink-0 -mt-1 -me-1" className="p-2 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors shrink-0 -mt-1 -me-1"
aria-label="Close dialog"
> >
<X className="w-5 h-5" /> <X className="w-5 h-5" />
</button> </button>
@@ -47,13 +76,15 @@ export default function TabbedModal({
{/* Tabs */} {/* Tabs */}
{tabs.length > 0 && ( {tabs.length > 0 && (
<div className="flex gap-0 px-6 border-b border-border overflow-x-auto"> <div className="flex gap-0 px-6 border-b border-border overflow-x-auto" role="tablist">
{tabs.map(tab => { {tabs.map(tab => {
const TabIcon = tab.icon const TabIcon = tab.icon
return ( return (
<button <button
key={tab.key} key={tab.key}
onClick={() => onTabChange(tab.key)} onClick={() => onTabChange(tab.key)}
role="tab"
aria-selected={activeTab === tab.key}
className={`relative flex items-center gap-2 px-4 py-3 text-[13px] font-medium whitespace-nowrap transition-colors ${ className={`relative flex items-center gap-2 px-4 py-3 text-[13px] font-medium whitespace-nowrap transition-colors ${
activeTab === tab.key activeTab === tab.key
? 'text-brand-primary' ? 'text-brand-primary'
@@ -80,13 +111,13 @@ export default function TabbedModal({
</div> </div>
{/* Body */} {/* Body */}
<div className="flex-1 overflow-y-auto"> <div role="tabpanel">
{children} {children}
</div> </div>
{/* Footer */} {/* Footer */}
{footer && ( {footer && (
<div className="border-t border-border px-6 py-3.5 flex items-center justify-between shrink-0 rounded-b-2xl bg-white"> <div className="border-t border-border px-6 py-3.5 flex items-center justify-between rounded-b-2xl bg-surface">
{footer} {footer}
</div> </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 === 'urgent') return 'bg-red-500 text-white'
if (p === 'high') return 'bg-orange-400 text-white' if (p === 'high') return 'bg-orange-400 text-white'
if (p === 'medium') return 'bg-amber-400 text-amber-900' if (p === 'medium') return 'bg-amber-400 text-amber-900'
return 'bg-gray-300 text-gray-700' return 'bg-gray-300 text-text-secondary'
} }
return ( return (
@@ -124,14 +124,14 @@ export default function TaskCalendarView({ tasks, onTaskClick }) {
<div className="flex bg-surface-tertiary rounded-lg p-0.5"> <div className="flex bg-surface-tertiary rounded-lg p-0.5">
<button <button
onClick={() => setCalView('month')} onClick={() => setCalView('month')}
className={`px-2.5 py-1 text-[11px] font-medium rounded-md transition-colors flex items-center gap-1 ${calView === 'month' ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary'}`} className={`px-2.5 py-1 text-[11px] font-medium rounded-md transition-colors flex items-center gap-1 ${calView === 'month' ? 'bg-surface shadow-sm text-text-primary' : 'text-text-tertiary'}`}
> >
<CalendarIcon className="w-3 h-3" /> <CalendarIcon className="w-3 h-3" />
Month Month
</button> </button>
<button <button
onClick={() => setCalView('week')} onClick={() => setCalView('week')}
className={`px-2.5 py-1 text-[11px] font-medium rounded-md transition-colors flex items-center gap-1 ${calView === 'week' ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary'}`} className={`px-2.5 py-1 text-[11px] font-medium rounded-md transition-colors flex items-center gap-1 ${calView === 'week' ? 'bg-surface shadow-sm text-text-primary' : 'text-text-tertiary'}`}
> >
<CalendarDays className="w-3 h-3" /> <CalendarDays className="w-3 h-3" />
Week Week
@@ -162,7 +162,7 @@ export default function TaskCalendarView({ tasks, onTaskClick }) {
<div <div
key={i} key={i}
className={`border-r border-b border-border ${calView === 'week' ? 'min-h-[300px]' : 'min-h-[90px]'} p-1 ${ className={`border-r border-b border-border ${calView === 'week' ? 'min-h-[300px]' : 'min-h-[90px]'} p-1 ${
cell.current ? 'bg-white' : 'bg-surface-secondary/50' cell.current ? 'bg-surface' : 'bg-surface-secondary/50'
}`} }`}
> >
<div className={`text-[11px] font-medium mb-0.5 w-6 h-6 flex items-center justify-center rounded-full ${ <div className={`text-[11px] font-medium mb-0.5 w-6 h-6 flex items-center justify-center rounded-full ${
@@ -175,7 +175,7 @@ export default function TaskCalendarView({ tasks, onTaskClick }) {
<button <button
key={task._id || task.id} key={task._id || task.id}
onClick={() => onTaskClick(task)} onClick={() => onTaskClick(task)}
className={`w-full text-left text-[10px] px-1.5 py-0.5 rounded truncate font-medium hover:opacity-80 transition-opacity ${ className={`w-full text-start text-[10px] px-1.5 py-0.5 rounded truncate font-medium hover:opacity-80 transition-opacity ${
task.status === 'done' ? 'bg-emerald-100 text-emerald-700 line-through' : getPillColor(task) task.status === 'done' ? 'bg-emerald-100 text-emerald-700 line-through' : getPillColor(task)
}`} }`}
title={task.title} title={task.title}
@@ -206,7 +206,7 @@ export default function TaskCalendarView({ tasks, onTaskClick }) {
<button <button
key={task._id || task.id} key={task._id || task.id}
onClick={() => onTaskClick(task)} onClick={() => onTaskClick(task)}
className="w-full text-left bg-white border border-border rounded-lg p-2 hover:border-brand-primary/30 transition-colors" className="w-full text-start bg-surface border border-border rounded-lg p-2 hover:border-brand-primary/30 transition-colors"
> >
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<div className={`w-2 h-2 rounded-full ${priority.color} shrink-0`} /> <div className={`w-2 h-2 rounded-full ${priority.color} shrink-0`} />
+1 -1
View File
@@ -32,7 +32,7 @@ export default function TaskCard({ task, onMove, showProject = true }) {
const assignedName = task.assigned_name || task.assignedName const assignedName = task.assigned_name || task.assignedName
return ( return (
<div className={`bg-white rounded-lg border border-border p-3 card-hover group cursor-pointer ${isExternallyAssigned ? 'border-l-[3px] border-l-blue-400' : ''}`}> <div className={`bg-surface rounded-lg border border-border p-3 card-hover group cursor-pointer ${isExternallyAssigned ? 'border-l-[3px] border-l-blue-400' : ''}`}>
<div className="flex items-start gap-2.5"> <div className="flex items-start gap-2.5">
{/* Priority dot */} {/* Priority dot */}
<div className={`w-2.5 h-2.5 rounded-full ${priority.color} mt-1.5 shrink-0`} title={priority.label} /> <div className={`w-2.5 h-2.5 rounded-full ${priority.color} mt-1.5 shrink-0`} title={priority.label} />
+30 -42
View File
@@ -5,6 +5,7 @@ import { useLanguage } from '../i18n/LanguageContext'
import CommentsSection from './CommentsSection' import CommentsSection from './CommentsSection'
import Modal from './Modal' import Modal from './Modal'
import TabbedModal from './TabbedModal' import TabbedModal from './TabbedModal'
import PortalSelect from './PortalSelect'
const API_BASE = '/api' const API_BASE = '/api'
@@ -199,11 +200,11 @@ export default function TaskDetailPanel({ task, onClose, onSave, onDelete, proje
{/* Thumbnail banner */} {/* Thumbnail banner */}
{currentThumbnail && ( {currentThumbnail && (
<div className="relative -mx-6 -mt-5 mb-3 h-32 overflow-hidden rounded-t-2xl"> <div className="relative -mx-6 -mt-5 mb-3 h-32 overflow-hidden rounded-t-2xl">
<img src={currentThumbnail} alt="" className="w-full h-full object-cover" /> <img src={currentThumbnail} alt="" className="w-full h-full object-cover" loading="lazy" />
<div className="absolute inset-0 bg-gradient-to-t from-white/80 to-transparent" /> <div className="absolute inset-0 bg-gradient-to-t from-white/80 to-transparent" />
<button <button
onClick={handleRemoveThumbnail} onClick={handleRemoveThumbnail}
className="absolute top-2 right-2 p-1 bg-black/40 hover:bg-black/60 rounded-full text-white transition-colors" className="absolute top-2 end-2 p-1 bg-black/40 hover:bg-black/60 rounded-full text-white transition-colors"
title={t('tasks.removeThumbnail')} title={t('tasks.removeThumbnail')}
> >
<X className="w-3 h-3" /> <X className="w-3 h-3" />
@@ -218,11 +219,11 @@ export default function TaskDetailPanel({ task, onClose, onSave, onDelete, proje
placeholder={t('tasks.taskTitle')} placeholder={t('tasks.taskTitle')}
/> />
<div className="flex items-center gap-2 mt-2"> <div className="flex items-center gap-2 mt-2">
<span className={`inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full font-medium ${priority.color === 'bg-gray-400' ? 'bg-gray-100 text-gray-600' : priority.color === 'bg-amber-400' ? 'bg-amber-100 text-amber-700' : priority.color === 'bg-orange-500' ? 'bg-orange-100 text-orange-700' : 'bg-red-100 text-red-700'}`}> <span className={`inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full font-medium ${priority.color === 'bg-gray-400' ? 'bg-gray-100 text-text-secondary' : priority.color === 'bg-amber-400' ? 'bg-amber-100 text-amber-700' : priority.color === 'bg-orange-500' ? 'bg-orange-100 text-orange-700' : 'bg-red-100 text-red-700'}`}>
<div className={`w-1.5 h-1.5 rounded-full ${priority.color}`} /> <div className={`w-1.5 h-1.5 rounded-full ${priority.color}`} />
{priorityOptions.find(p => p.value === form.priority)?.label} {priorityOptions.find(p => p.value === form.priority)?.label}
</span> </span>
<span className={`text-[11px] px-2 py-0.5 rounded-full font-medium ${form.status === 'done' ? 'bg-emerald-100 text-emerald-700' : form.status === 'in_progress' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600'}`}> <span className={`text-[11px] px-2 py-0.5 rounded-full font-medium ${form.status === 'done' ? 'bg-emerald-100 text-emerald-700' : form.status === 'in_progress' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-text-secondary'}`}>
{statusOptions.find(s => s.value === form.status)?.label} {statusOptions.find(s => s.value === form.status)?.label}
</span> </span>
{isOverdue && !isCreateMode && ( {isOverdue && !isCreateMode && (
@@ -293,16 +294,12 @@ export default function TaskDetailPanel({ task, onClose, onSave, onDelete, proje
<div> <div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.project')}</label> <label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.project')}</label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<select <PortalSelect
value={form.project_id} 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" 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 && ( {brandName && (
<span className={`text-[10px] px-1.5 py-0.5 rounded shrink-0 ${getBrandColor(brandName).bg} ${getBrandColor(brandName).text}`}> <span className={`text-[10px] px-1.5 py-0.5 rounded shrink-0 ${getBrandColor(brandName).bg} ${getBrandColor(brandName).text}`}>
{brandName} {brandName}
@@ -314,43 +311,33 @@ export default function TaskDetailPanel({ task, onClose, onSave, onDelete, proje
{/* Assignee */} {/* Assignee */}
<div> <div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.assignee')}</label> <label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.assignee')}</label>
<select <PortalSelect
value={form.assigned_to} 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" 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> </div>
{/* Priority & Status */} {/* Priority & Status */}
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.priority')}</label> <label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.priority')}</label>
<select <PortalSelect
value={form.priority} 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" 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>
<div> <div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.status')}</label> <label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.status')}</label>
<select <PortalSelect
value={form.status} 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" 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> </div>
@@ -401,11 +388,11 @@ export default function TaskDetailPanel({ task, onClose, onSave, onDelete, proje
const isThumbnail = currentThumbnail && attUrl === currentThumbnail const isThumbnail = currentThumbnail && attUrl === currentThumbnail
return ( return (
<div key={attId} className="relative group/att border border-border rounded-lg overflow-hidden bg-white"> <div key={attId} className="relative group/att border border-border rounded-lg overflow-hidden bg-surface">
<div className="h-20 relative"> <div className="h-20 relative">
{isImage ? ( {isImage ? (
<a href={attUrl} target="_blank" rel="noopener noreferrer" className="block h-full"> <a href={attUrl} target="_blank" rel="noopener noreferrer" className="block h-full">
<img src={attUrl} alt={name} className="absolute inset-0 w-full h-full object-cover" /> <img src={attUrl} alt={name} className="absolute inset-0 w-full h-full object-cover" loading="lazy" />
</a> </a>
) : ( ) : (
<a href={attUrl} target="_blank" rel="noopener noreferrer" className="absolute inset-0 flex items-center gap-2 p-3"> <a href={attUrl} target="_blank" rel="noopener noreferrer" className="absolute inset-0 flex items-center gap-2 p-3">
@@ -414,11 +401,11 @@ export default function TaskDetailPanel({ task, onClose, onSave, onDelete, proje
</a> </a>
)} )}
{isThumbnail && ( {isThumbnail && (
<div className="absolute top-1 left-1 p-0.5 bg-amber-400 rounded-full text-white"> <div className="absolute top-1 start-1 p-0.5 bg-amber-400 rounded-full text-white">
<Star className="w-2.5 h-2.5 fill-current" /> <Star className="w-2.5 h-2.5 fill-current" />
</div> </div>
)} )}
<div className="absolute top-1 right-1 flex items-center gap-0.5 opacity-0 group-hover/att:opacity-100 transition-opacity"> <div className="absolute top-1 end-1 flex items-center gap-0.5 opacity-0 group-hover/att:opacity-100 transition-opacity">
{isImage && !isThumbnail && ( {isImage && !isThumbnail && (
<button <button
onClick={() => handleSetThumbnail(att)} onClick={() => handleSetThumbnail(att)}
@@ -454,17 +441,17 @@ export default function TaskDetailPanel({ task, onClose, onSave, onDelete, proje
const previewUrl = isImage ? URL.createObjectURL(file) : null const previewUrl = isImage ? URL.createObjectURL(file) : null
return ( return (
<div key={i} className="relative group/att border border-border rounded-lg overflow-hidden bg-white"> <div key={i} className="relative group/att border border-border rounded-lg overflow-hidden bg-surface">
<div className="h-20 relative"> <div className="h-20 relative">
{isImage ? ( {isImage ? (
<img src={previewUrl} alt={file.name} className="absolute inset-0 w-full h-full object-cover" /> <img src={previewUrl} alt={file.name} className="absolute inset-0 w-full h-full object-cover" loading="lazy" />
) : ( ) : (
<div className="absolute inset-0 flex items-center gap-2 p-3"> <div className="absolute inset-0 flex items-center gap-2 p-3">
<FileText className="w-6 h-6 text-text-tertiary shrink-0" /> <FileText className="w-6 h-6 text-text-tertiary shrink-0" />
<span className="text-xs text-text-secondary truncate">{file.name}</span> <span className="text-xs text-text-secondary truncate">{file.name}</span>
</div> </div>
)} )}
<div className="absolute top-1 right-1 flex items-center gap-0.5 opacity-0 group-hover/att:opacity-100 transition-opacity"> <div className="absolute top-1 end-1 flex items-center gap-0.5 opacity-0 group-hover/att:opacity-100 transition-opacity">
<button <button
onClick={() => setPendingFiles(prev => prev.filter((_, j) => j !== i))} onClick={() => setPendingFiles(prev => prev.filter((_, j) => j !== i))}
className="p-1 bg-black/50 hover:bg-red-500 rounded-full text-white transition-colors" className="p-1 bg-black/50 hover:bg-red-500 rounded-full text-white transition-colors"
@@ -494,7 +481,8 @@ export default function TaskDetailPanel({ task, onClose, onSave, onDelete, proje
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
multiple multiple
className="hidden" className="absolute w-0 h-0 opacity-0 pointer-events-none"
tabIndex={-1}
onChange={e => { onChange={e => {
setUploadError(null) setUploadError(null)
const files = Array.from(e.target.files || []) const files = Array.from(e.target.files || [])
+16 -18
View File
@@ -6,14 +6,15 @@ import { useToast } from './ToastContainer'
import Modal from './Modal' import Modal from './Modal'
import TabbedModal from './TabbedModal' import TabbedModal from './TabbedModal'
import StatusBadge from './StatusBadge' import StatusBadge from './StatusBadge'
import PortalSelect from './PortalSelect'
import { AppContext, PERMISSION_LEVELS } from '../App' import { AppContext, PERMISSION_LEVELS } from '../App'
const ALL_MODULES = ['marketing', 'projects', 'finance'] const ALL_MODULES = ['marketing', 'projects', 'finance']
const MODULE_LABELS = { marketing: 'Marketing', projects: 'Projects', finance: 'Finance' } const MODULE_LABELS = { marketing: 'Marketing', projects: 'Projects', finance: 'Finance' }
const MODULE_COLORS = { const MODULE_COLORS = {
marketing: { on: 'bg-emerald-100 text-emerald-700 border-emerald-300', off: 'bg-gray-100 text-gray-400 border-gray-200' }, marketing: { on: 'bg-emerald-100 text-emerald-700 border-emerald-300', off: 'bg-gray-100 text-text-tertiary border-gray-200' },
projects: { on: 'bg-blue-100 text-blue-700 border-blue-300', off: 'bg-gray-100 text-gray-400 border-gray-200' }, projects: { on: 'bg-blue-100 text-blue-700 border-blue-300', off: 'bg-gray-100 text-text-tertiary border-gray-200' },
finance: { on: 'bg-amber-100 text-amber-700 border-amber-300', off: 'bg-gray-100 text-gray-400 border-gray-200' }, finance: { on: 'bg-amber-100 text-amber-700 border-amber-300', off: 'bg-gray-100 text-text-tertiary border-gray-200' },
} }
export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave, onDelete, canManageTeam, userRole, teams, brands: brandsList }) { export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave, onDelete, canManageTeam, userRole, teams, brands: brandsList }) {
@@ -231,13 +232,12 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
{userRole === 'superadmin' && !isEditingSelf && ( {userRole === 'superadmin' && !isEditingSelf && (
<div> <div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.permissionLevel')}</label> <label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.permissionLevel')}</label>
<select <PortalSelect
value={form.permission_level} 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" className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
> />
{PERMISSION_LEVELS.map(p => <option key={p.value} value={p.value}>{p.label}</option>)}
</select>
</div> </div>
)} )}
@@ -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" 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 || ''} 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" className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
> />
<option value="">{t('team.selectRole')}</option>
{roles.map(r => <option key={r.Id || r.id} value={r.Id || r.id}>{r.name}</option>)}
</select>
)} )}
</div> </div>
@@ -285,7 +283,7 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
<button <button
type="button" type="button"
onClick={() => setShowBrandsDropdown(prev => !prev)} onClick={() => setShowBrandsDropdown(prev => !prev)}
className="w-full flex items-center justify-between px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-white text-left" className="w-full flex items-center justify-between px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface text-start"
> >
<span className={`flex-1 truncate ${(form.brands || []).length === 0 ? 'text-text-tertiary' : 'text-text-primary'}`}> <span className={`flex-1 truncate ${(form.brands || []).length === 0 ? 'text-text-tertiary' : 'text-text-primary'}`}>
{(form.brands || []).length === 0 {(form.brands || []).length === 0
@@ -315,7 +313,7 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
{/* Dropdown */} {/* Dropdown */}
{showBrandsDropdown && ( {showBrandsDropdown && (
<div className="absolute z-20 mt-1 w-full bg-white border border-border rounded-lg shadow-lg max-h-48 overflow-y-auto"> <div className="absolute z-20 mt-1 w-full bg-surface border border-border rounded-lg shadow-lg max-h-48 overflow-y-auto">
{brandsList && brandsList.length > 0 ? ( {brandsList && brandsList.length > 0 ? (
brandsList.map(brand => { brandsList.map(brand => {
const name = lang === 'ar' && brand.name_ar ? brand.name_ar : brand.name const name = lang === 'ar' && brand.name_ar ? brand.name_ar : brand.name
@@ -325,7 +323,7 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
type="button" type="button"
key={brand.id || brand._id} key={brand.id || brand._id}
onClick={() => toggleBrand(name)} onClick={() => toggleBrand(name)}
className={`w-full flex items-center gap-2.5 px-3 py-2 cursor-pointer hover:bg-surface-secondary transition-colors text-left ${checked ? 'bg-brand-primary/5' : ''}`} className={`w-full flex items-center gap-2.5 px-3 py-2 cursor-pointer hover:bg-surface-secondary transition-colors text-start ${checked ? 'bg-brand-primary/5' : ''}`}
> >
<div className={`w-4 h-4 rounded border flex items-center justify-center shrink-0 transition-colors ${ <div className={`w-4 h-4 rounded border flex items-center justify-center shrink-0 transition-colors ${
checked ? 'bg-brand-primary border-brand-primary' : 'border-border' checked ? 'bg-brand-primary border-brand-primary' : 'border-border'
@@ -393,7 +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 ${ className={`text-xs px-3 py-1.5 rounded-full border font-medium transition-colors ${
active active
? 'bg-blue-100 text-blue-700 border-blue-300' ? 'bg-blue-100 text-blue-700 border-blue-300'
: 'bg-gray-100 text-gray-400 border-gray-200' : 'bg-gray-100 text-text-tertiary border-gray-200'
}`} }`}
> >
{team.name} {team.name}
+2 -2
View File
@@ -149,13 +149,13 @@ export default function TeamPanel({ team, onClose, onSave, onDelete, teamMembers
{activeTab === 'members' && ( {activeTab === 'members' && (
<div className="p-6"> <div className="p-6">
<div className="relative mb-3"> <div className="relative mb-3">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-text-tertiary" /> <Search className="absolute start-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-text-tertiary" />
<input <input
type="text" type="text"
value={memberSearch} value={memberSearch}
onChange={e => setMemberSearch(e.target.value)} onChange={e => setMemberSearch(e.target.value)}
placeholder={t('teams.selectMembers')} placeholder={t('teams.selectMembers')}
className="w-full pl-9 pr-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary" className="w-full ps-9 pe-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
/> />
</div> </div>
<div className="space-y-1 max-h-80 overflow-y-auto"> <div className="space-y-1 max-h-80 overflow-y-auto">
+1 -1
View File
@@ -14,7 +14,7 @@ export default function ThemeToggle({ className = '' }) {
{darkMode ? ( {darkMode ? (
<Sun className="w-5 h-5 text-yellow-500" /> <Sun className="w-5 h-5 text-yellow-500" />
) : ( ) : (
<Moon className="w-5 h-5 text-gray-600" /> <Moon className="w-5 h-5 text-text-secondary" />
)} )}
</button> </button>
) )
+1 -1
View File
@@ -37,7 +37,7 @@ export function ToastProvider({ children }) {
<ToastContext.Provider value={toast}> <ToastContext.Provider value={toast}>
{children} {children}
{/* Toast container - fixed position */} {/* Toast container - fixed position */}
<div className="fixed top-4 right-4 z-[10000] flex flex-col gap-2 pointer-events-none"> <div className="fixed top-4 end-4 z-[10000] flex flex-col gap-2 pointer-events-none">
<div className="flex flex-col gap-2 pointer-events-auto"> <div className="flex flex-col gap-2 pointer-events-auto">
{toasts.map(t => ( {toasts.map(t => (
<Toast <Toast
+14 -24
View File
@@ -5,6 +5,7 @@ import { PLATFORMS } from '../utils/api'
import Modal from './Modal' import Modal from './Modal'
import TabbedModal from './TabbedModal' import TabbedModal from './TabbedModal'
import BudgetBar from './BudgetBar' import BudgetBar from './BudgetBar'
import PortalSelect from './PortalSelect'
const TRACK_TYPES = { const TRACK_TYPES = {
organic_social: { label: 'Organic Social' }, 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 === 'active' ? 'bg-emerald-100 text-emerald-700' :
form.status === 'paused' ? 'bg-amber-100 text-amber-700' : form.status === 'paused' ? 'bg-amber-100 text-amber-700' :
form.status === 'completed' ? 'bg-blue-100 text-blue-700' : form.status === 'completed' ? 'bg-blue-100 text-blue-700' :
'bg-gray-100 text-gray-600' 'bg-gray-100 text-text-secondary'
}`}> }`}>
{form.status?.charAt(0).toUpperCase() + form.status?.slice(1)} {form.status?.charAt(0).toUpperCase() + form.status?.slice(1)}
</span> </span>
@@ -156,29 +157,21 @@ export default function TrackDetailPanel({ track, campaignId, onClose, onSave, o
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tracks.type')}</label> <label className="block text-xs font-medium text-text-tertiary mb-1">{t('tracks.type')}</label>
<select <PortalSelect
value={form.type} 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" 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>
<div> <div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tracks.platform')}</label> <label className="block text-xs font-medium text-text-tertiary mb-1">{t('tracks.platform')}</label>
<select <PortalSelect
value={form.platform} 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" 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>
</div> </div>
@@ -195,15 +188,12 @@ export default function TrackDetailPanel({ track, campaignId, onClose, onSave, o
</div> </div>
<div> <div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tracks.status')}</label> <label className="block text-xs font-medium text-text-tertiary mb-1">{t('tracks.status')}</label>
<select <PortalSelect
value={form.status} 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" 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>
</div> </div>
@@ -7,7 +7,7 @@ import { AVAILABLE_LANGUAGES, TRANSLATION_STATUS_COLORS, isTextSelected, groupTe
import Modal from './Modal' import Modal from './Modal'
import TabbedModal from './TabbedModal' import TabbedModal from './TabbedModal'
import { useToast } from './ToastContainer' import { useToast } from './ToastContainer'
import ApproverMultiSelect from './ApproverMultiSelect' import PortalSelect from './PortalSelect'
export default function TranslationDetailPanel({ translation, onClose, onUpdate, onDelete, assignableUsers = [], posts: externalPosts }) { export default function TranslationDetailPanel({ translation, onClose, onUpdate, onDelete, assignableUsers = [], posts: externalPosts }) {
const { t } = useLanguage() const { t } = useLanguage()
@@ -296,14 +296,13 @@ export default function TranslationDetailPanel({ translation, onClose, onUpdate,
<div className="p-6 space-y-5"> <div className="p-6 space-y-5">
<div> <div>
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-2">{t('translations.sourceLanguage')}</h4> <h4 className="text-xs font-semibold text-text-tertiary uppercase mb-2">{t('translations.sourceLanguage')}</h4>
<select <PortalSelect
value={editSourceLanguage} value={editSourceLanguage}
onChange={e => setEditSourceLanguage(e.target.value)} onChange={val => setEditSourceLanguage(val)}
disabled={isApproved} 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" 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"
> />
{AVAILABLE_LANGUAGES.map(l => <option key={l.code} value={l.code}>{l.label} ({l.code})</option>)}
</select>
</div> </div>
<div> <div>
@@ -317,75 +316,7 @@ export default function TranslationDetailPanel({ translation, onClose, onUpdate,
/> />
</div> </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)}
disabled={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 disabled:opacity-60 disabled:cursor-default"
>
<option value=""></option>
{brands.map(b => <option key={b._id} value={b._id}>{b.name}</option>)}
</select>
</div>
<div>
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1.5">{t('translations.linkedPost')}</h4>
{isApproved ? (
<p className="px-3 py-2 text-sm text-text-secondary">{translation.post_name || '—'}</p>
) : 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-2 py-2 bg-brand-primary text-white text-xs rounded-lg hover:bg-brand-primary-light disabled:opacity-50"
>
{creatingPost ? '...' : t('common.create')}
</button>
<button onClick={() => setShowCreatePost(false)} className="text-xs text-text-secondary hover:text-text-primary">
{t('common.cancel')}
</button>
</div>
) : (
<div className="flex items-center gap-1">
<select
value={translation.post_id || ''}
onChange={e => handleFieldUpdate('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="p-2 text-brand-primary hover:text-brand-primary/80"
title={t('translations.createPost')}
>
<Plus className="w-4 h-4" />
</button>
</div>
)}
</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> </div>
)} )}
@@ -441,7 +372,7 @@ export default function TranslationDetailPanel({ translation, onClose, onUpdate,
<div className="flex items-center justify-between mb-1.5"> <div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-medium text-text-tertiary"> <span className="text-xs font-medium text-text-tertiary">
{t('translations.optionLabel')} {text.option_number || idx + 1} {t('translations.optionLabel')} {text.option_number || idx + 1}
{selected && <span className="ml-2 text-emerald-600 font-semibold">{t('translations.selected')}</span>} {selected && <span className="ms-2 text-emerald-600 font-semibold">{t('translations.selected')}</span>}
</span> </span>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{editingTextId !== text.Id && ( {editingTextId !== text.Id && (
@@ -503,13 +434,28 @@ export default function TranslationDetailPanel({ translation, onClose, onUpdate,
{activeTab === 'review' && ( {activeTab === 'review' && (
<div className="p-6 space-y-5"> <div className="p-6 space-y-5">
{['draft', 'revision_requested', 'rejected'].includes(translation.status) && ( {['draft', 'revision_requested', 'rejected'].includes(translation.status) && (
<button <>
onClick={handleSubmitReview} <div>
disabled={submitting} <h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1.5">{t('artefacts.reviewer')}</h4>
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" <PortalSelect
> value={editApproverIds[0] || ''}
{submitting ? t('translations.submitting') : t('translations.submitForReview')} onChange={val => {
</button> 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 && ( {currentReviewUrl && (
@@ -520,7 +466,7 @@ export default function TranslationDetailPanel({ translation, onClose, onUpdate,
type="text" type="text"
value={currentReviewUrl} value={currentReviewUrl}
readOnly readOnly
className="flex-1 px-3 py-2 text-sm bg-white border border-blue-200 rounded-lg text-blue-800" className="flex-1 px-3 py-2 text-sm bg-surface border border-blue-200 rounded-lg text-blue-800"
/> />
<button <button
onClick={copyReviewLink} onClick={copyReviewLink}
@@ -574,17 +520,15 @@ export default function TranslationDetailPanel({ translation, onClose, onUpdate,
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('translations.languageLabel')} *</label> <label className="block text-sm font-medium text-text-primary mb-1">{t('translations.languageLabel')} *</label>
<select <PortalSelect
value={langForm.language_code} value={langForm.language_code}
onChange={e => setLangForm(f => ({ ...f, language_code: e.target.value }))} onChange={val => setLangForm(f => ({ ...f, language_code: val }))}
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" options={[{ value: '', label: t('translations.selectLanguage') }, ...targetLanguages.map(l => {
>
<option value="">{t('translations.selectLanguage')}</option>
{targetLanguages.map(l => {
const count = textsByLanguage[l.code]?.length || 0 const count = textsByLanguage[l.code]?.length || 0
return <option key={l.code} value={l.code}>{l.label} ({l.code}){count > 0 ? `${count} ${t('translations.existing')}` : ''}</option> return { value: l.code, label: `${l.label} (${l.code})${count > 0 ? `${count} ${t('translations.existing')}` : ''}` }
})} })]}
</select> className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
/>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('translations.translatedContent')} *</label> <label className="block text-sm font-medium text-text-primary mb-1">{t('translations.translatedContent')} *</label>
+2 -2
View File
@@ -177,7 +177,7 @@ export default function Tutorial({ onComplete }) {
{/* Tooltip card */} {/* Tooltip card */}
<div <div
className="absolute bg-white rounded-xl shadow-2xl border border-border p-6 animate-fade-in pointer-events-auto" className="absolute bg-surface rounded-xl shadow-2xl border border-border p-6 animate-fade-in pointer-events-auto"
style={{ style={{
top: tooltipPosition.top, top: tooltipPosition.top,
left: tooltipPosition.left, left: tooltipPosition.left,
@@ -188,7 +188,7 @@ export default function Tutorial({ onComplete }) {
{/* Close button */} {/* Close button */}
<button <button
onClick={handleSkip} onClick={handleSkip}
className="absolute top-4 right-4 text-text-tertiary hover:text-text-primary transition-colors" className="absolute top-4 end-4 text-text-tertiary hover:text-text-primary transition-colors"
> >
<X className="w-5 h-5" /> <X className="w-5 h-5" />
</button> </button>
+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>
)
}
+166 -4
View File
@@ -1,6 +1,6 @@
{ {
"app.name": "المركز الرقمي", "app.name": "رواج",
"app.subtitle": "المنصة", "app.subtitle": "مركز التسويق",
"nav.dashboard": "لوحة التحكم", "nav.dashboard": "لوحة التحكم",
"nav.campaigns": "الحملات", "nav.campaigns": "الحملات",
"nav.finance": "المالية والعائد", "nav.finance": "المالية والعائد",
@@ -31,6 +31,7 @@
"common.loading": "جاري التحميل...", "common.loading": "جاري التحميل...",
"common.unassigned": "غير مُسند", "common.unassigned": "غير مُسند",
"common.close": "إغلاق", "common.close": "إغلاق",
"common.created": "تاريخ الإنشاء",
"common.required": "مطلوب", "common.required": "مطلوب",
"common.saveFailed": "فشل الحفظ. حاول مجدداً.", "common.saveFailed": "فشل الحفظ. حاول مجدداً.",
"common.updateFailed": "فشل التحديث. حاول مجدداً.", "common.updateFailed": "فشل التحديث. حاول مجدداً.",
@@ -78,6 +79,29 @@
"posts.saveChanges": "حفظ التغييرات", "posts.saveChanges": "حفظ التغييرات",
"posts.postTitle": "العنوان", "posts.postTitle": "العنوان",
"posts.description": "الوصف", "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.brand": "العلامة التجارية",
"posts.platforms": "المنصات", "posts.platforms": "المنصات",
"posts.status": "الحالة", "posts.status": "الحالة",
@@ -396,6 +420,16 @@
"campaigns.editCampaign": "تعديل الحملة", "campaigns.editCampaign": "تعديل الحملة",
"campaigns.deleteCampaign": "حذف الحملة؟", "campaigns.deleteCampaign": "حذف الحملة؟",
"campaigns.deleteConfirm": "هل أنت متأكد من حذف هذه الحملة؟ سيتم حذف جميع البيانات المرتبطة. لا يمكن التراجع.", "campaigns.deleteConfirm": "هل أنت متأكد من حذف هذه الحملة؟ سيتم حذف جميع البيانات المرتبطة. لا يمكن التراجع.",
"campaigns.tracks": "المسارات",
"campaigns.addTrack": "إضافة مسار",
"campaigns.noTracks": "لا توجد مسارات بعد. أضف مسارات عضوية أو مدفوعة أو SEO لتنظيم هذه الحملة.",
"campaigns.postsLinked": "منشورات مرتبطة",
"campaigns.team": "الفريق",
"campaigns.assignMembers": "تعيين أعضاء",
"campaigns.linkedPosts": "المنشورات المرتبطة",
"campaigns.notFound": "الحملة غير موجودة.",
"common.goBack": "رجوع",
"finance.allocated": "مخصص",
"tracks.details": "التفاصيل", "tracks.details": "التفاصيل",
"tracks.metrics": "المقاييس", "tracks.metrics": "المقاييس",
"tracks.trackName": "اسم المسار", "tracks.trackName": "اسم المسار",
@@ -503,6 +537,59 @@
"budgets.dateExpensed": "التاريخ", "budgets.dateExpensed": "التاريخ",
"dashboard.expenses": "المصروفات", "dashboard.expenses": "المصروفات",
"finance.expenses": "إجمالي المصروفات", "finance.expenses": "إجمالي المصروفات",
"finance.totalReceived": "إجمالي المستلم",
"finance.totalSpent": "إجمالي المنفق",
"finance.remaining": "المتبقي",
"finance.revenue": "الإيرادات",
"finance.globalROI": "العائد الإجمالي",
"finance.budgetAllocation": "توزيع الميزانية",
"finance.manageBudgets": "إدارة الميزانيات",
"finance.campaigns": "الحملات",
"finance.projects": "المشاريع",
"finance.unallocated": "غير مخصص",
"finance.budgetUtilization": "استخدام الميزانية",
"finance.globalPerformance": "الأداء العام",
"finance.impressions": "مرات الظهور",
"finance.clicks": "النقرات",
"finance.conversions": "التحويلات",
"finance.campaignBreakdown": "توزيع الحملات",
"finance.allocatedFunds": "الأموال المخصصة",
"finance.requestBudget": "طلب ميزانية",
"finance.budgetRequests": "طلبات الميزانية",
"finance.pendingApproval": "بانتظار موافقة المدير التنفيذي",
"finance.justification": "المبرر",
"finance.earmarkFor": "تخصيص لـ",
"finance.submitRequest": "إرسال الطلب",
"finance.cancelRequest": "إلغاء الطلب",
"finance.approved": "تمت الموافقة",
"finance.rejected": "مرفوض",
"finance.cancelled": "ملغي",
"finance.pending": "قيد الانتظار",
"finance.ceoNote": "ملاحظة المدير",
"finance.requestPending": "طلب(ات) ميزانية بانتظار الموافقة",
"finance.insufficientBudget": "ميزانية غير كافية",
"finance.availableBudget": "المتاح",
"finance.requestMore": "طلب المزيد من الأموال",
"finance.noCeoEmail": "لم يتم تكوين بريد المدير التنفيذي. اذهب إلى الإعدادات.",
"finance.amount": "المبلغ",
"finance.justificationPlaceholder": "لماذا هذه الميزانية مطلوبة؟",
"finance.optional": "اختياري",
"settings.budgetApproval": "موافقة الميزانية",
"settings.ceoEmail": "بريد المدير التنفيذي / المعتمد",
"settings.ceoEmailHint": "عنوان البريد الإلكتروني الذي يستلم طلبات الموافقة على الميزانية",
"budgetApproval.title": "موافقة الميزانية",
"budgetApproval.amount": "المبلغ المطلوب",
"budgetApproval.requestedBy": "مقدم الطلب",
"budgetApproval.justification": "المبرر",
"budgetApproval.earmarkedFor": "مخصص لـ",
"budgetApproval.approve": "موافقة",
"budgetApproval.reject": "رفض",
"budgetApproval.addNote": "أضف ملاحظة (اختياري)",
"budgetApproval.approved": "تمت الموافقة على هذا الطلب.",
"budgetApproval.rejected": "تم رفض هذا الطلب.",
"budgetApproval.expired": "انتهت صلاحية هذا الطلب.",
"budgetApproval.alreadyHandled": "تمت معالجة هذا الطلب بالفعل.",
"finance.ofBudget": "من الميزانية",
"settings.uploads": "الرفع", "settings.uploads": "الرفع",
"settings.maxFileSize": "الحد الأقصى لحجم الملف", "settings.maxFileSize": "الحد الأقصى لحجم الملف",
"settings.maxFileSizeHint": "الحد الأقصى المسموح لحجم المرفقات (١-٥٠٠ ميجابايت)", "settings.maxFileSizeHint": "الحد الأقصى المسموح لحجم المرفقات (١-٥٠٠ ميجابايت)",
@@ -520,6 +607,11 @@
"issues.noIssuesInColumn": "لا توجد مشاكل", "issues.noIssuesInColumn": "لا توجد مشاكل",
"artefacts.details": "التفاصيل", "artefacts.details": "التفاصيل",
"artefacts.review": "المراجعة", "artefacts.review": "المراجعة",
"artefacts.selectVersionFirst": "اختر إصداراً لعرض التعليقات.",
"artefacts.pendingReviewInfo": "هذا العنصر قيد المراجعة حالياً.",
"artefacts.noReviewInfo": "لا توجد معلومات مراجعة متاحة.",
"artefacts.rejectedMustCreateNewVersion": "تم رفض هذا العنصر. أنشئ إصداراً جديداً لمعالجة الملاحظات.",
"artefacts.revisionEditCurrentVersion": "طُلب تعديل — عدّل الإصدار الحالي وأعد إرساله للمراجعة.",
"artefacts.grid": "شبكة", "artefacts.grid": "شبكة",
"artefacts.list": "قائمة", "artefacts.list": "قائمة",
"artefacts.allCreators": "جميع المنشئين", "artefacts.allCreators": "جميع المنشئين",
@@ -629,7 +721,7 @@
"review.alreadyReviewed": "تمت مراجعة هذا المحتوى بالفعل.", "review.alreadyReviewed": "تمت مراجعة هذا المحتوى بالفعل.",
"review.statusLabel": "الحالة", "review.statusLabel": "الحالة",
"review.reviewedBy": "تمت المراجعة بواسطة", "review.reviewedBy": "تمت المراجعة بواسطة",
"review.poweredBy": "مدعوم بواسطة Samaya Digital Hub", "review.poweredBy": "مدعوم بواسطة Rawaj",
"review.loadFailed": "فشل في تحميل المحتوى", "review.loadFailed": "فشل في تحميل المحتوى",
"review.actionFailed": "فشل الإجراء", "review.actionFailed": "فشل الإجراء",
"review.actionCompleted": "تم الإجراء بنجاح", "review.actionCompleted": "تم الإجراء بنجاح",
@@ -638,6 +730,11 @@
"review.confirmReject": "هل تريد رفض هذا المحتوى؟", "review.confirmReject": "هل تريد رفض هذا المحتوى؟",
"review.feedbackRequired": "يرجى تقديم ملاحظات لطلب التعديل", "review.feedbackRequired": "يرجى تقديم ملاحظات لطلب التعديل",
"review.contentLanguages": "لغات المحتوى", "review.contentLanguages": "لغات المحتوى",
"review.redirectReview": "لست المراجع المناسب؟ أعد التوجيه لشخص آخر",
"review.redirectDesc": "اختر عضو فريق لإعادة توجيه المراجعة إليه:",
"review.selectNewReviewer": "اختر مراجعاً جديداً...",
"review.redirect": "إعادة توجيه",
"review.redirected": "تم إعادة توجيه المراجعة بنجاح",
"review.content": "المحتوى", "review.content": "المحتوى",
"review.designFiles": "ملفات التصميم", "review.designFiles": "ملفات التصميم",
"review.videos": "الفيديوهات", "review.videos": "الفيديوهات",
@@ -665,6 +762,9 @@
"issues.trackingLinkCopied": "تم نسخ رابط التتبع!", "issues.trackingLinkCopied": "تم نسخ رابط التتبع!",
"issues.deleteAttachment": "حذف المرفق؟", "issues.deleteAttachment": "حذف المرفق؟",
"issues.deleteAttachmentDesc": "لا يمكن التراجع عن هذا الإجراء.", "issues.deleteAttachmentDesc": "لا يمكن التراجع عن هذا الإجراء.",
"artefacts.editLanguage": "تعديل اللغة",
"artefacts.linkedPost": "المنشور المرتبط",
"artefacts.post": "منشور",
"artefacts.deleteLanguage": "حذف هذه اللغة؟", "artefacts.deleteLanguage": "حذف هذه اللغة؟",
"artefacts.deleteLanguageDesc": "سيتم إزالة المحتوى لهذه اللغة.", "artefacts.deleteLanguageDesc": "سيتم إزالة المحتوى لهذه اللغة.",
"artefacts.deleteAttachment": "حذف هذا المرفق؟", "artefacts.deleteAttachment": "حذف هذا المرفق؟",
@@ -694,6 +794,9 @@
"team.selectRole": "اختر دوراً...", "team.selectRole": "اختر دوراً...",
"common.team": "الفريق", "common.team": "الفريق",
"common.noTeam": "بدون فريق", "common.noTeam": "بدون فريق",
"common.none": "بدون",
"common.untitled": "بدون عنوان",
"common.success": "تم بنجاح",
"common.error": "حدث خطأ", "common.error": "حدث خطأ",
"settings.roles": "الأدوار", "settings.roles": "الأدوار",
"settings.rolesDesc": "حدد أدوار العمل مثل مصمم، استراتيجي، إلخ. يتم تعيينها لأعضاء الفريق بشكل منفصل عن مستويات الصلاحية.", "settings.rolesDesc": "حدد أدوار العمل مثل مصمم، استراتيجي، إلخ. يتم تعيينها لأعضاء الفريق بشكل منفصل عن مستويات الصلاحية.",
@@ -717,6 +820,11 @@
"header.budgets": "الميزانيات", "header.budgets": "الميزانيات",
"header.issues": "البلاغات", "header.issues": "البلاغات",
"header.settings": "الإعدادات", "header.settings": "الإعدادات",
"header.translations": "الترجمات",
"header.copy": "النسخ",
"header.postDetails": "تفاصيل المنشور",
"calendar.unscheduledPosts": "منشورات غير مجدولة",
"calendar.statusLegend": "دليل الحالات",
"header.users": "إدارة المستخدمين", "header.users": "إدارة المستخدمين",
"header.projectDetails": "تفاصيل المشروع", "header.projectDetails": "تفاصيل المشروع",
"header.campaignDetails": "تفاصيل الحملة", "header.campaignDetails": "تفاصيل الحملة",
@@ -814,6 +922,8 @@
"artefacts.descriptionLabel": "الوصف", "artefacts.descriptionLabel": "الوصف",
"artefacts.descriptionFieldPlaceholder": "أضف وصفاً...", "artefacts.descriptionFieldPlaceholder": "أضف وصفاً...",
"artefacts.approversLabel": "المعتمدون", "artefacts.approversLabel": "المعتمدون",
"artefacts.reviewer": "المراجع",
"artefacts.selectReviewer": "اختر مراجعاً...",
"artefacts.versions": "الإصدارات", "artefacts.versions": "الإصدارات",
"artefacts.newVersion": "إصدار جديد", "artefacts.newVersion": "إصدار جديد",
"artefacts.languages": "اللغات", "artefacts.languages": "اللغات",
@@ -822,6 +932,8 @@
"artefacts.imagesLabel": "الصور", "artefacts.imagesLabel": "الصور",
"artefacts.uploadImage": "رفع صورة", "artefacts.uploadImage": "رفع صورة",
"artefacts.uploading": "جاري الرفع...", "artefacts.uploading": "جاري الرفع...",
"artefacts.dropOrClickImage": "اسحب الصور هنا أو انقر للرفع",
"artefacts.imageFormats": "PNG, JPG, WebP",
"artefacts.noImages": "لم يتم رفع صور بعد", "artefacts.noImages": "لم يتم رفع صور بعد",
"artefacts.videosLabel": "الفيديوهات", "artefacts.videosLabel": "الفيديوهات",
"artefacts.addVideoBtn": "إضافة فيديو", "artefacts.addVideoBtn": "إضافة فيديو",
@@ -1065,5 +1177,55 @@
"translations.createPost": "منشور جديد", "translations.createPost": "منشور جديد",
"translations.newPostTitle": "عنوان المنشور...", "translations.newPostTitle": "عنوان المنشور...",
"translations.postCreated": "تم إنشاء المنشور!", "translations.postCreated": "تم إنشاء المنشور!",
"translations.postCreateFailed": "فشل إنشاء المنشور" "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": "اليوم"
} }
+169 -7
View File
@@ -1,6 +1,6 @@
{ {
"app.name": "Digital Hub", "app.name": "Rawaj",
"app.subtitle": "Platform", "app.subtitle": "Marketing Hub",
"nav.dashboard": "Dashboard", "nav.dashboard": "Dashboard",
"nav.campaigns": "Campaigns", "nav.campaigns": "Campaigns",
"nav.finance": "Finance & ROI", "nav.finance": "Finance & ROI",
@@ -31,6 +31,7 @@
"common.loading": "Loading...", "common.loading": "Loading...",
"common.unassigned": "Unassigned", "common.unassigned": "Unassigned",
"common.close": "Close", "common.close": "Close",
"common.created": "Created",
"common.required": "Required", "common.required": "Required",
"common.saveFailed": "Failed to save. Please try again.", "common.saveFailed": "Failed to save. Please try again.",
"common.updateFailed": "Failed to update. 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.noPostsYet": "No posts yet. Create your first post!",
"dashboard.upcomingDeadlines": "Upcoming Deadlines", "dashboard.upcomingDeadlines": "Upcoming Deadlines",
"dashboard.noUpcomingDeadlines": "No upcoming deadlines this week. 🎉", "dashboard.noUpcomingDeadlines": "No upcoming deadlines this week. 🎉",
"dashboard.loadingHub": "Loading Digital Hub...", "dashboard.loadingHub": "Loading Rawaj...",
"posts.title": "Post Production", "posts.title": "Post Production",
"posts.newPost": "New Post", "posts.newPost": "New Post",
"posts.editPost": "Edit Post", "posts.editPost": "Edit Post",
@@ -78,6 +79,29 @@
"posts.saveChanges": "Save Changes", "posts.saveChanges": "Save Changes",
"posts.postTitle": "Title", "posts.postTitle": "Title",
"posts.description": "Description", "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.brand": "Brand",
"posts.platforms": "Platforms", "posts.platforms": "Platforms",
"posts.status": "Status", "posts.status": "Status",
@@ -271,7 +295,7 @@
"settings.english": "English", "settings.english": "English",
"settings.arabic": "Arabic", "settings.arabic": "Arabic",
"settings.restartTutorial": "Restart Tutorial", "settings.restartTutorial": "Restart Tutorial",
"settings.tutorialDesc": "Need a refresher? Restart the interactive tutorial to learn about all the features of Digital Hub.", "settings.tutorialDesc": "Need a refresher? Restart the interactive tutorial to learn about all the features of Rawaj.",
"settings.general": "General", "settings.general": "General",
"settings.onboardingTutorial": "Onboarding Tutorial", "settings.onboardingTutorial": "Onboarding Tutorial",
"settings.tutorialRestarted": "Tutorial Restarted!", "settings.tutorialRestarted": "Tutorial Restarted!",
@@ -315,7 +339,7 @@
"tutorial.newPost.desc": "Start creating content here. Pick your brand, platforms, and assign it to a team member.", "tutorial.newPost.desc": "Start creating content here. Pick your brand, platforms, and assign it to a team member.",
"tutorial.filters.title": "Filter & Focus", "tutorial.filters.title": "Filter & Focus",
"tutorial.filters.desc": "Use filters to focus on specific brands, platforms, or team members.", "tutorial.filters.desc": "Use filters to focus on specific brands, platforms, or team members.",
"login.title": "Digital Hub", "login.title": "Rawaj",
"login.subtitle": "Sign in to continue", "login.subtitle": "Sign in to continue",
"login.forgotPassword": "Forgot password?", "login.forgotPassword": "Forgot password?",
"login.defaultCreds": "Default credentials:", "login.defaultCreds": "Default credentials:",
@@ -396,6 +420,16 @@
"campaigns.editCampaign": "Edit Campaign", "campaigns.editCampaign": "Edit Campaign",
"campaigns.deleteCampaign": "Delete Campaign?", "campaigns.deleteCampaign": "Delete Campaign?",
"campaigns.deleteConfirm": "Are you sure you want to delete this campaign? All associated data will be removed. This action cannot be undone.", "campaigns.deleteConfirm": "Are you sure you want to delete this campaign? All associated data will be removed. This action cannot be undone.",
"campaigns.tracks": "Tracks",
"campaigns.addTrack": "Add Track",
"campaigns.noTracks": "No tracks yet. Add organic, paid, or SEO tracks to organize this campaign.",
"campaigns.postsLinked": "posts linked",
"campaigns.team": "Team",
"campaigns.assignMembers": "Assign Members",
"campaigns.linkedPosts": "Linked Posts",
"campaigns.notFound": "Campaign not found.",
"common.goBack": "Go back",
"finance.allocated": "allocated",
"tracks.details": "Details", "tracks.details": "Details",
"tracks.metrics": "Metrics", "tracks.metrics": "Metrics",
"tracks.trackName": "Track Name", "tracks.trackName": "Track Name",
@@ -503,6 +537,59 @@
"budgets.dateExpensed": "Date", "budgets.dateExpensed": "Date",
"dashboard.expenses": "Expenses", "dashboard.expenses": "Expenses",
"finance.expenses": "Total Expenses", "finance.expenses": "Total Expenses",
"finance.totalReceived": "Total Received",
"finance.totalSpent": "Total Spent",
"finance.remaining": "Remaining",
"finance.revenue": "Revenue",
"finance.globalROI": "Global ROI",
"finance.budgetAllocation": "Budget Allocation",
"finance.manageBudgets": "Manage Budgets",
"finance.campaigns": "Campaigns",
"finance.projects": "Projects",
"finance.unallocated": "Unallocated",
"finance.budgetUtilization": "Budget Utilization",
"finance.globalPerformance": "Global Performance",
"finance.impressions": "Impressions",
"finance.clicks": "Clicks",
"finance.conversions": "Conversions",
"finance.campaignBreakdown": "Campaign Breakdown",
"finance.allocatedFunds": "Allocated Funds",
"finance.requestBudget": "Request Budget",
"finance.budgetRequests": "Budget Requests",
"finance.pendingApproval": "pending CEO approval",
"finance.justification": "Justification",
"finance.earmarkFor": "Earmark for",
"finance.submitRequest": "Submit Request",
"finance.cancelRequest": "Cancel Request",
"finance.approved": "Approved",
"finance.rejected": "Rejected",
"finance.cancelled": "Cancelled",
"finance.pending": "Pending",
"finance.ceoNote": "CEO Note",
"finance.requestPending": "budget request(s) pending CEO approval",
"finance.insufficientBudget": "Insufficient budget",
"finance.availableBudget": "Available",
"finance.requestMore": "Request more funds",
"finance.noCeoEmail": "CEO email not configured. Go to Settings.",
"finance.amount": "Amount",
"finance.justificationPlaceholder": "Why is this budget needed?",
"finance.optional": "Optional",
"settings.budgetApproval": "Budget Approval",
"settings.ceoEmail": "CEO / Budget Approver Email",
"settings.ceoEmailHint": "Email address that receives budget approval requests",
"budgetApproval.title": "Budget Approval",
"budgetApproval.amount": "Requested Amount",
"budgetApproval.requestedBy": "Requested by",
"budgetApproval.justification": "Justification",
"budgetApproval.earmarkedFor": "Earmarked for",
"budgetApproval.approve": "Approve",
"budgetApproval.reject": "Reject",
"budgetApproval.addNote": "Add a note (optional)",
"budgetApproval.approved": "This request has been approved.",
"budgetApproval.rejected": "This request has been rejected.",
"budgetApproval.expired": "This request has expired.",
"budgetApproval.alreadyHandled": "This request has already been processed.",
"finance.ofBudget": "of budget",
"settings.uploads": "Uploads", "settings.uploads": "Uploads",
"settings.maxFileSize": "Maximum File Size", "settings.maxFileSize": "Maximum File Size",
"settings.maxFileSizeHint": "Maximum allowed file size for attachments (1-500 MB)", "settings.maxFileSizeHint": "Maximum allowed file size for attachments (1-500 MB)",
@@ -520,6 +607,11 @@
"issues.noIssuesInColumn": "No issues", "issues.noIssuesInColumn": "No issues",
"artefacts.details": "Details", "artefacts.details": "Details",
"artefacts.review": "Review", "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.grid": "Grid",
"artefacts.list": "List", "artefacts.list": "List",
"artefacts.allCreators": "All Creators", "artefacts.allCreators": "All Creators",
@@ -629,7 +721,7 @@
"review.alreadyReviewed": "This artefact has already been reviewed.", "review.alreadyReviewed": "This artefact has already been reviewed.",
"review.statusLabel": "Status", "review.statusLabel": "Status",
"review.reviewedBy": "Reviewed by", "review.reviewedBy": "Reviewed by",
"review.poweredBy": "Powered by Samaya Digital Hub", "review.poweredBy": "Powered by Rawaj",
"review.loadFailed": "Failed to load artefact", "review.loadFailed": "Failed to load artefact",
"review.actionFailed": "Action failed", "review.actionFailed": "Action failed",
"review.actionCompleted": "Action completed successfully", "review.actionCompleted": "Action completed successfully",
@@ -638,6 +730,11 @@
"review.confirmReject": "Reject this artefact?", "review.confirmReject": "Reject this artefact?",
"review.feedbackRequired": "Please provide feedback for revision request", "review.feedbackRequired": "Please provide feedback for revision request",
"review.contentLanguages": "Content Languages", "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.content": "Content",
"review.designFiles": "Design Files", "review.designFiles": "Design Files",
"review.videos": "Videos", "review.videos": "Videos",
@@ -665,6 +762,9 @@
"issues.trackingLinkCopied": "Tracking link copied to clipboard!", "issues.trackingLinkCopied": "Tracking link copied to clipboard!",
"issues.deleteAttachment": "Delete attachment?", "issues.deleteAttachment": "Delete attachment?",
"issues.deleteAttachmentDesc": "This action cannot be undone.", "issues.deleteAttachmentDesc": "This action cannot be undone.",
"artefacts.editLanguage": "Edit Language",
"artefacts.linkedPost": "Linked Post",
"artefacts.post": "Post",
"artefacts.deleteLanguage": "Delete this language?", "artefacts.deleteLanguage": "Delete this language?",
"artefacts.deleteLanguageDesc": "The content for this language will be removed.", "artefacts.deleteLanguageDesc": "The content for this language will be removed.",
"artefacts.deleteAttachment": "Delete this attachment?", "artefacts.deleteAttachment": "Delete this attachment?",
@@ -694,6 +794,9 @@
"team.selectRole": "Select role...", "team.selectRole": "Select role...",
"common.team": "Team", "common.team": "Team",
"common.noTeam": "No team", "common.noTeam": "No team",
"common.none": "None",
"common.untitled": "Untitled",
"common.success": "Success",
"common.error": "An error occurred", "common.error": "An error occurred",
"settings.roles": "Roles", "settings.roles": "Roles",
"settings.rolesDesc": "Define job roles like Designer, Strategist, etc. These are assigned to team members separately from permission levels.", "settings.rolesDesc": "Define job roles like Designer, Strategist, etc. These are assigned to team members separately from permission levels.",
@@ -717,6 +820,11 @@
"header.budgets": "Budgets", "header.budgets": "Budgets",
"header.issues": "Issues", "header.issues": "Issues",
"header.settings": "Settings", "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.users": "User Management",
"header.projectDetails": "Project Details", "header.projectDetails": "Project Details",
"header.campaignDetails": "Campaign Details", "header.campaignDetails": "Campaign Details",
@@ -814,6 +922,8 @@
"artefacts.descriptionLabel": "Description", "artefacts.descriptionLabel": "Description",
"artefacts.descriptionFieldPlaceholder": "Add a description...", "artefacts.descriptionFieldPlaceholder": "Add a description...",
"artefacts.approversLabel": "Approvers", "artefacts.approversLabel": "Approvers",
"artefacts.reviewer": "Reviewer",
"artefacts.selectReviewer": "Select a reviewer...",
"artefacts.versions": "Versions", "artefacts.versions": "Versions",
"artefacts.newVersion": "New Version", "artefacts.newVersion": "New Version",
"artefacts.languages": "Languages", "artefacts.languages": "Languages",
@@ -822,6 +932,8 @@
"artefacts.imagesLabel": "Images", "artefacts.imagesLabel": "Images",
"artefacts.uploadImage": "Upload Image", "artefacts.uploadImage": "Upload Image",
"artefacts.uploading": "Uploading...", "artefacts.uploading": "Uploading...",
"artefacts.dropOrClickImage": "Drop images here or click to upload",
"artefacts.imageFormats": "PNG, JPG, WebP",
"artefacts.noImages": "No images uploaded yet", "artefacts.noImages": "No images uploaded yet",
"artefacts.videosLabel": "Videos", "artefacts.videosLabel": "Videos",
"artefacts.addVideoBtn": "Add Video", "artefacts.addVideoBtn": "Add Video",
@@ -1065,5 +1177,55 @@
"translations.createPost": "New Post", "translations.createPost": "New Post",
"translations.newPostTitle": "Post title...", "translations.newPostTitle": "Post title...",
"translations.postCreated": "Post created!", "translations.postCreated": "Post created!",
"translations.postCreateFailed": "Failed to create post" "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"; @import "tailwindcss";
@theme { @theme {
--font-sans: 'Inter', 'IBM Plex Sans Arabic', system-ui, -apple-system, sans-serif; --font-sans: 'DM Sans', 'IBM Plex Sans Arabic', system-ui, -apple-system, sans-serif;
--color-sidebar: #0f172a; --color-sidebar: #0a1f1c;
--color-sidebar-hover: #1e293b; --color-sidebar-hover: #123b35;
--color-sidebar-active: #020617; --color-sidebar-active: #061411;
--color-brand-primary: #4f46e5; --color-brand-primary: #0d9488;
--color-brand-primary-light: #6366f1; --color-brand-primary-light: #14b8a6;
--color-brand-secondary: #db2777; --color-brand-secondary: #db2777;
--color-brand-tertiary: #f59e0b; --color-brand-tertiary: #f59e0b;
--color-brand-quaternary: #059669; --color-brand-quaternary: #0d9488;
--color-surface: #ffffff; --color-surface: #ffffff;
--color-surface-secondary: #f9fafb; --color-surface-secondary: #f9fafb;
--color-surface-tertiary: #f3f4f6; --color-surface-tertiary: #f3f4f6;
@@ -37,40 +37,39 @@
} }
/* ═══════════════════════════════════════════════ /* ═══════════════════════════════════════════════
DARK MODE — Inspired by SpaceTime DARK MODE — Forest teal tinted surfaces
Deep layered surfaces, glass edges, ambient glow
═══════════════════════════════════════════════ */ ═══════════════════════════════════════════════ */
.dark { .dark {
/* Layered depth: void → surface → surface-2surface-3 */ /* Layered depth: deep forest → surface → elevated */
--color-surface: #15151e; --color-surface: #0f1a18;
--color-surface-secondary: #1c1c2a; --color-surface-secondary: #162220;
--color-surface-tertiary: #24243a; --color-surface-tertiary: #1e2e2b;
--color-border: rgba(255, 255, 255, 0.08); --color-border: rgba(255, 255, 255, 0.08);
--color-border-light: rgba(255, 255, 255, 0.04); --color-border-light: rgba(255, 255, 255, 0.04);
/* Text — crisp hierarchy */ /* Text — warm neutrals, teal-tinted */
--color-text-primary: #eeecf5; --color-text-primary: #e8f0ee;
--color-text-secondary: #a8a3c0; --color-text-secondary: #9db5b0;
--color-text-tertiary: #706b8a; --color-text-tertiary: #637e78;
/* Sidebar */ /* Sidebar */
--color-sidebar: #0e0e16; --color-sidebar: #0a1412;
--color-sidebar-hover: #15151e; --color-sidebar-hover: #0f1a18;
--color-sidebar-active: #0a0a12; --color-sidebar-active: #060e0c;
/* Brand — brighter on dark */ /* Brand — brighter on dark */
--color-brand-primary: #8b5cf6; --color-brand-primary: #14b8a6;
--color-brand-primary-light: #a78bfa; --color-brand-primary-light: #2dd4bf;
color-scheme: dark; color-scheme: dark;
background-color: #15151e; background-color: #0f1a18;
color: #eeecf5; color: #e8f0ee;
} }
/* ─── Ambient background glow ────────────────── */ /* ─── Ambient background glow ────────────────── */
.dark .bg-mesh { .dark .bg-mesh {
background-color: #15151e !important; background-color: #0f1a18 !important;
background-image: none !important; background-image: none !important;
} }
.dark .bg-mesh::before { .dark .bg-mesh::before {
@@ -78,9 +77,8 @@
position: fixed; position: fixed;
inset: 0; inset: 0;
background: background:
radial-gradient(ellipse 70% 50% at 20% 50%, rgba(139, 92, 246, 0.045) 0%, transparent 60%), radial-gradient(ellipse 70% 50% at 20% 50%, rgba(13, 148, 136, 0.04) 0%, transparent 60%),
radial-gradient(ellipse 50% 40% at 80% 30%, rgba(56, 189, 248, 0.03) 0%, transparent 60%), radial-gradient(ellipse 50% 40% at 80% 30%, rgba(20, 184, 166, 0.025) 0%, transparent 60%);
radial-gradient(ellipse 60% 40% at 50% 90%, rgba(232, 168, 56, 0.02) 0%, transparent 60%);
pointer-events: none; pointer-events: none;
z-index: 0; z-index: 0;
} }
@@ -89,11 +87,11 @@
.dark .bg-white, .dark .bg-white,
.dark .bg-\[\#fff\], .dark .bg-\[\#fff\],
.dark .bg-\[\#ffffff\] { .dark .bg-\[\#ffffff\] {
background-color: #22223a !important; background-color: #1a2a28 !important;
} }
.dark .bg-gray-50 { background-color: #15151e !important; } .dark .bg-gray-50 { background-color: #0f1a18 !important; }
.dark .bg-gray-100 { background-color: #1c1c2a !important; } .dark .bg-gray-100 { background-color: #162220 !important; }
.dark .bg-gray-200 { background-color: #24243a !important; } .dark .bg-gray-200 { background-color: #1e2e2b !important; }
/* ─── Borders ────────────────────────────────── */ /* ─── Borders ────────────────────────────────── */
.dark .border-gray-100, .dark .border-gray-100,
@@ -104,12 +102,12 @@
.dark .divide-border-light > :not(:first-child) { border-color: rgba(255, 255, 255, 0.05) !important; } .dark .divide-border-light > :not(:first-child) { border-color: rgba(255, 255, 255, 0.05) !important; }
/* ─── Text ───────────────────────────────────── */ /* ─── Text ───────────────────────────────────── */
.dark .text-gray-900 { color: #eeecf5 !important; } .dark .text-gray-900 { color: #e8f0ee !important; }
.dark .text-gray-800 { color: #d8d5e8 !important; } .dark .text-gray-800 { color: #d0ddd9 !important; }
.dark .text-gray-700 { color: #c2bedb !important; } .dark .text-gray-700 { color: #b5cac5 !important; }
.dark .text-gray-600 { color: #a8a3c0 !important; } .dark .text-gray-600 { color: #9db5b0 !important; }
.dark .text-gray-500 { color: #8b85a8 !important; } .dark .text-gray-500 { color: #7e9a94 !important; }
.dark .text-gray-400 { color: #706b8a !important; } .dark .text-gray-400 { color: #637e78 !important; }
/* ─── Status badges — translucent glass ──────── */ /* ─── Status badges — translucent glass ──────── */
.dark .bg-emerald-100, .dark .bg-emerald-50 { background-color: rgba(74, 222, 128, 0.12) !important; } .dark .bg-emerald-100, .dark .bg-emerald-50 { background-color: rgba(74, 222, 128, 0.12) !important; }
@@ -150,49 +148,49 @@
.dark input:focus, .dark input:focus,
.dark select:focus, .dark select:focus,
.dark textarea:focus { .dark textarea:focus {
border-color: rgba(139, 92, 246, 0.5); border-color: rgba(20, 184, 166, 0.5);
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.1); box-shadow: 0 0 0 3px rgba(20, 184, 166, 0.1);
} }
.dark input::placeholder, .dark input::placeholder,
.dark textarea::placeholder { .dark textarea::placeholder {
color: #706b8a; color: #637e78;
} }
.dark input:disabled, .dark input:disabled,
.dark select:disabled, .dark select:disabled,
.dark textarea:disabled { .dark textarea:disabled {
background-color: rgba(255, 255, 255, 0.02) !important; background-color: rgba(255, 255, 255, 0.02) !important;
color: #706b8a !important; color: #637e78 !important;
opacity: 0.6; opacity: 0.6;
} }
/* Dark select arrow */ /* Dark select arrow */
.dark select { .dark select {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23706b8a' d='M6 9L1 4h10z'/%3E%3C/svg%3E"); background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23637e78' d='M6 9L1 4h10z'/%3E%3C/svg%3E");
} }
/* ─── Cards — glass edges ────────────────────── */ /* ─── Cards — glass edges ────────────────────── */
.dark .card-hover { .dark .card-hover {
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.04), 0 2px 8px rgba(0, 0, 0, 0.3); box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.04);
} }
.dark .card-hover:hover { .dark .card-hover:hover {
box-shadow: 0 0 0 1px rgba(139, 92, 246, 0.15), 0 16px 48px -12px rgba(0, 0, 0, 0.5); box-shadow: 0 0 0 1px rgba(20, 184, 166, 0.12), 0 4px 16px -4px rgba(0, 0, 0, 0.4);
} }
.dark .section-card { .dark .section-card {
background: #1c1c2a; background: #162220;
border-color: rgba(255, 255, 255, 0.06); border-color: rgba(255, 255, 255, 0.06);
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.04); box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.04);
} }
.dark .section-card:hover { .dark .section-card:hover {
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.06), 0 8px 32px -8px rgba(0, 0, 0, 0.4); box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.06), 0 4px 16px -4px rgba(0, 0, 0, 0.3);
} }
.dark .section-card-header { .dark .section-card-header {
background: linear-gradient(180deg, rgba(36, 36, 58, 0.5) 0%, #1c1c2a 100%); background: rgba(30, 46, 43, 0.3);
} }
/* ─── Sidebar ────────────────────────────────── */ /* ─── Sidebar ────────────────────────────────── */
.dark .sidebar { .dark .sidebar {
background: linear-gradient(180deg, #0e0e16 0%, #0a0a12 100%); background: linear-gradient(180deg, #0a1412 0%, #060e0c 100%);
box-shadow: 2px 0 24px rgba(0, 0, 0, 0.5); box-shadow: 2px 0 24px rgba(0, 0, 0, 0.5);
} }
@@ -216,22 +214,22 @@
.dark .hover\:bg-red-50:hover { background-color: rgba(251, 113, 133, 0.08) !important; } .dark .hover\:bg-red-50:hover { background-color: rgba(251, 113, 133, 0.08) !important; }
.dark .hover\:bg-blue-100:hover { background-color: rgba(96, 165, 250, 0.08) !important; } .dark .hover\:bg-blue-100:hover { background-color: rgba(96, 165, 250, 0.08) !important; }
/* ─── Brand glow ─────────────────────────────── */ /* ─── Brand accent ────────────────────────────── */
.dark .bg-brand-primary { .dark .bg-brand-primary {
box-shadow: 0 0 24px -4px rgba(139, 92, 246, 0.35); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
} }
.dark .bg-brand-primary:hover { .dark .bg-brand-primary:hover {
box-shadow: 0 0 32px -4px rgba(139, 92, 246, 0.45); box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4);
} }
/* ─── White/light text overrides on colored badges ── */ /* ─── White/light text overrides on colored badges ── */
.dark .bg-white\/90 { background-color: rgba(28, 28, 42, 0.9) !important; } .dark .bg-white\/90 { background-color: rgba(22, 34, 32, 0.9) !important; }
/* ─── Toasts — solid backgrounds, no transparency ── */ /* ─── Toasts — solid backgrounds ────────────────── */
.dark .bg-emerald-50.border-emerald-200 { background-color: #132a1e !important; border-color: #1a4a2e !important; } .dark .bg-emerald-50.border-emerald-200 { background-color: #0f2a1e !important; border-color: #154a2e !important; }
.dark .bg-red-50.border-red-200 { background-color: #2a1318 !important; border-color: #4a1a22 !important; } .dark .bg-red-50.border-red-200 { background-color: #2a1315 !important; border-color: #4a1a20 !important; }
.dark .bg-blue-50.border-blue-200 { background-color: #131d2a !important; border-color: #1a2e4a !important; } .dark .bg-blue-50.border-blue-200 { background-color: #0f1d2a !important; border-color: #152e4a !important; }
.dark .bg-amber-50.border-amber-200 { background-color: #2a2213 !important; border-color: #4a3a1a !important; } .dark .bg-amber-50.border-amber-200 { background-color: #2a2210 !important; border-color: #4a3a15 !important; }
.dark .text-emerald-800 { color: #6ee7b7 !important; } .dark .text-emerald-800 { color: #6ee7b7 !important; }
.dark .text-red-800 { color: #fca5a5 !important; } .dark .text-red-800 { color: #fca5a5 !important; }
.dark .text-blue-800 { color: #93c5fd !important; } .dark .text-blue-800 { color: #93c5fd !important; }
@@ -239,10 +237,19 @@
/* ─── Selection ──────────────────────────────── */ /* ─── Selection ──────────────────────────────── */
.dark ::selection { .dark ::selection {
background: rgba(139, 92, 246, 0.4); background: rgba(20, 184, 166, 0.4);
color: white; color: white;
} }
/* Reduced motion — disable animations for accessibility */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* Custom scrollbar */ /* Custom scrollbar */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 6px; width: 6px;
@@ -315,15 +322,15 @@ textarea {
margin-right: 0; margin-right: 0;
} }
/* Enhanced sidebar with gradient */ /* Enhanced sidebar */
.sidebar { .sidebar {
background: linear-gradient(180deg, #0f172a 0%, #020617 100%); background: linear-gradient(180deg, #0a1f1c 0%, #061411 100%);
box-shadow: 2px 0 12px rgba(0, 0, 0, 0.08); box-shadow: 2px 0 12px rgba(0, 0, 0, 0.08);
} }
/* Animation keyframes */ /* Animation keyframes */
@keyframes fadeIn { @keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); } from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); } to { opacity: 1; transform: translateY(0); }
} }
@@ -347,11 +354,6 @@ textarea {
50% { opacity: 0.7; } 50% { opacity: 0.7; }
} }
@keyframes bounce-subtle {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-4px); }
}
@keyframes spin { @keyframes spin {
from { transform: rotate(0deg); } from { transform: rotate(0deg); }
to { transform: rotate(360deg); } to { transform: rotate(360deg); }
@@ -425,29 +427,24 @@ textarea {
overflow: visible; overflow: visible;
} }
/* Stagger children */ /* Stagger children — short, max 4 items */
.stagger-children > * { .stagger-children > * {
opacity: 0; opacity: 0;
animation: fadeIn 0.3s ease-out forwards; animation: fadeIn 0.2s ease-out forwards;
} }
.stagger-children > *:nth-child(1) { animation-delay: 0ms; } .stagger-children > *:nth-child(1) { animation-delay: 0ms; }
.stagger-children > *:nth-child(2) { animation-delay: 50ms; } .stagger-children > *:nth-child(2) { animation-delay: 40ms; }
.stagger-children > *:nth-child(3) { animation-delay: 100ms; } .stagger-children > *:nth-child(3) { animation-delay: 80ms; }
.stagger-children > *:nth-child(4) { animation-delay: 150ms; } .stagger-children > *:nth-child(n+4) { animation-delay: 120ms; }
.stagger-children > *:nth-child(5) { animation-delay: 200ms; }
.stagger-children > *:nth-child(6) { animation-delay: 250ms; }
.stagger-children > *:nth-child(7) { animation-delay: 300ms; }
.stagger-children > *:nth-child(8) { animation-delay: 350ms; }
/* Card hover effect - smooth and elegant */ /* Card hover effect - refined, no lift */
.card-hover { .card-hover {
position: relative; position: relative;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); transition: box-shadow 0.2s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05), 0 1px 2px rgba(0, 0, 0, 0.06); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
} }
.card-hover:hover { .card-hover:hover {
transform: translateY(-3px); box-shadow: 0 4px 12px -2px rgba(0, 0, 0, 0.08);
box-shadow: 0 12px 28px -6px rgba(0, 0, 0, 0.12), 0 6px 16px -8px rgba(0, 0, 0, 0.08);
} }
/* Stat card accents - subtle colored top borders */ /* Stat card accents - subtle colored top borders */
@@ -470,24 +467,12 @@ textarea {
opacity: 1; opacity: 1;
} }
/* Mesh background - subtle radial gradients */ /* Mesh background — flat, no gradients */
.bg-mesh { .bg-mesh {
background-color: #f8fafc; background-color: #f8fafc;
background-image:
radial-gradient(at 20% 20%, rgba(79, 70, 229, 0.04) 0, transparent 50%),
radial-gradient(at 80% 40%, rgba(219, 39, 119, 0.03) 0, transparent 50%),
radial-gradient(at 40% 80%, rgba(5, 150, 105, 0.03) 0, transparent 50%);
} }
/* Gradient text */ /* Stat card accent — subtle top border, no gradient */
.text-gradient {
background: linear-gradient(135deg, var(--color-brand-primary) 0%, #7c3aed 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Premium stat card - always-visible gradient top bar */
.stat-card-premium { .stat-card-premium {
position: relative; position: relative;
overflow: hidden; overflow: hidden;
@@ -498,20 +483,20 @@ textarea {
top: 0; top: 0;
left: 0; left: 0;
right: 0; right: 0;
height: 3px; height: 2px;
opacity: 1; opacity: 0.6;
} }
.stat-card-premium.accent-primary::before { .stat-card-premium.accent-primary::before {
background: linear-gradient(90deg, #4f46e5, #7c3aed); background: #0d9488;
} }
.stat-card-premium.accent-secondary::before { .stat-card-premium.accent-secondary::before {
background: linear-gradient(90deg, #db2777, #ec4899); background: #db2777;
} }
.stat-card-premium.accent-tertiary::before { .stat-card-premium.accent-tertiary::before {
background: linear-gradient(90deg, #f59e0b, #fbbf24); background: #f59e0b;
} }
.stat-card-premium.accent-quaternary::before { .stat-card-premium.accent-quaternary::before {
background: linear-gradient(90deg, #059669, #34d399); background: #059669;
} }
/* Section card - premium container */ /* Section card - premium container */
@@ -519,25 +504,24 @@ textarea {
background: white; background: white;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: 1rem; border-radius: 1rem;
overflow: hidden; overflow: clip;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
transition: box-shadow 0.3s ease; transition: box-shadow 0.3s ease;
} }
.section-card:hover { .section-card:hover {
box-shadow: 0 4px 20px -4px rgba(0, 0, 0, 0.08); box-shadow: 0 2px 8px -2px rgba(0, 0, 0, 0.06);
} }
.section-card-header { .section-card-header {
padding: 1rem 1.25rem; padding: 1rem 1.25rem;
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
background: linear-gradient(180deg, rgba(249, 250, 251, 0.5) 0%, white 100%);
} }
/* Sidebar active glow */ /* Sidebar active glow */
.sidebar-active-glow { .sidebar-active-glow {
box-shadow: inset 3px 0 0 rgba(129, 140, 248, 0.8); box-shadow: inset 3px 0 0 rgba(20, 184, 166, 0.8);
} }
[dir="rtl"] .sidebar-active-glow { [dir="rtl"] .sidebar-active-glow {
box-shadow: inset -3px 0 0 rgba(129, 140, 248, 0.8); box-shadow: inset -3px 0 0 rgba(20, 184, 166, 0.8);
} }
/* Refined button styles */ /* Refined button styles */
@@ -594,23 +578,6 @@ select:not(:disabled):hover {
grid-template-columns: repeat(7, 1fr); grid-template-columns: repeat(7, 1fr);
} }
/* Ripple effect on buttons (optional enhancement) */
@keyframes ripple {
0% {
transform: scale(0);
opacity: 0.5;
}
100% {
transform: scale(2.5);
opacity: 0;
}
}
/* Badge pulse animation */
.badge-pulse {
animation: pulse-subtle 2s ease-in-out infinite;
}
/* Smooth height transitions */ /* Smooth height transitions */
.transition-height { .transition-height {
transition: height 0.3s cubic-bezier(0.4, 0, 0.2, 1); transition: height 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+28 -85
View File
@@ -11,7 +11,7 @@ import { useToast } from '../components/ToastContainer'
import ArtefactVersionTimeline from '../components/ArtefactVersionTimeline' import ArtefactVersionTimeline from '../components/ArtefactVersionTimeline'
import { SkeletonCard, SkeletonTable } from '../components/SkeletonLoader' import { SkeletonCard, SkeletonTable } from '../components/SkeletonLoader'
import ArtefactDetailPanel from '../components/ArtefactDetailPanel' import ArtefactDetailPanel from '../components/ArtefactDetailPanel'
import ApproverMultiSelect from '../components/ApproverMultiSelect' import PortalSelect from '../components/PortalSelect'
const STATUS_COLORS = { const STATUS_COLORS = {
draft: 'bg-surface-tertiary text-text-secondary', draft: 'bg-surface-tertiary text-text-secondary',
@@ -56,7 +56,7 @@ export default function Artefacts() {
const [searchTerm, setSearchTerm] = useState('') const [searchTerm, setSearchTerm] = useState('')
const [showCreateModal, setShowCreateModal] = useState(false) const [showCreateModal, setShowCreateModal] = useState(false)
const [selectedArtefact, setSelectedArtefact] = useState(null) 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) const [saving, setSaving] = useState(false)
// Bulk select // Bulk select
@@ -101,12 +101,12 @@ export default function Artefacts() {
setSaving(true) setSaving(true)
try { try {
const created = await api.post('/artefacts', { const created = await api.post('/artefacts', {
...newArtefact, title: newArtefact.title,
approver_ids: newArtefact.approver_ids.length > 0 ? newArtefact.approver_ids.join(',') : null, type: newArtefact.type,
}) })
toast.success(t('artefacts.created')) toast.success(t('artefacts.created'))
setShowCreateModal(false) setShowCreateModal(false)
setNewArtefact({ title: '', description: '', type: 'copy', brand_id: '', content: '', project_id: '', campaign_id: '', approver_ids: [] }) setNewArtefact({ title: '', type: 'copy' })
loadArtefacts() loadArtefacts()
setSelectedArtefact(created) setSelectedArtefact(created)
} catch (err) { } catch (err) {
@@ -199,8 +199,8 @@ export default function Artefacts() {
const SortIcon = ({ col }) => { const SortIcon = ({ col }) => {
if (listSortBy !== col) return null if (listSortBy !== col) return null
return listSortDir === 'asc' return listSortDir === 'asc'
? <ChevronUp className="w-3 h-3 inline ml-0.5" /> ? <ChevronUp className="w-3 h-3 inline ms-0.5" />
: <ChevronDown className="w-3 h-3 inline ml-0.5" /> : <ChevronDown className="w-3 h-3 inline ms-0.5" />
} }
const formatDate = (dateStr) => { const formatDate = (dateStr) => {
@@ -211,11 +211,7 @@ export default function Artefacts() {
return ( return (
<div className="space-y-4 animate-fade-in"> <div className="space-y-4 animate-fade-in">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-end">
<div>
<h1 className="text-2xl font-bold text-text-primary">{t('artefacts.title')}</h1>
<p className="text-sm text-text-secondary mt-1">{t('artefacts.subtitle')}</p>
</div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{/* View switcher */} {/* View switcher */}
<div className="flex items-center bg-surface-tertiary rounded-lg p-0.5"> <div className="flex items-center bg-surface-tertiary rounded-lg p-0.5">
@@ -228,7 +224,7 @@ export default function Artefacts() {
onClick={() => setViewMode(mode)} onClick={() => setViewMode(mode)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${ className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
viewMode === mode viewMode === mode
? 'bg-white text-text-primary shadow-sm' ? 'bg-surface text-text-primary shadow-sm'
: 'text-text-tertiary hover:text-text-secondary' : 'text-text-tertiary hover:text-text-secondary'
}`} }`}
> >
@@ -251,13 +247,13 @@ export default function Artefacts() {
{/* Filters */} {/* Filters */}
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<div className="relative flex-1 min-w-[200px] max-w-md"> <div className="relative flex-1 min-w-[200px] max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" /> <Search className="absolute start-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<input <input
type="text" type="text"
placeholder={t('artefacts.searchArtefacts')} placeholder={t('artefacts.searchArtefacts')}
value={searchTerm} value={searchTerm}
onChange={e => setSearchTerm(e.target.value)} onChange={e => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface transition-colors" className="w-full ps-10 pe-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface transition-colors"
/> />
</div> </div>
@@ -351,7 +347,7 @@ export default function Artefacts() {
<button <button
key={artefact.Id} key={artefact.Id}
onClick={() => setSelectedArtefact(artefact)} onClick={() => setSelectedArtefact(artefact)}
className="bg-surface border border-border rounded-xl p-4 hover:border-brand-primary/30 hover:shadow-sm transition-colors text-left" className="bg-surface border border-border rounded-xl p-4 hover:border-brand-primary/30 hover:shadow-sm transition-colors text-start"
> >
<div className="flex items-start gap-3 mb-2"> <div className="flex items-start gap-3 mb-2">
<div className="w-10 h-10 rounded-lg bg-brand-primary/10 flex items-center justify-center shrink-0"> <div className="w-10 h-10 rounded-lg bg-brand-primary/10 flex items-center justify-center shrink-0">
@@ -418,22 +414,22 @@ export default function Artefacts() {
<th className="w-10 px-3 py-3" onClick={e => e.stopPropagation()}> <th className="w-10 px-3 py-3" onClick={e => e.stopPropagation()}>
<input type="checkbox" checked={selectedIds.size === sortedArtefacts.length && sortedArtefacts.length > 0} onChange={toggleSelectAll} className="rounded border-border" /> <input type="checkbox" checked={selectedIds.size === sortedArtefacts.length && sortedArtefacts.length > 0} onChange={toggleSelectAll} className="rounded border-border" />
</th> </th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleListSort('title')}> <th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleListSort('title')}>
{t('artefacts.titleLabel')} <SortIcon col="title" /> {t('artefacts.titleLabel')} <SortIcon col="title" />
</th> </th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleListSort('type')}> <th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleListSort('type')}>
{t('artefacts.type')} <SortIcon col="type" /> {t('artefacts.type')} <SortIcon col="type" />
</th> </th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleListSort('status')}> <th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleListSort('status')}>
{t('artefacts.status')} <SortIcon col="status" /> {t('artefacts.status')} <SortIcon col="status" />
</th> </th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('artefacts.brand')}</th> <th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('artefacts.brand')}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('artefacts.project')}</th> <th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('artefacts.project')}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('artefacts.campaign')}</th> <th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('artefacts.campaign')}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('artefacts.creator')}</th> <th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('artefacts.creator')}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('artefacts.approvers')}</th> <th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('artefacts.approvers')}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('artefacts.version')}</th> <th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('artefacts.version')}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleListSort('updated_at')}> <th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleListSort('updated_at')}>
{t('artefacts.updated')} <SortIcon col="updated_at" /> {t('artefacts.updated')} <SortIcon col="updated_at" />
</th> </th>
</tr> </tr>
@@ -484,7 +480,7 @@ export default function Artefacts() {
)} )}
{/* Create Modal */} {/* 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 className="space-y-4">
<div> <div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('artefacts.titleLabel')} *</label> <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 }))} 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" 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')} placeholder={t('artefacts.titlePlaceholder')}
autoFocus
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('artefacts.type')}</label> <label className="block text-sm font-medium text-text-primary mb-1">{t('artefacts.type')}</label>
<select <PortalSelect
value={newArtefact.type} value={newArtefact.type}
onChange={e => setNewArtefact(f => ({ ...f, type: e.target.value }))} onChange={val => setNewArtefact(f => ({ ...f, type: val }))}
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" 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"
{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')}
/> />
</div> </div>
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border"> <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)} onClose={() => setSelectedArtefact(null)}
onUpdate={loadArtefacts} onUpdate={loadArtefacts}
onDelete={canDeleteResource('artefact', selectedArtefact) ? handleDelete : undefined} onDelete={canDeleteResource('artefact', selectedArtefact) ? handleDelete : undefined}
projects={projects}
campaigns={campaigns}
assignableUsers={assignableUsers} assignableUsers={assignableUsers}
/> />
)} )}
+8 -8
View File
@@ -181,20 +181,20 @@ export default function Assets() {
{/* Toolbar */} {/* Toolbar */}
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<div className="relative flex-1 min-w-[200px] max-w-md"> <div className="relative flex-1 min-w-[200px] max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" /> <Search className="absolute start-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<input <input
type="text" type="text"
placeholder="Search assets..." placeholder="Search assets..."
value={filters.search} value={filters.search}
onChange={e => setFilters(f => ({ ...f, search: e.target.value }))} onChange={e => setFilters(f => ({ ...f, search: e.target.value }))}
className="w-full pl-10 pr-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-white" className="w-full ps-10 pe-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface"
/> />
</div> </div>
<select <select
value={filters.brand} value={filters.brand}
onChange={e => setFilters(f => ({ ...f, brand: e.target.value }))} onChange={e => setFilters(f => ({ ...f, brand: e.target.value }))}
className="text-sm border border-border rounded-lg px-3 py-2 bg-white text-text-secondary focus:outline-none" className="text-sm border border-border rounded-lg px-3 py-2 bg-surface text-text-secondary focus:outline-none"
> >
<option value="">All Brands</option> <option value="">All Brands</option>
{brands.map(b => <option key={b} value={b}>{b}</option>)} {brands.map(b => <option key={b} value={b}>{b}</option>)}
@@ -203,7 +203,7 @@ export default function Assets() {
<select <select
value={filters.tag} value={filters.tag}
onChange={e => setFilters(f => ({ ...f, tag: e.target.value }))} onChange={e => setFilters(f => ({ ...f, tag: e.target.value }))}
className="text-sm border border-border rounded-lg px-3 py-2 bg-white text-text-secondary focus:outline-none" className="text-sm border border-border rounded-lg px-3 py-2 bg-surface text-text-secondary focus:outline-none"
> >
<option value="">All Tags</option> <option value="">All Tags</option>
{allTags.map(t => <option key={t} value={t}>{t}</option>)} {allTags.map(t => <option key={t} value={t}>{t}</option>)}
@@ -211,7 +211,7 @@ export default function Assets() {
<button <button
onClick={() => setShowUpload(true)} onClick={() => setShowUpload(true)}
className="flex items-center gap-2 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm ml-auto" className="flex items-center gap-2 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm ms-auto"
> >
<Upload className="w-4 h-4" /> <Upload className="w-4 h-4" />
Upload Upload
@@ -260,7 +260,7 @@ export default function Assets() {
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4 stagger-children"> <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4 stagger-children">
{filteredAssets.map(asset => ( {filteredAssets.map(asset => (
<div key={asset._id || asset.id} className="relative"> <div key={asset._id || asset.id} className="relative">
<div className="absolute top-2 left-2 z-10" onClick={e => e.stopPropagation()}> <div className="absolute top-2 start-2 z-10" onClick={e => e.stopPropagation()}>
<input type="checkbox" checked={selectedIds.has(asset._id || asset.id)} onChange={() => toggleSelect(asset._id || asset.id)} className="rounded border-border" /> <input type="checkbox" checked={selectedIds.has(asset._id || asset.id)} onChange={() => toggleSelect(asset._id || asset.id)} className="rounded border-border" />
</div> </div>
<AssetCard asset={asset} onClick={setSelectedAsset} /> <AssetCard asset={asset} onClick={setSelectedAsset} />
@@ -319,7 +319,7 @@ export default function Assets() {
<div className="space-y-4"> <div className="space-y-4">
{selectedAsset.type === 'image' && selectedAsset.url && ( {selectedAsset.type === 'image' && selectedAsset.url && (
<div className="rounded-lg overflow-hidden bg-surface-tertiary"> <div className="rounded-lg overflow-hidden bg-surface-tertiary">
<img src={selectedAsset.url} alt={selectedAsset.name} className="w-full max-h-[400px] object-contain" /> <img src={selectedAsset.url} alt={selectedAsset.name} className="w-full max-h-[400px] object-contain" loading="lazy" />
</div> </div>
)} )}
{selectedAsset.type === 'video' && selectedAsset.url && ( {selectedAsset.type === 'video' && selectedAsset.url && (
@@ -374,7 +374,7 @@ export default function Assets() {
download={selectedAsset.name} download={selectedAsset.name}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="ml-auto px-4 py-2 text-sm font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light" className="ms-auto px-4 py-2 text-sm font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light"
> >
Download Download
</a> </a>
+7 -5
View File
@@ -143,7 +143,7 @@ export default function Brands() {
{/* Brand Cards Grid */} {/* Brand Cards Grid */}
{brands.length === 0 ? ( {brands.length === 0 ? (
<div className="bg-white rounded-xl border border-border py-16 text-center"> <div className="bg-surface rounded-xl border border-border py-16 text-center">
<Tag className="w-12 h-12 text-text-quaternary mx-auto mb-3" /> <Tag className="w-12 h-12 text-text-quaternary mx-auto mb-3" />
<p className="text-sm text-text-tertiary">{t('brands.noBrands')}</p> <p className="text-sm text-text-tertiary">{t('brands.noBrands')}</p>
</div> </div>
@@ -154,7 +154,7 @@ export default function Brands() {
return ( return (
<div <div
key={getBrandId(brand)} key={getBrandId(brand)}
className={`bg-white rounded-xl border border-border overflow-hidden hover:shadow-md transition-all aspect-square flex flex-col ${isSuperadminOrManager ? 'cursor-pointer' : ''}`} className={`bg-surface rounded-xl border border-border overflow-clip hover:shadow-md transition-all aspect-square flex flex-col ${isSuperadminOrManager ? 'cursor-pointer' : ''}`}
onClick={() => isSuperadminOrManager && openEditBrand(brand)} onClick={() => isSuperadminOrManager && openEditBrand(brand)}
> >
{/* Logo area */} {/* Logo area */}
@@ -164,6 +164,7 @@ export default function Brands() {
src={`${API_BASE}/uploads/${brand.logo}`} src={`${API_BASE}/uploads/${brand.logo}`}
alt={displayName} alt={displayName}
className="w-full h-full object-contain p-4" className="w-full h-full object-contain p-4"
loading="lazy"
/> />
) : ( ) : (
<div className="text-3xl"> <div className="text-3xl">
@@ -171,17 +172,17 @@ export default function Brands() {
</div> </div>
)} )}
{isSuperadminOrManager && ( {isSuperadminOrManager && (
<div className="absolute top-1.5 right-1.5 flex gap-1 opacity-0 group-hover:opacity-100" onClick={e => e.stopPropagation()}> <div className="absolute top-1.5 end-1.5 flex gap-1 opacity-0 group-hover:opacity-100" onClick={e => e.stopPropagation()}>
<button <button
onClick={() => openEditBrand(brand)} onClick={() => openEditBrand(brand)}
className="p-1 bg-white/90 hover:bg-white rounded-md text-text-tertiary hover:text-text-primary shadow-sm" className="p-1 bg-white/90 hover:bg-surface rounded-md text-text-tertiary hover:text-text-primary shadow-sm"
title={t('common.edit')} title={t('common.edit')}
> >
<Edit2 className="w-3 h-3" /> <Edit2 className="w-3 h-3" />
</button> </button>
<button <button
onClick={() => { setBrandToDelete(brand); setShowDeleteModal(true) }} onClick={() => { setBrandToDelete(brand); setShowDeleteModal(true) }}
className="p-1 bg-white/90 hover:bg-white rounded-md text-text-tertiary hover:text-red-500 shadow-sm" className="p-1 bg-white/90 hover:bg-surface rounded-md text-text-tertiary hover:text-red-500 shadow-sm"
title={t('common.delete')} title={t('common.delete')}
> >
<Trash2 className="w-3 h-3" /> <Trash2 className="w-3 h-3" />
@@ -269,6 +270,7 @@ export default function Brands() {
src={`${API_BASE}/uploads/${editingBrand.logo}`} src={`${API_BASE}/uploads/${editingBrand.logo}`}
alt="Logo" alt="Logo"
className="h-16 object-contain" className="h-16 object-contain"
loading="lazy"
/> />
</div> </div>
)} )}
+16 -20
View File
@@ -153,11 +153,7 @@ export default function Budgets() {
return ( return (
<div className="space-y-6 animate-fade-in"> <div className="space-y-6 animate-fade-in">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-end">
<div>
<h1 className="text-2xl font-bold text-text-primary">{t('budgets.title')}</h1>
<p className="text-sm text-text-tertiary mt-0.5">{t('budgets.subtitle')}</p>
</div>
{canManageFinance && ( {canManageFinance && (
<button <button
onClick={() => { setEditing(null); setForm(EMPTY_ENTRY); setShowModal(true) }} onClick={() => { setEditing(null); setForm(EMPTY_ENTRY); setShowModal(true) }}
@@ -171,19 +167,19 @@ export default function Budgets() {
{/* Filters */} {/* Filters */}
<div className="flex items-center gap-3 flex-wrap"> <div className="flex items-center gap-3 flex-wrap">
<div className="relative flex-1 min-w-[200px] max-w-xs"> <div className="relative flex-1 min-w-[200px] max-w-xs">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" /> <Search className="absolute start-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<input <input
type="text" type="text"
value={searchQuery} value={searchQuery}
onChange={e => setSearchQuery(e.target.value)} onChange={e => setSearchQuery(e.target.value)}
placeholder={t('budgets.searchEntries')} placeholder={t('budgets.searchEntries')}
className="w-full pl-9 pr-3 py-2 text-sm border border-border rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-brand-primary/20" className="w-full ps-9 pe-3 py-2 text-sm border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
/> />
</div> </div>
<select <select
value={filterCategory} value={filterCategory}
onChange={e => setFilterCategory(e.target.value)} onChange={e => setFilterCategory(e.target.value)}
className="px-3 py-2 text-sm border border-border rounded-lg bg-white focus:outline-none" className="px-3 py-2 text-sm border border-border rounded-lg bg-surface focus:outline-none"
> >
<option value="">{t('budgets.allCategories')}</option> <option value="">{t('budgets.allCategories')}</option>
{CATEGORIES.map(c => <option key={c.value} value={c.value}>{c.label}</option>)} {CATEGORIES.map(c => <option key={c.value} value={c.value}>{c.label}</option>)}
@@ -191,7 +187,7 @@ export default function Budgets() {
<select <select
value={filterDestination} value={filterDestination}
onChange={e => setFilterDestination(e.target.value)} onChange={e => setFilterDestination(e.target.value)}
className="px-3 py-2 text-sm border border-border rounded-lg bg-white focus:outline-none" className="px-3 py-2 text-sm border border-border rounded-lg bg-surface focus:outline-none"
> >
<option value="">{t('budgets.allDestinations')}</option> <option value="">{t('budgets.allDestinations')}</option>
{DESTINATIONS.map(d => <option key={d.value} value={d.value}>{t(d.labelKey)}</option>)} {DESTINATIONS.map(d => <option key={d.value} value={d.value}>{t(d.labelKey)}</option>)}
@@ -206,7 +202,7 @@ export default function Budgets() {
className={`px-3 py-1.5 text-xs font-medium transition-colors ${ className={`px-3 py-1.5 text-xs font-medium transition-colors ${
filterType === opt.value filterType === opt.value
? opt.value === 'expense' ? 'bg-red-500 text-white' : opt.value === 'income' ? 'bg-emerald-500 text-white' : 'bg-brand-primary text-white' ? opt.value === 'expense' ? 'bg-red-500 text-white' : opt.value === 'income' ? 'bg-emerald-500 text-white' : 'bg-brand-primary text-white'
: 'bg-white text-text-secondary hover:bg-surface-secondary' : 'bg-surface text-text-secondary hover:bg-surface-secondary'
}`} }`}
> >
{opt.label} {opt.label}
@@ -215,7 +211,7 @@ export default function Budgets() {
</div> </div>
{filteredEntries.length > 0 && ( {filteredEntries.length > 0 && (
<div className="ml-auto flex items-center gap-3 text-sm text-text-tertiary"> <div className="ms-auto flex items-center gap-3 text-sm text-text-tertiary">
<span>{filteredEntries.length} {filteredEntries.length === 1 ? 'entry' : 'entries'}</span> <span>{filteredEntries.length} {filteredEntries.length === 1 ? 'entry' : 'entries'}</span>
<span className="text-emerald-600 font-semibold">+{totalIncome.toLocaleString()}</span> <span className="text-emerald-600 font-semibold">+{totalIncome.toLocaleString()}</span>
{totalExpenseAmt > 0 && <span className="text-red-500 font-semibold">-{totalExpenseAmt.toLocaleString()}</span>} {totalExpenseAmt > 0 && <span className="text-red-500 font-semibold">-{totalExpenseAmt.toLocaleString()}</span>}
@@ -235,12 +231,12 @@ export default function Budgets() {
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b border-border bg-surface-secondary"> <tr className="border-b border-border bg-surface-secondary">
<th className="px-4 py-3 text-left text-xs font-medium text-text-tertiary">{t('budgets.label')}</th> <th className="px-4 py-3 text-start text-xs font-medium text-text-tertiary">{t('budgets.label')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-text-tertiary">{t('budgets.source')}</th> <th className="px-4 py-3 text-start text-xs font-medium text-text-tertiary">{t('budgets.source')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-text-tertiary">{t('budgets.destination')}</th> <th className="px-4 py-3 text-start text-xs font-medium text-text-tertiary">{t('budgets.destination')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-text-tertiary">{t('budgets.linkedTo')}</th> <th className="px-4 py-3 text-start text-xs font-medium text-text-tertiary">{t('budgets.linkedTo')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-text-tertiary">{t('budgets.date')}</th> <th className="px-4 py-3 text-start text-xs font-medium text-text-tertiary">{t('budgets.date')}</th>
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">{t('budgets.amount')}</th> <th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">{t('budgets.amount')}</th>
{canManageFinance && <th className="px-4 py-3 w-20" />} {canManageFinance && <th className="px-4 py-3 w-20" />}
</tr> </tr>
</thead> </thead>
@@ -289,7 +285,7 @@ export default function Budgets() {
<td className="px-4 py-3 text-text-secondary whitespace-nowrap"> <td className="px-4 py-3 text-text-secondary whitespace-nowrap">
{entry.date_received ? format(new Date(entry.date_received), 'MMM d, yyyy') : '--'} {entry.date_received ? format(new Date(entry.date_received), 'MMM d, yyyy') : '--'}
</td> </td>
<td className={`px-4 py-3 text-right font-semibold whitespace-nowrap ${ <td className={`px-4 py-3 text-end font-semibold whitespace-nowrap ${
(entry.type || 'income') === 'expense' ? 'text-red-500' : 'text-emerald-600' (entry.type || 'income') === 'expense' ? 'text-red-500' : 'text-emerald-600'
}`}> }`}>
{(entry.type || 'income') === 'expense' ? '-' : '+'}{Number(entry.amount).toLocaleString()} {currencySymbol} {(entry.type || 'income') === 'expense' ? '-' : '+'}{Number(entry.amount).toLocaleString()} {currencySymbol}
@@ -332,7 +328,7 @@ export default function Budgets() {
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium border-2 transition-all ${ className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium border-2 transition-all ${
form.type === 'income' form.type === 'income'
? 'border-emerald-500 bg-emerald-50 text-emerald-700' ? 'border-emerald-500 bg-emerald-50 text-emerald-700'
: 'border-border bg-white text-text-secondary hover:bg-surface-secondary' : 'border-border bg-surface text-text-secondary hover:bg-surface-secondary'
}`} }`}
> >
<TrendingUp className="w-4 h-4" /> <TrendingUp className="w-4 h-4" />
@@ -344,7 +340,7 @@ export default function Budgets() {
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium border-2 transition-all ${ className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium border-2 transition-all ${
form.type === 'expense' form.type === 'expense'
? 'border-red-500 bg-red-50 text-red-700' ? 'border-red-500 bg-red-50 text-red-700'
: 'border-border bg-white text-text-secondary hover:bg-surface-secondary' : 'border-border bg-surface text-text-secondary hover:bg-surface-secondary'
}`} }`}
> >
<TrendingDown className="w-4 h-4" /> <TrendingDown className="w-4 h-4" />
+68 -127
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useContext } from 'react' import { useState, useEffect, useContext } from 'react'
import { useParams, useNavigate } from 'react-router-dom' import { useParams, useNavigate } from 'react-router-dom'
import { ArrowLeft, Plus, Edit2, Trash2, DollarSign, Eye, MousePointer, Target, TrendingUp, FileText, Megaphone, Search, Globe, Pencil, Users, X, UserPlus, MessageCircle, Settings } from 'lucide-react' import { ArrowLeft, Plus, Edit2, Trash2, DollarSign, Eye, MousePointer, Target, TrendingUp, FileText, Megaphone, Search, Globe, Users, X, MessageCircle, Settings } from 'lucide-react'
import { format } from 'date-fns' import { format } from 'date-fns'
import { AppContext } from '../App' import { AppContext } from '../App'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
@@ -14,7 +14,6 @@ import BudgetBar from '../components/BudgetBar'
import CommentsSection from '../components/CommentsSection' import CommentsSection from '../components/CommentsSection'
import CampaignDetailPanel from '../components/CampaignDetailPanel' import CampaignDetailPanel from '../components/CampaignDetailPanel'
import TrackDetailPanel from '../components/TrackDetailPanel' import TrackDetailPanel from '../components/TrackDetailPanel'
import PostDetailPanel from '../components/PostDetailPanel'
const TRACK_TYPES = { const TRACK_TYPES = {
organic_social: { label: 'Organic Social', icon: Megaphone, color: 'text-green-600 bg-green-50', hasBudget: false }, 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'] const TRACK_STATUSES = ['planned', 'active', 'paused', 'completed']
function MetricBox({ label, value, icon: Icon, color = 'text-text-primary' }) {
return (
<div className="text-center">
<Icon className={`w-4 h-4 mx-auto mb-0.5 ${color}`} />
<div className={`text-base font-bold ${color}`}>{value ?? '—'}</div>
<div className="text-[10px] text-text-tertiary">{label}</div>
</div>
)
}
export default function CampaignDetail() { export default function CampaignDetail() {
const { id } = useParams() const { id } = useParams()
const navigate = useNavigate() const navigate = useNavigate()
const { brands, getBrandName, teamMembers } = useContext(AppContext) const { brands, getBrandName, teamMembers } = useContext(AppContext)
const { lang, currencySymbol } = useLanguage() const { t, lang, currencySymbol } = useLanguage()
const { permissions, user } = useAuth() const { permissions, user } = useAuth()
const isSuperadmin = user?.role === 'superadmin' const isSuperadmin = user?.role === 'superadmin'
const [campaign, setCampaign] = useState(null) const [campaign, setCampaign] = useState(null)
@@ -56,7 +45,6 @@ export default function CampaignDetail() {
const [budgetValue, setBudgetValue] = useState('') const [budgetValue, setBudgetValue] = useState('')
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [trackToDelete, setTrackToDelete] = useState(null) const [trackToDelete, setTrackToDelete] = useState(null)
const [selectedPost, setSelectedPost] = useState(null)
const [showDiscussion, setShowDiscussion] = useState(false) const [showDiscussion, setShowDiscussion] = useState(false)
const [allCampaigns, setAllCampaigns] = useState([]) const [allCampaigns, setAllCampaigns] = useState([])
@@ -163,21 +151,6 @@ export default function CampaignDetail() {
loadAll() 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) => { const deleteTrack = async (trackId) => {
setTrackToDelete(trackId) setTrackToDelete(trackId)
setShowDeleteConfirm(true) setShowDeleteConfirm(true)
@@ -211,7 +184,7 @@ export default function CampaignDetail() {
if (!campaign) { if (!campaign) {
return ( return (
<div className="text-center py-12 text-text-tertiary"> <div className="text-center py-12 text-text-tertiary">
Campaign not found. <button onClick={() => navigate('/campaigns')} className="text-brand-primary underline">Go back</button> {t('campaigns.notFound')} <button onClick={() => navigate('/campaigns')} className="text-brand-primary underline">{t('common.goBack')}</button>
</div> </div>
) )
} }
@@ -244,9 +217,6 @@ export default function CampaignDetail() {
{campaign.start_date && campaign.end_date && ( {campaign.start_date && campaign.end_date && (
<span>{format(new Date(campaign.start_date), 'MMM d')} {format(new Date(campaign.end_date), 'MMM d, yyyy')}</span> <span>{format(new Date(campaign.start_date), 'MMM d')} {format(new Date(campaign.end_date), 'MMM d, yyyy')}</span>
)} )}
<span>
Budget: {campaign.budget > 0 ? `${campaign.budget.toLocaleString()} ${currencySymbol}` : 'Not set'}
</span>
{campaign.platforms && campaign.platforms.length > 0 && ( {campaign.platforms && campaign.platforms.length > 0 && (
<PlatformIcons platforms={campaign.platforms} size={16} /> <PlatformIcons platforms={campaign.platforms} size={16} />
)} )}
@@ -263,109 +233,73 @@ export default function CampaignDetail() {
}`} }`}
> >
<MessageCircle className="w-4 h-4" /> <MessageCircle className="w-4 h-4" />
Discussion {t('campaigns.discussion')}
</button> </button>
{canSetBudget && (
<button
onClick={() => { setBudgetValue(campaign.budget || ''); setEditingBudget(true) }}
className="flex items-center gap-1.5 px-3 py-2 bg-surface-tertiary text-text-secondary hover:bg-surface-tertiary/80 hover:text-text-primary rounded-lg text-sm font-medium transition-colors"
>
<DollarSign className="w-4 h-4" />
Budget
</button>
)}
{canManage && ( {canManage && (
<button <button
onClick={() => setPanelCampaign(campaign)} onClick={() => setPanelCampaign(campaign)}
className="flex items-center gap-1.5 px-3 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm transition-colors" className="flex items-center gap-1.5 px-3 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm transition-colors"
> >
<Settings className="w-4 h-4" /> <Settings className="w-4 h-4" />
Edit {t('common.edit')}
</button> </button>
)} )}
</div> </div>
</div> </div>
{/* Assigned Team */} {/* Budget Card */}
<div className="bg-white rounded-xl border border-border p-5"> <div className="bg-surface rounded-xl border border-border p-5">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium flex items-center gap-1.5"> <h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium">{t('campaigns.budget')}</h3>
<Users className="w-3.5 h-3.5" /> Assigned Team {canSetBudget && (
</h3> <button onClick={() => { setBudgetValue(campaign.budget || ''); setEditingBudget(true) }}
{canAssign && ( className="text-xs text-brand-primary hover:text-brand-primary-light font-medium">
<button {t('common.edit')}
onClick={openAssignModal}
className="flex items-center gap-1.5 px-3 py-1.5 bg-brand-primary text-white rounded-lg text-xs font-medium hover:bg-brand-primary-light"
>
<UserPlus className="w-3.5 h-3.5" /> Assign Members
</button> </button>
)} )}
</div> </div>
{assignments.length === 0 ? ( <div className="flex items-baseline gap-2 mb-3">
<p className="text-xs text-text-tertiary py-2">No team members assigned yet.</p> <span className="text-2xl font-bold text-text-primary">
) : ( {totalAllocated.toLocaleString()} {currencySymbol}
<div className="flex flex-wrap gap-2"> </span>
{assignments.map(a => ( <span className="text-sm text-text-tertiary">{t('finance.allocated')}</span>
<div key={a.user_id} className="flex items-center gap-2 bg-surface-secondary rounded-full pl-1 pr-2 py-1"> </div>
<div className="w-6 h-6 rounded-full bg-brand-primary/10 text-brand-primary flex items-center justify-center text-[10px] font-bold shrink-0"> {totalAllocated > 0 && (
{a.user_avatar ? ( <>
<img src={a.user_avatar} className="w-full h-full rounded-full object-cover" alt="" /> <BudgetBar budget={totalAllocated} spent={totalSpent} height="h-2.5" />
) : ( <div className="flex justify-between mt-2 text-xs text-text-tertiary">
getInitials(a.user_name) <span>{totalSpent.toLocaleString()} {currencySymbol} {t('dashboard.spent')}</span>
)} <span>{(totalAllocated - totalSpent).toLocaleString()} {currencySymbol} {t('dashboard.remaining')}</span>
</div> </div>
<span className="text-xs font-medium text-text-primary">{a.user_name}</span> </>
{canAssign && ( )}
<button {(totalImpressions > 0 || totalClicks > 0) && (
onClick={() => removeAssignment(a.user_id)} <div className="flex items-center gap-4 mt-4 pt-3 border-t border-border-light text-xs text-text-secondary">
className="p-0.5 rounded-full hover:bg-red-100 text-text-tertiary hover:text-red-500" <span><Eye className="w-3.5 h-3.5 inline me-1" />{totalImpressions.toLocaleString()}</span>
> <span><MousePointer className="w-3.5 h-3.5 inline me-1" />{totalClicks.toLocaleString()}</span>
<X className="w-3 h-3" /> {totalConversions > 0 && <span><Target className="w-3.5 h-3.5 inline me-1" />{totalConversions.toLocaleString()}</span>}
</button> {totalRevenue > 0 && <span><DollarSign className="w-3.5 h-3.5 inline me-1" />{totalRevenue.toLocaleString()} {currencySymbol}</span>}
)}
</div>
))}
</div> </div>
)} )}
</div> </div>
{/* Aggregate Metrics */}
{tracks.length > 0 && (
<div className="bg-white rounded-xl border border-border p-5">
<h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium mb-4">Campaign Totals (from tracks)</h3>
<div className="grid grid-cols-3 md:grid-cols-6 gap-4">
<MetricBox icon={DollarSign} label="Allocated" value={`${totalAllocated.toLocaleString()}`} color="text-blue-600" />
<MetricBox icon={TrendingUp} label="Spent" value={`${totalSpent.toLocaleString()}`} color="text-amber-600" />
<MetricBox icon={Eye} label="Impressions" value={totalImpressions.toLocaleString()} color="text-purple-600" />
<MetricBox icon={MousePointer} label="Clicks" value={totalClicks.toLocaleString()} color="text-green-600" />
<MetricBox icon={Target} label="Conversions" value={totalConversions.toLocaleString()} color="text-red-600" />
<MetricBox icon={DollarSign} label="Revenue" value={`${totalRevenue.toLocaleString()}`} color="text-emerald-600" />
</div>
{totalAllocated > 0 && (
<div className="mt-4">
<BudgetBar budget={totalAllocated} spent={totalSpent} height="h-2" />
</div>
)}
</div>
)}
{/* Tracks */} {/* Tracks */}
<div className="bg-white rounded-xl border border-border overflow-hidden"> <div className="bg-surface rounded-xl border border-border overflow-clip">
<div className="flex items-center justify-between px-5 py-4 border-b border-border"> <div className="flex items-center justify-between px-5 py-4 border-b border-border">
<h3 className="font-semibold text-text-primary">Tracks</h3> <h3 className="font-semibold text-text-primary">{t('campaigns.tracks')}</h3>
{canManage && ( {canManage && (
<button <button
onClick={() => { setPanelTrack({}); setTrackScrollToMetrics(false) }} onClick={() => { setPanelTrack({}); setTrackScrollToMetrics(false) }}
className="flex items-center gap-1.5 px-3 py-1.5 bg-brand-primary text-white rounded-lg text-xs font-medium hover:bg-brand-primary-light" className="flex items-center gap-1.5 px-3 py-1.5 bg-brand-primary text-white rounded-lg text-xs font-medium hover:bg-brand-primary-light"
> >
<Plus className="w-3.5 h-3.5" /> Add Track <Plus className="w-3.5 h-3.5" /> {t('campaigns.addTrack')}
</button> </button>
)} )}
</div> </div>
{tracks.length === 0 ? ( {tracks.length === 0 ? (
<div className="py-12 text-center text-sm text-text-tertiary"> <div className="py-12 text-center text-sm text-text-tertiary">
No tracks yet. Add organic, paid, or SEO tracks to organize this campaign. {t('campaigns.noTracks')}
</div> </div>
) : ( ) : (
<div className="divide-y divide-border-light"> <div className="divide-y divide-border-light">
@@ -403,9 +337,9 @@ export default function CampaignDetail() {
{/* Quick metrics */} {/* Quick metrics */}
{(track.impressions > 0 || track.clicks > 0 || track.conversions > 0) && ( {(track.impressions > 0 || track.clicks > 0 || track.conversions > 0) && (
<div className="flex items-center gap-3 mt-1.5 text-[10px] text-text-tertiary"> <div className="flex items-center gap-3 mt-1.5 text-[10px] text-text-tertiary">
{track.impressions > 0 && <span>👁 {track.impressions.toLocaleString()}</span>} {track.impressions > 0 && <span><Eye className="w-3 h-3 inline" /> {track.impressions.toLocaleString()}</span>}
{track.clicks > 0 && <span>🖱 {track.clicks.toLocaleString()}</span>} {track.clicks > 0 && <span><MousePointer className="w-3 h-3 inline" /> {track.clicks.toLocaleString()}</span>}
{track.conversions > 0 && <span>🎯 {track.conversions.toLocaleString()}</span>} {track.conversions > 0 && <span><Target className="w-3 h-3 inline" /> {track.conversions.toLocaleString()}</span>}
{track.clicks > 0 && track.budget_spent > 0 && ( {track.clicks > 0 && track.budget_spent > 0 && (
<span>CPC: {(track.budget_spent / track.clicks).toFixed(2)} {currencySymbol}</span> <span>CPC: {(track.budget_spent / track.clicks).toFixed(2)} {currencySymbol}</span>
)} )}
@@ -418,7 +352,7 @@ export default function CampaignDetail() {
{/* Linked posts count */} {/* Linked posts count */}
{trackPosts.length > 0 && ( {trackPosts.length > 0 && (
<div className="text-[10px] text-text-tertiary mt-1"> <div className="text-[10px] text-text-tertiary mt-1">
📝 {trackPosts.length} post{trackPosts.length !== 1 ? 's' : ''} linked <FileText className="w-3 h-3 inline" /> {trackPosts.length} {t('campaigns.postsLinked')}
</div> </div>
)} )}
@@ -461,21 +395,41 @@ export default function CampaignDetail() {
)} )}
</div> </div>
{/* Team */}
{(assignments.length > 0 || canAssign) && (
<div className="flex items-center gap-3">
<span className="text-xs text-text-tertiary font-medium">{t('campaigns.team')}:</span>
<div className="flex -space-x-1.5">
{assignments.slice(0, 6).map(a => (
<div key={a.user_id} className="w-7 h-7 rounded-full bg-brand-primary/10 text-brand-primary flex items-center justify-center text-[10px] font-bold border-2 border-surface" title={a.user_name}>
{a.user_avatar ? <img src={a.user_avatar} className="w-full h-full rounded-full object-cover" alt="" loading="lazy" /> : getInitials(a.user_name)}
</div>
))}
{assignments.length > 6 && <div className="w-7 h-7 rounded-full bg-surface-tertiary flex items-center justify-center text-[10px] text-text-tertiary font-medium border-2 border-surface">+{assignments.length - 6}</div>}
</div>
{canAssign && (
<button onClick={openAssignModal} className="text-xs text-brand-primary hover:text-brand-primary-light font-medium">
{t('campaigns.assignMembers')}
</button>
)}
</div>
)}
{/* Linked Posts */} {/* Linked Posts */}
{posts.length > 0 && ( {posts.length > 0 && (
<div className="bg-white rounded-xl border border-border overflow-hidden"> <div className="bg-surface rounded-xl border border-border overflow-clip">
<div className="px-5 py-4 border-b border-border"> <div className="px-5 py-4 border-b border-border">
<h3 className="font-semibold text-text-primary">Linked Posts ({posts.length})</h3> <h3 className="font-semibold text-text-primary">{t('campaigns.linkedPosts')} ({posts.length})</h3>
</div> </div>
<div className="divide-y divide-border-light"> <div className="divide-y divide-border-light">
{posts.map(post => ( {posts.map(post => (
<div <div
key={post.id} 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" className="flex items-center gap-3 px-5 py-3 hover:bg-surface-secondary cursor-pointer transition-colors"
> >
{post.thumbnail_url && ( {post.thumbnail_url && (
<img src={post.thumbnail_url} alt="" className="w-10 h-10 rounded-lg object-cover shrink-0" /> <img src={post.thumbnail_url} alt="" className="w-10 h-10 rounded-lg object-cover shrink-0" loading="lazy" />
)} )}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -501,11 +455,11 @@ export default function CampaignDetail() {
{/* ─── DISCUSSION SIDEBAR ─── */} {/* ─── DISCUSSION SIDEBAR ─── */}
{showDiscussion && ( {showDiscussion && (
<div className="w-[340px] shrink-0 bg-white rounded-xl border border-border flex flex-col self-start sticky top-4" style={{ maxHeight: 'calc(100vh - 6rem)' }}> <div className="w-[340px] shrink-0 bg-surface rounded-xl border border-border flex flex-col self-start sticky top-4" style={{ maxHeight: 'calc(100vh - 6rem)' }}>
<div className="flex items-center justify-between px-4 py-3 border-b border-border"> <div className="flex items-center justify-between px-4 py-3 border-b border-border">
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-1.5"> <h3 className="text-sm font-semibold text-text-primary flex items-center gap-1.5">
<MessageCircle className="w-4 h-4" /> <MessageCircle className="w-4 h-4" />
Discussion {t('campaigns.discussion')}
</h3> </h3>
<button onClick={() => setShowDiscussion(false)} className="p-1 hover:bg-surface-tertiary rounded-lg text-text-tertiary"> <button onClick={() => setShowDiscussion(false)} className="p-1 hover:bg-surface-tertiary rounded-lg text-text-tertiary">
<X className="w-4 h-4" /> <X className="w-4 h-4" />
@@ -557,7 +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"> <div className="w-7 h-7 rounded-full bg-brand-primary/10 text-brand-primary flex items-center justify-center text-[10px] font-bold shrink-0">
{u.avatar ? ( {u.avatar ? (
<img src={u.avatar} className="w-full h-full rounded-full object-cover" alt="" /> <img src={u.avatar} className="w-full h-full rounded-full object-cover" alt="" loading="lazy" />
) : ( ) : (
getInitials(u.name) getInitials(u.name)
)} )}
@@ -618,19 +572,6 @@ export default function CampaignDetail() {
</div> </div>
</Modal> </Modal>
{/* Post Detail Panel */}
{selectedPost && (
<PostDetailPanel
post={selectedPost}
onClose={() => setSelectedPost(null)}
onSave={handlePostPanelSave}
onDelete={handlePostPanelDelete}
brands={brands}
teamMembers={teamMembers}
campaigns={allCampaigns}
/>
)}
{/* Campaign Edit Panel */} {/* Campaign Edit Panel */}
{panelCampaign && ( {panelCampaign && (
<CampaignDetailPanel <CampaignDetailPanel
+11 -11
View File
@@ -145,7 +145,7 @@ export default function Campaigns() {
<select <select
value={filters.brand} value={filters.brand}
onChange={e => setFilters(f => ({ ...f, brand: e.target.value }))} onChange={e => setFilters(f => ({ ...f, brand: e.target.value }))}
className="text-sm border border-border rounded-lg px-3 py-2 bg-white text-text-secondary focus:outline-none" className="text-sm border border-border rounded-lg px-3 py-2 bg-surface text-text-secondary focus:outline-none"
> >
<option value="">All Brands</option> <option value="">All Brands</option>
{brands.map(b => <option key={b.id || b._id} value={b.id || b._id}>{lang === 'ar' && b.name_ar ? b.name_ar : b.name}</option>)} {brands.map(b => <option key={b.id || b._id} value={b.id || b._id}>{lang === 'ar' && b.name_ar ? b.name_ar : b.name}</option>)}
@@ -154,7 +154,7 @@ export default function Campaigns() {
<select <select
value={filters.status} value={filters.status}
onChange={e => setFilters(f => ({ ...f, status: e.target.value }))} onChange={e => setFilters(f => ({ ...f, status: e.target.value }))}
className="text-sm border border-border rounded-lg px-3 py-2 bg-white text-text-secondary focus:outline-none" className="text-sm border border-border rounded-lg px-3 py-2 bg-surface text-text-secondary focus:outline-none"
> >
<option value="">All Statuses</option> <option value="">All Statuses</option>
<option value="planning">Planning</option> <option value="planning">Planning</option>
@@ -167,7 +167,7 @@ export default function Campaigns() {
{permissions?.canCreateCampaigns && ( {permissions?.canCreateCampaigns && (
<button <button
onClick={openNew} onClick={openNew}
className="flex items-center gap-2 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm ml-auto" className="flex items-center gap-2 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm ms-auto"
> >
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
New Campaign New Campaign
@@ -178,7 +178,7 @@ export default function Campaigns() {
{/* Summary Cards */} {/* Summary Cards */}
{(totalBudget > 0 || totalSpent > 0) && ( {(totalBudget > 0 || totalSpent > 0) && (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3 stagger-children"> <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3 stagger-children">
<div className="bg-white rounded-xl border border-border p-4"> <div className="bg-surface rounded-xl border border-border p-4">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<DollarSign className="w-4 h-4 text-blue-500" /> <DollarSign className="w-4 h-4 text-blue-500" />
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Budget</span> <span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Budget</span>
@@ -186,7 +186,7 @@ export default function Campaigns() {
<div className="text-lg font-bold text-text-primary">{totalBudget.toLocaleString()}</div> <div className="text-lg font-bold text-text-primary">{totalBudget.toLocaleString()}</div>
<div className="text-[10px] text-text-tertiary">{currencySymbol} total</div> <div className="text-[10px] text-text-tertiary">{currencySymbol} total</div>
</div> </div>
<div className="bg-white rounded-xl border border-border p-4"> <div className="bg-surface rounded-xl border border-border p-4">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<TrendingUp className="w-4 h-4 text-amber-500" /> <TrendingUp className="w-4 h-4 text-amber-500" />
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Spent</span> <span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Spent</span>
@@ -194,28 +194,28 @@ export default function Campaigns() {
<div className="text-lg font-bold text-text-primary">{totalSpent.toLocaleString()}</div> <div className="text-lg font-bold text-text-primary">{totalSpent.toLocaleString()}</div>
<div className="text-[10px] text-text-tertiary">{currencySymbol} spent</div> <div className="text-[10px] text-text-tertiary">{currencySymbol} spent</div>
</div> </div>
<div className="bg-white rounded-xl border border-border p-4"> <div className="bg-surface rounded-xl border border-border p-4">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<Eye className="w-4 h-4 text-purple-500" /> <Eye className="w-4 h-4 text-purple-500" />
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Impressions</span> <span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Impressions</span>
</div> </div>
<div className="text-lg font-bold text-text-primary">{totalImpressions.toLocaleString()}</div> <div className="text-lg font-bold text-text-primary">{totalImpressions.toLocaleString()}</div>
</div> </div>
<div className="bg-white rounded-xl border border-border p-4"> <div className="bg-surface rounded-xl border border-border p-4">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<MousePointer className="w-4 h-4 text-green-500" /> <MousePointer className="w-4 h-4 text-green-500" />
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Clicks</span> <span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Clicks</span>
</div> </div>
<div className="text-lg font-bold text-text-primary">{totalClicks.toLocaleString()}</div> <div className="text-lg font-bold text-text-primary">{totalClicks.toLocaleString()}</div>
</div> </div>
<div className="bg-white rounded-xl border border-border p-4"> <div className="bg-surface rounded-xl border border-border p-4">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<Target className="w-4 h-4 text-red-500" /> <Target className="w-4 h-4 text-red-500" />
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Conversions</span> <span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Conversions</span>
</div> </div>
<div className="text-lg font-bold text-text-primary">{totalConversions.toLocaleString()}</div> <div className="text-lg font-bold text-text-primary">{totalConversions.toLocaleString()}</div>
</div> </div>
<div className="bg-white rounded-xl border border-border p-4"> <div className="bg-surface rounded-xl border border-border p-4">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<BarChart3 className="w-4 h-4 text-emerald-500" /> <BarChart3 className="w-4 h-4 text-emerald-500" />
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Revenue</span> <span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Revenue</span>
@@ -264,7 +264,7 @@ export default function Campaigns() {
/> />
{/* Campaign list */} {/* Campaign list */}
<div className="bg-white rounded-xl border border-border overflow-hidden"> <div className="bg-surface rounded-xl border border-border overflow-clip">
<div className="px-5 py-4 border-b border-border"> <div className="px-5 py-4 border-b border-border">
<h3 className="font-semibold text-text-primary">All Campaigns</h3> <h3 className="font-semibold text-text-primary">All Campaigns</h3>
</div> </div>
@@ -308,7 +308,7 @@ export default function Campaigns() {
)} )}
</div> </div>
</div> </div>
<div className="text-right shrink-0"> <div className="text-end shrink-0">
<StatusBadge status={campaign.status} size="xs" /> <StatusBadge status={campaign.status} size="xs" />
<div className="text-xs text-text-tertiary mt-1"> <div className="text-xs text-text-tertiary mt-1">
{campaign.startDate && campaign.endDate ? ( {campaign.startDate && campaign.endDate ? (
+125 -210
View File
@@ -1,12 +1,11 @@
import { useContext, useEffect, useState, useMemo } from 'react' import { useContext, useEffect, useState, useMemo } from 'react'
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import { format, isAfter, isBefore, addDays } from 'date-fns' import { format, isAfter, isBefore, addDays } from 'date-fns'
import { FileText, Megaphone, AlertTriangle, ArrowRight, Clock, Wallet, TrendingUp, TrendingDown, DollarSign, Landmark, CheckSquare, FolderKanban } from 'lucide-react' import { FileText, Megaphone, AlertTriangle, ArrowRight, Clock, Landmark, CheckSquare, FolderKanban } from 'lucide-react'
import { AppContext } from '../App' import { AppContext } from '../App'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../i18n/LanguageContext' import { useLanguage } from '../i18n/LanguageContext'
import { api, PRIORITY_CONFIG } from '../utils/api' import { api, PRIORITY_CONFIG } from '../utils/api'
import StatCard from '../components/StatCard'
import StatusBadge from '../components/StatusBadge' import StatusBadge from '../components/StatusBadge'
import BrandBadge from '../components/BrandBadge' import BrandBadge from '../components/BrandBadge'
import DatePresetPicker from '../components/DatePresetPicker' import DatePresetPicker from '../components/DatePresetPicker'
@@ -18,24 +17,17 @@ function getBudgetBarColor(percentage) {
return 'bg-emerald-500' return 'bg-emerald-500'
} }
function FinanceMini({ finance }) { function BudgetSummary({ finance }) {
const { t, currencySymbol } = useLanguage() const { t, currencySymbol } = useLanguage()
if (!finance) return null if (!finance) return null
const totalReceived = finance.totalReceived || 0 const totalReceived = finance.totalReceived || 0
const spent = finance.spent || 0 const mainAvailable = finance.mainAvailable != null ? finance.mainAvailable : (finance.remaining || 0)
const remaining = finance.remaining || 0 const consumed = totalReceived - mainAvailable
const roi = finance.roi || 0 const pct = totalReceived > 0 ? (consumed / totalReceived) * 100 : 0
const totalExpenses = finance.totalExpenses || 0
const campaignBudget = finance.totalCampaignBudget || 0
const projectBudget = finance.totalProjectBudget || 0
const unallocated = finance.unallocated ?? (totalReceived - campaignBudget - projectBudget)
const pct = totalReceived > 0 ? (spent / totalReceived) * 100 : 0
const barColor = getBudgetBarColor(pct) const barColor = getBudgetBarColor(pct)
const campPct = totalReceived > 0 ? (campaignBudget / totalReceived) * 100 : 0
const projPct = totalReceived > 0 ? (projectBudget / totalReceived) * 100 : 0
return ( return (
<div className="bg-white rounded-xl border border-border p-5"> <div className="bg-surface rounded-xl border border-border p-5">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-text-primary">{t('dashboard.budgetOverview')}</h3> <h3 className="font-semibold text-text-primary">{t('dashboard.budgetOverview')}</h3>
<Link to="/finance" className="text-sm text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1"> <Link to="/finance" className="text-sm text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1">
@@ -49,58 +41,15 @@ function FinanceMini({ finance }) {
</div> </div>
) : ( ) : (
<> <>
{/* Spending bar */} <div className="flex justify-between text-xs text-text-tertiary mb-1">
<div className="mb-3"> <span>{consumed.toLocaleString()} {currencySymbol} {t('dashboard.spent')}</span>
<div className="flex justify-between text-xs text-text-tertiary mb-1"> <span>{totalReceived.toLocaleString()} {currencySymbol} {t('dashboard.received')}</span>
<span>{spent.toLocaleString()} {currencySymbol} {t('dashboard.spent')}</span>
<span>{totalReceived.toLocaleString()} {currencySymbol} {t('dashboard.received')}</span>
</div>
<div className="h-2.5 bg-surface-tertiary rounded-full overflow-hidden">
<div className={`h-full ${barColor} rounded-full transition-all`} style={{ width: `${Math.min(pct, 100)}%` }} />
</div>
</div> </div>
<div className="h-2.5 bg-surface-tertiary rounded-full overflow-hidden">
{/* Allocation bar */} <div className={`h-full ${barColor} rounded-full transition-all`} style={{ width: `${Math.min(pct, 100)}%` }} />
{(campaignBudget > 0 || projectBudget > 0) && ( </div>
<div className="mb-3"> <div className={`mt-3 text-sm font-semibold ${mainAvailable >= 0 ? 'text-emerald-600' : 'text-red-600'}`}>
<div className="text-[10px] text-text-tertiary mb-1 font-medium uppercase tracking-wide">Allocation</div> {mainAvailable.toLocaleString()} {currencySymbol} {t('dashboard.remaining')}
<div className="h-2 bg-surface-tertiary rounded-full overflow-hidden flex">
{campPct > 0 && <div className="h-full bg-blue-500" style={{ width: `${campPct}%` }} />}
{projPct > 0 && <div className="h-full bg-purple-500" style={{ width: `${projPct}%` }} />}
</div>
<div className="flex gap-3 mt-1 text-[10px] text-text-tertiary">
{campaignBudget > 0 && <span><span className="inline-block w-1.5 h-1.5 rounded-full bg-blue-500 mr-1" />{campaignBudget.toLocaleString()}</span>}
{projectBudget > 0 && <span><span className="inline-block w-1.5 h-1.5 rounded-full bg-purple-500 mr-1" />{projectBudget.toLocaleString()}</span>}
{unallocated > 0 && <span><span className="inline-block w-1.5 h-1.5 rounded-full bg-gray-300 mr-1" />{unallocated.toLocaleString()} free</span>}
</div>
</div>
)}
{/* Key numbers */}
<div className={`grid ${totalExpenses > 0 ? 'grid-cols-3' : 'grid-cols-2'} gap-3`}>
<div className="text-center p-2 bg-surface-secondary rounded-lg">
<Landmark className="w-4 h-4 mx-auto mb-1 text-emerald-500" />
<div className={`text-sm font-bold ${remaining >= 0 ? 'text-emerald-600' : 'text-red-600'}`}>
{remaining.toLocaleString()}
</div>
<div className="text-[10px] text-text-tertiary">{t('dashboard.remaining')}</div>
</div>
{totalExpenses > 0 && (
<div className="text-center p-2 bg-surface-secondary rounded-lg">
<TrendingDown className="w-4 h-4 mx-auto mb-1 text-red-500" />
<div className="text-sm font-bold text-red-600">
{totalExpenses.toLocaleString()}
</div>
<div className="text-[10px] text-text-tertiary">{t('dashboard.expenses')}</div>
</div>
)}
<div className="text-center p-2 bg-surface-secondary rounded-lg">
<TrendingUp className="w-4 h-4 mx-auto mb-1 text-blue-500" />
<div className={`text-sm font-bold ${roi >= 0 ? 'text-emerald-600' : 'text-red-600'}`}>
{roi.toFixed(0)}%
</div>
<div className="text-[10px] text-text-tertiary">{t('dashboard.roi')}</div>
</div>
</div> </div>
</> </>
)} )}
@@ -146,13 +95,6 @@ function ActiveCampaignsList({ campaigns, finance }) {
</div> </div>
)} )}
</div> </div>
<div className="text-right shrink-0">
{cd.tracks_impressions > 0 && (
<div className="text-[10px] text-text-tertiary">
{cd.tracks_impressions.toLocaleString()} imp. / {cd.tracks_clicks.toLocaleString()} clicks
</div>
)}
</div>
</Link> </Link>
) )
})} })}
@@ -162,12 +104,12 @@ function ActiveCampaignsList({ campaigns, finance }) {
} }
function MyTasksList({ tasks, currentUserId, navigate, t }) { function MyTasksList({ tasks, currentUserId, navigate, t }) {
const myTasks = tasks const myTasks = useMemo(() => tasks
.filter(task => { .filter(task => {
const assignedId = task.assigned_to_id || task.assignedTo const assignedId = task.assigned_to_id || task.assignedTo
return assignedId === currentUserId && task.status !== 'done' return assignedId === currentUserId && task.status !== 'done'
}) })
.slice(0, 5) .slice(0, 5), [tasks, currentUserId])
return ( return (
<div className="section-card"> <div className="section-card">
@@ -187,10 +129,10 @@ function MyTasksList({ tasks, currentUserId, navigate, t }) {
</div> </div>
) : ( ) : (
myTasks.map(task => ( myTasks.map(task => (
<div <button
key={task._id || task.id} key={task._id || task.id}
onClick={() => navigate('/tasks')} onClick={() => navigate('/tasks')}
className="flex items-center gap-3 px-5 py-3 hover:bg-surface-secondary transition-colors cursor-pointer" className="w-full flex items-center gap-3 px-5 py-3 hover:bg-surface-secondary transition-colors text-start"
> >
<div className={`w-2 h-2 rounded-full shrink-0 ${(PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium).color}`} /> <div className={`w-2 h-2 rounded-full shrink-0 ${(PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium).color}`} />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
@@ -203,7 +145,7 @@ function MyTasksList({ tasks, currentUserId, navigate, t }) {
{format(new Date(task.dueDate), 'MMM d')} {format(new Date(task.dueDate), 'MMM d')}
</div> </div>
)} )}
</div> </button>
)) ))
)} )}
</div> </div>
@@ -261,10 +203,84 @@ function ProjectProgress({ projects, tasks, t }) {
) )
} }
function ActivityFeed({ posts, deadlines, navigate, t }) {
const [tab, setTab] = useState('posts')
const hasPosts = posts.length > 0
const hasDeadlines = deadlines.length > 0
return (
<div className="section-card">
<div className="section-card-header flex items-center justify-between">
<div className="flex items-center gap-1">
<button
onClick={() => setTab('posts')}
className={`px-3 py-1 text-sm font-medium rounded-md transition-colors ${
tab === 'posts' ? 'bg-brand-primary/10 text-brand-primary' : 'text-text-tertiary hover:text-text-secondary'
}`}
>
{t('dashboard.recentPosts')}
</button>
<button
onClick={() => setTab('deadlines')}
className={`px-3 py-1 text-sm font-medium rounded-md transition-colors ${
tab === 'deadlines' ? 'bg-brand-primary/10 text-brand-primary' : 'text-text-tertiary hover:text-text-secondary'
}`}
>
{t('dashboard.upcomingDeadlines')}
</button>
</div>
<Link to={tab === 'posts' ? '/posts' : '/tasks'} className="text-sm text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1">
{t('dashboard.viewAll')} <ArrowRight className="w-3.5 h-3.5" />
</Link>
</div>
<div className="divide-y divide-border-light">
{tab === 'posts' ? (
!hasPosts ? (
<div className="py-10 text-center text-sm text-text-tertiary">{t('dashboard.noPostsYet')}</div>
) : (
posts.slice(0, 6).map(post => (
<button key={post._id} onClick={() => navigate('/posts')} className="w-full flex items-center gap-3 px-5 py-3 hover:bg-surface-secondary transition-colors text-start">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{post.title}</p>
<div className="flex items-center gap-2 mt-1">
{post.brand && <BrandBadge brand={post.brand} />}
</div>
</div>
<StatusBadge status={post.status} size="xs" />
</button>
))
)
) : (
!hasDeadlines ? (
<div className="py-10 text-center text-sm text-text-tertiary">{t('dashboard.noUpcomingDeadlines')}</div>
) : (
deadlines.map(task => (
<button key={task._id} onClick={() => navigate('/tasks')} className="w-full flex items-center gap-3 px-5 py-3 hover:bg-surface-secondary transition-colors text-start">
<div className={`w-2 h-2 rounded-full ${(PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium).color}`} />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{task.title}</p>
<div className="flex items-center gap-2 mt-0.5">
<StatusBadge status={task.status} size="xs" />
{task.assignedName && <span className="text-xs text-text-tertiary truncate">{task.assignedName}</span>}
</div>
</div>
<div className="flex items-center gap-1 text-xs text-text-tertiary shrink-0">
<Clock className="w-3.5 h-3.5" />
{format(new Date(task.dueDate), 'MMM d')}
</div>
</button>
))
)
)}
</div>
</div>
)
}
export default function Dashboard() { export default function Dashboard() {
const { t, currencySymbol } = useLanguage() const { t, currencySymbol } = useLanguage()
const navigate = useNavigate() const navigate = useNavigate()
const { currentUser, teamMembers } = useContext(AppContext) const { currentUser } = useContext(AppContext)
const { hasModule } = useAuth() const { hasModule } = useAuth()
const [posts, setPosts] = useState([]) const [posts, setPosts] = useState([])
const [campaigns, setCampaigns] = useState([]) const [campaigns, setCampaigns] = useState([])
@@ -273,7 +289,6 @@ export default function Dashboard() {
const [finance, setFinance] = useState(null) const [finance, setFinance] = useState(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
// Date filtering
const [dateFrom, setDateFrom] = useState('') const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('') const [dateTo, setDateTo] = useState('')
const [activePreset, setActivePreset] = useState('') const [activePreset, setActivePreset] = useState('')
@@ -285,7 +300,6 @@ export default function Dashboard() {
const loadData = async () => { const loadData = async () => {
try { try {
const fetches = [] const fetches = []
// Only fetch data for modules the user has access to
if (hasModule('marketing')) { if (hasModule('marketing')) {
fetches.push(api.get('/posts?limit=50&sort=-createdAt').then(r => ({ key: 'posts', data: Array.isArray(r) ? r : [] }))) fetches.push(api.get('/posts?limit=50&sort=-createdAt').then(r => ({ key: 'posts', data: Array.isArray(r) ? r : [] })))
fetches.push(api.get('/campaigns').then(r => ({ key: 'campaigns', data: Array.isArray(r) ? r : [] }))) fetches.push(api.get('/campaigns').then(r => ({ key: 'campaigns', data: Array.isArray(r) ? r : [] })))
@@ -315,7 +329,6 @@ export default function Dashboard() {
} }
} }
// Filtered data based on date range
const filteredPosts = useMemo(() => { const filteredPosts = useMemo(() => {
if (!dateFrom && !dateTo) return posts if (!dateFrom && !dateTo) return posts
return posts.filter(p => { return posts.filter(p => {
@@ -343,7 +356,7 @@ export default function Dashboard() {
t.dueDate && new Date(t.dueDate) < new Date() && t.status !== 'done' t.dueDate && new Date(t.dueDate) < new Date() && t.status !== 'done'
).length ).length
const upcomingDeadlines = filteredTasks const upcomingDeadlines = useMemo(() => filteredTasks
.filter(t => { .filter(t => {
if (!t.dueDate || t.status === 'done') return false if (!t.dueDate || t.status === 'done') return false
const due = new Date(t.dueDate) const due = new Date(t.dueDate)
@@ -351,60 +364,27 @@ export default function Dashboard() {
return isAfter(due, now) && isBefore(due, addDays(now, 7)) return isAfter(due, now) && isBefore(due, addDays(now, 7))
}) })
.sort((a, b) => new Date(a.dueDate) - new Date(b.dueDate)) .sort((a, b) => new Date(a.dueDate) - new Date(b.dueDate))
.slice(0, 8) .slice(0, 6), [filteredTasks])
const statCards = [] // Inline stat values no card component needed
const stats = []
if (hasModule('marketing')) { if (hasModule('marketing')) {
statCards.push({ stats.push({ label: t('dashboard.totalPosts'), value: filteredPosts.length, detail: `${filteredPosts.filter(p => p.status === 'published').length} ${t('dashboard.published')}`, icon: FileText, accent: 'text-indigo-600' })
icon: FileText, stats.push({ label: t('dashboard.activeCampaigns'), value: activeCampaigns, detail: `${campaigns.length} ${t('dashboard.total')}`, icon: Megaphone, accent: 'text-pink-600' })
label: t('dashboard.totalPosts'),
value: filteredPosts.length || 0,
subtitle: `${filteredPosts.filter(p => p.status === 'published').length} ${t('dashboard.published')}`,
color: 'brand-primary',
})
statCards.push({
icon: Megaphone,
label: t('dashboard.activeCampaigns'),
value: activeCampaigns,
subtitle: `${campaigns.length} ${t('dashboard.total')}`,
color: 'brand-secondary',
})
}
if (hasModule('finance')) {
statCards.push({
icon: Landmark,
label: t('dashboard.budgetRemaining'),
value: `${(finance?.remaining ?? 0).toLocaleString()}`,
subtitle: finance?.totalReceived ? `${(finance.spent || 0).toLocaleString()} ${t('dashboard.spent')} ${t('dashboard.of')} ${finance.totalReceived.toLocaleString()} ${currencySymbol}` : t('dashboard.noBudget'),
color: 'brand-tertiary',
})
} }
if (hasModule('projects')) { if (hasModule('projects')) {
statCards.push({ stats.push({ label: t('dashboard.overdueTasks'), value: overdueTasks, detail: overdueTasks > 0 ? t('dashboard.needsAttention') : t('dashboard.allOnTrack'), icon: AlertTriangle, accent: overdueTasks > 0 ? 'text-red-600' : 'text-emerald-600' })
icon: AlertTriangle,
label: t('dashboard.overdueTasks'),
value: overdueTasks,
subtitle: overdueTasks > 0 ? t('dashboard.needsAttention') : t('dashboard.allOnTrack'),
color: 'brand-quaternary',
})
} }
if (loading) { if (loading) return <SkeletonDashboard />
return <SkeletonDashboard />
}
return ( return (
<div className="space-y-6 animate-fade-in"> <div className="space-y-6 animate-fade-in">
{/* Welcome + Date presets */} {/* Welcome + Date presets */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div> <p className="text-lg font-medium text-text-primary">
<h1 className="text-2xl font-bold text-gradient"> {t('dashboard.welcomeBack')}, {currentUser?.name || 'there'}
{t('dashboard.welcomeBack')}, {currentUser?.name || 'there'} </p>
</h1>
<p className="text-text-secondary mt-1">
{t('dashboard.happeningToday')}
</p>
</div>
<DatePresetPicker <DatePresetPicker
activePreset={activePreset} activePreset={activePreset}
onSelect={(from, to, key) => { setDateFrom(from); setDateTo(to); setActivePreset(key) }} onSelect={(from, to, key) => { setDateFrom(from); setDateTo(to); setActivePreset(key) }}
@@ -412,11 +392,18 @@ export default function Dashboard() {
/> />
</div> </div>
{/* Stats */} {/* Stats — compact inline row, no cards */}
{statCards.length > 0 && ( {stats.length > 0 && (
<div className={`grid grid-cols-1 sm:grid-cols-2 ${statCards.length >= 4 ? 'lg:grid-cols-4' : statCards.length === 3 ? 'lg:grid-cols-3' : 'lg:grid-cols-2'} gap-4 stagger-children`}> <div className="flex flex-wrap gap-6">
{statCards.map((card, i) => ( {stats.map((s, i) => (
<StatCard key={i} {...card} /> <div key={i} className="flex items-center gap-3">
<s.icon className={`w-5 h-5 ${s.accent}`} />
<div>
<span className="text-2xl font-bold text-text-primary">{s.value}</span>
<span className="text-sm text-text-tertiary ms-1.5">{s.label}</span>
<p className="text-xs text-text-tertiary">{s.detail}</p>
</div>
</div>
))} ))}
</div> </div>
)} )}
@@ -432,7 +419,7 @@ export default function Dashboard() {
{/* Budget + Active Campaigns */} {/* Budget + Active Campaigns */}
{(hasModule('finance') || hasModule('marketing')) && ( {(hasModule('finance') || hasModule('marketing')) && (
<div className={`grid grid-cols-1 ${hasModule('finance') && hasModule('marketing') ? 'lg:grid-cols-3' : ''} gap-6`}> <div className={`grid grid-cols-1 ${hasModule('finance') && hasModule('marketing') ? 'lg:grid-cols-3' : ''} gap-6`}>
{hasModule('finance') && <FinanceMini finance={finance} />} {hasModule('finance') && <BudgetSummary finance={finance} />}
{hasModule('marketing') && ( {hasModule('marketing') && (
<div className={hasModule('finance') ? 'lg:col-span-2' : ''}> <div className={hasModule('finance') ? 'lg:col-span-2' : ''}>
<ActiveCampaignsList campaigns={campaigns} finance={finance} /> <ActiveCampaignsList campaigns={campaigns} finance={finance} />
@@ -441,86 +428,14 @@ export default function Dashboard() {
</div> </div>
)} )}
{/* Recent Posts + Upcoming Deadlines */} {/* Activity — merged posts + deadlines */}
{(hasModule('marketing') || hasModule('projects')) && ( {(hasModule('marketing') || hasModule('projects')) && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <ActivityFeed
{/* Recent Posts */} posts={hasModule('marketing') ? filteredPosts : []}
{hasModule('marketing') && ( deadlines={hasModule('projects') ? upcomingDeadlines : []}
<div className="section-card"> navigate={navigate}
<div className="section-card-header flex items-center justify-between"> t={t}
<h3 className="font-semibold text-text-primary">{t('dashboard.recentPosts')}</h3> />
<Link to="/posts" className="text-sm text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1">
{t('dashboard.viewAll')} <ArrowRight className="w-3.5 h-3.5" />
</Link>
</div>
<div className="divide-y divide-border-light">
{filteredPosts.length === 0 ? (
<div className="py-12 text-center text-sm text-text-tertiary">
{t('dashboard.noPostsYet')}
</div>
) : (
filteredPosts.slice(0, 8).map((post) => (
<div
key={post._id}
onClick={() => navigate('/posts')}
className="flex items-center gap-3 px-5 py-3 hover:bg-surface-secondary transition-colors cursor-pointer"
>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{post.title}</p>
<div className="flex items-center gap-2 mt-1">
{post.brand && <BrandBadge brand={post.brand} />}
</div>
</div>
<StatusBadge status={post.status} size="xs" />
</div>
))
)}
</div>
</div>
)}
{/* Upcoming Deadlines */}
{hasModule('projects') && (
<div className="section-card">
<div className="section-card-header flex items-center justify-between">
<h3 className="font-semibold text-text-primary">{t('dashboard.upcomingDeadlines')}</h3>
<Link to="/tasks" className="text-sm text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1">
{t('dashboard.viewAll')} <ArrowRight className="w-3.5 h-3.5" />
</Link>
</div>
<div className="divide-y divide-border-light">
{upcomingDeadlines.length === 0 ? (
<div className="py-12 text-center text-sm text-text-tertiary">
{t('dashboard.noUpcomingDeadlines')}
</div>
) : (
upcomingDeadlines.map((task) => (
<div
key={task._id}
onClick={() => navigate('/tasks')}
className="flex items-center gap-3 px-5 py-3 hover:bg-surface-secondary transition-colors cursor-pointer"
>
<div className={`w-2 h-2 rounded-full ${(PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium).color}`} />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{task.title}</p>
<div className="flex items-center gap-2 mt-0.5">
<StatusBadge status={task.status} size="xs" />
{task.assignedName && (
<span className="text-xs text-text-tertiary truncate">{task.assignedName}</span>
)}
</div>
</div>
<div className="flex items-center gap-1 text-xs text-text-tertiary shrink-0">
<Clock className="w-3.5 h-3.5" />
{format(new Date(task.dueDate), 'MMM d')}
</div>
</div>
))
)}
</div>
</div>
)}
</div>
)} )}
</div> </div>
) )
+262 -46
View File
@@ -1,14 +1,16 @@
import { useState, useEffect, useContext } from 'react' import { useState, useEffect, useContext } from 'react'
import { DollarSign, TrendingUp, TrendingDown, Wallet, Landmark, Eye, MousePointer, Target, Briefcase, ArrowRight, Receipt } from 'lucide-react' import { DollarSign, TrendingUp, TrendingDown, Wallet, Landmark, Eye, MousePointer, Target, Briefcase, ArrowRight, Receipt, Plus, X } from 'lucide-react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { AppContext } from '../App' import { AppContext } from '../App'
import { api } from '../utils/api' import { api } from '../utils/api'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../i18n/LanguageContext' import { useLanguage } from '../i18n/LanguageContext'
import StatusBadge from '../components/StatusBadge' import StatusBadge from '../components/StatusBadge'
import Modal from '../components/Modal'
import { useToast } from '../components/ToastContainer'
import { SkeletonStatCard, SkeletonTable } from '../components/SkeletonLoader' import { SkeletonStatCard, SkeletonTable } from '../components/SkeletonLoader'
function FinanceStatCard({ icon: Icon, label, value, sub, color = 'text-text-primary', bgColor = 'bg-white' }) { function FinanceStatCard({ icon: Icon, label, value, sub, color = 'text-text-primary', bgColor = 'bg-surface' }) {
return ( return (
<div className={`${bgColor} rounded-xl border border-border p-5`}> <div className={`${bgColor} rounded-xl border border-border p-5`}>
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
@@ -40,19 +42,36 @@ function ProgressRing({ pct, size = 80, stroke = 8, color = '#10b981' }) {
) )
} }
const BUDGET_REQUEST_STATUS_COLORS = {
pending: 'bg-amber-100 text-amber-800',
approved: 'bg-emerald-100 text-emerald-800',
rejected: 'bg-red-100 text-red-800',
cancelled: 'bg-gray-100 text-gray-600',
}
export default function Finance() { export default function Finance() {
const { brands } = useContext(AppContext) const { brands } = useContext(AppContext)
const { permissions } = useAuth() const { permissions, user } = useAuth()
const { currencySymbol } = useLanguage() const { t, currencySymbol } = useLanguage()
const toast = useToast()
const [summary, setSummary] = useState(null) const [summary, setSummary] = useState(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [budgetRequests, setBudgetRequests] = useState([])
const [showRequestModal, setShowRequestModal] = useState(false)
const [requestForm, setRequestForm] = useState({ amount: '', justification: '', earmark_type: '', earmark_id: '' })
const [submittingRequest, setSubmittingRequest] = useState(false)
const isSuperadmin = user?.role === 'superadmin'
useEffect(() => { loadAll() }, []) useEffect(() => { loadAll() }, [])
const loadAll = async () => { const loadAll = async () => {
try { try {
const sum = await api.get('/finance/summary') const fetches = [api.get('/finance/summary')]
if (isSuperadmin) fetches.push(api.get('/budget-requests').catch(() => []))
const [sum, reqs] = await Promise.all(fetches)
setSummary(sum.data || sum || {}) setSummary(sum.data || sum || {})
if (reqs) setBudgetRequests(Array.isArray(reqs) ? reqs : [])
} catch (err) { } catch (err) {
console.error('Failed to load finance:', err) console.error('Failed to load finance:', err)
} finally { } finally {
@@ -60,6 +79,41 @@ export default function Finance() {
} }
} }
const handleSubmitRequest = async () => {
if (!requestForm.amount || !requestForm.justification.trim()) return
setSubmittingRequest(true)
try {
const body = {
amount: Number(requestForm.amount),
justification: requestForm.justification.trim(),
}
if (requestForm.earmark_type === 'campaign' && requestForm.earmark_id) {
body.earmarked_campaign_id = Number(requestForm.earmark_id)
} else if (requestForm.earmark_type === 'project' && requestForm.earmark_id) {
body.earmarked_project_id = Number(requestForm.earmark_id)
}
await api.post('/budget-requests', body)
toast.success(t('finance.requestBudget') + ' — ' + t('common.success'))
setShowRequestModal(false)
setRequestForm({ amount: '', justification: '', earmark_type: '', earmark_id: '' })
loadAll()
} catch (err) {
toast.error(err.message || t('common.error'))
} finally {
setSubmittingRequest(false)
}
}
const handleCancelRequest = async (id) => {
try {
await api.patch(`/budget-requests/${id}/cancel`)
toast.success(t('common.success'))
loadAll()
} catch (err) {
toast.error(err.message || t('common.error'))
}
}
if (loading) { if (loading) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@@ -86,18 +140,35 @@ export default function Finance() {
const projectPct = totalReceived > 0 ? (totalProjectBudget / totalReceived) * 100 : 0 const projectPct = totalReceived > 0 ? (totalProjectBudget / totalReceived) * 100 : 0
const unallocatedPct = totalReceived > 0 ? (Math.max(0, unallocated) / totalReceived) * 100 : 0 const unallocatedPct = totalReceived > 0 ? (Math.max(0, unallocated) / totalReceived) * 100 : 0
const campaigns = s.campaigns || []
const projects = s.projects || []
const pendingCount = budgetRequests.filter(r => r.status === 'pending').length
return ( return (
<div className="space-y-6 animate-fade-in"> <div className="space-y-6 animate-fade-in">
{/* Request Budget button (superadmin) */}
{isSuperadmin && (
<div className="flex justify-end">
<button
onClick={() => setShowRequestModal(true)}
className="flex items-center gap-2 px-4 py-2.5 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light transition-colors shadow-sm"
>
<Plus className="w-4 h-4" />
{t('finance.requestBudget')}
</button>
</div>
)}
{/* Top metrics */} {/* Top metrics */}
<div className={`grid grid-cols-2 ${totalExpenses > 0 ? 'lg:grid-cols-6' : 'lg:grid-cols-5'} gap-4 stagger-children`}> <div className={`grid grid-cols-2 ${totalExpenses > 0 ? 'lg:grid-cols-6' : 'lg:grid-cols-5'} gap-4 stagger-children`}>
<FinanceStatCard icon={Wallet} label="Total Received" value={`${totalReceived.toLocaleString()} ${currencySymbol}`} color="text-blue-600" /> <FinanceStatCard icon={Wallet} label={t('finance.totalReceived')} value={`${totalReceived.toLocaleString()} ${currencySymbol}`} color="text-blue-600" />
<FinanceStatCard icon={TrendingUp} label="Total Spent" value={`${totalSpent.toLocaleString()} ${currencySymbol}`} sub={`${spendPct.toFixed(1)}% of budget`} color="text-amber-600" /> <FinanceStatCard icon={TrendingUp} label={t('finance.totalSpent')} value={`${totalSpent.toLocaleString()} ${currencySymbol}`} sub={`${spendPct.toFixed(1)}% ${t('finance.ofBudget')}`} color="text-amber-600" />
{totalExpenses > 0 && ( {totalExpenses > 0 && (
<FinanceStatCard icon={Receipt} label="Expenses" value={`${totalExpenses.toLocaleString()} ${currencySymbol}`} color="text-red-600" /> <FinanceStatCard icon={Receipt} label={t('finance.expenses')} value={`${totalExpenses.toLocaleString()} ${currencySymbol}`} color="text-red-600" />
)} )}
<FinanceStatCard icon={Landmark} label="Remaining" value={`${remaining.toLocaleString()} ${currencySymbol}`} color={remaining >= 0 ? 'text-emerald-600' : 'text-red-600'} /> <FinanceStatCard icon={Landmark} label={t('finance.remaining')} value={`${remaining.toLocaleString()} ${currencySymbol}`} color={remaining >= 0 ? 'text-emerald-600' : 'text-red-600'} />
<FinanceStatCard icon={DollarSign} label="Revenue" value={`${totalRevenue.toLocaleString()} ${currencySymbol}`} color="text-purple-600" /> <FinanceStatCard icon={DollarSign} label={t('finance.revenue')} value={`${totalRevenue.toLocaleString()} ${currencySymbol}`} color="text-purple-600" />
<FinanceStatCard icon={roi >= 0 ? TrendingUp : TrendingDown} label="Global ROI" <FinanceStatCard icon={roi >= 0 ? TrendingUp : TrendingDown} label={t('finance.globalROI')}
value={`${roi.toFixed(1)}%`} value={`${roi.toFixed(1)}%`}
color={roi >= 0 ? 'text-emerald-600' : 'text-red-600'} /> color={roi >= 0 ? 'text-emerald-600' : 'text-red-600'} />
</div> </div>
@@ -106,9 +177,9 @@ export default function Finance() {
{totalReceived > 0 && ( {totalReceived > 0 && (
<div className="section-card p-5"> <div className="section-card p-5">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium">Budget Allocation</h3> <h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium">{t('finance.budgetAllocation')}</h3>
<Link to="/budgets" className="text-xs text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1"> <Link to="/budgets" className="text-xs text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1">
Manage Budgets <ArrowRight className="w-3 h-3" /> {t('finance.manageBudgets')} <ArrowRight className="w-3 h-3" />
</Link> </Link>
</div> </div>
<div className="h-4 bg-surface-tertiary rounded-full overflow-hidden flex"> <div className="h-4 bg-surface-tertiary rounded-full overflow-hidden flex">
@@ -122,17 +193,17 @@ export default function Finance() {
<div className="flex items-center gap-4 mt-2.5 text-xs"> <div className="flex items-center gap-4 mt-2.5 text-xs">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-full bg-blue-500" /> <div className="w-2.5 h-2.5 rounded-full bg-blue-500" />
<span className="text-text-secondary">Campaigns: <span className="font-semibold text-text-primary">{totalCampaignBudget.toLocaleString()} {currencySymbol}</span></span> <span className="text-text-secondary">{t('finance.campaigns')}: <span className="font-semibold text-text-primary">{totalCampaignBudget.toLocaleString()} {currencySymbol}</span></span>
<span className="text-text-tertiary">({campaignPct.toFixed(0)}%)</span> <span className="text-text-tertiary">({campaignPct.toFixed(0)}%)</span>
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-full bg-purple-500" /> <div className="w-2.5 h-2.5 rounded-full bg-purple-500" />
<span className="text-text-secondary">Projects: <span className="font-semibold text-text-primary">{totalProjectBudget.toLocaleString()} {currencySymbol}</span></span> <span className="text-text-secondary">{t('finance.projects')}: <span className="font-semibold text-text-primary">{totalProjectBudget.toLocaleString()} {currencySymbol}</span></span>
<span className="text-text-tertiary">({projectPct.toFixed(0)}%)</span> <span className="text-text-tertiary">({projectPct.toFixed(0)}%)</span>
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-full bg-gray-300" /> <div className="w-2.5 h-2.5 rounded-full bg-gray-300" />
<span className="text-text-secondary">Unallocated: <span className="font-semibold text-text-primary">{Math.max(0, unallocated).toLocaleString()} {currencySymbol}</span></span> <span className="text-text-secondary">{t('finance.unallocated')}: <span className="font-semibold text-text-primary">{Math.max(0, unallocated).toLocaleString()} {currencySymbol}</span></span>
<span className="text-text-tertiary">({unallocatedPct.toFixed(0)}%)</span> <span className="text-text-tertiary">({unallocatedPct.toFixed(0)}%)</span>
</div> </div>
</div> </div>
@@ -143,7 +214,7 @@ export default function Finance() {
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{/* Utilization ring */} {/* Utilization ring */}
<div className="section-card p-5 flex flex-col items-center justify-center"> <div className="section-card p-5 flex flex-col items-center justify-center">
<h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium mb-4">Budget Utilization</h3> <h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium mb-4">{t('finance.budgetUtilization')}</h3>
<ProgressRing <ProgressRing
pct={spendPct} pct={spendPct}
size={120} size={120}
@@ -151,23 +222,23 @@ export default function Finance() {
color={spendPct > 90 ? '#ef4444' : spendPct > 70 ? '#f59e0b' : '#10b981'} color={spendPct > 90 ? '#ef4444' : spendPct > 70 ? '#f59e0b' : '#10b981'}
/> />
<div className="text-xs text-text-tertiary mt-3"> <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>
</div> </div>
{/* Global performance */} {/* Global performance */}
<div className="section-card p-5 lg:col-span-2"> <div className="section-card p-5 lg:col-span-2">
<h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium mb-4">Global Performance</h3> <h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium mb-4">{t('finance.globalPerformance')}</h3>
<div className="grid grid-cols-3 gap-6"> <div className="grid grid-cols-3 gap-6">
<div className="text-center"> <div className="text-center">
<Eye className="w-5 h-5 mx-auto mb-1 text-purple-500" /> <Eye className="w-5 h-5 mx-auto mb-1 text-purple-500" />
<div className="text-xl font-bold text-text-primary">{(s.impressions || 0).toLocaleString()}</div> <div className="text-xl font-bold text-text-primary">{(s.impressions || 0).toLocaleString()}</div>
<div className="text-xs text-text-tertiary">Impressions</div> <div className="text-xs text-text-tertiary">{t('finance.impressions')}</div>
</div> </div>
<div className="text-center"> <div className="text-center">
<MousePointer className="w-5 h-5 mx-auto mb-1 text-blue-500" /> <MousePointer className="w-5 h-5 mx-auto mb-1 text-blue-500" />
<div className="text-xl font-bold text-text-primary">{(s.clicks || 0).toLocaleString()}</div> <div className="text-xl font-bold text-text-primary">{(s.clicks || 0).toLocaleString()}</div>
<div className="text-xs text-text-tertiary">Clicks</div> <div className="text-xs text-text-tertiary">{t('finance.clicks')}</div>
{s.clicks > 0 && s.spent > 0 && ( {s.clicks > 0 && s.spent > 0 && (
<div className="text-[10px] text-text-tertiary mt-0.5">CPC: {(s.spent / s.clicks).toFixed(2)} {currencySymbol}</div> <div className="text-[10px] text-text-tertiary mt-0.5">CPC: {(s.spent / s.clicks).toFixed(2)} {currencySymbol}</div>
)} )}
@@ -175,7 +246,7 @@ export default function Finance() {
<div className="text-center"> <div className="text-center">
<Target className="w-5 h-5 mx-auto mb-1 text-emerald-500" /> <Target className="w-5 h-5 mx-auto mb-1 text-emerald-500" />
<div className="text-xl font-bold text-text-primary">{(s.conversions || 0).toLocaleString()}</div> <div className="text-xl font-bold text-text-primary">{(s.conversions || 0).toLocaleString()}</div>
<div className="text-xs text-text-tertiary">Conversions</div> <div className="text-xs text-text-tertiary">{t('finance.conversions')}</div>
{s.conversions > 0 && s.spent > 0 && ( {s.conversions > 0 && s.spent > 0 && (
<div className="text-[10px] text-text-tertiary mt-0.5">CPA: {(s.spent / s.conversions).toFixed(2)} {currencySymbol}</div> <div className="text-[10px] text-text-tertiary mt-0.5">CPA: {(s.spent / s.conversions).toFixed(2)} {currencySymbol}</div>
)} )}
@@ -200,22 +271,22 @@ export default function Finance() {
<Target className="w-4 h-4 text-blue-600" /> <Target className="w-4 h-4 text-blue-600" />
</div> </div>
<div> <div>
<h3 className="font-semibold text-text-primary text-base">Campaign Breakdown</h3> <h3 className="font-semibold text-text-primary">{t('finance.campaignBreakdown')}</h3>
<p className="text-xs text-text-tertiary mt-0.5">{s.campaigns.length} campaigns &middot; Track-level budget allocation</p> <p className="text-xs text-text-tertiary mt-0.5">{t('finance.campaignCount').replace('{{count}}', s.campaigns.length)}</p>
</div> </div>
</div> </div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b border-border bg-surface-secondary"> <tr className="border-b border-border bg-surface-secondary">
<th className="px-4 py-3 text-left text-xs font-medium text-text-tertiary">Campaign</th> <th className="px-4 py-3 text-start text-xs font-medium text-text-tertiary">{t('finance.campaign')}</th>
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">Budget Assigned</th> <th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">{t('finance.budgetAssigned')}</th>
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">Track Allocated</th> <th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">{t('finance.trackAllocated')}</th>
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">Spent</th> <th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">{t('finance.spent')}</th>
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">Expenses</th> <th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">{t('finance.expenses')}</th>
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">Revenue</th> <th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">{t('finance.revenue')}</th>
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">ROI</th> <th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">{t('finance.roi')}</th>
<th className="px-4 py-3 text-center text-xs font-medium text-text-tertiary">Status</th> <th className="px-4 py-3 text-center text-xs font-medium text-text-tertiary">{t('common.status')}</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-border-light"> <tbody className="divide-y divide-border-light">
@@ -225,20 +296,20 @@ export default function Finance() {
return ( return (
<tr key={c.id} className="hover:bg-surface-secondary"> <tr key={c.id} className="hover:bg-surface-secondary">
<td className="px-4 py-3 font-medium text-text-primary">{c.name}</td> <td className="px-4 py-3 font-medium text-text-primary">{c.name}</td>
<td className="px-4 py-3 text-right"> <td className="px-4 py-3 text-end">
{c.budget_from_entries > 0 ? ( {c.budget_from_entries > 0 ? (
<span className="font-semibold text-blue-600">{c.budget_from_entries.toLocaleString()}</span> <span className="font-semibold text-blue-600">{c.budget_from_entries.toLocaleString()}</span>
) : <span className="text-text-tertiary">{'\u2014'}</span>} ) : <span className="text-text-tertiary">{'\u2014'}</span>}
</td> </td>
<td className="px-4 py-3 text-right text-text-secondary">{c.tracks_allocated.toLocaleString()}</td> <td className="px-4 py-3 text-end text-text-secondary">{c.tracks_allocated.toLocaleString()}</td>
<td className="px-4 py-3 text-right text-text-secondary">{c.tracks_spent.toLocaleString()}</td> <td className="px-4 py-3 text-end text-text-secondary">{c.tracks_spent.toLocaleString()}</td>
<td className="px-4 py-3 text-right"> <td className="px-4 py-3 text-end">
{c.expenses > 0 ? ( {c.expenses > 0 ? (
<span className="font-semibold text-red-500">-{c.expenses.toLocaleString()}</span> <span className="font-semibold text-red-500">-{c.expenses.toLocaleString()}</span>
) : <span className="text-text-tertiary">{'\u2014'}</span>} ) : <span className="text-text-tertiary">{'\u2014'}</span>}
</td> </td>
<td className="px-4 py-3 text-right text-text-secondary">{c.tracks_revenue.toLocaleString()}</td> <td className="px-4 py-3 text-end text-text-secondary">{c.tracks_revenue.toLocaleString()}</td>
<td className="px-4 py-3 text-right"> <td className="px-4 py-3 text-end">
{totalCampaignConsumed > 0 ? ( {totalCampaignConsumed > 0 ? (
<span className={`text-xs font-semibold px-1.5 py-0.5 rounded ${cRoi >= 0 ? 'text-emerald-600 bg-emerald-50' : 'text-red-600 bg-red-50'}`}> <span className={`text-xs font-semibold px-1.5 py-0.5 rounded ${cRoi >= 0 ? 'text-emerald-600 bg-emerald-50' : 'text-red-600 bg-red-50'}`}>
{cRoi.toFixed(0)}% {cRoi.toFixed(0)}%
@@ -263,26 +334,26 @@ export default function Finance() {
<Briefcase className="w-4 h-4 text-purple-600" /> <Briefcase className="w-4 h-4 text-purple-600" />
</div> </div>
<div> <div>
<h3 className="font-semibold text-text-primary text-base">Allocated Funds</h3> <h3 className="font-semibold text-text-primary">{t('finance.allocatedFunds')}</h3>
<p className="text-xs text-text-tertiary mt-0.5">{s.projects.filter(p => p.budget_allocated > 0 || p.expenses > 0).length} work orders with assigned budget</p> <p className="text-xs text-text-tertiary mt-0.5">{t('finance.workOrderCount').replace('{{count}}', s.projects.filter(p => p.budget_allocated > 0 || p.expenses > 0).length)}</p>
</div> </div>
</div> </div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b border-border bg-surface-secondary"> <tr className="border-b border-border bg-surface-secondary">
<th className="px-4 py-3 text-left text-xs font-medium text-text-tertiary">Work Order</th> <th className="px-4 py-3 text-start text-xs font-medium text-text-tertiary">{t('finance.workOrder')}</th>
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">Budget Allocated</th> <th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">{t('finance.budgetAllocated')}</th>
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">Expenses</th> <th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">{t('finance.expenses')}</th>
<th className="px-4 py-3 text-center text-xs font-medium text-text-tertiary">Status</th> <th className="px-4 py-3 text-center text-xs font-medium text-text-tertiary">{t('common.status')}</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-border-light"> <tbody className="divide-y divide-border-light">
{s.projects.filter(p => p.budget_allocated > 0 || p.expenses > 0).map(p => ( {s.projects.filter(p => p.budget_allocated > 0 || p.expenses > 0).map(p => (
<tr key={p.id} className="hover:bg-surface-secondary"> <tr key={p.id} className="hover:bg-surface-secondary">
<td className="px-4 py-3 font-medium text-text-primary">{p.name}</td> <td className="px-4 py-3 font-medium text-text-primary">{p.name}</td>
<td className="px-4 py-3 text-right text-text-secondary">{p.budget_allocated.toLocaleString()} {currencySymbol}</td> <td className="px-4 py-3 text-end text-text-secondary">{p.budget_allocated.toLocaleString()} {currencySymbol}</td>
<td className="px-4 py-3 text-right"> <td className="px-4 py-3 text-end">
{p.expenses > 0 ? ( {p.expenses > 0 ? (
<span className="font-semibold text-red-500">-{p.expenses.toLocaleString()}</span> <span className="font-semibold text-red-500">-{p.expenses.toLocaleString()}</span>
) : <span className="text-text-tertiary">{'\u2014'}</span>} ) : <span className="text-text-tertiary">{'\u2014'}</span>}
@@ -295,6 +366,151 @@ export default function Finance() {
</div> </div>
</div> </div>
)} )}
{/* Budget Requests (superadmin) */}
{isSuperadmin && (
<div className="section-card">
<div className="section-card-header flex items-center gap-3">
<div className="p-2 rounded-lg bg-amber-50">
<Wallet className="w-4 h-4 text-amber-600" />
</div>
<div>
<h3 className="font-semibold text-text-primary">{t('finance.budgetRequests')}</h3>
</div>
</div>
{pendingCount > 0 && (
<div className="mx-5 mt-4 px-4 py-2.5 bg-amber-50 border border-amber-200 rounded-lg text-sm text-amber-800 font-medium">
{pendingCount} {t('finance.requestPending')}
</div>
)}
{budgetRequests.length === 0 ? (
<div className="px-5 py-8 text-center text-sm text-text-tertiary">
{t('common.noData')}
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-surface-secondary">
<th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">{t('finance.amount')}</th>
<th className="px-4 py-3 text-start text-xs font-medium text-text-tertiary">{t('budgetApproval.justification')}</th>
<th className="px-4 py-3 text-center text-xs font-medium text-text-tertiary">{t('common.status')}</th>
<th className="px-4 py-3 text-start text-xs font-medium text-text-tertiary">{t('budgetApproval.earmarkedFor')}</th>
<th className="px-4 py-3 text-start text-xs font-medium text-text-tertiary">{t('common.date')}</th>
<th className="px-4 py-3 text-center text-xs font-medium text-text-tertiary"></th>
</tr>
</thead>
<tbody className="divide-y divide-border-light">
{budgetRequests.map(req => (
<tr key={req.id || req.Id} className="hover:bg-surface-secondary">
<td className="px-4 py-3 text-end font-semibold text-text-primary">
{Number(req.amount).toLocaleString()} {currencySymbol}
</td>
<td className="px-4 py-3 text-text-secondary max-w-[200px]">
<span title={req.justification}>
{req.justification?.length > 60 ? req.justification.slice(0, 60) + '...' : req.justification}
</span>
</td>
<td className="px-4 py-3 text-center">
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${BUDGET_REQUEST_STATUS_COLORS[req.status] || 'bg-gray-100 text-gray-600'}`}>
{req.status}
</span>
</td>
<td className="px-4 py-3 text-text-secondary text-xs">
{req.earmark_name || '\u2014'}
</td>
<td className="px-4 py-3 text-text-tertiary text-xs">
{req.created_at ? new Date(req.created_at).toLocaleDateString() : '\u2014'}
</td>
<td className="px-4 py-3 text-center">
{req.status === 'pending' && (
<button
onClick={() => handleCancelRequest(req.id || req.Id)}
className="text-xs text-red-600 hover:text-red-700 font-medium hover:bg-red-50 px-2 py-1 rounded transition-colors"
>
{t('common.cancel')}
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
{/* Budget Request Modal */}
<Modal isOpen={showRequestModal} onClose={() => setShowRequestModal(false)} title={t('finance.requestBudget')} size="md">
<div className="space-y-4">
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('finance.amount')}</label>
<div className="flex items-center gap-2">
<input
type="number"
min="1"
value={requestForm.amount}
onChange={e => setRequestForm(f => ({ ...f, amount: e.target.value }))}
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface"
placeholder="0"
autoFocus
/>
<span className="text-sm text-text-tertiary">{currencySymbol}</span>
</div>
</div>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('budgetApproval.justification')}</label>
<textarea
value={requestForm.justification}
onChange={e => setRequestForm(f => ({ ...f, justification: e.target.value }))}
rows={3}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface"
placeholder={t('budgetApproval.justification')}
/>
</div>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('budgetApproval.earmarkedFor')}</label>
<select
value={requestForm.earmark_type ? `${requestForm.earmark_type}:${requestForm.earmark_id}` : ''}
onChange={e => {
if (!e.target.value) {
setRequestForm(f => ({ ...f, earmark_type: '', earmark_id: '' }))
} else {
const [type, id] = e.target.value.split(':')
setRequestForm(f => ({ ...f, earmark_type: type, earmark_id: id }))
}
}}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface"
>
<option value="">{t('common.none')}</option>
{campaigns.length > 0 && (
<optgroup label={t('finance.campaigns')}>
{campaigns.map(c => (
<option key={`campaign:${c.id}`} value={`campaign:${c.id}`}>{c.name}</option>
))}
</optgroup>
)}
{projects.length > 0 && (
<optgroup label={t('finance.projects')}>
{projects.map(p => (
<option key={`project:${p.id}`} value={`project:${p.id}`}>{p.name}</option>
))}
</optgroup>
)}
</select>
</div>
<button
onClick={handleSubmitRequest}
disabled={!requestForm.amount || !requestForm.justification.trim() || submittingRequest}
className={`w-full px-4 py-2.5 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm transition-colors ${submittingRequest ? 'btn-loading' : ''}`}
>
{t('finance.requestBudget')}
</button>
</div>
</Modal>
</div> </div>
) )
} }
+16 -7
View File
@@ -1,9 +1,18 @@
import { useState } from 'react' import { useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { useLanguage } from '../i18n/LanguageContext' import { useLanguage } from '../i18n/LanguageContext'
import { Megaphone, Mail, AlertCircle, CheckCircle, ArrowLeft } from 'lucide-react' import { Mail, AlertCircle, CheckCircle, ArrowLeft } from 'lucide-react'
import { api } from '../utils/api' import { api } from '../utils/api'
function MarkaLogo({ className = '' }) {
return (
<svg viewBox="0 0 32 32" fill="none" className={className}>
<path d="M4 26V6l10 10L4 26z" fill="currentColor" opacity="0.85" />
<path d="M18 26V6l10 10-10 10z" fill="currentColor" opacity="0.5" />
</svg>
)
}
export default function ForgotPassword() { export default function ForgotPassword() {
const { t } = useLanguage() const { t } = useLanguage()
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
@@ -27,11 +36,11 @@ export default function ForgotPassword() {
} }
return ( return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-blue-900 to-slate-900 flex items-center justify-center px-4"> <div className="min-h-screen bg-slate-900 flex items-center justify-center px-4">
<div className="w-full max-w-md"> <div className="w-full max-w-md">
<div className="text-center mb-8"> <div className="text-center mb-8">
<div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-purple-600 rounded-2xl flex items-center justify-center mx-auto mb-4 shadow-lg"> <div className="w-16 h-16 bg-brand-primary rounded-2xl flex items-center justify-center mx-auto mb-4">
<Megaphone className="w-8 h-8 text-white" /> <MarkaLogo className="w-9 h-9 text-white" />
</div> </div>
<h1 className="text-3xl font-bold text-white mb-2">{t('forgotPassword.title')}</h1> <h1 className="text-3xl font-bold text-white mb-2">{t('forgotPassword.title')}</h1>
<p className="text-slate-400">{t('forgotPassword.subtitle')}</p> <p className="text-slate-400">{t('forgotPassword.subtitle')}</p>
@@ -57,13 +66,13 @@ export default function ForgotPassword() {
<div> <div>
<label className="block text-sm font-medium text-slate-300 mb-2">{t('auth.email')}</label> <label className="block text-sm font-medium text-slate-300 mb-2">{t('auth.email')}</label>
<div className="relative"> <div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" /> <Mail className="absolute start-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<input <input
type="email" type="email"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
dir="auto" dir="auto"
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all" className="w-full ps-11 pe-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
placeholder={t('forgotPassword.emailPlaceholder')} placeholder={t('forgotPassword.emailPlaceholder')}
required required
autoFocus autoFocus
@@ -81,7 +90,7 @@ export default function ForgotPassword() {
<button <button
type="submit" type="submit"
disabled={loading} disabled={loading}
className="w-full py-3 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed" className="w-full py-3 bg-brand-primary hover:bg-brand-primary-light text-white font-semibold rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
> >
{loading ? ( {loading ? (
<span className="flex items-center justify-center gap-2"> <span className="flex items-center justify-center gap-2">
+15 -23
View File
@@ -196,8 +196,8 @@ export default function Issues() {
const SortIcon = ({ col }) => { const SortIcon = ({ col }) => {
if (sortBy !== col) return null if (sortBy !== col) return null
return sortDir === 'asc' return sortDir === 'asc'
? <ChevronUp className="w-3 h-3 inline ml-0.5" /> ? <ChevronUp className="w-3 h-3 inline ms-0.5" />
: <ChevronDown className="w-3 h-3 inline ml-0.5" /> : <ChevronDown className="w-3 h-3 inline ms-0.5" />
} }
if (loading) { if (loading) {
@@ -211,15 +211,7 @@ export default function Issues() {
return ( return (
<div className="space-y-4 animate-fade-in"> <div className="space-y-4 animate-fade-in">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-end">
<div>
<h1 className="text-2xl font-bold text-text-primary flex items-center gap-2">
<AlertCircle className="w-7 h-7" />
{t('issues.title')}
</h1>
<p className="text-text-secondary mt-1">{t('issues.subtitle')}</p>
</div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<button <button
onClick={copyPublicLink} onClick={copyPublicLink}
@@ -241,7 +233,7 @@ export default function Issues() {
onClick={() => setViewMode(mode)} onClick={() => setViewMode(mode)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${ className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
viewMode === mode viewMode === mode
? 'bg-white text-text-primary shadow-sm' ? 'bg-surface text-text-primary shadow-sm'
: 'text-text-tertiary hover:text-text-secondary' : 'text-text-tertiary hover:text-text-secondary'
}`} }`}
> >
@@ -276,13 +268,13 @@ export default function Issues() {
<div className="flex items-center gap-3 flex-wrap"> <div className="flex items-center gap-3 flex-wrap">
{/* Search */} {/* Search */}
<div className="relative flex-1 min-w-[200px] max-w-xs"> <div className="relative flex-1 min-w-[200px] max-w-xs">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" /> <Search className="absolute start-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<input <input
type="text" type="text"
placeholder={t('issues.searchPlaceholder')} placeholder={t('issues.searchPlaceholder')}
value={searchTerm} value={searchTerm}
onChange={e => setSearchTerm(e.target.value)} onChange={e => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 bg-surface" className="w-full ps-10 pe-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 bg-surface"
/> />
</div> </div>
@@ -413,21 +405,21 @@ export default function Issues() {
<th className="w-10 px-3 py-3" onClick={e => e.stopPropagation()}> <th className="w-10 px-3 py-3" onClick={e => e.stopPropagation()}>
<input type="checkbox" checked={selectedIds.size === sortedIssues.length && sortedIssues.length > 0} onChange={toggleSelectAll} className="rounded border-border" /> <input type="checkbox" checked={selectedIds.size === sortedIssues.length && sortedIssues.length > 0} onChange={toggleSelectAll} className="rounded border-border" />
</th> </th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleSort('title')}> <th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleSort('title')}>
{t('issues.tableTitle')} <SortIcon col="title" /> {t('issues.tableTitle')} <SortIcon col="title" />
</th> </th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('issues.tableSubmitter')}</th> <th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('issues.tableSubmitter')}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('issues.tableBrand')}</th> <th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('issues.tableBrand')}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('issues.tableCategory')}</th> <th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('issues.tableCategory')}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('issues.tableType')}</th> <th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('issues.tableType')}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleSort('priority')}> <th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleSort('priority')}>
{t('issues.tablePriority')} <SortIcon col="priority" /> {t('issues.tablePriority')} <SortIcon col="priority" />
</th> </th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleSort('status')}> <th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleSort('status')}>
{t('issues.tableStatus')} <SortIcon col="status" /> {t('issues.tableStatus')} <SortIcon col="status" />
</th> </th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('issues.tableAssignedTo')}</th> <th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('issues.tableAssignedTo')}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleSort('created_at')}> <th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleSort('created_at')}>
{t('issues.tableCreated')} <SortIcon col="created_at" /> {t('issues.tableCreated')} <SortIcon col="created_at" />
</th> </th>
</tr> </tr>
+33 -21
View File
@@ -2,9 +2,18 @@ import { useState, useEffect } from 'react'
import { useNavigate, Link } from 'react-router-dom' import { useNavigate, Link } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../i18n/LanguageContext' import { useLanguage } from '../i18n/LanguageContext'
import { Megaphone, Lock, Mail, AlertCircle, User, CheckCircle } from 'lucide-react' import { Lock, Mail, AlertCircle, User, CheckCircle } from 'lucide-react'
import { api } from '../utils/api' import { api } from '../utils/api'
function MarkaLogo({ className = '' }) {
return (
<svg viewBox="0 0 32 32" fill="none" className={className}>
<path d="M4 26V6l10 10L4 26z" fill="currentColor" opacity="0.85" />
<path d="M18 26V6l10 10-10 10z" fill="currentColor" opacity="0.5" />
</svg>
)
}
export default function Login() { export default function Login() {
const navigate = useNavigate() const navigate = useNavigate()
const { login } = useAuth() const { login } = useAuth()
@@ -63,19 +72,19 @@ export default function Login() {
if (needsSetup === null) { if (needsSetup === null) {
return ( return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-blue-900 to-slate-900 flex items-center justify-center"> <div className="min-h-screen bg-slate-900 flex items-center justify-center">
<div className="w-8 h-8 border-2 border-white/30 border-t-white rounded-full animate-spin" /> <div className="w-8 h-8 border-2 border-white/30 border-t-white rounded-full animate-spin" />
</div> </div>
) )
} }
return ( return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-blue-900 to-slate-900 flex items-center justify-center px-4"> <div className="min-h-screen bg-slate-900 flex items-center justify-center px-4">
<div className="w-full max-w-md"> <div className="w-full max-w-md">
{/* Logo & Title */} {/* Logo & Title */}
<div className="text-center mb-8"> <div className="text-center mb-8">
<div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-purple-600 rounded-2xl flex items-center justify-center mx-auto mb-4 shadow-lg"> <div className="w-16 h-16 bg-brand-primary rounded-2xl flex items-center justify-center mx-auto mb-4">
<Megaphone className="w-8 h-8 text-white" /> <MarkaLogo className="w-9 h-9 text-white" />
</div> </div>
<h1 className="text-3xl font-bold text-white mb-2"> <h1 className="text-3xl font-bold text-white mb-2">
{needsSetup ? t('login.initialSetup') : t('login.title')} {needsSetup ? t('login.initialSetup') : t('login.title')}
@@ -101,15 +110,16 @@ export default function Login() {
<div> <div>
<label className="block text-sm font-medium text-slate-300 mb-2">{t('login.fullName')}</label> <label className="block text-sm font-medium text-slate-300 mb-2">{t('login.fullName')}</label>
<div className="relative"> <div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" /> <User className="absolute start-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<input <input
type="text" type="text"
value={setupName} value={setupName}
onChange={(e) => setSetupName(e.target.value)} onChange={(e) => setSetupName(e.target.value)}
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all" className="w-full ps-11 pe-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
placeholder={t('login.fullNamePlaceholder')} placeholder={t('login.fullNamePlaceholder')}
required required
autoFocus autoFocus
aria-describedby={error ? 'setup-error' : undefined}
/> />
</div> </div>
</div> </div>
@@ -118,13 +128,13 @@ export default function Login() {
<div> <div>
<label className="block text-sm font-medium text-slate-300 mb-2">{t('login.email')}</label> <label className="block text-sm font-medium text-slate-300 mb-2">{t('login.email')}</label>
<div className="relative"> <div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" /> <Mail className="absolute start-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<input <input
type="email" type="email"
value={setupEmail} value={setupEmail}
onChange={(e) => setSetupEmail(e.target.value)} onChange={(e) => setSetupEmail(e.target.value)}
dir="auto" dir="auto"
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all" className="w-full ps-11 pe-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
placeholder="admin@company.com" placeholder="admin@company.com"
required required
/> />
@@ -135,12 +145,12 @@ export default function Login() {
<div> <div>
<label className="block text-sm font-medium text-slate-300 mb-2">{t('login.password')}</label> <label className="block text-sm font-medium text-slate-300 mb-2">{t('login.password')}</label>
<div className="relative"> <div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" /> <Lock className="absolute start-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<input <input
type="password" type="password"
value={setupPassword} value={setupPassword}
onChange={(e) => setSetupPassword(e.target.value)} onChange={(e) => setSetupPassword(e.target.value)}
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all" className="w-full ps-11 pe-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
placeholder={t('login.passwordPlaceholder')} placeholder={t('login.passwordPlaceholder')}
required required
minLength={6} minLength={6}
@@ -152,12 +162,12 @@ export default function Login() {
<div> <div>
<label className="block text-sm font-medium text-slate-300 mb-2">{t('login.confirmPassword')}</label> <label className="block text-sm font-medium text-slate-300 mb-2">{t('login.confirmPassword')}</label>
<div className="relative"> <div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" /> <Lock className="absolute start-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<input <input
type="password" type="password"
value={setupConfirm} value={setupConfirm}
onChange={(e) => setSetupConfirm(e.target.value)} onChange={(e) => setSetupConfirm(e.target.value)}
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all" className="w-full ps-11 pe-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
placeholder={t('login.confirmPasswordPlaceholder')} placeholder={t('login.confirmPasswordPlaceholder')}
required required
minLength={6} minLength={6}
@@ -167,7 +177,7 @@ export default function Login() {
{/* Error */} {/* Error */}
{error && ( {error && (
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg"> <div id="setup-error" className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg" role="alert">
<AlertCircle className="w-5 h-5 text-red-400 shrink-0" /> <AlertCircle className="w-5 h-5 text-red-400 shrink-0" />
<p className="text-sm text-red-400">{error}</p> <p className="text-sm text-red-400">{error}</p>
</div> </div>
@@ -177,7 +187,7 @@ export default function Login() {
<button <button
type="submit" type="submit"
disabled={loading} disabled={loading}
className="w-full py-3 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed" className="w-full py-3 bg-brand-primary hover:bg-brand-primary-light text-white font-semibold rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
> >
{loading ? ( {loading ? (
<span className="flex items-center justify-center gap-2"> <span className="flex items-center justify-center gap-2">
@@ -197,16 +207,17 @@ export default function Login() {
{t('auth.email')} {t('auth.email')}
</label> </label>
<div className="relative"> <div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" /> <Mail className="absolute start-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<input <input
type="email" type="email"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
dir="auto" dir="auto"
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all" className="w-full ps-11 pe-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
placeholder="user@company.com" placeholder="user@company.com"
required required
autoFocus autoFocus
aria-describedby={error ? 'login-error' : undefined}
/> />
</div> </div>
</div> </div>
@@ -217,21 +228,22 @@ export default function Login() {
{t('auth.password')} {t('auth.password')}
</label> </label>
<div className="relative"> <div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" /> <Lock className="absolute start-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<input <input
type="password" type="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all" className="w-full ps-11 pe-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
placeholder="••••••••" placeholder="••••••••"
required required
aria-describedby={error ? 'login-error' : undefined}
/> />
</div> </div>
</div> </div>
{/* Error */} {/* Error */}
{error && ( {error && (
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg"> <div id="login-error" className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg" role="alert">
<AlertCircle className="w-5 h-5 text-red-400 shrink-0" /> <AlertCircle className="w-5 h-5 text-red-400 shrink-0" />
<p className="text-sm text-red-400">{error}</p> <p className="text-sm text-red-400">{error}</p>
</div> </div>
@@ -241,7 +253,7 @@ export default function Login() {
<button <button
type="submit" type="submit"
disabled={loading} disabled={loading}
className="w-full py-3 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed" className="w-full py-3 bg-brand-primary hover:bg-brand-primary-light text-white font-semibold rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
> >
{loading ? ( {loading ? (
<span className="flex items-center justify-center gap-2"> <span className="flex items-center justify-center gap-2">
+14 -22
View File
@@ -6,7 +6,7 @@ import { api, PLATFORMS } from '../utils/api'
import PostDetailPanel from '../components/PostDetailPanel' import PostDetailPanel from '../components/PostDetailPanel'
import { SkeletonCalendar } from '../components/SkeletonLoader' 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 = { const STATUS_COLORS = {
draft: 'bg-surface-tertiary text-text-secondary', draft: 'bg-surface-tertiary text-text-secondary',
@@ -158,14 +158,6 @@ export default function PostCalendar() {
return ( return (
<div className="space-y-4 animate-fade-in"> <div className="space-y-4 animate-fade-in">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-text-primary">Content Calendar</h1>
<p className="text-sm text-text-secondary mt-1">Schedule and plan your posts</p>
</div>
</div>
{/* Filters */} {/* Filters */}
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<select <select
@@ -202,7 +194,7 @@ export default function PostCalendar() {
</div> </div>
{/* Calendar */} {/* Calendar */}
<div className="bg-surface rounded-xl border border-border overflow-hidden"> <div className="bg-surface rounded-xl border border-border overflow-clip">
{/* Nav */} {/* Nav */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border"> <div className="flex items-center justify-between px-6 py-4 border-b border-border">
<div className="flex items-center gap-3"> <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"> <div className="flex bg-surface-tertiary rounded-lg p-0.5">
<button <button
onClick={() => setCalView('month')} onClick={() => setCalView('month')}
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors flex items-center gap-1.5 ${calView === 'month' ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary'}`} className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors flex items-center gap-1.5 ${calView === 'month' ? 'bg-surface shadow-sm text-text-primary' : 'text-text-tertiary'}`}
> >
<CalendarIcon className="w-3.5 h-3.5" /> <CalendarIcon className="w-3.5 h-3.5" />
Month {t('calendar.month')}
</button> </button>
<button <button
onClick={() => setCalView('week')} onClick={() => setCalView('week')}
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors flex items-center gap-1.5 ${calView === 'week' ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary'}`} className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors flex items-center gap-1.5 ${calView === 'week' ? 'bg-surface shadow-sm text-text-primary' : 'text-text-tertiary'}`}
> >
<CalendarDays className="w-3.5 h-3.5" /> <CalendarDays className="w-3.5 h-3.5" />
Week {t('calendar.week')}
</button> </button>
</div> </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"> <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> </button>
</div> </div>
</div> </div>
{/* Day headers */} {/* Day headers */}
<div className="grid grid-cols-7 border-b border-border bg-surface-secondary"> <div className="grid grid-cols-7 border-b border-border bg-surface-secondary">
{DAYS.map(d => ( {DAY_KEYS.map(k => (
<div key={d} className="text-center text-xs font-semibold text-text-tertiary uppercase py-3"> <div key={k} className="text-center text-xs font-semibold text-text-tertiary uppercase py-3">
{d} {t(k)}
</div> </div>
))} ))}
</div> </div>
@@ -271,7 +263,7 @@ export default function PostCalendar() {
<button <button
key={post.Id || post._id} key={post.Id || post._id}
onClick={() => handlePostClick(post)} onClick={() => handlePostClick(post)}
className={`w-full text-left text-[10px] px-2 py-1 rounded font-medium hover:opacity-80 transition-opacity truncate ${ className={`w-full text-start text-[10px] px-2 py-1 rounded font-medium hover:opacity-80 transition-opacity truncate ${
STATUS_COLORS[post.status] || 'bg-surface-tertiary text-text-secondary' STATUS_COLORS[post.status] || 'bg-surface-tertiary text-text-secondary'
}`} }`}
title={post.title} title={post.title}
@@ -294,13 +286,13 @@ export default function PostCalendar() {
{/* Unscheduled Posts */} {/* Unscheduled Posts */}
{unscheduled.length > 0 && ( {unscheduled.length > 0 && (
<div className="bg-surface rounded-xl border border-border p-6"> <div className="bg-surface rounded-xl border border-border p-6">
<h3 className="text-sm font-semibold text-text-tertiary uppercase mb-3">Unscheduled Posts</h3> <h3 className="text-sm font-semibold text-text-tertiary uppercase mb-3">{t('calendar.unscheduledPosts')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{unscheduled.map(post => ( {unscheduled.map(post => (
<button <button
key={post.Id || post._id} key={post.Id || post._id}
onClick={() => handlePostClick(post)} onClick={() => handlePostClick(post)}
className="text-left bg-surface-secondary border border-border rounded-lg p-3 hover:border-brand-primary/30 transition-colors" className="text-start bg-surface-secondary border border-border rounded-lg p-3 hover:border-brand-primary/30 transition-colors"
> >
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<span className={`text-xs px-2 py-0.5 rounded-full ${STATUS_COLORS[post.status] || 'bg-surface-tertiary text-text-secondary'}`}> <span className={`text-xs px-2 py-0.5 rounded-full ${STATUS_COLORS[post.status] || 'bg-surface-tertiary text-text-secondary'}`}>
@@ -319,7 +311,7 @@ export default function PostCalendar() {
{/* Legend */} {/* Legend */}
<div className="bg-surface rounded-xl border border-border p-4"> <div className="bg-surface rounded-xl border border-border p-4">
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-3">Status Legend</h4> <h4 className="text-xs font-semibold text-text-tertiary uppercase mb-3">{t('calendar.statusLegend')}</h4>
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
{Object.entries(STATUS_COLORS).map(([status, color]) => ( {Object.entries(STATUS_COLORS).map(([status, color]) => (
<div key={status} className="flex items-center gap-2"> <div key={status} className="flex items-center gap-2">
+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 { Plus, LayoutGrid, List, Search, X, FileText, Filter } from 'lucide-react'
import { AppContext } from '../App' import { AppContext } from '../App'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
@@ -7,12 +8,11 @@ import { api, PLATFORMS } from '../utils/api'
import KanbanBoard from '../components/KanbanBoard' import KanbanBoard from '../components/KanbanBoard'
import KanbanCard from '../components/KanbanCard' import KanbanCard from '../components/KanbanCard'
import PostCard from '../components/PostCard' import PostCard from '../components/PostCard'
import PostDetailPanel from '../components/PostDetailPanel'
import DatePresetPicker from '../components/DatePresetPicker' import DatePresetPicker from '../components/DatePresetPicker'
import { SkeletonKanbanBoard, SkeletonTable } from '../components/SkeletonLoader' import { SkeletonKanbanBoard, SkeletonTable } from '../components/SkeletonLoader'
import EmptyState from '../components/EmptyState' import EmptyState from '../components/EmptyState'
import BulkSelectBar from '../components/BulkSelectBar'
import Modal from '../components/Modal' import Modal from '../components/Modal'
import BulkSelectBar from '../components/BulkSelectBar'
import { useToast } from '../components/ToastContainer' import { useToast } from '../components/ToastContainer'
const EMPTY_POST = { const EMPTY_POST = {
@@ -23,13 +23,13 @@ const EMPTY_POST = {
export default function PostProduction() { export default function PostProduction() {
const { t, lang } = useLanguage() const { t, lang } = useLanguage()
const navigate = useNavigate()
const { teamMembers, brands, getBrandName } = useContext(AppContext) const { teamMembers, brands, getBrandName } = useContext(AppContext)
const { canEditResource } = useAuth() const { canEditResource } = useAuth()
const toast = useToast() const toast = useToast()
const [posts, setPosts] = useState([]) const [posts, setPosts] = useState([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [view, setView] = useState('kanban') const [view, setView] = useState('kanban')
const [panelPost, setPanelPost] = useState(null)
const [campaigns, setCampaigns] = useState([]) const [campaigns, setCampaigns] = useState([])
const [filters, setFilters] = useState({ brand: '', platform: '', assignedTo: '', campaign: '', periodFrom: '', periodTo: '' }) const [filters, setFilters] = useState({ brand: '', platform: '', assignedTo: '', campaign: '', periodFrom: '', periodTo: '' })
const [searchTerm, setSearchTerm] = useState('') const [searchTerm, setSearchTerm] = useState('')
@@ -38,9 +38,6 @@ export default function PostProduction() {
const [selectedIds, setSelectedIds] = useState(new Set()) const [selectedIds, setSelectedIds] = useState(new Set())
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false) const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false)
const [showFilters, setShowFilters] = useState(false) const [showFilters, setShowFilters] = useState(false)
const [showCreateModal, setShowCreateModal] = useState(false)
const [createForm, setCreateForm] = useState({ ...EMPTY_POST })
const [createSaving, setCreateSaving] = useState(false)
useEffect(() => { useEffect(() => {
loadPosts() 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) => { const handlePanelDelete = async (postId) => {
try { try {
await api.delete(`/posts/${postId}`) await api.delete(`/posts/${postId}`)
@@ -131,43 +114,22 @@ export default function PostProduction() {
} }
const openEdit = (post) => { const openEdit = (post) => {
if (!canEditResource('post', post)) { const postId = post._id || post.id || post.Id
toast.error(t('posts.canOnlyEditOwn')) navigate(`/posts/${postId}`)
return
}
setPanelPost(post)
} }
const openNew = () => { const openNew = async () => {
setCreateForm({ ...EMPTY_POST })
setShowCreateModal(true)
}
const handleCreate = async () => {
setCreateSaving(true)
try { try {
const data = { const result = await api.post('/posts', { title: '', status: 'draft', platforms: [] })
title: createForm.title, const newId = result._id || result.id || result.Id
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)
toast.success(t('posts.created')) toast.success(t('posts.created'))
loadPosts() navigate(`/posts/${newId}`)
// Open the detail panel for further editing } catch {
if (created) setPanelPost(created)
} catch (err) {
console.error('Create post failed:', err)
toast.error(t('common.saveFailed')) 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.brand && String(p.brandId || p.brand_id) !== filters.brand) return false
if (filters.platform && !(p.platforms || []).includes(filters.platform) && p.platform !== filters.platform) return false if (filters.platform && !(p.platforms || []).includes(filters.platform) && p.platform !== filters.platform) return false
if (filters.assignedTo && String(p.assignedTo || p.assigned_to) !== filters.assignedTo) return false if (filters.assignedTo && String(p.assignedTo || p.assigned_to) !== filters.assignedTo) return false
@@ -181,7 +143,7 @@ export default function PostProduction() {
if (filters.periodTo && d > filters.periodTo) return false if (filters.periodTo && d > filters.periodTo) return false
} }
return true return true
}) }), [posts, filters, searchTerm])
if (loading) { if (loading) {
return view === 'kanban' ? <SkeletonKanbanBoard /> : <SkeletonTable rows={8} cols={6} /> return view === 'kanban' ? <SkeletonKanbanBoard /> : <SkeletonTable rows={8} cols={6} />
@@ -193,20 +155,20 @@ export default function PostProduction() {
<div className="space-y-2"> <div className="space-y-2">
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<div className="relative flex-1 min-w-[200px] max-w-md"> <div className="relative flex-1 min-w-[200px] max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" /> <Search className="absolute start-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<input <input
type="text" type="text"
placeholder={t('posts.searchPosts')} placeholder={t('posts.searchPosts')}
value={searchTerm} value={searchTerm}
onChange={e => setSearchTerm(e.target.value)} onChange={e => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-white" className="w-full ps-10 pe-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface"
/> />
</div> </div>
<button <button
data-tutorial="filters" data-tutorial="filters"
onClick={() => setShowFilters(f => !f)} onClick={() => setShowFilters(f => !f)}
className={`relative flex items-center gap-1.5 px-3 py-2 text-sm border rounded-lg transition-colors ${showFilters ? 'border-brand-primary bg-brand-primary/5 text-brand-primary' : 'border-border bg-white text-text-secondary hover:border-brand-primary/40'}`} className={`relative flex items-center gap-1.5 px-3 py-2 text-sm border rounded-lg transition-colors ${showFilters ? 'border-brand-primary bg-brand-primary/5 text-brand-primary' : 'border-border bg-surface text-text-secondary hover:border-brand-primary/40'}`}
> >
<Filter className="w-4 h-4" /> <Filter className="w-4 h-4" />
{t('common.filter')} {t('common.filter')}
@@ -215,16 +177,16 @@ export default function PostProduction() {
)} )}
</button> </button>
<div className="flex bg-surface-tertiary rounded-lg p-0.5 ml-auto"> <div className="flex bg-surface-tertiary rounded-lg p-0.5 ms-auto">
<button <button
onClick={() => setView('kanban')} onClick={() => setView('kanban')}
className={`p-2 rounded-md ${view === 'kanban' ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary'}`} className={`p-2 rounded-md ${view === 'kanban' ? 'bg-surface shadow-sm text-text-primary' : 'text-text-tertiary'}`}
> >
<LayoutGrid className="w-4 h-4" /> <LayoutGrid className="w-4 h-4" />
</button> </button>
<button <button
onClick={() => setView('list')} onClick={() => setView('list')}
className={`p-2 rounded-md ${view === 'list' ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary'}`} className={`p-2 rounded-md ${view === 'list' ? 'bg-surface shadow-sm text-text-primary' : 'text-text-tertiary'}`}
> >
<List className="w-4 h-4" /> <List className="w-4 h-4" />
</button> </button>
@@ -245,7 +207,7 @@ export default function PostProduction() {
<select <select
value={filters.brand} value={filters.brand}
onChange={e => setFilters(f => ({ ...f, brand: e.target.value }))} onChange={e => setFilters(f => ({ ...f, brand: e.target.value }))}
className="text-xs border border-border rounded-lg px-2.5 py-1.5 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20" className="text-xs border border-border rounded-lg px-2.5 py-1.5 bg-surface text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
> >
<option value="">{t('posts.allBrands')}</option> <option value="">{t('posts.allBrands')}</option>
{brands.map(b => <option key={b._id} value={b._id}>{lang === 'ar' && b.name_ar ? b.name_ar : b.name}</option>)} {brands.map(b => <option key={b._id} value={b._id}>{lang === 'ar' && b.name_ar ? b.name_ar : b.name}</option>)}
@@ -254,7 +216,7 @@ export default function PostProduction() {
<select <select
value={filters.platform} value={filters.platform}
onChange={e => setFilters(f => ({ ...f, platform: e.target.value }))} onChange={e => setFilters(f => ({ ...f, platform: e.target.value }))}
className="text-xs border border-border rounded-lg px-2.5 py-1.5 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20" className="text-xs border border-border rounded-lg px-2.5 py-1.5 bg-surface text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
> >
<option value="">{t('posts.allPlatforms')}</option> <option value="">{t('posts.allPlatforms')}</option>
{Object.entries(PLATFORMS).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)} {Object.entries(PLATFORMS).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
@@ -263,7 +225,7 @@ export default function PostProduction() {
<select <select
value={filters.assignedTo} value={filters.assignedTo}
onChange={e => setFilters(f => ({ ...f, assignedTo: e.target.value }))} onChange={e => setFilters(f => ({ ...f, assignedTo: e.target.value }))}
className="text-xs border border-border rounded-lg px-2.5 py-1.5 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20" className="text-xs border border-border rounded-lg px-2.5 py-1.5 bg-surface text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
> >
<option value="">{t('posts.allPeople')}</option> <option value="">{t('posts.allPeople')}</option>
{teamMembers.map(m => <option key={m._id} value={String(m._id)}>{m.name}</option>)} {teamMembers.map(m => <option key={m._id} value={String(m._id)}>{m.name}</option>)}
@@ -281,7 +243,7 @@ export default function PostProduction() {
value={filters.periodFrom} value={filters.periodFrom}
onChange={e => { setFilters(f => ({ ...f, periodFrom: e.target.value })); setActivePreset('') }} onChange={e => { setFilters(f => ({ ...f, periodFrom: e.target.value })); setActivePreset('') }}
title={t('posts.periodFrom')} title={t('posts.periodFrom')}
className="text-xs border border-border rounded-lg px-2 py-1.5 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20" className="text-xs border border-border rounded-lg px-2 py-1.5 bg-surface text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
/> />
<span className="text-xs text-text-tertiary"></span> <span className="text-xs text-text-tertiary"></span>
<input <input
@@ -289,7 +251,7 @@ export default function PostProduction() {
value={filters.periodTo} value={filters.periodTo}
onChange={e => { setFilters(f => ({ ...f, periodTo: e.target.value })); setActivePreset('') }} onChange={e => { setFilters(f => ({ ...f, periodTo: e.target.value })); setActivePreset('') }}
title={t('posts.periodTo')} title={t('posts.periodTo')}
className="text-xs border border-border rounded-lg px-2 py-1.5 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20" className="text-xs border border-border rounded-lg px-2 py-1.5 bg-surface text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
/> />
</div> </div>
</div> </div>
@@ -334,7 +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 ? ( {filteredPosts.length === 0 ? (
<EmptyState <EmptyState
icon={FileText} icon={FileText}
@@ -361,12 +323,12 @@ export default function PostProduction() {
<th className="w-10 px-3 py-3" onClick={e => e.stopPropagation()}> <th className="w-10 px-3 py-3" onClick={e => e.stopPropagation()}>
<input type="checkbox" checked={selectedIds.size === filteredPosts.length && filteredPosts.length > 0} onChange={toggleSelectAll} className="rounded border-border" /> <input type="checkbox" checked={selectedIds.size === filteredPosts.length && filteredPosts.length > 0} onChange={toggleSelectAll} className="rounded border-border" />
</th> </th>
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.postTitle')}</th> <th className="text-start px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.postTitle')}</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.brand')}</th> <th className="text-start px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.brand')}</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.status')}</th> <th className="text-start px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.status')}</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.platforms')}</th> <th className="text-start px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.platforms')}</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.assignTo')}</th> <th className="text-start px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.assignTo')}</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.scheduledDate')}</th> <th className="text-start px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.scheduledDate')}</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-border-light"> <tbody className="divide-y divide-border-light">
@@ -401,59 +363,6 @@ export default function PostProduction() {
{t('common.bulkDeleteDesc')} {t('common.bulkDeleteDesc')}
</Modal> </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> </div>
) )
} }
+19 -19
View File
@@ -223,14 +223,14 @@ export default function ProjectDetail() {
</button> </button>
{/* Project header */} {/* Project header */}
<div className="bg-white rounded-xl border border-border overflow-hidden"> <div className="bg-surface rounded-xl border border-border overflow-clip">
{/* Thumbnail banner */} {/* Thumbnail banner */}
{(project.thumbnail_url || project.thumbnailUrl) && ( {(project.thumbnail_url || project.thumbnailUrl) && (
<div className="relative w-full h-40 overflow-hidden"> <div className="relative w-full h-40 overflow-hidden">
<img src={project.thumbnail_url || project.thumbnailUrl} alt="" className="w-full h-full object-cover" /> <img src={project.thumbnail_url || project.thumbnailUrl} alt="" className="w-full h-full object-cover" loading="lazy" />
<div className="absolute inset-0 bg-gradient-to-t from-black/30 to-transparent" /> <div className="absolute inset-0 bg-gradient-to-t from-black/30 to-transparent" />
{canEditProject && ( {canEditProject && (
<div className="absolute top-2 right-2 flex items-center gap-1"> <div className="absolute top-2 end-2 flex items-center gap-1">
<button <button
onClick={() => thumbnailInputRef.current?.click()} onClick={() => thumbnailInputRef.current?.click()}
className="px-2 py-1 text-xs bg-black/40 hover:bg-black/60 rounded text-white transition-colors" className="px-2 py-1 text-xs bg-black/40 hover:bg-black/60 rounded text-white transition-colors"
@@ -341,7 +341,7 @@ export default function ProjectDetail() {
key={v.id} key={v.id}
onClick={() => setView(v.id)} onClick={() => setView(v.id)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${ className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
view === v.id ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary hover:text-text-secondary' view === v.id ? 'bg-surface shadow-sm text-text-primary' : 'text-text-tertiary hover:text-text-secondary'
}`} }`}
> >
<v.icon className="w-4 h-4" /> <v.icon className="w-4 h-4" />
@@ -411,21 +411,21 @@ export default function ProjectDetail() {
{/* ─── LIST VIEW ─── */} {/* ─── LIST VIEW ─── */}
{view === 'list' && ( {view === 'list' && (
<div className="bg-white rounded-xl border border-border overflow-hidden"> <div className="bg-surface rounded-xl border border-border overflow-clip">
<table className="w-full"> <table className="w-full">
<thead> <thead>
<tr className="border-b border-border bg-surface-secondary"> <tr className="border-b border-border bg-surface-secondary">
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider w-8"></th> <th className="text-start px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider w-8"></th>
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Task</th> <th className="text-start px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Task</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Status</th> <th className="text-start px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Status</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Priority</th> <th className="text-start px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Priority</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Assignee</th> <th className="text-start px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Assignee</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Due</th> <th className="text-start px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Due</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-border-light"> <tbody className="divide-y divide-border-light">
{tasks.length === 0 ? ( {tasks.length === 0 ? (
<tr><td colSpan={6} className="py-12 text-center text-sm text-text-tertiary">No tasks yet</td></tr> <tr><td colSpan={6} className="py-12 text-center text-sm text-text-tertiary">{t('tasks.noTasks')}</td></tr>
) : ( ) : (
tasks.map(task => { tasks.map(task => {
const prio = PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium const prio = PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium
@@ -470,7 +470,7 @@ export default function ProjectDetail() {
{/* ─── DISCUSSION SIDEBAR ─── */} {/* ─── DISCUSSION SIDEBAR ─── */}
{showDiscussion && ( {showDiscussion && (
<div className="w-[340px] shrink-0 bg-white rounded-xl border border-border flex flex-col self-start sticky top-4" style={{ maxHeight: 'calc(100vh - 6rem)' }}> <div className="w-[340px] shrink-0 bg-surface rounded-xl border border-border flex flex-col self-start sticky top-4" style={{ maxHeight: 'calc(100vh - 6rem)' }}>
<div className="flex items-center justify-between px-4 py-3 border-b border-border"> <div className="flex items-center justify-between px-4 py-3 border-b border-border">
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-1.5"> <h3 className="text-sm font-semibold text-text-primary flex items-center gap-1.5">
<MessageCircle className="w-4 h-4" /> <MessageCircle className="w-4 h-4" />
@@ -539,7 +539,7 @@ function TaskKanbanCard({ task, canEdit, canDelete, onClick, onDelete, onStatusC
onDragStart={(e) => canEdit && onDragStart(e, task)} onDragStart={(e) => canEdit && onDragStart(e, task)}
onDragEnd={onDragEnd} onDragEnd={onDragEnd}
onClick={onClick} onClick={onClick}
className={`bg-white rounded-lg border border-border p-3 group hover:border-brand-primary/30 hover:shadow-sm transition-all cursor-pointer ${canEdit ? 'active:cursor-grabbing' : ''}`} className={`bg-surface rounded-lg border border-border p-3 group hover:border-brand-primary/30 hover:shadow-sm transition-all cursor-pointer ${canEdit ? 'active:cursor-grabbing' : ''}`}
> >
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<div className={`w-2 h-2 rounded-full ${priority.color} mt-1.5 shrink-0`} title={priority.label} /> <div className={`w-2 h-2 rounded-full ${priority.color} mt-1.5 shrink-0`} title={priority.label} />
@@ -572,7 +572,7 @@ function TaskKanbanCard({ task, canEdit, canDelete, onClick, onDelete, onStatusC
)} )}
{canDelete && ( {canDelete && (
<button onClick={(e) => { e.stopPropagation(); onDelete() }} <button onClick={(e) => { e.stopPropagation(); onDelete() }}
className="text-[10px] text-red-400 hover:bg-red-50 px-2 py-0.5 rounded-full flex items-center gap-1 ml-auto"> className="text-[10px] text-red-400 hover:bg-red-50 px-2 py-0.5 rounded-full flex items-center gap-1 ms-auto">
<Trash2 className="w-3 h-3" /> <Trash2 className="w-3 h-3" />
</button> </button>
)} )}
@@ -614,7 +614,7 @@ function GanttView({ tasks, project, onEditTask, onTaskColorChange }) {
if (tasks.length === 0) { if (tasks.length === 0) {
return ( return (
<div className="bg-white rounded-xl border border-border py-16 text-center"> <div className="bg-surface rounded-xl border border-border py-16 text-center">
<GanttChart className="w-12 h-12 text-text-tertiary mx-auto mb-3" /> <GanttChart className="w-12 h-12 text-text-tertiary mx-auto mb-3" />
<p className="text-text-secondary font-medium">No tasks to display</p> <p className="text-text-secondary font-medium">No tasks to display</p>
<p className="text-sm text-text-tertiary mt-1">Add tasks with due dates to see the timeline</p> <p className="text-sm text-text-tertiary mt-1">Add tasks with due dates to see the timeline</p>
@@ -666,7 +666,7 @@ function GanttView({ tasks, project, onEditTask, onTaskColorChange }) {
} }
return ( return (
<div className="bg-white rounded-xl border border-border overflow-hidden"> <div className="bg-surface rounded-xl border border-border overflow-clip">
{/* Zoom toolbar */} {/* Zoom toolbar */}
<div className="flex items-center justify-between px-4 py-2 border-b border-border bg-surface-secondary"> <div className="flex items-center justify-between px-4 py-2 border-b border-border bg-surface-secondary">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -757,7 +757,7 @@ function GanttView({ tasks, project, onEditTask, onTaskColorChange }) {
)} )}
{!onTaskColorChange && <div className={`w-2 h-2 rounded-full ${prio.color} shrink-0`} />} {!onTaskColorChange && <div className={`w-2 h-2 rounded-full ${prio.color} shrink-0`} />}
<button onClick={() => onEditTask(task)} <button onClick={() => onEditTask(task)}
className="text-xs font-medium text-text-primary truncate hover:text-brand-primary text-left"> className="text-xs font-medium text-text-primary truncate hover:text-brand-primary text-start">
{task.title} {task.title}
</button> </button>
</div> </div>
@@ -787,7 +787,7 @@ function GanttView({ tasks, project, onEditTask, onTaskColorChange }) {
{colorPicker && onTaskColorChange && ( {colorPicker && onTaskColorChange && (
<div <div
ref={colorPickerRef} ref={colorPickerRef}
className="fixed z-50 bg-white rounded-lg shadow-xl border border-border p-2" className="fixed z-50 bg-surface rounded-lg shadow-xl border border-border p-2"
style={{ left: colorPicker.x, top: colorPicker.y }} style={{ left: colorPicker.x, top: colorPicker.y }}
> >
<div className="grid grid-cols-4 gap-1.5 mb-2"> <div className="grid grid-cols-4 gap-1.5 mb-2">
+4 -4
View File
@@ -80,13 +80,13 @@ export default function Projects() {
{/* Toolbar */} {/* Toolbar */}
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<div className="relative flex-1 min-w-[200px] max-w-md"> <div className="relative flex-1 min-w-[200px] max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" /> <Search className="absolute start-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<input <input
type="text" type="text"
placeholder="Search projects..." placeholder="Search projects..."
value={searchTerm} value={searchTerm}
onChange={e => setSearchTerm(e.target.value)} onChange={e => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-white" className="w-full ps-10 pe-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface"
/> />
</div> </div>
@@ -100,7 +100,7 @@ export default function Projects() {
key={v.id} key={v.id}
onClick={() => setView(v.id)} onClick={() => setView(v.id)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${ className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
view === v.id ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary hover:text-text-secondary' view === v.id ? 'bg-surface shadow-sm text-text-primary' : 'text-text-tertiary hover:text-text-secondary'
}`} }`}
> >
<v.icon className="w-4 h-4" /> <v.icon className="w-4 h-4" />
@@ -112,7 +112,7 @@ export default function Projects() {
{permissions?.canCreateProjects && ( {permissions?.canCreateProjects && (
<button <button
onClick={() => setShowModal(true)} onClick={() => setShowModal(true)}
className="flex items-center gap-2 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm ml-auto" className="flex items-center gap-2 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm ms-auto"
> >
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
New Project New Project
+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 }, acknowledged: { label: t('acknowledged'), bg: 'bg-blue-100', text: 'text-blue-700', dot: 'bg-blue-500', icon: CheckCircle2 },
in_progress: { label: t('in_progress'), bg: 'bg-amber-100', text: 'text-amber-700', dot: 'bg-amber-500', icon: Clock }, in_progress: { label: t('in_progress'), bg: 'bg-amber-100', text: 'text-amber-700', dot: 'bg-amber-500', icon: Clock },
resolved: { label: t('resolved'), bg: 'bg-emerald-100', text: 'text-emerald-700', dot: 'bg-emerald-500', icon: CheckCircle2 }, resolved: { label: t('resolved'), bg: 'bg-emerald-100', text: 'text-emerald-700', dot: 'bg-emerald-500', icon: CheckCircle2 },
declined: { label: t('declined_status'), bg: 'bg-gray-100', text: 'text-gray-700', dot: 'bg-gray-500', icon: XCircle }, declined: { label: t('declined_status'), bg: 'bg-gray-100', text: 'text-text-secondary', dot: 'bg-gray-500', icon: XCircle },
} }
const PRIORITY_CONFIG = { const PRIORITY_CONFIG = {
low: { label: t('low'), color: 'text-gray-700' }, low: { label: t('low'), color: 'text-text-secondary' },
medium: { label: t('medium'), color: 'text-blue-700' }, medium: { label: t('medium'), color: 'text-blue-700' },
high: { label: t('high'), color: 'text-orange-700' }, high: { label: t('high'), color: 'text-orange-700' },
urgent: { label: t('urgent'), color: 'text-red-700' }, urgent: { label: t('urgent'), color: 'text-red-700' },
@@ -267,16 +267,16 @@ export default function PublicIssueTracker() {
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
{issue.status === 'resolved' {issue.status === 'resolved'
? <CheckCircle2 className="w-6 h-6 text-emerald-600 shrink-0 mt-1" /> ? <CheckCircle2 className="w-6 h-6 text-emerald-600 shrink-0 mt-1" />
: <XCircle className="w-6 h-6 text-gray-600 shrink-0 mt-1" />} : <XCircle className="w-6 h-6 text-text-secondary shrink-0 mt-1" />}
<div className="flex-1"> <div className="flex-1">
<h2 className={`text-lg font-bold mb-2 ${issue.status === 'resolved' ? 'text-emerald-900' : 'text-gray-900'}`}> <h2 className={`text-lg font-bold mb-2 ${issue.status === 'resolved' ? 'text-emerald-900' : 'text-text-primary'}`}>
{issue.status === 'resolved' ? t('resolution') : t('declined')} {issue.status === 'resolved' ? t('resolution') : t('declined')}
</h2> </h2>
<p className={`${issue.status === 'resolved' ? 'text-emerald-800' : 'text-gray-800'} whitespace-pre-wrap`}> <p className={`${issue.status === 'resolved' ? 'text-emerald-800' : 'text-text-primary'} whitespace-pre-wrap`}>
{issue.resolution_summary} {issue.resolution_summary}
</p> </p>
{issue.resolved_at && ( {issue.resolved_at && (
<p className={`text-sm mt-2 ${issue.status === 'resolved' ? 'text-emerald-600' : 'text-gray-600'}`}> <p className={`text-sm mt-2 ${issue.status === 'resolved' ? 'text-emerald-600' : 'text-text-secondary'}`}>
{dateFmt(issue.resolved_at)} {dateFmt(issue.resolved_at)}
</p> </p>
)} )}
@@ -303,7 +303,7 @@ export default function PublicIssueTracker() {
<div className="flex items-start justify-between gap-3 mb-2"> <div className="flex items-start justify-between gap-3 mb-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-semibold text-text-primary">{update.author_name}</span> <span className="font-semibold text-text-primary">{update.author_name}</span>
<span className={`text-xs px-2 py-0.5 rounded-full ${update.author_type === 'staff' ? 'bg-purple-100 text-purple-700' : 'bg-gray-200 text-gray-700'}`}> <span className={`text-xs px-2 py-0.5 rounded-full ${update.author_type === 'staff' ? 'bg-purple-100 text-purple-700' : 'bg-gray-200 text-text-secondary'}`}>
{update.author_type === 'staff' ? t('team') : t('you')} {update.author_type === 'staff' ? t('team') : t('you')}
</span> </span>
</div> </div>
+2 -2
View File
@@ -146,7 +146,7 @@ export default function PublicPostReview() {
</div> </div>
<div> <div>
<h1 className="text-2xl font-bold text-white">{t('review.postReview')}</h1> <h1 className="text-2xl font-bold text-white">{t('review.postReview')}</h1>
<p className="text-white/80 text-sm">Samaya Digital Hub</p> <p className="text-white/80 text-sm">Rawaj</p>
</div> </div>
</div> </div>
</div> </div>
@@ -181,7 +181,7 @@ export default function PublicPostReview() {
{images.map((att, idx) => ( {images.map((att, idx) => (
<a key={idx} href={att.url} target="_blank" rel="noopener noreferrer" <a key={idx} href={att.url} target="_blank" rel="noopener noreferrer"
className="block rounded-xl overflow-hidden border-2 border-border hover:border-brand-primary transition-colors shadow-sm"> className="block rounded-xl overflow-hidden border-2 border-border hover:border-brand-primary transition-colors shadow-sm">
<img src={att.url} alt={att.original_name || `Image ${idx + 1}`} className="w-full h-64 object-cover" /> <img src={att.url} alt={att.original_name || `Image ${idx + 1}`} className="w-full h-64 object-cover" loading="lazy" />
{att.original_name && ( {att.original_name && (
<div className="bg-surface-secondary px-4 py-2 border-t border-border"> <div className="bg-surface-secondary px-4 py-2 border-t border-border">
<p className="text-sm text-text-secondary truncate">{att.original_name}</p> <p className="text-sm text-text-secondary truncate">{att.original_name}</p>
+100 -32
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useParams } from 'react-router-dom' 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 { useLanguage } from '../i18n/LanguageContext'
import { useToast } from '../components/ToastContainer' import { useToast } from '../components/ToastContainer'
import Modal from '../components/Modal' import Modal from '../components/Modal'
@@ -21,8 +21,13 @@ export default function PublicReview() {
const [error, setError] = useState('') const [error, setError] = useState('')
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [success, setSuccess] = useState('') const [success, setSuccess] = useState('')
const [successType, setSuccessType] = useState('review') // 'review' | 'redirect'
const [reviewerName, setReviewerName] = useState('') const [reviewerName, setReviewerName] = useState('')
const [feedback, setFeedback] = 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 [selectedLanguage, setSelectedLanguage] = useState(0)
const [pendingAction, setPendingAction] = useState(null) const [pendingAction, setPendingAction] = useState(null)
@@ -41,8 +46,8 @@ export default function PublicReview() {
} }
const data = await res.json() const data = await res.json()
setArtefact(data) setArtefact(data)
// Auto-set reviewer name if there's exactly one approver // Auto-set reviewer name from the selected approver
if (data.approvers?.length === 1 && data.approvers[0].name) { if (data.approvers?.length > 0 && data.approvers[0].name) {
setReviewerName(data.approvers[0].name) setReviewerName(data.approvers[0].name)
} }
} catch (err) { } 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 extractDriveFileId = (url) => {
const patterns = [ const patterns = [
/\/file\/d\/([^\/]+)/, /\/file\/d\/([^\/]+)/,
@@ -157,10 +197,15 @@ export default function PublicReview() {
return ( return (
<div className="min-h-screen bg-surface-secondary flex items-center justify-center p-4"> <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="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"> <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'}`}>
<CheckCircle className="w-8 h-8 text-emerald-600" /> {successType === 'redirect'
? <ArrowRightLeft className="w-8 h-8 text-blue-600" />
: <CheckCircle className="w-8 h-8 text-emerald-600" />
}
</div> </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> <p className="text-text-secondary">{success}</p>
</div> </div>
</div> </div>
@@ -184,7 +229,7 @@ export default function PublicReview() {
</div> </div>
<div> <div>
<h1 className="text-2xl font-bold text-white">{t('review.contentReview')}</h1> <h1 className="text-2xl font-bold text-white">{t('review.contentReview')}</h1>
<p className="text-white/80 text-sm">Samaya Digital Hub</p> <p className="text-white/80 text-sm">Rawaj</p>
</div> </div>
</div> </div>
</div> </div>
@@ -281,6 +326,7 @@ export default function PublicReview() {
src={att.url} src={att.url}
alt={att.original_name || `Design ${idx + 1}`} alt={att.original_name || `Design ${idx + 1}`}
className="w-full h-64 object-cover" className="w-full h-64 object-cover"
loading="lazy"
/> />
{att.original_name && ( {att.original_name && (
<div className="bg-surface-secondary px-4 py-2 border-t border-border"> <div className="bg-surface-secondary px-4 py-2 border-t border-border">
@@ -354,6 +400,7 @@ export default function PublicReview() {
src={att.url} src={att.url}
alt={att.original_name} alt={att.original_name}
className="w-full h-48 object-cover" className="w-full h-48 object-cover"
loading="lazy"
/> />
<div className="bg-surface-secondary px-3 py-2 border-t border-border"> <div className="bg-surface-secondary px-3 py-2 border-t border-border">
<p className="text-xs text-text-secondary truncate">{att.original_name}</p> <p className="text-xs text-text-secondary truncate">{att.original_name}</p>
@@ -416,31 +463,10 @@ export default function PublicReview() {
{/* Reviewer identity */} {/* Reviewer identity */}
<div> <div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('review.reviewer')}</label> <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">
<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" />
<User className="w-4 h-4 text-text-tertiary" /> <span className="text-sm text-text-primary">{artefact.approvers?.[0]?.name || reviewerName || '—'}</span>
<span className="text-sm text-text-primary">{artefact.approvers[0].name}</span> </div>
</div>
) : artefact.approvers?.length > 1 ? (
<select
value={reviewerName}
onChange={e => setReviewerName(e.target.value)}
className="w-full px-4 py-2 text-sm border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary transition-colors"
>
<option value="">{t('review.selectYourName')}</option>
{artefact.approvers.map(a => (
<option key={a.id} value={a.name}>{a.name}</option>
))}
</select>
) : (
<input
type="text"
value={reviewerName}
onChange={e => setReviewerName(e.target.value)}
placeholder={t('review.enterYourName')}
className="w-full px-4 py-2 text-sm border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary transition-colors"
/>
)}
</div> </div>
<div> <div>
@@ -481,6 +507,48 @@ export default function PublicReview() {
{t('review.reject')} {t('review.reject')}
</button> </button>
</div> </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> </div>
)} )}
+1 -1
View File
@@ -350,7 +350,7 @@ export default function PublicTranslationReview() {
value={suggestionContent} value={suggestionContent}
onChange={e => setSuggestionContent(e.target.value)} onChange={e => setSuggestionContent(e.target.value)}
placeholder={t('translations.enterSuggestion')} placeholder={t('translations.enterSuggestion')}
className="w-full px-3 py-2 text-sm border border-amber-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-amber-400/30 min-h-[80px] resize-y bg-white" className="w-full px-3 py-2 text-sm border border-amber-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-amber-400/30 min-h-[80px] resize-y bg-surface"
/> />
<div className="flex items-center gap-2 mt-2"> <div className="flex items-center gap-2 mt-2">
<button <button
+19 -10
View File
@@ -1,9 +1,18 @@
import { useState } from 'react' import { useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom' import { Link, useSearchParams } from 'react-router-dom'
import { useLanguage } from '../i18n/LanguageContext' import { useLanguage } from '../i18n/LanguageContext'
import { Megaphone, Lock, AlertCircle, CheckCircle, ArrowLeft } from 'lucide-react' import { Lock, AlertCircle, CheckCircle, ArrowLeft } from 'lucide-react'
import { api } from '../utils/api' import { api } from '../utils/api'
function MarkaLogo({ className = '' }) {
return (
<svg viewBox="0 0 32 32" fill="none" className={className}>
<path d="M4 26V6l10 10L4 26z" fill="currentColor" opacity="0.85" />
<path d="M18 26V6l10 10-10 10z" fill="currentColor" opacity="0.5" />
</svg>
)
}
export default function ResetPassword() { export default function ResetPassword() {
const { t } = useLanguage() const { t } = useLanguage()
const [searchParams] = useSearchParams() const [searchParams] = useSearchParams()
@@ -16,7 +25,7 @@ export default function ResetPassword() {
if (!token) { if (!token) {
return ( return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-blue-900 to-slate-900 flex items-center justify-center px-4"> <div className="min-h-screen bg-slate-900 flex items-center justify-center px-4">
<div className="w-full max-w-md text-center"> <div className="w-full max-w-md text-center">
<div className="bg-slate-800/50 backdrop-blur-sm rounded-2xl border border-slate-700/50 p-8 shadow-2xl"> <div className="bg-slate-800/50 backdrop-blur-sm rounded-2xl border border-slate-700/50 p-8 shadow-2xl">
<AlertCircle className="w-12 h-12 text-red-400 mx-auto mb-4" /> <AlertCircle className="w-12 h-12 text-red-400 mx-auto mb-4" />
@@ -51,11 +60,11 @@ export default function ResetPassword() {
} }
return ( return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-blue-900 to-slate-900 flex items-center justify-center px-4"> <div className="min-h-screen bg-slate-900 flex items-center justify-center px-4">
<div className="w-full max-w-md"> <div className="w-full max-w-md">
<div className="text-center mb-8"> <div className="text-center mb-8">
<div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-purple-600 rounded-2xl flex items-center justify-center mx-auto mb-4 shadow-lg"> <div className="w-16 h-16 bg-brand-primary rounded-2xl flex items-center justify-center mx-auto mb-4">
<Megaphone className="w-8 h-8 text-white" /> <MarkaLogo className="w-9 h-9 text-white" />
</div> </div>
<h1 className="text-3xl font-bold text-white mb-2">{t('resetPassword.title')}</h1> <h1 className="text-3xl font-bold text-white mb-2">{t('resetPassword.title')}</h1>
<p className="text-slate-400">{t('resetPassword.subtitle')}</p> <p className="text-slate-400">{t('resetPassword.subtitle')}</p>
@@ -81,12 +90,12 @@ export default function ResetPassword() {
<div> <div>
<label className="block text-sm font-medium text-slate-300 mb-2">{t('resetPassword.newPassword')}</label> <label className="block text-sm font-medium text-slate-300 mb-2">{t('resetPassword.newPassword')}</label>
<div className="relative"> <div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" /> <Lock className="absolute start-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<input <input
type="password" type="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all" className="w-full ps-11 pe-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
placeholder="••••••••" placeholder="••••••••"
required required
minLength={6} minLength={6}
@@ -98,12 +107,12 @@ export default function ResetPassword() {
<div> <div>
<label className="block text-sm font-medium text-slate-300 mb-2">{t('resetPassword.confirmPassword')}</label> <label className="block text-sm font-medium text-slate-300 mb-2">{t('resetPassword.confirmPassword')}</label>
<div className="relative"> <div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" /> <Lock className="absolute start-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<input <input
type="password" type="password"
value={confirm} value={confirm}
onChange={(e) => setConfirm(e.target.value)} onChange={(e) => setConfirm(e.target.value)}
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all" className="w-full ps-11 pe-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
placeholder="••••••••" placeholder="••••••••"
required required
minLength={6} minLength={6}
@@ -121,7 +130,7 @@ export default function ResetPassword() {
<button <button
type="submit" type="submit"
disabled={loading} disabled={loading}
className="w-full py-3 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed" className="w-full py-3 bg-brand-primary hover:bg-brand-primary-light text-white font-semibold rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
> >
{loading ? ( {loading ? (
<span className="flex items-center justify-center gap-2"> <span className="flex items-center justify-center gap-2">
+71 -15
View File
@@ -1,5 +1,5 @@
import { useState, useEffect, useContext } from 'react' import { useState, useEffect, useContext } from 'react'
import { Settings as SettingsIcon, Play, CheckCircle, Languages, Coins, Upload, Tag, Plus, Pencil, Trash2, X } from 'lucide-react' import { Settings as SettingsIcon, Play, CheckCircle, Languages, Coins, Upload, Tag, Plus, Pencil, Trash2, X, Mail } from 'lucide-react'
import { api } from '../utils/api' import { api } from '../utils/api'
import { useLanguage } from '../i18n/LanguageContext' import { useLanguage } from '../i18n/LanguageContext'
import { useToast } from '../components/ToastContainer' import { useToast } from '../components/ToastContainer'
@@ -23,9 +23,15 @@ export default function Settings() {
const [maxSizeMB, setMaxSizeMB] = useState(50) const [maxSizeMB, setMaxSizeMB] = useState(50)
const [sizeSaving, setSizeSaving] = useState(false) const [sizeSaving, setSizeSaving] = useState(false)
const [sizeSaved, setSizeSaved] = useState(false) const [sizeSaved, setSizeSaved] = useState(false)
const [ceoEmail, setCeoEmail] = useState('')
const [ceoSaving, setCeoSaving] = useState(false)
const [ceoSaved, setCeoSaved] = useState(false)
useEffect(() => { useEffect(() => {
api.get('/settings/app').then(s => setMaxSizeMB(s.uploadMaxSizeMB || 50)).catch(() => {}) api.get('/settings/app').then(s => {
setMaxSizeMB(s.uploadMaxSizeMB || 50)
if (s.ceoEmail) setCeoEmail(s.ceoEmail)
}).catch(() => {})
}, []) }, [])
const handleSaveMaxSize = async () => { const handleSaveMaxSize = async () => {
@@ -65,9 +71,9 @@ export default function Settings() {
<p className="text-sm text-text-tertiary">{t('settings.preferences')}</p> <p className="text-sm text-text-tertiary">{t('settings.preferences')}</p>
{/* General Settings */} {/* General Settings */}
<div className="bg-white rounded-xl border border-border overflow-hidden"> <div className="bg-surface rounded-xl border border-border overflow-clip">
<div className="px-6 py-4 border-b border-border"> <div className="px-6 py-4 border-b border-border">
<h2 className="text-lg font-semibold text-text-primary">{t('settings.general')}</h2> <h3 className="font-semibold text-text-primary">{t('settings.general')}</h3>
</div> </div>
<div className="p-6 space-y-4"> <div className="p-6 space-y-4">
{/* Language Selector */} {/* Language Selector */}
@@ -79,7 +85,7 @@ export default function Settings() {
<select <select
value={lang} value={lang}
onChange={(e) => setLang(e.target.value)} onChange={(e) => setLang(e.target.value)}
className="w-full max-w-xs px-4 py-2.5 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-white" className="w-full max-w-xs px-4 py-2.5 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface"
> >
<option value="en">{t('settings.english')}</option> <option value="en">{t('settings.english')}</option>
<option value="ar">{t('settings.arabic')}</option> <option value="ar">{t('settings.arabic')}</option>
@@ -95,7 +101,7 @@ export default function Settings() {
<select <select
value={currency} value={currency}
onChange={(e) => setCurrency(e.target.value)} onChange={(e) => setCurrency(e.target.value)}
className="w-full max-w-xs px-4 py-2.5 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-white" className="w-full max-w-xs px-4 py-2.5 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface"
> >
{CURRENCIES.map(c => ( {CURRENCIES.map(c => (
<option key={c.code} value={c.code}> <option key={c.code} value={c.code}>
@@ -109,12 +115,12 @@ export default function Settings() {
</div> </div>
{/* Uploads Section */} {/* Uploads Section */}
<div className="bg-white rounded-xl border border-border overflow-hidden"> <div className="bg-surface rounded-xl border border-border overflow-clip">
<div className="px-6 py-4 border-b border-border"> <div className="px-6 py-4 border-b border-border">
<h2 className="text-lg font-semibold text-text-primary flex items-center gap-2"> <h3 className="font-semibold text-text-primary flex items-center gap-2">
<Upload className="w-5 h-5 text-brand-primary" /> <Upload className="w-5 h-5 text-brand-primary" />
{t('settings.uploads')} {t('settings.uploads')}
</h2> </h3>
</div> </div>
<div className="p-6 space-y-4"> <div className="p-6 space-y-4">
<div> <div>
@@ -128,7 +134,7 @@ export default function Settings() {
max="500" max="500"
value={maxSizeMB} value={maxSizeMB}
onChange={(e) => setMaxSizeMB(Number(e.target.value))} onChange={(e) => setMaxSizeMB(Number(e.target.value))}
className="w-24 px-3 py-2.5 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-white" className="w-24 px-3 py-2.5 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface"
/> />
<span className="text-sm text-text-secondary">{t('settings.mb')}</span> <span className="text-sm text-text-secondary">{t('settings.mb')}</span>
<button <button
@@ -147,9 +153,9 @@ export default function Settings() {
</div> </div>
{/* Tutorial Section */} {/* Tutorial Section */}
<div className="bg-white rounded-xl border border-border overflow-hidden"> <div className="bg-surface rounded-xl border border-border overflow-clip">
<div className="px-6 py-4 border-b border-border"> <div className="px-6 py-4 border-b border-border">
<h2 className="text-lg font-semibold text-text-primary">{t('settings.onboardingTutorial')}</h2> <h3 className="font-semibold text-text-primary">{t('settings.onboardingTutorial')}</h3>
</div> </div>
<div className="p-6 space-y-4"> <div className="p-6 space-y-4">
<p className="text-sm text-text-secondary"> <p className="text-sm text-text-secondary">
@@ -180,6 +186,56 @@ export default function Settings() {
</div> </div>
</div> </div>
{/* Budget Approval (Superadmin only) */}
{user?.role === 'superadmin' && (
<div className="bg-surface rounded-xl border border-border overflow-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) */} {/* Roles Management (Superadmin only) */}
{user?.role === 'superadmin' && <RolesSection roles={roles} loadRoles={loadRoles} t={t} toast={toast} />} {user?.role === 'superadmin' && <RolesSection roles={roles} loadRoles={loadRoles} t={t} toast={toast} />}
</div> </div>
@@ -235,12 +291,12 @@ function RolesSection({ roles, loadRoles, t, toast }) {
return ( return (
<> <>
<div className="bg-white dark:bg-surface-primary rounded-xl border border-border overflow-hidden"> <div className="bg-surface dark:bg-surface-primary rounded-xl border border-border overflow-clip">
<div className="px-6 py-4 border-b border-border flex items-center justify-between"> <div className="px-6 py-4 border-b border-border flex items-center justify-between">
<h2 className="text-lg font-semibold text-text-primary flex items-center gap-2"> <h3 className="font-semibold text-text-primary flex items-center gap-2">
<Tag className="w-5 h-5 text-brand-primary" /> <Tag className="w-5 h-5 text-brand-primary" />
{t('settings.roles')} {t('settings.roles')}
</h2> </h3>
<button <button
onClick={openAddModal} onClick={openAddModal}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors" className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors"
+23 -23
View File
@@ -325,16 +325,16 @@ export default function Tasks() {
<div className="flex items-center gap-3 flex-1 min-w-0"> <div className="flex items-center gap-3 flex-1 min-w-0">
{/* Search */} {/* Search */}
<div className="relative flex-1 max-w-xs"> <div className="relative flex-1 max-w-xs">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" /> <Search className="absolute start-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<input <input
type="text" type="text"
value={searchQuery} value={searchQuery}
onChange={e => setSearchQuery(e.target.value)} onChange={e => setSearchQuery(e.target.value)}
placeholder={t('tasks.search')} placeholder={t('tasks.search')}
className="w-full pl-9 pr-3 py-1.5 text-sm border border-border rounded-lg bg-surface-primary text-text-primary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary" className="w-full ps-9 pe-3 py-1.5 text-sm border border-border rounded-lg bg-surface-primary text-text-primary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
/> />
{searchQuery && ( {searchQuery && (
<button onClick={() => setSearchQuery('')} className="absolute right-2 top-1/2 -translate-y-1/2 p-0.5 rounded text-text-tertiary hover:text-text-primary"> <button onClick={() => setSearchQuery('')} className="absolute end-2 top-1/2 -translate-y-1/2 p-0.5 rounded text-text-tertiary hover:text-text-primary">
<X className="w-3.5 h-3.5" /> <X className="w-3.5 h-3.5" />
</button> </button>
)} )}
@@ -350,7 +350,7 @@ export default function Tasks() {
onClick={() => setViewMode(mode)} onClick={() => setViewMode(mode)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${ className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
viewMode === mode viewMode === mode
? 'bg-white text-text-primary shadow-sm' ? 'bg-surface text-text-primary shadow-sm'
: 'text-text-tertiary hover:text-text-secondary' : 'text-text-tertiary hover:text-text-secondary'
}`} }`}
> >
@@ -399,7 +399,7 @@ export default function Tasks() {
<select <select
value={filterProject} value={filterProject}
onChange={e => setFilterProject(e.target.value)} onChange={e => setFilterProject(e.target.value)}
className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-white text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20" className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
> >
<option value="">{t('tasks.allProjects')}</option> <option value="">{t('tasks.allProjects')}</option>
{taskProjects.map(p => ( {taskProjects.map(p => (
@@ -411,7 +411,7 @@ export default function Tasks() {
<select <select
value={filterBrand} value={filterBrand}
onChange={e => setFilterBrand(e.target.value)} onChange={e => setFilterBrand(e.target.value)}
className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-white text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20" className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
> >
<option value="">{t('tasks.allBrands')}</option> <option value="">{t('tasks.allBrands')}</option>
{taskBrands.map(b => ( {taskBrands.map(b => (
@@ -440,7 +440,7 @@ export default function Tasks() {
className={`px-2.5 py-1 text-[11px] font-medium rounded-full border transition-colors ${ className={`px-2.5 py-1 text-[11px] font-medium rounded-full border transition-colors ${
active active
? 'bg-brand-primary/10 border-brand-primary/20 text-brand-primary' ? 'bg-brand-primary/10 border-brand-primary/20 text-brand-primary'
: 'bg-white border-border text-text-tertiary' : 'bg-surface border-border text-text-tertiary'
}`} }`}
> >
{t(`tasks.${s}`)} {t(`tasks.${s}`)}
@@ -453,7 +453,7 @@ export default function Tasks() {
<select <select
value={filterPriority} value={filterPriority}
onChange={e => setFilterPriority(e.target.value)} onChange={e => setFilterPriority(e.target.value)}
className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-white text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20" className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
> >
<option value="">{t('tasks.allPriorities')}</option> <option value="">{t('tasks.allPriorities')}</option>
<option value="low">{t('tasks.priority.low')}</option> <option value="low">{t('tasks.priority.low')}</option>
@@ -466,7 +466,7 @@ export default function Tasks() {
<select <select
value={filterAssignee} value={filterAssignee}
onChange={e => setFilterAssignee(e.target.value)} onChange={e => setFilterAssignee(e.target.value)}
className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-white text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20" className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
> >
<option value="">{t('tasks.allAssignees')}</option> <option value="">{t('tasks.allAssignees')}</option>
{(assignableUsers || []).map(m => ( {(assignableUsers || []).map(m => (
@@ -479,7 +479,7 @@ export default function Tasks() {
<select <select
value={filterCreator} value={filterCreator}
onChange={e => setFilterCreator(e.target.value)} onChange={e => setFilterCreator(e.target.value)}
className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-white text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20" className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
> >
<option value="">{t('tasks.allCreators')}</option> <option value="">{t('tasks.allCreators')}</option>
{users.map(m => ( {users.map(m => (
@@ -501,7 +501,7 @@ export default function Tasks() {
type="date" type="date"
value={filterDateFrom} value={filterDateFrom}
onChange={e => { setFilterDateFrom(e.target.value); setActivePreset('') }} onChange={e => { setFilterDateFrom(e.target.value); setActivePreset('') }}
className="px-2 py-1.5 text-xs border border-border rounded-lg bg-white text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20" className="px-2 py-1.5 text-xs border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
title={t('posts.periodFrom')} title={t('posts.periodFrom')}
/> />
<span className="text-text-tertiary text-xs">-</span> <span className="text-text-tertiary text-xs">-</span>
@@ -509,7 +509,7 @@ export default function Tasks() {
type="date" type="date"
value={filterDateTo} value={filterDateTo}
onChange={e => { setFilterDateTo(e.target.value); setActivePreset('') }} onChange={e => { setFilterDateTo(e.target.value); setActivePreset('') }}
className="px-2 py-1.5 text-xs border border-border rounded-lg bg-white text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20" className="px-2 py-1.5 text-xs border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
title={t('posts.periodTo')} title={t('posts.periodTo')}
/> />
</div> </div>
@@ -520,7 +520,7 @@ export default function Tasks() {
className={`px-2.5 py-1 text-[11px] font-medium rounded-full border transition-colors ${ className={`px-2.5 py-1 text-[11px] font-medium rounded-full border transition-colors ${
filterOverdue filterOverdue
? 'bg-red-50 border-red-200 text-red-600' ? 'bg-red-50 border-red-200 text-red-600'
: 'bg-white border-border text-text-tertiary' : 'bg-surface border-border text-text-tertiary'
}`} }`}
> >
{t('tasks.overdue')} {t('tasks.overdue')}
@@ -599,7 +599,7 @@ export default function Tasks() {
onDelete={() => setShowBulkDeleteConfirm(true)} onDelete={() => setShowBulkDeleteConfirm(true)}
/> />
)} )}
<div className="bg-white rounded-xl border border-border overflow-hidden"> <div className="bg-surface rounded-xl border border-border overflow-clip">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b border-border bg-surface-secondary/50"> <tr className="border-b border-border bg-surface-secondary/50">
@@ -614,28 +614,28 @@ export default function Tasks() {
</th> </th>
<th className="w-8 px-3 py-2.5"></th> <th className="w-8 px-3 py-2.5"></th>
<th <th
className="text-left px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase cursor-pointer hover:text-text-primary" className="text-start px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase cursor-pointer hover:text-text-primary"
onClick={() => toggleSort('title')} onClick={() => toggleSort('title')}
> >
{t('tasks.taskTitle')} {sortBy === 'title' && (sortDir === 'asc' ? '↑' : '↓')} {t('tasks.taskTitle')} {sortBy === 'title' && (sortDir === 'asc' ? '↑' : '↓')}
</th> </th>
<th className="text-left px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase">{t('tasks.project')}</th> <th className="text-start px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase">{t('tasks.project')}</th>
<th className="text-left px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase">{t('tasks.brand')}</th> <th className="text-start px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase">{t('tasks.brand')}</th>
<th <th
className="text-left px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase cursor-pointer hover:text-text-primary" className="text-start px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase cursor-pointer hover:text-text-primary"
onClick={() => toggleSort('status')} onClick={() => toggleSort('status')}
> >
{t('tasks.status')} {sortBy === 'status' && (sortDir === 'asc' ? '↑' : '↓')} {t('tasks.status')} {sortBy === 'status' && (sortDir === 'asc' ? '↑' : '↓')}
</th> </th>
<th className="text-left px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase">{t('tasks.assignee')}</th> <th className="text-start px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase">{t('tasks.assignee')}</th>
<th <th
className="text-left px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase cursor-pointer hover:text-text-primary" className="text-start px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase cursor-pointer hover:text-text-primary"
onClick={() => toggleSort('due_date')} onClick={() => toggleSort('due_date')}
> >
{t('tasks.dueDate')} {sortBy === 'due_date' && (sortDir === 'asc' ? '↑' : '↓')} {t('tasks.dueDate')} {sortBy === 'due_date' && (sortDir === 'asc' ? '↑' : '↓')}
</th> </th>
<th <th
className="text-left px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase cursor-pointer hover:text-text-primary" className="text-start px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase cursor-pointer hover:text-text-primary"
onClick={() => toggleSort('priority')} onClick={() => toggleSort('priority')}
> >
{t('tasks.priority')} {sortBy === 'priority' && (sortDir === 'asc' ? '↑' : '↓')} {t('tasks.priority')} {sortBy === 'priority' && (sortDir === 'asc' ? '↑' : '↓')}
@@ -651,7 +651,7 @@ export default function Tasks() {
const brandName = task.brand_name || task.brandName const brandName = task.brand_name || task.brandName
const assignedName = task.assigned_name || task.assignedName const assignedName = task.assigned_name || task.assignedName
const statusLabels = { todo: t('tasks.todo'), in_progress: t('tasks.in_progress'), done: t('tasks.done') } const statusLabels = { todo: t('tasks.todo'), in_progress: t('tasks.in_progress'), done: t('tasks.done') }
const statusColors = { todo: 'bg-gray-100 text-gray-600', in_progress: 'bg-blue-100 text-blue-700', done: 'bg-emerald-100 text-emerald-700' } const statusColors = { todo: 'bg-gray-100 text-text-secondary', in_progress: 'bg-blue-100 text-blue-700', done: 'bg-emerald-100 text-emerald-700' }
return ( return (
<tr <tr
@@ -675,7 +675,7 @@ export default function Tasks() {
{task.title} {task.title}
</span> </span>
{(task.comment_count || task.commentCount) > 0 && ( {(task.comment_count || task.commentCount) > 0 && (
<span className="ml-2 text-[10px] text-text-tertiary">💬 {task.comment_count || task.commentCount}</span> <span className="ms-2 text-[10px] text-text-tertiary">💬 {task.comment_count || task.commentCount}</span>
)} )}
</td> </td>
<td className="px-3 py-2.5 text-text-tertiary text-xs">{projectName || '—'}</td> <td className="px-3 py-2.5 text-text-tertiary text-xs">{projectName || '—'}</td>
+28 -26
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 { Plus, Users, ArrowLeft, User as UserIcon, Edit2, LayoutGrid, Network, Link2, ChevronDown, Check, X } from 'lucide-react'
import { getInitials } from '../utils/api' import { getInitials } from '../utils/api'
import { AppContext, PERMISSION_LEVELS } from '../App' import { AppContext, PERMISSION_LEVELS } from '../App'
@@ -16,9 +16,9 @@ import { useToast } from '../components/ToastContainer'
const ALL_MODULES = ['marketing', 'projects', 'finance'] const ALL_MODULES = ['marketing', 'projects', 'finance']
const MODULE_LABELS = { marketing: 'Marketing', projects: 'Projects', finance: 'Finance' } const MODULE_LABELS = { marketing: 'Marketing', projects: 'Projects', finance: 'Finance' }
const MODULE_COLORS = { const MODULE_COLORS = {
marketing: { on: 'bg-emerald-100 text-emerald-700 border-emerald-300', off: 'bg-gray-100 text-gray-400 border-gray-200' }, marketing: { on: 'bg-emerald-100 text-emerald-700 border-emerald-300', off: 'bg-gray-100 text-text-tertiary border-gray-200' },
projects: { on: 'bg-blue-100 text-blue-700 border-blue-300', off: 'bg-gray-100 text-gray-400 border-gray-200' }, projects: { on: 'bg-blue-100 text-blue-700 border-blue-300', off: 'bg-gray-100 text-text-tertiary border-gray-200' },
finance: { on: 'bg-amber-100 text-amber-700 border-amber-300', off: 'bg-gray-100 text-gray-400 border-gray-200' }, finance: { on: 'bg-amber-100 text-amber-700 border-amber-300', off: 'bg-gray-100 text-text-tertiary border-gray-200' },
} }
const EMPTY_MEMBER = { const EMPTY_MEMBER = {
@@ -238,9 +238,11 @@ export default function Team() {
// Member detail view // Member detail view
if (selectedMember) { if (selectedMember) {
const todoCount = memberTasks.filter(t => t.status === 'todo').length const { todoCount, inProgressCount, doneCount } = useMemo(() => ({
const inProgressCount = memberTasks.filter(t => t.status === 'in_progress').length todoCount: memberTasks.filter(t => t.status === 'todo').length,
const doneCount = memberTasks.filter(t => t.status === 'done').length inProgressCount: memberTasks.filter(t => t.status === 'in_progress').length,
doneCount: memberTasks.filter(t => t.status === 'done').length,
}), [memberTasks])
return ( return (
<div className="space-y-6 animate-fade-in"> <div className="space-y-6 animate-fade-in">
@@ -253,7 +255,7 @@ export default function Team() {
</button> </button>
{/* Member profile */} {/* Member profile */}
<div className="bg-white rounded-xl border border-border p-6"> <div className="bg-surface rounded-xl border border-border p-6">
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<div className={`w-16 h-16 rounded-full bg-gradient-to-br from-indigo-400 to-purple-500 flex items-center justify-center text-white text-xl font-bold`}> <div className={`w-16 h-16 rounded-full bg-gradient-to-br from-indigo-400 to-purple-500 flex items-center justify-center text-white text-xl font-bold`}>
{selectedMember.name?.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase()} {selectedMember.name?.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase()}
@@ -281,19 +283,19 @@ export default function Team() {
{/* Workload stats */} {/* Workload stats */}
<div className="grid grid-cols-4 gap-4"> <div className="grid grid-cols-4 gap-4">
<div className="bg-white rounded-xl border border-border p-4 text-center"> <div className="bg-surface rounded-xl border border-border p-4 text-center">
<p className="text-2xl font-bold text-text-primary">{memberTasks.length}</p> <p className="text-2xl font-bold text-text-primary">{memberTasks.length}</p>
<p className="text-xs text-text-tertiary">{t('team.totalTasks')}</p> <p className="text-xs text-text-tertiary">{t('team.totalTasks')}</p>
</div> </div>
<div className="bg-white rounded-xl border border-border p-4 text-center"> <div className="bg-surface rounded-xl border border-border p-4 text-center">
<p className="text-2xl font-bold text-amber-500">{todoCount}</p> <p className="text-2xl font-bold text-amber-500">{todoCount}</p>
<p className="text-xs text-text-tertiary">{t('team.toDo')}</p> <p className="text-xs text-text-tertiary">{t('team.toDo')}</p>
</div> </div>
<div className="bg-white rounded-xl border border-border p-4 text-center"> <div className="bg-surface rounded-xl border border-border p-4 text-center">
<p className="text-2xl font-bold text-blue-500">{inProgressCount}</p> <p className="text-2xl font-bold text-blue-500">{inProgressCount}</p>
<p className="text-xs text-text-tertiary">{t('team.inProgress')}</p> <p className="text-xs text-text-tertiary">{t('team.inProgress')}</p>
</div> </div>
<div className="bg-white rounded-xl border border-border p-4 text-center"> <div className="bg-surface rounded-xl border border-border p-4 text-center">
<p className="text-2xl font-bold text-emerald-500">{doneCount}</p> <p className="text-2xl font-bold text-emerald-500">{doneCount}</p>
<p className="text-xs text-text-tertiary">{t('tasks.done')}</p> <p className="text-xs text-text-tertiary">{t('tasks.done')}</p>
</div> </div>
@@ -302,7 +304,7 @@ export default function Team() {
{/* Tasks & Posts */} {/* Tasks & Posts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Tasks */} {/* Tasks */}
<div className="bg-white rounded-xl border border-border"> <div className="bg-surface rounded-xl border border-border">
<div className="px-5 py-4 border-b border-border"> <div className="px-5 py-4 border-b border-border">
<h3 className="font-semibold text-text-primary">{t('tasks.title')} ({memberTasks.length})</h3> <h3 className="font-semibold text-text-primary">{t('tasks.title')} ({memberTasks.length})</h3>
</div> </div>
@@ -327,7 +329,7 @@ export default function Team() {
</div> </div>
{/* Posts */} {/* Posts */}
<div className="bg-white rounded-xl border border-border"> <div className="bg-surface rounded-xl border border-border">
<div className="px-5 py-4 border-b border-border"> <div className="px-5 py-4 border-b border-border">
<h3 className="font-semibold text-text-primary">{t('nav.posts')} ({memberPosts.length})</h3> <h3 className="font-semibold text-text-primary">{t('nav.posts')} ({memberPosts.length})</h3>
</div> </div>
@@ -394,7 +396,7 @@ export default function Team() {
{displayedMembers.length} {displayedMembers.length !== 1 ? t('team.membersPlural') : t('team.member')} {displayedMembers.length} {displayedMembers.length !== 1 ? t('team.membersPlural') : t('team.member')}
</p> </p>
{/* View toggle */} {/* View toggle */}
<div className="flex items-center bg-white border border-border rounded-lg overflow-hidden"> <div className="flex items-center bg-surface border border-border rounded-lg overflow-hidden">
<button <button
onClick={() => setViewMode('grid')} onClick={() => setViewMode('grid')}
className={`p-2 transition-colors ${viewMode === 'grid' ? 'bg-brand-primary text-white' : 'text-text-tertiary hover:text-text-primary'}`} className={`p-2 transition-colors ${viewMode === 'grid' ? 'bg-brand-primary text-white' : 'text-text-tertiary hover:text-text-primary'}`}
@@ -415,7 +417,7 @@ export default function Team() {
{/* Copy generic issue link */} {/* Copy generic issue link */}
<button <button
onClick={() => copyIssueLink()} onClick={() => copyIssueLink()}
className="flex items-center gap-2 px-4 py-2 bg-white border border-border text-text-primary rounded-lg text-sm font-medium hover:bg-surface-secondary transition-colors" className="flex items-center gap-2 px-4 py-2 bg-surface border border-border text-text-primary rounded-lg text-sm font-medium hover:bg-surface-secondary transition-colors"
title={t('team.copyGenericIssueLink')} title={t('team.copyGenericIssueLink')}
> >
<Link2 className="w-4 h-4" /> <Link2 className="w-4 h-4" />
@@ -428,7 +430,7 @@ export default function Team() {
const self = teamMembers.find(m => m._id === user?.id || m.id === user?.id) const self = teamMembers.find(m => m._id === user?.id || m.id === user?.id)
if (self) openEdit(self) if (self) openEdit(self)
}} }}
className="flex items-center gap-2 px-4 py-2 bg-white border border-border text-text-primary rounded-lg text-sm font-medium hover:bg-surface-secondary transition-colors" className="flex items-center gap-2 px-4 py-2 bg-surface border border-border text-text-primary rounded-lg text-sm font-medium hover:bg-surface-secondary transition-colors"
> >
<UserIcon className="w-4 h-4" /> <UserIcon className="w-4 h-4" />
{t('team.myProfile')} {t('team.myProfile')}
@@ -438,7 +440,7 @@ export default function Team() {
{canManageTeam && ( {canManageTeam && (
<button <button
onClick={() => setPanelTeam({})} onClick={() => setPanelTeam({})}
className="flex items-center gap-2 px-4 py-2 bg-white border border-border text-text-primary rounded-lg text-sm font-medium hover:bg-surface-secondary transition-colors" className="flex items-center gap-2 px-4 py-2 bg-surface border border-border text-text-primary rounded-lg text-sm font-medium hover:bg-surface-secondary transition-colors"
> >
<Users className="w-4 h-4" /> <Users className="w-4 h-4" />
{t('teams.createTeam')} {t('teams.createTeam')}
@@ -468,7 +470,7 @@ export default function Team() {
<button <button
onClick={() => setTeamFilter(null)} onClick={() => setTeamFilter(null)}
className={`text-xs px-3 py-1.5 rounded-full border font-medium transition-colors ${ className={`text-xs px-3 py-1.5 rounded-full border font-medium transition-colors ${
!teamFilter ? 'bg-brand-primary text-white border-brand-primary' : 'bg-white text-text-secondary border-border hover:bg-surface-tertiary' !teamFilter ? 'bg-brand-primary text-white border-brand-primary' : 'bg-surface text-text-secondary border-border hover:bg-surface-tertiary'
}`} }`}
> >
{t('common.all')} {t('common.all')}
@@ -481,7 +483,7 @@ export default function Team() {
<button <button
onClick={() => setTeamFilter(active ? null : tid)} onClick={() => setTeamFilter(active ? null : tid)}
className={`text-xs px-3 py-1.5 rounded-full border font-medium transition-colors ${ className={`text-xs px-3 py-1.5 rounded-full border font-medium transition-colors ${
active ? 'bg-blue-600 text-white border-blue-600' : 'bg-white text-text-secondary border-border hover:bg-surface-tertiary' active ? 'bg-blue-600 text-white border-blue-600' : 'bg-surface text-text-secondary border-border hover:bg-surface-tertiary'
}`} }`}
> >
{team.name} ({team.member_count || 0}) {team.name} ({team.member_count || 0})
@@ -531,7 +533,7 @@ export default function Team() {
const tid = team.id || team._id const tid = team.id || team._id
const members = teamMembers.filter(m => m.teams?.some(t => t.id === tid)) const members = teamMembers.filter(m => m.teams?.some(t => t.id === tid))
return ( return (
<div key={tid} className="bg-white rounded-xl border border-border overflow-hidden"> <div key={tid} className="bg-surface rounded-xl border border-border overflow-clip">
{/* Team header */} {/* Team header */}
<div className="flex items-center justify-between px-5 py-4 bg-gradient-to-r from-blue-50 to-indigo-50 border-b border-border"> <div className="flex items-center justify-between px-5 py-4 bg-gradient-to-r from-blue-50 to-indigo-50 border-b border-border">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -601,7 +603,7 @@ export default function Team() {
{/* Unassigned members */} {/* Unassigned members */}
{unassignedMembers.length > 0 && ( {unassignedMembers.length > 0 && (
<div className="bg-white rounded-xl border border-border overflow-hidden"> <div className="bg-surface rounded-xl border border-border overflow-clip">
<div className="flex items-center gap-3 px-5 py-4 bg-gray-50 border-b border-border"> <div className="flex items-center gap-3 px-5 py-4 bg-gray-50 border-b border-border">
<div className="w-10 h-10 rounded-lg bg-gray-400 flex items-center justify-center text-white"> <div className="w-10 h-10 rounded-lg bg-gray-400 flex items-center justify-center text-white">
<UserIcon className="w-5 h-5" /> <UserIcon className="w-5 h-5" />
@@ -707,7 +709,7 @@ export default function Team() {
<div ref={addBrandsRef} className="relative"> <div ref={addBrandsRef} className="relative">
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.brands')}</label> <label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.brands')}</label>
<button type="button" onClick={() => setShowAddBrandsDropdown(p => !p)} <button type="button" onClick={() => setShowAddBrandsDropdown(p => !p)}
className="w-full flex items-center justify-between px-3 py-2 text-sm border border-border rounded-lg bg-white text-left focus:outline-none focus:ring-2 focus:ring-brand-primary/20"> className="w-full flex items-center justify-between px-3 py-2 text-sm border border-border rounded-lg bg-surface text-start focus:outline-none focus:ring-2 focus:ring-brand-primary/20">
<span className={`flex-1 truncate ${addForm.brands.length === 0 ? 'text-text-tertiary' : 'text-text-primary'}`}> <span className={`flex-1 truncate ${addForm.brands.length === 0 ? 'text-text-tertiary' : 'text-text-primary'}`}>
{addForm.brands.length === 0 ? t('team.selectBrands') : addForm.brands.join(', ')} {addForm.brands.length === 0 ? t('team.selectBrands') : addForm.brands.join(', ')}
</span> </span>
@@ -724,13 +726,13 @@ export default function Team() {
</div> </div>
)} )}
{showAddBrandsDropdown && ( {showAddBrandsDropdown && (
<div className="absolute z-20 mt-1 w-full bg-white border border-border rounded-lg shadow-lg max-h-48 overflow-y-auto"> <div className="absolute z-20 mt-1 w-full bg-surface border border-border rounded-lg shadow-lg max-h-48 overflow-y-auto">
{brands.map(brand => { {brands.map(brand => {
const name = lang === 'ar' && brand.name_ar ? brand.name_ar : brand.name const name = lang === 'ar' && brand.name_ar ? brand.name_ar : brand.name
const checked = addForm.brands.includes(name) const checked = addForm.brands.includes(name)
return ( return (
<button key={brand.id || brand._id} type="button" onClick={() => toggleAddBrand(name)} <button key={brand.id || brand._id} type="button" onClick={() => toggleAddBrand(name)}
className={`w-full flex items-center gap-2.5 px-3 py-2 hover:bg-surface-secondary transition-colors text-left ${checked ? 'bg-brand-primary/5' : ''}`}> className={`w-full flex items-center gap-2.5 px-3 py-2 hover:bg-surface-secondary transition-colors text-start ${checked ? 'bg-brand-primary/5' : ''}`}>
<div className={`w-4 h-4 rounded border flex items-center justify-center shrink-0 ${checked ? 'bg-brand-primary border-brand-primary' : 'border-border'}`}> <div className={`w-4 h-4 rounded border flex items-center justify-center shrink-0 ${checked ? 'bg-brand-primary border-brand-primary' : 'border-border'}`}>
{checked && <Check className="w-3 h-3 text-white" />} {checked && <Check className="w-3 h-3 text-white" />}
</div> </div>
@@ -771,7 +773,7 @@ export default function Team() {
return ( return (
<button key={tid} type="button" <button key={tid} type="button"
onClick={() => updateAdd('team_ids', active ? addForm.team_ids.filter(id => id !== tid) : [...addForm.team_ids, tid])} onClick={() => updateAdd('team_ids', active ? addForm.team_ids.filter(id => id !== tid) : [...addForm.team_ids, tid])}
className={`text-xs px-3 py-1.5 rounded-full border font-medium transition-colors ${active ? 'bg-blue-100 text-blue-700 border-blue-300' : 'bg-gray-100 text-gray-400 border-gray-200'}`}> className={`text-xs px-3 py-1.5 rounded-full border font-medium transition-colors ${active ? 'bg-blue-100 text-blue-700 border-blue-300' : 'bg-gray-100 text-text-tertiary border-gray-200'}`}>
{team.name} {team.name}
</button> </button>
) )
+14 -14
View File
@@ -189,8 +189,8 @@ export default function Translations() {
const SortIcon = ({ col }) => { const SortIcon = ({ col }) => {
if (listSortBy !== col) return null if (listSortBy !== col) return null
return listSortDir === 'asc' return listSortDir === 'asc'
? <ChevronUp className="w-3 h-3 inline ml-0.5" /> ? <ChevronUp className="w-3 h-3 inline ms-0.5" />
: <ChevronDown className="w-3 h-3 inline ml-0.5" /> : <ChevronDown className="w-3 h-3 inline ms-0.5" />
} }
const formatDate = (dateStr) => { const formatDate = (dateStr) => {
@@ -219,7 +219,7 @@ export default function Translations() {
onClick={() => setViewMode(mode)} onClick={() => setViewMode(mode)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${ className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
viewMode === mode viewMode === mode
? 'bg-white text-text-primary shadow-sm' ? 'bg-surface text-text-primary shadow-sm'
: 'text-text-tertiary hover:text-text-secondary' : 'text-text-tertiary hover:text-text-secondary'
}`} }`}
> >
@@ -242,13 +242,13 @@ export default function Translations() {
{/* Filters */} {/* Filters */}
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<div className="relative flex-1 min-w-[200px] max-w-md"> <div className="relative flex-1 min-w-[200px] max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" /> <Search className="absolute start-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<input <input
type="text" type="text"
placeholder={t('translations.searchTranslations')} placeholder={t('translations.searchTranslations')}
value={searchTerm} value={searchTerm}
onChange={e => setSearchTerm(e.target.value)} onChange={e => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface transition-colors" className="w-full ps-10 pe-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface transition-colors"
/> />
</div> </div>
@@ -352,26 +352,26 @@ export default function Translations() {
<p className="text-text-secondary">{t('translations.noTranslations')}</p> <p className="text-text-secondary">{t('translations.noTranslations')}</p>
</div> </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"> <table className="w-full">
<thead> <thead>
<tr className="border-b border-border bg-surface-secondary"> <tr className="border-b border-border bg-surface-secondary">
<th className="px-4 py-3 text-left w-10"> <th className="px-4 py-3 text-start w-10">
<input type="checkbox" checked={selectedIds.size === sortedTranslations.length && sortedTranslations.length > 0} onChange={toggleSelectAll} className="rounded border-border" /> <input type="checkbox" checked={selectedIds.size === sortedTranslations.length && sortedTranslations.length > 0} onChange={toggleSelectAll} className="rounded border-border" />
</th> </th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer" onClick={() => toggleListSort('title')}> <th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase cursor-pointer" onClick={() => toggleListSort('title')}>
{t('translations.titleLabel')} <SortIcon col="title" /> {t('translations.titleLabel')} <SortIcon col="title" />
</th> </th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase"> <th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">
{t('translations.sourceLanguage')} {t('translations.sourceLanguage')}
</th> </th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer" onClick={() => toggleListSort('status')}> <th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase cursor-pointer" onClick={() => toggleListSort('status')}>
{t('translations.status')} <SortIcon col="status" /> {t('translations.status')} <SortIcon col="status" />
</th> </th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('translations.brand')}</th> <th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('translations.brand')}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('translations.creator')}</th> <th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('translations.creator')}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('translations.languagesLabel')}</th> <th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('translations.languagesLabel')}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer" onClick={() => toggleListSort('updated_at')}> <th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase cursor-pointer" onClick={() => toggleListSort('updated_at')}>
{t('translations.updated')} <SortIcon col="updated_at" /> {t('translations.updated')} <SortIcon col="updated_at" />
</th> </th>
</tr> </tr>
+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
}
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,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'); .join('~and');
} }
const REQUEST_TIMEOUT_MS = 20_000;
async function request(method, url, body) { async function request(method, url, body) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
const opts = { const opts = {
method, method,
headers: { headers: {
'xc-token': NOCODB_TOKEN, 'xc-token': NOCODB_TOKEN,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
signal: controller.signal,
}; };
if (body !== undefined) opts.body = JSON.stringify(body); if (body !== undefined) opts.body = JSON.stringify(body);
const res = await fetch(url, opts); try {
if (!res.ok) { const res = await fetch(url, opts);
let details; clearTimeout(timer);
try { details = await res.json(); } catch {} if (!res.ok) {
throw new NocoDBError( let details;
`NocoDB ${method} ${url} failed: ${res.status}`, try { details = await res.json(); } catch {}
res.status, throw new NocoDBError(
details `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 ───────────────────────────────────────── // ─── Link Resolution ─────────────────────────────────────────
+79 -9
View File
@@ -3,9 +3,14 @@ const { sendMail } = require('./mail');
const nocodb = require('./nocodb'); const nocodb = require('./nocodb');
const { parseApproverIds } = require('./helpers'); 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_URL = process.env.APP_URL || process.env.CORS_ORIGIN || 'http://localhost:3001';
const APP_NAME_EN = "Samaya's Digital Hub"; const APP_NAME_EN = 'Rawaj';
const APP_NAME_AR = 'المركز الرقمي لسمايا'; const APP_NAME_AR = 'رواج';
// ─── TRANSLATIONS ─────────────────────────────────────────────── // ─── TRANSLATIONS ───────────────────────────────────────────────
@@ -94,6 +99,21 @@ const t = {
view: { en: 'View', ar: 'عرض' }, view: { en: 'View', ar: 'عرض' },
viewTask: { en: 'View Task', ar: 'عرض المهمة' }, viewTask: { en: 'View Task', ar: 'عرض المهمة' },
viewIssue:{ en: 'View Issue', ar: 'عرض المشكلة' }, viewIssue:{ en: 'View Issue', ar: 'عرض المشكلة' },
// Budget
budgetRequest: { en: 'Budget Request', ar: 'طلب ميزانية' },
budgetRequestHeading: { en: 'Budget Request', ar: 'طلب ميزانية' },
budgetRequestBody: { en: (name, amount) => `<strong>${name}</strong> is requesting <strong>${amount}</strong>.`,
ar: (name, amount) => `يطلب <strong>${name}</strong> مبلغ <strong>${amount}</strong>.` },
budgetJustification: { en: 'Justification', ar: 'المبرر' },
budgetEarmarkedFor: { en: 'Earmarked for', ar: 'مخصص لـ' },
reviewRequest: { en: 'Review Request', ar: 'مراجعة الطلب' },
budgetApproved: { en: 'Budget Request Approved', ar: 'تمت الموافقة على طلب الميزانية' },
budgetApprovedBody: { en: (amount) => `Your budget request for <strong>${amount}</strong> has been approved. Funds are now available.`,
ar: (amount) => `تمت الموافقة على طلب الميزانية بمبلغ <strong>${amount}</strong>. الأموال متاحة الآن.` },
budgetRejected: { en: 'Budget Request Rejected', ar: 'تم رفض طلب الميزانية' },
budgetRejectedBody: { en: (amount) => `Your budget request for <strong>${amount}</strong> has been rejected.`,
ar: (amount) => `تم رفض طلب الميزانية بمبلغ <strong>${amount}</strong>.` },
}; };
function tr(key, lang) { return t[key]?.[lang] || t[key]?.en || key; } function tr(key, lang) { return t[key]?.[lang] || t[key]?.en || key; }
@@ -111,7 +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> <html dir="${dir}" lang="${lang}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="margin:0;padding:0;background:#f4f5f7;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif"> <body style="margin:0;padding:0;background:#f4f5f7;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif">
<div style="max-width:600px;margin:0 auto;padding:20px;direction:${dir};text-align:${align}"> <div style="max-width:600px;margin:0 auto;padding:20px;direction:${dir};text-align:${align}">
<div style="background:#1e293b;color:#fff;padding:16px 24px;border-radius:12px 12px 0 0;font-size:16px;font-weight:600;text-align:${align}"> <div style="background:#0a1f1c;color:#fff;padding:16px 24px;border-radius:12px 12px 0 0;font-size:16px;font-weight:600;text-align:${align}">
${appName} ${appName}
</div> </div>
<div style="background:#fff;padding:32px 24px;border-radius:0 0 12px 12px;border:1px solid #e2e8f0;border-top:none"> <div style="background:#fff;padding:32px 24px;border-radius:0 0 12px 12px;border:1px solid #e2e8f0;border-top:none">
@@ -121,7 +141,7 @@ function renderEmail({ heading, bodyHtml, ctaText, ctaUrl, lang = 'en' }) {
</div> </div>
${ctaText && ctaUrl ? ` ${ctaText && ctaUrl ? `
<div style="margin:24px 0 8px"> <div style="margin:24px 0 8px">
<a href="${ctaUrl}" style="display:inline-block;background:#3b82f6;color:#fff;padding:12px 24px;border-radius:8px;text-decoration:none;font-weight:600;font-size:14px">${ctaText}</a> <a href="${ctaUrl}" style="display:inline-block;background:#0d9488;color:#fff;padding:12px 24px;border-radius:8px;text-decoration:none;font-weight:600;font-size:14px">${ctaText}</a>
</div>` : ''} </div>` : ''}
</div> </div>
<div style="text-align:center;padding:16px;color:#94a3b8;font-size:12px"> <div style="text-align:center;padding:16px;color:#94a3b8;font-size:12px">
@@ -151,8 +171,10 @@ async function getMultipleUsers(userIds) {
} }
function send({ to, subject, heading, bodyHtml, ctaText, ctaUrl, lang }) { function send({ to, subject, heading, bodyHtml, ctaText, ctaUrl, lang }) {
const appName = lang === 'ar' ? APP_NAME_AR : APP_NAME_EN;
const fullSubject = `${appName}${subject}`;
const { html, text } = renderEmail({ heading, bodyHtml, ctaText, ctaUrl, lang }); const { html, text } = renderEmail({ heading, bodyHtml, ctaText, ctaUrl, lang });
sendMail({ to, subject, html, text }) sendMail({ to, subject: fullSubject, html, text })
.then(() => console.log(`[notifications] Sent "${subject}" to ${to}`)) .then(() => console.log(`[notifications] Sent "${subject}" to ${to}`))
.catch(err => console.error(`[notifications] FAILED "${subject}" to ${to}:`, err.message)); .catch(err => console.error(`[notifications] FAILED "${subject}" to ${to}:`, err.message));
} }
@@ -222,7 +244,7 @@ function notifyRejected({ type, record, approverName, feedback }) {
heading: tr('rejectedHeading', l)(typeLabel), heading: tr('rejectedHeading', l)(typeLabel),
bodyHtml: ` bodyHtml: `
<p>${tr('rejectedBody', l)(title, approverName || (l === 'ar' ? 'مراجع' : 'a reviewer'))}</p> <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}`, ctaText: `${tr('view', l)} ${typeLabel}`,
ctaUrl: `${APP_URL}/${type === 'post' ? 'posts' : type === 'translation' ? 'translations' : 'artefacts'}`, ctaUrl: `${APP_URL}/${type === 'post' ? 'posts' : type === 'translation' ? 'translations' : 'artefacts'}`,
}); });
@@ -246,7 +268,7 @@ function notifyRevisionRequested({ type, record, approverName, feedback }) {
heading: tr('revisionRequested', l), heading: tr('revisionRequested', l),
bodyHtml: ` bodyHtml: `
<p>${tr('revisionRequestedBody', l)(title, approverName || (l === 'ar' ? 'مراجع' : 'a reviewer'))}</p> <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)}`, ctaText: `${tr('view', l)} ${tr(entityType, l)}`,
ctaUrl: `${APP_URL}/${entityPath}`, ctaUrl: `${APP_URL}/${entityPath}`,
}); });
@@ -269,7 +291,7 @@ function notifyTaskAssigned({ task, assignerName }) {
bodyHtml: ` bodyHtml: `
<p>${tr('taskAssignedBody', l)(assignerName || (l === 'ar' ? 'أحدهم' : 'Someone'))}</p> <p>${tr('taskAssignedBody', l)(assignerName || (l === 'ar' ? 'أحدهم' : 'Someone'))}</p>
<p style="font-size:16px;font-weight:600;color:#1e293b">${title}</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.priority ? `<p>${tr('priority', l)}: <strong>${task.priority}</strong></p>` : ''}
${task.due_date ? `<p>${tr('dueDate', l)}: <strong>${task.due_date}</strong></p>` : ''}`, ${task.due_date ? `<p>${tr('dueDate', l)}: <strong>${task.due_date}</strong></p>` : ''}`,
ctaText: tr('viewTask', l), ctaText: tr('viewTask', l),
@@ -334,7 +356,7 @@ function notifyIssueStatusUpdate({ issue, oldStatus, newStatus }) {
bodyHtml: ` bodyHtml: `
<p>${tr('issueUpdateBody', 'en')(title)}</p> <p>${tr('issueUpdateBody', 'en')(title)}</p>
<p><span style="color:#94a3b8">${oldStatus || 'new'}</span> <strong style="color:#3b82f6">${newStatus}</strong></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, ctaText: issue.tracking_token ? tr('trackIssue', 'en') : null,
ctaUrl: issue.tracking_token ? `${APP_URL}/track/${issue.tracking_token}` : 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 = { module.exports = {
renderEmail,
notifyReviewSubmitted, notifyReviewSubmitted,
notifyApproved, notifyApproved,
notifyRejected, notifyRejected,
@@ -398,4 +465,7 @@ module.exports = {
notifyIssueStatusUpdate, notifyIssueStatusUpdate,
notifyCampaignCreated, notifyCampaignCreated,
notifyUserInvited, 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 };
+959 -61
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env node #!/usr/bin/env node
/** /**
* setup-tables.js Creates a new "Digital Hub" base in NocoDB * setup-tables.js Creates a new "Rawaj" base in NocoDB
* with all 12 tables, fields, and links. * with all 12 tables, fields, and links.
* Run once: node setup-tables.js * Run once: node setup-tables.js
*/ */
@@ -28,9 +28,9 @@ async function request(method, url, body) {
} }
async function createBase() { async function createBase() {
console.log('Creating "Digital Hub" base...'); console.log('Creating "Rawaj" base...');
const data = await request('POST', `${NOCODB_URL}/api/v2/meta/bases/`, { const data = await request('POST', `${NOCODB_URL}/api/v2/meta/bases/`, {
title: 'Digital Hub', title: 'Rawaj',
type: 'database', type: 'database',
}); });
console.log(` Base created: ${data.id}`); console.log(` Base created: ${data.id}`);