Compare commits

...

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 17:20:20 +03:00
fahed b17108b321 feat: add Translation Management with approval workflow
Deploy / deploy (push) Successful in 12s
- New Translations + TranslationTexts NocoDB tables (auto-created on restart)
- Full CRUD: list, create, update, delete, bulk-delete translations
- Translation texts per language (add/edit/delete inline)
- Review flow: submit-review generates public token link
- Public review page: shows source + all translations, approve/reject/revision
- Email notifications to approvers (registered users)
- Sidebar nav under Marketing category
- Bilingual i18n (80+ keys in en.json and ar.json)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 14:49:04 +03:00
fahed 14751c42e4 fix: inline video upload in artefacts — drop modal, add drag-and-drop + progress bar
Deploy / deploy (push) Successful in 12s
- Replace the two-step video modal with inline drag-and-drop zone + click-to-browse
- Add Google Drive URL input directly in the versions tab
- Add upload progress bar with percentage via XHR
- Support onUploadProgress in api.upload()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 11:34:11 +03:00
fahed 51708267d3 fix: add missing i18n key for artefacts details tab
Deploy / deploy (push) Successful in 11s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 10:49:51 +03:00
fahed 05dc5df3f8 fix: persist artefact review link by deriving URL from approval_token
Deploy / deploy (push) Successful in 12s
Previously the review URL was only stored in component state and lost
when the panel was reopened. Now derives it from the artefact's
approval_token field, matching how PostDetailPanel works.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 15:37:34 +03:00
fahed f1bcf217ac fix: add missing i18n key for artefacts review tab
Deploy / deploy (push) Successful in 12s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 13:59:52 +03:00
fahed ed98ebb67f fix: add missing i18n keys for issues modal tabs
Deploy / deploy (push) Successful in 12s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 13:34:33 +03:00
fahed 44e706f777 feat: convert all slide panels to tabbed modals with shared TabbedModal component
Deploy / deploy (push) Successful in 11s
Extract reusable TabbedModal component (portal, backdrop, tab bar with icons/badges/underline, scrollable body, footer) and convert all 9 detail panels from SlidePanel+CollapsibleSection to tabbed modal layout:
- PostDetailPanel (5 tabs), TaskDetailPanel (3), ProjectEditPanel (2)
- TrackDetailPanel (2), CampaignDetailPanel (3), TeamMemberPanel (3)
- TeamPanel (2), IssueDetailPanel (4), ArtefactDetailPanel (4)
Also adds post versioning system (server routes + frontend).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 17:12:32 +03:00
fahed 539c204bde fix: add delete button inside brand edit modal
Deploy / deploy (push) Successful in 12s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 14:28:38 +03:00
fahed 64e377060f feat: bilingual public issue pages with browser detection and language toggle
Deploy / deploy (push) Successful in 11s
Both PublicIssueSubmit and PublicIssueTracker now support EN/AR with
auto-detection from browser language, a floating toggle button, RTL
layout, and localized dates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 14:23:06 +03:00
fahed 0ee3552c58 fix: show assignee in dashboard deadlines, untrack .env, update gitignore
Deploy / deploy (push) Successful in 11s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 14:12:36 +03:00
fahed 5d733ce066 fix: add language picker to Team add-member modal, remove orphaned Users page
Deploy / deploy (push) Successful in 11s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 13:21:44 +03:00
fahed 12628e2f78 fix: use named import for api in LanguageContext
Deploy / deploy (push) Successful in 11s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 13:17:38 +03:00
fahed 5e47d11e32 feat: language selector on user creation, pass to welcome email
Deploy / deploy (push) Failing after 9s
Adds preferred_language field to Users, language picker (EN/AR) in
create/edit user form, persists to NocoDB, and passes it to the
welcome notification so new users receive emails in their language.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 13:14:32 +03:00
fahed 8c69f1846f feat: add bilingual email notification system for key events
Deploy / deploy (push) Failing after 8s
Notifications (fire-and-forget, non-blocking) for: review submitted,
approved/rejected/revision requested, task assigned/completed, issue
assigned/status changed, campaign created, user invited. Emails render
in user's preferred language (EN/AR) with RTL support. Adds
preferred_language to Users, syncs from frontend language toggle.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 13:08:36 +03:00
fahed c9d0b8f151 fix: show creator name prominently in detail panels and public review pages
Deploy / deploy (push) Successful in 12s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 02:13:15 +03:00
fahed 35a0c4d6ce fix: allow unauthenticated access to public review pages
Deploy / deploy (push) Successful in 12s
The 401 handler in api.js was redirecting to /login on ALL pages,
including public review/approval pages, defeating their purpose.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:37:21 +03:00
fahed f4cf2e39a4 fix: remove duplicate page titles, add OG meta for review links
Deploy / deploy (push) Successful in 11s
- Removed h1 titles from Settings and Brands (already in Header)
- Server injects og:title, og:description, og:image meta tags for
  /review-post/:token URLs so WhatsApp/Slack show proper previews

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 17:50:45 +03:00
fahed 96fb838388 fix: consistent page titles for all routes in header
Deploy / deploy (push) Successful in 12s
Added missing title keys for calendar, artefacts, brands, budgets,
issues, and settings. No more fallback "Page" text.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 17:46:57 +03:00
fahed 8eaea27e89 fix: allow generating review link for pre-existing posts
Deploy / deploy (push) Successful in 12s
Show button only when post has no approval token. Once a link exists,
only the link is shown. Server preserves status for non-draft posts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 17:41:42 +03:00
fahed 593adbbc0b fix: review link always visible for posts with approval token
Deploy / deploy (push) Successful in 12s
Derived from post.approval_token instead of transient state, so it
persists when the panel is closed and reopened.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 17:34:37 +03:00
fahed 6203bf36e6 fix: brand cards now square-shaped in a denser grid
Deploy / deploy (push) Successful in 11s
Grid changed from 3-col to 5-col, cards use aspect-square layout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 16:55:54 +03:00
fahed 0789b7e550 fix: superadmin can edit own brands and modules in team panel
Deploy / deploy (push) Successful in 11s
Brands and modules fields were hidden/disabled when editing self.
Superadmins now see editable brands dropdown and modules toggles.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 15:38:11 +03:00
fahed 70de02c97c fix: superadmin can now assign brands/modules to themselves
Deploy / deploy (push) Successful in 11s
The self-edit path only sent name+phone. Superadmins now use the full
team update endpoint so brands, modules, and role changes persist.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 15:31:23 +03:00
fahed 93956ff117 fix: require feedback on post rejection, post-specific review text, show superadmins in team list
Deploy / deploy (push) Successful in 11s
- Reject requires feedback on both client and server (400 if empty)
- PublicPostReview uses post-specific i18n keys instead of artefact ones
- Team list always includes superadmins/managers for non-superadmin users

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 15:24:48 +03:00
fahed 0e948cbf37 feat: public review flow for posts (like artefacts)
Deploy / deploy (push) Successful in 12s
- Token-based public review page at /review-post/:token
- Submit for Review button generates shareable link
- External reviewers can approve/reject with comments
- Approval gate prevents skipping review (superadmin override)
- i18n keys for review flow in en + ar

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 15:16:13 +03:00
fahed 8e243517e2 fix: select dropdowns clipped by sections, add approval actions to posts
Deploy / deploy (push) Successful in 12s
- Fix CollapsibleSection overflow clipping select dropdowns
- Add SlidePanel footer prop for always-visible save/cancel bar
- Add approval action buttons: Send to Review, Approve, Reject, Schedule
- Add sync-brands.js script for local→remote NocoDB brand sync
- Add posts.reject i18n key (en + ar)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:34:15 +03:00
fahed 82236ecffa feat: post approval workflow, i18n completion, and multiple fixes
Deploy / deploy (push) Successful in 11s
- Add approval process to posts (approver multi-select, rejected status column)
- Reorganize PostDetailPanel into Content, Scheduling, Approval sections
- Fix save button visibility: move to fixed footer via SlidePanel footer prop
- Change date picker from datetime-local to date-only
- Complete Arabic translations across all panels (Header, Issues, Artefacts)
- Fix artefact versioning to start empty (copyFromPrevious defaults to false)
- Separate media uploads by type (image, audio, video) in PostDetailPanel
- Fix team membership save when editing own profile as superadmin
- Server: add approver_ids column to Posts, enrich GET/POST/PATCH responses

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:17:16 +03:00
fahed daf2404bda fix: remove non-functional notification bell from header
Deploy / deploy (push) Successful in 11s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 00:05:45 +03:00
fahed 7dc7fbbbe2 fix: RTL support for timelines and header dropdown
Deploy / deploy (push) Successful in 11s
- InteractiveTimeline: dir="ltr" on scroll area, i18n for all strings
- ArtefactVersionTimeline: text-start, ms-11 logical properties
- Header dropdown: end-0 instead of right-0, text-start on menu items
- Added 11 new timeline i18n keys (en + ar)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 23:54:32 +03:00
fahed fe509b65a9 fix: toast z-index above slide panels
Deploy / deploy (push) Successful in 11s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 23:34:23 +03:00
fahed ad539fd7f4 feat: admin password change with confirmation in team panel
Deploy / deploy (push) Successful in 11s
Add "Admin Actions" section (superadmin-only, collapsed by default) with
password + confirm fields, eye toggle, mismatch validation, and success toast.
Delete button moved here too.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 23:30:03 +03:00
fahed e8539af4f7 fix: solid dark toast backgrounds — no transparency
Deploy / deploy (push) Successful in 12s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 17:40:51 +03:00
fahed 1c10f79036 fix: dark mode card contrast — elevate bg-white above surface-secondary
Deploy / deploy (push) Successful in 11s
Cards (bg-white → #22223a) now sit visibly above kanban columns
and section backgrounds (surface-secondary → #1c1c2a).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 17:39:46 +03:00
fahed 06e992e2eb feat: premium dark mode inspired by SpaceTime
Deploy / deploy (push) Successful in 12s
Deep layered surfaces (#15151e → #1c1c2a → #24243a), ambient
purple/cyan/gold background glow, glass-edge borders, translucent
status badges, form inputs with accent focus glow, comprehensive
overrides for all hardcoded bg-white/gray/border/text utilities,
brand primary with violet glow effect, and dark selection highlight.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 17:38:25 +03:00
fahed f3c53e27aa fix: dark mode CSS variables and overrides
Deploy / deploy (push) Successful in 11s
The ThemeContext was toggling the 'dark' class on <html> but the CSS
custom properties never changed. Added:
- .dark selector overriding all theme color variables
- .dark overrides for hardcoded bg-white, bg-gray-*, text-gray-*,
  border-gray-* classes used throughout components
- Dark mode input/select/textarea styling
- Dark mode scrollbar colors

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 17:00:00 +03:00
fahed 643d004dc7 feat: use modals for creation across all pages + fix profile prompt
Deploy / deploy (push) Successful in 11s
- Campaigns: add create modal (name, brand, team, dates, budget)
- PostProduction: add create modal (title, brand, campaign, assignee),
  auto-opens detail panel after creation
- Tasks: add create modal (title, project, priority, assignee),
  auto-opens detail panel after creation
- Fix profileComplete check: use !!user.name instead of !!user.team_role
  in /api/auth/me (was always showing profile prompt since team_role
  is now deprecated in favor of role_id)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 16:44:22 +03:00
fahed 959bd6066d feat: use modals for creation, side panels for editing
Deploy / deploy (push) Successful in 11s
- Team page: add member via modal with password confirmation,
  keep SlidePanel for editing existing members only
- Settings: add role via modal with color picker presets,
  keep inline editing for existing roles
- Remove create-mode code from TeamMemberPanel
- Add i18n keys: confirmPassword, passwordsDoNotMatch, memberAdded,
  roleColor (en + ar)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 16:37:37 +03:00
fahed da161014af feat: team-based visibility, roles management, unified users, UI fixes
Deploy / deploy (push) Successful in 12s
- Add Roles table with CRUD routes and Settings page management
- Unify user management: remove Users page, enhance Team page with
  permission level + role dropdowns
- Add team-based visibility scoping to projects, campaigns, posts,
  tasks, issues, artefacts, and dashboard
- Add team_id to projects and campaigns (create + edit forms)
- Add getUserTeamIds/getUserVisibilityContext helpers
- Fix Budgets modal horizontal scroll (separate linked-to row)
- Add collapsible filter bar to PostProduction page

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 15:55:15 +03:00
fahed 7c6e8dce08 fix: use nocodb.get() after list() for auth — list may omit fields
Deploy / deploy (push) Successful in 11s
NocoDB list endpoint doesn't always return all fields (e.g.
password_hash). Use list() to find by email/token, then get()
to fetch the full record with all fields.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 12:33:36 +03:00
fahed fa6345f63e feat: add theme toggle, shared KanbanCard, keyboard shortcuts
Deploy / deploy (push) Successful in 11s
Previously unstaged files from prior sessions: ThemeContext,
ThemeToggle, KanbanCard, useKeyboardShortcuts hook, and updated
Header, KanbanBoard, Issues, Tasks, PostProduction, index.css.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 12:12:34 +03:00
fahed c31e6222d7 feat: consolidate auth into NocoDB, add password reset, health check
Deploy / deploy (push) Failing after 9s
- Migrate auth credentials from SQLite (auth.db) to NocoDB Users table
  with one-time migration function (auth.db → auth.db.bak)
- Add email-based password reset via Cloudron SMTP (nodemailer)
- Add GET /api/health endpoint for monitoring
- Add startup env var validation with clear error messages
- Strip sensitive fields (password_hash, reset_token) from all API responses
- Add ForgotPassword + ResetPassword pages with i18n (en/ar)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 11:47:27 +03:00
fahed 42a5f17d0b feat: bulk delete, team dispatch, calendar views, timeline colors
Deploy / deploy (push) Successful in 11s
- Multi-select bulk delete in all 5 list views (Artefacts, Posts, Tasks,
  Issues, Assets) with cascade deletes and confirmation modals
- Team-based issue dispatch: team picker on public issue form, team filter
  on Issues page, copy public link from Team page and Issues header,
  team assignment in IssueDetailPanel
- Month/Week toggle on PostCalendar and TaskCalendarView
- Month/Week/Day zoom on project and campaign timelines (InteractiveTimeline)
  and ProjectDetail GanttView, with Month as default
- Custom timeline bar colors: clickable color dot with 12-color palette
  popover on project, campaign, and task timeline bars
- Artefacts default view changed to list
- BulkSelectBar reusable component
- i18n keys for all new features (en + ar)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 14:55:36 +03:00
fahed 20d76dea8b correction erreur approval flow
Deploy / deploy (push) Successful in 11s
2026-03-01 11:30:20 +03:00
fahed 12415d5426 fix: replace hardcoded localhost:3001 URLs with relative paths
Deploy / deploy (push) Successful in 13s
Fixes CORS errors when deployed behind a reverse proxy by removing
hardcoded localhost references in Brands.jsx and PostCard.jsx.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 11:19:54 +03:00
fahed 0c945405e3 fix: use api.upload() for artefact file attachments
Deploy / deploy (push) Successful in 12s
api.post() was setting Content-Type to application/json and JSON.stringifying
the FormData, which destroyed the file data and caused "Either file upload or
drive_url is required" error.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 14:10:41 +03:00
fahed 01fdb93efd feat: hide dashboard sections for modules the user cannot access
Deploy / deploy (push) Successful in 11s
Only fetch data and render stat cards, lists, and widgets for modules
the user has enabled (marketing, projects, finance).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 15:58:48 +03:00
fahed 52d69ee02d feat: add self-service password change from user menu
Deploy / deploy (push) Successful in 11s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 15:54:29 +03:00
fahed 7554b1cb56 Add language selection to profile completion wizard
Deploy / deploy (push) Successful in 12s
Users can choose English or Arabic during profile setup. The
selection is applied immediately via the existing LanguageContext.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 15:40:10 +03:00
fahed 6cdec2b4b5 Restrict team_role and brands to admin-only editing
Deploy / deploy (push) Successful in 11s
- Remove team_role and brands from profile completion wizard
- Lock team_role and brands fields when user edits own profile
- Remove team_role and brands from PATCH /users/me/profile endpoint
- Profile completeness now checks name instead of team_role

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 15:36:48 +03:00
fahed 4d91e8e8a8 Add password confirmation to user creation/edit in Users page
Shows confirm password field when a password is entered. Validates
match before saving.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 15:33:14 +03:00
fahed b1f7d574ed Fix team data not refreshing after save/delete
Await loadTeam() and loadTeams() so the UI reflects changes
immediately without needing a manual page refresh.

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

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

+1 -1
View File
@@ -7,7 +7,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=IBM+Plex+Sans+Arabic:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<title>Digital Hub</title>
<title>Rawaj</title>
</head>
<body>
<div id="root"></div>
-29
View File
@@ -18,8 +18,6 @@
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
@@ -1627,26 +1625,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "19.2.13",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.13.tgz",
"integrity": "sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
}
},
"node_modules/@types/react-dom": {
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.2.0"
}
},
"node_modules/@vitejs/plugin-react": {
"version": "5.1.3",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.3.tgz",
@@ -1903,13 +1881,6 @@
"node": ">= 8"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"license": "MIT"
},
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
-2
View File
@@ -20,8 +20,6 @@
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
+95 -68
View File
@@ -1,57 +1,63 @@
import { Routes, Route, Navigate } from 'react-router-dom'
import { useState, useEffect, createContext } from 'react'
import { useState, useEffect, createContext, lazy, Suspense } from 'react'
import { AuthProvider, useAuth } from './contexts/AuthContext'
import { LanguageProvider } from './i18n/LanguageContext'
import { ToastProvider } from './components/ToastContainer'
import { ThemeProvider } from './contexts/ThemeContext'
import ErrorBoundary from './components/ErrorBoundary'
import Layout from './components/Layout'
import Dashboard from './pages/Dashboard'
import PostProduction from './pages/PostProduction'
import Assets from './pages/Assets'
import Campaigns from './pages/Campaigns'
import CampaignDetail from './pages/CampaignDetail'
import Finance from './pages/Finance'
import Budgets from './pages/Budgets'
import Projects from './pages/Projects'
import ProjectDetail from './pages/ProjectDetail'
import Tasks from './pages/Tasks'
import Team from './pages/Team'
import Users from './pages/Users'
import Settings from './pages/Settings'
import Brands from './pages/Brands'
import Login from './pages/Login'
import Artefacts from './pages/Artefacts'
import PostCalendar from './pages/PostCalendar'
import PublicReview from './pages/PublicReview'
import Issues from './pages/Issues'
import PublicIssueSubmit from './pages/PublicIssueSubmit'
import PublicIssueTracker from './pages/PublicIssueTracker'
import Tutorial from './components/Tutorial'
import Modal from './components/Modal'
import { api } from './utils/api'
import { useLanguage } from './i18n/LanguageContext'
import { useKeyboardShortcuts, DEFAULT_SHORTCUTS } from './hooks/useKeyboardShortcuts'
const TEAM_ROLES = [
// Lazy-loaded page components
const Dashboard = lazy(() => import('./pages/Dashboard'))
const PostProduction = lazy(() => import('./pages/PostProduction'))
const PostDetail = lazy(() => import('./pages/PostDetail'))
const Assets = lazy(() => import('./pages/Assets'))
const Campaigns = lazy(() => import('./pages/Campaigns'))
const CampaignDetail = lazy(() => import('./pages/CampaignDetail'))
const Finance = lazy(() => import('./pages/Finance'))
const Budgets = lazy(() => import('./pages/Budgets'))
const Projects = lazy(() => import('./pages/Projects'))
const ProjectDetail = lazy(() => import('./pages/ProjectDetail'))
const Tasks = lazy(() => import('./pages/Tasks'))
const Team = lazy(() => import('./pages/Team'))
// Users page removed — unified into Team page
const Settings = lazy(() => import('./pages/Settings'))
const Brands = lazy(() => import('./pages/Brands'))
const Login = lazy(() => import('./pages/Login'))
const Artefacts = lazy(() => import('./pages/Artefacts'))
const PostCalendar = lazy(() => import('./pages/PostCalendar'))
const PublicReview = lazy(() => import('./pages/PublicReview'))
const PublicPostReview = lazy(() => import('./pages/PublicPostReview'))
const Issues = lazy(() => import('./pages/Issues'))
const PublicIssueSubmit = lazy(() => import('./pages/PublicIssueSubmit'))
const PublicIssueTracker = lazy(() => import('./pages/PublicIssueTracker'))
const Translations = lazy(() => import('./pages/Translations'))
const PublicTranslationReview = lazy(() => import('./pages/PublicTranslationReview'))
const PublicBudgetApproval = lazy(() => import('./pages/PublicBudgetApproval'))
const ForgotPassword = lazy(() => import('./pages/ForgotPassword'))
const ResetPassword = lazy(() => import('./pages/ResetPassword'))
// Permission levels (access control)
export const PERMISSION_LEVELS = [
{ value: 'superadmin', label: 'Super Admin' },
{ value: 'manager', label: 'Manager' },
{ value: 'approver', label: 'Approver' },
{ value: 'publisher', label: 'Publisher' },
{ value: 'content_creator', label: 'Content Creator' },
{ value: 'producer', label: 'Producer' },
{ value: 'designer', label: 'Designer' },
{ value: 'content_writer', label: 'Content Writer' },
{ value: 'social_media_manager', label: 'Social Media Manager' },
{ value: 'photographer', label: 'Photographer' },
{ value: 'videographer', label: 'Videographer' },
{ value: 'strategist', label: 'Strategist' },
{ value: 'contributor', label: 'Contributor' },
]
export const AppContext = createContext()
function AppContent() {
const { user, loading: authLoading, checkAuth, hasModule } = useAuth()
const { t, lang } = useLanguage()
const { t, lang, setLang } = useLanguage()
const [teamMembers, setTeamMembers] = useState([])
const [brands, setBrands] = useState([])
const [teams, setTeams] = useState([])
const [roles, setRoles] = useState([])
const [loading, setLoading] = useState(true)
const [showTutorial, setShowTutorial] = useState(false)
const [showProfilePrompt, setShowProfilePrompt] = useState(false)
@@ -59,6 +65,9 @@ function AppContent() {
const [profileForm, setProfileForm] = useState({ name: '', team_role: '', phone: '', brands: '' })
const [profileSaving, setProfileSaving] = useState(false)
// Keyboard shortcuts
useKeyboardShortcuts(DEFAULT_SHORTCUTS)
useEffect(() => {
if (user && !authLoading) {
loadInitialData()
@@ -87,7 +96,7 @@ function AppContent() {
const loadTeam = async () => {
try {
const data = await api.get('/users/team')
const members = Array.isArray(data) ? data : (data.data || [])
const members = Array.isArray(data) ? data : []
setTeamMembers(members)
return members
} catch (err) {
@@ -99,18 +108,28 @@ function AppContent() {
const loadTeams = async () => {
try {
const data = await api.get('/teams')
setTeams(Array.isArray(data) ? data : (data.data || []))
setTeams(Array.isArray(data) ? data : [])
} catch (err) {
console.error('Failed to load teams:', err)
}
}
const loadRoles = async () => {
try {
const data = await api.get('/roles')
setRoles(Array.isArray(data) ? data : [])
} catch (err) {
console.error('Failed to load roles:', err)
}
}
const loadInitialData = async () => {
try {
const [, brandsData] = await Promise.all([
loadTeam(),
api.get('/brands').then(d => Array.isArray(d) ? d : (d.data || [])).catch(() => []),
api.get('/brands').then(d => Array.isArray(d) ? d : []).catch(() => []),
loadTeams(),
loadRoles(),
])
setBrands(brandsData)
} catch (err) {
@@ -141,10 +160,10 @@ function AppContent() {
}
return (
<AppContext.Provider value={{ currentUser: user, teamMembers, brands, loadTeam, getBrandName, teams, loadTeams }}>
<AppContext.Provider value={{ currentUser: user, teamMembers, brands, loadTeam, getBrandName, teams, loadTeams, roles, loadRoles }}>
{/* Profile completion prompt */}
{showProfilePrompt && (
<div className="fixed top-4 right-4 z-50 bg-amber-50 border-2 border-amber-400 rounded-xl shadow-lg p-4 max-w-md animate-fade-in">
<div className="fixed top-4 end-4 z-50 bg-amber-50 border-2 border-amber-400 rounded-xl shadow-lg p-4 max-w-md animate-fade-in">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-full bg-amber-400 flex items-center justify-center text-white shrink-0">
@@ -200,17 +219,6 @@ function AppContent() {
placeholder={t('team.fullName')}
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('team.teamRole')}</label>
<select
value={profileForm.team_role}
onChange={e => setProfileForm(f => ({ ...f, team_role: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
<option value=""></option>
{TEAM_ROLES.map(r => <option key={r.value} value={r.value}>{r.label}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('team.phone')} {t('team.optional')}</label>
<input
@@ -221,14 +229,29 @@ function AppContent() {
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('team.brands')}</label>
<input
type="text"
value={profileForm.brands}
onChange={e => setProfileForm(f => ({ ...f, brands: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
placeholder={t('team.brandsHelp')}
/>
<label className="block text-sm font-medium text-text-primary mb-1">{t('settings.language')}</label>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => setLang('en')}
className={`p-3 rounded-lg border-2 text-center transition-all ${
lang === 'en' ? 'border-brand-primary bg-brand-primary/5' : 'border-border hover:border-brand-primary/30'
}`}
>
<div className="text-lg mb-1">EN</div>
<div className="text-xs font-medium text-text-primary">English</div>
</button>
<button
type="button"
onClick={() => setLang('ar')}
className={`p-3 rounded-lg border-2 text-center transition-all ${
lang === 'ar' ? 'border-brand-primary bg-brand-primary/5' : 'border-border hover:border-brand-primary/30'
}`}
>
<div className="text-lg mb-1">ع</div>
<div className="text-xs font-medium text-text-primary">العربية</div>
</button>
</div>
</div>
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
<button
@@ -241,15 +264,9 @@ function AppContent() {
onClick={async () => {
setProfileSaving(true)
try {
const brandsArr = profileForm.brands
.split(',')
.map(b => b.trim())
.filter(Boolean)
await api.patch('/users/me/profile', {
name: profileForm.name,
team_role: profileForm.team_role,
phone: profileForm.phone || null,
brands: brandsArr,
})
await checkAuth()
setShowProfileModal(false)
@@ -260,7 +277,7 @@ function AppContent() {
setProfileSaving(false)
}
}}
disabled={!profileForm.name || !profileForm.team_role || profileSaving}
disabled={!profileForm.name || profileSaving}
className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
>
{profileSaving ? t('common.loading') : t('team.saveProfile')}
@@ -272,14 +289,22 @@ function AppContent() {
{/* Tutorial overlay */}
{showTutorial && <Tutorial onComplete={handleTutorialComplete} />}
<ErrorBoundary>
<Suspense fallback={<div className="min-h-screen flex items-center justify-center"><div className="w-8 h-8 border-2 border-brand-primary/30 border-t-brand-primary rounded-full animate-spin" /></div>}>
<Routes>
<Route path="/login" element={user ? <Navigate to="/" replace /> : <Login />} />
<Route path="/forgot-password" element={user ? <Navigate to="/" replace /> : <ForgotPassword />} />
<Route path="/reset-password" element={user ? <Navigate to="/" replace /> : <ResetPassword />} />
<Route path="/review/:token" element={<PublicReview />} />
<Route path="/review-post/:token" element={<PublicPostReview />} />
<Route path="/submit-issue" element={<PublicIssueSubmit />} />
<Route path="/track/:token" element={<PublicIssueTracker />} />
<Route path="/review-translation/:token" element={<PublicTranslationReview />} />
<Route path="/approve-budget/:token" element={<PublicBudgetApproval />} />
<Route path="/" element={user ? <Layout /> : <Navigate to="/login" replace />}>
<Route index element={<Dashboard />} />
{hasModule('marketing') && <>
<Route path="posts/:id" element={<PostDetail />} />
<Route path="posts" element={<PostProduction />} />
<Route path="calendar" element={<PostCalendar />} />
<Route path="artefacts" element={<Artefacts />} />
@@ -287,6 +312,7 @@ function AppContent() {
<Route path="campaigns" element={<Campaigns />} />
<Route path="campaigns/:id" element={<CampaignDetail />} />
<Route path="brands" element={<Brands />} />
<Route path="translations" element={<Translations />} />
</>}
{hasModule('finance') && (user?.role === 'superadmin' || user?.role === 'manager') && <>
<Route path="finance" element={<Finance />} />
@@ -300,12 +326,11 @@ function AppContent() {
{hasModule('issues') && <Route path="issues" element={<Issues />} />}
<Route path="team" element={<Team />} />
<Route path="settings" element={<Settings />} />
{user?.role === 'superadmin' && (
<Route path="users" element={<Users />} />
)}
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
</ErrorBoundary>
</AppContext.Provider>
)
}
@@ -315,7 +340,9 @@ function App() {
<LanguageProvider>
<AuthProvider>
<ToastProvider>
<ThemeProvider>
<AppContent />
</ThemeProvider>
</ToastProvider>
</AuthProvider>
</LanguageProvider>
@@ -0,0 +1,123 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import { createPortal } from 'react-dom'
import { Check, ChevronDown, X } from 'lucide-react'
export default function ApproverMultiSelect({ users = [], selected = [], onChange }) {
const [open, setOpen] = useState(false)
const triggerRef = useRef(null)
const dropdownRef = useRef(null)
const [pos, setPos] = useState({ top: 0, left: 0, width: 0 })
const updatePosition = useCallback(() => {
if (!triggerRef.current) return
const rect = triggerRef.current.getBoundingClientRect()
const spaceBelow = window.innerHeight - rect.bottom
const dropdownHeight = Math.min(users.length * 40 + 8, 220)
const showAbove = spaceBelow < dropdownHeight && rect.top > dropdownHeight
setPos({
top: showAbove ? rect.top - dropdownHeight - 4 : rect.bottom + 4,
left: rect.left,
width: rect.width,
})
}, [users.length])
useEffect(() => {
if (!open) return
const handleClick = (e) => {
if (triggerRef.current?.contains(e.target)) return
if (dropdownRef.current?.contains(e.target)) return
setOpen(false)
}
const handleEsc = (e) => { if (e.key === 'Escape') setOpen(false) }
const handleScroll = () => updatePosition()
document.addEventListener('mousedown', handleClick)
document.addEventListener('keydown', handleEsc)
window.addEventListener('scroll', handleScroll, true)
return () => {
document.removeEventListener('mousedown', handleClick)
document.removeEventListener('keydown', handleEsc)
window.removeEventListener('scroll', handleScroll, true)
}
}, [open, updatePosition])
const handleOpen = () => {
updatePosition()
setOpen(!open)
}
const toggle = (userId) => {
const id = String(userId)
const next = selected.includes(id) ? selected.filter(s => s !== id) : [...selected, id]
onChange(next)
}
const remove = (id) => {
onChange(selected.filter(s => s !== String(id)))
}
const selectedUsers = selected.map(id => users.find(u => String(u._id || u.id || u.Id) === String(id))).filter(Boolean)
return (
<>
<div
ref={triggerRef}
onClick={handleOpen}
className={`w-full min-h-[38px] px-3 py-1.5 text-sm border rounded-lg bg-surface cursor-pointer flex items-center flex-wrap gap-1.5 transition-colors ${
open ? 'border-brand-primary ring-2 ring-brand-primary/20' : 'border-border'
}`}
>
{selectedUsers.length === 0 && (
<span className="text-text-tertiary">Select approvers...</span>
)}
{selectedUsers.map(u => (
<span
key={u._id || u.id || u.Id}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-amber-100 text-amber-800 text-xs font-medium"
>
{u.name}
<button
type="button"
onClick={e => { e.stopPropagation(); remove(u._id || u.id || u.Id) }}
className="hover:text-amber-950 transition-colors"
>
<X className="w-3 h-3" />
</button>
</span>
))}
<ChevronDown className={`w-4 h-4 text-text-tertiary ms-auto shrink-0 transition-transform ${open ? 'rotate-180' : ''}`} />
</div>
{open && createPortal(
<div
ref={dropdownRef}
className="fixed z-[99999] bg-surface border border-border rounded-lg shadow-lg max-h-[220px] overflow-y-auto"
style={{ top: pos.top, left: pos.left, width: pos.width }}
>
{users.map(u => {
const uid = String(u._id || u.id || u.Id)
const isSelected = selected.includes(uid)
return (
<button
key={uid}
type="button"
onClick={() => toggle(uid)}
className={`w-full text-start px-3 py-2 text-sm hover:bg-surface-secondary flex items-center justify-between transition-colors ${
isSelected ? 'text-brand-primary font-medium' : 'text-text-primary'
}`}
>
<span>{u.name}</span>
{isSelected && <Check className="w-3.5 h-3.5" />}
</button>
)
})}
{users.length === 0 && (
<div className="px-3 py-4 text-sm text-text-tertiary text-center">No users available</div>
)}
</div>,
document.body
)}
</>
)
}
@@ -0,0 +1,576 @@
import { useState, useEffect, useContext } from 'react'
import { Copy, Check, ExternalLink, Trash2, FileText, Image as ImageIcon, Film, Sparkles, MessageSquare, Save, FileEdit, Layers, ShieldCheck } from 'lucide-react'
import { AppContext } from '../App'
import { useLanguage } from '../i18n/LanguageContext'
import { api } from '../utils/api'
import Modal from './Modal'
import TabbedModal from './TabbedModal'
import { useToast } from './ToastContainer'
import PortalSelect from './PortalSelect'
import { ArtefactDetailVersionsTab } from './ArtefactDetailVersionsTab'
const STATUS_COLORS = {
draft: 'bg-surface-tertiary text-text-secondary',
pending_review: 'bg-amber-100 text-amber-700',
approved: 'bg-emerald-100 text-emerald-700',
rejected: 'bg-red-100 text-red-700',
revision_requested: 'bg-orange-100 text-orange-700',
}
const TYPE_ICONS = {
copy: FileText,
design: ImageIcon,
video: Film,
other: Sparkles,
}
const parseApproverIds = (a) =>
a.approvers?.map(u => String(u.id)) ||
(a.approver_ids ? a.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : [])
export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDelete, assignableUsers = [] }) {
const { t } = useLanguage()
const { brands } = useContext(AppContext)
const toast = useToast()
const [versions, setVersions] = useState([])
const [selectedVersion, setSelectedVersion] = useState(null)
const [versionData, setVersionData] = useState(null)
const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false)
const [freshReviewUrl, setFreshReviewUrl] = useState('')
const [copied, setCopied] = useState(false)
const [activeTab, setActiveTab] = useState(artefact.type === 'copy' ? 'versions' : 'details')
// Editable fields — seeded from artefact prop; component is keyed by artefact._id at call site
const [editTitle, setEditTitle] = useState(artefact.title || '')
const [editDescription, setEditDescription] = useState(artefact.description || '')
const [editApproverIds, setEditApproverIds] = useState(() => parseApproverIds(artefact))
const reviewUrl = freshReviewUrl || (artefact.approval_token ? `${window.location.origin}/review/${artefact.approval_token}` : '')
const [savingDraft, setSavingDraft] = useState(false)
const [deleting, setDeleting] = useState(false)
const [showDeleteArtefactConfirm, setShowDeleteArtefactConfirm] = useState(false)
// File upload (for design/video)
const [uploading, setUploading] = useState(false)
const [uploadProgress, setUploadProgress] = useState(0)
// Comments
const [comments, setComments] = useState([])
const [newComment, setNewComment] = useState('')
const [addingComment, setAddingComment] = useState(false)
useEffect(() => {
loadVersions()
}, [artefact.Id])
const loadVersions = async () => {
try {
const res = await api.get(`/artefacts/${artefact.Id}/versions`)
const versionsList = Array.isArray(res) ? res : []
setVersions(versionsList)
// Select latest version by default
if (versionsList.length > 0) {
const latest = versionsList[versionsList.length - 1]
setSelectedVersion(latest)
loadVersionData(latest.Id)
}
} catch (err) {
console.error('Failed to load versions:', err)
toast.error(t('artefacts.failedLoadVersions'))
} finally {
setLoading(false)
}
}
const loadVersionData = async (versionId) => {
try {
const [versionRes, commentsRes] = await Promise.all([
api.get(`/artefacts/${artefact.Id}/versions/${versionId}`),
api.get(`/artefacts/${artefact.Id}/versions/${versionId}/comments`),
])
setVersionData(versionRes.data || versionRes)
setComments(commentsRes.data || commentsRes || [])
} catch (err) {
console.error('Failed to load version data:', err)
toast.error(t('artefacts.failedLoadVersionData'))
}
}
const handleSelectVersion = (version) => {
setSelectedVersion(version)
loadVersionData(version.Id)
}
const handleAddLanguage = async (languageForm) => {
await api.post(`/artefacts/${artefact.Id}/versions/${selectedVersion.Id}/texts`, languageForm)
toast.success(t('artefacts.languageAdded'))
loadVersionData(selectedVersion.Id)
}
const handleDeleteLanguage = async (textId) => {
await api.delete(`/artefact-version-texts/${textId}`)
toast.success(t('artefacts.languageDeleted'))
loadVersionData(selectedVersion.Id)
}
const handleFileUpload = async (fileOrEvent) => {
const file = fileOrEvent instanceof File ? fileOrEvent : fileOrEvent.target?.files?.[0]
if (!file) return
setUploading(true)
setUploadProgress(0)
try {
const formData = new FormData()
formData.append('file', file)
await api.upload(`/artefacts/${artefact.Id}/versions/${selectedVersion.Id}/attachments`, formData, {
onUploadProgress: (e) => {
if (e.total) setUploadProgress(Math.round((e.loaded / e.total) * 100))
}
})
toast.success(t('artefacts.fileUploaded'))
loadVersionData(selectedVersion.Id)
} catch (err) {
console.error('Upload failed:', err)
toast.error(t('artefacts.uploadFailed'))
} finally {
setUploading(false)
setUploadProgress(0)
}
}
const handleAddDriveVideo = async (driveUrl) => {
if (!driveUrl.trim()) {
toast.error(t('artefacts.enterDriveUrl'))
return
}
setUploading(true)
try {
await api.post(`/artefacts/${artefact.Id}/versions/${selectedVersion.Id}/attachments`, {
drive_url: driveUrl,
})
toast.success(t('artefacts.videoLinkAdded'))
loadVersionData(selectedVersion.Id)
} catch (err) {
console.error('Add Drive link failed:', err)
toast.error(t('artefacts.failedAddVideoLink'))
} finally {
setUploading(false)
}
}
const handleDeleteAttachment = async (attId) => {
await api.delete(`/artefact-attachments/${attId}`)
toast.success(t('artefacts.attachmentDeleted'))
loadVersionData(selectedVersion.Id)
}
const handleSubmitReview = async () => {
setSubmitting(true)
try {
const res = await api.post(`/artefacts/${artefact.Id}/submit-review`)
setFreshReviewUrl(res.reviewUrl || res.data?.reviewUrl || '')
toast.success(t('artefacts.submittedForReview'))
onUpdate()
} catch (err) {
toast.error(t('artefacts.failedSubmitReview'))
} finally {
setSubmitting(false)
}
}
const copyReviewLink = () => {
navigator.clipboard.writeText(reviewUrl)
setCopied(true)
toast.success(t('artefacts.linkCopied'))
setTimeout(() => setCopied(false), 2000)
}
const handleAddComment = async () => {
if (!newComment.trim()) return
setAddingComment(true)
try {
await api.post(`/artefacts/${artefact.Id}/versions/${selectedVersion.Id}/comments`, {
content: newComment.trim(),
})
toast.success(t('artefacts.commentAdded'))
setNewComment('')
loadVersionData(selectedVersion.Id)
} catch (err) {
toast.error(t('artefacts.failedAddComment'))
} finally {
setAddingComment(false)
}
}
const handleUpdateField = async (field, value) => {
try {
await api.patch(`/artefacts/${artefact.Id}`, { [field]: value || null })
toast.success(t('artefacts.updated'))
onUpdate()
} catch (err) {
toast.error(t('artefacts.failedUpdate'))
}
}
const handleSaveDraft = async () => {
if (!editTitle.trim()) {
toast.error(t('artefacts.titleRequired'))
return
}
setSavingDraft(true)
try {
await api.patch(`/artefacts/${artefact.Id}`, {
title: editTitle.trim(),
description: editDescription.trim() || null,
})
toast.success(t('artefacts.draftSaved'))
onUpdate()
} catch (err) {
toast.error(t('artefacts.failedSaveDraft'))
} finally {
setSavingDraft(false)
}
}
const handleUpdateLanguage = async (textId, content) => {
await api.patch(`/artefact-version-texts/${textId}`, { content })
toast.success(t('artefacts.languageAdded'))
loadVersionData(selectedVersion.Id)
}
const handleDeleteArtefact = async () => {
setDeleting(true)
try {
await onDelete(artefact.Id || artefact.id || artefact._id)
} catch (err) {
toast.error(t('artefacts.failedDelete'))
setDeleting(false)
}
}
const extractDriveFileId = (url) => {
const patterns = [
/\/file\/d\/([^\/]+)/,
/id=([^&]+)/,
/\/d\/([^\/]+)/,
]
for (const pattern of patterns) {
const match = url.match(pattern)
if (match) return match[1]
}
return null
}
const getDriveEmbedUrl = (url) => {
const fileId = extractDriveFileId(url)
return fileId ? `https://drive.google.com/file/d/${fileId}/preview` : url
}
const TypeIcon = TYPE_ICONS[artefact.type] || Sparkles
const tabs = [
{ key: 'details', label: t('artefacts.details'), icon: FileEdit },
{ key: 'versions', label: t('artefacts.versions'), icon: Layers, badge: versions.length },
{ key: 'discussion', label: t('artefacts.comments'), icon: MessageSquare, badge: comments.length },
{ key: 'review', label: t('artefacts.review'), icon: ShieldCheck },
]
if (loading) {
return (
<TabbedModal onClose={onClose} size="xl">
<div className="flex items-center justify-center h-64">
<div className="w-8 h-8 border-4 border-brand-primary border-t-transparent rounded-full animate-spin"></div>
</div>
</TabbedModal>
)
}
return (
<>
<TabbedModal
onClose={onClose}
size="xl"
header={
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-lg bg-brand-primary/10 flex items-center justify-center shrink-0">
<TypeIcon className="w-5 h-5 text-brand-primary" />
</div>
<div className="flex-1 min-w-0">
<input
type="text"
value={editTitle}
onChange={e => setEditTitle(e.target.value)}
className="w-full text-lg font-semibold text-text-primary bg-transparent border-0 border-b border-transparent hover:border-border focus:border-brand-primary focus:outline-none focus:ring-0 px-0 py-0.5 transition-colors"
/>
<div className="flex items-center gap-2 mt-1 flex-wrap">
<span className={`text-xs px-2 py-0.5 rounded-full ${STATUS_COLORS[artefact.status] || 'bg-surface-tertiary text-text-secondary'}`}>
{artefact.status?.replace('_', ' ')}
</span>
<span className="text-xs text-text-tertiary capitalize">{artefact.type}</span>
{artefact.creator_name && (
<span className="text-xs text-text-secondary font-medium">
{t('review.createdBy')} <strong className="text-text-primary">{artefact.creator_name}</strong>
</span>
)}
</div>
</div>
</div>
}
tabs={tabs}
activeTab={activeTab}
onTabChange={setActiveTab}
footer={
<>
<div>
{onDelete && (
<button
onClick={() => setShowDeleteArtefactConfirm(true)}
disabled={deleting}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-red-600 hover:bg-red-50 rounded-lg transition-colors"
title={t('artefacts.deleteArtefactTooltip')}
>
<Trash2 className="w-3.5 h-3.5" />
{t('common.delete')}
</button>
)}
</div>
{activeTab === 'details' && (
<button
onClick={handleSaveDraft}
disabled={savingDraft}
className="flex items-center gap-1.5 px-4 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light disabled:opacity-50 transition-colors"
title={t('artefacts.saveDraftTooltip')}
>
<Save className="w-3.5 h-3.5" />
{savingDraft ? t('artefacts.savingDraft') : t('artefacts.saveDraft')}
</button>
)}
</>
}
>
{/* Details Tab */}
{activeTab === 'details' && (
<div className="p-6 space-y-5">
{/* Description */}
<div>
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-2">{t('artefacts.descriptionLabel')}</h4>
<textarea
value={editDescription}
onChange={e => setEditDescription(e.target.value)}
rows={3}
className="w-full px-3 py-2 text-sm text-text-secondary bg-surface-secondary border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary resize-none"
placeholder={t('artefacts.descriptionFieldPlaceholder')}
/>
</div>
{/* Metadata row */}
<div className="grid grid-cols-2 gap-4 pt-1">
{/* Brand */}
{(artefact.brand_id || artefact.brandId) && (
<div>
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1">{t('posts.brand')}</h4>
<p className="text-sm text-text-primary">
{brands.find(b => String(b._id) === String(artefact.brand_id || artefact.brandId))?.name || `#${artefact.brand_id || artefact.brandId}`}
</p>
</div>
)}
{/* Created date */}
{artefact.CreatedAt && (
<div>
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1">{t('common.created')}</h4>
<p className="text-sm text-text-secondary">{new Date(artefact.CreatedAt).toLocaleDateString()}</p>
</div>
)}
{/* Linked post */}
{(artefact.post_id || artefact.postId) && (
<div>
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1">{t('artefacts.linkedPost')}</h4>
<p className="text-sm text-text-secondary">{t('artefacts.post')} #{artefact.post_id || artefact.postId}</p>
</div>
)}
</div>
</div>
)}
{/* Versions Tab */}
{activeTab === 'versions' && (
<ArtefactDetailVersionsTab
artefact={artefact}
versions={versions}
selectedVersion={selectedVersion}
versionData={versionData}
uploading={uploading}
uploadProgress={uploadProgress}
onSelectVersion={handleSelectVersion}
onAddLanguage={handleAddLanguage}
onUpdateLanguage={handleUpdateLanguage}
onDeleteLanguage={handleDeleteLanguage}
onFileUpload={handleFileUpload}
onDeleteAttachment={handleDeleteAttachment}
onAddDriveVideo={handleAddDriveVideo}
getDriveEmbedUrl={getDriveEmbedUrl}
/>
)}
{/* Discussion Tab */}
{activeTab === 'discussion' && (
<div className="p-6 space-y-5">
{selectedVersion ? (
<>
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-3">
{t('artefacts.comments')} ({comments.length})
</h4>
<div className="space-y-3 mb-4">
{comments.map(comment => (
<div key={comment.Id} className="flex gap-3">
<div className="w-8 h-8 rounded-full bg-brand-primary/10 flex items-center justify-center shrink-0">
{comment.user_avatar ? (
<img src={comment.user_avatar} alt="" className="w-full h-full rounded-full object-cover" />
) : (
<MessageSquare className="w-4 h-4 text-brand-primary" />
)}
</div>
<div className="flex-1 bg-surface-secondary rounded-lg p-3">
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-medium text-text-primary">{comment.user_name || 'Anonymous'}</span>
<span className="text-xs text-text-tertiary">
{new Date(comment.CreatedAt).toLocaleString()}
</span>
</div>
<p className="text-sm text-text-secondary whitespace-pre-wrap">{comment.content}</p>
</div>
</div>
))}
</div>
<div className="flex gap-2">
<input
type="text"
value={newComment}
onChange={e => setNewComment(e.target.value)}
onKeyPress={e => e.key === 'Enter' && handleAddComment()}
placeholder={t('artefacts.addCommentPlaceholder')}
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
/>
<button
onClick={handleAddComment}
disabled={addingComment || !newComment.trim()}
className="px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 transition-colors"
>
{t('artefacts.sendComment')}
</button>
</div>
</>
) : (
<div className="text-center py-8 text-sm text-text-tertiary">
{t('artefacts.selectVersionFirst')}
</div>
)}
</div>
)}
{/* Review Tab */}
{activeTab === 'review' && (
<div className="p-6 space-y-5">
{/* Reviewer Selection (single) */}
{['draft', 'revision_requested', 'rejected'].includes(artefact.status) && (
<div>
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1.5">{t('artefacts.reviewer')}</h4>
<PortalSelect
value={editApproverIds[0] || ''}
onChange={val => {
const ids = val ? [val] : []
setEditApproverIds(ids)
handleUpdateField('approver_ids', val || '')
}}
options={[{ value: '', label: t('artefacts.selectReviewer') }, ...assignableUsers.map(u => ({ value: u.id || u.Id, label: u.name }))]}
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
/>
</div>
)}
{/* Submit for Review */}
{['draft', 'revision_requested', 'rejected'].includes(artefact.status) && (
<button
onClick={handleSubmitReview}
disabled={submitting || editApproverIds.length === 0}
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-amber-500 text-white rounded-lg hover:bg-amber-600 transition-colors font-medium disabled:opacity-50"
>
<ExternalLink className="w-4 h-4" />
{submitting ? t('artefacts.submitting') : t('artefacts.submitForReview')}
</button>
)}
{/* Review Link */}
{reviewUrl && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="text-sm font-semibold text-blue-900 mb-2">{t('artefacts.reviewLinkTitle')}</div>
<div className="flex items-center gap-2">
<input
type="text"
value={reviewUrl}
readOnly
className="flex-1 px-3 py-2 text-sm bg-surface border border-border rounded"
/>
<button
onClick={copyReviewLink}
className="p-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
>
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
</button>
</div>
</div>
)}
{/* Feedback */}
{artefact.feedback && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
<h4 className="text-sm font-semibold text-amber-900 mb-2">{t('artefacts.feedbackTitle')}</h4>
<p className="text-sm text-amber-800 whitespace-pre-wrap">{artefact.feedback}</p>
</div>
)}
{/* Approval Info */}
{artefact.status === 'approved' && artefact.approved_by_name && (
<div className="bg-emerald-50 border border-emerald-200 rounded-lg p-4">
<div className="font-medium text-emerald-900">{t('artefacts.approvedByLabel')} {artefact.approved_by_name}</div>
{artefact.approved_at && (
<div className="text-sm text-emerald-700 mt-1">
{new Date(artefact.approved_at).toLocaleString()}
</div>
)}
</div>
)}
{/* Empty state: pending_review or unknown status with no review info */}
{artefact.status === 'pending_review' && !reviewUrl && !artefact.feedback && (
<div className="text-center py-8 text-sm text-text-tertiary">
{t('artefacts.pendingReviewInfo')}
</div>
)}
</div>
)}
</TabbedModal>
{/* Delete Artefact Confirmation */}
<Modal
isOpen={showDeleteArtefactConfirm}
onClose={() => setShowDeleteArtefactConfirm(false)}
title={t('artefacts.deleteArtefact')}
isConfirm
danger
onConfirm={handleDeleteArtefact}
confirmText={t('common.delete')}
>
{t('artefacts.deleteArtefactDesc')}
</Modal>
</>
)
}
@@ -0,0 +1,398 @@
import { useState } from 'react'
import { Trash2, Globe, Image as ImageIcon, Pencil } from 'lucide-react'
import PortalSelect from './PortalSelect'
import UploadZone from './UploadZone'
import { useLanguage } from '../i18n/LanguageContext'
import Modal from './Modal'
import ArtefactVersionTimeline from './ArtefactVersionTimeline'
const AVAILABLE_LANGUAGES = [
{ code: 'AR', label: '\u0627\u0644\u0639\u0631\u0628\u064A\u0629' },
{ code: 'EN', label: 'English' },
{ code: 'FR', label: 'Fran\u00E7ais' },
{ code: 'ID', label: 'Bahasa Indonesia' },
]
export function ArtefactDetailVersionsTab({
artefact,
versions,
selectedVersion,
versionData,
uploading,
uploadProgress,
onSelectVersion,
onAddLanguage,
onUpdateLanguage,
onDeleteLanguage,
onFileUpload,
onDeleteAttachment,
onAddDriveVideo,
getDriveEmbedUrl,
}) {
const { t } = useLanguage()
const [showLanguageModal, setShowLanguageModal] = useState(false)
const [languageForm, setLanguageForm] = useState({ language_code: '', language_label: '', content: '' })
const [savingLanguage, setSavingLanguage] = useState(false)
const [confirmDeleteLangId, setConfirmDeleteLangId] = useState(null)
const [confirmDeleteAttId, setConfirmDeleteAttId] = useState(null)
const [editingLangText, setEditingLangText] = useState(null) // { Id, language_code, language_label, content }
const [editLangContent, setEditLangContent] = useState('')
const [savingEditLang, setSavingEditLang] = useState(false)
const [dragOver, setDragOver] = useState(false)
const [driveUrl, setDriveUrl] = useState('')
const handleAddLanguage = async () => {
if (!languageForm.language_code || !languageForm.language_label || !languageForm.content) return
setSavingLanguage(true)
try {
await onAddLanguage(languageForm)
setShowLanguageModal(false)
setLanguageForm({ language_code: '', language_label: '', content: '' })
} finally {
setSavingLanguage(false)
}
}
const handleDeleteLanguage = async (textId) => {
await onDeleteLanguage(textId)
setConfirmDeleteLangId(null)
}
const handleDeleteAttachment = async (attId) => {
await onDeleteAttachment(attId)
setConfirmDeleteAttId(null)
}
const handleSaveEditLang = async () => {
if (!editingLangText || !editLangContent.trim()) return
setSavingEditLang(true)
try {
await onUpdateLanguage(editingLangText.Id, editLangContent)
setEditingLangText(null)
} finally {
setSavingEditLang(false)
}
}
const handleVideoDrop = (e) => {
e.preventDefault()
setDragOver(false)
const file = e.dataTransfer.files?.[0]
if (file && file.type.startsWith('video/')) {
onFileUpload(file)
}
}
const handleAddDriveVideo = async () => {
if (!driveUrl.trim()) return
await onAddDriveVideo(driveUrl)
setDriveUrl('')
}
return (
<>
<div className="p-6 space-y-5">
{/* Version Timeline — only shown when there are multiple rounds */}
{versions.length > 1 && (
<div>
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-3">{t('artefacts.versions')}</h4>
<ArtefactVersionTimeline
versions={versions}
activeVersionId={selectedVersion?.Id}
onSelectVersion={onSelectVersion}
artefactType={artefact.type}
/>
</div>
)}
{/* Type-specific content */}
{versionData && selectedVersion && (
<div className="border-t border-border pt-5">
{/* COPY TYPE: Language entries */}
{artefact.type === 'copy' && (
<div>
<div className="flex items-center justify-between mb-3">
<h4 className="text-xs font-semibold text-text-tertiary uppercase">{t('artefacts.languages')}</h4>
<button
onClick={() => setShowLanguageModal(true)}
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors"
>
<Globe className="w-3 h-3" />
{t('artefacts.addLanguage')}
</button>
</div>
{versionData.texts && versionData.texts.length > 0 ? (
<div className="space-y-3">
{versionData.texts.map(text => (
<div key={text.Id} className="bg-surface-secondary rounded-lg p-4 border border-border">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
<span className="px-2 py-0.5 bg-surface border border-border rounded text-xs font-mono font-medium">
{text.language_code}
</span>
<span className="text-sm font-medium text-text-primary">{text.language_label}</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => { setEditingLangText(text); setEditLangContent(text.content || '') }}
className="p-1 text-text-tertiary hover:text-brand-primary rounded transition-colors"
title={t('common.edit')}
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button
onClick={() => setConfirmDeleteLangId(text.Id)}
className="p-1 text-red-500 hover:text-red-700 rounded transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
<div className="bg-surface rounded border border-border p-3 text-sm text-text-primary whitespace-pre-wrap font-sans">
{text.content}
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8 bg-surface-secondary rounded-lg border-2 border-dashed border-border">
<Globe className="w-8 h-8 text-text-tertiary mx-auto mb-2" />
<p className="text-sm text-text-secondary">{t('artefacts.noLanguages')}</p>
</div>
)}
</div>
)}
{/* DESIGN TYPE: Image gallery */}
{artefact.type === 'design' && (
<div>
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-3">{t('artefacts.imagesLabel')}</h4>
{versionData.attachments && versionData.attachments.length > 0 && (
<div className="grid grid-cols-2 gap-3 mb-3">
{versionData.attachments.map(att => (
<div key={att.Id} className="relative group">
<img
src={att.url}
alt={att.original_name}
className="w-full h-48 object-cover rounded-lg border border-border"
loading="lazy"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors rounded-lg flex items-center justify-center">
<button
onClick={() => setConfirmDeleteAttId(att.Id)}
className="opacity-0 group-hover:opacity-100 transition-opacity px-3 py-2 bg-red-600 text-white rounded-lg text-xs font-medium hover:bg-red-700"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
<div className="mt-1 px-2 py-1 bg-surface-secondary rounded text-xs text-text-secondary truncate">
{att.original_name}
</div>
</div>
))}
</div>
)}
<UploadZone
onUpload={onFileUpload}
accept="image/*"
uploading={uploading}
progress={uploadProgress}
label={t('artefacts.dropOrClickImage') || 'Drop images here or click to upload'}
hint={t('artefacts.imageFormats') || 'PNG, JPG, WebP'}
compact={versionData.attachments?.length > 0}
/>
</div>
)}
{/* VIDEO TYPE: Files and Drive links -- all inline */}
{artefact.type === 'video' && (
<div>
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-3">{t('artefacts.videosLabel')}</h4>
{/* Existing attachments */}
{versionData.attachments && versionData.attachments.length > 0 && (
<div className="space-y-3 mb-4">
{versionData.attachments.map(att => (
<div key={att.Id} className="bg-surface-secondary rounded-lg p-4 border border-border">
{att.drive_url ? (
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-text-primary">{t('artefacts.googleDriveVideo')}</span>
<button onClick={() => setConfirmDeleteAttId(att.Id)} className="text-red-600 hover:text-red-700">
<Trash2 className="w-4 h-4" />
</button>
</div>
<iframe src={getDriveEmbedUrl(att.drive_url)} className="w-full h-64 rounded border border-border" allow="autoplay" />
</div>
) : (
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-text-primary truncate">{att.original_name}</span>
<button onClick={() => setConfirmDeleteAttId(att.Id)} className="text-red-600 hover:text-red-700">
<Trash2 className="w-4 h-4" />
</button>
</div>
<video src={att.url} controls className="w-full rounded border border-border" />
</div>
)}
</div>
))}
</div>
)}
{/* Drag-and-drop / click-to-upload zone */}
<UploadZone
onUpload={onFileUpload}
accept="video/*"
uploading={uploading}
progress={uploadProgress}
label={t('artefacts.dropOrClickVideo')}
hint={t('artefacts.videoFormats')}
/>
{/* Google Drive URL inline input */}
<div className="flex items-center gap-2 mt-3">
<Globe className="w-4 h-4 text-text-tertiary shrink-0" />
<input
type="text"
value={driveUrl}
onChange={e => setDriveUrl(e.target.value)}
placeholder="https://drive.google.com/file/d/..."
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
onKeyDown={e => { if (e.key === 'Enter' && driveUrl.trim()) handleAddDriveVideo() }}
/>
<button
onClick={handleAddDriveVideo}
disabled={uploading || !driveUrl.trim()}
className="px-3 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 shrink-0"
>
{t('artefacts.addLink')}
</button>
</div>
</div>
)}
</div>
)}
</div>
{/* Language Modal */}
<Modal isOpen={showLanguageModal} onClose={() => setShowLanguageModal(false)} title={t('artefacts.addLanguage')} size="md">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('artefacts.languageLabel')} *</label>
<PortalSelect
value={languageForm.language_code}
onChange={val => {
const lang = AVAILABLE_LANGUAGES.find(l => l.code === val)
if (lang) setLanguageForm(f => ({ ...f, language_code: lang.code, language_label: lang.label }))
else setLanguageForm(f => ({ ...f, language_code: '', language_label: '' }))
}}
options={[
{ value: '', label: t('artefacts.selectLanguage') },
...AVAILABLE_LANGUAGES
.filter(lang => !(versionData?.texts || []).some(t => t.language_code === lang.code))
.map(lang => ({ value: lang.code, label: `${lang.label} (${lang.code})` }))
]}
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('artefacts.contentLabel')} *</label>
<textarea
value={languageForm.content}
onChange={e => setLanguageForm(f => ({ ...f, content: e.target.value }))}
rows={8}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 font-sans"
placeholder={t('artefacts.enterContent')}
/>
</div>
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
<button
onClick={() => setShowLanguageModal(false)}
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
>
{t('common.cancel')}
</button>
<button
onClick={handleAddLanguage}
disabled={savingLanguage}
className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 shadow-sm"
>
{savingLanguage ? t('header.saving') : t('common.save')}
</button>
</div>
</div>
</Modal>
{/* Edit Language Modal */}
<Modal isOpen={!!editingLangText} onClose={() => setEditingLangText(null)} title={t('artefacts.editLanguage')} size="md">
<div className="space-y-4">
{editingLangText && (
<div className="flex items-center gap-2">
<span className="px-2 py-0.5 bg-surface border border-border rounded text-xs font-mono font-medium">
{editingLangText.language_code}
</span>
<span className="text-sm font-medium text-text-primary">{editingLangText.language_label}</span>
</div>
)}
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('artefacts.contentLabel')}</label>
<textarea
value={editLangContent}
onChange={e => setEditLangContent(e.target.value)}
rows={8}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 font-sans"
/>
</div>
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
<button
onClick={() => setEditingLangText(null)}
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
>
{t('common.cancel')}
</button>
<button
onClick={handleSaveEditLang}
disabled={savingEditLang}
className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 shadow-sm"
>
{savingEditLang ? t('header.saving') : t('common.save')}
</button>
</div>
</div>
</Modal>
{/* Delete Language Confirmation */}
<Modal
isOpen={!!confirmDeleteLangId}
onClose={() => setConfirmDeleteLangId(null)}
title={t('artefacts.deleteLanguage')}
isConfirm
danger
onConfirm={() => handleDeleteLanguage(confirmDeleteLangId)}
confirmText={t('common.delete')}
>
{t('artefacts.deleteLanguageDesc')}
</Modal>
{/* Delete Attachment Confirmation */}
<Modal
isOpen={!!confirmDeleteAttId}
onClose={() => setConfirmDeleteAttId(null)}
title={t('artefacts.deleteAttachment')}
isConfirm
danger
onConfirm={() => handleDeleteAttachment(confirmDeleteAttId)}
confirmText={t('common.delete')}
>
{t('artefacts.deleteAttachmentDesc')}
</Modal>
</>
)
}
@@ -19,7 +19,7 @@ export default function ArtefactVersionTimeline({ versions, activeVersionId, onS
<button
key={version.Id}
onClick={() => onSelectVersion(version)}
className={`w-full text-left p-3 rounded-lg border transition-colors ${
className={`w-full text-start p-3 rounded-lg border transition-colors ${
isActive
? 'border-brand-primary bg-brand-primary/5'
: 'border-border hover:border-brand-primary/30 bg-surface hover:shadow-sm'
@@ -80,11 +80,12 @@ export default function ArtefactVersionTimeline({ versions, activeVersionId, onS
{/* Thumbnail for image artefacts */}
{artefactType === 'design' && version.thumbnail && (
<div className="mt-2 ml-11">
<div className="mt-2 ms-11">
<img
src={version.thumbnail}
alt={`Version ${version.version_number}`}
className="w-full h-20 object-cover rounded border border-border"
loading="lazy"
/>
</div>
)}
+2 -1
View File
@@ -24,7 +24,7 @@ export default function AssetCard({ asset, onClick }) {
return (
<div
onClick={() => onClick?.(asset)}
className="bg-white rounded-xl border border-border overflow-hidden card-hover cursor-pointer group"
className="bg-surface rounded-xl border border-border overflow-clip card-hover cursor-pointer group"
>
{/* Thumbnail */}
<div className="aspect-square bg-surface-tertiary flex items-center justify-center overflow-hidden relative">
@@ -33,6 +33,7 @@ export default function AssetCard({ asset, onClick }) {
src={asset.url}
alt={asset.name}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
loading="lazy"
onError={(e) => {
e.target.style.display = 'none'
e.target.nextSibling.style.display = 'flex'
+30
View File
@@ -0,0 +1,30 @@
import { Trash2, X } from 'lucide-react'
import { useLanguage } from '../i18n/LanguageContext'
export default function BulkSelectBar({ selectedCount, onDelete, onClear }) {
const { t } = useLanguage()
if (selectedCount === 0) return null
return (
<div className="flex items-center justify-between px-4 py-2.5 bg-red-50 border border-red-200 rounded-lg animate-fade-in">
<span className="text-sm font-medium text-red-800">
{selectedCount} {t('common.selected')}
</span>
<div className="flex items-center gap-2">
<button
onClick={onClear}
className="text-xs text-text-tertiary hover:text-text-primary transition-colors"
>
{t('common.clearSelection')}
</button>
<button
onClick={onDelete}
className="flex items-center gap-1.5 px-3 py-1.5 bg-red-600 text-white rounded-lg text-xs font-medium hover:bg-red-700 transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
{t('common.deleteSelected')}
</button>
</div>
</div>
)
}
+3 -3
View File
@@ -41,7 +41,7 @@ export default function CampaignCalendar({ campaigns = [] }) {
}
return (
<div className="bg-white rounded-xl border border-border overflow-hidden">
<div className="bg-surface rounded-xl border border-border overflow-clip">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<h3 className="text-lg font-semibold text-text-primary">
@@ -109,8 +109,8 @@ export default function CampaignCalendar({ campaigns = [] }) {
<div
key={campaign._id || ci}
className={`h-5 text-[10px] font-medium text-white flex items-center px-1 truncate ${CAMPAIGN_COLORS[colorIndex]} ${
isStart ? 'rounded-l-full ml-0' : '-ml-1'
} ${isEnd ? 'rounded-r-full mr-0' : '-mr-1'}`}
isStart ? 'rounded-l-full ms-0' : '-ms-1'
} ${isEnd ? 'rounded-r-full me-0' : '-me-1'}`}
title={campaign.name}
>
{isStart ? campaign.name : ''}
+88 -71
View File
@@ -1,19 +1,22 @@
import { useState, useEffect } from 'react'
import { X, Trash2, DollarSign, Eye, MousePointer, Target } from 'lucide-react'
import { useState, useEffect, useContext } from 'react'
import { Trash2, DollarSign, Eye, MousePointer, Target, FileEdit, BarChart3, MessageSquare } from 'lucide-react'
import { useLanguage } from '../i18n/LanguageContext'
import { PLATFORMS, getBrandColor } from '../utils/api'
import CommentsSection from './CommentsSection'
import Modal from './Modal'
import SlidePanel from './SlidePanel'
import CollapsibleSection from './CollapsibleSection'
import TabbedModal from './TabbedModal'
import BudgetBar from './BudgetBar'
import PortalSelect from './PortalSelect'
import { AppContext } from '../App'
export default function CampaignDetailPanel({ campaign, onClose, onSave, onDelete, brands, permissions }) {
const { t, lang, currencySymbol } = useLanguage()
const { teams } = useContext(AppContext)
const [form, setForm] = useState({})
const [dirty, setDirty] = useState(false)
const [saving, setSaving] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [activeTab, setActiveTab] = useState('details')
const campaignId = campaign?._id || campaign?.id
const isCreateMode = !campaignId
@@ -24,6 +27,7 @@ export default function CampaignDetailPanel({ campaign, onClose, onSave, onDelet
name: campaign.name || '',
description: campaign.description || '',
brand_id: campaign.brandId || campaign.brand_id || '',
team_id: campaign.team_id || '',
status: campaign.status || 'planning',
start_date: campaign.startDate ? new Date(campaign.startDate).toISOString().slice(0, 10) : (campaign.start_date || ''),
end_date: campaign.endDate ? new Date(campaign.endDate).toISOString().slice(0, 10) : (campaign.end_date || ''),
@@ -63,6 +67,7 @@ export default function CampaignDetailPanel({ campaign, onClose, onSave, onDelet
name: form.name,
description: form.description,
brand_id: form.brand_id ? Number(form.brand_id) : null,
team_id: form.team_id ? Number(form.team_id) : null,
status: form.status,
start_date: form.start_date,
end_date: form.end_date,
@@ -98,10 +103,21 @@ export default function CampaignDetailPanel({ campaign, onClose, onSave, onDelet
return campaign.brand_name || campaign.brandName || null
})()
const header = (
<div className="px-5 py-4 border-b border-border shrink-0">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
const tabs = isCreateMode
? [{ key: 'details', label: t('campaigns.details'), icon: FileEdit }]
: [
{ key: 'details', label: t('campaigns.details'), icon: FileEdit },
{ key: 'performance', label: t('campaigns.performance'), icon: BarChart3 },
{ key: 'discussion', label: t('campaigns.discussion'), icon: MessageSquare },
]
return (
<>
<TabbedModal
onClose={onClose}
size="lg"
header={
<>
<input
type="text"
value={form.name}
@@ -115,7 +131,7 @@ export default function CampaignDetailPanel({ campaign, onClose, onSave, onDelet
form.status === 'paused' ? 'bg-amber-100 text-amber-700' :
form.status === 'completed' ? 'bg-blue-100 text-blue-700' :
form.status === 'cancelled' ? 'bg-red-100 text-red-700' :
'bg-gray-100 text-gray-600'
'bg-gray-100 text-text-secondary'
}`}>
{statusOptions.find(s => s.value === form.status)?.label}
</span>
@@ -125,23 +141,41 @@ export default function CampaignDetailPanel({ campaign, onClose, onSave, onDelet
</span>
)}
</div>
</div>
<button
onClick={onClose}
className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors shrink-0"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
)
return (
</>
}
tabs={tabs}
activeTab={activeTab}
onTabChange={setActiveTab}
footer={
<>
<SlidePanel onClose={onClose} maxWidth="520px" header={header}>
{/* Details Section */}
<CollapsibleSection title={t('campaigns.details')}>
<div className="px-5 pb-4 space-y-3">
<div className="flex items-center gap-2">
{onDelete && !isCreateMode && (
<button
onClick={() => setShowDeleteConfirm(true)}
className="p-2 text-text-tertiary hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
title={t('common.delete')}
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
<div className="flex items-center gap-2.5">
{dirty && (
<button
onClick={handleSave}
disabled={!form.name || !form.start_date || !form.end_date || saving}
className={`px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm ${saving ? 'btn-loading' : ''}`}
>
{isCreateMode ? t('campaigns.createCampaign') : t('tasks.saveChanges')}
</button>
)}
</div>
</>
}
>
{/* Details Tab */}
{activeTab === 'details' && (
<div className="p-6 space-y-3">
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.description')}</label>
<textarea
@@ -156,31 +190,39 @@ export default function CampaignDetailPanel({ campaign, onClose, onSave, onDelet
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.brand')}</label>
<select
<PortalSelect
value={form.brand_id}
onChange={e => update('brand_id', e.target.value)}
onChange={val => update('brand_id', val)}
options={[{ value: '', label: 'Select brand' }, ...(brands || []).map(b => ({ value: b.id || b._id, label: `${b.icon || ''} ${lang === 'ar' && b.name_ar ? b.name_ar : b.name}`.trim() }))]}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
<option value="">Select brand</option>
{(brands || []).map(b => <option key={b.id || b._id} value={b.id || b._id}>{b.icon} {lang === 'ar' && b.name_ar ? b.name_ar : b.name}</option>)}
</select>
/>
</div>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.status')}</label>
<select
<PortalSelect
value={form.status}
onChange={e => update('status', e.target.value)}
onChange={val => update('status', val)}
options={statusOptions}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
{statusOptions.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
</select>
/>
</div>
</div>
{/* Team */}
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('common.team')}</label>
<PortalSelect
value={form.team_id}
onChange={val => update('team_id', val)}
options={[{ value: '', label: t('common.noTeam') }, ...(teams || []).map(tm => ({ value: tm.id || tm._id, label: tm.name }))]}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
/>
</div>
{/* Platforms */}
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.platforms')}</label>
<div className="flex flex-wrap gap-2 p-2 border border-border rounded-lg bg-white min-h-[38px]">
<div className="flex flex-wrap gap-2 p-2 border border-border rounded-lg bg-surface min-h-[38px]">
{Object.entries(PLATFORMS).map(([k, v]) => {
const checked = (form.platforms || []).includes(k)
return (
@@ -235,7 +277,7 @@ export default function CampaignDetailPanel({ campaign, onClose, onSave, onDelet
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">
{t('campaigns.budget')} ({currencySymbol})
{!permissions?.canSetBudget && <span className="text-[10px] text-text-tertiary ml-1">(Superadmin only)</span>}
{!permissions?.canSetBudget && <span className="text-[10px] text-text-tertiary ms-1">(Superadmin only)</span>}
</label>
<input
type="number"
@@ -257,34 +299,12 @@ export default function CampaignDetailPanel({ campaign, onClose, onSave, onDelet
/>
</div>
</div>
<div className="flex items-center gap-2 pt-2">
{dirty && (
<button
onClick={handleSave}
disabled={!form.name || !form.start_date || !form.end_date || saving}
className={`flex-1 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm ${saving ? 'btn-loading' : ''}`}
>
{isCreateMode ? t('campaigns.createCampaign') : t('tasks.saveChanges')}
</button>
)}
{onDelete && !isCreateMode && (
<button
onClick={() => setShowDeleteConfirm(true)}
className="p-2 text-text-tertiary hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
title={t('common.delete')}
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
</div>
</CollapsibleSection>
)}
{/* Performance Section (hidden in create mode) */}
{!isCreateMode && (
<CollapsibleSection title={t('campaigns.performance')}>
<div className="px-5 pb-4 space-y-3">
{/* Performance Tab */}
{activeTab === 'performance' && !isCreateMode && (
<div className="p-6 space-y-3">
{(form.budget_spent || form.impressions || form.clicks) && (
<div className="grid grid-cols-4 gap-2">
<div className="bg-surface-secondary rounded-lg p-2 text-center">
@@ -398,18 +418,15 @@ export default function CampaignDetailPanel({ campaign, onClose, onSave, onDelet
/>
</div>
</div>
</CollapsibleSection>
)}
{/* Discussion Section (hidden in create mode) */}
{!isCreateMode && (
<CollapsibleSection title={t('campaigns.discussion')} noBorder>
<div className="px-5 pb-5">
{/* Discussion Tab */}
{activeTab === 'discussion' && !isCreateMode && (
<div className="p-6 space-y-3">
<CommentsSection entityType="campaign" entityId={campaignId} />
</div>
</CollapsibleSection>
)}
</SlidePanel>
</TabbedModal>
<Modal
isOpen={showDeleteConfirm}
+3 -3
View File
@@ -33,7 +33,7 @@ export default function CommentsSection({ entityType, entityId }) {
const loadComments = async () => {
try {
const data = await api.get(`/comments/${entityType}/${entityId}`)
setComments(Array.isArray(data) ? data : (data.data || []))
setComments(Array.isArray(data) ? data : [])
} catch (err) {
console.error('Failed to load comments:', err)
}
@@ -116,7 +116,7 @@ export default function CommentsSection({ entityType, entityId }) {
<div key={c.id} className="flex items-start gap-2 group">
<div className="w-7 h-7 rounded-full bg-brand-primary/10 text-brand-primary flex items-center justify-center text-[10px] font-bold shrink-0 mt-0.5">
{c.user_avatar ? (
<img src={c.user_avatar} className="w-full h-full rounded-full object-cover" alt="" />
<img src={c.user_avatar} className="w-full h-full rounded-full object-cover" alt="" loading="lazy" />
) : (
getInitials(c.user_name)
)}
@@ -125,7 +125,7 @@ export default function CommentsSection({ entityType, entityId }) {
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-text-primary">{c.user_name}</span>
<span className="text-[10px] text-text-tertiary">{relativeTime(c.created_at, t)}</span>
<div className="flex items-center gap-0.5 ml-auto opacity-0 group-hover:opacity-100 transition-opacity">
<div className="flex items-center gap-0.5 ms-auto opacity-0 group-hover:opacity-100 transition-opacity">
{canEdit(c) && editingId !== c.id && (
<button
onClick={() => startEdit(c)}
+1 -1
View File
@@ -17,7 +17,7 @@ export default function DatePresetPicker({ onSelect, activePreset, onClear }) {
className={`px-2.5 py-1 text-[11px] font-medium rounded-full border transition-colors ${
activePreset === preset.key
? 'bg-brand-primary/10 border-brand-primary/30 text-brand-primary'
: 'bg-white border-border text-text-tertiary hover:text-text-primary hover:border-border-dark'
: 'bg-surface border-border text-text-tertiary hover:text-text-primary hover:border-border-dark'
}`}
>
{t(preset.labelKey)}
+3 -3
View File
@@ -21,7 +21,7 @@ export default function EmptyState({
{actionLabel && (
<button
onClick={onAction}
className="mt-3 text-sm text-brand-primary hover:text-brand-primary-light font-medium"
className="mt-3 text-sm text-brand-primary hover:text-brand-primary-light font-medium transition-colors"
>
{actionLabel}
</button>
@@ -44,7 +44,7 @@ export default function EmptyState({
{actionLabel && (
<button
onClick={onAction}
className="px-5 py-2.5 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm transition-all hover:-translate-y-0.5"
className="px-5 py-2.5 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light transition-colors"
>
{actionLabel}
</button>
@@ -52,7 +52,7 @@ export default function EmptyState({
{secondaryActionLabel && (
<button
onClick={onSecondaryAction}
className="px-5 py-2.5 bg-white border border-border text-text-primary rounded-lg text-sm font-medium hover:bg-surface-secondary transition-colors"
className="px-5 py-2.5 bg-surface border border-border text-text-primary rounded-lg text-sm font-medium hover:bg-surface-secondary transition-colors"
>
{secondaryActionLabel}
</button>
+41
View File
@@ -0,0 +1,41 @@
import { Component } from 'react'
import { AlertTriangle } from 'lucide-react'
export default class ErrorBoundary extends Component {
constructor(props) {
super(props)
this.state = { hasError: false, error: null }
}
static getDerivedStateFromError(error) {
return { hasError: true, error }
}
componentDidCatch(error, errorInfo) {
console.error('ErrorBoundary caught:', error, errorInfo)
}
render() {
if (this.state.hasError) {
return (
<div className="min-h-screen bg-surface-secondary flex items-center justify-center p-4">
<div className="max-w-md w-full bg-surface rounded-2xl shadow-sm p-8 text-center">
<div className="w-16 h-16 rounded-full bg-red-100 flex items-center justify-center mx-auto mb-4">
<AlertTriangle className="w-8 h-8 text-red-600" />
</div>
<h2 className="text-2xl font-bold text-text-primary mb-2">Something went wrong</h2>
<p className="text-text-secondary mb-6">An unexpected error occurred. Please try refreshing the page.</p>
<button
onClick={() => window.location.reload()}
className="px-6 py-2.5 bg-brand-primary text-white rounded-lg font-medium hover:bg-brand-primary-light transition-colors"
>
Refresh Page
</button>
</div>
</div>
)
}
return this.props.children
}
}
+3 -3
View File
@@ -28,7 +28,7 @@ export default function FormInput({
? 'border-emerald-300 focus:border-emerald-500 focus:ring-emerald-500/20'
: 'border-border focus:border-brand-primary focus:ring-brand-primary/20'
}
${disabled ? 'bg-surface-tertiary cursor-not-allowed opacity-60' : 'bg-white'}
${disabled ? 'bg-surface-tertiary cursor-not-allowed opacity-60' : 'bg-surface'}
${className}
`.trim()
@@ -39,7 +39,7 @@ export default function FormInput({
{label && (
<label className="block text-sm font-medium text-text-primary">
{label}
{required && <span className="text-red-500 ml-0.5">*</span>}
{required && <span className="text-red-500 ms-0.5">*</span>}
</label>
)}
@@ -57,7 +57,7 @@ export default function FormInput({
{/* Validation icon */}
{(hasError || hasSuccess) && (
<div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none">
<div className="absolute end-3 top-1/2 -translate-y-1/2 pointer-events-none">
{hasError ? (
<AlertCircle className="w-4 h-4 text-red-500" />
) : (
+165 -36
View File
@@ -1,38 +1,54 @@
import { useState, useRef, useEffect } from 'react'
import { useLocation } from 'react-router-dom'
import { Bell, ChevronDown, LogOut, Shield } from 'lucide-react'
import { ChevronDown, LogOut, Shield, Lock, AlertCircle, CheckCircle } from 'lucide-react'
import { useAuth } from '../contexts/AuthContext'
import { getInitials } from '../utils/api'
import { getInitials, api } from '../utils/api'
import { useLanguage } from '../i18n/LanguageContext'
import Modal from './Modal'
import ThemeToggle from './ThemeToggle'
const pageTitles = {
'/': 'Dashboard',
'/posts': 'Post Production',
'/assets': 'Assets',
'/campaigns': 'Campaigns',
'/finance': 'Finance',
'/projects': 'Projects',
'/tasks': 'My Tasks',
'/team': 'Team',
'/users': 'User Management',
const PAGE_TITLE_KEYS = {
'/': 'header.dashboard',
'/posts': 'header.posts',
'/calendar': 'header.calendar',
'/assets': 'header.assets',
'/artefacts': 'header.artefacts',
'/campaigns': 'header.campaigns',
'/brands': 'header.brands',
'/finance': 'header.finance',
'/budgets': 'header.budgets',
'/projects': 'header.projects',
'/tasks': 'header.tasks',
'/issues': 'header.issues',
'/team': 'header.team',
'/settings': 'header.settings',
'/translations': 'header.copy',
}
const ROLE_INFO = {
superadmin: { label: 'Superadmin', color: 'bg-purple-100 text-purple-700', icon: '👑' },
manager: { label: 'Manager', color: 'bg-blue-100 text-blue-700', icon: '📊' },
contributor: { label: 'Contributor', color: 'bg-green-100 text-green-700', icon: '✏️' },
superadmin: { labelKey: 'header.superadmin', color: 'bg-purple-100 text-purple-700', icon: '👑' },
manager: { labelKey: 'header.manager', color: 'bg-blue-100 text-blue-700', icon: '📊' },
contributor: { labelKey: 'header.contributor', color: 'bg-green-100 text-green-700', icon: '✏️' },
}
export default function Header() {
const { user, logout } = useAuth()
const { t } = useLanguage()
const [showDropdown, setShowDropdown] = useState(false)
const [showPasswordModal, setShowPasswordModal] = useState(false)
const [passwordForm, setPasswordForm] = useState({ currentPassword: '', newPassword: '', confirmPassword: '' })
const [passwordError, setPasswordError] = useState('')
const [passwordSuccess, setPasswordSuccess] = useState('')
const [passwordSaving, setPasswordSaving] = useState(false)
const dropdownRef = useRef(null)
const location = useLocation()
function getPageTitle(pathname) {
if (pageTitles[pathname]) return pageTitles[pathname]
if (pathname.startsWith('/projects/')) return 'Project Details'
if (pathname.startsWith('/campaigns/')) return 'Campaign Details'
return 'Page'
if (PAGE_TITLE_KEYS[pathname]) return t(PAGE_TITLE_KEYS[pathname])
if (pathname.startsWith('/posts/')) return t('header.postDetails')
if (pathname.startsWith('/projects/')) return t('header.projectDetails')
if (pathname.startsWith('/campaigns/')) return t('header.campaignDetails')
return t('header.page')
}
const pageTitle = getPageTitle(location.pathname)
@@ -46,22 +62,55 @@ export default function Header() {
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
const handlePasswordChange = async () => {
setPasswordError('')
setPasswordSuccess('')
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
setPasswordError(t('header.passwordMismatch'))
return
}
if (passwordForm.newPassword.length < 6) {
setPasswordError(t('header.passwordMinLength'))
return
}
setPasswordSaving(true)
try {
await api.patch('/users/me/password', {
currentPassword: passwordForm.currentPassword,
newPassword: passwordForm.newPassword,
})
setPasswordSuccess(t('header.passwordUpdateSuccess'))
setPasswordForm({ currentPassword: '', newPassword: '', confirmPassword: '' })
setTimeout(() => setShowPasswordModal(false), 1500)
} catch (err) {
setPasswordError(err.message || t('header.passwordUpdateFailed'))
} finally {
setPasswordSaving(false)
}
}
const openPasswordModal = () => {
setShowDropdown(false)
setPasswordForm({ currentPassword: '', newPassword: '', confirmPassword: '' })
setPasswordError('')
setPasswordSuccess('')
setShowPasswordModal(true)
}
const roleInfo = ROLE_INFO[user?.role] || ROLE_INFO.contributor
return (
<header className="h-16 bg-white border-b border-border flex items-center justify-between px-6 shrink-0 sticky top-0 z-20">
<>
<header className="h-16 bg-surface border-b border-border flex items-center justify-between px-6 shrink-0 sticky top-0 z-20">
{/* Page title */}
<div>
<h2 className="text-xl font-bold text-text-primary">{pageTitle}</h2>
</div>
{/* Right side */}
<div className="flex items-center gap-4">
{/* Notifications */}
<button className="relative p-2 rounded-lg hover:bg-surface-tertiary text-text-secondary hover:text-text-primary transition-colors">
<Bell className="w-5 h-5" />
<span className="absolute top-1.5 right-1.5 w-2 h-2 bg-red-500 rounded-full"></span>
</button>
<div className="flex items-center gap-2">
{/* Theme toggle */}
<ThemeToggle />
{/* User menu */}
<div ref={dropdownRef} className="relative">
@@ -71,31 +120,31 @@ export default function Header() {
>
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-semibold ${
user?.role === 'superadmin'
? 'bg-gradient-to-br from-purple-500 to-pink-500'
: 'bg-gradient-to-br from-blue-500 to-indigo-500'
? 'bg-brand-primary'
: 'bg-teal-700'
}`}>
{getInitials(user?.name)}
</div>
<div className="text-left hidden sm:block">
<div className="text-start hidden sm:block">
<p className="text-sm font-medium text-text-primary">
{user?.name || 'User'}
</p>
<p className={`text-[10px] font-medium ${roleInfo.color.split(' ')[1]}`}>
{roleInfo.icon} {roleInfo.label}
{roleInfo.icon} {t(roleInfo.labelKey)}
</p>
</div>
<ChevronDown className={`w-4 h-4 text-text-tertiary transition-transform ${showDropdown ? 'rotate-180' : ''}`} />
</button>
{showDropdown && (
<div className="absolute right-0 top-full mt-2 w-64 bg-white rounded-xl shadow-lg border border-border overflow-hidden animate-scale-in">
<div className="absolute end-0 top-full mt-2 w-64 bg-surface rounded-xl shadow-lg border border-border overflow-hidden animate-scale-in" role="menu">
{/* User info */}
<div className="px-4 py-3 border-b border-border-light bg-surface-secondary">
<p className="text-sm font-semibold text-text-primary">{user?.name}</p>
<p className="text-xs text-text-tertiary">{user?.email}</p>
<div className={`inline-flex items-center gap-1 text-[10px] font-medium px-2 py-0.5 rounded-full mt-2 ${roleInfo.color}`}>
<span>{roleInfo.icon}</span>
{roleInfo.label}
{t(roleInfo.labelKey)}
</div>
</div>
@@ -107,22 +156,30 @@ export default function Header() {
setShowDropdown(false)
window.location.href = '/users'
}}
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-surface-secondary transition-colors text-left"
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-surface-secondary transition-colors text-start"
>
<Shield className="w-4 h-4 text-text-tertiary" />
<span className="text-sm text-text-primary">User Management</span>
<span className="text-sm text-text-primary">{t('header.userManagement')}</span>
</button>
)}
<button
onClick={openPasswordModal}
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-surface-secondary transition-colors text-start"
>
<Lock className="w-4 h-4 text-text-tertiary" />
<span className="text-sm text-text-primary">{t('header.changePassword')}</span>
</button>
<button
onClick={() => {
setShowDropdown(false)
logout()
}}
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-red-50 transition-colors text-left group"
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-red-50 transition-colors text-start group"
>
<LogOut className="w-4 h-4 text-text-tertiary group-hover:text-red-500" />
<span className="text-sm text-text-primary group-hover:text-red-500">Sign Out</span>
<span className="text-sm text-text-primary group-hover:text-red-500">{t('header.signOut')}</span>
</button>
</div>
</div>
@@ -130,5 +187,77 @@ export default function Header() {
</div>
</div>
</header>
{/* Change Password Modal */}
<Modal isOpen={showPasswordModal} onClose={() => setShowPasswordModal(false)} title={t('header.changePassword')} size="md">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('header.currentPassword')}</label>
<input
type="password"
value={passwordForm.currentPassword}
onChange={e => { setPasswordForm(f => ({ ...f, currentPassword: e.target.value })); setPasswordError('') }}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
placeholder="••••••••"
aria-describedby={passwordError ? 'password-error' : undefined}
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('header.newPassword')}</label>
<input
type="password"
value={passwordForm.newPassword}
onChange={e => { setPasswordForm(f => ({ ...f, newPassword: e.target.value })); setPasswordError('') }}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
placeholder="••••••••"
minLength={6}
aria-describedby={passwordError ? 'password-error' : undefined}
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('header.confirmNewPassword')}</label>
<input
type="password"
value={passwordForm.confirmPassword}
onChange={e => { setPasswordForm(f => ({ ...f, confirmPassword: e.target.value })); setPasswordError('') }}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
placeholder="••••••••"
minLength={6}
aria-describedby={passwordError ? 'password-error' : undefined}
/>
</div>
{passwordError && (
<div id="password-error" className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg" role="alert">
<AlertCircle className="w-4 h-4 text-red-500 shrink-0" />
<p className="text-sm text-red-500">{passwordError}</p>
</div>
)}
{passwordSuccess && (
<div className="flex items-center gap-2 p-3 bg-green-500/10 border border-green-500/30 rounded-lg">
<CheckCircle className="w-4 h-4 text-green-500 shrink-0" />
<p className="text-sm text-green-500">{passwordSuccess}</p>
</div>
)}
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
<button
onClick={() => setShowPasswordModal(false)}
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
>
{t('common.cancel')}
</button>
<button
onClick={handlePasswordChange}
disabled={!passwordForm.currentPassword || !passwordForm.newPassword || !passwordForm.confirmPassword || passwordSaving}
className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
>
{passwordSaving ? t('header.saving') : t('header.updatePassword')}
</button>
</div>
</div>
</Modal>
</>
)
}
+120 -32
View File
@@ -1,6 +1,7 @@
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
import { format, differenceInDays, startOfDay, addDays, isBefore, isAfter } from 'date-fns'
import { Calendar, Rows3, Rows4 } from 'lucide-react'
import { useLanguage } from '../i18n/LanguageContext'
const STATUS_COLORS = {
todo: 'bg-gray-500',
@@ -33,8 +34,15 @@ const PRIORITY_BORDER = {
}
const ZOOM_LEVELS = [
{ key: 'day', label: 'Day', pxPerDay: 48 },
{ key: 'week', label: 'Week', pxPerDay: 20 },
{ key: 'month', i18n: 'timeline.month', pxPerDay: 8 },
{ key: 'week', i18n: 'timeline.week', pxPerDay: 20 },
{ key: 'day', i18n: 'timeline.day', pxPerDay: 48 },
]
const COLOR_PALETTE = [
'#6366f1', '#ec4899', '#10b981', '#f59e0b',
'#8b5cf6', '#06b6d4', '#f43f5e', '#14b8a6',
'#3b82f6', '#ef4444', '#84cc16', '#a855f7',
]
function getInitials(name) {
@@ -42,7 +50,8 @@ function getInitials(name) {
return name.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2)
}
export default function InteractiveTimeline({ items = [], mapItem, onDateChange, onItemClick, readOnly = false }) {
export default function InteractiveTimeline({ items = [], mapItem, onDateChange, onColorChange, onItemClick, readOnly = false }) {
const { t } = useLanguage()
const containerRef = useRef(null)
const didDragRef = useRef(false)
const optimisticRef = useRef({}) // { [itemId]: { startDate, endDate } } — keeps bar in place until fresh data arrives
@@ -51,10 +60,24 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
const [tooltip, setTooltip] = useState(null)
const [dragState, setDragState] = useState(null) // { itemId, mode: 'move'|'resize-left'|'resize-right', startX, origStart, origEnd }
const dragStateRef = useRef(null)
const [colorPicker, setColorPicker] = useState(null) // { itemId, x, y }
const colorPickerRef = useRef(null)
const pxPerDay = ZOOM_LEVELS[zoomIdx].pxPerDay
const today = useMemo(() => startOfDay(new Date()), [])
// Close color picker on outside click
useEffect(() => {
if (!colorPicker) return
const handleClick = (e) => {
if (colorPickerRef.current && !colorPickerRef.current.contains(e.target)) {
setColorPicker(null)
}
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [colorPicker])
// Clear optimistic overrides when fresh data arrives
useEffect(() => {
optimisticRef.current = {}
@@ -214,16 +237,16 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
if (items.length === 0) {
return (
<div className="bg-white rounded-xl border border-border py-16 text-center">
<div className="bg-surface rounded-xl border border-border py-16 text-center">
<Calendar className="w-12 h-12 text-text-tertiary mx-auto mb-3" />
<p className="text-text-secondary font-medium">No items to display</p>
<p className="text-sm text-text-tertiary mt-1">Add items with dates to see the timeline</p>
<p className="text-text-secondary font-medium">{t('timeline.noItems')}</p>
<p className="text-sm text-text-tertiary mt-1">{t('timeline.addItems')}</p>
</div>
)
}
return (
<div className="bg-white rounded-xl border border-border overflow-hidden">
<div className="bg-surface rounded-xl border border-border overflow-clip">
{/* Toolbar */}
<div className="flex items-center justify-between px-4 py-2 border-b border-border bg-surface-secondary">
<div className="flex items-center gap-2">
@@ -237,7 +260,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
: 'text-text-tertiary hover:text-text-primary hover:bg-surface-tertiary'
}`}
>
{z.label}
{t(z.i18n)}
</button>
))}
</div>
@@ -245,34 +268,35 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
<button
onClick={() => setBarMode(m => m === 'compact' ? 'expanded' : 'compact')}
className="flex items-center gap-1.5 px-3 py-1 text-xs font-medium text-text-secondary hover:text-text-primary hover:bg-surface-tertiary rounded-md transition-colors"
title={isExpanded ? 'Compact bars' : 'Expanded bars'}
title={isExpanded ? t('timeline.compactBars') : t('timeline.expandedBars')}
>
{isExpanded ? <Rows4 className="w-3.5 h-3.5" /> : <Rows3 className="w-3.5 h-3.5" />}
{isExpanded ? 'Compact' : 'Expand'}
{isExpanded ? t('timeline.compact') : t('timeline.expand')}
</button>
<button
onClick={scrollToToday}
className="flex items-center gap-1.5 px-3 py-1 text-xs font-medium text-brand-primary hover:bg-brand-primary/10 rounded-md transition-colors"
>
<Calendar className="w-3.5 h-3.5" />
Today
{t('timeline.today')}
</button>
</div>
</div>
{/* Timeline */}
<div ref={containerRef} className="overflow-x-auto relative" style={{ cursor: dragState ? 'grabbing' : undefined }}>
<div ref={containerRef} dir="ltr" className="overflow-x-auto relative" style={{ cursor: dragState ? 'grabbing' : undefined }}>
<div style={{ minWidth: `${labelWidth + totalDays * pxPerDay}px` }}>
{/* Day header */}
<div className="flex sticky top-0 z-20 bg-white border-b border-border" style={{ height: headerHeight }}>
<div className="shrink-0 border-r border-border bg-surface-secondary flex items-center px-4 sticky left-0 z-30" style={{ width: labelWidth }}>
<span className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">Item</span>
<div className="flex sticky top-0 z-20 bg-surface border-b border-border" style={{ height: headerHeight }}>
<div className="shrink-0 border-e border-border bg-surface-secondary flex items-center px-4 sticky start-0 z-30" style={{ width: labelWidth }}>
<span className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('timeline.item')}</span>
</div>
<div className="flex relative">
{days.map((day, i) => {
const isToday = differenceInDays(day, today) === 0
const isWeekend = day.getDay() === 0 || day.getDay() === 6
const isMonthStart = day.getDate() === 1
const isWeekStart = day.getDay() === 1 // Monday
return (
<div
key={i}
@@ -285,7 +309,13 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
>
{pxPerDay >= 30 && <div>{format(day, 'd')}</div>}
{pxPerDay >= 40 && <div className="text-[8px] uppercase">{format(day, 'EEE')}</div>}
{pxPerDay < 30 && day.getDate() % 7 === 1 && <div>{format(day, 'd')}</div>}
{pxPerDay >= 15 && pxPerDay < 30 && day.getDate() % 7 === 1 && <div>{format(day, 'd')}</div>}
{pxPerDay < 15 && isMonthStart && (
<div className="text-[8px] font-semibold whitespace-nowrap">{format(day, 'MMM')}</div>
)}
{pxPerDay < 15 && !isMonthStart && isWeekStart && day.getDate() % 14 <= 7 && (
<div className="text-[8px]">{format(day, 'd')}</div>
)}
</div>
)
})}
@@ -295,7 +325,8 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
{/* Rows */}
{mapped.map((item, idx) => {
const { left, width } = getBarPosition(item)
const statusColor = STATUS_COLORS[item.status] || STATUS_COLORS[item.type] || 'bg-gray-400'
const hasCustomColor = !!item.color
const statusColor = hasCustomColor ? '' : (STATUS_COLORS[item.status] || STATUS_COLORS[item.type] || 'bg-gray-400')
const priorityRing = PRIORITY_BORDER[item.priority] || ''
const isDragging = dragState?.itemId === item.id
@@ -307,15 +338,27 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
>
{/* Label column */}
<div
className={`shrink-0 border-r border-border flex ${isExpanded ? 'flex-col justify-center gap-1' : 'items-center gap-2'} px-3 overflow-hidden sticky left-0 z-10 bg-white group-hover/row:bg-surface-secondary/50`}
className={`shrink-0 border-e border-border flex ${isExpanded ? 'flex-col justify-center gap-1' : 'items-center gap-2'} px-3 overflow-hidden sticky start-0 z-10 bg-surface group-hover/row:bg-surface-secondary/50`}
style={{ width: labelWidth }}
>
{isExpanded ? (
<>
<div className="flex items-center gap-2">
{onColorChange && (
<button
onClick={(e) => {
e.stopPropagation()
const rect = e.currentTarget.getBoundingClientRect()
setColorPicker(colorPicker?.itemId === item.id ? null : { itemId: item.id, x: rect.left, y: rect.bottom + 4 })
}}
className={`w-5 h-5 rounded-full border-2 border-white shadow-sm shrink-0 hover:scale-110 transition-transform ${!item.color ? (STATUS_COLORS[item.status] || 'bg-gray-400') : ''}`}
style={item.color ? { backgroundColor: item.color } : undefined}
title={t('timeline.changeColor')}
/>
)}
{item.thumbnailUrl ? (
<div className="w-8 h-8 rounded overflow-hidden shrink-0">
<img src={item.thumbnailUrl} alt="" className="w-full h-full object-cover" />
<img src={item.thumbnailUrl} alt="" className="w-full h-full object-cover" loading="lazy" />
</div>
) : item.assigneeName ? (
<div className="w-6 h-6 rounded-full bg-brand-primary/10 text-brand-primary flex items-center justify-center text-[9px] font-bold shrink-0">
@@ -337,9 +380,21 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
</>
) : (
<>
{onColorChange && (
<button
onClick={(e) => {
e.stopPropagation()
const rect = e.currentTarget.getBoundingClientRect()
setColorPicker(colorPicker?.itemId === item.id ? null : { itemId: item.id, x: rect.left, y: rect.bottom + 4 })
}}
className={`w-4 h-4 rounded-full border-2 border-white shadow-sm shrink-0 hover:scale-110 transition-transform ${!item.color ? (STATUS_COLORS[item.status] || 'bg-gray-400') : ''}`}
style={item.color ? { backgroundColor: item.color } : undefined}
title={t('timeline.changeColor')}
/>
)}
{item.thumbnailUrl ? (
<div className="w-6 h-6 rounded overflow-hidden shrink-0">
<img src={item.thumbnailUrl} alt="" className="w-full h-full object-cover" />
<img src={item.thumbnailUrl} alt="" className="w-full h-full object-cover" loading="lazy" />
</div>
) : item.assigneeName ? (
<div className="w-6 h-6 rounded-full bg-brand-primary/10 text-brand-primary flex items-center justify-center text-[9px] font-bold shrink-0">
@@ -360,8 +415,8 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
style={{ left: `${todayOffset + pxPerDay / 2}px` }}
>
{idx === 0 && (
<div className="absolute -top-0 left-1 text-[8px] font-bold text-red-500 bg-red-50 px-1 rounded whitespace-nowrap">
Today
<div className="absolute -top-0 start-1 text-[8px] font-bold text-red-500 bg-red-50 px-1 rounded whitespace-nowrap">
{t('timeline.today')}
</div>
)}
</div>
@@ -377,6 +432,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
width: `${width}px`,
height: `${barHeight}px`,
top: isExpanded ? '8px' : '8px',
...(hasCustomColor ? { backgroundColor: item.color } : {}),
}}
onMouseDown={(e) => handleMouseDown(e, item, 'move')}
onClick={(e) => {
@@ -403,7 +459,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
{/* Left resize handle */}
{!readOnly && onDateChange && (
<div
className="absolute left-0 top-0 bottom-0 w-2 cursor-col-resize hover:bg-white/30 z-10"
className="absolute start-0 top-0 bottom-0 w-2 cursor-col-resize hover:bg-white/30 z-10"
onMouseDown={(e) => handleMouseDown(e, item, 'resize-left')}
/>
)}
@@ -423,7 +479,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
</span>
)}
{width > 120 && item.status && (
<span className="text-[9px] text-white/70 bg-white/15 px-1.5 py-0.5 rounded ml-auto shrink-0">
<span className="text-[9px] text-white/70 bg-white/15 px-1.5 py-0.5 rounded ms-auto shrink-0">
{item.status.replace(/_/g, ' ')}
</span>
)}
@@ -439,7 +495,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
<span key={i} className="text-[8px] px-1 py-0.5 rounded bg-white/15 text-white/70 font-medium">{tag}</span>
))}
{width > 140 && item.startDate && item.endDate && (
<span className="text-[8px] text-white/50 ml-auto">
<span className="text-[8px] text-white/50 ms-auto">
{format(item.startDate, 'MMM d')} {format(item.endDate, 'MMM d')}
</span>
)}
@@ -464,7 +520,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
{/* Right resize handle */}
{!readOnly && onDateChange && (
<div
className="absolute right-0 top-0 bottom-0 w-2 cursor-col-resize hover:bg-white/30 z-10"
className="absolute end-0 top-0 bottom-0 w-2 cursor-col-resize hover:bg-white/30 z-10"
onMouseDown={(e) => handleMouseDown(e, item, 'resize-right')}
/>
)}
@@ -476,6 +532,38 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
</div>
</div>
{/* Color Picker Popover */}
{colorPicker && onColorChange && (
<div
ref={colorPickerRef}
className="fixed z-50 bg-surface rounded-lg shadow-xl border border-border p-2"
style={{ left: colorPicker.x, top: colorPicker.y }}
>
<div className="grid grid-cols-4 gap-1.5 mb-2">
{COLOR_PALETTE.map(c => (
<button
key={c}
onClick={() => {
onColorChange(colorPicker.itemId, c)
setColorPicker(null)
}}
className="w-7 h-7 rounded-full border-2 border-transparent hover:border-gray-400 hover:scale-110 transition-all"
style={{ backgroundColor: c }}
/>
))}
</div>
<button
onClick={() => {
onColorChange(colorPicker.itemId, null)
setColorPicker(null)
}}
className="w-full text-[10px] text-text-tertiary hover:text-text-primary text-center py-1 hover:bg-surface-tertiary rounded transition-colors"
>
{t('timeline.resetColor')}
</button>
</div>
)}
{/* Tooltip */}
{tooltip && !dragState && (
<div
@@ -490,21 +578,21 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
<div className="font-semibold mb-1">{tooltip.item.label}</div>
<div className="text-gray-300 space-y-0.5">
{tooltip.item.startDate && (
<div>Start: {format(tooltip.item.startDate, 'MMM d, yyyy')}</div>
<div>{t('timeline.startDate')}: {format(tooltip.item.startDate, 'MMM d, yyyy')}</div>
)}
{tooltip.item.endDate && (
<div>End: {format(tooltip.item.endDate, 'MMM d, yyyy')}</div>
<div>{t('timeline.endDate')}: {format(tooltip.item.endDate, 'MMM d, yyyy')}</div>
)}
{tooltip.item.assigneeName && (
<div>Assignee: {tooltip.item.assigneeName}</div>
<div>{t('timeline.assignee')}: {tooltip.item.assigneeName}</div>
)}
{tooltip.item.status && (
<div>Status: {tooltip.item.status.replace(/_/g, ' ')}</div>
<div>{t('timeline.status')}: {tooltip.item.status.replace(/_/g, ' ')}</div>
)}
</div>
{!readOnly && onDateChange && (
<div className="text-gray-400 mt-1 text-[10px] italic">
Drag to move · Drag edges to resize
<div className="text-text-tertiary mt-1 text-[10px] italic">
{t('timeline.dragToMove')} · {t('timeline.dragToResize')}
</div>
)}
</div>
+6 -14
View File
@@ -1,20 +1,12 @@
const PRIORITY_CONFIG = {
low: { label: 'Low', dot: 'bg-text-tertiary' },
medium: { label: 'Medium', dot: 'bg-blue-500' },
high: { label: 'High', dot: 'bg-orange-500' },
urgent: { label: 'Urgent', dot: 'bg-red-500' },
}
const TYPE_LABELS = {
request: 'Request',
correction: 'Correction',
complaint: 'Complaint',
suggestion: 'Suggestion',
other: 'Other',
const PRIORITY_DOTS = {
low: 'bg-text-tertiary',
medium: 'bg-blue-500',
high: 'bg-orange-500',
urgent: 'bg-red-500',
}
export default function IssueCard({ issue, onClick }) {
const priority = PRIORITY_CONFIG[issue.priority] || PRIORITY_CONFIG.medium
const priority = { dot: PRIORITY_DOTS[issue.priority] || PRIORITY_DOTS.medium }
const formatDate = (dateStr) => {
if (!dateStr) return ''
+182 -155
View File
@@ -1,37 +1,29 @@
import { useState, useEffect, useContext } from 'react'
import { X, Copy, Eye, Lock, Send, Upload, FileText, Trash2, Check, Clock, CheckCircle2, XCircle } from 'lucide-react'
import { api } from '../utils/api'
import SlidePanel from './SlidePanel'
import FormInput from './FormInput'
import { Copy, Eye, Lock, Send, FileText, Trash2, Check, Clock, CheckCircle2, XCircle, FileEdit, Wrench, MessageSquare, Paperclip } from 'lucide-react'
import UploadZone from './UploadZone'
import { api, STATUS_CONFIG, PRIORITY_CONFIG } from '../utils/api'
import TabbedModal from './TabbedModal'
import Modal from './Modal'
import { useToast } from './ToastContainer'
import { AppContext } from '../App'
import { useLanguage } from '../i18n/LanguageContext'
import PortalSelect from './PortalSelect'
const STATUS_CONFIG = {
new: { label: 'New', bg: 'bg-purple-100', text: 'text-purple-700', dot: 'bg-purple-500' },
acknowledged: { label: 'Acknowledged', bg: 'bg-blue-100', text: 'text-blue-700', dot: 'bg-blue-500' },
in_progress: { label: 'In Progress', bg: 'bg-amber-100', text: 'text-amber-700', dot: 'bg-amber-500' },
resolved: { label: 'Resolved', bg: 'bg-emerald-100', text: 'text-emerald-700', dot: 'bg-emerald-500' },
declined: { label: 'Declined', bg: 'bg-surface-tertiary', text: 'text-text-secondary', dot: 'bg-text-tertiary' },
}
const PRIORITY_CONFIG = {
low: { label: 'Low', bg: 'bg-surface-tertiary', text: 'text-text-secondary' },
medium: { label: 'Medium', bg: 'bg-blue-100', text: 'text-blue-700' },
high: { label: 'High', bg: 'bg-orange-100', text: 'text-orange-700' },
urgent: { label: 'Urgent', bg: 'bg-red-100', text: 'text-red-700' },
}
export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers }) {
export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers, teams = [] }) {
const { brands } = useContext(AppContext)
const toast = useToast()
const { t } = useLanguage()
const [issueData, setIssueData] = useState(null)
const [updates, setUpdates] = useState([])
const [attachments, setAttachments] = useState([])
const [initialLoading, setInitialLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [uploadingFile, setUploadingFile] = useState(false)
const [activeTab, setActiveTab] = useState('details')
// Form state
const [assignedTo, setAssignedTo] = useState('')
const [teamId, setTeamId] = useState('')
const [internalNotes, setInternalNotes] = useState('')
const [resolutionSummary, setResolutionSummary] = useState('')
const [newUpdate, setNewUpdate] = useState('')
@@ -40,6 +32,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
// Modals
const [showResolveModal, setShowResolveModal] = useState(false)
const [showDeclineModal, setShowDeclineModal] = useState(false)
const [confirmDeleteAttId, setConfirmDeleteAttId] = useState(null)
const issueId = issue?.Id || issue?.id
@@ -54,6 +47,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
setUpdates(data.updates || [])
setAttachments(data.attachments || [])
setAssignedTo(data.assigned_to_id || '')
setTeamId(data.team_id || '')
setInternalNotes(data.internal_notes || '')
setResolutionSummary(data.resolution_summary || '')
} catch (err) {
@@ -72,7 +66,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
await loadIssueDetails()
} catch (err) {
console.error('Failed to update status:', err)
alert('Failed to update status')
toast.error(t('issues.failedToUpdateStatus'))
} finally {
setSaving(false)
}
@@ -88,7 +82,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
await loadIssueDetails()
} catch (err) {
console.error('Failed to resolve issue:', err)
alert('Failed to resolve issue')
toast.error(t('issues.failedToResolve'))
} finally {
setSaving(false)
}
@@ -104,7 +98,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
await loadIssueDetails()
} catch (err) {
console.error('Failed to decline issue:', err)
alert('Failed to decline issue')
toast.error(t('issues.failedToDecline'))
} finally {
setSaving(false)
}
@@ -117,7 +111,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
await onUpdate()
} catch (err) {
console.error('Failed to update assignment:', err)
alert('Failed to update assignment')
toast.error(t('issues.failedToUpdateAssignment'))
}
}
@@ -128,7 +122,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
await api.patch(`/issues/${issueId}`, { internal_notes: internalNotes })
} catch (err) {
console.error('Failed to save notes:', err)
alert('Failed to save notes')
toast.error(t('issues.failedToSaveNotes'))
} finally {
setSaving(false)
}
@@ -144,7 +138,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
await loadIssueDetails()
} catch (err) {
console.error('Failed to add update:', err)
alert('Failed to add update')
toast.error(t('issues.failedToAddUpdate'))
} finally {
setSaving(false)
}
@@ -162,27 +156,26 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
e.target.value = '' // Reset input
} catch (err) {
console.error('Failed to upload file:', err)
alert('Failed to upload file')
toast.error(t('issues.failedToUploadFile'))
} finally {
setUploadingFile(false)
}
}
const handleDeleteAttachment = async (attachmentId) => {
if (!confirm('Delete this attachment?')) return
try {
await api.delete(`/issue-attachments/${attachmentId}`)
await loadIssueDetails()
} catch (err) {
console.error('Failed to delete attachment:', err)
alert('Failed to delete attachment')
toast.error(t('issues.failedToDeleteAttachment'))
}
}
const copyTrackingLink = () => {
const url = `${window.location.origin}/track/${issueData.tracking_token}`
navigator.clipboard.writeText(url)
alert('Tracking link copied to clipboard!')
toast.success(t('issues.trackingLinkCopied'))
}
const formatDate = (dateStr) => {
@@ -199,31 +192,33 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
if (initialLoading || !issueData) {
return (
<SlidePanel onClose={onClose} maxWidth="600px">
<TabbedModal onClose={onClose} size="lg">
<div className="flex items-center justify-center h-96">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-primary"></div>
</div>
</SlidePanel>
</TabbedModal>
)
}
const statusConfig = STATUS_CONFIG[issueData.status] || STATUS_CONFIG.new
const priorityConfig = PRIORITY_CONFIG[issueData.priority] || PRIORITY_CONFIG.medium
const tabs = [
{ key: 'details', label: t('issues.details') || 'Details', icon: FileEdit },
{ key: 'actions', label: t('issues.actions') || 'Actions', icon: Wrench },
{ key: 'updates', label: t('issues.updates') || 'Updates', icon: MessageSquare, badge: updates.length },
{ key: 'attachments', label: t('issues.attachments') || 'Attachments', icon: Paperclip, badge: attachments.length },
]
return (
<>
<SlidePanel
<TabbedModal
onClose={onClose}
maxWidth="600px"
size="lg"
header={
<div className="p-4 border-b border-border bg-surface-secondary">
<div className="flex items-start justify-between gap-3 mb-3">
<h2 className="text-lg font-bold text-text-primary flex-1">{issueData.title}</h2>
<button onClick={onClose} className="p-1 hover:bg-surface-tertiary rounded">
<X className="w-5 h-5" />
</button>
</div>
<div className="flex items-center gap-2 flex-wrap">
<>
<h2 className="text-lg font-bold text-text-primary">{issueData.title}</h2>
<div className="flex items-center gap-2 flex-wrap mt-2">
<span className={`text-xs px-2 py-1 rounded-full font-medium flex items-center gap-1.5 ${statusConfig.bg} ${statusConfig.text}`}>
<span className={`w-1.5 h-1.5 rounded-full ${statusConfig.dot}`}></span>
{statusConfig.label}
@@ -243,80 +238,115 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
</span>
)}
</div>
</div>
</>
}
tabs={tabs}
activeTab={activeTab}
onTabChange={setActiveTab}
footer={
<>
<button
onClick={copyTrackingLink}
className="px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light transition-colors flex items-center gap-2"
>
<Copy className="w-4 h-4" />
{t('issues.publicTrackingLink')}
</button>
<button
onClick={onClose}
className="px-4 py-2 bg-surface-secondary text-text-primary rounded-lg text-sm font-medium hover:bg-surface-tertiary transition-colors"
>
{t('common.close') || 'Close'}
</button>
</>
}
>
<div className="p-4 space-y-6">
{/* Details Tab */}
{activeTab === 'details' && (
<div className="p-6 space-y-5">
{/* Submitter Info */}
<div className="bg-surface-secondary rounded-lg p-4">
<h3 className="text-sm font-semibold text-text-primary mb-2">Submitter Information</h3>
<h3 className="text-sm font-semibold text-text-primary mb-2">{t('issues.submitterInfo')}</h3>
<div className="space-y-1 text-sm">
<div><span className="text-text-tertiary">Name:</span> <span className="text-text-primary font-medium">{issueData.submitter_name}</span></div>
<div><span className="text-text-tertiary">Email:</span> <span className="text-text-primary">{issueData.submitter_email}</span></div>
<div><span className="text-text-tertiary">{t('issues.nameLabel')}</span> <span className="text-text-primary font-medium">{issueData.submitter_name}</span></div>
<div><span className="text-text-tertiary">{t('issues.emailLabel')}</span> <span className="text-text-primary">{issueData.submitter_email}</span></div>
{issueData.submitter_phone && (
<div><span className="text-text-tertiary">Phone:</span> <span className="text-text-primary">{issueData.submitter_phone}</span></div>
<div><span className="text-text-tertiary">{t('issues.phoneLabel')}</span> <span className="text-text-primary">{issueData.submitter_phone}</span></div>
)}
<div><span className="text-text-tertiary">Submitted:</span> <span className="text-text-primary">{formatDate(issueData.created_at)}</span></div>
<div><span className="text-text-tertiary">{t('issues.submittedLabel')}</span> <span className="text-text-primary">{formatDate(issueData.created_at)}</span></div>
</div>
</div>
{/* Description */}
<div>
<h3 className="text-sm font-semibold text-text-primary mb-2">Description</h3>
<p className="text-sm text-text-secondary whitespace-pre-wrap">{issueData.description || 'No description provided'}</p>
<h3 className="text-sm font-semibold text-text-primary mb-2">{t('issues.description')}</h3>
<p className="text-sm text-text-secondary whitespace-pre-wrap">{issueData.description || t('issues.noDescription')}</p>
</div>
{/* Assigned To */}
<div>
<label className="block text-sm font-semibold text-text-primary mb-2">Assigned To</label>
<select
<label className="block text-sm font-semibold text-text-primary mb-2">{t('issues.assignedTo')}</label>
<PortalSelect
value={assignedTo}
onChange={(e) => handleAssignmentChange(e.target.value)}
onChange={val => handleAssignmentChange(val)}
options={[{ value: '', label: t('issues.unassigned') }, ...teamMembers.map(member => ({ value: member.id || member._id, label: member.name }))]}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
>
<option value="">Unassigned</option>
{teamMembers.map((member) => (
<option key={member.id || member._id} value={member.id || member._id}>
{member.name}
</option>
))}
</select>
/>
</div>
{/* Team */}
{teams.length > 0 && (
<div>
<label className="block text-sm font-semibold text-text-primary mb-2">{t('issues.team')}</label>
<PortalSelect
value={teamId}
onChange={async (val) => {
const resolvedVal = val || null
setTeamId(resolvedVal || '')
try {
await api.patch(`/issues/${issueId}`, { team_id: resolvedVal })
await onUpdate()
await loadIssueDetails()
} catch (err) {
console.error('Failed to update team:', err)
}
}}
options={[{ value: '', label: t('issues.allTeams') }, ...teams.map(team => ({ value: team.id || team._id, label: team.name }))]}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
/>
</div>
)}
{/* Brand */}
<div>
<label className="block text-sm font-semibold text-text-primary mb-2">Brand</label>
<select
<label className="block text-sm font-semibold text-text-primary mb-2">{t('issues.brandLabel')}</label>
<PortalSelect
value={issueData.brand_id || ''}
onChange={async (e) => {
const val = e.target.value || null;
onChange={async (val) => {
const resolvedVal = val || null;
try {
await api.patch(`/issues/${issueId}`, { brand_id: val });
await api.patch(`/issues/${issueId}`, { brand_id: resolvedVal });
loadIssueDetails();
onUpdate();
} catch {}
}}
options={[{ value: '', label: t('issues.noBrand') }, ...(brands || []).map(b => ({ value: b._id || b.Id, label: b.name }))]}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
>
<option value="">No brand</option>
{(brands || []).map((b) => (
<option key={b._id || b.Id} value={b._id || b.Id}>{b.name}</option>
))}
</select>
/>
</div>
{/* Internal Notes */}
<div>
<label className="block text-sm font-semibold text-text-primary mb-2 flex items-center gap-2">
<Lock className="w-4 h-4" />
Internal Notes (Staff Only)
{t('issues.internalNotes')}
</label>
<textarea
value={internalNotes}
onChange={(e) => setInternalNotes(e.target.value)}
onBlur={handleNotesChange}
rows={4}
placeholder="Internal notes not visible to submitter..."
placeholder={t('issues.internalNotesPlaceholder')}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
/>
</div>
@@ -326,89 +356,79 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
<div className="bg-emerald-50 border border-emerald-200 rounded-lg p-4">
<h3 className="text-sm font-semibold text-emerald-900 mb-2 flex items-center gap-2">
<CheckCircle2 className="w-4 h-4" />
Resolution Summary (Public)
{t('issues.resolutionSummary')}
</h3>
<p className="text-sm text-emerald-800 whitespace-pre-wrap">{issueData.resolution_summary}</p>
{issueData.resolved_at && (
<p className="text-xs text-emerald-600 mt-2">Resolved on {formatDate(issueData.resolved_at)}</p>
<p className="text-xs text-emerald-600 mt-2">{t('issues.resolvedOn')} {formatDate(issueData.resolved_at)}</p>
)}
</div>
)}
</div>
)}
{/* Status Actions */}
{issueData.status !== 'resolved' && issueData.status !== 'declined' && (
<div className="flex gap-2 flex-wrap">
{/* Actions Tab */}
{activeTab === 'actions' && (
<div className="p-6 space-y-5">
{issueData.status !== 'resolved' && issueData.status !== 'declined' ? (
<div className="space-y-3">
{issueData.status === 'new' && (
<button
onClick={() => handleUpdateStatus('acknowledged')}
disabled={saving}
className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50"
className="w-full px-4 py-3 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50 flex items-center justify-center gap-2"
>
<Check className="w-4 h-4 inline mr-1" />
Acknowledge
<Check className="w-4 h-4" />
{t('issues.acknowledge')}
</button>
)}
{(issueData.status === 'new' || issueData.status === 'acknowledged') && (
<button
onClick={() => handleUpdateStatus('in_progress')}
disabled={saving}
className="px-4 py-2 bg-amber-600 text-white rounded-lg text-sm font-medium hover:bg-amber-700 disabled:opacity-50"
className="w-full px-4 py-3 bg-amber-600 text-white rounded-lg text-sm font-medium hover:bg-amber-700 disabled:opacity-50 flex items-center justify-center gap-2"
>
<Clock className="w-4 h-4 inline mr-1" />
Start Work
<Clock className="w-4 h-4" />
{t('issues.startWork')}
</button>
)}
<button
onClick={() => setShowResolveModal(true)}
disabled={saving}
className="px-4 py-2 bg-emerald-600 text-white rounded-lg text-sm font-medium hover:bg-emerald-700 disabled:opacity-50"
className="w-full px-4 py-3 bg-emerald-600 text-white rounded-lg text-sm font-medium hover:bg-emerald-700 disabled:opacity-50 flex items-center justify-center gap-2"
>
<CheckCircle2 className="w-4 h-4 inline mr-1" />
Resolve
<CheckCircle2 className="w-4 h-4" />
{t('issues.resolve')}
</button>
<button
onClick={() => setShowDeclineModal(true)}
disabled={saving}
className="px-4 py-2 bg-gray-600 text-white rounded-lg text-sm font-medium hover:bg-gray-700 disabled:opacity-50"
className="w-full px-4 py-3 bg-gray-600 text-white rounded-lg text-sm font-medium hover:bg-gray-700 disabled:opacity-50 flex items-center justify-center gap-2"
>
<XCircle className="w-4 h-4 inline mr-1" />
Decline
<XCircle className="w-4 h-4" />
{t('issues.decline')}
</button>
</div>
) : (
<div className="text-center py-12">
<CheckCircle2 className="w-10 h-10 mx-auto mb-3 text-text-tertiary" />
<p className="text-sm text-text-tertiary">
{issueData.status === 'resolved' ? t('issues.issueResolved') || 'This issue has been resolved.' : t('issues.issueDeclined') || 'This issue has been declined.'}
</p>
</div>
)}
</div>
)}
{/* Tracking Link */}
<div>
<label className="block text-sm font-semibold text-text-primary mb-2">Public Tracking Link</label>
<div className="flex gap-2">
<input
type="text"
value={`${window.location.origin}/track/${issueData.tracking_token}`}
readOnly
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg bg-surface-secondary"
/>
<button
onClick={copyTrackingLink}
className="px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light transition-colors"
>
<Copy className="w-4 h-4" />
</button>
</div>
</div>
{/* Updates Timeline */}
<div>
<h3 className="text-sm font-semibold text-text-primary mb-3 flex items-center gap-2">
Updates Timeline
<span className="text-xs text-text-tertiary font-normal">({updates.length})</span>
</h3>
{/* Updates Tab */}
{activeTab === 'updates' && (
<div className="p-6 space-y-5">
{/* Add Update */}
<div className="bg-surface-secondary rounded-lg p-3 mb-4">
<div className="bg-surface-secondary rounded-lg p-3">
<textarea
value={newUpdate}
onChange={(e) => setNewUpdate(e.target.value)}
placeholder="Add an update..."
placeholder={t('issues.addUpdatePlaceholder')}
rows={3}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 mb-2"
/>
@@ -421,7 +441,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
className="rounded"
/>
<Eye className="w-4 h-4" />
Make public (visible to submitter)
{t('issues.makePublic')}
</label>
<button
onClick={handleAddUpdate}
@@ -429,7 +449,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
className="px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 transition-colors flex items-center gap-2"
>
<Send className="w-4 h-4" />
Add Update
{t('issues.addUpdate')}
</button>
</div>
</div>
@@ -459,28 +479,22 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
</div>
))}
{updates.length === 0 && (
<p className="text-sm text-text-tertiary text-center py-6">No updates yet</p>
<p className="text-sm text-text-tertiary text-center py-6">{t('issues.noUpdates')}</p>
)}
</div>
</div>
)}
{/* Attachments */}
<div>
<h3 className="text-sm font-semibold text-text-primary mb-3 flex items-center gap-2">
Attachments
<span className="text-xs text-text-tertiary font-normal">({attachments.length})</span>
</h3>
{/* Attachments Tab */}
{activeTab === 'attachments' && (
<div className="p-6 space-y-5">
{/* Upload */}
<label className="block mb-3">
<input type="file" onChange={handleFileUpload} disabled={uploadingFile} className="hidden" />
<div className="border-2 border-dashed border-border rounded-lg p-4 text-center cursor-pointer hover:bg-surface-secondary transition-colors">
<Upload className="w-6 h-6 mx-auto mb-2 text-text-tertiary" />
<p className="text-sm text-text-secondary">
{uploadingFile ? 'Uploading...' : 'Click to upload file'}
</p>
</div>
</label>
<UploadZone
onUpload={handleFileUpload}
uploading={uploadingFile}
label={t('issues.clickToUpload')}
compact
/>
{/* Attachments List */}
<div className="space-y-2">
@@ -491,7 +505,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{att.original_name}</p>
<p className="text-xs text-text-tertiary">
{formatFileSize(att.size)} {att.uploaded_by}
{formatFileSize(att.size)} &bull; {att.uploaded_by}
</p>
</div>
</div>
@@ -502,31 +516,31 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
rel="noopener noreferrer"
className="text-xs text-brand-primary hover:underline"
>
Download
{t('issues.download')}
</a>
<button onClick={() => handleDeleteAttachment(att.Id || att.id)} className="p-1 hover:bg-surface-tertiary rounded">
<button onClick={() => setConfirmDeleteAttId(att.Id || att.id)} className="p-1 hover:bg-surface-tertiary rounded">
<Trash2 className="w-4 h-4 text-red-600" />
</button>
</div>
</div>
))}
{attachments.length === 0 && (
<p className="text-sm text-text-tertiary text-center py-4">No attachments</p>
<p className="text-sm text-text-tertiary text-center py-4">{t('issues.noAttachments')}</p>
)}
</div>
</div>
</div>
</SlidePanel>
)}
</TabbedModal>
{/* Resolve Modal */}
{showResolveModal && (
<Modal isOpen title="Resolve Issue" onClose={() => setShowResolveModal(false)}>
<Modal isOpen title={t('issues.resolveIssue')} onClose={() => setShowResolveModal(false)}>
<div className="space-y-4">
<p className="text-sm text-text-secondary">Provide a resolution summary that will be visible to the submitter.</p>
<p className="text-sm text-text-secondary">{t('issues.resolveSummaryHint')}</p>
<textarea
value={resolutionSummary}
onChange={(e) => setResolutionSummary(e.target.value)}
placeholder="Explain how this issue was resolved..."
placeholder={t('issues.resolutionPlaceholder')}
rows={5}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
/>
@@ -535,14 +549,14 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
onClick={() => setShowResolveModal(false)}
className="px-4 py-2 bg-surface-secondary text-text-primary rounded-lg text-sm font-medium hover:bg-surface-tertiary"
>
Cancel
{t('common.cancel')}
</button>
<button
onClick={handleResolve}
disabled={!resolutionSummary.trim() || saving}
className="px-4 py-2 bg-emerald-600 text-white rounded-lg text-sm font-medium hover:bg-emerald-700 disabled:opacity-50"
>
{saving ? 'Resolving...' : 'Mark as Resolved'}
{saving ? t('issues.resolving') : t('issues.markAsResolved')}
</button>
</div>
</div>
@@ -551,13 +565,13 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
{/* Decline Modal */}
{showDeclineModal && (
<Modal isOpen title="Decline Issue" onClose={() => setShowDeclineModal(false)}>
<Modal isOpen title={t('issues.declineIssue')} onClose={() => setShowDeclineModal(false)}>
<div className="space-y-4">
<p className="text-sm text-text-secondary">Provide a reason for declining this issue. This will be visible to the submitter.</p>
<p className="text-sm text-text-secondary">{t('issues.declineReasonHint')}</p>
<textarea
value={resolutionSummary}
onChange={(e) => setResolutionSummary(e.target.value)}
placeholder="Explain why this issue cannot be addressed..."
placeholder={t('issues.declinePlaceholder')}
rows={5}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
/>
@@ -566,19 +580,32 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
onClick={() => setShowDeclineModal(false)}
className="px-4 py-2 bg-surface-secondary text-text-primary rounded-lg text-sm font-medium hover:bg-surface-tertiary"
>
Cancel
{t('common.cancel')}
</button>
<button
onClick={handleDecline}
disabled={!resolutionSummary.trim() || saving}
className="px-4 py-2 bg-gray-600 text-white rounded-lg text-sm font-medium hover:bg-gray-700 disabled:opacity-50"
>
{saving ? 'Declining...' : 'Decline Issue'}
{saving ? t('issues.declining') : t('issues.declineIssue')}
</button>
</div>
</div>
</Modal>
)}
{/* Delete Attachment Confirmation */}
<Modal
isOpen={!!confirmDeleteAttId}
onClose={() => setConfirmDeleteAttId(null)}
title={t('issues.deleteAttachment')}
isConfirm
danger
onConfirm={() => handleDeleteAttachment(confirmDeleteAttId)}
confirmText={t('common.delete')}
>
{t('issues.deleteAttachmentDesc')}
</Modal>
</>
)
}
+25 -41
View File
@@ -1,24 +1,14 @@
import { useState } from 'react'
import PostCard from './PostCard'
import { useLanguage } from '../i18n/LanguageContext'
const COLUMNS = [
{ id: 'draft', labelKey: 'posts.status.draft', color: 'bg-gray-400' },
{ id: 'in_review', labelKey: 'posts.status.in_review', color: 'bg-amber-400' },
{ id: 'approved', labelKey: 'posts.status.approved', color: 'bg-blue-400' },
{ id: 'scheduled', labelKey: 'posts.status.scheduled', color: 'bg-purple-400' },
{ id: 'published', labelKey: 'posts.status.published', color: 'bg-emerald-400' },
]
export default function KanbanBoard({ posts, onPostClick, onMovePost }) {
export default function KanbanBoard({ columns, items, renderCard, getItemId, onMove, emptyLabel }) {
const { t } = useLanguage()
const [draggedPost, setDraggedPost] = useState(null)
const [draggedItem, setDraggedItem] = useState(null)
const [dragOverCol, setDragOverCol] = useState(null)
const handleDragStart = (e, post) => {
setDraggedPost(post)
const handleDragStart = (e, item) => {
setDraggedItem(item)
e.dataTransfer.effectAllowed = 'move'
// Make the drag image slightly transparent
if (e.target) {
setTimeout(() => e.target.style.opacity = '0.4', 0)
}
@@ -26,7 +16,7 @@ export default function KanbanBoard({ posts, onPostClick, onMovePost }) {
const handleDragEnd = (e) => {
e.target.style.opacity = '1'
setDraggedPost(null)
setDraggedItem(null)
setDragOverCol(null)
}
@@ -36,8 +26,7 @@ export default function KanbanBoard({ posts, onPostClick, onMovePost }) {
setDragOverCol(colId)
}
const handleDragLeave = (e, colId) => {
// Only clear if we're actually leaving the column (not entering a child)
const handleDragLeave = (e) => {
if (!e.currentTarget.contains(e.relatedTarget)) {
setDragOverCol(null)
}
@@ -46,59 +35,54 @@ export default function KanbanBoard({ posts, onPostClick, onMovePost }) {
const handleDrop = (e, colId) => {
e.preventDefault()
setDragOverCol(null)
if (draggedPost && draggedPost.status !== colId) {
onMovePost(draggedPost._id, colId)
if (draggedItem && draggedItem.status !== colId) {
onMove(getItemId(draggedItem), colId)
}
setDraggedPost(null)
setDraggedItem(null)
}
return (
<div className="flex gap-4 overflow-x-auto pb-4">
{COLUMNS.map((col) => {
const colPosts = posts.filter((p) => p.status === col.id)
const isOver = dragOverCol === col.id && draggedPost?.status !== col.id
{columns.map((col) => {
const colItems = items.filter((item) => item.status === col.id)
const isOver = dragOverCol === col.id && draggedItem?.status !== col.id
return (
<div key={col.id} className="flex-shrink-0 w-72">
<div key={col.id} className="min-w-[240px] flex-1">
{/* Column header */}
<div className="flex items-center gap-2 mb-3 px-1">
<div className="flex items-center gap-2 mb-3">
<div className={`w-2.5 h-2.5 rounded-full ${col.color}`} />
<h4 className="text-sm font-semibold text-text-primary">{t(col.labelKey)}</h4>
<span className="text-xs font-medium text-text-tertiary bg-surface-tertiary px-2 py-0.5 rounded-full ml-auto">
{colPosts.length}
<h4 className="text-sm font-semibold text-text-primary">{col.label}</h4>
<span className="text-xs font-medium text-text-tertiary bg-surface-tertiary px-2 py-0.5 rounded-full">
{colItems.length}
</span>
</div>
{/* Column body — drop zone */}
<div
className={`kanban-column rounded-xl p-2 space-y-2 border-2 transition-colors min-h-[120px] ${
className={`rounded-xl p-2 space-y-2 border-2 transition-colors min-h-[200px] ${
isOver
? 'bg-brand-primary/5 border-brand-primary/40 border-dashed'
: 'bg-surface-secondary border-border-light border-solid'
}`}
onDragOver={(e) => handleDragOver(e, col.id)}
onDragLeave={(e) => handleDragLeave(e, col.id)}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, col.id)}
>
{colPosts.length === 0 ? (
{colItems.length === 0 ? (
<div className={`py-8 text-center text-xs ${isOver ? 'text-brand-primary font-medium' : 'text-text-tertiary'}`}>
{isOver ? t('posts.dropHere') : t('posts.noPosts')}
{isOver ? t('posts.dropHere') : (emptyLabel || t('posts.noPosts'))}
</div>
) : (
colPosts.map((post) => (
colItems.map((item) => (
<div
key={post._id}
key={getItemId(item)}
draggable
onDragStart={(e) => handleDragStart(e, post)}
onDragStart={(e) => handleDragStart(e, item)}
onDragEnd={handleDragEnd}
className="cursor-grab active:cursor-grabbing"
>
<PostCard
post={post}
onClick={() => onPostClick(post)}
onMove={onMovePost}
compact
/>
{renderCard(item)}
</div>
))
)}
+56
View File
@@ -0,0 +1,56 @@
import { format } from 'date-fns'
import { Clock } from 'lucide-react'
import { getInitials } from '../utils/api'
import BrandBadge from './BrandBadge'
import { useLanguage } from '../i18n/LanguageContext'
export default function KanbanCard({ title, thumbnail, brandName, tags, assigneeName, date, dateOverdue, onClick, children }) {
const { t } = useLanguage()
return (
<div
onClick={onClick}
className="bg-surface rounded-lg p-3 border border-border hover:border-brand-primary/30 cursor-pointer transition-all group overflow-hidden"
>
{/* Thumbnail */}
{thumbnail && (
<div className="w-[calc(100%+1.5rem)] h-32 -mx-3 -mt-3 mb-2 rounded-t-lg overflow-hidden">
<img src={thumbnail} alt="" className="w-full h-full object-cover" loading="lazy" />
</div>
)}
{/* Title */}
<h5 className="text-sm font-medium text-text-primary line-clamp-2 leading-snug mb-2">{title}</h5>
{/* Tags row: brand + extra tags */}
<div className="flex items-center gap-2 flex-wrap">
{brandName && <BrandBadge brand={brandName} />}
{tags}
</div>
{/* Footer: assignee + date */}
<div className="flex items-center justify-between mt-3 pt-2 border-t border-border-light">
{assigneeName ? (
<div className="flex items-center gap-1.5">
<div className="w-5 h-5 rounded-full bg-brand-primary/10 text-brand-primary flex items-center justify-center text-[10px] font-semibold">
{getInitials(assigneeName)}
</div>
<span className="text-xs text-text-tertiary">{assigneeName}</span>
</div>
) : (
<span className="text-xs text-text-tertiary">{t('common.unassigned')}</span>
)}
{date && (
<span className={`text-[10px] flex items-center gap-1 ${dateOverdue ? 'text-red-500 font-medium' : 'text-text-tertiary'}`}>
<Clock className="w-3 h-3" />
{format(new Date(date), 'MMM d')}
</span>
)}
</div>
{/* Optional extra content (quick actions, delete overlay, etc.) */}
{children}
</div>
)
}
+2 -2
View File
@@ -15,7 +15,7 @@ const ROLE_BADGES = {
strategist: { bg: 'bg-rose-50', text: 'text-rose-700', label: 'Strategist' },
superadmin: { bg: 'bg-red-50', text: 'text-red-700', label: 'Super Admin' },
contributor: { bg: 'bg-slate-50', text: 'text-slate-700', label: 'Contributor' },
default: { bg: 'bg-gray-50', text: 'text-gray-700', label: 'Team Member' },
default: { bg: 'bg-gray-50', text: 'text-text-secondary', label: 'Team Member' },
}
export default function MemberCard({ member, onClick }) {
@@ -33,7 +33,7 @@ export default function MemberCard({ member, onClick }) {
return (
<div
onClick={() => onClick?.(member)}
className="bg-white rounded-xl border border-border p-5 card-hover cursor-pointer text-center"
className="bg-surface rounded-xl border border-border p-5 card-hover cursor-pointer text-center"
>
{/* Avatar */}
<div className={`w-16 h-16 rounded-full bg-gradient-to-br ${avatarColors[colorIndex]} flex items-center justify-center text-white text-xl font-bold mx-auto mb-3`}>
+44 -19
View File
@@ -1,15 +1,38 @@
import { useEffect } from 'react'
import { useEffect, useRef, useCallback } from 'react'
import { createPortal } from 'react-dom'
import { X, AlertTriangle } from 'lucide-react'
import { useLanguage } from '../i18n/LanguageContext'
function useFocusTrap(ref, isOpen) {
useEffect(() => {
if (!isOpen || !ref.current) return
const el = ref.current
const focusable = el.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
if (focusable.length > 0) focusable[0].focus()
const handleTab = (e) => {
if (e.key !== 'Tab' || focusable.length === 0) return
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (e.shiftKey) {
if (document.activeElement === first) { e.preventDefault(); last.focus() }
} else {
if (document.activeElement === last) { e.preventDefault(); first.focus() }
}
}
el.addEventListener('keydown', handleTab)
return () => el.removeEventListener('keydown', handleTab)
}, [isOpen, ref])
}
export default function Modal({
isOpen,
onClose,
title,
children,
size = 'md',
// Confirmation mode props
isConfirm = false,
confirmText,
cancelText,
@@ -17,10 +40,11 @@ export default function Modal({
danger = false,
}) {
const { t } = useLanguage()
const modalRef = useRef(null)
// Default translations
const finalConfirmText = confirmText || (danger ? t('common.delete') : t('common.save'))
const finalCancelText = cancelText || t('common.cancel')
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden'
@@ -30,6 +54,12 @@ export default function Modal({
return () => { document.body.style.overflow = '' }
}, [isOpen])
const handleKeyDown = useCallback((e) => {
if (e.key === 'Escape') onClose()
}, [onClose])
useFocusTrap(modalRef, isOpen)
if (!isOpen) return null
const sizeClasses = {
@@ -39,25 +69,23 @@ export default function Modal({
xl: 'max-w-4xl',
}
// Confirmation dialog
if (isConfirm) {
return createPortal(
<div className="fixed inset-0 z-[9999] flex items-center justify-center px-4">
{/* Backdrop */}
<div className="fixed inset-0 z-[9999] flex items-center justify-center px-4" onKeyDown={handleKeyDown} ref={modalRef}>
<div
className="fixed inset-0 bg-black/40 backdrop-blur-sm animate-backdrop-in"
onClick={onClose}
aria-label="Close dialog"
/>
{/* Modal content */}
<div className="relative bg-white rounded-2xl shadow-2xl w-full max-w-md animate-scale-in">
<div className="relative bg-surface rounded-2xl shadow-2xl w-full max-w-md animate-scale-in" role="dialog" aria-modal="true" aria-labelledby="modal-title">
<div className="p-6">
{danger && (
<div className="w-12 h-12 rounded-full bg-red-100 flex items-center justify-center mx-auto mb-4">
<AlertTriangle className="w-6 h-6 text-red-600" />
</div>
)}
<h3 className="text-lg font-semibold text-text-primary text-center mb-2">{title}</h3>
<h3 id="modal-title" className="text-lg font-semibold text-text-primary text-center mb-2">{title}</h3>
<div className="text-sm text-text-secondary text-center mb-6">
{children}
</div>
@@ -89,30 +117,27 @@ export default function Modal({
)
}
// Regular modal
return createPortal(
<div className="fixed inset-0 z-[9999] flex items-start justify-center pt-[10vh] px-4">
{/* Backdrop */}
<div className="fixed inset-0 z-[9999] flex items-start justify-center pt-[10vh] px-4" onKeyDown={handleKeyDown} ref={modalRef}>
<div
className="fixed inset-0 bg-black/40 backdrop-blur-sm animate-backdrop-in"
onClick={onClose}
aria-label="Close dialog"
/>
{/* Modal content */}
<div className={`relative bg-white rounded-2xl shadow-2xl w-full ${sizeClasses[size]} max-h-[80vh] flex flex-col animate-scale-in`}>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border shrink-0">
<h3 className="text-lg font-semibold text-text-primary">{title}</h3>
<div className={`relative bg-surface rounded-2xl shadow-2xl w-full ${sizeClasses[size]} max-h-[80vh] overflow-y-auto animate-scale-in`} role="dialog" aria-modal="true" aria-labelledby="modal-title">
<div className="sticky top-0 z-10 bg-surface rounded-t-2xl flex items-center justify-between px-6 py-4 border-b border-border">
<h3 id="modal-title" className="text-lg font-semibold text-text-primary">{title}</h3>
<button
onClick={onClose}
className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors"
aria-label="Close dialog"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Body */}
<div className="px-6 py-4 overflow-y-auto flex-1">
<div className="px-6 py-4">
{children}
</div>
</div>
+123
View File
@@ -0,0 +1,123 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import { createPortal } from 'react-dom'
import { ChevronDown, Check } from 'lucide-react'
/**
* Portal-based select dropdown that renders options outside any overflow/stacking context.
* Drop-in replacement for <select> inside SlidePanel/TabbedModal/Modal.
*
* Props:
* value - current value
* onChange - (value) => void
* options - [{ value, label }] or children-based (fallback to native if no options)
* placeholder - text when no value selected
* className - additional classes on the trigger button
* disabled - boolean
*/
export default function PortalSelect({ value, onChange, options = [], placeholder = '—', className = '', disabled = false }) {
const [open, setOpen] = useState(false)
const triggerRef = useRef(null)
const dropdownRef = useRef(null)
const [pos, setPos] = useState({ top: 0, left: 0, width: 0 })
const selectedOption = options.find(o => String(o.value) === String(value))
const displayText = selectedOption?.label || placeholder
const updatePosition = useCallback(() => {
if (!triggerRef.current) return
const rect = triggerRef.current.getBoundingClientRect()
const spaceBelow = window.innerHeight - rect.bottom
const dropdownHeight = Math.min(options.length * 32 + 8, 240)
const showAbove = spaceBelow < dropdownHeight && rect.top > dropdownHeight
setPos({
top: showAbove ? rect.top - dropdownHeight - 4 : rect.bottom + 4,
left: rect.left,
width: Math.max(rect.width, 160),
})
}, [options.length])
const handleOpen = () => {
if (disabled) return
updatePosition()
setOpen(true)
}
const handleSelect = (val) => {
onChange(val)
setOpen(false)
}
// Close on outside click
useEffect(() => {
if (!open) return
const handleClick = (e) => {
if (triggerRef.current?.contains(e.target)) return
if (dropdownRef.current?.contains(e.target)) return
setOpen(false)
}
const handleEsc = (e) => { if (e.key === 'Escape') setOpen(false) }
const handleScroll = () => updatePosition()
document.addEventListener('mousedown', handleClick)
document.addEventListener('keydown', handleEsc)
window.addEventListener('scroll', handleScroll, true)
return () => {
document.removeEventListener('mousedown', handleClick)
document.removeEventListener('keydown', handleEsc)
window.removeEventListener('scroll', handleScroll, true)
}
}, [open, updatePosition])
return (
<>
<button
ref={triggerRef}
type="button"
onClick={handleOpen}
disabled={disabled}
role="combobox"
aria-expanded={open}
aria-haspopup="listbox"
className={`flex items-center justify-between gap-1 text-start ${className} ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
>
<span className={`truncate ${selectedOption ? '' : 'text-text-tertiary'}`}>{displayText}</span>
<ChevronDown className={`w-3 h-3 shrink-0 text-text-tertiary transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
{open && createPortal(
<div
ref={dropdownRef}
role="listbox"
className="fixed z-[99999] bg-surface border border-border rounded-lg shadow-lg overflow-y-auto animate-scale-in"
style={{ top: pos.top, left: pos.left, width: pos.width, maxHeight: 240 }}
>
{options.map(opt => {
const isSelected = String(opt.value) === String(value)
return (
<button
key={opt.value}
type="button"
role="option"
aria-selected={String(opt.value) === String(value)}
onClick={() => handleSelect(opt.value)}
className={`w-full flex items-center gap-2 px-3 py-1.5 text-xs text-start transition-colors ${
isSelected
? 'bg-brand-primary/10 text-brand-primary font-medium'
: 'text-text-primary hover:bg-surface-secondary'
}`}
>
<span className="flex-1 truncate">{opt.label}</span>
{isSelected && <Check className="w-3 h-3 shrink-0" />}
</button>
)
})}
{options.length === 0 && (
<div className="px-3 py-2 text-xs text-text-tertiary text-center"></div>
)}
</div>,
document.body
)}
</>
)
}
+4 -3
View File
@@ -8,7 +8,7 @@ import BrandBadge from './BrandBadge'
import StatusBadge from './StatusBadge'
import { PlatformIcons } from './PlatformIcon'
export default function PostCard({ post, onClick, onMove, compact = false }) {
export default function PostCard({ post, onClick, onMove, compact = false, checkboxSlot }) {
const { t } = useLanguage()
const { getBrandName } = useContext(AppContext)
const brandName = getBrandName(post.brand_id || post.brandId) || post.brand_name || post.brand
@@ -23,11 +23,11 @@ export default function PostCard({ post, onClick, onMove, compact = false }) {
return (
<div
onClick={onClick}
className="bg-white rounded-lg p-3 border border-border hover:border-brand-primary/30 cursor-pointer transition-all group overflow-hidden card-hover"
className="bg-surface rounded-lg p-3 border border-border hover:border-brand-primary/30 cursor-pointer transition-all group overflow-hidden card-hover"
>
{post.thumbnail_url && (
<div className="w-[calc(100%+1.5rem)] h-32 -mx-3 -mt-3 mb-2 rounded-t-lg overflow-hidden">
<img src={`http://localhost:3001${post.thumbnail_url}`} alt="" className="w-full h-full object-cover" />
<img src={post.thumbnail_url} alt="" className="w-full h-full object-cover" loading="lazy" />
</div>
)}
@@ -97,6 +97,7 @@ export default function PostCard({ post, onClick, onMove, compact = false }) {
// Table row view
return (
<tr onClick={onClick} className="hover:bg-surface-secondary cursor-pointer group">
{checkboxSlot && <td className="w-10 px-3 py-3" onClick={e => e.stopPropagation()}>{checkboxSlot}</td>}
<td className="px-4 py-3">
<div className="flex items-center gap-3">
<div className="shrink-0">
@@ -0,0 +1,109 @@
import { Send, CheckCircle2, XCircle, Copy, Check, Clock } from 'lucide-react'
import { useLanguage } from '../i18n/LanguageContext'
import ApproverMultiSelect from './ApproverMultiSelect'
export function PostDetailApproval({
form,
update,
post,
isCreateMode,
reviewUrl,
copied,
submittingReview,
saving,
teamMembers,
onSubmitReview,
onCopyReviewLink,
onStatusAction,
}) {
const { t } = useLanguage()
return (
<div className="p-6 space-y-5 w-full">
<div className="bg-surface-secondary rounded-xl p-4">
<label className="block text-xs font-medium text-text-primary mb-2">{t('posts.approvers')}</label>
<ApproverMultiSelect
users={teamMembers || []}
selected={form.approver_ids || []}
onChange={ids => update('approver_ids', ids)}
/>
</div>
{!isCreateMode && (
<div className="space-y-4">
{/* Approval status cards */}
{form.status === 'approved' && post.approved_by_name && (
<div className="bg-emerald-50 border border-emerald-200 rounded-xl p-4 flex items-start gap-3">
<div className="w-8 h-8 rounded-full bg-emerald-100 flex items-center justify-center shrink-0 mt-0.5">
<CheckCircle2 className="w-4 h-4 text-emerald-600" />
</div>
<div>
<p className="text-sm font-semibold text-emerald-800">{t('posts.approvedBy')} {post.approved_by_name}</p>
{post.feedback && <p className="text-xs text-emerald-700 mt-1.5 leading-relaxed">{post.feedback}</p>}
</div>
</div>
)}
{form.status === 'rejected' && post.approved_by_name && (
<div className="bg-red-50 border border-red-200 rounded-xl p-4 flex items-start gap-3">
<div className="w-8 h-8 rounded-full bg-red-100 flex items-center justify-center shrink-0 mt-0.5">
<XCircle className="w-4 h-4 text-red-600" />
</div>
<div>
<p className="text-sm font-semibold text-red-800">{t('posts.rejectedBy')} {post.approved_by_name}</p>
{post.feedback && <p className="text-xs text-red-700 mt-1.5 leading-relaxed">{post.feedback}</p>}
</div>
</div>
)}
{form.status === 'in_review' && (
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4 text-center">
<div className="w-10 h-10 rounded-full bg-amber-100 flex items-center justify-center mx-auto mb-2">
<Clock className="w-5 h-5 text-amber-600" />
</div>
<p className="text-sm font-semibold text-amber-800">{t('posts.awaitingReview')}</p>
<p className="text-xs text-amber-600 mt-1">{t('posts.awaitingReviewDesc')}</p>
</div>
)}
{/* Review link */}
{reviewUrl && (
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
<div className="text-xs font-semibold text-blue-900 mb-2.5">{t('posts.reviewLinkTitle')}</div>
<div className="flex items-center gap-2">
<input type="text" value={reviewUrl} readOnly className="flex-1 px-3 py-2 text-xs bg-surface border border-blue-200 rounded-lg font-mono" />
<button onClick={onCopyReviewLink} className="p-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors shadow-sm">
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
</button>
</div>
</div>
)}
{/* Action buttons */}
<div className="flex gap-3">
{!reviewUrl && (
<button
onClick={onSubmitReview}
disabled={submittingReview}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-amber-500 text-white rounded-xl hover:bg-amber-600 transition-colors font-medium text-sm disabled:opacity-50 shadow-sm"
>
<Send className="w-4 h-4" />
{submittingReview ? t('posts.submitting') : t('posts.sendToReview')}
</button>
)}
{form.status === 'approved' && (
<button
onClick={() => onStatusAction('scheduled')}
disabled={saving}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-purple-600 text-white rounded-xl hover:bg-purple-700 transition-colors font-medium text-sm disabled:opacity-50 shadow-sm"
>
{t('posts.schedule')}
</button>
)}
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,247 @@
import { useState, useRef } from 'react'
import { X, Upload, FileText, FolderOpen, Image as ImageIcon, Music, Film } from 'lucide-react'
import { useLanguage } from '../i18n/LanguageContext'
export function PostDetailAttachments({
attachments,
uploading,
onFileUpload,
onDeleteAttachment,
onAttachAsset,
}) {
const { t } = useLanguage()
const imageInputRef = useRef(null)
const [dragActive, setDragActive] = useState(false)
const [showAssetPicker, setShowAssetPicker] = useState(false)
const [availableAssets, setAvailableAssets] = useState([])
const [assetSearch, setAssetSearch] = useState('')
const handleDrop = (e) => {
e.preventDefault(); e.stopPropagation(); setDragActive(false)
if (e.dataTransfer.files?.length) onFileUpload(e.dataTransfer.files)
}
const openAssetPicker = async () => {
const { api } = await import('../utils/api')
try {
const data = await api.get('/assets')
setAvailableAssets(Array.isArray(data) ? data : [])
} catch {
setAvailableAssets([])
}
setAssetSearch('')
setShowAssetPicker(true)
}
const handleAttachAsset = async (assetId) => {
await onAttachAsset(assetId)
setShowAssetPicker(false)
}
const images = attachments.filter(a => (a.mime_type || a.mimeType || '').startsWith('image/'))
const audio = attachments.filter(a => (a.mime_type || a.mimeType || '').startsWith('audio/'))
const videos = attachments.filter(a => (a.mime_type || a.mimeType || '').startsWith('video/'))
const others = attachments.filter(a => {
const mime = a.mime_type || a.mimeType || ''
return !mime.startsWith('image/') && !mime.startsWith('audio/') && !mime.startsWith('video/')
})
return (
<div className="space-y-4">
{/* Images */}
<div>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-1.5 text-xs font-medium text-text-secondary">
<ImageIcon className="w-3.5 h-3.5" />
{t('posts.images')}
{images.length > 0 && <span className="text-text-tertiary">({images.length})</span>}
</div>
<label className="flex items-center gap-1 px-2 py-1 text-[11px] font-medium text-brand-primary hover:bg-brand-primary/5 rounded cursor-pointer transition-colors">
<Upload className="w-3 h-3" />
{t('posts.addImage')}
<input ref={imageInputRef} type="file" multiple accept="image/*" className="hidden"
onChange={e => { onFileUpload(e.target.files); e.target.value = '' }} />
</label>
</div>
{images.length > 0 && (
<div className="grid grid-cols-3 gap-2">
{images.map(att => {
const attUrl = att.url || `/api/uploads/${att.filename}`
const name = att.original_name || att.originalName || att.filename
const attId = att.id || att._id
return (
<div key={attId} className="relative group/att border border-border rounded-lg overflow-hidden bg-surface">
<div className="h-20 relative">
<a href={attUrl} target="_blank" rel="noopener noreferrer" className="block h-full">
<img src={attUrl} alt={name} className="absolute inset-0 w-full h-full object-cover" />
</a>
<button onClick={() => onDeleteAttachment(attId)}
className="absolute top-1 end-1 p-1 bg-black/50 hover:bg-red-500 rounded-full text-white opacity-0 group-hover/att:opacity-100 transition-opacity"
title={t('common.delete')}><X className="w-2.5 h-2.5" /></button>
</div>
<div className="px-2 py-1 text-[10px] text-text-tertiary truncate border-t border-border-light">{name}</div>
</div>
)
})}
</div>
)}
</div>
{/* Audio */}
{audio.length > 0 && (
<div>
<div className="flex items-center gap-1.5 text-xs font-medium text-text-secondary mb-2">
<Music className="w-3.5 h-3.5" />
{t('posts.audio')} <span className="text-text-tertiary">({audio.length})</span>
</div>
<div className="space-y-2">
{audio.map(att => {
const attUrl = att.url || `/api/uploads/${att.filename}`
const name = att.original_name || att.originalName || att.filename
const attId = att.id || att._id
return (
<div key={attId} className="flex items-center gap-2 border border-border rounded-lg p-2 bg-surface group/att">
<Music className="w-4 h-4 text-text-tertiary shrink-0" />
<span className="text-xs text-text-secondary truncate flex-1">{name}</span>
<audio src={attUrl} controls className="h-7 max-w-[160px]" />
<button onClick={() => onDeleteAttachment(attId)}
className="p-1 text-text-tertiary hover:text-red-500 opacity-0 group-hover/att:opacity-100 transition-opacity"
title={t('common.delete')}><X className="w-3 h-3" /></button>
</div>
)
})}
</div>
</div>
)}
{/* Videos */}
{videos.length > 0 && (
<div>
<div className="flex items-center gap-1.5 text-xs font-medium text-text-secondary mb-2">
<Film className="w-3.5 h-3.5" />
{t('posts.videos')} <span className="text-text-tertiary">({videos.length})</span>
</div>
<div className="space-y-2">
{videos.map(att => {
const attUrl = att.url || `/api/uploads/${att.filename}`
const name = att.original_name || att.originalName || att.filename
const attId = att.id || att._id
return (
<div key={attId} className="border border-border rounded-lg overflow-hidden bg-surface group/att">
<video src={attUrl} controls className="w-full max-h-40" />
<div className="flex items-center justify-between px-2 py-1 border-t border-border-light">
<span className="text-[10px] text-text-tertiary truncate">{name}</span>
<button onClick={() => onDeleteAttachment(attId)}
className="p-1 text-text-tertiary hover:text-red-500 opacity-0 group-hover/att:opacity-100 transition-opacity"
title={t('common.delete')}><X className="w-3 h-3" /></button>
</div>
</div>
)
})}
</div>
</div>
)}
{/* Other files */}
{others.length > 0 && (
<div>
<div className="flex items-center gap-1.5 text-xs font-medium text-text-secondary mb-2">
<FileText className="w-3.5 h-3.5" />
{t('posts.otherFiles')} <span className="text-text-tertiary">({others.length})</span>
</div>
<div className="grid grid-cols-3 gap-2">
{others.map(att => {
const attUrl = att.url || `/api/uploads/${att.filename}`
const name = att.original_name || att.originalName || att.filename
const attId = att.id || att._id
return (
<div key={attId} className="relative group/att border border-border rounded-lg overflow-hidden bg-surface">
<a href={attUrl} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 p-3 h-16">
<FileText className="w-6 h-6 text-text-tertiary shrink-0" />
<span className="text-xs text-text-secondary truncate">{name}</span>
</a>
<button onClick={() => onDeleteAttachment(attId)}
className="absolute top-1 end-1 p-1 bg-black/50 hover:bg-red-500 rounded-full text-white opacity-0 group-hover/att:opacity-100 transition-opacity"
title={t('common.delete')}><X className="w-2.5 h-2.5" /></button>
</div>
)
})}
</div>
</div>
)}
{/* Drag and drop zone */}
<div
className={`border-2 border-dashed rounded-lg p-3 text-center cursor-pointer transition-colors ${
dragActive ? 'border-brand-primary bg-brand-primary/5' : 'border-border hover:border-brand-primary/40'
}`}
onDragEnter={e => { e.preventDefault(); setDragActive(true) }}
onDragLeave={e => { e.preventDefault(); setDragActive(false) }}
onDragOver={e => e.preventDefault()}
onDrop={handleDrop}
>
<Upload className={`w-4 h-4 text-text-tertiary mx-auto mb-1 ${uploading ? 'animate-pulse' : ''}`} />
<p className="text-[11px] text-text-secondary">
{dragActive ? t('posts.dropFiles') : t('posts.dragToUpload')}
</p>
</div>
<button
type="button"
onClick={openAssetPicker}
className="flex items-center gap-2 px-3 py-2 text-sm text-text-secondary border border-border rounded-lg hover:bg-surface-tertiary transition-colors w-full justify-center"
>
<FolderOpen className="w-4 h-4" />
{t('posts.attachFromAssets')}
</button>
{showAssetPicker && (
<div className="border border-border rounded-lg p-3 bg-surface-secondary">
<div className="flex items-center justify-between mb-2">
<p className="text-xs font-medium text-text-secondary">{t('posts.selectAssets')}</p>
<button onClick={() => setShowAssetPicker(false)} className="p-1 text-text-tertiary hover:text-text-primary">
<X className="w-3.5 h-3.5" />
</button>
</div>
<input
type="text"
value={assetSearch}
onChange={e => setAssetSearch(e.target.value)}
placeholder={t('common.search')}
className="w-full px-3 py-1.5 text-xs border border-border rounded-lg mb-2 focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
/>
<div className="grid grid-cols-4 gap-2 max-h-48 overflow-y-auto">
{availableAssets
.filter(a => !assetSearch || (a.original_name || a.filename || '').toLowerCase().includes(assetSearch.toLowerCase()))
.map(asset => {
const isImage = asset.mime_type?.startsWith('image/')
const assetUrl = `/api/uploads/${asset.filename}`
const name = asset.original_name || asset.filename
return (
<button
key={asset.id || asset._id}
onClick={() => handleAttachAsset(asset.id || asset._id)}
className="block w-full border border-border rounded-lg overflow-hidden bg-surface hover:border-brand-primary hover:ring-2 hover:ring-brand-primary/20 transition-all text-start"
>
<div className="aspect-square relative">
{isImage ? (
<img src={assetUrl} alt={name} className="absolute inset-0 w-full h-full object-cover" />
) : (
<div className="absolute inset-0 flex items-center justify-center bg-surface-tertiary">
<FileText className="w-6 h-6 text-text-tertiary" />
</div>
)}
</div>
<div className="px-1.5 py-1 text-[10px] text-text-tertiary truncate border-t border-border-light">{name}</div>
</button>
)
})}
</div>
{availableAssets.filter(a => !assetSearch || (a.original_name || a.filename || '').toLowerCase().includes(assetSearch.toLowerCase())).length === 0 && (
<p className="text-xs text-text-tertiary text-center py-4">{t('posts.noAssetsFound')}</p>
)}
</div>
)}
</div>
)
}
+366 -325
View File
@@ -1,31 +1,46 @@
import { useState, useEffect, useRef } from 'react'
import { X, Trash2, Upload, FileText, Link2, ExternalLink, FolderOpen } from 'lucide-react'
import { Trash2, XCircle, FileEdit, Layers, Share2, ShieldCheck, MessageSquare } from 'lucide-react'
import { useLanguage } from '../i18n/LanguageContext'
import { api, PLATFORMS, getBrandColor } from '../utils/api'
import { api, getBrandColor } from '../utils/api'
import CommentsSection from './CommentsSection'
import Modal from './Modal'
import SlidePanel from './SlidePanel'
import CollapsibleSection from './CollapsibleSection'
import TabbedModal from './TabbedModal'
import { useToast } from './ToastContainer'
import { PostDetailVersions } from './PostDetailVersions'
import { PostDetailPlatforms } from './PostDetailPlatforms'
import { PostDetailApproval } from './PostDetailApproval'
import { PostDetailAttachments } from './PostDetailAttachments'
const TABS = ['details', 'versions', 'platforms', 'approval', 'discussion']
export default function PostDetailPanel({ post, onClose, onSave, onDelete, brands, teamMembers, campaigns }) {
const { t, lang } = useLanguage()
const fileInputRef = useRef(null)
const toast = useToast()
const versionFileInputRef = useRef(null)
const [activeTab, setActiveTab] = useState('details')
const [form, setForm] = useState({})
const [dirty, setDirty] = useState(false)
const [saving, setSaving] = useState(false)
const [publishError, setPublishError] = useState('')
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
// Attachments state
// Review state
const [submittingReview, setSubmittingReview] = useState(false)
const [copied, setCopied] = useState(false)
// Attachments state (non-versioned, legacy)
const [attachments, setAttachments] = useState([])
const [uploading, setUploading] = useState(false)
const [dragActive, setDragActive] = useState(false)
const [showAssetPicker, setShowAssetPicker] = useState(false)
const [availableAssets, setAvailableAssets] = useState([])
const [assetSearch, setAssetSearch] = useState('')
// Versions state
const [versions, setVersions] = useState([])
const [selectedVersion, setSelectedVersion] = useState(null)
const [versionData, setVersionData] = useState(null)
const [uploadingVersionFile, setUploadingVersionFile] = useState(false)
const postId = post?._id || post?.id
const isCreateMode = !postId
const reviewUrl = post?.approval_token ? `${window.location.origin}/review-post/${post.approval_token}` : ''
useEffect(() => {
if (post) {
@@ -36,14 +51,19 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
platforms: post.platforms || (post.platform ? [post.platform] : []),
status: post.status || 'draft',
assigned_to: post.assignedTo || post.assigned_to || '',
scheduled_date: post.scheduledDate ? new Date(post.scheduledDate).toISOString().slice(0, 16) : '',
scheduled_date: post.scheduledDate ? new Date(post.scheduledDate).toISOString().slice(0, 10) : (post.scheduled_date ? new Date(post.scheduled_date).toISOString().slice(0, 10) : ''),
notes: post.notes || '',
campaign_id: post.campaignId || post.campaign_id || '',
publication_links: post.publication_links || post.publicationLinks || [],
approver_ids: post.approvers?.map(a => String(a.id)) || (post.approver_ids ? post.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : []),
})
setDirty(isCreateMode)
setPublishError('')
if (!isCreateMode) loadAttachments()
setActiveTab('details')
if (!isCreateMode) {
loadAttachments()
loadVersions()
}
}
}, [post])
@@ -53,6 +73,7 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
{ value: 'draft', label: t('posts.status.draft') },
{ value: 'in_review', label: t('posts.status.in_review') },
{ value: 'approved', label: t('posts.status.approved') },
{ value: 'rejected', label: t('posts.status.rejected') },
{ value: 'scheduled', label: t('posts.status.scheduled') },
{ value: 'published', label: t('posts.status.published') },
]
@@ -91,9 +112,11 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
notes: form.notes,
campaign_id: form.campaign_id ? Number(form.campaign_id) : null,
publication_links: form.publication_links || [],
approver_ids: (form.approver_ids || []).length > 0 ? form.approver_ids.join(',') : null,
}
if (data.status === 'published' && data.platforms.length > 0) {
const { PLATFORMS } = await import('../utils/api')
const missingPlatforms = data.platforms.filter(platform => {
const link = (data.publication_links || []).find(l => l.platform === platform)
return !link || !link.url || !link.url.trim()
@@ -118,18 +141,53 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
}
}
const handleStatusAction = async (newStatus) => {
if (!postId || saving) return
setSaving(true)
try {
await onSave(postId, { ...form, status: newStatus, approver_ids: (form.approver_ids || []).length > 0 ? form.approver_ids.join(',') : null })
setForm(f => ({ ...f, status: newStatus }))
setDirty(false)
} finally {
setSaving(false)
}
}
const handleSubmitReview = async () => {
if (!postId || submittingReview) return
if (dirty) await handleSave()
setSubmittingReview(true)
try {
await api.post(`/posts/${postId}/submit-review`)
setForm(f => ({ ...f, status: 'in_review' }))
toast.success(t('posts.submittedForReview'))
onSave(postId, {})
} catch (err) {
toast.error(err.message || t('posts.failedSubmitReview'))
} finally {
setSubmittingReview(false)
}
}
const copyReviewLink = () => {
navigator.clipboard.writeText(reviewUrl)
setCopied(true)
toast.success(t('posts.reviewLinkCopied'))
setTimeout(() => setCopied(false), 2000)
}
const confirmDelete = async () => {
setShowDeleteConfirm(false)
await onDelete(postId)
onClose()
}
// ─── Attachments ──────────────────────────────
// ─── Legacy Attachments ──────────────────────────
async function loadAttachments() {
if (!postId) return
try {
const data = await api.get(`/posts/${postId}/attachments`)
setAttachments(Array.isArray(data) ? data : (data.data || []))
setAttachments(Array.isArray(data) ? data : [])
} catch {
setAttachments([])
}
@@ -160,31 +218,105 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
}
}
const openAssetPicker = async () => {
try {
const data = await api.get('/assets')
setAvailableAssets(Array.isArray(data) ? data : (data.data || []))
} catch {
setAvailableAssets([])
}
setAssetSearch('')
setShowAssetPicker(true)
}
const handleAttachAsset = async (assetId) => {
if (!postId) return
try {
await api.post(`/posts/${postId}/attachments/from-asset`, { asset_id: assetId })
loadAttachments()
setShowAssetPicker(false)
} catch (err) {
console.error('Attach asset failed:', err)
}
}
const handleDrop = (e) => {
e.preventDefault(); e.stopPropagation(); setDragActive(false)
if (e.dataTransfer.files?.length) handleFileUpload(e.dataTransfer.files)
// ─── Versions ──────────────────────────
async function loadVersions() {
if (!postId) return
try {
const data = await api.get(`/posts/${postId}/versions`)
const vList = Array.isArray(data) ? data : []
setVersions(vList)
if (vList.length > 0) {
const latest = vList[vList.length - 1]
setSelectedVersion(latest)
loadVersionData(latest.Id || latest.id || latest._id)
} else {
setSelectedVersion(null)
setVersionData(null)
}
} catch {
setVersions([])
}
}
async function loadVersionData(versionId) {
if (!postId || !versionId) return
try {
const data = await api.get(`/posts/${postId}/versions/${versionId}`)
setVersionData(data)
} catch {
setVersionData(null)
}
}
const handleSelectVersion = (version) => {
setSelectedVersion(version)
loadVersionData(version.Id || version.id || version._id)
}
const handleCreateVersion = async ({ notes, copy_from_previous }) => {
try {
await api.post(`/posts/${postId}/versions`, {
notes: notes || undefined,
copy_from_previous,
})
loadVersions()
} catch (err) {
console.error('Create version failed:', err)
}
}
const handleAddLanguage = async (languageForm) => {
if (!selectedVersion) return
const vId = selectedVersion.Id || selectedVersion.id || selectedVersion._id
await api.post(`/posts/${postId}/versions/${vId}/texts`, languageForm)
loadVersionData(vId)
}
const handleDeleteLanguage = async (textId) => {
try {
await api.delete(`/post-version-texts/${textId}`)
const vId = selectedVersion.Id || selectedVersion.id || selectedVersion._id
loadVersionData(vId)
} catch (err) {
console.error('Delete language failed:', err)
}
}
const handleVersionFileUpload = async (files) => {
if (!selectedVersion || !files?.length) return
setUploadingVersionFile(true)
const vId = selectedVersion.Id || selectedVersion.id || selectedVersion._id
for (const file of files) {
const fd = new FormData()
fd.append('file', file)
try {
await api.upload(`/posts/${postId}/versions/${vId}/attachments`, fd)
} catch (err) {
console.error('Version upload failed:', err)
}
}
setUploadingVersionFile(false)
loadVersionData(vId)
}
const handleDeleteVersionAttachment = async (attId) => {
try {
await api.delete(`/attachments/${attId}`)
const vId = selectedVersion.Id || selectedVersion.id || selectedVersion._id
loadVersionData(vId)
} catch (err) {
console.error('Delete version attachment failed:', err)
}
}
const brandName = (() => {
@@ -195,10 +327,21 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
return post.brand_name || post.brandName || null
})()
const header = (
<div className="px-5 py-4 border-b border-border shrink-0">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
const tabConfig = {
details: { label: t('posts.details'), icon: FileEdit },
versions: { label: t('posts.versions'), icon: Layers, badge: versions.length || null },
platforms: { label: t('posts.platformsLinks'), icon: Share2 },
approval: { label: t('posts.approval'), icon: ShieldCheck },
discussion: { label: t('posts.discussion'), icon: MessageSquare },
}
// Filter tabs: hide some in create mode
const visibleTabs = isCreateMode
? ['details', 'platforms']
: TABS
const modalHeader = (
<>
<input
type="text"
value={form.title}
@@ -206,366 +349,264 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
className="w-full text-lg font-semibold text-text-primary bg-transparent border-0 p-0 focus:outline-none focus:ring-0"
placeholder={t('posts.postTitlePlaceholder')}
/>
<div className="flex items-center gap-2 mt-2">
<span className={`text-[11px] px-2 py-0.5 rounded-full font-medium ${
<div className="flex items-center gap-2 mt-2 flex-wrap">
<span className={`text-[11px] px-2.5 py-0.5 rounded-full font-medium ${
form.status === 'published' ? 'bg-emerald-100 text-emerald-700' :
form.status === 'scheduled' ? 'bg-purple-100 text-purple-700' :
form.status === 'approved' ? 'bg-blue-100 text-blue-700' :
form.status === 'in_review' ? 'bg-amber-100 text-amber-700' :
'bg-gray-100 text-gray-600'
form.status === 'rejected' ? 'bg-red-100 text-red-700' :
'bg-gray-100 text-text-secondary'
}`}>
{statusOptions.find(s => s.value === form.status)?.label}
</span>
{brandName && (
<span className={`text-[10px] px-1.5 py-0.5 rounded ${getBrandColor(brandName).bg} ${getBrandColor(brandName).text}`}>
<span className={`text-[11px] px-2 py-0.5 rounded-full ${getBrandColor(brandName).bg} ${getBrandColor(brandName).text}`}>
{brandName}
</span>
)}
{post.current_version && (
<span className="text-[11px] px-2 py-0.5 rounded-full bg-indigo-50 text-indigo-600 font-medium">
v{post.current_version}
</span>
)}
{post.creator_user_name && (
<span className="text-[11px] text-text-tertiary">
{t('review.createdBy')} <span className="text-text-secondary font-medium">{post.creator_user_name}</span>
</span>
)}
</div>
</>
)
const modalFooter = (
<>
<div className="flex items-center gap-2">
{onDelete && !isCreateMode && (
<button
onClick={() => setShowDeleteConfirm(true)}
className="px-3 py-2 text-sm font-medium text-red-600 hover:bg-red-50 rounded-lg transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
<div className="flex items-center gap-2.5">
{dirty ? (
<>
<button
onClick={onClose}
className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors shrink-0"
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg transition-colors"
>
<X className="w-5 h-5" />
{t('common.cancel')}
</button>
<button
onClick={handleSave}
disabled={!form.title || saving}
className={`px-6 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm transition-colors ${saving ? 'btn-loading' : ''}`}
>
{isCreateMode ? t('posts.createPost') : t('posts.saveChanges')}
</button>
</>
) : (
<button
onClick={onClose}
className="px-5 py-2 text-sm font-medium text-text-secondary border border-border rounded-lg hover:bg-surface-tertiary transition-colors"
>
{t('common.close')}
</button>
)}
</div>
</div>
</>
)
return (
<>
<SlidePanel onClose={onClose} maxWidth="520px" header={header}>
{/* Details Section */}
<CollapsibleSection title={t('posts.details')}>
<div className="px-5 pb-4 space-y-3">
<TabbedModal
onClose={onClose}
size="xl"
header={modalHeader}
tabs={visibleTabs.map(tab => ({ key: tab, ...tabConfig[tab] }))}
activeTab={activeTab}
onTabChange={setActiveTab}
footer={modalFooter}
>
{/* ─── Details Tab ─── */}
{activeTab === 'details' && (
<div className="p-6 space-y-5">
{/* Two-column layout for details */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
{/* Main content — left column */}
<div className="lg:col-span-2 space-y-4">
<div className="bg-surface-secondary rounded-xl p-4 space-y-4">
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.description')}</label>
<label className="block text-xs font-medium text-text-primary mb-1.5">{t('posts.description')}</label>
<textarea
value={form.description}
onChange={e => update('description', e.target.value)}
rows={3}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary resize-none"
rows={4}
className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary resize-none"
placeholder={t('posts.postDescPlaceholder')}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.brand')}</label>
<select
value={form.brand_id}
onChange={e => update('brand_id', e.target.value)}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
<option value="">{t('posts.selectBrand')}</option>
{(brands || []).map(b => <option key={b._id || b.id} value={b._id || b.id}>{b.icon} {lang === 'ar' && b.name_ar ? b.name_ar : b.name}</option>)}
</select>
</div>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.campaign')}</label>
<select
value={form.campaign_id}
onChange={e => update('campaign_id', e.target.value)}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
<option value="">{t('posts.noCampaign')}</option>
{(campaigns || []).map(c => <option key={c._id || c.id} value={c._id || c.id}>{c.name}</option>)}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.assignTo')}</label>
<select
value={form.assigned_to}
onChange={e => update('assigned_to', e.target.value)}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
<option value="">{t('common.unassigned')}</option>
{(teamMembers || []).map(m => <option key={m._id || m.id} value={m._id || m.id}>{m.name}</option>)}
</select>
</div>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.status')}</label>
<select
value={form.status}
onChange={e => update('status', e.target.value)}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
{statusOptions.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.scheduledDate')}</label>
<input
type="datetime-local"
value={form.scheduled_date}
onChange={e => update('scheduled_date', e.target.value)}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
/>
</div>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.notes')}</label>
<label className="block text-xs font-medium text-text-primary mb-1.5">{t('posts.notes')}</label>
<input
type="text"
value={form.notes}
onChange={e => update('notes', e.target.value)}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
placeholder={t('posts.additionalNotes')}
/>
</div>
</div>
{publishError && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
<div className="p-3 bg-red-50 border border-red-200 rounded-xl text-sm text-red-700 flex items-center gap-2">
<XCircle className="w-4 h-4 shrink-0" />
{publishError}
</div>
)}
<div className="flex items-center gap-2 pt-2">
{dirty && (
<button
onClick={handleSave}
disabled={!form.title || saving}
className={`flex-1 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm ${saving ? 'btn-loading' : ''}`}
>
{isCreateMode ? t('posts.createPost') : t('posts.saveChanges')}
</button>
)}
{onDelete && !isCreateMode && (
<button
onClick={() => setShowDeleteConfirm(true)}
className="p-2 text-text-tertiary hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
title={t('common.delete')}
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
</div>
</CollapsibleSection>
{/* Platforms & Links Section */}
<CollapsibleSection title={t('posts.platformsLinks')}>
<div className="px-5 pb-4 space-y-3">
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.platforms')}</label>
<div className="flex flex-wrap gap-2 p-2 border border-border rounded-lg bg-white min-h-[38px]">
{Object.entries(PLATFORMS).map(([k, v]) => {
const checked = (form.platforms || []).includes(k)
return (
<label
key={k}
className={`flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-full cursor-pointer border transition-colors ${
checked
? 'bg-brand-primary/10 border-brand-primary/30 text-brand-primary font-medium'
: 'bg-surface-tertiary border-transparent text-text-secondary hover:bg-surface-tertiary/80'
}`}
>
<input
type="checkbox"
checked={checked}
onChange={() => {
update('platforms', checked
? form.platforms.filter(p => p !== k)
: [...(form.platforms || []), k]
)
}}
className="sr-only"
/>
{v.label}
</label>
)
})}
</div>
</div>
{(form.platforms || []).length > 0 && (
<div className="space-y-2 border border-border rounded-lg p-3 bg-surface-secondary">
<div className="flex items-center gap-1.5 text-xs font-medium text-text-secondary mb-1">
<Link2 className="w-3.5 h-3.5" />
{t('posts.publicationLinks')}
</div>
{(form.platforms || []).map(platformKey => {
const platformInfo = PLATFORMS[platformKey] || { label: platformKey }
const existingLink = (form.publication_links || []).find(l => l.platform === platformKey)
const linkUrl = existingLink?.url || ''
return (
<div key={platformKey} className="flex items-center gap-2">
<span className="text-xs font-medium text-text-secondary w-24 shrink-0 flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: platformInfo.color || '#888' }} />
{platformInfo.label}
</span>
<input
type="url"
value={linkUrl}
onChange={e => updatePublicationLink(platformKey, e.target.value)}
className="flex-1 px-3 py-1.5 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
placeholder="https://..."
/>
{linkUrl && (
<a href={linkUrl} target="_blank" rel="noopener noreferrer" className="p-1.5 text-text-tertiary hover:text-brand-primary">
<ExternalLink className="w-3.5 h-3.5" />
</a>
)}
</div>
)
})}
{form.status === 'published' && (form.platforms || []).some(p => {
const link = (form.publication_links || []).find(l => l.platform === p)
return !link || !link.url?.trim()
}) && (
<p className="text-xs text-amber-600 mt-1">{t('posts.publishRequired')}</p>
)}
</div>
)}
</div>
</CollapsibleSection>
{/* Attachments Section (hidden in create mode) */}
{/* Legacy Attachments (non-versioned) */}
{!isCreateMode && (
<CollapsibleSection
title={t('posts.attachments')}
badge={attachments.length > 0 ? (
<span className="text-xs font-medium text-text-tertiary bg-surface-tertiary px-1.5 py-0.5 rounded-full">
<div>
<div className="flex items-center justify-between mb-3">
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wide">{t('posts.attachments')}</h4>
{attachments.length > 0 && (
<span className="text-[10px] font-medium text-text-tertiary bg-surface-tertiary px-1.5 py-0.5 rounded-full">
{attachments.length}
</span>
) : null}
>
<div className="px-5 pb-4">
{attachments.length > 0 && (
<div className="grid grid-cols-2 gap-2 mb-3">
{attachments.map(att => {
const isImage = att.mime_type?.startsWith('image/') || att.mimeType?.startsWith('image/')
const attUrl = att.url || `/api/uploads/${att.filename}`
const name = att.original_name || att.originalName || att.filename
const attId = att.id || att._id
return (
<div key={attId} className="relative group/att border border-border rounded-lg overflow-hidden bg-white">
<div className="h-20 relative">
{isImage ? (
<a href={attUrl} target="_blank" rel="noopener noreferrer" className="block h-full">
<img src={attUrl} alt={name} className="absolute inset-0 w-full h-full object-cover" />
</a>
) : (
<a href={attUrl} target="_blank" rel="noopener noreferrer" className="absolute inset-0 flex items-center gap-2 p-3">
<FileText className="w-6 h-6 text-text-tertiary shrink-0" />
<span className="text-xs text-text-secondary truncate">{name}</span>
</a>
)}
<button
onClick={() => handleDeleteAttachment(attId)}
className="absolute top-1 right-1 p-1 bg-black/50 hover:bg-red-500 rounded-full text-white opacity-0 group-hover/att:opacity-100 transition-opacity"
title={t('common.delete')}
>
<X className="w-2.5 h-2.5" />
</button>
</div>
<div className="px-2 py-1 text-[10px] text-text-tertiary truncate border-t border-border-light">{name}</div>
</div>
)
})}
</div>
)}
<div
className={`border-2 border-dashed rounded-lg p-4 text-center cursor-pointer transition-colors ${
dragActive ? 'border-brand-primary bg-brand-primary/5' : 'border-border hover:border-brand-primary/40'
}`}
onClick={() => fileInputRef.current?.click()}
onDragEnter={e => { e.preventDefault(); setDragActive(true) }}
onDragLeave={e => { e.preventDefault(); setDragActive(false) }}
onDragOver={e => e.preventDefault()}
onDrop={handleDrop}
>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={e => { handleFileUpload(e.target.files); e.target.value = '' }}
<PostDetailAttachments
attachments={attachments}
uploading={uploading}
onFileUpload={handleFileUpload}
onDeleteAttachment={handleDeleteAttachment}
onAttachAsset={handleAttachAsset}
/>
<Upload className={`w-5 h-5 text-text-tertiary mx-auto mb-1 ${uploading ? 'animate-pulse' : ''}`} />
<p className="text-xs text-text-secondary">
{dragActive ? t('posts.dropFiles') : t('posts.uploadFiles')}
</p>
</div>
)}
</div>
<button
type="button"
onClick={openAssetPicker}
className="mt-2 flex items-center gap-2 px-3 py-2 text-sm text-text-secondary border border-border rounded-lg hover:bg-surface-tertiary transition-colors w-full justify-center"
{/* Sidebar — right column */}
<div className="space-y-4">
<div className="bg-surface-secondary rounded-xl p-4 space-y-3">
<div>
<label className="block text-xs font-medium text-text-primary mb-1.5">{t('posts.status')}</label>
<select
value={form.status}
onChange={e => update('status', e.target.value)}
className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
<FolderOpen className="w-4 h-4" />
{t('posts.attachFromAssets')}
</button>
{showAssetPicker && (
<div className="mt-2 border border-border rounded-lg p-3 bg-surface-secondary">
<div className="flex items-center justify-between mb-2">
<p className="text-xs font-medium text-text-secondary">{t('posts.selectAssets')}</p>
<button onClick={() => setShowAssetPicker(false)} className="p-1 text-text-tertiary hover:text-text-primary">
<X className="w-3.5 h-3.5" />
</button>
{statusOptions.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
</select>
</div>
<div>
<label className="block text-xs font-medium text-text-primary mb-1.5">{t('posts.scheduledDate')}</label>
<input
type="text"
value={assetSearch}
onChange={e => setAssetSearch(e.target.value)}
placeholder={t('common.search')}
className="w-full px-3 py-1.5 text-xs border border-border rounded-lg mb-2 focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
type="date"
value={form.scheduled_date}
onChange={e => update('scheduled_date', e.target.value)}
className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
/>
<div className="grid grid-cols-3 gap-2 max-h-48 overflow-y-auto">
{availableAssets
.filter(a => !assetSearch || (a.original_name || a.filename || '').toLowerCase().includes(assetSearch.toLowerCase()))
.map(asset => {
const isImage = asset.mime_type?.startsWith('image/')
const assetUrl = `/api/uploads/${asset.filename}`
const name = asset.original_name || asset.filename
return (
<button
key={asset.id || asset._id}
onClick={() => handleAttachAsset(asset.id || asset._id)}
className="block w-full border border-border rounded-lg overflow-hidden bg-white hover:border-brand-primary hover:ring-2 hover:ring-brand-primary/20 transition-all text-left"
</div>
<div>
<label className="block text-xs font-medium text-text-primary mb-1.5">{t('posts.assignTo')}</label>
<select
value={form.assigned_to}
onChange={e => update('assigned_to', e.target.value)}
className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
<div className="aspect-square relative">
{isImage ? (
<img src={assetUrl} alt={name} className="absolute inset-0 w-full h-full object-cover" />
) : (
<div className="absolute inset-0 flex items-center justify-center bg-surface-tertiary">
<FileText className="w-6 h-6 text-text-tertiary" />
<option value="">{t('common.unassigned')}</option>
{(teamMembers || []).map(m => <option key={m._id || m.id} value={m._id || m.id}>{m.name}</option>)}
</select>
</div>
</div>
<div className="bg-surface-secondary rounded-xl p-4 space-y-3">
<div>
<label className="block text-xs font-medium text-text-primary mb-1.5">{t('posts.brand')}</label>
<select
value={form.brand_id}
onChange={e => update('brand_id', e.target.value)}
className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
<option value="">{t('posts.selectBrand')}</option>
{(brands || []).map(b => <option key={b._id || b.id} value={b._id || b.id}>{b.icon} {lang === 'ar' && b.name_ar ? b.name_ar : b.name}</option>)}
</select>
</div>
<div>
<label className="block text-xs font-medium text-text-primary mb-1.5">{t('posts.campaign')}</label>
<select
value={form.campaign_id}
onChange={e => update('campaign_id', e.target.value)}
className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
<option value="">{t('posts.noCampaign')}</option>
{(campaigns || []).map(c => <option key={c._id || c.id} value={c._id || c.id}>{c.name}</option>)}
</select>
</div>
)}
</div>
<div className="px-1.5 py-1 text-[10px] text-text-tertiary truncate border-t border-border-light">{name}</div>
</button>
)
})}
</div>
{availableAssets.filter(a => !assetSearch || (a.original_name || a.filename || '').toLowerCase().includes(assetSearch.toLowerCase())).length === 0 && (
<p className="text-xs text-text-tertiary text-center py-4">{t('posts.noAssetsFound')}</p>
)}
</div>
)}
</div>
</CollapsibleSection>
)}
{/* Discussion Section (hidden in create mode) */}
{!isCreateMode && (
<CollapsibleSection title={t('posts.discussion')} noBorder>
<div className="px-5 pb-5">
{/* ─── Versions Tab ─── */}
{activeTab === 'versions' && !isCreateMode && (
<PostDetailVersions
versions={versions}
selectedVersion={selectedVersion}
versionData={versionData}
onSelectVersion={handleSelectVersion}
onCreateVersion={handleCreateVersion}
onAddLanguage={handleAddLanguage}
onDeleteLanguage={handleDeleteLanguage}
onVersionFileUpload={handleVersionFileUpload}
onDeleteVersionAttachment={handleDeleteVersionAttachment}
uploadingVersionFile={uploadingVersionFile}
versionFileInputRef={versionFileInputRef}
/>
)}
{/* ─── Platforms & Links Tab ─── */}
{activeTab === 'platforms' && (
<PostDetailPlatforms
form={form}
update={update}
updatePublicationLink={updatePublicationLink}
/>
)}
{/* ─── Approval Tab ─── */}
{activeTab === 'approval' && (
<PostDetailApproval
form={form}
update={update}
post={post}
isCreateMode={isCreateMode}
reviewUrl={reviewUrl}
copied={copied}
submittingReview={submittingReview}
saving={saving}
teamMembers={teamMembers}
onSubmitReview={handleSubmitReview}
onCopyReviewLink={copyReviewLink}
onStatusAction={handleStatusAction}
/>
)}
{/* ─── Discussion Tab ─── */}
{activeTab === 'discussion' && !isCreateMode && (
<div className="p-6 w-full">
<CommentsSection entityType="post" entityId={postId} />
</div>
</CollapsibleSection>
)}
</SlidePanel>
</TabbedModal>
{/* Delete Confirmation */}
<Modal
isOpen={showDeleteConfirm}
onClose={() => setShowDeleteConfirm(false)}
@@ -0,0 +1,92 @@
import { Link2, ExternalLink, XCircle, Share2 } from 'lucide-react'
import { useLanguage } from '../i18n/LanguageContext'
import { PLATFORMS } from '../utils/api'
export function PostDetailPlatforms({ form, update, updatePublicationLink }) {
const { t } = useLanguage()
return (
<div className="p-6 space-y-6 w-full">
<div>
<div className="flex items-center gap-2 mb-3">
<Share2 className="w-4 h-4 text-text-tertiary" />
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wide">{t('posts.platforms')}</h4>
</div>
<div className="flex flex-wrap gap-2 p-3 rounded-xl bg-surface-secondary min-h-[44px]">
{Object.entries(PLATFORMS).map(([k, v]) => {
const checked = (form.platforms || []).includes(k)
return (
<label
key={k}
className={`flex items-center gap-1.5 text-xs px-3 py-2 rounded-lg cursor-pointer border transition-all ${
checked
? 'bg-surface border-brand-primary/30 text-brand-primary font-medium shadow-sm'
: 'bg-white/50 border-transparent text-text-secondary hover:bg-surface hover:shadow-sm'
}`}
>
<input
type="checkbox"
checked={checked}
onChange={() => {
update('platforms', checked
? form.platforms.filter(p => p !== k)
: [...(form.platforms || []), k]
)
}}
className="sr-only"
/>
<span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: v.color || '#888' }} />
{v.label}
</label>
)
})}
</div>
</div>
{(form.platforms || []).length > 0 && (
<div>
<div className="flex items-center gap-2 mb-3">
<Link2 className="w-4 h-4 text-text-tertiary" />
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wide">{t('posts.publicationLinks')}</h4>
</div>
<div className="space-y-2.5">
{(form.platforms || []).map(platformKey => {
const platformInfo = PLATFORMS[platformKey] || { label: platformKey }
const existingLink = (form.publication_links || []).find(l => l.platform === platformKey)
const linkUrl = existingLink?.url || ''
return (
<div key={platformKey} className="flex items-center gap-3 p-3 rounded-xl bg-surface-secondary">
<span className="text-xs font-medium text-text-primary w-28 shrink-0 flex items-center gap-2">
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: platformInfo.color || '#888' }} />
{platformInfo.label}
</span>
<input
type="url"
value={linkUrl}
onChange={e => updatePublicationLink(platformKey, e.target.value)}
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
placeholder="https://..."
/>
{linkUrl && (
<a href={linkUrl} target="_blank" rel="noopener noreferrer" className="p-2 text-text-tertiary hover:text-brand-primary hover:bg-surface rounded-lg transition-colors">
<ExternalLink className="w-4 h-4" />
</a>
)}
</div>
)
})}
</div>
{form.status === 'published' && (form.platforms || []).some(p => {
const link = (form.publication_links || []).find(l => l.platform === p)
return !link || !link.url?.trim()
}) && (
<p className="text-xs text-amber-600 mt-3 flex items-center gap-1.5">
<XCircle className="w-3.5 h-3.5" />
{t('posts.publishRequired')}
</p>
)}
</div>
)}
</div>
)
}
@@ -0,0 +1,391 @@
import { useState } from 'react'
import { Trash2, Upload, FileText, Image as ImageIcon, Plus, Globe, Layers } from 'lucide-react'
import { useLanguage } from '../i18n/LanguageContext'
import Modal from './Modal'
const AVAILABLE_LANGUAGES = [
{ code: 'ar', label: 'Arabic' },
{ code: 'en', label: 'English' },
{ code: 'fr', label: 'French' },
{ code: 'id', label: 'Bahasa Indonesia' },
]
export function PostDetailVersions({
versions,
selectedVersion,
versionData,
onSelectVersion,
onCreateVersion,
onAddLanguage,
onDeleteLanguage,
onVersionFileUpload,
onDeleteVersionAttachment,
uploadingVersionFile,
versionFileInputRef,
}) {
const { t } = useLanguage()
const [showNewVersionModal, setShowNewVersionModal] = useState(false)
const [newVersionNotes, setNewVersionNotes] = useState('')
const [copyFromPrevious, setCopyFromPrevious] = useState(false)
const [creatingVersion, setCreatingVersion] = useState(false)
const [showLanguageModal, setShowLanguageModal] = useState(false)
const [languageForm, setLanguageForm] = useState({ language_code: '', language_label: '', content: '' })
const [savingLanguage, setSavingLanguage] = useState(false)
const [confirmDeleteLangId, setConfirmDeleteLangId] = useState(null)
const [confirmDeleteAttId, setConfirmDeleteAttId] = useState(null)
const handleCreateVersion = async () => {
setCreatingVersion(true)
try {
await onCreateVersion({ notes: newVersionNotes || undefined, copy_from_previous: copyFromPrevious })
setShowNewVersionModal(false)
setNewVersionNotes('')
setCopyFromPrevious(false)
} finally {
setCreatingVersion(false)
}
}
const handleAddLanguage = async () => {
if (!selectedVersion || !languageForm.language_code || !languageForm.content) return
setSavingLanguage(true)
try {
await onAddLanguage(languageForm)
setShowLanguageModal(false)
setLanguageForm({ language_code: '', language_label: '', content: '' })
} finally {
setSavingLanguage(false)
}
}
const handleDeleteLanguage = async (textId) => {
await onDeleteLanguage(textId)
setConfirmDeleteLangId(null)
}
const handleDeleteAttachment = async (attId) => {
await onDeleteVersionAttachment(attId)
setConfirmDeleteAttId(null)
}
return (
<>
<div className="flex h-full">
{/* Version Timeline (left sidebar) */}
<div className="w-64 shrink-0 border-e border-border p-4 overflow-y-auto bg-surface-secondary/50">
<div className="flex items-center justify-between mb-4">
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wide">{t('posts.versions')}</h4>
<button
onClick={() => setShowNewVersionModal(true)}
className="flex items-center gap-1 px-2.5 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors shadow-sm"
>
<Plus className="w-3 h-3" />
{t('posts.newVersion')}
</button>
</div>
{versions.length === 0 ? (
<div className="text-center py-10">
<div className="w-12 h-12 rounded-full bg-surface-tertiary flex items-center justify-center mx-auto mb-3">
<Layers className="w-6 h-6 text-text-quaternary" />
</div>
<p className="text-xs text-text-tertiary leading-relaxed px-2">{t('posts.noVersions')}</p>
</div>
) : (
<div className="space-y-1.5">
{versions.map((version, idx) => {
const vId = version.Id || version.id || version._id
const isActive = vId === (selectedVersion?.Id || selectedVersion?.id || selectedVersion?._id)
const isLatest = idx === versions.length - 1
return (
<button
key={vId}
onClick={() => onSelectVersion(version)}
className={`w-full text-start p-3 rounded-xl border transition-all ${
isActive
? 'border-brand-primary bg-surface shadow-sm ring-1 ring-brand-primary/20'
: 'border-transparent hover:bg-surface hover:border-border'
}`}
>
<div className="flex items-center gap-2.5">
<div className={`flex-shrink-0 w-7 h-7 rounded-lg flex items-center justify-center text-[11px] font-bold ${
isActive ? 'bg-brand-primary text-white' : 'bg-surface-tertiary text-text-secondary'
}`}>
{version.version_number}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className={`text-sm font-medium ${isActive ? 'text-brand-primary' : 'text-text-primary'}`}>
v{version.version_number}
</span>
{isLatest && (
<span className="text-[9px] px-1.5 py-px bg-emerald-100 text-emerald-700 rounded font-semibold uppercase">
Latest
</span>
)}
</div>
{version.notes && (
<p className="text-[11px] text-text-tertiary line-clamp-1 mt-0.5">{version.notes}</p>
)}
</div>
</div>
{(version.creator_name || version.created_at) && (
<div className="flex items-center gap-2 mt-2 ms-[38px] text-[10px] text-text-quaternary">
{version.creator_name && <span>{version.creator_name}</span>}
{version.creator_name && version.created_at && <span>·</span>}
{version.created_at && <span>{new Date(version.created_at).toLocaleDateString()}</span>}
</div>
)}
</button>
)
})}
</div>
)}
</div>
{/* Version Content (right side) */}
<div className="flex-1 min-w-0 overflow-y-auto p-6">
{selectedVersion && versionData ? (
<div className="space-y-6 w-full">
{/* Languages */}
<div>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Globe className="w-4 h-4 text-text-tertiary" />
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wide">{t('posts.languages')}</h4>
{versionData.texts?.length > 0 && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-surface-tertiary text-text-tertiary font-medium">
{versionData.texts.length}
</span>
)}
</div>
<button
onClick={() => setShowLanguageModal(true)}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-brand-primary border border-brand-primary/30 hover:bg-brand-primary/5 rounded-lg transition-colors"
>
<Plus className="w-3 h-3" />
{t('posts.addLanguage')}
</button>
</div>
{versionData.texts && versionData.texts.length > 0 ? (
<div className="space-y-3">
{versionData.texts.map(text => {
const tId = text.Id || text.id || text._id
return (
<div key={tId} className="rounded-xl border border-border overflow-clip">
<div className="flex items-center justify-between px-4 py-2.5 bg-surface-secondary">
<div className="flex items-center gap-2">
<span className="px-2 py-0.5 bg-surface border border-border rounded text-[11px] font-semibold uppercase text-text-secondary">{text.language_code}</span>
<span className="text-sm font-medium text-text-primary">{text.language_label}</span>
</div>
<button
onClick={() => setConfirmDeleteLangId(tId)}
className="p-1.5 text-text-quaternary hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
<div className="px-4 py-3 text-sm whitespace-pre-wrap text-text-primary leading-relaxed" dir={text.language_code === 'ar' ? 'rtl' : 'ltr'}>
{text.content}
</div>
</div>
)
})}
</div>
) : (
<div className="text-center py-8 rounded-xl border-2 border-dashed border-border">
<Globe className="w-8 h-8 text-text-quaternary mx-auto mb-2" />
<p className="text-sm text-text-tertiary">{t('posts.noLanguages')}</p>
<button
onClick={() => setShowLanguageModal(true)}
className="mt-3 text-xs font-medium text-brand-primary hover:underline"
>
{t('posts.addLanguage')}
</button>
</div>
)}
</div>
{/* Media / Attachments for this version */}
<div>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<ImageIcon className="w-4 h-4 text-text-tertiary" />
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wide">{t('posts.media')}</h4>
{versionData.attachments?.length > 0 && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-surface-tertiary text-text-tertiary font-medium">
{versionData.attachments.length}
</span>
)}
</div>
<label className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-brand-primary border border-brand-primary/30 hover:bg-brand-primary/5 rounded-lg cursor-pointer transition-colors">
<Upload className="w-3 h-3" />
{uploadingVersionFile ? t('posts.uploading') : t('posts.addImage')}
<input
ref={versionFileInputRef}
type="file"
multiple
className="hidden"
onChange={e => { onVersionFileUpload(e.target.files); e.target.value = '' }}
disabled={uploadingVersionFile}
/>
</label>
</div>
{versionData.attachments && versionData.attachments.length > 0 ? (
<div className="grid grid-cols-2 gap-3">
{versionData.attachments.map(att => {
const attId = att.Id || att.id || att._id
const attUrl = att.url || `/api/uploads/${att.filename}`
const name = att.original_name || att.filename
const mime = att.mime_type || ''
const isImage = mime.startsWith('image/')
const isVideo = mime.startsWith('video/')
return (
<div key={attId} className="relative group rounded-xl border border-border overflow-clip bg-surface hover:shadow-md transition-shadow">
{isImage ? (
<a href={attUrl} target="_blank" rel="noopener noreferrer">
<img src={attUrl} alt={name} className="w-full h-44 object-cover" loading="lazy" />
</a>
) : isVideo ? (
<video src={attUrl} controls className="w-full h-44 object-cover" />
) : (
<a href={attUrl} target="_blank" rel="noopener noreferrer" className="flex items-center justify-center h-44 bg-surface-tertiary">
<FileText className="w-10 h-10 text-text-quaternary" />
</a>
)}
<div className="flex items-center justify-between px-3 py-2 border-t border-border bg-surface-secondary/50">
<span className="text-[11px] text-text-secondary truncate">{name}</span>
<button
onClick={() => setConfirmDeleteAttId(attId)}
className="p-1 text-text-quaternary hover:text-red-500 opacity-0 group-hover:opacity-100 transition-all"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
)
})}
</div>
) : (
<div className="text-center py-8 rounded-xl border-2 border-dashed border-border">
<ImageIcon className="w-8 h-8 text-text-quaternary mx-auto mb-2" />
<p className="text-sm text-text-tertiary">{t('posts.noMedia')}</p>
</div>
)}
</div>
</div>
) : versions.length > 0 ? (
<div className="flex items-center justify-center h-40">
<div className="w-6 h-6 border-2 border-brand-primary border-t-transparent rounded-full animate-spin" />
</div>
) : null}
</div>
</div>
{/* New Version Modal */}
<Modal
isOpen={showNewVersionModal}
onClose={() => { setShowNewVersionModal(false); setNewVersionNotes(''); setCopyFromPrevious(false) }}
title={t('posts.createNewVersion')}
size="sm"
>
<div className="space-y-4">
<textarea
value={newVersionNotes}
onChange={e => setNewVersionNotes(e.target.value)}
rows={3}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary resize-none"
placeholder={t('posts.whatChanged')}
/>
{versions.length > 0 && (
<label className="flex items-center gap-2 text-sm text-text-secondary cursor-pointer">
<input
type="checkbox"
checked={copyFromPrevious}
onChange={e => setCopyFromPrevious(e.target.checked)}
className="rounded border-border text-brand-primary focus:ring-brand-primary/20"
/>
{t('posts.copyLanguages')}
</label>
)}
<button
onClick={handleCreateVersion}
disabled={creatingVersion}
className="w-full px-4 py-2.5 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 shadow-sm"
>
{creatingVersion ? t('posts.creatingVersion') : t('posts.createVersion')}
</button>
</div>
</Modal>
{/* Add Language Modal */}
<Modal
isOpen={showLanguageModal}
onClose={() => { setShowLanguageModal(false); setLanguageForm({ language_code: '', language_label: '', content: '' }) }}
title={t('posts.addLanguage')}
size="md"
>
<div className="space-y-4">
<select
value={languageForm.language_code}
onChange={e => {
const lang = AVAILABLE_LANGUAGES.find(l => l.code === e.target.value)
setLanguageForm(f => ({ ...f, language_code: e.target.value, language_label: lang?.label || e.target.value }))
}}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
<option value="">{t('posts.selectLanguage')}</option>
{AVAILABLE_LANGUAGES
.filter(lang => !(versionData?.texts || []).some(t => t.language_code === lang.code))
.map(lang => (
<option key={lang.code} value={lang.code}>{lang.label} ({lang.code.toUpperCase()})</option>
))}
</select>
<textarea
value={languageForm.content}
onChange={e => setLanguageForm(f => ({ ...f, content: e.target.value }))}
rows={8}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary resize-none"
placeholder={t('posts.enterContent')}
dir={languageForm.language_code === 'ar' ? 'rtl' : 'ltr'}
/>
<button
onClick={handleAddLanguage}
disabled={savingLanguage || !languageForm.language_code || !languageForm.content}
className="w-full px-4 py-2.5 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 shadow-sm"
>
{savingLanguage ? t('common.loading') : t('common.save')}
</button>
</div>
</Modal>
{/* Delete Language Confirmation */}
<Modal
isOpen={!!confirmDeleteLangId}
onClose={() => setConfirmDeleteLangId(null)}
title={t('posts.deleteLanguage')}
isConfirm
danger
confirmText={t('common.delete')}
onConfirm={() => handleDeleteLanguage(confirmDeleteLangId)}
>
{t('posts.deleteLanguageConfirm')}
</Modal>
{/* Delete Version Attachment Confirmation */}
<Modal
isOpen={!!confirmDeleteAttId}
onClose={() => setConfirmDeleteAttId(null)}
title={t('posts.deleteAttachment')}
isConfirm
danger
confirmText={t('common.delete')}
onConfirm={() => handleDeleteAttachment(confirmDeleteAttId)}
>
{t('posts.deleteConfirm')}
</Modal>
</>
)
}
+2 -2
View File
@@ -21,11 +21,11 @@ export default function ProjectCard({ project }) {
return (
<div
onClick={() => navigate(`/projects/${project._id}`)}
className="bg-white rounded-xl border border-border card-hover cursor-pointer overflow-hidden"
className="bg-surface rounded-xl border border-border card-hover cursor-pointer overflow-hidden"
>
{thumbnailUrl ? (
<div className="w-full h-32 overflow-hidden">
<img src={thumbnailUrl} alt="" className="w-full h-full object-cover" />
<img src={thumbnailUrl} alt="" className="w-full h-full object-cover" loading="lazy" />
</div>
) : null}
<div className="p-5">
+79 -69
View File
@@ -1,13 +1,15 @@
import { useState, useEffect, useRef } from 'react'
import { X, Trash2, Upload } from 'lucide-react'
import { useState, useEffect, useRef, useContext } from 'react'
import { Trash2, Upload, FileEdit, MessageSquare } from 'lucide-react'
import { useLanguage } from '../i18n/LanguageContext'
import { api, getBrandColor } from '../utils/api'
import CommentsSection from './CommentsSection'
import Modal from './Modal'
import SlidePanel from './SlidePanel'
import CollapsibleSection from './CollapsibleSection'
import TabbedModal from './TabbedModal'
import PortalSelect from './PortalSelect'
import { AppContext } from '../App'
export default function ProjectEditPanel({ project, onClose, onSave, onDelete, brands, teamMembers }) {
const { teams } = useContext(AppContext)
const { t, lang } = useLanguage()
const thumbnailInputRef = useRef(null)
const [form, setForm] = useState({})
@@ -15,6 +17,7 @@ export default function ProjectEditPanel({ project, onClose, onSave, onDelete, b
const [saving, setSaving] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [thumbnailUploading, setThumbnailUploading] = useState(false)
const [activeTab, setActiveTab] = useState('details')
const projectId = project?._id || project?.id
if (!project) return null
@@ -26,6 +29,7 @@ export default function ProjectEditPanel({ project, onClose, onSave, onDelete, b
description: project.description || '',
brand_id: project.brandId || project.brand_id || '',
owner_id: project.ownerId || project.owner_id || '',
team_id: project.team_id || '',
status: project.status || 'active',
start_date: project.startDate || project.start_date ? new Date(project.startDate || project.start_date).toISOString().slice(0, 10) : '',
due_date: project.dueDate ? new Date(project.dueDate).toISOString().slice(0, 10) : '',
@@ -54,6 +58,7 @@ export default function ProjectEditPanel({ project, onClose, onSave, onDelete, b
description: form.description,
brand_id: form.brand_id ? Number(form.brand_id) : null,
owner_id: form.owner_id ? Number(form.owner_id) : null,
team_id: form.team_id ? Number(form.team_id) : null,
status: form.status,
start_date: form.start_date || null,
due_date: form.due_date || null,
@@ -103,10 +108,17 @@ export default function ProjectEditPanel({ project, onClose, onSave, onDelete, b
return project.brand_name || project.brandName || null
})()
const header = (
<div className="px-5 py-4 border-b border-border shrink-0">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
const tabs = [
{ key: 'details', label: t('projects.details'), icon: FileEdit },
{ key: 'discussion', label: t('projects.discussion'), icon: MessageSquare },
]
return (
<>
<TabbedModal
onClose={onClose}
size="md"
header={<>
<input
type="text"
value={form.name}
@@ -120,7 +132,7 @@ export default function ProjectEditPanel({ project, onClose, onSave, onDelete, b
form.status === 'paused' ? 'bg-amber-100 text-amber-700' :
form.status === 'completed' ? 'bg-blue-100 text-blue-700' :
form.status === 'cancelled' ? 'bg-red-100 text-red-700' :
'bg-gray-100 text-gray-600'
'bg-gray-100 text-text-secondary'
}`}>
{statusOptions.find(s => s.value === form.status)?.label}
</span>
@@ -130,23 +142,37 @@ export default function ProjectEditPanel({ project, onClose, onSave, onDelete, b
</span>
)}
</div>
</div>
</>}
tabs={tabs}
activeTab={activeTab}
onTabChange={setActiveTab}
footer={<>
<div className="flex items-center gap-2">
{onDelete && (
<button
onClick={onClose}
className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors shrink-0"
onClick={() => setShowDeleteConfirm(true)}
className="p-2 text-text-tertiary hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
title={t('common.delete')}
>
<X className="w-5 h-5" />
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
<div className="flex items-center gap-2.5">
{dirty && (
<button
onClick={handleSave}
disabled={!form.name || saving}
className={`px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm ${saving ? 'btn-loading' : ''}`}
>
{t('tasks.saveChanges')}
</button>
)}
</div>
)
return (
<>
<SlidePanel onClose={onClose} maxWidth="420px" header={header}>
{/* Details Section */}
<CollapsibleSection title={t('projects.details')}>
<div className="px-5 pb-4 space-y-3">
</>}
>
{activeTab === 'details' && (
<div className="p-6 space-y-3">
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('projects.description')}</label>
<textarea
@@ -161,39 +187,45 @@ export default function ProjectEditPanel({ project, onClose, onSave, onDelete, b
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('projects.brand')}</label>
<select
<PortalSelect
value={form.brand_id}
onChange={e => update('brand_id', e.target.value)}
onChange={val => update('brand_id', val)}
options={[{ value: '', label: 'Select brand' }, ...(brands || []).map(b => ({ value: b._id || b.id, label: `${b.icon || ''} ${lang === 'ar' && b.name_ar ? b.name_ar : b.name}`.trim() }))]}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
<option value="">Select brand</option>
{(brands || []).map(b => <option key={b._id || b.id} value={b._id || b.id}>{b.icon} {lang === 'ar' && b.name_ar ? b.name_ar : b.name}</option>)}
</select>
/>
</div>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('projects.status')}</label>
<select
<PortalSelect
value={form.status}
onChange={e => update('status', e.target.value)}
onChange={val => update('status', val)}
options={statusOptions}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
{statusOptions.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
</select>
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('projects.owner')}</label>
<select
<PortalSelect
value={form.owner_id}
onChange={e => update('owner_id', e.target.value)}
onChange={val => update('owner_id', val)}
options={[{ value: '', label: t('common.unassigned') }, ...(teamMembers || []).map(m => ({ value: m._id || m.id, label: m.name }))]}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
<option value="">{t('common.unassigned')}</option>
{(teamMembers || []).map(m => <option key={m._id || m.id} value={m._id || m.id}>{m.name}</option>)}
</select>
/>
</div>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('common.team')}</label>
<PortalSelect
value={form.team_id}
onChange={val => update('team_id', val)}
options={[{ value: '', label: t('common.noTeam') }, ...(teams || []).map(tm => ({ value: tm.id || tm._id, label: tm.name }))]}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
/>
</div>
</div>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('projects.startDate')}</label>
<input
@@ -203,7 +235,6 @@ export default function ProjectEditPanel({ project, onClose, onSave, onDelete, b
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
/>
</div>
</div>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('projects.dueDate')}</label>
@@ -220,11 +251,11 @@ export default function ProjectEditPanel({ project, onClose, onSave, onDelete, b
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('projects.thumbnail')}</label>
{(project.thumbnail_url || project.thumbnailUrl) ? (
<div className="relative group rounded-lg overflow-hidden border border-border">
<img src={project.thumbnail_url || project.thumbnailUrl} alt="" className="w-full h-24 object-cover" />
<img src={project.thumbnail_url || project.thumbnailUrl} alt="" className="w-full h-24 object-cover" loading="lazy" />
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/30 transition-colors flex items-center justify-center gap-2 opacity-0 group-hover:opacity-100">
<button
onClick={() => thumbnailInputRef.current?.click()}
className="px-3 py-1.5 text-xs bg-white/90 hover:bg-white rounded-lg font-medium text-text-primary transition-colors"
className="px-3 py-1.5 text-xs bg-white/90 hover:bg-surface rounded-lg font-medium text-text-primary transition-colors"
>
{t('projects.changeThumbnail')}
</button>
@@ -252,41 +283,20 @@ export default function ProjectEditPanel({ project, onClose, onSave, onDelete, b
ref={thumbnailInputRef}
type="file"
accept="image/*"
className="hidden"
className="absolute w-0 h-0 opacity-0 pointer-events-none"
tabIndex={-1}
onChange={e => { handleThumbnailUpload(e.target.files[0]); e.target.value = '' }}
/>
</div>
<div className="flex items-center gap-2 pt-2">
{dirty && (
<button
onClick={handleSave}
disabled={!form.name || saving}
className={`flex-1 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm ${saving ? 'btn-loading' : ''}`}
>
{t('tasks.saveChanges')}
</button>
)}
{onDelete && (
<button
onClick={() => setShowDeleteConfirm(true)}
className="p-2 text-text-tertiary hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
title={t('common.delete')}
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
</div>
</CollapsibleSection>
)}
{/* Discussion Section */}
<CollapsibleSection title={t('projects.discussion')} noBorder>
<div className="px-5 pb-5">
{activeTab === 'discussion' && (
<div className="p-6 space-y-3">
<CommentsSection entityType="project" entityId={projectId} />
</div>
</CollapsibleSection>
</SlidePanel>
)}
</TabbedModal>
<Modal
isOpen={showDeleteConfirm}
+14 -21
View File
@@ -3,8 +3,17 @@ import { NavLink } from 'react-router-dom'
import {
LayoutDashboard, FileEdit, Image, Calendar, Wallet,
FolderKanban, CheckSquare, Users, ChevronLeft, ChevronRight, ChevronDown,
Sparkles, Shield, LogOut, User, Settings, Languages, Tag, LayoutList, Receipt, BarChart3, Palette, CalendarDays, AlertCircle
LogOut, User, Settings, Languages, Tag, LayoutList, Receipt, BarChart3, Palette, CalendarDays, AlertCircle
} from 'lucide-react'
function MarkaLogo({ className = '' }) {
return (
<svg viewBox="0 0 32 32" fill="none" className={className}>
<path d="M4 26V6l10 10L4 26z" fill="currentColor" opacity="0.85" />
<path d="M18 26V6l10 10-10 10z" fill="currentColor" opacity="0.5" />
</svg>
)
}
import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../i18n/LanguageContext'
@@ -26,6 +35,7 @@ const moduleGroups = [
{ to: '/artefacts', icon: Palette, labelKey: 'nav.artefacts' },
{ to: '/assets', icon: Image, labelKey: 'nav.assets', tutorial: 'assets' },
{ to: '/brands', icon: Tag, labelKey: 'nav.brands' },
{ to: '/translations', icon: Languages, labelKey: 'nav.copy' },
],
},
{
@@ -114,8 +124,8 @@ export default function Sidebar({ collapsed, setCollapsed }) {
>
{/* Logo */}
<div className="flex items-center gap-3 px-4 h-16 border-b border-white/10 shrink-0">
<div className="w-9 h-9 rounded-lg bg-gradient-to-br from-indigo-400 via-purple-500 to-pink-500 flex items-center justify-center shrink-0 shadow-lg shadow-indigo-500/30">
<Sparkles className="w-5 h-5 text-white" />
<div className="w-9 h-9 rounded-lg bg-brand-primary flex items-center justify-center shrink-0">
<MarkaLogo className="w-5 h-5 text-white" />
</div>
{!collapsed && (
<div className="animate-fade-in overflow-hidden">
@@ -167,23 +177,6 @@ export default function Sidebar({ collapsed, setCollapsed }) {
{standaloneBottom.map(item => navLink(item))}
</div>
{/* Superadmin Only: Users Management */}
{currentUser?.role === 'superadmin' && (
<NavLink
to="/users"
className={({ isActive }) =>
`flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-all duration-200 group ${
isActive
? 'bg-white/15 text-white shadow-sm'
: 'text-text-on-dark-muted hover:bg-white/8 hover:text-white'
}`
}
>
<Shield className="w-5 h-5 shrink-0" />
{!collapsed && <span className="animate-fade-in whitespace-nowrap">{t('nav.users')}</span>}
</NavLink>
)}
{/* Settings (visible to all) */}
<NavLink
to="/settings"
@@ -207,7 +200,7 @@ export default function Sidebar({ collapsed, setCollapsed }) {
<div className="flex items-center gap-3 px-3 py-2 rounded-lg bg-white/5">
<div className="w-8 h-8 rounded-full bg-brand-primary flex items-center justify-center shrink-0">
{currentUser.avatar ? (
<img src={currentUser.avatar} alt={currentUser.name} className="w-full h-full rounded-full object-cover" />
<img src={currentUser.avatar} alt={currentUser.name} className="w-full h-full rounded-full object-cover" loading="lazy" />
) : (
<User className="w-4 h-4 text-white" />
)}
+6 -6
View File
@@ -2,7 +2,7 @@
export function SkeletonCard() {
return (
<div className="bg-white rounded-xl border border-border p-5 animate-pulse">
<div className="bg-surface rounded-xl border border-border p-5 animate-pulse">
<div className="h-4 bg-surface-tertiary rounded w-3/4 mb-3"></div>
<div className="h-3 bg-surface-tertiary rounded w-1/2 mb-2"></div>
<div className="h-3 bg-surface-tertiary rounded w-2/3"></div>
@@ -12,7 +12,7 @@ export function SkeletonCard() {
export function SkeletonStatCard() {
return (
<div className="bg-white rounded-xl border border-border p-5 animate-pulse">
<div className="bg-surface rounded-xl border border-border p-5 animate-pulse">
<div className="flex items-start justify-between mb-4">
<div className="w-10 h-10 bg-surface-tertiary rounded-lg"></div>
<div className="h-3 bg-surface-tertiary rounded w-16"></div>
@@ -25,7 +25,7 @@ export function SkeletonStatCard() {
export function SkeletonTable({ rows = 5, cols = 6 }) {
return (
<div className="bg-white rounded-xl border border-border overflow-hidden animate-pulse">
<div className="bg-surface rounded-xl border border-border overflow-clip animate-pulse">
<div className="border-b border-border bg-surface-secondary p-4">
<div className="flex gap-4">
{[...Array(cols)].map((_, i) => (
@@ -60,7 +60,7 @@ export function SkeletonKanbanBoard() {
</div>
<div className="bg-surface-secondary rounded-xl p-2 space-y-2 min-h-[400px]">
{[...Array(3)].map((_, cardIdx) => (
<div key={cardIdx} className="bg-white rounded-lg border border-border p-3">
<div key={cardIdx} className="bg-surface rounded-lg border border-border p-3">
<div className="h-4 bg-surface-tertiary rounded w-full mb-2"></div>
<div className="h-3 bg-surface-tertiary rounded w-3/4 mb-3"></div>
<div className="flex gap-2">
@@ -78,7 +78,7 @@ export function SkeletonKanbanBoard() {
export function SkeletonCalendar() {
return (
<div className="bg-white rounded-xl border border-border overflow-hidden animate-pulse">
<div className="bg-surface rounded-xl border border-border overflow-clip animate-pulse">
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<div className="h-6 bg-surface-tertiary rounded w-40"></div>
<div className="h-8 bg-surface-tertiary rounded w-20"></div>
@@ -138,7 +138,7 @@ export function SkeletonDashboard() {
{/* Content cards */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{[...Array(2)].map((_, i) => (
<div key={i} className="bg-white rounded-xl border border-border animate-pulse">
<div key={i} className="bg-surface rounded-xl border border-border animate-pulse">
<div className="px-5 py-4 border-b border-border">
<div className="h-5 bg-surface-tertiary rounded w-32"></div>
</div>
+39 -7
View File
@@ -1,17 +1,49 @@
import { useEffect, useRef, useCallback } from 'react'
import { createPortal } from 'react-dom'
export default function SlidePanel({ onClose, maxWidth = '420px', header, children }) {
export default function SlidePanel({ onClose, maxWidth = '420px', header, footer, children }) {
const panelRef = useRef(null)
const handleKeyDown = useCallback((e) => {
if (e.key === 'Escape') onClose()
}, [onClose])
useEffect(() => {
if (!panelRef.current) return
const el = panelRef.current
const focusable = el.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
if (focusable.length > 0) focusable[0].focus()
const handleTab = (e) => {
if (e.key !== 'Tab' || focusable.length === 0) return
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (e.shiftKey) {
if (document.activeElement === first) { e.preventDefault(); last.focus() }
} else {
if (document.activeElement === last) { e.preventDefault(); first.focus() }
}
}
el.addEventListener('keydown', handleTab)
return () => el.removeEventListener('keydown', handleTab)
}, [])
return createPortal(
<>
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm animate-backdrop-in z-[9998]" onClick={onClose} />
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm animate-backdrop-in z-[9998]" onClick={onClose} aria-label="Close panel" />
<div
className="fixed top-0 right-0 h-full w-full bg-white shadow-2xl z-[9998] flex flex-col animate-slide-in-right overflow-hidden"
ref={panelRef}
className="fixed top-0 right-0 h-full w-full bg-surface shadow-2xl z-[9998] animate-slide-in-right overflow-y-auto"
style={{ maxWidth }}
role="dialog"
aria-modal="true"
onKeyDown={handleKeyDown}
>
{header}
<div className="flex-1 overflow-y-auto">
{children}
</div>
<div className="sticky top-0 z-10 bg-surface">{header}</div>
<div className="flex-1">{children}</div>
{footer}
</div>
</>,
document.body
+6 -6
View File
@@ -7,20 +7,20 @@ export default function StatCard({ icon: Icon, label, value, subtitle, color = '
}
const iconBgMap = {
'brand-primary': 'bg-indigo-50 text-indigo-600 shadow-lg shadow-indigo-500/20',
'brand-secondary': 'bg-pink-50 text-pink-600 shadow-lg shadow-pink-500/20',
'brand-tertiary': 'bg-amber-50 text-amber-600 shadow-lg shadow-amber-500/20',
'brand-quaternary': 'bg-emerald-50 text-emerald-600 shadow-lg shadow-emerald-500/20',
'brand-primary': 'bg-teal-50 text-teal-700',
'brand-secondary': 'bg-pink-50 text-pink-600',
'brand-tertiary': 'bg-amber-50 text-amber-600',
'brand-quaternary': 'bg-teal-50 text-teal-600',
}
const accentClass = accentMap[color] || 'accent-primary'
return (
<div className={`stat-card-premium ${accentClass} bg-white rounded-xl border border-border p-5 card-hover`}>
<div className={`stat-card-premium ${accentClass} bg-surface rounded-xl border border-border p-5 card-hover`}>
<div className="flex items-start justify-between">
<div>
<p className="text-sm font-medium text-text-tertiary">{label}</p>
<p className="text-3xl font-bold text-text-primary mt-1">{value}</p>
<p className="text-2xl font-bold text-text-primary mt-1">{value}</p>
{subtitle && (
<p className="text-xs text-text-tertiary mt-1">{subtitle}</p>
)}
+128
View File
@@ -0,0 +1,128 @@
import { useEffect, useRef, useCallback } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
const SIZE_CLASSES = {
sm: 'max-w-md',
md: 'max-w-lg',
lg: 'max-w-2xl',
xl: 'max-w-4xl',
}
export default function TabbedModal({
onClose,
size = 'md',
header,
tabs = [],
activeTab,
onTabChange,
footer,
children,
}) {
const modalRef = useRef(null)
useEffect(() => {
document.body.style.overflow = 'hidden'
return () => { document.body.style.overflow = '' }
}, [])
useEffect(() => {
if (!modalRef.current) return
const el = modalRef.current
const focusable = el.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
if (focusable.length > 0) focusable[0].focus()
const handleTab = (e) => {
if (e.key !== 'Tab' || focusable.length === 0) return
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (e.shiftKey) {
if (document.activeElement === first) { e.preventDefault(); last.focus() }
} else {
if (document.activeElement === last) { e.preventDefault(); first.focus() }
}
}
el.addEventListener('keydown', handleTab)
return () => el.removeEventListener('keydown', handleTab)
}, [])
const handleKeyDown = useCallback((e) => {
if (e.key === 'Escape') onClose()
}, [onClose])
return createPortal(
<div className="fixed inset-0 z-[9999] flex items-start justify-center pt-[5vh] px-4" onKeyDown={handleKeyDown} ref={modalRef}>
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm animate-backdrop-in" onClick={onClose} aria-label="Close dialog" />
<div className={`relative bg-surface rounded-2xl shadow-2xl w-full ${SIZE_CLASSES[size] || SIZE_CLASSES.md} max-h-[90vh] overflow-y-auto animate-scale-in`} role="dialog" aria-modal="true" aria-labelledby="tabbed-modal-title">
{/* Header */}
<div className="sticky top-0 z-10 bg-surface rounded-t-2xl">
<div className="px-6 pt-5 pb-3">
<div className="flex items-start justify-between gap-4">
<div id="tabbed-modal-title" className="flex-1 min-w-0">
{header}
</div>
<button
onClick={onClose}
className="p-2 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors shrink-0 -mt-1 -me-1"
aria-label="Close dialog"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
{/* Tabs */}
{tabs.length > 0 && (
<div className="flex gap-0 px-6 border-b border-border overflow-x-auto" role="tablist">
{tabs.map(tab => {
const TabIcon = tab.icon
return (
<button
key={tab.key}
onClick={() => onTabChange(tab.key)}
role="tab"
aria-selected={activeTab === tab.key}
className={`relative flex items-center gap-2 px-4 py-3 text-[13px] font-medium whitespace-nowrap transition-colors ${
activeTab === tab.key
? 'text-brand-primary'
: 'text-text-tertiary hover:text-text-secondary'
}`}
>
{TabIcon && <TabIcon className="w-4 h-4" />}
{tab.label}
{tab.badge > 0 && (
<span className={`text-[10px] px-1.5 py-px rounded-full font-medium leading-tight ${
activeTab === tab.key ? 'bg-brand-primary/10 text-brand-primary' : 'bg-surface-tertiary text-text-tertiary'
}`}>
{tab.badge}
</span>
)}
{activeTab === tab.key && (
<span className="absolute bottom-0 inset-x-1 h-0.5 bg-brand-primary rounded-full" />
)}
</button>
)
})}
</div>
)}
</div>
{/* Body */}
<div role="tabpanel">
{children}
</div>
{/* Footer */}
{footer && (
<div className="border-t border-border px-6 py-3.5 flex items-center justify-between rounded-b-2xl bg-surface">
{footer}
</div>
)}
</div>
</div>,
document.body
)
}
+63 -14
View File
@@ -1,5 +1,5 @@
import { useState } from 'react'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { ChevronLeft, ChevronRight, Calendar as CalendarIcon, CalendarDays } from 'lucide-react'
import { PRIORITY_CONFIG } from '../utils/api'
import { useLanguage } from '../i18n/LanguageContext'
@@ -27,6 +27,18 @@ function getMonthData(year, month) {
return cells
}
function getWeekData(startDate) {
const cells = []
const start = new Date(startDate)
start.setDate(start.getDate() - start.getDay())
for (let i = 0; i < 7; i++) {
const d = new Date(start)
d.setDate(start.getDate() + i)
cells.push({ day: d.getDate(), current: true, date: d })
}
return cells
}
function dateKey(d) {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
@@ -36,8 +48,12 @@ export default function TaskCalendarView({ tasks, onTaskClick }) {
const today = new Date()
const [year, setYear] = useState(today.getFullYear())
const [month, setMonth] = useState(today.getMonth())
const [calView, setCalView] = useState('month')
const [weekStart, setWeekStart] = useState(() => {
const d = new Date(); d.setDate(d.getDate() - d.getDay()); return d
})
const cells = getMonthData(year, month)
const cells = calView === 'month' ? getMonthData(year, month) : getWeekData(weekStart)
const todayKey = dateKey(today)
// Group tasks by due_date
@@ -62,16 +78,29 @@ export default function TaskCalendarView({ tasks, onTaskClick }) {
if (month === 11) { setMonth(0); setYear(y => y + 1) }
else setMonth(m => m + 1)
}
const goToday = () => { setYear(today.getFullYear()); setMonth(today.getMonth()) }
const prevWeek = () => setWeekStart(d => { const n = new Date(d); n.setDate(n.getDate() - 7); return n })
const nextWeek = () => setWeekStart(d => { const n = new Date(d); n.setDate(n.getDate() + 7); return n })
const goToday = () => {
setYear(today.getFullYear()); setMonth(today.getMonth())
const d = new Date(); d.setDate(d.getDate() - d.getDay()); setWeekStart(d)
}
const monthLabel = new Date(year, month).toLocaleString('default', { month: 'long', year: 'numeric' })
const weekLabel = (() => {
const start = new Date(weekStart)
start.setDate(start.getDate() - start.getDay())
const end = new Date(start); end.setDate(start.getDate() + 6)
const fmt = (d) => d.toLocaleString('default', { month: 'short', day: 'numeric' })
return `${fmt(start)} ${fmt(end)}, ${end.getFullYear()}`
})()
const getPillColor = (task) => {
const p = task.priority || 'medium'
if (p === 'urgent') return 'bg-red-500 text-white'
if (p === 'high') return 'bg-orange-400 text-white'
if (p === 'medium') return 'bg-amber-400 text-amber-900'
return 'bg-gray-300 text-gray-700'
return 'bg-gray-300 text-text-secondary'
}
return (
@@ -81,18 +110,38 @@ export default function TaskCalendarView({ tasks, onTaskClick }) {
{/* Nav */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<button onClick={prevMonth} className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors">
<button onClick={calView === 'month' ? prevMonth : prevWeek} className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors">
<ChevronLeft className="w-4 h-4" />
</button>
<h3 className="text-sm font-semibold text-text-primary min-w-[150px] text-center">{monthLabel}</h3>
<button onClick={nextMonth} className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors">
<h3 className="text-sm font-semibold text-text-primary min-w-[180px] text-center">
{calView === 'month' ? monthLabel : weekLabel}
</h3>
<button onClick={calView === 'month' ? nextMonth : nextWeek} className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors">
<ChevronRight className="w-4 h-4" />
</button>
</div>
<div className="flex items-center gap-2">
<div className="flex bg-surface-tertiary rounded-lg p-0.5">
<button
onClick={() => setCalView('month')}
className={`px-2.5 py-1 text-[11px] font-medium rounded-md transition-colors flex items-center gap-1 ${calView === 'month' ? 'bg-surface shadow-sm text-text-primary' : 'text-text-tertiary'}`}
>
<CalendarIcon className="w-3 h-3" />
Month
</button>
<button
onClick={() => setCalView('week')}
className={`px-2.5 py-1 text-[11px] font-medium rounded-md transition-colors flex items-center gap-1 ${calView === 'week' ? 'bg-surface shadow-sm text-text-primary' : 'text-text-tertiary'}`}
>
<CalendarDays className="w-3 h-3" />
Week
</button>
</div>
<button onClick={goToday} className="px-3 py-1 text-xs font-medium text-brand-primary hover:bg-brand-primary/5 rounded-lg transition-colors">
{t('tasks.today')}
</button>
</div>
</div>
{/* Day headers */}
<div className="grid grid-cols-7 mb-1">
@@ -112,8 +161,8 @@ export default function TaskCalendarView({ tasks, onTaskClick }) {
return (
<div
key={i}
className={`border-r border-b border-border min-h-[90px] p-1 ${
cell.current ? 'bg-white' : 'bg-surface-secondary/50'
className={`border-r border-b border-border ${calView === 'week' ? 'min-h-[300px]' : 'min-h-[90px]'} p-1 ${
cell.current ? 'bg-surface' : 'bg-surface-secondary/50'
}`}
>
<div className={`text-[11px] font-medium mb-0.5 w-6 h-6 flex items-center justify-center rounded-full ${
@@ -122,11 +171,11 @@ export default function TaskCalendarView({ tasks, onTaskClick }) {
{cell.day}
</div>
<div className="space-y-0.5">
{dayTasks.slice(0, 3).map(task => (
{dayTasks.slice(0, calView === 'week' ? 10 : 3).map(task => (
<button
key={task._id || task.id}
onClick={() => onTaskClick(task)}
className={`w-full text-left text-[10px] px-1.5 py-0.5 rounded truncate font-medium hover:opacity-80 transition-opacity ${
className={`w-full text-start text-[10px] px-1.5 py-0.5 rounded truncate font-medium hover:opacity-80 transition-opacity ${
task.status === 'done' ? 'bg-emerald-100 text-emerald-700 line-through' : getPillColor(task)
}`}
title={task.title}
@@ -134,9 +183,9 @@ export default function TaskCalendarView({ tasks, onTaskClick }) {
{task.title}
</button>
))}
{dayTasks.length > 3 && (
{dayTasks.length > (calView === 'week' ? 10 : 3) && (
<div className="text-[9px] text-text-tertiary text-center font-medium">
+{dayTasks.length - 3} more
+{dayTasks.length - (calView === 'week' ? 10 : 3)} more
</div>
)}
</div>
@@ -157,7 +206,7 @@ export default function TaskCalendarView({ tasks, onTaskClick }) {
<button
key={task._id || task.id}
onClick={() => onTaskClick(task)}
className="w-full text-left bg-white border border-border rounded-lg p-2 hover:border-brand-primary/30 transition-colors"
className="w-full text-start bg-surface border border-border rounded-lg p-2 hover:border-brand-primary/30 transition-colors"
>
<div className="flex items-center gap-1.5">
<div className={`w-2 h-2 rounded-full ${priority.color} shrink-0`} />
+1 -1
View File
@@ -32,7 +32,7 @@ export default function TaskCard({ task, onMove, showProject = true }) {
const assignedName = task.assigned_name || task.assignedName
return (
<div className={`bg-white rounded-lg border border-border p-3 card-hover group cursor-pointer ${isExternallyAssigned ? 'border-l-[3px] border-l-blue-400' : ''}`}>
<div className={`bg-surface rounded-lg border border-border p-3 card-hover group cursor-pointer ${isExternallyAssigned ? 'border-l-[3px] border-l-blue-400' : ''}`}>
<div className="flex items-start gap-2.5">
{/* Priority dot */}
<div className={`w-2.5 h-2.5 rounded-full ${priority.color} mt-1.5 shrink-0`} title={priority.label} />
+90 -98
View File
@@ -1,17 +1,18 @@
import { useState, useEffect, useRef } from 'react'
import { X, Trash2, AlertCircle, Upload, FileText, Star } from 'lucide-react'
import { X, Trash2, AlertCircle, Upload, FileText, Star, FileEdit, Paperclip, MessageSquare } from 'lucide-react'
import { PRIORITY_CONFIG, getBrandColor, api } from '../utils/api'
import { useLanguage } from '../i18n/LanguageContext'
import CommentsSection from './CommentsSection'
import Modal from './Modal'
import SlidePanel from './SlidePanel'
import CollapsibleSection from './CollapsibleSection'
import TabbedModal from './TabbedModal'
import PortalSelect from './PortalSelect'
const API_BASE = '/api'
export default function TaskDetailPanel({ task, onClose, onSave, onDelete, projects, users, brands }) {
const { t } = useLanguage()
const fileInputRef = useRef(null)
const [activeTab, setActiveTab] = useState('details')
const [form, setForm] = useState({
title: '', description: '', project_id: '', assigned_to: '',
priority: 'medium', status: 'todo', start_date: '', due_date: '',
@@ -120,7 +121,7 @@ export default function TaskDetailPanel({ task, onClose, onSave, onDelete, proje
if (!taskId) return
try {
const data = await api.get(`/tasks/${taskId}/attachments`)
setAttachments(Array.isArray(data) ? data : (data.data || []))
setAttachments(Array.isArray(data) ? data : [])
} catch {
setAttachments([])
}
@@ -186,24 +187,30 @@ export default function TaskDetailPanel({ task, onClose, onSave, onDelete, proje
const selectedProject = projects?.find(p => String(p._id || p.id) === String(form.project_id))
const brandName = selectedProject ? (selectedProject.brand_name || selectedProject.brandName) : (task.brand_name || task.brandName)
const header = (
<div className="px-5 py-4 border-b border-border shrink-0">
const attachmentCount = attachments.length + pendingFiles.length
const tabs = [
{ key: 'details', label: t('tasks.details'), icon: FileEdit },
{ key: 'attachments', label: t('tasks.attachments'), icon: Paperclip, badge: attachmentCount },
...(!isCreateMode ? [{ key: 'discussion', label: t('tasks.discussion'), icon: MessageSquare }] : []),
]
const headerContent = (
<>
{/* Thumbnail banner */}
{currentThumbnail && (
<div className="relative -mx-5 -mt-4 mb-3 h-32 overflow-hidden">
<img src={currentThumbnail} alt="" className="w-full h-full object-cover" />
<div className="relative -mx-6 -mt-5 mb-3 h-32 overflow-hidden rounded-t-2xl">
<img src={currentThumbnail} alt="" className="w-full h-full object-cover" loading="lazy" />
<div className="absolute inset-0 bg-gradient-to-t from-white/80 to-transparent" />
<button
onClick={handleRemoveThumbnail}
className="absolute top-2 right-2 p-1 bg-black/40 hover:bg-black/60 rounded-full text-white transition-colors"
className="absolute top-2 end-2 p-1 bg-black/40 hover:bg-black/60 rounded-full text-white transition-colors"
title={t('tasks.removeThumbnail')}
>
<X className="w-3 h-3" />
</button>
</div>
)}
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<input
type="text"
value={form.title}
@@ -212,11 +219,11 @@ export default function TaskDetailPanel({ task, onClose, onSave, onDelete, proje
placeholder={t('tasks.taskTitle')}
/>
<div className="flex items-center gap-2 mt-2">
<span className={`inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full font-medium ${priority.color === 'bg-gray-400' ? 'bg-gray-100 text-gray-600' : priority.color === 'bg-amber-400' ? 'bg-amber-100 text-amber-700' : priority.color === 'bg-orange-500' ? 'bg-orange-100 text-orange-700' : 'bg-red-100 text-red-700'}`}>
<span className={`inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full font-medium ${priority.color === 'bg-gray-400' ? 'bg-gray-100 text-text-secondary' : priority.color === 'bg-amber-400' ? 'bg-amber-100 text-amber-700' : priority.color === 'bg-orange-500' ? 'bg-orange-100 text-orange-700' : 'bg-red-100 text-red-700'}`}>
<div className={`w-1.5 h-1.5 rounded-full ${priority.color}`} />
{priorityOptions.find(p => p.value === form.priority)?.label}
</span>
<span className={`text-[11px] px-2 py-0.5 rounded-full font-medium ${form.status === 'done' ? 'bg-emerald-100 text-emerald-700' : form.status === 'in_progress' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600'}`}>
<span className={`text-[11px] px-2 py-0.5 rounded-full font-medium ${form.status === 'done' ? 'bg-emerald-100 text-emerald-700' : form.status === 'in_progress' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-text-secondary'}`}>
{statusOptions.find(s => s.value === form.status)?.label}
</span>
{isOverdue && !isCreateMode && (
@@ -226,23 +233,51 @@ export default function TaskDetailPanel({ task, onClose, onSave, onDelete, proje
</span>
)}
</div>
</div>
</>
)
const footerContent = (
<>
<div className="flex items-center gap-2">
{onDelete && !isCreateMode && (
<button
onClick={onClose}
className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors shrink-0"
onClick={handleDelete}
className="p-2 text-text-tertiary hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
title={t('common.delete')}
>
<X className="w-5 h-5" />
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
<div className="flex items-center gap-2.5">
{dirty && (
<button
onClick={handleSave}
disabled={!form.title || saving}
className={`px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm ${saving ? 'btn-loading' : ''}`}
>
{isCreateMode ? t('tasks.createTask') : t('tasks.saveChanges')}
</button>
)}
</div>
</>
)
return (
<>
<SlidePanel onClose={onClose} maxWidth="420px" header={header}>
{/* Details Section */}
<CollapsibleSection title={t('tasks.details')}>
<div className="px-5 pb-4 space-y-3">
<TabbedModal
onClose={onClose}
size="md"
header={headerContent}
tabs={tabs}
activeTab={activeTab}
onTabChange={setActiveTab}
footer={footerContent}
>
{/* Details Tab */}
{activeTab === 'details' && (
<div className="p-6">
<div className="space-y-3">
{/* Description */}
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.description')}</label>
@@ -259,16 +294,12 @@ export default function TaskDetailPanel({ task, onClose, onSave, onDelete, proje
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.project')}</label>
<div className="flex items-center gap-2">
<select
<PortalSelect
value={form.project_id}
onChange={e => update('project_id', e.target.value)}
onChange={val => update('project_id', val)}
options={[{ value: '', label: t('tasks.noProject') }, ...(projects || []).map(p => ({ value: p._id || p.id, label: p.name || p.title }))]}
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
<option value="">{t('tasks.noProject')}</option>
{(projects || []).map(p => (
<option key={p._id || p.id} value={p._id || p.id}>{p.name || p.title}</option>
))}
</select>
/>
{brandName && (
<span className={`text-[10px] px-1.5 py-0.5 rounded shrink-0 ${getBrandColor(brandName).bg} ${getBrandColor(brandName).text}`}>
{brandName}
@@ -280,43 +311,33 @@ export default function TaskDetailPanel({ task, onClose, onSave, onDelete, proje
{/* Assignee */}
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.assignee')}</label>
<select
<PortalSelect
value={form.assigned_to}
onChange={e => update('assigned_to', e.target.value)}
onChange={val => update('assigned_to', val)}
options={[{ value: '', label: t('common.unassigned') }, ...(users || []).map(m => ({ value: m._id || m.team_member_id, label: m.name }))]}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
<option value="">{t('common.unassigned')}</option>
{(users || []).map(m => (
<option key={m._id || m.team_member_id} value={m._id || m.team_member_id}>{m.name}</option>
))}
</select>
/>
</div>
{/* Priority & Status */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.priority')}</label>
<select
<PortalSelect
value={form.priority}
onChange={e => update('priority', e.target.value)}
onChange={val => update('priority', val)}
options={priorityOptions}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
{priorityOptions.map(p => (
<option key={p.value} value={p.value}>{p.label}</option>
))}
</select>
/>
</div>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.status')}</label>
<select
<PortalSelect
value={form.status}
onChange={e => update('status', e.target.value)}
onChange={val => update('status', val)}
options={statusOptions}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
{statusOptions.map(s => (
<option key={s.value} value={s.value}>{s.label}</option>
))}
</select>
/>
</div>
</div>
@@ -349,41 +370,13 @@ export default function TaskDetailPanel({ task, onClose, onSave, onDelete, proje
<p className="text-sm text-text-secondary">{creatorName}</p>
</div>
)}
{/* Action buttons */}
<div className="flex items-center gap-2 pt-2">
{dirty && (
<button
onClick={handleSave}
disabled={!form.title || saving}
className={`flex-1 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm ${saving ? 'btn-loading' : ''}`}
>
{isCreateMode ? t('tasks.createTask') : t('tasks.saveChanges')}
</button>
)}
{onDelete && !isCreateMode && (
<button
onClick={handleDelete}
className="p-2 text-text-tertiary hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
title={t('common.delete')}
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
</div>
</CollapsibleSection>
)}
{/* Attachments Section */}
<CollapsibleSection
title={t('tasks.attachments')}
badge={(attachments.length + pendingFiles.length) > 0 ? (
<span className="text-xs font-medium text-text-tertiary bg-surface-tertiary px-1.5 py-0.5 rounded-full">
{attachments.length + pendingFiles.length}
</span>
) : null}
>
<div className="px-5 pb-4">
{/* Attachments Tab */}
{activeTab === 'attachments' && (
<div className="p-6">
{/* Existing attachment grid (edit mode) */}
{attachments.length > 0 && (
<div className="grid grid-cols-2 gap-2 mb-3">
@@ -395,11 +388,11 @@ export default function TaskDetailPanel({ task, onClose, onSave, onDelete, proje
const isThumbnail = currentThumbnail && attUrl === currentThumbnail
return (
<div key={attId} className="relative group/att border border-border rounded-lg overflow-hidden bg-white">
<div key={attId} className="relative group/att border border-border rounded-lg overflow-hidden bg-surface">
<div className="h-20 relative">
{isImage ? (
<a href={attUrl} target="_blank" rel="noopener noreferrer" className="block h-full">
<img src={attUrl} alt={name} className="absolute inset-0 w-full h-full object-cover" />
<img src={attUrl} alt={name} className="absolute inset-0 w-full h-full object-cover" loading="lazy" />
</a>
) : (
<a href={attUrl} target="_blank" rel="noopener noreferrer" className="absolute inset-0 flex items-center gap-2 p-3">
@@ -408,11 +401,11 @@ export default function TaskDetailPanel({ task, onClose, onSave, onDelete, proje
</a>
)}
{isThumbnail && (
<div className="absolute top-1 left-1 p-0.5 bg-amber-400 rounded-full text-white">
<div className="absolute top-1 start-1 p-0.5 bg-amber-400 rounded-full text-white">
<Star className="w-2.5 h-2.5 fill-current" />
</div>
)}
<div className="absolute top-1 right-1 flex items-center gap-0.5 opacity-0 group-hover/att:opacity-100 transition-opacity">
<div className="absolute top-1 end-1 flex items-center gap-0.5 opacity-0 group-hover/att:opacity-100 transition-opacity">
{isImage && !isThumbnail && (
<button
onClick={() => handleSetThumbnail(att)}
@@ -448,17 +441,17 @@ export default function TaskDetailPanel({ task, onClose, onSave, onDelete, proje
const previewUrl = isImage ? URL.createObjectURL(file) : null
return (
<div key={i} className="relative group/att border border-border rounded-lg overflow-hidden bg-white">
<div key={i} className="relative group/att border border-border rounded-lg overflow-hidden bg-surface">
<div className="h-20 relative">
{isImage ? (
<img src={previewUrl} alt={file.name} className="absolute inset-0 w-full h-full object-cover" />
<img src={previewUrl} alt={file.name} className="absolute inset-0 w-full h-full object-cover" loading="lazy" />
) : (
<div className="absolute inset-0 flex items-center gap-2 p-3">
<FileText className="w-6 h-6 text-text-tertiary shrink-0" />
<span className="text-xs text-text-secondary truncate">{file.name}</span>
</div>
)}
<div className="absolute top-1 right-1 flex items-center gap-0.5 opacity-0 group-hover/att:opacity-100 transition-opacity">
<div className="absolute top-1 end-1 flex items-center gap-0.5 opacity-0 group-hover/att:opacity-100 transition-opacity">
<button
onClick={() => setPendingFiles(prev => prev.filter((_, j) => j !== i))}
className="p-1 bg-black/50 hover:bg-red-500 rounded-full text-white transition-colors"
@@ -488,7 +481,8 @@ export default function TaskDetailPanel({ task, onClose, onSave, onDelete, proje
ref={fileInputRef}
type="file"
multiple
className="hidden"
className="absolute w-0 h-0 opacity-0 pointer-events-none"
tabIndex={-1}
onChange={e => {
setUploadError(null)
const files = Array.from(e.target.files || [])
@@ -524,17 +518,15 @@ export default function TaskDetailPanel({ task, onClose, onSave, onDelete, proje
</div>
)}
</div>
</CollapsibleSection>
)}
{/* Discussion Section (hidden in create mode) */}
{!isCreateMode && (
<CollapsibleSection title={t('tasks.discussion')} noBorder>
<div className="px-5 pb-5">
{/* Discussion Tab */}
{activeTab === 'discussion' && !isCreateMode && (
<div className="p-6">
<CommentsSection entityType="task" entityId={taskId} />
</div>
</CollapsibleSection>
)}
</SlidePanel>
</TabbedModal>
{/* Delete Confirmation */}
<Modal
+182 -144
View File
@@ -1,43 +1,35 @@
import { useState, useEffect, useRef } from 'react'
import { X, Trash2, ChevronDown, Check } from 'lucide-react'
import { useState, useEffect, useRef, useContext } from 'react'
import { Trash2, ChevronDown, Check, ShieldAlert, Eye, EyeOff, FileEdit, BarChart3, X } from 'lucide-react'
import { useLanguage } from '../i18n/LanguageContext'
import { api } from '../utils/api'
import { useToast } from './ToastContainer'
import Modal from './Modal'
import SlidePanel from './SlidePanel'
import CollapsibleSection from './CollapsibleSection'
import TabbedModal from './TabbedModal'
import StatusBadge from './StatusBadge'
const ROLES = [
{ value: 'manager', label: 'Manager' },
{ value: 'approver', label: 'Approver' },
{ value: 'publisher', label: 'Publisher' },
{ value: 'content_creator', label: 'Content Creator' },
{ value: 'producer', label: 'Producer' },
{ value: 'designer', label: 'Designer' },
{ value: 'content_writer', label: 'Content Writer' },
{ value: 'social_media_manager', label: 'Social Media Manager' },
{ value: 'photographer', label: 'Photographer' },
{ value: 'videographer', label: 'Videographer' },
{ value: 'strategist', label: 'Strategist' },
]
import PortalSelect from './PortalSelect'
import { AppContext, PERMISSION_LEVELS } from '../App'
const ALL_MODULES = ['marketing', 'projects', 'finance']
const MODULE_LABELS = { marketing: 'Marketing', projects: 'Projects', finance: 'Finance' }
const MODULE_COLORS = {
marketing: { on: 'bg-emerald-100 text-emerald-700 border-emerald-300', off: 'bg-gray-100 text-gray-400 border-gray-200' },
projects: { on: 'bg-blue-100 text-blue-700 border-blue-300', off: 'bg-gray-100 text-gray-400 border-gray-200' },
finance: { on: 'bg-amber-100 text-amber-700 border-amber-300', off: 'bg-gray-100 text-gray-400 border-gray-200' },
marketing: { on: 'bg-emerald-100 text-emerald-700 border-emerald-300', off: 'bg-gray-100 text-text-tertiary border-gray-200' },
projects: { on: 'bg-blue-100 text-blue-700 border-blue-300', off: 'bg-gray-100 text-text-tertiary border-gray-200' },
finance: { on: 'bg-amber-100 text-amber-700 border-amber-300', off: 'bg-gray-100 text-text-tertiary border-gray-200' },
}
export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave, onDelete, canManageTeam, userRole, teams, brands: brandsList }) {
const { t, lang } = useLanguage()
const toast = useToast()
const { roles } = useContext(AppContext)
const [form, setForm] = useState({})
const [dirty, setDirty] = useState(false)
const [saving, setSaving] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [showBrandsDropdown, setShowBrandsDropdown] = useState(false)
const [confirmPassword, setConfirmPassword] = useState('')
const [passwordError, setPasswordError] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [passwordSaving, setPasswordSaving] = useState(false)
const [activeTab, setActiveTab] = useState('details')
const brandsDropdownRef = useRef(null)
// Workload state (loaded internally)
@@ -46,7 +38,6 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
const [loadingWorkload, setLoadingWorkload] = useState(false)
const memberId = member?._id || member?.id
const isCreateMode = !memberId
useEffect(() => {
if (member) {
@@ -54,16 +45,18 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
name: member.name || '',
email: member.email || '',
password: '',
role: member.team_role || member.role || 'content_writer',
permission_level: member.role || 'contributor',
role_id: member.role_id || '',
brands: Array.isArray(member.brands) ? member.brands : [],
phone: member.phone || '',
modules: Array.isArray(member.modules) ? member.modules : ALL_MODULES,
team_ids: Array.isArray(member.teams) ? member.teams.map(t => t.id) : [],
})
setDirty(isCreateMode)
setDirty(false)
setConfirmPassword('')
setPasswordError('')
if (!isCreateMode) loadWorkload()
setShowPassword(false)
setActiveTab('details')
if (memberId) loadWorkload()
}
}, [member])
@@ -112,30 +105,40 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
}
const handleSave = async () => {
setPasswordError('')
if (isCreateMode && form.password && form.password !== confirmPassword) {
setPasswordError('Passwords do not match')
return
}
setSaving(true)
try {
await onSave(isCreateMode ? null : memberId, {
await onSave(memberId, {
name: form.name,
email: form.email,
password: form.password,
role: form.role,
role: form.permission_level,
role_id: form.role_id || null,
brands: form.brands || [],
phone: form.phone,
modules: form.modules,
team_ids: form.team_ids,
}, isEditingSelf)
setDirty(false)
if (isCreateMode) onClose()
} finally {
setSaving(false)
}
}
const handlePasswordChange = async () => {
if (!form.password || form.password !== confirmPassword) return
setPasswordSaving(true)
try {
await onSave(memberId, { password: form.password }, false)
setForm(f => ({ ...f, password: '' }))
setConfirmPassword('')
setShowPassword(false)
toast.success(t('team.passwordChanged'))
} finally {
setPasswordSaving(false)
}
}
const passwordMismatch = confirmPassword && form.password !== confirmPassword
const confirmDelete = async () => {
setShowDeleteConfirm(false)
await onDelete(memberId)
@@ -143,14 +146,26 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
}
const initials = member.name?.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase() || '?'
const roleName = (form.role || '').replace(/_/g, ' ')
const currentRole = roles.find(r => (r.Id || r.id) === form.role_id)
const roleName = currentRole?.name || member.role_name || member.team_role || ''
const todoCount = memberTasks.filter(t => t.status === 'todo').length
const inProgressCount = memberTasks.filter(t => t.status === 'in_progress').length
const doneCount = memberTasks.filter(t => t.status === 'done').length
const header = (
<div className="px-5 py-4 border-b border-border shrink-0">
<div className="flex items-start justify-between gap-3">
const showAdminTab = !isEditingSelf && userRole === 'superadmin'
const tabs = [
{ key: 'details', label: t('team.details'), icon: FileEdit },
{ key: 'workload', label: t('team.workload'), icon: BarChart3 },
...(showAdminTab ? [{ key: 'admin', label: t('team.adminActions'), icon: ShieldAlert }] : []),
]
return (
<>
<TabbedModal
onClose={onClose}
size="md"
header={
<div className="flex items-center gap-3 min-w-0">
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-indigo-400 to-purple-500 flex items-center justify-center text-white text-sm font-bold shrink-0">
{initials}
@@ -168,93 +183,84 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
</span>
</div>
</div>
}
tabs={tabs}
activeTab={activeTab}
onTabChange={setActiveTab}
footer={<>
<div className="flex items-center gap-2">
{canManageTeam && onDelete && !isEditingSelf && (
<button
onClick={onClose}
className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors shrink-0"
onClick={() => setShowDeleteConfirm(true)}
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-red-600 border border-red-200 rounded-lg hover:bg-red-50 transition-colors"
>
<X className="w-5 h-5" />
<Trash2 className="w-4 h-4" />
{t('team.removeMember')}
</button>
)}
</div>
<div className="flex items-center gap-2.5">
{dirty && (
<button
onClick={handleSave}
disabled={!form.name || saving}
className={`px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm ${saving ? 'btn-loading' : ''}`}
>
{isEditingSelf ? t('team.saveProfile') : t('team.saveChanges')}
</button>
)}
</div>
)
return (
<>
<SlidePanel onClose={onClose} maxWidth="420px" header={header}>
{/* Details Section */}
<CollapsibleSection title={t('team.details')}>
<div className="px-5 pb-4 space-y-3">
</>}
>
{/* Details Tab */}
{activeTab === 'details' && (
<div className="p-6 space-y-3">
{!isEditingSelf && (
<>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.email')} *</label>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.email')}</label>
<input
type="email"
value={form.email}
onChange={e => update('email', e.target.value)}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
placeholder="email@example.com"
disabled={!isCreateMode}
/>
</div>
{isCreateMode && (
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.password')}</label>
<input
type="password"
value={form.password}
onChange={e => update('password', e.target.value)}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
placeholder="••••••••"
/>
{!form.password && (
<p className="text-xs text-text-tertiary mt-1">{t('team.defaultPassword')}</p>
)}
</div>
)}
{isCreateMode && form.password && (
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">Confirm Password</label>
<input
type="password"
value={confirmPassword}
onChange={e => { setConfirmPassword(e.target.value); setPasswordError('') }}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
placeholder="••••••••"
/>
{passwordError && (
<p className="text-xs text-red-500 mt-1">{passwordError}</p>
)}
</div>
)}
</>
)}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.teamRole')}</label>
{userRole === 'manager' && isCreateMode && !isEditingSelf ? (
<>
<input
type="text"
value="Contributor"
disabled
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface-tertiary text-text-tertiary cursor-not-allowed"
/>
<p className="text-xs text-text-tertiary mt-1">{t('team.fixedRole')}</p>
</>
) : (
<select
value={form.role}
onChange={e => update('role', e.target.value)}
</div>
)}
<div className="grid grid-cols-2 gap-3">
{/* Permission Level (superadmin only) */}
{userRole === 'superadmin' && !isEditingSelf && (
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.permissionLevel')}</label>
<PortalSelect
value={form.permission_level}
onChange={val => update('permission_level', val)}
options={PERMISSION_LEVELS.map(p => ({ value: p.value, label: p.label }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
{ROLES.map(r => <option key={r.value} value={r.value}>{r.label}</option>)}
</select>
/>
</div>
)}
{/* Role (from Roles table) */}
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.role')}</label>
{isEditingSelf ? (
<input
type="text"
value={roleName || '—'}
disabled
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface-tertiary text-text-tertiary cursor-not-allowed"
/>
) : (
<PortalSelect
value={form.role_id || ''}
onChange={val => update('role_id', val ? Number(val) : null)}
options={[{ value: '', label: t('team.selectRole') }, ...roles.map(r => ({ value: r.Id || r.id, label: r.name }))]}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
/>
)}
</div>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.phone')}</label>
<input
@@ -269,10 +275,15 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
<div ref={brandsDropdownRef} className="relative">
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.brands')}</label>
{isEditingSelf && userRole !== 'superadmin' ? (
<div className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface-tertiary text-text-tertiary cursor-not-allowed">
{(form.brands || []).length === 0 ? '—' : (form.brands || []).join(', ')}
</div>
) : <>
<button
type="button"
onClick={() => setShowBrandsDropdown(prev => !prev)}
className="w-full flex items-center justify-between px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-white text-left"
className="w-full flex items-center justify-between px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface text-start"
>
<span className={`flex-1 truncate ${(form.brands || []).length === 0 ? 'text-text-tertiary' : 'text-text-primary'}`}>
{(form.brands || []).length === 0
@@ -302,7 +313,7 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
{/* Dropdown */}
{showBrandsDropdown && (
<div className="absolute z-20 mt-1 w-full bg-white border border-border rounded-lg shadow-lg max-h-48 overflow-y-auto">
<div className="absolute z-20 mt-1 w-full bg-surface border border-border rounded-lg shadow-lg max-h-48 overflow-y-auto">
{brandsList && brandsList.length > 0 ? (
brandsList.map(brand => {
const name = lang === 'ar' && brand.name_ar ? brand.name_ar : brand.name
@@ -312,7 +323,7 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
type="button"
key={brand.id || brand._id}
onClick={() => toggleBrand(name)}
className={`w-full flex items-center gap-2.5 px-3 py-2 cursor-pointer hover:bg-surface-secondary transition-colors text-left ${checked ? 'bg-brand-primary/5' : ''}`}
className={`w-full flex items-center gap-2.5 px-3 py-2 cursor-pointer hover:bg-surface-secondary transition-colors text-start ${checked ? 'bg-brand-primary/5' : ''}`}
>
<div className={`w-4 h-4 rounded border flex items-center justify-center shrink-0 transition-colors ${
checked ? 'bg-brand-primary border-brand-primary' : 'border-border'
@@ -328,10 +339,11 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
)}
</div>
)}
</>}
</div>
{/* Modules toggle */}
{!isEditingSelf && canManageTeam && (
{(!isEditingSelf || userRole === 'superadmin') && canManageTeam && (
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.modules')}</label>
<div className="flex flex-wrap gap-2">
@@ -379,7 +391,7 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
className={`text-xs px-3 py-1.5 rounded-full border font-medium transition-colors ${
active
? 'bg-blue-100 text-blue-700 border-blue-300'
: 'bg-gray-100 text-gray-400 border-gray-200'
: 'bg-gray-100 text-text-tertiary border-gray-200'
}`}
>
{team.name}
@@ -389,34 +401,12 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
</div>
</div>
)}
<div className="flex items-center gap-2 pt-2">
{dirty && (
<button
onClick={handleSave}
disabled={!form.name || (!isEditingSelf && isCreateMode && !form.email) || saving}
className={`flex-1 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm ${saving ? 'btn-loading' : ''}`}
>
{isEditingSelf ? t('team.saveProfile') : (isCreateMode ? t('team.addMember') : t('team.saveChanges'))}
</button>
)}
{!isCreateMode && !isEditingSelf && canManageTeam && onDelete && (
<button
onClick={() => setShowDeleteConfirm(true)}
className="p-2 text-text-tertiary hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
title={t('team.remove')}
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
</div>
</CollapsibleSection>
)}
{/* Workload Section (hidden in create mode) */}
{!isCreateMode && (
<CollapsibleSection title={t('team.workload')} noBorder>
<div className="px-5 pb-4 space-y-3">
{/* Workload Tab */}
{activeTab === 'workload' && (
<div className="p-6 space-y-3">
{/* Stats */}
<div className="grid grid-cols-4 gap-2">
<div className="bg-surface-secondary rounded-lg p-2 text-center">
@@ -473,9 +463,57 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
<p className="text-xs text-text-tertiary text-center py-2">{t('common.loading')}</p>
)}
</div>
</CollapsibleSection>
)}
</SlidePanel>
{/* Admin Actions Tab */}
{activeTab === 'admin' && showAdminTab && (
<div className="p-6 space-y-3">
{/* Change password */}
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.password')}</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
value={form.password}
onChange={e => update('password', e.target.value)}
className="w-full px-3 py-2 pe-9 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
placeholder={t('team.newPassword')}
autoComplete="new-password"
/>
<button
type="button"
onClick={() => setShowPassword(v => !v)}
className="absolute end-2.5 top-1/2 -translate-y-1/2 text-text-tertiary hover:text-text-primary"
tabIndex={-1}
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.confirmPassword')}</label>
<input
type={showPassword ? 'text' : 'password'}
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
className={`w-full px-3 py-2 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary ${passwordMismatch ? 'border-red-400' : 'border-border'}`}
placeholder={t('team.confirmPassword')}
autoComplete="new-password"
/>
{passwordMismatch && (
<p className="text-[11px] text-red-500 mt-1">{t('team.passwordsDoNotMatch')}</p>
)}
</div>
<button
onClick={handlePasswordChange}
disabled={!form.password || form.password.length < 6 || form.password !== confirmPassword || passwordSaving}
className={`w-full px-4 py-2 bg-amber-500 text-white rounded-lg text-sm font-medium hover:bg-amber-600 disabled:opacity-50 disabled:cursor-not-allowed shadow-sm ${passwordSaving ? 'btn-loading' : ''}`}
>
{t('team.changePassword')}
</button>
</div>
)}
</TabbedModal>
<Modal
isOpen={showDeleteConfirm}
+56 -51
View File
@@ -1,10 +1,9 @@
import { useState, useEffect } from 'react'
import { X, Trash2, Search } from 'lucide-react'
import { Trash2, Search, FileEdit, Users } from 'lucide-react'
import { useLanguage } from '../i18n/LanguageContext'
import { getInitials } from '../utils/api'
import Modal from './Modal'
import SlidePanel from './SlidePanel'
import CollapsibleSection from './CollapsibleSection'
import TabbedModal from './TabbedModal'
export default function TeamPanel({ team, onClose, onSave, onDelete, teamMembers }) {
const { t } = useLanguage()
@@ -13,6 +12,7 @@ export default function TeamPanel({ team, onClose, onSave, onDelete, teamMembers
const [saving, setSaving] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [memberSearch, setMemberSearch] = useState('')
const [activeTab, setActiveTab] = useState('details')
const teamId = team?.id || team?._id
const isCreateMode = !teamId
@@ -68,10 +68,15 @@ export default function TeamPanel({ team, onClose, onSave, onDelete, teamMembers
!memberSearch || m.name?.toLowerCase().includes(memberSearch.toLowerCase())
)
const header = (
<div className="px-5 py-4 border-b border-border shrink-0">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
const memberCount = (form.member_ids || []).length
return (
<>
<TabbedModal
onClose={onClose}
size="md"
header={
<>
<input
type="text"
value={form.name}
@@ -80,24 +85,45 @@ export default function TeamPanel({ team, onClose, onSave, onDelete, teamMembers
placeholder={t('teams.name')}
/>
<span className="text-[11px] px-2 py-0.5 rounded-full font-medium bg-blue-100 text-blue-700">
{(form.member_ids || []).length} {t('teams.members')}
{memberCount} {t('teams.members')}
</span>
</div>
<button
onClick={onClose}
className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors shrink-0"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
)
return (
</>
}
tabs={[
{ key: 'details', label: t('teams.details'), icon: FileEdit },
{ key: 'members', label: t('teams.members'), icon: Users, badge: memberCount },
]}
activeTab={activeTab}
onTabChange={setActiveTab}
footer={
<>
<SlidePanel onClose={onClose} maxWidth="420px" header={header}>
<CollapsibleSection title={t('teams.details')}>
<div className="px-5 pb-4 space-y-3">
<div className="flex items-center gap-2">
{!isCreateMode && onDelete && (
<button
onClick={() => setShowDeleteConfirm(true)}
className="p-2 text-text-tertiary hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
title={t('teams.deleteTeam')}
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
<div className="flex items-center gap-2.5">
{dirty && (
<button
onClick={handleSave}
disabled={!form.name || saving}
className={`px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm ${saving ? 'btn-loading' : ''}`}
>
{isCreateMode ? t('teams.createTeam') : t('common.save')}
</button>
)}
</div>
</>
}
>
{activeTab === 'details' && (
<div className="p-6 space-y-3">
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('teams.name')}</label>
<input
@@ -117,40 +143,19 @@ export default function TeamPanel({ team, onClose, onSave, onDelete, teamMembers
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary resize-none"
/>
</div>
<div className="flex items-center gap-2 pt-2">
{dirty && (
<button
onClick={handleSave}
disabled={!form.name || saving}
className={`flex-1 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm ${saving ? 'btn-loading' : ''}`}
>
{isCreateMode ? t('teams.createTeam') : t('common.save')}
</button>
)}
{!isCreateMode && onDelete && (
<button
onClick={() => setShowDeleteConfirm(true)}
className="p-2 text-text-tertiary hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
title={t('teams.deleteTeam')}
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
</div>
</CollapsibleSection>
)}
<CollapsibleSection title={t('teams.members')} noBorder>
<div className="px-5 pb-4">
{activeTab === 'members' && (
<div className="p-6">
<div className="relative mb-3">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-text-tertiary" />
<Search className="absolute start-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-text-tertiary" />
<input
type="text"
value={memberSearch}
onChange={e => setMemberSearch(e.target.value)}
placeholder={t('teams.selectMembers')}
className="w-full pl-9 pr-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
className="w-full ps-9 pe-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
/>
</div>
<div className="space-y-1 max-h-80 overflow-y-auto">
@@ -180,8 +185,8 @@ export default function TeamPanel({ team, onClose, onSave, onDelete, teamMembers
)}
</div>
</div>
</CollapsibleSection>
</SlidePanel>
)}
</TabbedModal>
<Modal
isOpen={showDeleteConfirm}
+21
View File
@@ -0,0 +1,21 @@
import { useTheme } from '../contexts/ThemeContext'
import { Moon, Sun } from 'lucide-react'
export default function ThemeToggle({ className = '' }) {
const { darkMode, toggleDarkMode } = useTheme()
return (
<button
onClick={toggleDarkMode}
className={`p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors ${className}`}
title={darkMode ? 'Switch to light mode' : 'Switch to dark mode'}
aria-label={darkMode ? 'Switch to light mode' : 'Switch to dark mode'}
>
{darkMode ? (
<Sun className="w-5 h-5 text-yellow-500" />
) : (
<Moon className="w-5 h-5 text-text-secondary" />
)}
</button>
)
}
+1 -1
View File
@@ -37,7 +37,7 @@ export function ToastProvider({ children }) {
<ToastContext.Provider value={toast}>
{children}
{/* Toast container - fixed position */}
<div className="fixed top-4 right-4 z-50 flex flex-col gap-2 pointer-events-none">
<div className="fixed top-4 end-4 z-[10000] flex flex-col gap-2 pointer-events-none">
<div className="flex flex-col gap-2 pointer-events-auto">
{toasts.map(t => (
<Toast
+68 -75
View File
@@ -1,11 +1,11 @@
import { useState, useEffect } from 'react'
import { X, Trash2 } from 'lucide-react'
import { Trash2, FileEdit, BarChart3 } from 'lucide-react'
import { useLanguage } from '../i18n/LanguageContext'
import { PLATFORMS } from '../utils/api'
import Modal from './Modal'
import SlidePanel from './SlidePanel'
import CollapsibleSection from './CollapsibleSection'
import TabbedModal from './TabbedModal'
import BudgetBar from './BudgetBar'
import PortalSelect from './PortalSelect'
const TRACK_TYPES = {
organic_social: { label: 'Organic Social' },
@@ -23,6 +23,7 @@ export default function TrackDetailPanel({ track, campaignId, onClose, onSave, o
const [dirty, setDirty] = useState(false)
const [saving, setSaving] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [activeTab, setActiveTab] = useState(scrollToMetrics ? 'metrics' : 'details')
const trackId = track?._id || track?.id
const isCreateMode = !trackId
@@ -85,10 +86,20 @@ export default function TrackDetailPanel({ track, campaignId, onClose, onSave, o
const typeInfo = TRACK_TYPES[form.type] || TRACK_TYPES.organic_social
const header = (
<div className="px-5 py-4 border-b border-border shrink-0">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
const tabs = isCreateMode
? [{ key: 'details', label: t('tracks.details'), icon: FileEdit }]
: [
{ key: 'details', label: t('tracks.details'), icon: FileEdit },
{ key: 'metrics', label: t('tracks.metrics'), icon: BarChart3 },
]
return (
<>
<TabbedModal
onClose={onClose}
size="md"
header={
<>
<input
type="text"
value={form.name}
@@ -104,54 +115,63 @@ export default function TrackDetailPanel({ track, campaignId, onClose, onSave, o
form.status === 'active' ? 'bg-emerald-100 text-emerald-700' :
form.status === 'paused' ? 'bg-amber-100 text-amber-700' :
form.status === 'completed' ? 'bg-blue-100 text-blue-700' :
'bg-gray-100 text-gray-600'
'bg-gray-100 text-text-secondary'
}`}>
{form.status?.charAt(0).toUpperCase() + form.status?.slice(1)}
</span>
</div>
</div>
<button
onClick={onClose}
className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors shrink-0"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
)
return (
</>
}
tabs={tabs}
activeTab={activeTab}
onTabChange={setActiveTab}
footer={
<>
<SlidePanel onClose={onClose} maxWidth="420px" header={header}>
{/* Details Section */}
<CollapsibleSection title={t('tracks.details')}>
<div className="px-5 pb-4 space-y-3">
<div className="flex items-center gap-2">
{onDelete && !isCreateMode && (
<button
onClick={() => setShowDeleteConfirm(true)}
className="p-2 text-text-tertiary hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
title={t('common.delete')}
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
<div className="flex items-center gap-2.5">
{dirty && (
<button
onClick={handleSave}
disabled={saving}
className={`px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm ${saving ? 'btn-loading' : ''}`}
>
{isCreateMode ? t('tracks.addTrack') : t('tasks.saveChanges')}
</button>
)}
</div>
</>
}
>
{activeTab === 'details' && (
<div className="p-6 space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tracks.type')}</label>
<select
<PortalSelect
value={form.type}
onChange={e => update('type', e.target.value)}
onChange={val => update('type', val)}
options={Object.entries(TRACK_TYPES).map(([k, v]) => ({ value: k, label: v.label }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
>
{Object.entries(TRACK_TYPES).map(([k, v]) => (
<option key={k} value={k}>{v.label}</option>
))}
</select>
/>
</div>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tracks.platform')}</label>
<select
<PortalSelect
value={form.platform}
onChange={e => update('platform', e.target.value)}
onChange={val => update('platform', val)}
options={[{ value: '', label: 'All / Multiple' }, ...Object.entries(PLATFORMS).map(([k, v]) => ({ value: k, label: v.label })), { value: 'google_ads', label: 'Google Ads' }]}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
>
<option value="">All / Multiple</option>
{Object.entries(PLATFORMS).map(([k, v]) => (
<option key={k} value={k}>{v.label}</option>
))}
<option value="google_ads">Google Ads</option>
</select>
/>
</div>
</div>
@@ -168,15 +188,12 @@ export default function TrackDetailPanel({ track, campaignId, onClose, onSave, o
</div>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tracks.status')}</label>
<select
<PortalSelect
value={form.status}
onChange={e => update('status', e.target.value)}
onChange={val => update('status', val)}
options={TRACK_STATUSES.map(s => ({ value: s, label: s.charAt(0).toUpperCase() + s.slice(1) }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
>
{TRACK_STATUSES.map(s => (
<option key={s} value={s}>{s.charAt(0).toUpperCase() + s.slice(1)}</option>
))}
</select>
/>
</div>
</div>
@@ -190,34 +207,11 @@ export default function TrackDetailPanel({ track, campaignId, onClose, onSave, o
placeholder="Keywords, targeting details..."
/>
</div>
<div className="flex items-center gap-2 pt-2">
{dirty && (
<button
onClick={handleSave}
disabled={saving}
className={`flex-1 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm ${saving ? 'btn-loading' : ''}`}
>
{isCreateMode ? t('tracks.addTrack') : t('tasks.saveChanges')}
</button>
)}
{onDelete && !isCreateMode && (
<button
onClick={() => setShowDeleteConfirm(true)}
className="p-2 text-text-tertiary hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
title={t('common.delete')}
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
</div>
</CollapsibleSection>
)}
{/* Metrics Section (hidden in create mode) */}
{!isCreateMode && (
<CollapsibleSection title={t('tracks.metrics')} defaultOpen={!!scrollToMetrics} noBorder>
<div className="px-5 pb-4 space-y-3">
{activeTab === 'metrics' && !isCreateMode && (
<div className="p-6 space-y-3">
{Number(form.budget_allocated) > 0 && (
<div className="p-3 bg-surface-secondary rounded-lg">
<BudgetBar budget={Number(form.budget_allocated)} spent={Number(form.budget_spent) || 0} height="h-2" />
@@ -287,9 +281,8 @@ export default function TrackDetailPanel({ track, campaignId, onClose, onSave, o
</div>
</div>
</div>
</CollapsibleSection>
)}
</SlidePanel>
</TabbedModal>
<Modal
isOpen={showDeleteConfirm}
@@ -0,0 +1,584 @@
import { useState, useEffect, useContext } from 'react'
import { Plus, Copy, Check, ExternalLink, Trash2, Save, FileEdit, Languages, ShieldCheck, Globe, Lock } from 'lucide-react'
import { AppContext } from '../App'
import { useLanguage } from '../i18n/LanguageContext'
import { api } from '../utils/api'
import { AVAILABLE_LANGUAGES, TRANSLATION_STATUS_COLORS, isTextSelected, groupTextsByLanguage } from '../utils/translations'
import Modal from './Modal'
import TabbedModal from './TabbedModal'
import { useToast } from './ToastContainer'
import PortalSelect from './PortalSelect'
export default function TranslationDetailPanel({ translation, onClose, onUpdate, onDelete, assignableUsers = [], posts: externalPosts }) {
const { t } = useLanguage()
const { brands } = useContext(AppContext)
const toast = useToast()
const isApproved = translation.status === 'approved'
const [editTitle, setEditTitle] = useState(translation.title || '')
const [editSourceContent, setEditSourceContent] = useState(translation.source_content || '')
const [editSourceLanguage, setEditSourceLanguage] = useState(translation.source_language || 'EN')
const [editApproverIds, setEditApproverIds] = useState(
translation.approvers?.map(a => String(a.id)) || (translation.approver_ids ? translation.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : [])
)
const reviewUrl = translation.approval_token ? `${window.location.origin}/review-translation/${translation.approval_token}` : ''
const [activeTab, setActiveTab] = useState('details')
const [texts, setTexts] = useState([])
const [loading, setLoading] = useState(true)
const [savingDraft, setSavingDraft] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [copied, setCopied] = useState(false)
const [freshReviewUrl, setFreshReviewUrl] = useState('')
const [copiedTextId, setCopiedTextId] = useState(null)
// Post selector
const [posts, setPosts] = useState(externalPosts || [])
const [showCreatePost, setShowCreatePost] = useState(false)
const [newPostTitle, setNewPostTitle] = useState('')
const [creatingPost, setCreatingPost] = useState(false)
// Language add modal
const [showAddLang, setShowAddLang] = useState(false)
const [langForm, setLangForm] = useState({ language_code: '', content: '' })
const [savingLang, setSavingLang] = useState(false)
// Delete confirm
const [confirmDeleteTextId, setConfirmDeleteTextId] = useState(null)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
// Inline editing for translation texts
const [editingTextId, setEditingTextId] = useState(null)
const [editingContent, setEditingContent] = useState('')
useEffect(() => {
loadTexts()
}, [translation.Id])
useEffect(() => {
if (externalPosts) setPosts(externalPosts)
}, [externalPosts])
useEffect(() => {
setEditTitle(translation.title || '')
setEditSourceContent(translation.source_content || '')
setEditSourceLanguage(translation.source_language || 'EN')
setEditApproverIds(
translation.approvers?.map(a => String(a.id)) || (translation.approver_ids ? translation.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : [])
)
}, [translation.Id])
const loadTexts = async () => {
try {
const res = await api.get(`/translations/${translation.Id}/texts`)
setTexts(Array.isArray(res) ? res : [])
} catch (err) {
console.error('Failed to load texts:', err)
} finally {
setLoading(false)
}
}
const handleSaveDraft = async () => {
if (!editTitle.trim()) {
toast.error(t('translations.titleRequired'))
return
}
setSavingDraft(true)
try {
await api.patch(`/translations/${translation.Id}`, {
title: editTitle,
source_content: editSourceContent,
source_language: editSourceLanguage,
approver_ids: editApproverIds.length > 0 ? editApproverIds.join(',') : null,
})
toast.success(t('translations.draftSaved'))
onUpdate()
} catch (err) {
toast.error(t('translations.failedSaveDraft'))
} finally {
setSavingDraft(false)
}
}
const handleFieldUpdate = async (field, value) => {
try {
await api.patch(`/translations/${translation.Id}`, { [field]: value || null })
toast.success(t('translations.updated'))
onUpdate()
} catch (err) {
toast.error(t('translations.failedUpdate'))
}
}
const handleAddTranslationText = async () => {
if (!langForm.language_code || !langForm.content) {
toast.error(t('translations.allFieldsRequired'))
return
}
setSavingLang(true)
try {
const lang = AVAILABLE_LANGUAGES.find(l => l.code === langForm.language_code)
await api.post(`/translations/${translation.Id}/texts`, {
language_code: langForm.language_code,
language_label: lang?.label || langForm.language_code,
content: langForm.content,
})
toast.success(t('translations.translationAdded'))
setShowAddLang(false)
setLangForm({ language_code: '', content: '' })
loadTexts()
} catch (err) {
toast.error(t('translations.failedAddTranslation'))
} finally {
setSavingLang(false)
}
}
const handleUpdateText = async (textId) => {
try {
await api.patch(`/translations/${translation.Id}/texts/${textId}`, {
content: editingContent,
})
toast.success(t('translations.updated'))
setEditingTextId(null)
loadTexts()
} catch (err) {
toast.error(t('translations.failedUpdate'))
}
}
const handleDeleteText = async (textId) => {
try {
await api.delete(`/translations/${translation.Id}/texts/${textId}`)
toast.success(t('translations.translationDeleted'))
loadTexts()
} catch (err) {
toast.error(t('translations.failedDeleteTranslation'))
}
}
const handleSubmitReview = async () => {
setSubmitting(true)
try {
const res = await api.post(`/translations/${translation.Id}/submit-review`)
setFreshReviewUrl(res.reviewUrl || res.data?.reviewUrl || '')
toast.success(t('translations.submittedForReview'))
onUpdate()
} catch (err) {
toast.error(t('translations.failedSubmitReview'))
} finally {
setSubmitting(false)
}
}
const copyReviewLink = () => {
const url = freshReviewUrl || reviewUrl
navigator.clipboard.writeText(url)
setCopied(true)
toast.success(t('translations.linkCopied'))
setTimeout(() => setCopied(false), 2000)
}
const handleDelete = async () => {
try {
await onDelete(translation.Id || translation.id || translation._id)
} catch (err) {
toast.error(t('translations.failedDelete'))
}
}
const handleCreatePost = async () => {
if (!newPostTitle.trim()) return
setCreatingPost(true)
try {
const created = await api.post('/posts', { title: newPostTitle, status: 'draft' })
const postId = created.Id || created.id || created._id
setPosts(prev => [created, ...prev])
await handleFieldUpdate('post_id', postId)
setShowCreatePost(false)
setNewPostTitle('')
} catch (err) {
toast.error(t('translations.postCreateFailed'))
} finally {
setCreatingPost(false)
}
}
const copyTextContent = (content, id) => {
navigator.clipboard.writeText(content)
setCopiedTextId(id)
toast.success(t('translations.copiedToClipboard'))
setTimeout(() => setCopiedTextId(null), 2000)
}
// Available languages (exclude source language only — multiple options per language allowed)
const targetLanguages = AVAILABLE_LANGUAGES.filter(l => l.code !== translation.source_language)
// Group texts by language
const textsByLanguage = groupTextsByLanguage(texts)
const tabs = [
{ key: 'details', label: t('translations.details'), icon: FileEdit },
{ key: 'translations', label: t('translations.translationTexts'), icon: Languages, badge: texts.length },
{ key: 'review', label: t('translations.review'), icon: ShieldCheck },
]
const currentReviewUrl = freshReviewUrl || reviewUrl
return (
<>
<TabbedModal
onClose={onClose}
size="xl"
header={
<>
<div className="flex items-center gap-3 mb-1">
<Languages className="w-5 h-5 text-brand-primary shrink-0" />
<input
type="text"
value={editTitle}
onChange={e => setEditTitle(e.target.value)}
readOnly={isApproved}
className={`text-lg font-bold text-text-primary bg-transparent border-none outline-none focus:ring-0 w-full ${isApproved ? 'cursor-default' : ''}`}
placeholder={t('translations.titlePlaceholder')}
/>
</div>
<div className="flex items-center gap-2 flex-wrap">
<span className={`text-xs px-2 py-0.5 rounded-full ${TRANSLATION_STATUS_COLORS[translation.status] || 'bg-surface-tertiary text-text-secondary'}`}>
{translation.status?.replace('_', ' ')}
</span>
<span className="text-xs px-2 py-0.5 rounded-full bg-blue-50 text-blue-600 font-medium">
{AVAILABLE_LANGUAGES.find(l => l.code === editSourceLanguage)?.label || editSourceLanguage}
</span>
{translation.creator_name && (
<span className="text-xs text-text-tertiary">
{t('review.createdBy')} <strong className="text-text-primary">{translation.creator_name}</strong>
</span>
)}
</div>
</>
}
tabs={tabs}
activeTab={activeTab}
onTabChange={setActiveTab}
footer={isApproved ? (
<div className="flex items-center gap-2 w-full justify-center">
<Lock className="w-4 h-4 text-text-tertiary" />
<span className="text-sm text-text-tertiary">{t('translations.approvedReadOnly')}</span>
</div>
) : (
<div className="flex items-center gap-2 w-full justify-between">
<div className="flex items-center gap-2">
<button
onClick={() => setShowDeleteConfirm(true)}
className="p-2 rounded-lg text-red-500 hover:bg-red-50 transition-colors"
title={t('translations.deleteTranslation')}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
<button
onClick={handleSaveDraft}
disabled={savingDraft}
className="flex items-center gap-2 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 shadow-sm"
title={t('translations.saveDraftTooltip')}
>
<Save className="w-4 h-4" />
{savingDraft ? t('translations.savingDraft') : t('translations.saveDraft')}
</button>
</div>
)}
>
{/* Details Tab */}
{activeTab === 'details' && (
<div className="p-6 space-y-5">
<div>
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-2">{t('translations.sourceLanguage')}</h4>
<PortalSelect
value={editSourceLanguage}
onChange={val => setEditSourceLanguage(val)}
disabled={isApproved}
options={AVAILABLE_LANGUAGES.map(l => ({ value: l.code, label: `${l.label} (${l.code})` }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 disabled:opacity-60 disabled:cursor-default"
/>
</div>
<div>
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-2">{t('translations.sourceContent')}</h4>
<textarea
value={editSourceContent}
onChange={e => setEditSourceContent(e.target.value)}
readOnly={isApproved}
className={`w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 min-h-[150px] resize-y ${isApproved ? 'opacity-60 cursor-default' : ''}`}
placeholder={t('translations.sourceContentPlaceholder')}
/>
</div>
</div>
)}
{/* Translations Tab */}
{activeTab === 'translations' && (
<div className="p-6 space-y-5">
{/* Source content reference */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="flex items-center gap-2 mb-2">
<Globe className="w-4 h-4 text-blue-600" />
<h4 className="text-sm font-semibold text-blue-900">
{t('translations.sourceContent')} {AVAILABLE_LANGUAGES.find(l => l.code === translation.source_language)?.label || translation.source_language}
</h4>
</div>
<p className="text-sm text-blue-800 whitespace-pre-wrap">{translation.source_content}</p>
</div>
{/* Add translation option button */}
<div className="flex items-center justify-between">
<h4 className="text-xs font-semibold text-text-tertiary uppercase">{t('translations.translationTexts')}</h4>
{!isApproved && (
<button
onClick={() => setShowAddLang(true)}
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors"
>
<Plus className="w-3 h-3" />
{t('translations.addOption')}
</button>
)}
</div>
{/* Grouped by language */}
{targetLanguages.some(l => textsByLanguage[l.code]?.length > 0) ? (
<div className="space-y-5">
{targetLanguages.map(lang => {
const options = textsByLanguage[lang.code] || []
if (options.length === 0) return null
return (
<div key={lang.code}>
<div className="flex items-center gap-2 mb-2">
<span className="text-sm font-semibold text-text-primary">{lang.label}</span>
<span className="text-xs text-text-tertiary">({lang.code})</span>
<span className="text-xs px-1.5 py-0.5 rounded-full bg-surface-tertiary text-text-tertiary">
{options.length} {options.length === 1 ? t('translations.option') : t('translations.options')}
</span>
</div>
<div className="space-y-2">
{(() => { const hasSelected = options.some(isTextSelected); return options.map((text, idx) => {
const selected = isTextSelected(text)
const isDimmed = isApproved && hasSelected && !selected
return (
<div key={text.Id} className={`rounded-lg p-3 border ${selected ? 'bg-emerald-50 border-emerald-300' : isDimmed ? 'bg-surface-secondary border-border opacity-50' : 'bg-surface-secondary border-border'}`}>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-medium text-text-tertiary">
{t('translations.optionLabel')} {text.option_number || idx + 1}
{selected && <span className="ms-2 text-emerald-600 font-semibold">{t('translations.selected')}</span>}
</span>
<div className="flex items-center gap-1">
{editingTextId !== text.Id && (
<button
onClick={() => copyTextContent(text.content, text.Id)}
className="text-text-tertiary hover:text-text-primary p-1"
title={t('translations.copyContent')}
>
{copiedTextId === text.Id ? <Check className="w-3.5 h-3.5 text-emerald-600" /> : <Copy className="w-3.5 h-3.5" />}
</button>
)}
{isApproved ? null : editingTextId === text.Id ? (
<>
<button onClick={() => handleUpdateText(text.Id)} className="text-emerald-600 hover:text-emerald-700 p-1">
<Check className="w-3.5 h-3.5" />
</button>
<button onClick={() => setEditingTextId(null)} className="text-text-tertiary hover:text-text-secondary p-1 text-xs"></button>
</>
) : (
<>
<button onClick={() => { setEditingTextId(text.Id); setEditingContent(text.content || '') }} className="text-text-tertiary hover:text-text-secondary p-1">
<FileEdit className="w-3.5 h-3.5" />
</button>
<button onClick={() => setConfirmDeleteTextId(text.Id)} className="text-red-500 hover:text-red-600 p-1">
<Trash2 className="w-3.5 h-3.5" />
</button>
</>
)}
</div>
</div>
{editingTextId === text.Id ? (
<textarea
value={editingContent}
onChange={e => setEditingContent(e.target.value)}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 min-h-[80px] resize-y"
autoFocus
/>
) : (
<p className="text-sm text-text-secondary whitespace-pre-wrap">{text.content}</p>
)}
</div>
)
}) })()}
</div>
</div>
)
})}
</div>
) : (
<div className="text-center py-8 bg-surface-secondary rounded-lg border-2 border-dashed border-border">
<Languages className="w-8 h-8 text-text-tertiary mx-auto mb-2" />
<p className="text-sm text-text-secondary">{t('translations.noTranslationTexts')}</p>
</div>
)}
</div>
)}
{/* Review Tab */}
{activeTab === 'review' && (
<div className="p-6 space-y-5">
{['draft', 'revision_requested', 'rejected'].includes(translation.status) && (
<>
<div>
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1.5">{t('artefacts.reviewer')}</h4>
<PortalSelect
value={editApproverIds[0] || ''}
onChange={val => {
const ids = val ? [val] : []
setEditApproverIds(ids)
handleFieldUpdate('approver_ids', val || '')
}}
options={[{ value: '', label: t('artefacts.selectReviewer') }, ...(assignableUsers || []).map(u => ({ value: u.id || u.Id, label: u.name }))]}
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface text-text-primary"
/>
</div>
<button
onClick={handleSubmitReview}
disabled={submitting || editApproverIds.length === 0}
className="w-full py-3 bg-brand-primary text-white rounded-lg font-medium hover:bg-brand-primary-light disabled:opacity-50 transition-colors shadow-sm"
>
{submitting ? t('translations.submitting') : t('translations.submitForReview')}
</button>
</>
)}
{currentReviewUrl && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="text-sm font-semibold text-blue-900 mb-2">{t('translations.reviewLinkTitle')}</div>
<div className="flex items-center gap-2">
<input
type="text"
value={currentReviewUrl}
readOnly
className="flex-1 px-3 py-2 text-sm bg-surface border border-blue-200 rounded-lg text-blue-800"
/>
<button
onClick={copyReviewLink}
className="p-2 rounded-lg bg-blue-600 text-white hover:bg-blue-700 transition-colors"
>
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
</button>
<a
href={currentReviewUrl}
target="_blank"
rel="noopener noreferrer"
className="p-2 rounded-lg bg-blue-600 text-white hover:bg-blue-700 transition-colors"
>
<ExternalLink className="w-4 h-4" />
</a>
</div>
</div>
)}
{translation.feedback && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
<h4 className="text-sm font-semibold text-amber-900 mb-2">{t('translations.feedbackTitle')}</h4>
<p className="text-sm text-amber-800 whitespace-pre-wrap">{translation.feedback}</p>
</div>
)}
{translation.status === 'approved' && translation.approved_by_name && (
<div className="bg-emerald-50 border border-emerald-200 rounded-lg p-4">
<div className="font-medium text-emerald-900">{t('translations.approvedByLabel')} {translation.approved_by_name}</div>
{translation.approved_at && (
<div className="text-sm text-emerald-700 mt-1">
{new Date(translation.approved_at).toLocaleString()}
</div>
)}
</div>
)}
{!['draft', 'revision_requested', 'rejected'].includes(translation.status) && !currentReviewUrl && !translation.feedback && !(translation.status === 'approved' && translation.approved_by_name) && (
<p className="text-sm text-text-secondary text-center py-4">
{translation.status === 'pending_review'
? t('translations.pendingReviewInfo')
: t('translations.noReviewInfo')}
</p>
)}
</div>
)}
</TabbedModal>
{/* Add Translation Modal */}
<Modal isOpen={showAddLang} onClose={() => setShowAddLang(false)} title={t('translations.addOption')} size="md">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('translations.languageLabel')} *</label>
<PortalSelect
value={langForm.language_code}
onChange={val => setLangForm(f => ({ ...f, language_code: val }))}
options={[{ value: '', label: t('translations.selectLanguage') }, ...targetLanguages.map(l => {
const count = textsByLanguage[l.code]?.length || 0
return { value: l.code, label: `${l.label} (${l.code})${count > 0 ? `${count} ${t('translations.existing')}` : ''}` }
})]}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('translations.translatedContent')} *</label>
<textarea
value={langForm.content}
onChange={e => setLangForm(f => ({ ...f, content: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 min-h-[150px] resize-y"
placeholder={t('translations.enterTranslatedContent')}
/>
</div>
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
<button onClick={() => setShowAddLang(false)} className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg">
{t('common.cancel')}
</button>
<button
onClick={handleAddTranslationText}
disabled={savingLang || !langForm.language_code || !langForm.content}
className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 shadow-sm"
>
{savingLang ? t('common.loading') : t('translations.addOption')}
</button>
</div>
</div>
</Modal>
{/* Delete translation text confirm */}
<Modal
isOpen={!!confirmDeleteTextId}
onClose={() => setConfirmDeleteTextId(null)}
title={t('translations.deleteTranslationText')}
isConfirm
danger
onConfirm={() => { handleDeleteText(confirmDeleteTextId); setConfirmDeleteTextId(null) }}
confirmText={t('common.delete')}
>
{t('translations.deleteTranslationTextDesc')}
</Modal>
{/* Delete translation confirm */}
<Modal
isOpen={showDeleteConfirm}
onClose={() => setShowDeleteConfirm(false)}
title={t('translations.deleteTranslation')}
isConfirm
danger
onConfirm={() => { handleDelete(); setShowDeleteConfirm(false) }}
confirmText={t('common.delete')}
>
{t('translations.deleteTranslationDesc')}
</Modal>
</>
)
}
+2 -2
View File
@@ -177,7 +177,7 @@ export default function Tutorial({ onComplete }) {
{/* Tooltip card */}
<div
className="absolute bg-white rounded-xl shadow-2xl border border-border p-6 animate-fade-in pointer-events-auto"
className="absolute bg-surface rounded-xl shadow-2xl border border-border p-6 animate-fade-in pointer-events-auto"
style={{
top: tooltipPosition.top,
left: tooltipPosition.left,
@@ -188,7 +188,7 @@ export default function Tutorial({ onComplete }) {
{/* Close button */}
<button
onClick={handleSkip}
className="absolute top-4 right-4 text-text-tertiary hover:text-text-primary transition-colors"
className="absolute top-4 end-4 text-text-tertiary hover:text-text-primary transition-colors"
>
<X className="w-5 h-5" />
</button>
+92
View File
@@ -0,0 +1,92 @@
import { useState, useRef } from 'react'
import { Upload } from 'lucide-react'
import { useLanguage } from '../i18n/LanguageContext'
export default function UploadZone({
onUpload,
accept = '*',
uploading = false,
progress = 0,
label,
hint,
compact = false,
multiple = false,
disabled = false,
}) {
const { t } = useLanguage()
const [dragOver, setDragOver] = useState(false)
const inputRef = useRef(null)
const processFiles = (files) => {
const list = Array.from(files)
const filtered = accept === '*' ? list : list.filter(f => {
if (accept.endsWith('/*')) return f.type.startsWith(accept.replace('/*', '/'))
return f.type === accept
})
if (filtered.length === 0) return
if (multiple) filtered.forEach(f => onUpload(f))
else onUpload(filtered[0])
}
const handleClick = () => {
if (uploading || disabled) return
inputRef.current?.click()
}
const handleChange = (e) => {
processFiles(e.target.files || [])
e.target.value = ''
}
const handleDrop = (e) => {
e.preventDefault()
setDragOver(false)
if (uploading || disabled) return
processFiles(e.dataTransfer.files || [])
}
return (
<div
onClick={handleClick}
onDragOver={(e) => { e.preventDefault(); setDragOver(true) }}
onDragLeave={() => setDragOver(false)}
onDrop={handleDrop}
className={`flex flex-col items-center gap-2 border-2 border-dashed rounded-lg cursor-pointer transition-colors ${
compact ? 'px-4 py-4' : 'px-6 py-6'
} ${
dragOver ? 'border-brand-primary bg-brand-primary/5' : 'border-border hover:border-brand-primary/30'
} ${
(uploading || disabled) ? 'pointer-events-none opacity-60' : ''
}`}
>
<input
ref={inputRef}
type="file"
accept={accept}
multiple={multiple}
onChange={handleChange}
className="absolute w-0 h-0 opacity-0 pointer-events-none"
tabIndex={-1}
/>
{uploading ? (
<>
<div className="w-full max-w-xs bg-surface-tertiary rounded-full h-2 overflow-hidden">
<div
className="bg-brand-primary h-full rounded-full transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
<span className="text-xs text-text-secondary">
{t('artefacts.uploading')} {progress}%
</span>
</>
) : (
<>
<Upload className={compact ? 'w-5 h-5 text-text-tertiary' : 'w-7 h-7 text-text-tertiary'} />
{label && <span className="text-sm font-medium text-text-primary">{label}</span>}
{hint && <span className="text-xs text-text-tertiary">{hint}</span>}
</>
)}
</div>
)
}
+38
View File
@@ -0,0 +1,38 @@
import { createContext, useContext, useState, useEffect } from 'react'
const ThemeContext = createContext()
export function ThemeProvider({ children }) {
const [darkMode, setDarkMode] = useState(() => {
// Check localStorage or system preference
const stored = localStorage.getItem('darkMode')
if (stored !== null) return stored === 'true'
return window.matchMedia('(prefers-color-scheme: dark)').matches
})
useEffect(() => {
// Apply dark mode class to document
if (darkMode) {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
localStorage.setItem('darkMode', String(darkMode))
}, [darkMode])
const toggleDarkMode = () => setDarkMode(prev => !prev)
return (
<ThemeContext.Provider value={{ darkMode, toggleDarkMode }}>
{children}
</ThemeContext.Provider>
)
}
export function useTheme() {
const context = useContext(ThemeContext)
if (!context) {
throw new Error('useTheme must be used within ThemeProvider')
}
return context
}
+59
View File
@@ -0,0 +1,59 @@
import { useEffect } from 'react'
export function useKeyboardShortcuts(shortcuts = {}) {
useEffect(() => {
const handleKeyDown = (e) => {
// Ignore if user is typing in an input/textarea
if (
e.target.tagName === 'INPUT' ||
e.target.tagName === 'TEXTAREA' ||
e.target.isContentEditable
) {
return
}
// Check for modifier + key
const key = e.key.toLowerCase()
const ctrl = e.ctrlKey || e.metaKey
const shift = e.shiftKey
for (const [combination, callback] of Object.entries(shortcuts)) {
const parts = combination.toLowerCase().split('+')
const needsCtrl = parts.includes('ctrl') || parts.includes('cmd')
const needsShift = parts.includes('shift')
const keyPart = parts.find(p => !['ctrl', 'cmd', 'shift'].includes(p))
if (
key === keyPart &&
needsCtrl === ctrl &&
needsShift === shift
) {
e.preventDefault()
callback()
return
}
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [shortcuts])
}
// Default keyboard shortcuts
export const DEFAULT_SHORTCUTS = {
'?': () => {
// Show help (could implement a shortcuts modal)
console.log('Keyboard shortcuts: ? to show help')
},
'g d': () => window.location.hash = '#/dashboard',
'g p': () => window.location.hash = '#/posts',
'g c': () => window.location.hash = '#/campaigns',
'g t': () => window.location.hash = '#/tasks',
'g a': () => window.location.hash = '#/artefacts',
'/': () => {
// Focus search - implement based on your search component
const searchInput = document.querySelector('[data-search-input]')
if (searchInput) searchInput.focus()
},
}
+2
View File
@@ -1,4 +1,5 @@
import { createContext, useContext, useState, useEffect } from 'react'
import { api } from '../utils/api'
import en from './en.json'
import ar from './ar.json'
@@ -33,6 +34,7 @@ export function LanguageProvider({ children }) {
if (newLang !== 'en' && newLang !== 'ar') return
setLangState(newLang)
localStorage.setItem('digitalhub-lang', newLang)
api.patch('/api/users/me/language', { language: newLang }).catch(() => {})
}
const setCurrency = (code) => {
+745 -4
View File
@@ -1,6 +1,6 @@
{
"app.name": "المركز الرقمي",
"app.subtitle": "المنصة",
"app.name": "رواج",
"app.subtitle": "مركز التسويق",
"nav.dashboard": "لوحة التحكم",
"nav.campaigns": "الحملات",
"nav.finance": "المالية والعائد",
@@ -30,6 +30,8 @@
"common.noResults": "لا توجد نتائج",
"common.loading": "جاري التحميل...",
"common.unassigned": "غير مُسند",
"common.close": "إغلاق",
"common.created": "تاريخ الإنشاء",
"common.required": "مطلوب",
"common.saveFailed": "فشل الحفظ. حاول مجدداً.",
"common.updateFailed": "فشل التحديث. حاول مجدداً.",
@@ -77,6 +79,29 @@
"posts.saveChanges": "حفظ التغييرات",
"posts.postTitle": "العنوان",
"posts.description": "الوصف",
"post.caption": "التعليق",
"post.captionPlaceholder": "اكتب تعليق المنشور...",
"post.copy": "النص (داخل التصميم)",
"post.designs": "التصاميم",
"post.video": "الفيديو",
"post.formatChecklist": "قائمة الأحجام المطلوبة",
"post.formatsNeeded": "الأحجام المطلوبة بناءً على المنصات المختارة",
"post.selectPlatforms": "اختر المنصات لعرض الأحجام المطلوبة",
"post.readiness": "الجاهزية",
"post.allPiecesReady": "جميع العناصر جاهزة — بانتظار الاعتماد",
"post.waitingOn": "بانتظار",
"post.signOff": "اعتماد وجدولة",
"post.signOffConfirm": "هل تريد اعتماد هذا المنشور وتجهيزه للجدولة؟",
"common.confirm": "تأكيد",
"post.linkExisting": "ربط موجود",
"post.createNew": "إنشاء جديد",
"post.addDesign": "إضافة تصميم",
"post.addVideo": "إضافة فيديو",
"post.linkTranslation": "ربط ترجمة",
"post.selectLanguage": "اللغة...",
"post.noCopyLinked": "لا يوجد نص مرتبط بعد",
"post.noDesignsLinked": "لا توجد تصاميم مرتبطة بعد",
"post.noVideoLinked": "لا يوجد فيديو مرتبط بعد",
"posts.brand": "العلامة التجارية",
"posts.platforms": "المنصات",
"posts.status": "الحالة",
@@ -130,6 +155,7 @@
"posts.status.approved": "مُعتمد",
"posts.status.scheduled": "مجدول",
"posts.status.published": "منشور",
"posts.status.rejected": "مرفوض",
"tasks.title": "المهام",
"tasks.newTask": "مهمة جديدة",
"tasks.editTask": "تعديل المهمة",
@@ -209,6 +235,7 @@
"team.title": "الفريق",
"team.members": "أعضاء الفريق",
"team.addMember": "إضافة عضو",
"team.memberAdded": "تمت إضافة العضو بنجاح",
"team.newMember": "عضو جديد",
"team.editMember": "تعديل العضو",
"team.myProfile": "ملفي الشخصي",
@@ -231,6 +258,12 @@
"team.membersPlural": "أعضاء فريق",
"team.fullName": "الاسم الكامل",
"team.defaultPassword": "افتراضياً: changeme123",
"team.confirmPassword": "تأكيد كلمة المرور",
"team.passwordsDoNotMatch": "كلمتا المرور غير متطابقتين",
"team.adminActions": "إجراءات المسؤول",
"team.newPassword": "كلمة مرور جديدة (٦ أحرف على الأقل)",
"team.changePassword": "تغيير كلمة المرور",
"team.passwordChanged": "تم تغيير كلمة المرور بنجاح",
"team.optional": "(اختياري)",
"team.fixedRole": "دور ثابت للمديرين",
"team.remove": "إزالة",
@@ -310,6 +343,25 @@
"login.subtitle": "سجل دخولك للمتابعة",
"login.forgotPassword": "نسيت كلمة المرور؟",
"login.defaultCreds": "بيانات الدخول الافتراضية:",
"forgotPassword.title": "نسيت كلمة المرور",
"forgotPassword.subtitle": "أدخل بريدك الإلكتروني لتلقي رابط إعادة التعيين",
"forgotPassword.emailPlaceholder": "بريدك@email.com",
"forgotPassword.submit": "إرسال رابط إعادة التعيين",
"forgotPassword.sending": "جارٍ الإرسال...",
"forgotPassword.success": "إذا كان هناك حساب بهذا البريد الإلكتروني، فقد تم إرسال رابط إعادة التعيين.",
"forgotPassword.backToLogin": "العودة لتسجيل الدخول",
"forgotPassword.error": "حدث خطأ. يرجى المحاولة مرة أخرى.",
"resetPassword.title": "إعادة تعيين كلمة المرور",
"resetPassword.subtitle": "أدخل كلمة المرور الجديدة",
"resetPassword.newPassword": "كلمة المرور الجديدة",
"resetPassword.confirmPassword": "تأكيد كلمة المرور",
"resetPassword.submit": "إعادة تعيين كلمة المرور",
"resetPassword.resetting": "جارٍ إعادة التعيين...",
"resetPassword.success": "تم إعادة تعيين كلمة المرور. يمكنك الآن تسجيل الدخول.",
"resetPassword.invalidToken": "رابط إعادة التعيين غير صالح أو منتهي الصلاحية.",
"resetPassword.goToLogin": "الذهاب لتسجيل الدخول",
"resetPassword.passwordMismatch": "كلمتا المرور غير متطابقتين",
"resetPassword.error": "فشل إعادة تعيين كلمة المرور. ربما انتهت صلاحية الرابط.",
"comments.title": "النقاش",
"comments.noComments": "لا توجد تعليقات بعد. ابدأ المحادثة.",
"comments.placeholder": "اكتب تعليقاً...",
@@ -325,13 +377,24 @@
"timeline.day": "يوم",
"timeline.week": "أسبوع",
"timeline.today": "اليوم",
"timeline.startDate": "تاريخ البدء",
"timeline.startDate": "البداية",
"timeline.endDate": "النهاية",
"timeline.assignee": "المُكلّف",
"timeline.status": "الحالة",
"timeline.dragToMove": "اسحب للنقل",
"timeline.dragToResize": "اسحب الحواف لتغيير الحجم",
"timeline.noItems": "لا توجد عناصر للعرض",
"timeline.addItems": "أضف عناصر بتواريخ لعرض الجدول الزمني",
"timeline.tracks": "المسارات",
"timeline.timeline": "الجدول الزمني",
"timeline.item": "العنصر",
"timeline.month": "شهر",
"timeline.compact": "مضغوط",
"timeline.expand": "موسّع",
"timeline.resetColor": "إعادة إلى الافتراضي",
"timeline.changeColor": "تغيير اللون",
"timeline.compactBars": "أشرطة مضغوطة",
"timeline.expandedBars": "أشرطة موسّعة",
"posts.details": "التفاصيل",
"posts.platformsLinks": "المنصات والروابط",
"posts.discussion": "النقاش",
@@ -357,6 +420,16 @@
"campaigns.editCampaign": "تعديل الحملة",
"campaigns.deleteCampaign": "حذف الحملة؟",
"campaigns.deleteConfirm": "هل أنت متأكد من حذف هذه الحملة؟ سيتم حذف جميع البيانات المرتبطة. لا يمكن التراجع.",
"campaigns.tracks": "المسارات",
"campaigns.addTrack": "إضافة مسار",
"campaigns.noTracks": "لا توجد مسارات بعد. أضف مسارات عضوية أو مدفوعة أو SEO لتنظيم هذه الحملة.",
"campaigns.postsLinked": "منشورات مرتبطة",
"campaigns.team": "الفريق",
"campaigns.assignMembers": "تعيين أعضاء",
"campaigns.linkedPosts": "المنشورات المرتبطة",
"campaigns.notFound": "الحملة غير موجودة.",
"common.goBack": "رجوع",
"finance.allocated": "مخصص",
"tracks.details": "التفاصيل",
"tracks.metrics": "المقاييس",
"tracks.trackName": "اسم المسار",
@@ -464,6 +537,59 @@
"budgets.dateExpensed": "التاريخ",
"dashboard.expenses": "المصروفات",
"finance.expenses": "إجمالي المصروفات",
"finance.totalReceived": "إجمالي المستلم",
"finance.totalSpent": "إجمالي المنفق",
"finance.remaining": "المتبقي",
"finance.revenue": "الإيرادات",
"finance.globalROI": "العائد الإجمالي",
"finance.budgetAllocation": "توزيع الميزانية",
"finance.manageBudgets": "إدارة الميزانيات",
"finance.campaigns": "الحملات",
"finance.projects": "المشاريع",
"finance.unallocated": "غير مخصص",
"finance.budgetUtilization": "استخدام الميزانية",
"finance.globalPerformance": "الأداء العام",
"finance.impressions": "مرات الظهور",
"finance.clicks": "النقرات",
"finance.conversions": "التحويلات",
"finance.campaignBreakdown": "توزيع الحملات",
"finance.allocatedFunds": "الأموال المخصصة",
"finance.requestBudget": "طلب ميزانية",
"finance.budgetRequests": "طلبات الميزانية",
"finance.pendingApproval": "بانتظار موافقة المدير التنفيذي",
"finance.justification": "المبرر",
"finance.earmarkFor": "تخصيص لـ",
"finance.submitRequest": "إرسال الطلب",
"finance.cancelRequest": "إلغاء الطلب",
"finance.approved": "تمت الموافقة",
"finance.rejected": "مرفوض",
"finance.cancelled": "ملغي",
"finance.pending": "قيد الانتظار",
"finance.ceoNote": "ملاحظة المدير",
"finance.requestPending": "طلب(ات) ميزانية بانتظار الموافقة",
"finance.insufficientBudget": "ميزانية غير كافية",
"finance.availableBudget": "المتاح",
"finance.requestMore": "طلب المزيد من الأموال",
"finance.noCeoEmail": "لم يتم تكوين بريد المدير التنفيذي. اذهب إلى الإعدادات.",
"finance.amount": "المبلغ",
"finance.justificationPlaceholder": "لماذا هذه الميزانية مطلوبة؟",
"finance.optional": "اختياري",
"settings.budgetApproval": "موافقة الميزانية",
"settings.ceoEmail": "بريد المدير التنفيذي / المعتمد",
"settings.ceoEmailHint": "عنوان البريد الإلكتروني الذي يستلم طلبات الموافقة على الميزانية",
"budgetApproval.title": "موافقة الميزانية",
"budgetApproval.amount": "المبلغ المطلوب",
"budgetApproval.requestedBy": "مقدم الطلب",
"budgetApproval.justification": "المبرر",
"budgetApproval.earmarkedFor": "مخصص لـ",
"budgetApproval.approve": "موافقة",
"budgetApproval.reject": "رفض",
"budgetApproval.addNote": "أضف ملاحظة (اختياري)",
"budgetApproval.approved": "تمت الموافقة على هذا الطلب.",
"budgetApproval.rejected": "تم رفض هذا الطلب.",
"budgetApproval.expired": "انتهت صلاحية هذا الطلب.",
"budgetApproval.alreadyHandled": "تمت معالجة هذا الطلب بالفعل.",
"finance.ofBudget": "من الميزانية",
"settings.uploads": "الرفع",
"settings.maxFileSize": "الحد الأقصى لحجم الملف",
"settings.maxFileSizeHint": "الحد الأقصى المسموح لحجم المرفقات (١-٥٠٠ ميجابايت)",
@@ -471,11 +597,21 @@
"settings.saved": "تم حفظ الإعدادات!",
"tasks.maxFileSize": "الحد الأقصى: {size} ميجابايت",
"tasks.fileTooLarge": "الملف \"{name}\" كبير جداً ({size} ميجابايت). الحد المسموح: {max} ميجابايت.",
"issues.details": "التفاصيل",
"issues.actions": "الإجراءات",
"issues.updates": "التحديثات",
"issues.board": "لوحة",
"issues.list": "قائمة",
"issues.statusUpdated": "تم تحديث حالة المشكلة!",
"issues.dropHere": "أفلت هنا",
"issues.noIssuesInColumn": "لا توجد مشاكل",
"artefacts.details": "التفاصيل",
"artefacts.review": "المراجعة",
"artefacts.selectVersionFirst": "اختر إصداراً لعرض التعليقات.",
"artefacts.pendingReviewInfo": "هذا العنصر قيد المراجعة حالياً.",
"artefacts.noReviewInfo": "لا توجد معلومات مراجعة متاحة.",
"artefacts.rejectedMustCreateNewVersion": "تم رفض هذا العنصر. أنشئ إصداراً جديداً لمعالجة الملاحظات.",
"artefacts.revisionEditCurrentVersion": "طُلب تعديل — عدّل الإصدار الحالي وأعد إرساله للمراجعة.",
"artefacts.grid": "شبكة",
"artefacts.list": "قائمة",
"artefacts.allCreators": "جميع المنشئين",
@@ -486,5 +622,610 @@
"artefacts.sortRecentlyUpdated": "آخر تحديث",
"artefacts.sortNewest": "الأحدث أولاً",
"artefacts.sortOldest": "الأقدم أولاً",
"artefacts.sortTitleAZ": "العنوان أ-ي"
"artefacts.sortTitleAZ": "العنوان أ-ي",
"login.initialSetup": "الإعداد الأولي",
"login.initialSetupDesc": "أنشئ حساب المسؤول للبدء",
"login.createAccount": "إنشاء حساب",
"login.signIn": "تسجيل الدخول",
"login.fullName": "الاسم الكامل",
"login.fullNamePlaceholder": "اسمك",
"login.email": "البريد الإلكتروني",
"login.password": "كلمة المرور",
"login.passwordPlaceholder": "اختر كلمة مرور قوية",
"login.confirmPassword": "تأكيد كلمة المرور",
"login.confirmPasswordPlaceholder": "أعد إدخال كلمة المرور",
"login.passwordMismatch": "كلمات المرور غير متطابقة",
"login.setupFailed": "فشل الإعداد",
"login.accountCreated": "تم إنشاء الحساب. يمكنك الآن تسجيل الدخول.",
"login.welcomeBack": "مرحباً بعودتك",
"login.signInDesc": "سجل الدخول للمتابعة",
"login.invalidCredentials": "البريد الإلكتروني أو كلمة المرور غير صحيحة",
"login.creatingAccount": "جاري إنشاء الحساب...",
"users.title": "إدارة المستخدمين",
"users.addUser": "إضافة مستخدم",
"users.addNewUser": "إضافة مستخدم جديد",
"users.editUser": "تعديل المستخدم",
"users.deleteUser": "حذف المستخدم",
"users.deleteUserConfirmTitle": "حذف المستخدم؟",
"users.deleteConfirm": "هل أنت متأكد من حذف هذا المستخدم؟ لا يمكن التراجع.",
"users.userSingular": "مستخدم",
"users.usersPlural": "مستخدمين",
"users.noUsers": "لم يتم العثور على مستخدمين",
"users.you": "أنت",
"users.name": "الاسم",
"users.fullNamePlaceholder": "الاسم الكامل",
"users.email": "البريد الإلكتروني",
"users.password": "كلمة المرور",
"users.confirmPassword": "تأكيد كلمة المرور",
"users.role": "الدور",
"users.created": "تاريخ الإنشاء",
"users.actions": "الإجراءات",
"users.leaveBlankToKeep": "اتركه فارغاً للإبقاء على الحالي",
"users.saveChanges": "حفظ التغييرات",
"users.passwordMismatch": "كلمات المرور غير متطابقة",
"users.passwordRequired": "كلمة المرور مطلوبة للمستخدمين الجدد",
"users.saveFailed": "فشل في حفظ المستخدم",
"users.preferredLanguage": "اللغة المفضلة",
"users.deleteFailed": "فشل في حذف المستخدم",
"settings.saveFailed": "فشل في الحفظ",
"settings.restartTutorialFailed": "فشل في إعادة تشغيل البرنامج التعليمي",
"artefacts.title": "القطع الإبداعية",
"artefacts.subtitle": "سير عمل الموافقة على المحتوى مع إدارة الإصدارات",
"artefacts.newArtefact": "محتوى جديد",
"artefacts.createArtefact": "إنشاء محتوى",
"artefacts.searchArtefacts": "البحث في المحتوى...",
"artefacts.allBrands": "جميع العلامات التجارية",
"artefacts.allStatuses": "جميع الحالات",
"artefacts.allTypes": "جميع الأنواع",
"artefacts.noArtefacts": "لم يتم العثور على محتوى",
"artefacts.titleLabel": "العنوان",
"artefacts.titlePlaceholder": "عنوان المحتوى",
"artefacts.type": "النوع",
"artefacts.status": "الحالة",
"artefacts.brand": "العلامة التجارية",
"artefacts.creator": "المنشئ",
"artefacts.approvers": "المعتمدون",
"artefacts.version": "الإصدار",
"artefacts.updated": "آخر تحديث",
"artefacts.description": "الوصف",
"artefacts.descriptionPlaceholder": "وصف مختصر",
"artefacts.titleRequired": "العنوان مطلوب",
"artefacts.created": "تم إنشاء المحتوى",
"artefacts.createFailed": "فشل في إنشاء المحتوى",
"artefacts.deleted": "تم حذف المحتوى",
"artefacts.deleteFailed": "فشل في حذف المحتوى",
"artefacts.loadFailed": "فشل في تحميل المحتوى",
"artefacts.creating": "جاري الإنشاء...",
"artefacts.status.draft": "مسودة",
"artefacts.status.pendingReview": "بانتظار المراجعة",
"artefacts.status.approved": "مُعتمد",
"artefacts.status.rejected": "مرفوض",
"artefacts.status.revisionRequested": "مطلوب تعديل",
"review.contentReview": "مراجعة المحتوى",
"review.yourReview": "مراجعتك",
"review.approve": "موافقة",
"review.reject": "رفض",
"review.requestRevision": "طلب تعديل",
"review.reviewer": "المراجع",
"review.selectYourName": "اختر اسمك...",
"review.enterYourName": "أدخل اسمك",
"review.feedbackOptional": "ملاحظات (اختياري)",
"review.feedbackPlaceholder": "شارك أفكارك أو اقتراحاتك أو التغييرات المطلوبة...",
"review.thankYou": "شكراً لك!",
"review.notAvailable": "المراجعة غير متاحة",
"review.alreadyReviewed": "تمت مراجعة هذا المحتوى بالفعل.",
"review.statusLabel": "الحالة",
"review.reviewedBy": "تمت المراجعة بواسطة",
"review.poweredBy": "مدعوم بواسطة Rawaj",
"review.loadFailed": "فشل في تحميل المحتوى",
"review.actionFailed": "فشل الإجراء",
"review.actionCompleted": "تم الإجراء بنجاح",
"review.enterName": "يرجى اختيار أو إدخال اسمك",
"review.confirmApprove": "هل تريد الموافقة على هذا المحتوى؟",
"review.confirmReject": "هل تريد رفض هذا المحتوى؟",
"review.feedbackRequired": "يرجى تقديم ملاحظات لطلب التعديل",
"review.contentLanguages": "لغات المحتوى",
"review.redirectReview": "لست المراجع المناسب؟ أعد التوجيه لشخص آخر",
"review.redirectDesc": "اختر عضو فريق لإعادة توجيه المراجعة إليه:",
"review.selectNewReviewer": "اختر مراجعاً جديداً...",
"review.redirect": "إعادة توجيه",
"review.redirected": "تم إعادة توجيه المراجعة بنجاح",
"review.content": "المحتوى",
"review.designFiles": "ملفات التصميم",
"review.videos": "الفيديوهات",
"review.googleDriveVideo": "فيديو Google Drive",
"review.attachments": "المرفقات",
"review.previousComments": "التعليقات السابقة",
"review.version": "الإصدار",
"common.failedToSave": "فشل في الحفظ",
"common.copiedToClipboard": "تم النسخ إلى الحافظة!",
"team.failedToSaveTeam": "فشل في حفظ الفريق",
"posts.canOnlyEditOwn": "يمكنك فقط تعديل منشوراتك الخاصة",
"assets.uploadFailed": "فشل في الرفع",
"assets.failedToDelete": "فشل في حذف الملف",
"issues.failedToAddComment": "فشل في إضافة التعليق",
"issues.failedToUploadFile": "فشل في رفع الملف",
"issues.failedToSubmit": "فشل في إرسال المشكلة. حاول مجدداً.",
"issues.failedToUpdateStatus": "فشل في تحديث الحالة",
"issues.failedToResolve": "فشل في حل المشكلة",
"issues.failedToDecline": "فشل في رفض المشكلة",
"issues.failedToUpdateAssignment": "فشل في تحديث التعيين",
"issues.failedToSaveNotes": "فشل في حفظ الملاحظات",
"issues.failedToAddUpdate": "فشل في إضافة التحديث",
"issues.failedToDeleteAttachment": "فشل في حذف المرفق",
"issues.trackingLinkCopied": "تم نسخ رابط التتبع!",
"issues.deleteAttachment": "حذف المرفق؟",
"issues.deleteAttachmentDesc": "لا يمكن التراجع عن هذا الإجراء.",
"artefacts.editLanguage": "تعديل اللغة",
"artefacts.linkedPost": "المنشور المرتبط",
"artefacts.post": "منشور",
"artefacts.deleteLanguage": "حذف هذه اللغة؟",
"artefacts.deleteLanguageDesc": "سيتم إزالة المحتوى لهذه اللغة.",
"artefacts.deleteAttachment": "حذف هذا المرفق؟",
"artefacts.deleteAttachmentDesc": "لا يمكن التراجع عن هذا الإجراء.",
"artefacts.deleteArtefact": "حذف هذا المحتوى؟",
"artefacts.deleteArtefactDesc": "لا يمكن التراجع عن هذا الإجراء.",
"review.confirmApproveDesc": "هل أنت متأكد من الموافقة على هذا المحتوى؟",
"review.confirmRejectDesc": "هل أنت متأكد من رفض هذا المحتوى؟",
"common.selected": "محدد",
"common.deleteSelected": "حذف المحدد",
"common.clearSelection": "إلغاء التحديد",
"common.bulkDeleteConfirm": "حذف {count} عناصر؟",
"common.bulkDeleteDesc": "لا يمكن التراجع عن هذا الإجراء.",
"common.selectAll": "تحديد الكل",
"issues.team": "الفريق",
"issues.allTeams": "جميع الفرق",
"issues.copyPublicLink": "نسخ الرابط العام",
"issues.linkCopied": "تم نسخ الرابط!",
"issues.selectTeam": "اختر فريقاً",
"issues.publicSubmitTeam": "أي فريق يجب أن يتولى مشكلتك؟",
"team.copyIssueLink": "نسخ رابط المشكلة",
"team.copyGenericIssueLink": "نسخ رابط المشاكل العام",
"team.permissionLevel": "مستوى الصلاحية",
"team.role": "الدور",
"team.selectRole": "اختر دوراً...",
"common.team": "الفريق",
"common.noTeam": "بدون فريق",
"common.none": "بدون",
"common.untitled": "بدون عنوان",
"common.success": "تم بنجاح",
"common.error": "حدث خطأ",
"settings.roles": "الأدوار",
"settings.rolesDesc": "حدد أدوار العمل مثل مصمم، استراتيجي، إلخ. يتم تعيينها لأعضاء الفريق بشكل منفصل عن مستويات الصلاحية.",
"settings.addRole": "إضافة دور",
"settings.roleName": "اسم الدور",
"settings.roleColor": "اللون",
"settings.deleteRoleConfirm": "هل أنت متأكد من حذف هذا الدور؟",
"settings.noRoles": "لم يتم تحديد أدوار بعد. أضف أول دور.",
"header.dashboard": "لوحة التحكم",
"header.posts": "إنتاج المحتوى",
"header.assets": "الأصول",
"header.campaigns": "الحملات",
"header.finance": "المالية",
"header.projects": "المشاريع",
"header.tasks": "مهامي",
"header.team": "الفريق",
"header.calendar": "تقويم المنشورات",
"header.artefacts": "المخرجات",
"header.brands": "العلامات التجارية",
"header.budgets": "الميزانيات",
"header.issues": "البلاغات",
"header.settings": "الإعدادات",
"header.translations": "الترجمات",
"header.copy": "النسخ",
"header.postDetails": "تفاصيل المنشور",
"calendar.unscheduledPosts": "منشورات غير مجدولة",
"calendar.statusLegend": "دليل الحالات",
"header.users": "إدارة المستخدمين",
"header.projectDetails": "تفاصيل المشروع",
"header.campaignDetails": "تفاصيل الحملة",
"header.page": "الصفحة",
"header.superadmin": "مسؤول عام",
"header.manager": "مدير",
"header.contributor": "مساهم",
"header.passwordMismatch": "كلمتا المرور الجديدتان غير متطابقتين",
"header.passwordMinLength": "كلمة المرور الجديدة يجب أن تكون ٦ أحرف على الأقل",
"header.passwordUpdateSuccess": "تم تحديث كلمة المرور بنجاح",
"header.passwordUpdateFailed": "فشل في تغيير كلمة المرور",
"header.userManagement": "إدارة المستخدمين",
"header.changePassword": "تغيير كلمة المرور",
"header.signOut": "تسجيل الخروج",
"header.currentPassword": "كلمة المرور الحالية",
"header.newPassword": "كلمة المرور الجديدة",
"header.confirmNewPassword": "تأكيد كلمة المرور الجديدة",
"header.updatePassword": "تحديث كلمة المرور",
"header.saving": "جاري الحفظ...",
"issues.title": "المشاكل",
"issues.subtitle": "تتبع وإدارة البلاغات المقدمة",
"issues.searchPlaceholder": "البحث في المشاكل...",
"issues.allStatuses": "جميع الحالات",
"issues.allCategories": "جميع الفئات",
"issues.allTypes": "جميع الأنواع",
"issues.allBrands": "جميع العلامات",
"issues.allPriorities": "جميع الأولويات",
"issues.clearAll": "مسح الكل",
"issues.noIssuesFound": "لم يتم العثور على مشاكل",
"issues.tryAdjustingFilters": "جرّب تعديل الفلاتر",
"issues.noIssuesSubmitted": "لم يتم تقديم أي مشاكل بعد",
"issues.issuesDeleted": "تم حذف المشاكل",
"issues.tableTitle": "العنوان",
"issues.tableSubmitter": "مُقدّم البلاغ",
"issues.tableBrand": "العلامة التجارية",
"issues.tableCategory": "الفئة",
"issues.tableType": "النوع",
"issues.tablePriority": "الأولوية",
"issues.tableStatus": "الحالة",
"issues.tableAssignedTo": "مُسند إلى",
"issues.tableCreated": "تاريخ الإنشاء",
"issues.typeRequest": "طلب",
"issues.typeCorrection": "تصحيح",
"issues.typeComplaint": "شكوى",
"issues.typeSuggestion": "اقتراح",
"issues.typeOther": "أخرى",
"issues.priorityLow": "منخفض",
"issues.priorityMedium": "متوسط",
"issues.priorityHigh": "عالي",
"issues.priorityUrgent": "عاجل",
"issues.submitterInfo": "معلومات مُقدّم البلاغ",
"issues.nameLabel": "الاسم:",
"issues.emailLabel": "البريد الإلكتروني:",
"issues.phoneLabel": "الهاتف:",
"issues.submittedLabel": "تاريخ التقديم:",
"issues.description": "الوصف",
"issues.noDescription": "لا يوجد وصف",
"issues.assignedTo": "مُسند إلى",
"issues.unassigned": "غير مُسند",
"issues.brandLabel": "العلامة التجارية",
"issues.noBrand": "بدون علامة تجارية",
"issues.internalNotes": "ملاحظات داخلية (للموظفين فقط)",
"issues.internalNotesPlaceholder": "ملاحظات داخلية غير مرئية لمقدم البلاغ...",
"issues.resolutionSummary": "ملخص الحل (عام)",
"issues.resolvedOn": "تم الحل في",
"issues.acknowledge": "إقرار",
"issues.startWork": "بدء العمل",
"issues.resolve": "حل",
"issues.decline": "رفض",
"issues.publicTrackingLink": "رابط التتبع العام",
"issues.updatesTimeline": "الجدول الزمني للتحديثات",
"issues.addUpdatePlaceholder": "أضف تحديثاً...",
"issues.makePublic": "جعله عاماً (مرئي لمقدم البلاغ)",
"issues.addUpdate": "إضافة تحديث",
"issues.noUpdates": "لا توجد تحديثات بعد",
"issues.attachments": "المرفقات",
"issues.clickToUpload": "انقر لرفع ملف",
"issues.uploading": "جاري الرفع...",
"issues.download": "تحميل",
"issues.noAttachments": "لا توجد مرفقات",
"issues.resolveIssue": "حل المشكلة",
"issues.resolveSummaryHint": "قدّم ملخصاً للحل سيكون مرئياً لمقدم البلاغ.",
"issues.resolutionPlaceholder": "اشرح كيف تم حل هذه المشكلة...",
"issues.markAsResolved": "تحديد كمحلولة",
"issues.resolving": "جاري الحل...",
"issues.declineIssue": "رفض المشكلة",
"issues.declineReasonHint": "قدّم سبباً لرفض هذه المشكلة. سيكون مرئياً لمقدم البلاغ.",
"issues.declinePlaceholder": "اشرح لماذا لا يمكن معالجة هذه المشكلة...",
"issues.declining": "جاري الرفض...",
"artefacts.descriptionLabel": "الوصف",
"artefacts.descriptionFieldPlaceholder": "أضف وصفاً...",
"artefacts.approversLabel": "المعتمدون",
"artefacts.reviewer": "المراجع",
"artefacts.selectReviewer": "اختر مراجعاً...",
"artefacts.versions": "الإصدارات",
"artefacts.newVersion": "إصدار جديد",
"artefacts.languages": "اللغات",
"artefacts.addLanguage": "إضافة لغة",
"artefacts.noLanguages": "لم تتم إضافة لغات بعد",
"artefacts.imagesLabel": "الصور",
"artefacts.uploadImage": "رفع صورة",
"artefacts.uploading": "جاري الرفع...",
"artefacts.dropOrClickImage": "اسحب الصور هنا أو انقر للرفع",
"artefacts.imageFormats": "PNG, JPG, WebP",
"artefacts.noImages": "لم يتم رفع صور بعد",
"artefacts.videosLabel": "الفيديوهات",
"artefacts.addVideoBtn": "إضافة فيديو",
"artefacts.noVideos": "لم تتم إضافة فيديوهات بعد",
"artefacts.comments": "التعليقات",
"artefacts.sendComment": "إرسال",
"artefacts.addCommentPlaceholder": "أضف تعليقاً...",
"artefacts.submitForReview": "إرسال للمراجعة",
"artefacts.submitting": "جاري الإرسال...",
"artefacts.reviewLinkTitle": "رابط المراجعة (ينتهي خلال ٧ أيام)",
"artefacts.feedbackTitle": "الملاحظات",
"artefacts.approvedByLabel": "تمت الموافقة بواسطة",
"artefacts.saveDraft": "حفظ",
"artefacts.savingDraft": "جاري الحفظ...",
"artefacts.versionNotes": "ملاحظات الإصدار",
"artefacts.whatChanged": "ما الذي تغير في هذا الإصدار؟",
"artefacts.copyLanguages": "نسخ اللغات من الإصدار السابق",
"artefacts.createVersion": "إنشاء إصدار",
"artefacts.creatingVersion": "جاري الإنشاء...",
"artefacts.languageLabel": "اللغة",
"artefacts.contentLabel": "المحتوى",
"artefacts.selectLanguage": "اختر لغة...",
"artefacts.enterContent": "أدخل المحتوى بهذه اللغة...",
"artefacts.addVideoTitle": "إضافة فيديو",
"artefacts.uploadFile": "رفع ملف",
"artefacts.chooseVideoFile": "اختر ملف فيديو",
"artefacts.videoFormats": "MP4، MOV، AVI، إلخ.",
"artefacts.dropOrClickVideo": "اسحب فيديو هنا أو انقر للتصفح",
"artefacts.googleDriveLink": "رابط Google Drive",
"artefacts.googleDriveUrl": "رابط Google Drive",
"artefacts.driveUrlPlaceholder": "https://drive.google.com/file/d/...",
"artefacts.publiclyAccessible": "الصق رابط مشاركة Google Drive. تأكد أن الملف متاح للعامة.",
"artefacts.addLink": "إضافة رابط",
"artefacts.adding": "جاري الإضافة...",
"artefacts.googleDriveVideo": "فيديو Google Drive",
"artefacts.deleteArtefactTooltip": "حذف المحتوى",
"artefacts.saveDraftTooltip": "حفظ المسودة",
"artefacts.createNewVersion": "إنشاء إصدار جديد",
"artefacts.failedLoadVersions": "فشل في تحميل الإصدارات",
"artefacts.failedLoadVersionData": "فشل في تحميل بيانات الإصدار",
"artefacts.versionCreated": "تم إنشاء الإصدار الجديد",
"artefacts.failedCreateVersion": "فشل في إنشاء الإصدار",
"artefacts.languageAdded": "تمت إضافة اللغة",
"artefacts.allFieldsRequired": "جميع الحقول مطلوبة",
"artefacts.failedAddLanguage": "فشل في إضافة اللغة",
"artefacts.languageDeleted": "تم حذف اللغة",
"artefacts.failedDeleteLanguage": "فشل في حذف اللغة",
"artefacts.fileUploaded": "تم رفع الملف",
"artefacts.uploadFailed": "فشل في الرفع",
"artefacts.videoLinkAdded": "تمت إضافة رابط الفيديو",
"artefacts.failedAddVideoLink": "فشل في إضافة رابط الفيديو",
"artefacts.enterDriveUrl": "يرجى إدخال رابط Google Drive",
"artefacts.attachmentDeleted": "تم حذف المرفق",
"artefacts.failedDeleteAttachment": "فشل في حذف المرفق",
"artefacts.submittedForReview": "تم الإرسال للمراجعة!",
"artefacts.failedSubmitReview": "فشل في الإرسال للمراجعة",
"artefacts.linkCopied": "تم نسخ الرابط",
"artefacts.commentAdded": "تمت إضافة التعليق",
"artefacts.failedAddComment": "فشل في إضافة التعليق",
"artefacts.updated": "تم التحديث",
"artefacts.failedUpdate": "فشل في التحديث",
"artefacts.draftSaved": "تم حفظ المسودة",
"artefacts.failedSaveDraft": "فشل في حفظ المسودة",
"artefacts.titleRequired": "العنوان مطلوب",
"artefacts.failedDelete": "فشل في الحذف",
"posts.images": "الصور",
"posts.audio": "الصوت",
"posts.videos": "الفيديوهات",
"posts.otherFiles": "ملفات أخرى",
"posts.addImage": "إضافة صورة",
"posts.addAudio": "إضافة صوت",
"posts.addVideo": "إضافة فيديو",
"posts.dragToUpload": "اسحب الملفات هنا للرفع",
"posts.assignedTo": "مُسند إلى",
"posts.approval": "الموافقة",
"posts.approvers": "المعتمدون",
"posts.selectApprovers": "اختر المعتمدين...",
"posts.scheduling": "الجدولة والتعيين",
"posts.content": "المحتوى",
"posts.reject": "رفض",
"posts.submittedForReview": "تم إرسال المنشور للمراجعة",
"posts.failedSubmitReview": "فشل إرسال المراجعة",
"posts.reviewLinkCopied": "تم نسخ رابط المراجعة!",
"posts.reviewLinkTitle": "رابط المراجعة",
"posts.awaitingReview": "بانتظار المراجعة",
"posts.awaitingReviewDesc": "هذا المنشور بانتظار الموافقة الخارجية.",
"posts.approvedBy": "تمت الموافقة من",
"posts.rejectedBy": "تم الرفض من",
"posts.submitting": "جارٍ الإرسال...",
"posts.submitForReview": "إرسال للمراجعة",
"posts.schedulePost": "جدولة المنشور",
"review.postReview": "مراجعة المنشور",
"review.createdBy": "أنشئ بواسطة",
"review.confirmApprovePost": "الموافقة على هذا المنشور؟",
"review.confirmRejectPost": "رفض هذا المنشور؟",
"review.confirmApprovePostDesc": "هل أنت متأكد من الموافقة على هذا المنشور؟",
"review.confirmRejectPostDesc": "هل أنت متأكد من رفض هذا المنشور؟ يرجى تقديم ملاحظات توضح السبب.",
"review.feedbackRequired": "الملاحظات (مطلوبة)",
"review.feedbackRequiredError": "يرجى تقديم ملاحظات عند الرفض",
"review.loadFailed": "فشل تحميل المراجعة",
"review.errorTitle": "خطأ",
"review.thankYou": "شكراً لمراجعتك!",
"review.approveSuccess": "تمت الموافقة على الترجمة بنجاح!",
"review.rejectSuccess": "تم رفض الترجمة.",
"review.revisionSuccess": "تم طلب التعديل بنجاح.",
"review.nameRequired": "يرجى إدخال اسمك",
"review.yourReview": "مراجعتك",
"review.selectYourName": "اختر اسمك",
"review.selectApprover": "اختر المراجع...",
"review.yourName": "اسمك",
"review.enterYourName": "أدخل اسمك...",
"review.feedback": "الملاحظات",
"review.feedbackPlaceholder": "شارك أفكارك أو ملاحظاتك...",
"review.approve": "موافقة",
"review.approved": "تمت الموافقة",
"review.rejected": "مرفوض",
"review.requestRevision": "طلب تعديل",
"review.reject": "رفض",
"review.statusLabel": "الحالة",
"review.reviewedBy": "تمت المراجعة بواسطة",
"review.confirmReject": "تأكيد الرفض",
"review.rejectConfirmDesc": "هل أنت متأكد من رفض هذه الترجمة؟ تأكد من تقديم الملاحظات.",
"review.feedbackRequiredForReject": "يرجى تقديم ملاحظات قبل الرفض.",
"posts.versions": "الإصدارات",
"posts.newVersion": "إصدار جديد",
"posts.createNewVersion": "إنشاء إصدار جديد",
"posts.createVersion": "إنشاء إصدار",
"posts.creatingVersion": "جارٍ الإنشاء...",
"posts.whatChanged": "ما الذي تغير في هذا الإصدار؟",
"posts.copyLanguages": "نسخ اللغات من الإصدار السابق",
"posts.languages": "اللغات",
"posts.addLanguage": "إضافة لغة",
"posts.selectLanguage": "اختر لغة...",
"posts.enterContent": "أدخل المحتوى بهذه اللغة...",
"posts.noLanguages": "لم تتم إضافة لغات بعد",
"posts.noVersions": "لا توجد إصدارات بعد. أنشئ إصدارًا لبدء إدارة المحتوى متعدد اللغات والوسائط.",
"posts.deleteLanguage": "حذف هذه اللغة؟",
"posts.deleteLanguageConfirm": "سيتم حذف محتوى اللغة من هذا الإصدار.",
"posts.media": "الوسائط",
"posts.noMedia": "لم يتم رفع ملفات وسائط",
"nav.translations": "الترجمات",
"translations.title": "الترجمات",
"translations.subtitle": "إدارة ترجمات المحتوى مع سير عمل الموافقة",
"translations.newTranslation": "ترجمة جديدة",
"translations.createTranslation": "إنشاء ترجمة",
"translations.searchTranslations": "البحث في الترجمات...",
"translations.titleLabel": "العنوان",
"translations.titlePlaceholder": "مثال: ترجمة شعار الحملة",
"translations.sourceLanguage": "لغة المصدر",
"translations.sourceContent": "المحتوى الأصلي",
"translations.sourceContentPlaceholder": "أدخل المحتوى الأصلي المراد ترجمته...",
"translations.description": "الوصف",
"translations.descriptionLabel": "الوصف",
"translations.descriptionPlaceholder": "سياق أو ملاحظات حول هذه الترجمة...",
"translations.brand": "العلامة التجارية",
"translations.creator": "المنشئ",
"translations.approvers": "المراجعون",
"translations.approversLabel": "المراجعون",
"translations.status": "الحالة",
"translations.languagesLabel": "اللغات",
"translations.languagesCount": "لغات",
"translations.grid": "شبكة",
"translations.list": "قائمة",
"translations.allBrands": "جميع العلامات",
"translations.allStatuses": "جميع الحالات",
"translations.allCreators": "جميع المنشئين",
"translations.status.draft": "مسودة",
"translations.status.pendingReview": "بانتظار المراجعة",
"translations.status.approved": "موافق عليه",
"translations.status.rejected": "مرفوض",
"translations.status.revisionRequested": "طلب تعديل",
"translations.sortRecentlyUpdated": "آخر تحديث",
"translations.sortNewest": "الأحدث أولاً",
"translations.sortOldest": "الأقدم أولاً",
"translations.sortTitleAZ": "العنوان أ-ي",
"translations.noTranslations": "لم يتم العثور على ترجمات",
"translations.loadFailed": "فشل تحميل الترجمات",
"translations.titleRequired": "العنوان مطلوب",
"translations.sourceContentRequired": "المحتوى الأصلي مطلوب",
"translations.created": "تم إنشاء الترجمة!",
"translations.createFailed": "فشل إنشاء الترجمة",
"translations.creating": "جارٍ الإنشاء...",
"translations.deleted": "تم حذف الترجمة!",
"translations.deleteFailed": "فشل حذف الترجمة",
"translations.details": "التفاصيل",
"translations.translationTexts": "الترجمات",
"translations.review": "المراجعة",
"translations.draftSaved": "تم حفظ المسودة!",
"translations.failedSaveDraft": "فشل حفظ المسودة",
"translations.saveDraft": "حفظ المسودة",
"translations.saveDraftTooltip": "حفظ التغييرات على العنوان والمحتوى الأصلي",
"translations.savingDraft": "جارٍ الحفظ...",
"translations.updated": "تم التحديث!",
"translations.failedUpdate": "فشل التحديث",
"translations.addTranslation": "إضافة ترجمة",
"translations.translationAdded": "تمت إضافة الترجمة!",
"translations.failedAddTranslation": "فشل إضافة الترجمة",
"translations.translationDeleted": "تم حذف الترجمة!",
"translations.failedDeleteTranslation": "فشل حذف الترجمة",
"translations.noTranslationTexts": "لا توجد ترجمات بعد. أضف واحدة لكل لغة مستهدفة.",
"translations.allFieldsRequired": "اللغة والمحتوى مطلوبان",
"translations.languageLabel": "اللغة",
"translations.selectLanguage": "اختر لغة",
"translations.translatedContent": "المحتوى المترجم",
"translations.enterTranslatedContent": "أدخل المحتوى المترجم...",
"translations.deleteTranslation": "حذف الترجمة",
"translations.deleteTranslationDesc": "سيتم حذف هذه الترجمة وجميع نسخ اللغات نهائيًا.",
"translations.deleteTranslationText": "حذف نص الترجمة",
"translations.deleteTranslationTextDesc": "سيتم حذف ترجمة هذه اللغة.",
"translations.bulkDeleteDesc": "حذف الترجمات المحددة؟",
"translations.submitForReview": "تقديم للمراجعة",
"translations.submitting": "جارٍ التقديم...",
"translations.submittedForReview": "تم التقديم للمراجعة!",
"translations.failedSubmitReview": "فشل التقديم للمراجعة",
"translations.reviewLinkTitle": "رابط المراجعة",
"translations.linkCopied": "تم نسخ الرابط!",
"translations.feedbackTitle": "ملاحظات المراجع",
"translations.approvedByLabel": "وافق عليه",
"translations.pendingReviewInfo": "هذه الترجمة بانتظار المراجعة حاليًا.",
"translations.noReviewInfo": "لا توجد معلومات مراجعة متاحة.",
"translations.failedDelete": "فشل الحذف",
"translations.addOption": "إضافة خيار",
"translations.option": "خيار",
"translations.options": "خيارات",
"translations.optionLabel": "الخيار",
"translations.selected": "محدد",
"translations.selectThis": "اختيار",
"translations.optionSelected": "تم اختيار الخيار!",
"translations.suggestAlternative": "اقتراح بديل",
"translations.suggestForLang": "اقترح ترجمة لـ",
"translations.enterSuggestion": "أدخل الترجمة المقترحة...",
"translations.submitSuggestion": "إرسال الاقتراح",
"translations.suggestionAdded": "تمت إضافة الاقتراح!",
"translations.existing": "موجود",
"translations.copyContent": "نسخ إلى الحافظة",
"translations.copiedToClipboard": "تم النسخ!",
"translations.approvedReadOnly": "هذه الترجمة معتمدة ولا يمكن تعديلها.",
"translations.linkedPost": "المنشور المرتبط",
"translations.createPost": "منشور جديد",
"translations.newPostTitle": "عنوان المنشور...",
"translations.postCreated": "تم إنشاء المنشور!",
"translations.postCreateFailed": "فشل إنشاء المنشور",
"nav.copy": "النسخ",
"postDetail.captionCopy": "نص التسمية التوضيحية",
"postDetail.bodyCopy": "النص الرئيسي",
"postDetail.design": "التصميم",
"postDetail.video": "الفيديو",
"postDetail.readiness": "الجاهزية",
"postDetail.noAssets": "لا توجد أصول مرتبطة بعد",
"postDetail.allPiecesApproved": "جميع العناصر معتمدة",
"postDetail.waitingOn": "بانتظار",
"postDetail.notLinked": "غير مرتبط",
"postDetail.linkExisting": "ربط موجود",
"postDetail.createNew": "إنشاء جديد",
"postDetail.open": "فتح",
"postDetail.unlink": "إلغاء الربط",
"postDetail.viewDetails": "عرض التفاصيل",
"postDetail.reviewer": "المراجع",
"postDetail.selectReviewer": "اختر المراجع",
"postDetail.submitForReview": "إرسال للمراجعة",
"postDetail.pendingReviewBy": "بانتظار مراجعة",
"postDetail.approved": "تمت الموافقة",
"postDetail.sourceLanguage": "اللغة المصدر",
"postDetail.content": "المحتوى",
"postDetail.contentPlaceholder": "اكتب النص...",
"postDetail.files": "الملفات",
"postDetail.dragDropFiles": "اسحب وأفلت أو انقر للرفع",
"postDetail.addMoreFiles": "إضافة ملفات أخرى",
"postDetail.createAndSubmit": "إنشاء وإرسال للمراجعة",
"postDetail.create": "إنشاء",
"finance.campaign": "الحملة",
"finance.budgetAssigned": "الميزانية المخصصة",
"finance.trackAllocated": "المسار المخصص",
"finance.spent": "المنفق",
"finance.roi": "العائد",
"finance.workOrder": "أمر العمل",
"finance.budgetAllocated": "الميزانية المخصصة",
"finance.of": "من",
"finance.campaignCount": "{{count}} حملات · توزيع ميزانية على مستوى المسار",
"finance.workOrderCount": "{{count}} أوامر عمل بميزانية مخصصة",
"calendar.sun": "أحد",
"calendar.mon": "إثن",
"calendar.tue": "ثلا",
"calendar.wed": "أرب",
"calendar.thu": "خمي",
"calendar.fri": "جمع",
"calendar.sat": "سبت",
"calendar.month": "شهر",
"calendar.week": "أسبوع",
"calendar.today": "اليوم"
}
+748 -7
View File
@@ -1,6 +1,6 @@
{
"app.name": "Digital Hub",
"app.subtitle": "Platform",
"app.name": "Rawaj",
"app.subtitle": "Marketing Hub",
"nav.dashboard": "Dashboard",
"nav.campaigns": "Campaigns",
"nav.finance": "Finance & ROI",
@@ -30,6 +30,8 @@
"common.noResults": "No results",
"common.loading": "Loading...",
"common.unassigned": "Unassigned",
"common.close": "Close",
"common.created": "Created",
"common.required": "Required",
"common.saveFailed": "Failed to save. Please try again.",
"common.updateFailed": "Failed to update. Please try again.",
@@ -69,7 +71,7 @@
"dashboard.noPostsYet": "No posts yet. Create your first post!",
"dashboard.upcomingDeadlines": "Upcoming Deadlines",
"dashboard.noUpcomingDeadlines": "No upcoming deadlines this week. 🎉",
"dashboard.loadingHub": "Loading Digital Hub...",
"dashboard.loadingHub": "Loading Rawaj...",
"posts.title": "Post Production",
"posts.newPost": "New Post",
"posts.editPost": "Edit Post",
@@ -77,6 +79,29 @@
"posts.saveChanges": "Save Changes",
"posts.postTitle": "Title",
"posts.description": "Description",
"post.caption": "Caption",
"post.captionPlaceholder": "Write your social media caption...",
"post.copy": "Copy (In-Design Text)",
"post.designs": "Designs",
"post.video": "Video",
"post.formatChecklist": "Format Checklist",
"post.formatsNeeded": "Formats needed based on selected platforms",
"post.selectPlatforms": "Select platforms to see required formats",
"post.readiness": "Readiness",
"post.allPiecesReady": "All pieces ready — awaiting sign-off",
"post.waitingOn": "Waiting on",
"post.signOff": "Approve & Schedule",
"post.signOffConfirm": "Mark this post as approved and ready for scheduling?",
"common.confirm": "Confirm",
"post.linkExisting": "Link existing",
"post.createNew": "Create new",
"post.addDesign": "Add Design",
"post.addVideo": "Add Video",
"post.linkTranslation": "Link Translation",
"post.selectLanguage": "Language...",
"post.noCopyLinked": "No copy linked yet",
"post.noDesignsLinked": "No designs linked yet",
"post.noVideoLinked": "No video linked yet",
"posts.brand": "Brand",
"posts.platforms": "Platforms",
"posts.status": "Status",
@@ -130,6 +155,7 @@
"posts.status.approved": "Approved",
"posts.status.scheduled": "Scheduled",
"posts.status.published": "Published",
"posts.status.rejected": "Rejected",
"tasks.title": "Tasks",
"tasks.newTask": "New Task",
"tasks.editTask": "Edit Task",
@@ -209,6 +235,7 @@
"team.title": "Team",
"team.members": "Team Members",
"team.addMember": "Add Member",
"team.memberAdded": "Member added successfully",
"team.newMember": "New Team Member",
"team.editMember": "Edit Team Member",
"team.myProfile": "My Profile",
@@ -231,6 +258,12 @@
"team.membersPlural": "team members",
"team.fullName": "Full name",
"team.defaultPassword": "Default: changeme123",
"team.confirmPassword": "Confirm Password",
"team.passwordsDoNotMatch": "Passwords do not match",
"team.adminActions": "Admin Actions",
"team.newPassword": "New password (min 6 characters)",
"team.changePassword": "Change Password",
"team.passwordChanged": "Password changed successfully",
"team.optional": "(optional)",
"team.fixedRole": "Fixed role for managers",
"team.remove": "Remove",
@@ -262,7 +295,7 @@
"settings.english": "English",
"settings.arabic": "Arabic",
"settings.restartTutorial": "Restart Tutorial",
"settings.tutorialDesc": "Need a refresher? Restart the interactive tutorial to learn about all the features of Digital Hub.",
"settings.tutorialDesc": "Need a refresher? Restart the interactive tutorial to learn about all the features of Rawaj.",
"settings.general": "General",
"settings.onboardingTutorial": "Onboarding Tutorial",
"settings.tutorialRestarted": "Tutorial Restarted!",
@@ -306,10 +339,29 @@
"tutorial.newPost.desc": "Start creating content here. Pick your brand, platforms, and assign it to a team member.",
"tutorial.filters.title": "Filter & Focus",
"tutorial.filters.desc": "Use filters to focus on specific brands, platforms, or team members.",
"login.title": "Digital Hub",
"login.title": "Rawaj",
"login.subtitle": "Sign in to continue",
"login.forgotPassword": "Forgot password?",
"login.defaultCreds": "Default credentials:",
"forgotPassword.title": "Forgot Password",
"forgotPassword.subtitle": "Enter your email to receive a reset link",
"forgotPassword.emailPlaceholder": "your@email.com",
"forgotPassword.submit": "Send Reset Link",
"forgotPassword.sending": "Sending...",
"forgotPassword.success": "If an account with that email exists, a reset link has been sent.",
"forgotPassword.backToLogin": "Back to Login",
"forgotPassword.error": "Something went wrong. Please try again.",
"resetPassword.title": "Reset Password",
"resetPassword.subtitle": "Enter your new password",
"resetPassword.newPassword": "New Password",
"resetPassword.confirmPassword": "Confirm Password",
"resetPassword.submit": "Reset Password",
"resetPassword.resetting": "Resetting...",
"resetPassword.success": "Password has been reset. You can now log in.",
"resetPassword.invalidToken": "Invalid or expired reset link.",
"resetPassword.goToLogin": "Go to Login",
"resetPassword.passwordMismatch": "Passwords do not match",
"resetPassword.error": "Failed to reset password. The link may have expired.",
"comments.title": "Discussion",
"comments.noComments": "No comments yet. Start the conversation.",
"comments.placeholder": "Write a comment...",
@@ -325,13 +377,24 @@
"timeline.day": "Day",
"timeline.week": "Week",
"timeline.today": "Today",
"timeline.startDate": "Start Date",
"timeline.startDate": "Start",
"timeline.endDate": "End",
"timeline.assignee": "Assignee",
"timeline.status": "Status",
"timeline.dragToMove": "Drag to move",
"timeline.dragToResize": "Drag edges to resize",
"timeline.noItems": "No items to display",
"timeline.addItems": "Add items with dates to see the timeline",
"timeline.tracks": "Tracks",
"timeline.timeline": "Timeline",
"timeline.item": "Item",
"timeline.month": "Month",
"timeline.compact": "Compact",
"timeline.expand": "Expand",
"timeline.resetColor": "Reset to default",
"timeline.changeColor": "Change color",
"timeline.compactBars": "Compact bars",
"timeline.expandedBars": "Expanded bars",
"posts.details": "Details",
"posts.platformsLinks": "Platforms & Links",
"posts.discussion": "Discussion",
@@ -357,6 +420,16 @@
"campaigns.editCampaign": "Edit Campaign",
"campaigns.deleteCampaign": "Delete Campaign?",
"campaigns.deleteConfirm": "Are you sure you want to delete this campaign? All associated data will be removed. This action cannot be undone.",
"campaigns.tracks": "Tracks",
"campaigns.addTrack": "Add Track",
"campaigns.noTracks": "No tracks yet. Add organic, paid, or SEO tracks to organize this campaign.",
"campaigns.postsLinked": "posts linked",
"campaigns.team": "Team",
"campaigns.assignMembers": "Assign Members",
"campaigns.linkedPosts": "Linked Posts",
"campaigns.notFound": "Campaign not found.",
"common.goBack": "Go back",
"finance.allocated": "allocated",
"tracks.details": "Details",
"tracks.metrics": "Metrics",
"tracks.trackName": "Track Name",
@@ -464,6 +537,59 @@
"budgets.dateExpensed": "Date",
"dashboard.expenses": "Expenses",
"finance.expenses": "Total Expenses",
"finance.totalReceived": "Total Received",
"finance.totalSpent": "Total Spent",
"finance.remaining": "Remaining",
"finance.revenue": "Revenue",
"finance.globalROI": "Global ROI",
"finance.budgetAllocation": "Budget Allocation",
"finance.manageBudgets": "Manage Budgets",
"finance.campaigns": "Campaigns",
"finance.projects": "Projects",
"finance.unallocated": "Unallocated",
"finance.budgetUtilization": "Budget Utilization",
"finance.globalPerformance": "Global Performance",
"finance.impressions": "Impressions",
"finance.clicks": "Clicks",
"finance.conversions": "Conversions",
"finance.campaignBreakdown": "Campaign Breakdown",
"finance.allocatedFunds": "Allocated Funds",
"finance.requestBudget": "Request Budget",
"finance.budgetRequests": "Budget Requests",
"finance.pendingApproval": "pending CEO approval",
"finance.justification": "Justification",
"finance.earmarkFor": "Earmark for",
"finance.submitRequest": "Submit Request",
"finance.cancelRequest": "Cancel Request",
"finance.approved": "Approved",
"finance.rejected": "Rejected",
"finance.cancelled": "Cancelled",
"finance.pending": "Pending",
"finance.ceoNote": "CEO Note",
"finance.requestPending": "budget request(s) pending CEO approval",
"finance.insufficientBudget": "Insufficient budget",
"finance.availableBudget": "Available",
"finance.requestMore": "Request more funds",
"finance.noCeoEmail": "CEO email not configured. Go to Settings.",
"finance.amount": "Amount",
"finance.justificationPlaceholder": "Why is this budget needed?",
"finance.optional": "Optional",
"settings.budgetApproval": "Budget Approval",
"settings.ceoEmail": "CEO / Budget Approver Email",
"settings.ceoEmailHint": "Email address that receives budget approval requests",
"budgetApproval.title": "Budget Approval",
"budgetApproval.amount": "Requested Amount",
"budgetApproval.requestedBy": "Requested by",
"budgetApproval.justification": "Justification",
"budgetApproval.earmarkedFor": "Earmarked for",
"budgetApproval.approve": "Approve",
"budgetApproval.reject": "Reject",
"budgetApproval.addNote": "Add a note (optional)",
"budgetApproval.approved": "This request has been approved.",
"budgetApproval.rejected": "This request has been rejected.",
"budgetApproval.expired": "This request has expired.",
"budgetApproval.alreadyHandled": "This request has already been processed.",
"finance.ofBudget": "of budget",
"settings.uploads": "Uploads",
"settings.maxFileSize": "Maximum File Size",
"settings.maxFileSizeHint": "Maximum allowed file size for attachments (1-500 MB)",
@@ -471,11 +597,21 @@
"settings.saved": "Settings saved!",
"tasks.maxFileSize": "Max file size: {size} MB",
"tasks.fileTooLarge": "File \"{name}\" is too large ({size} MB). Maximum allowed: {max} MB.",
"issues.details": "Details",
"issues.actions": "Actions",
"issues.updates": "Updates",
"issues.board": "Board",
"issues.list": "List",
"issues.statusUpdated": "Issue status updated!",
"issues.dropHere": "Drop here",
"issues.noIssuesInColumn": "No issues",
"artefacts.details": "Details",
"artefacts.review": "Review",
"artefacts.selectVersionFirst": "Select a version to view comments.",
"artefacts.pendingReviewInfo": "This artefact is currently pending review.",
"artefacts.noReviewInfo": "No review information available.",
"artefacts.rejectedMustCreateNewVersion": "This artefact was rejected. Create a new version to address the feedback.",
"artefacts.revisionEditCurrentVersion": "Revision requested — edit the current version and resubmit for review.",
"artefacts.grid": "Grid",
"artefacts.list": "List",
"artefacts.allCreators": "All Creators",
@@ -486,5 +622,610 @@
"artefacts.sortRecentlyUpdated": "Recently Updated",
"artefacts.sortNewest": "Newest First",
"artefacts.sortOldest": "Oldest First",
"artefacts.sortTitleAZ": "Title A-Z"
"artefacts.sortTitleAZ": "Title A-Z",
"login.initialSetup": "Initial Setup",
"login.initialSetupDesc": "Create your admin account to get started",
"login.createAccount": "Create Account",
"login.signIn": "Sign In",
"login.fullName": "Full Name",
"login.fullNamePlaceholder": "Your name",
"login.email": "Email",
"login.password": "Password",
"login.passwordPlaceholder": "Choose a strong password",
"login.confirmPassword": "Confirm Password",
"login.confirmPasswordPlaceholder": "Re-enter your password",
"login.passwordMismatch": "Passwords do not match",
"login.setupFailed": "Setup failed",
"login.accountCreated": "Account created. You can now log in.",
"login.welcomeBack": "Welcome Back",
"login.signInDesc": "Sign in to continue",
"login.invalidCredentials": "Invalid email or password",
"login.creatingAccount": "Creating account...",
"users.title": "User Management",
"users.addUser": "Add User",
"users.addNewUser": "Add New User",
"users.editUser": "Edit User",
"users.deleteUser": "Delete User",
"users.deleteUserConfirmTitle": "Delete User?",
"users.deleteConfirm": "Are you sure you want to delete this user? This action cannot be undone.",
"users.userSingular": "user",
"users.usersPlural": "users",
"users.noUsers": "No users found",
"users.you": "You",
"users.name": "Name",
"users.fullNamePlaceholder": "Full name",
"users.email": "Email",
"users.password": "Password",
"users.confirmPassword": "Confirm Password",
"users.role": "Role",
"users.created": "Created",
"users.actions": "Actions",
"users.leaveBlankToKeep": "leave blank to keep current",
"users.saveChanges": "Save Changes",
"users.passwordMismatch": "Passwords do not match",
"users.passwordRequired": "Password is required for new users",
"users.saveFailed": "Failed to save user",
"users.preferredLanguage": "Preferred Language",
"users.deleteFailed": "Failed to delete user",
"settings.saveFailed": "Failed to save",
"settings.restartTutorialFailed": "Failed to restart tutorial",
"artefacts.title": "Artefacts",
"artefacts.subtitle": "Content approval workflow with versioning",
"artefacts.newArtefact": "New Artefact",
"artefacts.createArtefact": "Create Artefact",
"artefacts.searchArtefacts": "Search artefacts...",
"artefacts.allBrands": "All Brands",
"artefacts.allStatuses": "All Statuses",
"artefacts.allTypes": "All Types",
"artefacts.noArtefacts": "No artefacts found",
"artefacts.titleLabel": "Title",
"artefacts.titlePlaceholder": "Artefact title",
"artefacts.type": "Type",
"artefacts.status": "Status",
"artefacts.brand": "Brand",
"artefacts.creator": "Creator",
"artefacts.approvers": "Approvers",
"artefacts.version": "Version",
"artefacts.updated": "Updated",
"artefacts.description": "Description",
"artefacts.descriptionPlaceholder": "Brief description",
"artefacts.titleRequired": "Title is required",
"artefacts.created": "Artefact created",
"artefacts.createFailed": "Failed to create artefact",
"artefacts.deleted": "Artefact deleted",
"artefacts.deleteFailed": "Failed to delete artefact",
"artefacts.loadFailed": "Failed to load artefacts",
"artefacts.creating": "Creating...",
"artefacts.status.draft": "Draft",
"artefacts.status.pendingReview": "Pending Review",
"artefacts.status.approved": "Approved",
"artefacts.status.rejected": "Rejected",
"artefacts.status.revisionRequested": "Revision Requested",
"review.contentReview": "Content Review",
"review.yourReview": "Your Review",
"review.approve": "Approve",
"review.reject": "Reject",
"review.requestRevision": "Request Revision",
"review.reviewer": "Reviewer",
"review.selectYourName": "Select your name...",
"review.enterYourName": "Enter your name",
"review.feedbackOptional": "Feedback (optional)",
"review.feedbackPlaceholder": "Share your thoughts, suggestions, or required changes...",
"review.thankYou": "Thank You!",
"review.notAvailable": "Review Not Available",
"review.alreadyReviewed": "This artefact has already been reviewed.",
"review.statusLabel": "Status",
"review.reviewedBy": "Reviewed by",
"review.poweredBy": "Powered by Rawaj",
"review.loadFailed": "Failed to load artefact",
"review.actionFailed": "Action failed",
"review.actionCompleted": "Action completed successfully",
"review.enterName": "Please select or enter your name",
"review.confirmApprove": "Approve this artefact?",
"review.confirmReject": "Reject this artefact?",
"review.feedbackRequired": "Please provide feedback for revision request",
"review.contentLanguages": "Content Languages",
"review.redirectReview": "Not the right reviewer? Redirect to someone else",
"review.redirectDesc": "Select a team member to redirect this review to:",
"review.selectNewReviewer": "Select new reviewer...",
"review.redirect": "Redirect",
"review.redirected": "Review redirected successfully",
"review.content": "Content",
"review.designFiles": "Design Files",
"review.videos": "Videos",
"review.googleDriveVideo": "Google Drive Video",
"review.attachments": "Attachments",
"review.previousComments": "Previous Comments",
"review.version": "Version",
"common.failedToSave": "Failed to save",
"common.copiedToClipboard": "Copied to clipboard!",
"team.failedToSaveTeam": "Failed to save team",
"posts.canOnlyEditOwn": "You can only edit your own posts",
"assets.uploadFailed": "Upload failed",
"assets.failedToDelete": "Failed to delete asset",
"issues.failedToAddComment": "Failed to add comment",
"issues.failedToUploadFile": "Failed to upload file",
"issues.failedToSubmit": "Failed to submit issue. Please try again.",
"issues.failedToUpdateStatus": "Failed to update status",
"issues.failedToResolve": "Failed to resolve issue",
"issues.failedToDecline": "Failed to decline issue",
"issues.failedToUpdateAssignment": "Failed to update assignment",
"issues.failedToSaveNotes": "Failed to save notes",
"issues.failedToAddUpdate": "Failed to add update",
"issues.failedToDeleteAttachment": "Failed to delete attachment",
"issues.trackingLinkCopied": "Tracking link copied to clipboard!",
"issues.deleteAttachment": "Delete attachment?",
"issues.deleteAttachmentDesc": "This action cannot be undone.",
"artefacts.editLanguage": "Edit Language",
"artefacts.linkedPost": "Linked Post",
"artefacts.post": "Post",
"artefacts.deleteLanguage": "Delete this language?",
"artefacts.deleteLanguageDesc": "The content for this language will be removed.",
"artefacts.deleteAttachment": "Delete this attachment?",
"artefacts.deleteAttachmentDesc": "This action cannot be undone.",
"artefacts.deleteArtefact": "Delete this artefact?",
"artefacts.deleteArtefactDesc": "This action cannot be undone.",
"review.confirmApproveDesc": "Are you sure you want to approve this artefact?",
"review.confirmRejectDesc": "Are you sure you want to reject this artefact?",
"common.selected": "selected",
"common.deleteSelected": "Delete Selected",
"common.clearSelection": "Clear selection",
"common.bulkDeleteConfirm": "Delete {count} items?",
"common.bulkDeleteDesc": "This action cannot be undone.",
"common.selectAll": "Select all",
"issues.team": "Team",
"issues.allTeams": "All Teams",
"issues.copyPublicLink": "Copy Public Link",
"issues.linkCopied": "Link copied!",
"issues.selectTeam": "Select a team",
"issues.publicSubmitTeam": "Which team should handle your issue?",
"team.copyIssueLink": "Copy Issue Link",
"team.copyGenericIssueLink": "Copy Public Issue Link",
"team.permissionLevel": "Permission Level",
"team.role": "Role",
"team.selectRole": "Select role...",
"common.team": "Team",
"common.noTeam": "No team",
"common.none": "None",
"common.untitled": "Untitled",
"common.success": "Success",
"common.error": "An error occurred",
"settings.roles": "Roles",
"settings.rolesDesc": "Define job roles like Designer, Strategist, etc. These are assigned to team members separately from permission levels.",
"settings.addRole": "Add Role",
"settings.roleName": "Role name",
"settings.roleColor": "Color",
"settings.deleteRoleConfirm": "Are you sure you want to delete this role?",
"settings.noRoles": "No roles defined yet. Add your first role.",
"header.dashboard": "Dashboard",
"header.posts": "Post Production",
"header.assets": "Assets",
"header.campaigns": "Campaigns",
"header.finance": "Finance",
"header.projects": "Projects",
"header.tasks": "My Tasks",
"header.team": "Team",
"header.calendar": "Post Calendar",
"header.artefacts": "Artefacts",
"header.brands": "Brands",
"header.budgets": "Budgets",
"header.issues": "Issues",
"header.settings": "Settings",
"header.translations": "Translations",
"header.copy": "Copy",
"header.postDetails": "Post Details",
"calendar.unscheduledPosts": "Unscheduled Posts",
"calendar.statusLegend": "Status Legend",
"header.users": "User Management",
"header.projectDetails": "Project Details",
"header.campaignDetails": "Campaign Details",
"header.page": "Page",
"header.superadmin": "Superadmin",
"header.manager": "Manager",
"header.contributor": "Contributor",
"header.passwordMismatch": "New passwords do not match",
"header.passwordMinLength": "New password must be at least 6 characters",
"header.passwordUpdateSuccess": "Password updated successfully",
"header.passwordUpdateFailed": "Failed to change password",
"header.userManagement": "User Management",
"header.changePassword": "Change Password",
"header.signOut": "Sign Out",
"header.currentPassword": "Current Password",
"header.newPassword": "New Password",
"header.confirmNewPassword": "Confirm New Password",
"header.updatePassword": "Update Password",
"header.saving": "Saving...",
"issues.title": "Issues",
"issues.subtitle": "Track and manage issue submissions",
"issues.searchPlaceholder": "Search issues...",
"issues.allStatuses": "All Statuses",
"issues.allCategories": "All Categories",
"issues.allTypes": "All Types",
"issues.allBrands": "All Brands",
"issues.allPriorities": "All Priorities",
"issues.clearAll": "Clear All",
"issues.noIssuesFound": "No issues found",
"issues.tryAdjustingFilters": "Try adjusting your filters",
"issues.noIssuesSubmitted": "No issues have been submitted yet",
"issues.issuesDeleted": "Issues deleted",
"issues.tableTitle": "Title",
"issues.tableSubmitter": "Submitter",
"issues.tableBrand": "Brand",
"issues.tableCategory": "Category",
"issues.tableType": "Type",
"issues.tablePriority": "Priority",
"issues.tableStatus": "Status",
"issues.tableAssignedTo": "Assigned To",
"issues.tableCreated": "Created",
"issues.typeRequest": "Request",
"issues.typeCorrection": "Correction",
"issues.typeComplaint": "Complaint",
"issues.typeSuggestion": "Suggestion",
"issues.typeOther": "Other",
"issues.priorityLow": "Low",
"issues.priorityMedium": "Medium",
"issues.priorityHigh": "High",
"issues.priorityUrgent": "Urgent",
"issues.submitterInfo": "Submitter Information",
"issues.nameLabel": "Name:",
"issues.emailLabel": "Email:",
"issues.phoneLabel": "Phone:",
"issues.submittedLabel": "Submitted:",
"issues.description": "Description",
"issues.noDescription": "No description provided",
"issues.assignedTo": "Assigned To",
"issues.unassigned": "Unassigned",
"issues.brandLabel": "Brand",
"issues.noBrand": "No brand",
"issues.internalNotes": "Internal Notes (Staff Only)",
"issues.internalNotesPlaceholder": "Internal notes not visible to submitter...",
"issues.resolutionSummary": "Resolution Summary (Public)",
"issues.resolvedOn": "Resolved on",
"issues.acknowledge": "Acknowledge",
"issues.startWork": "Start Work",
"issues.resolve": "Resolve",
"issues.decline": "Decline",
"issues.publicTrackingLink": "Public Tracking Link",
"issues.updatesTimeline": "Updates Timeline",
"issues.addUpdatePlaceholder": "Add an update...",
"issues.makePublic": "Make public (visible to submitter)",
"issues.addUpdate": "Add Update",
"issues.noUpdates": "No updates yet",
"issues.attachments": "Attachments",
"issues.clickToUpload": "Click to upload file",
"issues.uploading": "Uploading...",
"issues.download": "Download",
"issues.noAttachments": "No attachments",
"issues.resolveIssue": "Resolve Issue",
"issues.resolveSummaryHint": "Provide a resolution summary that will be visible to the submitter.",
"issues.resolutionPlaceholder": "Explain how this issue was resolved...",
"issues.markAsResolved": "Mark as Resolved",
"issues.resolving": "Resolving...",
"issues.declineIssue": "Decline Issue",
"issues.declineReasonHint": "Provide a reason for declining this issue. This will be visible to the submitter.",
"issues.declinePlaceholder": "Explain why this issue cannot be addressed...",
"issues.declining": "Declining...",
"artefacts.descriptionLabel": "Description",
"artefacts.descriptionFieldPlaceholder": "Add a description...",
"artefacts.approversLabel": "Approvers",
"artefacts.reviewer": "Reviewer",
"artefacts.selectReviewer": "Select a reviewer...",
"artefacts.versions": "Versions",
"artefacts.newVersion": "New Version",
"artefacts.languages": "Languages",
"artefacts.addLanguage": "Add Language",
"artefacts.noLanguages": "No languages added yet",
"artefacts.imagesLabel": "Images",
"artefacts.uploadImage": "Upload Image",
"artefacts.uploading": "Uploading...",
"artefacts.dropOrClickImage": "Drop images here or click to upload",
"artefacts.imageFormats": "PNG, JPG, WebP",
"artefacts.noImages": "No images uploaded yet",
"artefacts.videosLabel": "Videos",
"artefacts.addVideoBtn": "Add Video",
"artefacts.noVideos": "No videos added yet",
"artefacts.comments": "Comments",
"artefacts.sendComment": "Send",
"artefacts.addCommentPlaceholder": "Add a comment...",
"artefacts.submitForReview": "Submit for Review",
"artefacts.submitting": "Submitting...",
"artefacts.reviewLinkTitle": "Review Link (expires in 7 days)",
"artefacts.feedbackTitle": "Feedback",
"artefacts.approvedByLabel": "Approved by",
"artefacts.saveDraft": "Save",
"artefacts.savingDraft": "Saving...",
"artefacts.versionNotes": "Version Notes",
"artefacts.whatChanged": "What changed in this version?",
"artefacts.copyLanguages": "Copy languages from previous version",
"artefacts.createVersion": "Create Version",
"artefacts.creatingVersion": "Creating...",
"artefacts.languageLabel": "Language",
"artefacts.contentLabel": "Content",
"artefacts.selectLanguage": "Select a language...",
"artefacts.enterContent": "Enter the content in this language...",
"artefacts.addVideoTitle": "Add Video",
"artefacts.uploadFile": "Upload File",
"artefacts.chooseVideoFile": "Choose video file",
"artefacts.videoFormats": "MP4, MOV, AVI, etc.",
"artefacts.dropOrClickVideo": "Drop a video here or click to browse",
"artefacts.googleDriveLink": "Google Drive Link",
"artefacts.googleDriveUrl": "Google Drive URL",
"artefacts.driveUrlPlaceholder": "https://drive.google.com/file/d/...",
"artefacts.publiclyAccessible": "Paste a Google Drive share link. Make sure the file is publicly accessible.",
"artefacts.addLink": "Add Link",
"artefacts.adding": "Adding...",
"artefacts.googleDriveVideo": "Google Drive Video",
"artefacts.deleteArtefactTooltip": "Delete artefact",
"artefacts.saveDraftTooltip": "Save draft",
"artefacts.createNewVersion": "Create New Version",
"artefacts.failedLoadVersions": "Failed to load versions",
"artefacts.failedLoadVersionData": "Failed to load version data",
"artefacts.versionCreated": "New version created",
"artefacts.failedCreateVersion": "Failed to create version",
"artefacts.languageAdded": "Language added",
"artefacts.allFieldsRequired": "All fields are required",
"artefacts.failedAddLanguage": "Failed to add language",
"artefacts.languageDeleted": "Language deleted",
"artefacts.failedDeleteLanguage": "Failed to delete language",
"artefacts.fileUploaded": "File uploaded",
"artefacts.uploadFailed": "Upload failed",
"artefacts.videoLinkAdded": "Video link added",
"artefacts.failedAddVideoLink": "Failed to add video link",
"artefacts.enterDriveUrl": "Please enter a Google Drive URL",
"artefacts.attachmentDeleted": "Attachment deleted",
"artefacts.failedDeleteAttachment": "Failed to delete attachment",
"artefacts.submittedForReview": "Submitted for review!",
"artefacts.failedSubmitReview": "Failed to submit for review",
"artefacts.linkCopied": "Link copied to clipboard",
"artefacts.commentAdded": "Comment added",
"artefacts.failedAddComment": "Failed to add comment",
"artefacts.updated": "Updated",
"artefacts.failedUpdate": "Failed to update",
"artefacts.draftSaved": "Draft saved",
"artefacts.failedSaveDraft": "Failed to save draft",
"artefacts.titleRequired": "Title is required",
"artefacts.failedDelete": "Failed to delete",
"posts.images": "Images",
"posts.audio": "Audio",
"posts.videos": "Videos",
"posts.otherFiles": "Other Files",
"posts.addImage": "Add Image",
"posts.addAudio": "Add Audio",
"posts.addVideo": "Add Video",
"posts.dragToUpload": "Drag files here to upload",
"posts.assignedTo": "Assigned To",
"posts.approval": "Approval",
"posts.approvers": "Approvers",
"posts.selectApprovers": "Select approvers...",
"posts.scheduling": "Scheduling & Assignment",
"posts.content": "Content",
"posts.reject": "Reject",
"posts.submittedForReview": "Post submitted for review",
"posts.failedSubmitReview": "Failed to submit for review",
"posts.reviewLinkCopied": "Review link copied!",
"posts.reviewLinkTitle": "Review Link",
"posts.awaitingReview": "Awaiting Review",
"posts.awaitingReviewDesc": "This post is waiting for external approval.",
"posts.approvedBy": "Approved by",
"posts.rejectedBy": "Rejected by",
"posts.submitting": "Submitting...",
"posts.submitForReview": "Submit for Review",
"posts.schedulePost": "Schedule Post",
"review.postReview": "Post Review",
"review.createdBy": "Created by",
"review.confirmApprovePost": "Approve this post?",
"review.confirmRejectPost": "Reject this post?",
"review.confirmApprovePostDesc": "Are you sure you want to approve this post?",
"review.confirmRejectPostDesc": "Are you sure you want to reject this post? Please provide feedback explaining why.",
"review.feedbackRequired": "Feedback (required)",
"review.feedbackRequiredError": "Please provide feedback when rejecting",
"review.loadFailed": "Failed to load review",
"review.errorTitle": "Error",
"review.thankYou": "Thank you for your review!",
"review.approveSuccess": "Translation approved successfully!",
"review.rejectSuccess": "Translation has been rejected.",
"review.revisionSuccess": "Revision requested successfully.",
"review.nameRequired": "Please provide your name",
"review.yourReview": "Your Review",
"review.selectYourName": "Select your name",
"review.selectApprover": "Select approver...",
"review.yourName": "Your Name",
"review.enterYourName": "Enter your name...",
"review.feedback": "Feedback",
"review.feedbackPlaceholder": "Share your thoughts or feedback...",
"review.approve": "Approve",
"review.approved": "Approved",
"review.rejected": "Rejected",
"review.requestRevision": "Request Revision",
"review.reject": "Reject",
"review.statusLabel": "Status",
"review.reviewedBy": "Reviewed by",
"review.confirmReject": "Confirm Rejection",
"review.rejectConfirmDesc": "Are you sure you want to reject this translation? Please make sure you have provided feedback.",
"review.feedbackRequiredForReject": "Please provide feedback before rejecting.",
"posts.versions": "Versions",
"posts.newVersion": "New Version",
"posts.createNewVersion": "Create New Version",
"posts.createVersion": "Create Version",
"posts.creatingVersion": "Creating...",
"posts.whatChanged": "What changed in this version?",
"posts.copyLanguages": "Copy languages from previous version",
"posts.languages": "Languages",
"posts.addLanguage": "Add Language",
"posts.selectLanguage": "Select a language...",
"posts.enterContent": "Enter the content in this language...",
"posts.noLanguages": "No languages added yet",
"posts.noVersions": "No versions yet. Create one to start managing multilingual content and media.",
"posts.deleteLanguage": "Delete this language?",
"posts.deleteLanguageConfirm": "This will remove the language content from this version.",
"posts.media": "Media",
"posts.noMedia": "No media files uploaded",
"nav.translations": "Translations",
"translations.title": "Translations",
"translations.subtitle": "Manage content translations with approval workflow",
"translations.newTranslation": "New Translation",
"translations.createTranslation": "Create Translation",
"translations.searchTranslations": "Search translations...",
"translations.titleLabel": "Title",
"translations.titlePlaceholder": "e.g. Campaign tagline translation",
"translations.sourceLanguage": "Source Language",
"translations.sourceContent": "Source Content",
"translations.sourceContentPlaceholder": "Enter the original content to translate...",
"translations.description": "Description",
"translations.descriptionLabel": "Description",
"translations.descriptionPlaceholder": "Context or notes about this translation...",
"translations.brand": "Brand",
"translations.creator": "Creator",
"translations.approvers": "Approvers",
"translations.approversLabel": "Approvers",
"translations.status": "Status",
"translations.languagesLabel": "Languages",
"translations.languagesCount": "languages",
"translations.grid": "Grid",
"translations.list": "List",
"translations.allBrands": "All Brands",
"translations.allStatuses": "All Statuses",
"translations.allCreators": "All Creators",
"translations.status.draft": "Draft",
"translations.status.pendingReview": "Pending Review",
"translations.status.approved": "Approved",
"translations.status.rejected": "Rejected",
"translations.status.revisionRequested": "Revision Requested",
"translations.sortRecentlyUpdated": "Recently Updated",
"translations.sortNewest": "Newest First",
"translations.sortOldest": "Oldest First",
"translations.sortTitleAZ": "Title A-Z",
"translations.noTranslations": "No translations found",
"translations.loadFailed": "Failed to load translations",
"translations.titleRequired": "Title is required",
"translations.sourceContentRequired": "Source content is required",
"translations.created": "Translation created!",
"translations.createFailed": "Failed to create translation",
"translations.creating": "Creating...",
"translations.deleted": "Translation deleted!",
"translations.deleteFailed": "Failed to delete translation",
"translations.details": "Details",
"translations.translationTexts": "Translations",
"translations.review": "Review",
"translations.draftSaved": "Draft saved!",
"translations.failedSaveDraft": "Failed to save draft",
"translations.saveDraft": "Save Draft",
"translations.saveDraftTooltip": "Save changes to title and source content",
"translations.savingDraft": "Saving...",
"translations.updated": "Updated",
"translations.failedUpdate": "Failed to update",
"translations.addTranslation": "Add Translation",
"translations.translationAdded": "Translation added!",
"translations.failedAddTranslation": "Failed to add translation",
"translations.translationDeleted": "Translation deleted!",
"translations.failedDeleteTranslation": "Failed to delete translation",
"translations.noTranslationTexts": "No translations yet. Add one for each target language.",
"translations.allFieldsRequired": "Language and content are required",
"translations.languageLabel": "Language",
"translations.selectLanguage": "Select a language",
"translations.translatedContent": "Translated Content",
"translations.enterTranslatedContent": "Enter the translated content...",
"translations.deleteTranslation": "Delete Translation",
"translations.deleteTranslationDesc": "This will permanently delete this translation and all its language versions.",
"translations.deleteTranslationText": "Delete Translation Text",
"translations.deleteTranslationTextDesc": "This will remove this language translation.",
"translations.bulkDeleteDesc": "Delete selected translations?",
"translations.submitForReview": "Submit for Review",
"translations.submitting": "Submitting...",
"translations.submittedForReview": "Submitted for review!",
"translations.failedSubmitReview": "Failed to submit for review",
"translations.reviewLinkTitle": "Review Link",
"translations.linkCopied": "Link copied!",
"translations.feedbackTitle": "Reviewer Feedback",
"translations.approvedByLabel": "Approved by",
"translations.pendingReviewInfo": "This translation is currently pending review.",
"translations.noReviewInfo": "No review information available.",
"translations.failedDelete": "Failed to delete",
"translations.addOption": "Add Option",
"translations.option": "option",
"translations.options": "options",
"translations.optionLabel": "Option",
"translations.selected": "Selected",
"translations.selectThis": "Select",
"translations.optionSelected": "Option selected!",
"translations.suggestAlternative": "Suggest alternative",
"translations.suggestForLang": "Suggest a translation for",
"translations.enterSuggestion": "Enter your suggested translation...",
"translations.submitSuggestion": "Submit Suggestion",
"translations.suggestionAdded": "Suggestion added!",
"translations.existing": "existing",
"translations.copyContent": "Copy to clipboard",
"translations.copiedToClipboard": "Copied to clipboard!",
"translations.approvedReadOnly": "This translation is approved and cannot be modified.",
"translations.linkedPost": "Linked Post",
"translations.createPost": "New Post",
"translations.newPostTitle": "Post title...",
"translations.postCreated": "Post created!",
"translations.postCreateFailed": "Failed to create post",
"nav.copy": "Copy",
"postDetail.captionCopy": "Caption Copy",
"postDetail.bodyCopy": "Body Copy",
"postDetail.design": "Design",
"postDetail.video": "Video",
"postDetail.readiness": "Readiness",
"postDetail.noAssets": "No assets linked yet",
"postDetail.allPiecesApproved": "All pieces approved",
"postDetail.waitingOn": "Waiting on",
"postDetail.notLinked": "Not linked",
"postDetail.linkExisting": "Link existing",
"postDetail.createNew": "Create new",
"postDetail.open": "Open",
"postDetail.unlink": "Unlink",
"postDetail.viewDetails": "View details",
"postDetail.reviewer": "Reviewer",
"postDetail.selectReviewer": "Select reviewer",
"postDetail.submitForReview": "Submit for Review",
"postDetail.pendingReviewBy": "Pending review by",
"postDetail.approved": "Approved",
"postDetail.sourceLanguage": "Source Language",
"postDetail.content": "Content",
"postDetail.contentPlaceholder": "Write the copy text...",
"postDetail.files": "Files",
"postDetail.dragDropFiles": "Drag & drop or click to upload",
"postDetail.addMoreFiles": "Add more files",
"postDetail.createAndSubmit": "Create & Submit for Review",
"postDetail.create": "Create",
"finance.campaign": "Campaign",
"finance.budgetAssigned": "Budget Assigned",
"finance.trackAllocated": "Track Allocated",
"finance.spent": "Spent",
"finance.roi": "ROI",
"finance.workOrder": "Work Order",
"finance.budgetAllocated": "Budget Allocated",
"finance.of": "of",
"finance.campaignCount": "{{count}} campaigns · Track-level budget allocation",
"finance.workOrderCount": "{{count}} work orders with assigned budget",
"calendar.sun": "Sun",
"calendar.mon": "Mon",
"calendar.tue": "Tue",
"calendar.wed": "Wed",
"calendar.thu": "Thu",
"calendar.fri": "Fri",
"calendar.sat": "Sat",
"calendar.month": "Month",
"calendar.week": "Week",
"calendar.today": "Today"
}
+255 -91
View File
@@ -1,16 +1,16 @@
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+Arabic:wght@300;400;500;600;700&family=Inter:wght@300;400;500;600;700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500;600;700&family=IBM+Plex+Sans+Arabic:wght@300;400;500;600;700&display=swap');
@import "tailwindcss";
@theme {
--font-sans: 'Inter', 'IBM Plex Sans Arabic', system-ui, -apple-system, sans-serif;
--color-sidebar: #0f172a;
--color-sidebar-hover: #1e293b;
--color-sidebar-active: #020617;
--color-brand-primary: #4f46e5;
--color-brand-primary-light: #6366f1;
--font-sans: 'DM Sans', 'IBM Plex Sans Arabic', system-ui, -apple-system, sans-serif;
--color-sidebar: #0a1f1c;
--color-sidebar-hover: #123b35;
--color-sidebar-active: #061411;
--color-brand-primary: #0d9488;
--color-brand-primary-light: #14b8a6;
--color-brand-secondary: #db2777;
--color-brand-tertiary: #f59e0b;
--color-brand-quaternary: #059669;
--color-brand-quaternary: #0d9488;
--color-surface: #ffffff;
--color-surface-secondary: #f9fafb;
--color-surface-tertiary: #f3f4f6;
@@ -36,6 +36,220 @@
--color-status-cancelled: #dc2626;
}
/* ═══════════════════════════════════════════════
DARK MODE — Forest teal tinted surfaces
═══════════════════════════════════════════════ */
.dark {
/* Layered depth: deep forest → surface → elevated */
--color-surface: #0f1a18;
--color-surface-secondary: #162220;
--color-surface-tertiary: #1e2e2b;
--color-border: rgba(255, 255, 255, 0.08);
--color-border-light: rgba(255, 255, 255, 0.04);
/* Text — warm neutrals, teal-tinted */
--color-text-primary: #e8f0ee;
--color-text-secondary: #9db5b0;
--color-text-tertiary: #637e78;
/* Sidebar */
--color-sidebar: #0a1412;
--color-sidebar-hover: #0f1a18;
--color-sidebar-active: #060e0c;
/* Brand — brighter on dark */
--color-brand-primary: #14b8a6;
--color-brand-primary-light: #2dd4bf;
color-scheme: dark;
background-color: #0f1a18;
color: #e8f0ee;
}
/* ─── Ambient background glow ────────────────── */
.dark .bg-mesh {
background-color: #0f1a18 !important;
background-image: none !important;
}
.dark .bg-mesh::before {
content: '';
position: fixed;
inset: 0;
background:
radial-gradient(ellipse 70% 50% at 20% 50%, rgba(13, 148, 136, 0.04) 0%, transparent 60%),
radial-gradient(ellipse 50% 40% at 80% 30%, rgba(20, 184, 166, 0.025) 0%, transparent 60%);
pointer-events: none;
z-index: 0;
}
/* ─── Every white surface → elevated dark ────── */
.dark .bg-white,
.dark .bg-\[\#fff\],
.dark .bg-\[\#ffffff\] {
background-color: #1a2a28 !important;
}
.dark .bg-gray-50 { background-color: #0f1a18 !important; }
.dark .bg-gray-100 { background-color: #162220 !important; }
.dark .bg-gray-200 { background-color: #1e2e2b !important; }
/* ─── Borders ────────────────────────────────── */
.dark .border-gray-100,
.dark .border-gray-200,
.dark .border-gray-300 { border-color: rgba(255, 255, 255, 0.08) !important; }
.dark .divide-gray-100 > :not(:first-child),
.dark .divide-gray-200 > :not(:first-child),
.dark .divide-border-light > :not(:first-child) { border-color: rgba(255, 255, 255, 0.05) !important; }
/* ─── Text ───────────────────────────────────── */
.dark .text-gray-900 { color: #e8f0ee !important; }
.dark .text-gray-800 { color: #d0ddd9 !important; }
.dark .text-gray-700 { color: #b5cac5 !important; }
.dark .text-gray-600 { color: #9db5b0 !important; }
.dark .text-gray-500 { color: #7e9a94 !important; }
.dark .text-gray-400 { color: #637e78 !important; }
/* ─── Status badges — translucent glass ──────── */
.dark .bg-emerald-100, .dark .bg-emerald-50 { background-color: rgba(74, 222, 128, 0.12) !important; }
.dark .bg-blue-100, .dark .bg-blue-50 { background-color: rgba(96, 165, 250, 0.12) !important; }
.dark .bg-amber-100, .dark .bg-amber-50 { background-color: rgba(251, 191, 36, 0.12) !important; }
.dark .bg-red-100, .dark .bg-red-50 { background-color: rgba(251, 113, 133, 0.12) !important; }
.dark .bg-purple-100, .dark .bg-purple-50 { background-color: rgba(167, 139, 250, 0.12) !important; }
.dark .bg-orange-100 { background-color: rgba(251, 146, 60, 0.12) !important; }
.dark .bg-indigo-100, .dark .bg-indigo-50 { background-color: rgba(129, 140, 248, 0.12) !important; }
.dark .bg-pink-100 { background-color: rgba(244, 114, 182, 0.12) !important; }
.dark .bg-cyan-100 { background-color: rgba(34, 211, 238, 0.12) !important; }
.dark .bg-teal-100 { background-color: rgba(45, 212, 191, 0.12) !important; }
/* Status text colors — brighter on dark */
.dark .text-emerald-700 { color: #4ade80 !important; }
.dark .text-emerald-600 { color: #4ade80 !important; }
.dark .text-blue-700 { color: #60a5fa !important; }
.dark .text-amber-700 { color: #fbbf24 !important; }
.dark .text-amber-900 { color: #fbbf24 !important; }
.dark .text-red-700, .dark .text-red-600, .dark .text-red-500 { color: #fb7185 !important; }
.dark .text-purple-700 { color: #a78bfa !important; }
.dark .text-orange-700 { color: #fb923c !important; }
.dark .text-indigo-700 { color: #818cf8 !important; }
/* Gradient backgrounds for team sections etc. */
.dark .from-blue-50 { --tw-gradient-from: rgba(96, 165, 250, 0.06) !important; }
.dark .to-indigo-50 { --tw-gradient-to: rgba(129, 140, 248, 0.06) !important; }
.dark .bg-gradient-to-r.from-blue-50 { background: rgba(96, 165, 250, 0.06) !important; }
/* ─── Form elements ──────────────────────────── */
.dark input,
.dark select,
.dark textarea {
background-color: rgba(255, 255, 255, 0.04);
color: #eeecf5;
border-color: rgba(255, 255, 255, 0.08);
}
.dark input:focus,
.dark select:focus,
.dark textarea:focus {
border-color: rgba(20, 184, 166, 0.5);
box-shadow: 0 0 0 3px rgba(20, 184, 166, 0.1);
}
.dark input::placeholder,
.dark textarea::placeholder {
color: #637e78;
}
.dark input:disabled,
.dark select:disabled,
.dark textarea:disabled {
background-color: rgba(255, 255, 255, 0.02) !important;
color: #637e78 !important;
opacity: 0.6;
}
/* Dark select arrow */
.dark select {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23637e78' d='M6 9L1 4h10z'/%3E%3C/svg%3E");
}
/* ─── Cards — glass edges ────────────────────── */
.dark .card-hover {
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.04);
}
.dark .card-hover:hover {
box-shadow: 0 0 0 1px rgba(20, 184, 166, 0.12), 0 4px 16px -4px rgba(0, 0, 0, 0.4);
}
.dark .section-card {
background: #162220;
border-color: rgba(255, 255, 255, 0.06);
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.04);
}
.dark .section-card:hover {
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.06), 0 4px 16px -4px rgba(0, 0, 0, 0.3);
}
.dark .section-card-header {
background: rgba(30, 46, 43, 0.3);
}
/* ─── Sidebar ────────────────────────────────── */
.dark .sidebar {
background: linear-gradient(180deg, #0a1412 0%, #060e0c 100%);
box-shadow: 2px 0 24px rgba(0, 0, 0, 0.5);
}
/* ─── Scrollbar ──────────────────────────────── */
.dark ::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.08); }
.dark ::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.15); }
/* ─── Shadows ────────────────────────────────── */
.dark .shadow-sm { box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4) !important; }
.dark .shadow { box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4) !important; }
.dark .shadow-lg { box-shadow: 0 12px 40px -10px rgba(0, 0, 0, 0.6) !important; }
.dark .shadow-2xl { box-shadow: 0 20px 60px -15px rgba(0, 0, 0, 0.7) !important; }
/* ─── Modal backdrop ─────────────────────────── */
.dark .bg-black\/40 { background-color: rgba(0, 0, 0, 0.7) !important; }
/* ─── Hover states ───────────────────────────── */
.dark .hover\:bg-surface-secondary:hover,
.dark .hover\:bg-gray-50:hover,
.dark .hover\:bg-gray-100:hover { background-color: rgba(255, 255, 255, 0.04) !important; }
.dark .hover\:bg-red-50:hover { background-color: rgba(251, 113, 133, 0.08) !important; }
.dark .hover\:bg-blue-100:hover { background-color: rgba(96, 165, 250, 0.08) !important; }
/* ─── Brand accent ────────────────────────────── */
.dark .bg-brand-primary {
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
.dark .bg-brand-primary:hover {
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4);
}
/* ─── White/light text overrides on colored badges ── */
.dark .bg-white\/90 { background-color: rgba(22, 34, 32, 0.9) !important; }
/* ─── Toasts — solid backgrounds ────────────────── */
.dark .bg-emerald-50.border-emerald-200 { background-color: #0f2a1e !important; border-color: #154a2e !important; }
.dark .bg-red-50.border-red-200 { background-color: #2a1315 !important; border-color: #4a1a20 !important; }
.dark .bg-blue-50.border-blue-200 { background-color: #0f1d2a !important; border-color: #152e4a !important; }
.dark .bg-amber-50.border-amber-200 { background-color: #2a2210 !important; border-color: #4a3a15 !important; }
.dark .text-emerald-800 { color: #6ee7b7 !important; }
.dark .text-red-800 { color: #fca5a5 !important; }
.dark .text-blue-800 { color: #93c5fd !important; }
.dark .text-amber-800 { color: #fcd34d !important; }
/* ─── Selection ──────────────────────────────── */
.dark ::selection {
background: rgba(20, 184, 166, 0.4);
color: white;
}
/* Reduced motion — disable animations for accessibility */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 6px;
@@ -52,12 +266,10 @@
background: #94a3b8;
}
/* Smooth transitions */
* {
transition-property: background-color, border-color, color, opacity, box-shadow, transform;
transition-duration: 200ms;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
/* Smooth transitions — scoped to interactive elements only.
Do NOT use * selector — it causes every element to re-animate
on any React state change (e.g. drag-and-drop). Components should
use Tailwind transition-colors / transition-all where needed. */
/* Arabic text support */
[dir="rtl"] {
@@ -110,15 +322,15 @@ textarea {
margin-right: 0;
}
/* Enhanced sidebar with gradient */
/* Enhanced sidebar */
.sidebar {
background: linear-gradient(180deg, #0f172a 0%, #020617 100%);
background: linear-gradient(180deg, #0a1f1c 0%, #061411 100%);
box-shadow: 2px 0 12px rgba(0, 0, 0, 0.08);
}
/* Animation keyframes */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
@@ -142,11 +354,6 @@ textarea {
50% { opacity: 0.7; }
}
@keyframes bounce-subtle {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-4px); }
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
@@ -216,29 +423,28 @@ textarea {
overflow: hidden;
}
/* Stagger children */
.collapsible-content.is-open > .collapsible-inner {
overflow: visible;
}
/* Stagger children — short, max 4 items */
.stagger-children > * {
opacity: 0;
animation: fadeIn 0.3s ease-out forwards;
animation: fadeIn 0.2s ease-out forwards;
}
.stagger-children > *:nth-child(1) { animation-delay: 0ms; }
.stagger-children > *:nth-child(2) { animation-delay: 50ms; }
.stagger-children > *:nth-child(3) { animation-delay: 100ms; }
.stagger-children > *:nth-child(4) { animation-delay: 150ms; }
.stagger-children > *:nth-child(5) { animation-delay: 200ms; }
.stagger-children > *:nth-child(6) { animation-delay: 250ms; }
.stagger-children > *:nth-child(7) { animation-delay: 300ms; }
.stagger-children > *:nth-child(8) { animation-delay: 350ms; }
.stagger-children > *:nth-child(2) { animation-delay: 40ms; }
.stagger-children > *:nth-child(3) { animation-delay: 80ms; }
.stagger-children > *:nth-child(n+4) { animation-delay: 120ms; }
/* Card hover effect - smooth and elegant */
/* Card hover effect - refined, no lift */
.card-hover {
position: relative;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05), 0 1px 2px rgba(0, 0, 0, 0.06);
transition: box-shadow 0.2s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
}
.card-hover:hover {
transform: translateY(-3px);
box-shadow: 0 12px 28px -6px rgba(0, 0, 0, 0.12), 0 6px 16px -8px rgba(0, 0, 0, 0.08);
box-shadow: 0 4px 12px -2px rgba(0, 0, 0, 0.08);
}
/* Stat card accents - subtle colored top borders */
@@ -261,24 +467,12 @@ textarea {
opacity: 1;
}
/* Mesh background - subtle radial gradients */
/* Mesh background — flat, no gradients */
.bg-mesh {
background-color: #f8fafc;
background-image:
radial-gradient(at 20% 20%, rgba(79, 70, 229, 0.04) 0, transparent 50%),
radial-gradient(at 80% 40%, rgba(219, 39, 119, 0.03) 0, transparent 50%),
radial-gradient(at 40% 80%, rgba(5, 150, 105, 0.03) 0, transparent 50%);
}
/* Gradient text */
.text-gradient {
background: linear-gradient(135deg, var(--color-brand-primary) 0%, #7c3aed 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Premium stat card - always-visible gradient top bar */
/* Stat card accent — subtle top border, no gradient */
.stat-card-premium {
position: relative;
overflow: hidden;
@@ -289,20 +483,20 @@ textarea {
top: 0;
left: 0;
right: 0;
height: 3px;
opacity: 1;
height: 2px;
opacity: 0.6;
}
.stat-card-premium.accent-primary::before {
background: linear-gradient(90deg, #4f46e5, #7c3aed);
background: #0d9488;
}
.stat-card-premium.accent-secondary::before {
background: linear-gradient(90deg, #db2777, #ec4899);
background: #db2777;
}
.stat-card-premium.accent-tertiary::before {
background: linear-gradient(90deg, #f59e0b, #fbbf24);
background: #f59e0b;
}
.stat-card-premium.accent-quaternary::before {
background: linear-gradient(90deg, #059669, #34d399);
background: #059669;
}
/* Section card - premium container */
@@ -310,38 +504,30 @@ textarea {
background: white;
border: 1px solid var(--color-border);
border-radius: 1rem;
overflow: hidden;
overflow: clip;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
transition: box-shadow 0.3s ease;
}
.section-card:hover {
box-shadow: 0 4px 20px -4px rgba(0, 0, 0, 0.08);
box-shadow: 0 2px 8px -2px rgba(0, 0, 0, 0.06);
}
.section-card-header {
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--color-border);
background: linear-gradient(180deg, rgba(249, 250, 251, 0.5) 0%, white 100%);
}
/* Sidebar active glow */
.sidebar-active-glow {
box-shadow: inset 3px 0 0 rgba(129, 140, 248, 0.8);
box-shadow: inset 3px 0 0 rgba(20, 184, 166, 0.8);
}
[dir="rtl"] .sidebar-active-glow {
box-shadow: inset -3px 0 0 rgba(129, 140, 248, 0.8);
box-shadow: inset -3px 0 0 rgba(20, 184, 166, 0.8);
}
/* Refined button styles */
button {
border-radius: 0.625rem;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
button:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 12px -2px rgba(0, 0, 0, 0.12);
}
button:active:not(:disabled) {
transform: translateY(0) scale(0.98);
transition: background-color 0.2s, border-color 0.2s, color 0.2s, box-shadow 0.2s;
}
button:disabled {
cursor: not-allowed;
@@ -386,34 +572,12 @@ select:not(:disabled):hover {
color: white;
}
/* Kanban column */
.kanban-column {
min-height: 200px;
}
/* Calendar grid */
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
}
/* Ripple effect on buttons (optional enhancement) */
@keyframes ripple {
0% {
transform: scale(0);
opacity: 0.5;
}
100% {
transform: scale(2.5);
opacity: 0;
}
}
/* Badge pulse animation */
.badge-pulse {
animation: pulse-subtle 2s ease-in-out infinite;
}
/* Smooth height transitions */
.transition-height {
transition: height 0.3s cubic-bezier(0.4, 0, 0.2, 1);
File diff suppressed because it is too large Load Diff
+63 -11
View File
@@ -3,10 +3,15 @@ import { Plus, Upload, Search, FolderOpen, ChevronRight, Grid3X3, X } from 'luci
import { api } from '../utils/api'
import AssetCard from '../components/AssetCard'
import Modal from '../components/Modal'
import BulkSelectBar from '../components/BulkSelectBar'
import CommentsSection from '../components/CommentsSection'
import { SkeletonAssetGrid } from '../components/SkeletonLoader'
import { useLanguage } from '../i18n/LanguageContext'
import { useToast } from '../components/ToastContainer'
export default function Assets() {
const { t } = useLanguage()
const toast = useToast()
const [assets, setAssets] = useState([])
const [loading, setLoading] = useState(true)
const [filters, setFilters] = useState({ brand: '', tag: '', folder: '', search: '' })
@@ -18,13 +23,15 @@ export default function Assets() {
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [assetToDelete, setAssetToDelete] = useState(null)
const fileRef = useRef(null)
const [selectedIds, setSelectedIds] = useState(new Set())
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false)
useEffect(() => { loadAssets() }, [])
const loadAssets = async () => {
try {
const res = await api.get('/assets')
const assetsData = res.data || res || []
const assetsData = Array.isArray(res) ? res : []
// Map assets to include URL for thumbnails
const assetsWithUrls = assetsData.map(asset => ({
...asset,
@@ -91,7 +98,7 @@ export default function Assets() {
setUploadProgress(0)
} catch (err) {
console.error('Upload failed:', err)
alert('Upload failed: ' + err.message)
toast.error(t('assets.uploadFailed') + ': ' + err.message)
} finally {
setUploading(false)
}
@@ -111,10 +118,36 @@ export default function Assets() {
loadAssets()
} catch (err) {
console.error('Delete failed:', err)
alert('Failed to delete asset')
toast.error(t('assets.failedToDelete'))
}
}
const handleBulkDelete = async () => {
try {
await api.post('/assets/bulk-delete', { ids: [...selectedIds] })
setSelectedIds(new Set())
setShowBulkDeleteConfirm(false)
loadAssets()
} catch (err) {
console.error('Bulk delete failed:', err)
toast.error(t('common.deleteFailed'))
}
}
const toggleSelect = (id) => {
setSelectedIds(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const toggleSelectAll = () => {
if (selectedIds.size === filteredAssets.length) setSelectedIds(new Set())
else setSelectedIds(new Set(filteredAssets.map(a => a._id || a.id)))
}
const handleDrop = (e) => {
e.preventDefault()
setDragOver(false)
@@ -148,20 +181,20 @@ export default function Assets() {
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-3">
<div className="relative flex-1 min-w-[200px] max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<Search className="absolute start-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<input
type="text"
placeholder="Search assets..."
value={filters.search}
onChange={e => setFilters(f => ({ ...f, search: e.target.value }))}
className="w-full pl-10 pr-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-white"
className="w-full ps-10 pe-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface"
/>
</div>
<select
value={filters.brand}
onChange={e => setFilters(f => ({ ...f, brand: e.target.value }))}
className="text-sm border border-border rounded-lg px-3 py-2 bg-white text-text-secondary focus:outline-none"
className="text-sm border border-border rounded-lg px-3 py-2 bg-surface text-text-secondary focus:outline-none"
>
<option value="">All Brands</option>
{brands.map(b => <option key={b} value={b}>{b}</option>)}
@@ -170,7 +203,7 @@ export default function Assets() {
<select
value={filters.tag}
onChange={e => setFilters(f => ({ ...f, tag: e.target.value }))}
className="text-sm border border-border rounded-lg px-3 py-2 bg-white text-text-secondary focus:outline-none"
className="text-sm border border-border rounded-lg px-3 py-2 bg-surface text-text-secondary focus:outline-none"
>
<option value="">All Tags</option>
{allTags.map(t => <option key={t} value={t}>{t}</option>)}
@@ -178,7 +211,7 @@ export default function Assets() {
<button
onClick={() => setShowUpload(true)}
className="flex items-center gap-2 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm ml-auto"
className="flex items-center gap-2 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm ms-auto"
>
<Upload className="w-4 h-4" />
Upload
@@ -212,6 +245,10 @@ export default function Assets() {
</div>
)}
{selectedIds.size > 0 && (
<BulkSelectBar selectedCount={selectedIds.size} onDelete={() => setShowBulkDeleteConfirm(true)} onClear={() => setSelectedIds(new Set())} />
)}
{/* Asset grid */}
{filteredAssets.length === 0 ? (
<div className="py-20 text-center">
@@ -222,7 +259,10 @@ export default function Assets() {
) : (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4 stagger-children">
{filteredAssets.map(asset => (
<div key={asset._id || asset.id}>
<div key={asset._id || asset.id} className="relative">
<div className="absolute top-2 start-2 z-10" onClick={e => e.stopPropagation()}>
<input type="checkbox" checked={selectedIds.has(asset._id || asset.id)} onChange={() => toggleSelect(asset._id || asset.id)} className="rounded border-border" />
</div>
<AssetCard asset={asset} onClick={setSelectedAsset} />
</div>
))}
@@ -279,7 +319,7 @@ export default function Assets() {
<div className="space-y-4">
{selectedAsset.type === 'image' && selectedAsset.url && (
<div className="rounded-lg overflow-hidden bg-surface-tertiary">
<img src={selectedAsset.url} alt={selectedAsset.name} className="w-full max-h-[400px] object-contain" />
<img src={selectedAsset.url} alt={selectedAsset.name} className="w-full max-h-[400px] object-contain" loading="lazy" />
</div>
)}
{selectedAsset.type === 'video' && selectedAsset.url && (
@@ -334,7 +374,7 @@ export default function Assets() {
download={selectedAsset.name}
target="_blank"
rel="noopener noreferrer"
className="ml-auto px-4 py-2 text-sm font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light"
className="ms-auto px-4 py-2 text-sm font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light"
>
Download
</a>
@@ -343,6 +383,18 @@ export default function Assets() {
)}
</Modal>
<Modal
isOpen={showBulkDeleteConfirm}
onClose={() => setShowBulkDeleteConfirm(false)}
title={t('common.bulkDeleteConfirm').replace('{count}', selectedIds.size)}
isConfirm
danger
confirmText={t('common.deleteSelected')}
onConfirm={handleBulkDelete}
>
{t('common.bulkDeleteDesc')}
</Modal>
{/* Delete Asset Confirmation */}
<Modal
isOpen={showDeleteConfirm}
+34 -30
View File
@@ -7,7 +7,7 @@ import { AppContext } from '../App'
import Modal from '../components/Modal'
import { SkeletonCard } from '../components/SkeletonLoader'
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001/api'
const API_BASE = '/api'
const EMPTY_BRAND = { name: '', name_ar: '', priority: 2, icon: '' }
@@ -34,7 +34,7 @@ export default function Brands() {
const loadBrands = async () => {
try {
const data = await api.get('/brands')
setBrands(Array.isArray(data) ? data : (data.data || []))
setBrands(Array.isArray(data) ? data : [])
} catch (err) {
console.error('Failed to load brands:', err)
} finally {
@@ -129,13 +129,7 @@ export default function Brands() {
<div className="space-y-6 animate-fade-in">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-text-primary flex items-center gap-3">
<Tag className="w-7 h-7 text-brand-primary" />
{t('brands.title')}
</h1>
<p className="text-sm text-text-tertiary mt-1">{t('brands.manageBrands')}</p>
</div>
<p className="text-sm text-text-tertiary">{t('brands.manageBrands')}</p>
{isSuperadminOrManager && (
<button
onClick={openNewBrand}
@@ -149,64 +143,63 @@ export default function Brands() {
{/* Brand Cards Grid */}
{brands.length === 0 ? (
<div className="bg-white rounded-xl border border-border py-16 text-center">
<div className="bg-surface rounded-xl border border-border py-16 text-center">
<Tag className="w-12 h-12 text-text-quaternary mx-auto mb-3" />
<p className="text-sm text-text-tertiary">{t('brands.noBrands')}</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 stagger-children">
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 stagger-children">
{brands.map(brand => {
const displayName = lang === 'ar' && brand.name_ar ? brand.name_ar : brand.name
return (
<div
key={getBrandId(brand)}
className={`bg-white rounded-xl border border-border overflow-hidden hover:shadow-md transition-all ${isSuperadminOrManager ? 'cursor-pointer' : ''}`}
className={`bg-surface rounded-xl border border-border overflow-clip hover:shadow-md transition-all aspect-square flex flex-col ${isSuperadminOrManager ? 'cursor-pointer' : ''}`}
onClick={() => isSuperadminOrManager && openEditBrand(brand)}
>
{/* Logo area */}
<div className="h-32 bg-surface-secondary flex items-center justify-center relative">
<div className="flex-1 bg-surface-secondary flex items-center justify-center relative min-h-0">
{brand.logo ? (
<img
src={`${API_BASE}/uploads/${brand.logo}`}
alt={displayName}
className="w-full h-full object-contain p-4"
loading="lazy"
/>
) : (
<div className="text-4xl">
{brand.icon || <Image className="w-12 h-12 text-text-quaternary" />}
<div className="text-3xl">
{brand.icon || <Image className="w-10 h-10 text-text-quaternary" />}
</div>
)}
{isSuperadminOrManager && (
<div className="absolute top-2 right-2 flex gap-1" onClick={e => e.stopPropagation()}>
<div className="absolute top-1.5 end-1.5 flex gap-1 opacity-0 group-hover:opacity-100" onClick={e => e.stopPropagation()}>
<button
onClick={() => openEditBrand(brand)}
className="p-1.5 bg-white/90 hover:bg-white rounded-lg text-text-tertiary hover:text-text-primary shadow-sm"
className="p-1 bg-white/90 hover:bg-surface rounded-md text-text-tertiary hover:text-text-primary shadow-sm"
title={t('common.edit')}
>
<Edit2 className="w-3.5 h-3.5" />
<Edit2 className="w-3 h-3" />
</button>
<button
onClick={() => { setBrandToDelete(brand); setShowDeleteModal(true) }}
className="p-1.5 bg-white/90 hover:bg-white rounded-lg text-text-tertiary hover:text-red-500 shadow-sm"
className="p-1 bg-white/90 hover:bg-surface rounded-md text-text-tertiary hover:text-red-500 shadow-sm"
title={t('common.delete')}
>
<Trash2 className="w-3.5 h-3.5" />
<Trash2 className="w-3 h-3" />
</button>
</div>
)}
</div>
{/* Card body */}
<div className="p-4">
<div className="flex items-center gap-2 mb-1">
{brand.icon && <span className="text-lg">{brand.icon}</span>}
<h3 className="text-sm font-semibold text-text-primary truncate">{displayName}</h3>
</div>
<div className="flex items-center gap-3 text-[11px] text-text-tertiary">
{brand.name && <span>EN: {brand.name}</span>}
{brand.name_ar && <span>AR: {brand.name_ar}</span>}
<span>Priority: {brand.priority ?? '—'}</span>
<div className="p-3">
<div className="flex items-center gap-1.5 mb-0.5">
{brand.icon && <span className="text-sm">{brand.icon}</span>}
<h3 className="text-xs font-semibold text-text-primary truncate">{displayName}</h3>
</div>
<p className="text-[10px] text-text-tertiary truncate">
{brand.name_ar && lang !== 'ar' ? brand.name_ar : brand.name}
</p>
</div>
</div>
)
@@ -277,6 +270,7 @@ export default function Brands() {
src={`${API_BASE}/uploads/${editingBrand.logo}`}
alt="Logo"
className="h-16 object-contain"
loading="lazy"
/>
</div>
)}
@@ -305,7 +299,16 @@ export default function Brands() {
</div>
)}
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
<div className="flex items-center justify-between pt-4 border-t border-border">
{editingBrand && isSuperadminOrManager ? (
<button
onClick={() => { setShowModal(false); setBrandToDelete(editingBrand); setShowDeleteModal(true) }}
className="px-3 py-2 text-sm font-medium text-red-600 hover:bg-red-50 rounded-lg transition-colors"
>
{t('common.delete')}
</button>
) : <div />}
<div className="flex items-center gap-3">
<button
onClick={() => { setShowModal(false); setEditingBrand(null) }}
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
@@ -321,6 +324,7 @@ export default function Brands() {
</button>
</div>
</div>
</div>
</Modal>
{/* Delete Confirmation */}
+20 -25
View File
@@ -153,11 +153,7 @@ export default function Budgets() {
return (
<div className="space-y-6 animate-fade-in">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-text-primary">{t('budgets.title')}</h1>
<p className="text-sm text-text-tertiary mt-0.5">{t('budgets.subtitle')}</p>
</div>
<div className="flex items-center justify-end">
{canManageFinance && (
<button
onClick={() => { setEditing(null); setForm(EMPTY_ENTRY); setShowModal(true) }}
@@ -171,19 +167,19 @@ export default function Budgets() {
{/* Filters */}
<div className="flex items-center gap-3 flex-wrap">
<div className="relative flex-1 min-w-[200px] max-w-xs">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<Search className="absolute start-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<input
type="text"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder={t('budgets.searchEntries')}
className="w-full pl-9 pr-3 py-2 text-sm border border-border rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
className="w-full ps-9 pe-3 py-2 text-sm border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
/>
</div>
<select
value={filterCategory}
onChange={e => setFilterCategory(e.target.value)}
className="px-3 py-2 text-sm border border-border rounded-lg bg-white focus:outline-none"
className="px-3 py-2 text-sm border border-border rounded-lg bg-surface focus:outline-none"
>
<option value="">{t('budgets.allCategories')}</option>
{CATEGORIES.map(c => <option key={c.value} value={c.value}>{c.label}</option>)}
@@ -191,7 +187,7 @@ export default function Budgets() {
<select
value={filterDestination}
onChange={e => setFilterDestination(e.target.value)}
className="px-3 py-2 text-sm border border-border rounded-lg bg-white focus:outline-none"
className="px-3 py-2 text-sm border border-border rounded-lg bg-surface focus:outline-none"
>
<option value="">{t('budgets.allDestinations')}</option>
{DESTINATIONS.map(d => <option key={d.value} value={d.value}>{t(d.labelKey)}</option>)}
@@ -206,7 +202,7 @@ export default function Budgets() {
className={`px-3 py-1.5 text-xs font-medium transition-colors ${
filterType === opt.value
? opt.value === 'expense' ? 'bg-red-500 text-white' : opt.value === 'income' ? 'bg-emerald-500 text-white' : 'bg-brand-primary text-white'
: 'bg-white text-text-secondary hover:bg-surface-secondary'
: 'bg-surface text-text-secondary hover:bg-surface-secondary'
}`}
>
{opt.label}
@@ -215,7 +211,7 @@ export default function Budgets() {
</div>
{filteredEntries.length > 0 && (
<div className="ml-auto flex items-center gap-3 text-sm text-text-tertiary">
<div className="ms-auto flex items-center gap-3 text-sm text-text-tertiary">
<span>{filteredEntries.length} {filteredEntries.length === 1 ? 'entry' : 'entries'}</span>
<span className="text-emerald-600 font-semibold">+{totalIncome.toLocaleString()}</span>
{totalExpenseAmt > 0 && <span className="text-red-500 font-semibold">-{totalExpenseAmt.toLocaleString()}</span>}
@@ -235,12 +231,12 @@ export default function Budgets() {
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-surface-secondary">
<th className="px-4 py-3 text-left text-xs font-medium text-text-tertiary">{t('budgets.label')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-text-tertiary">{t('budgets.source')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-text-tertiary">{t('budgets.destination')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-text-tertiary">{t('budgets.linkedTo')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-text-tertiary">{t('budgets.date')}</th>
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">{t('budgets.amount')}</th>
<th className="px-4 py-3 text-start text-xs font-medium text-text-tertiary">{t('budgets.label')}</th>
<th className="px-4 py-3 text-start text-xs font-medium text-text-tertiary">{t('budgets.source')}</th>
<th className="px-4 py-3 text-start text-xs font-medium text-text-tertiary">{t('budgets.destination')}</th>
<th className="px-4 py-3 text-start text-xs font-medium text-text-tertiary">{t('budgets.linkedTo')}</th>
<th className="px-4 py-3 text-start text-xs font-medium text-text-tertiary">{t('budgets.date')}</th>
<th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">{t('budgets.amount')}</th>
{canManageFinance && <th className="px-4 py-3 w-20" />}
</tr>
</thead>
@@ -289,7 +285,7 @@ export default function Budgets() {
<td className="px-4 py-3 text-text-secondary whitespace-nowrap">
{entry.date_received ? format(new Date(entry.date_received), 'MMM d, yyyy') : '--'}
</td>
<td className={`px-4 py-3 text-right font-semibold whitespace-nowrap ${
<td className={`px-4 py-3 text-end font-semibold whitespace-nowrap ${
(entry.type || 'income') === 'expense' ? 'text-red-500' : 'text-emerald-600'
}`}>
{(entry.type || 'income') === 'expense' ? '-' : '+'}{Number(entry.amount).toLocaleString()} {currencySymbol}
@@ -332,7 +328,7 @@ export default function Budgets() {
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium border-2 transition-all ${
form.type === 'income'
? 'border-emerald-500 bg-emerald-50 text-emerald-700'
: 'border-border bg-white text-text-secondary hover:bg-surface-secondary'
: 'border-border bg-surface text-text-secondary hover:bg-surface-secondary'
}`}
>
<TrendingUp className="w-4 h-4" />
@@ -344,7 +340,7 @@ export default function Budgets() {
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium border-2 transition-all ${
form.type === 'expense'
? 'border-red-500 bg-red-50 text-red-700'
: 'border-border bg-white text-text-secondary hover:bg-surface-secondary'
: 'border-border bg-surface text-text-secondary hover:bg-surface-secondary'
}`}
>
<TrendingDown className="w-4 h-4" />
@@ -410,7 +406,6 @@ export default function Budgets() {
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('budgets.category')}</label>
<select
@@ -421,14 +416,15 @@ export default function Budgets() {
{CATEGORIES.map(c => <option key={c.value} value={c.value}>{c.label}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('budgets.linkedTo')}</label>
<div className="flex gap-2">
<div className="grid grid-cols-2 gap-3">
<select
value={form.campaign_id}
onChange={e => setForm(f => ({ ...f, campaign_id: e.target.value, project_id: '' }))}
disabled={!!form.project_id}
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg focus:outline-none disabled:opacity-50 disabled:bg-surface-secondary"
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none disabled:opacity-50 disabled:bg-surface-secondary"
>
<option value="">{t('budgets.noCampaign')}</option>
{campaigns.map(c => <option key={c._id || c.id} value={c._id || c.id}>{c.name}</option>)}
@@ -437,14 +433,13 @@ export default function Budgets() {
value={form.project_id}
onChange={e => setForm(f => ({ ...f, project_id: e.target.value, campaign_id: '' }))}
disabled={!!form.campaign_id}
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg focus:outline-none disabled:opacity-50 disabled:bg-surface-secondary"
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none disabled:opacity-50 disabled:bg-surface-secondary"
>
<option value="">{t('budgets.noProject')}</option>
{projects.map(p => <option key={p._id || p.id} value={p._id || p.id}>{p.name}</option>)}
</select>
</div>
</div>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('budgets.notes')}</label>
+72 -131
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useContext } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { ArrowLeft, Plus, Edit2, Trash2, DollarSign, Eye, MousePointer, Target, TrendingUp, FileText, Megaphone, Search, Globe, Pencil, Users, X, UserPlus, MessageCircle, Settings } from 'lucide-react'
import { ArrowLeft, Plus, Edit2, Trash2, DollarSign, Eye, MousePointer, Target, TrendingUp, FileText, Megaphone, Search, Globe, Users, X, MessageCircle, Settings } from 'lucide-react'
import { format } from 'date-fns'
import { AppContext } from '../App'
import { useAuth } from '../contexts/AuthContext'
@@ -14,7 +14,6 @@ import BudgetBar from '../components/BudgetBar'
import CommentsSection from '../components/CommentsSection'
import CampaignDetailPanel from '../components/CampaignDetailPanel'
import TrackDetailPanel from '../components/TrackDetailPanel'
import PostDetailPanel from '../components/PostDetailPanel'
const TRACK_TYPES = {
organic_social: { label: 'Organic Social', icon: Megaphone, color: 'text-green-600 bg-green-50', hasBudget: false },
@@ -26,21 +25,11 @@ const TRACK_TYPES = {
const TRACK_STATUSES = ['planned', 'active', 'paused', 'completed']
function MetricBox({ label, value, icon: Icon, color = 'text-text-primary' }) {
return (
<div className="text-center">
<Icon className={`w-4 h-4 mx-auto mb-0.5 ${color}`} />
<div className={`text-base font-bold ${color}`}>{value ?? '—'}</div>
<div className="text-[10px] text-text-tertiary">{label}</div>
</div>
)
}
export default function CampaignDetail() {
const { id } = useParams()
const navigate = useNavigate()
const { brands, getBrandName, teamMembers } = useContext(AppContext)
const { lang, currencySymbol } = useLanguage()
const { t, lang, currencySymbol } = useLanguage()
const { permissions, user } = useAuth()
const isSuperadmin = user?.role === 'superadmin'
const [campaign, setCampaign] = useState(null)
@@ -56,7 +45,6 @@ export default function CampaignDetail() {
const [budgetValue, setBudgetValue] = useState('')
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [trackToDelete, setTrackToDelete] = useState(null)
const [selectedPost, setSelectedPost] = useState(null)
const [showDiscussion, setShowDiscussion] = useState(false)
const [allCampaigns, setAllCampaigns] = useState([])
@@ -71,7 +59,7 @@ export default function CampaignDetail() {
useEffect(() => { loadAll() }, [id])
useEffect(() => {
api.get('/campaigns').then(r => setAllCampaigns(Array.isArray(r) ? r : (r.data || []))).catch(() => {})
api.get('/campaigns').then(r => setAllCampaigns(Array.isArray(r) ? r : [])).catch(() => {})
}, [])
const loadAll = async () => {
@@ -82,10 +70,10 @@ export default function CampaignDetail() {
api.get(`/campaigns/${id}/posts`),
api.get(`/campaigns/${id}/assignments`),
])
setCampaign(campRes.data || campRes || null)
setTracks(tracksRes.data || tracksRes || [])
setPosts(postsRes.data || postsRes || [])
setAssignments(Array.isArray(assignRes) ? assignRes : (assignRes.data || []))
setCampaign(campRes)
setTracks(Array.isArray(tracksRes) ? tracksRes : [])
setPosts(Array.isArray(postsRes) ? postsRes : [])
setAssignments(Array.isArray(assignRes) ? assignRes : [])
} catch (err) {
console.error('Failed to load campaign:', err)
} finally {
@@ -96,7 +84,7 @@ export default function CampaignDetail() {
const loadUsersForAssign = async () => {
try {
const users = await api.get('/users/team?all=true')
setAllUsers(Array.isArray(users) ? users : (users.data || []))
setAllUsers(Array.isArray(users) ? users : [])
} catch (err) {
console.error('Failed to load users:', err)
}
@@ -163,21 +151,6 @@ export default function CampaignDetail() {
loadAll()
}
const handlePostPanelSave = async (postId, data) => {
if (postId) {
await api.patch(`/posts/${postId}`, data)
} else {
await api.post('/posts', data)
}
loadAll()
}
const handlePostPanelDelete = async (postId) => {
await api.delete(`/posts/${postId}`)
setSelectedPost(null)
loadAll()
}
const deleteTrack = async (trackId) => {
setTrackToDelete(trackId)
setShowDeleteConfirm(true)
@@ -211,7 +184,7 @@ export default function CampaignDetail() {
if (!campaign) {
return (
<div className="text-center py-12 text-text-tertiary">
Campaign not found. <button onClick={() => navigate('/campaigns')} className="text-brand-primary underline">Go back</button>
{t('campaigns.notFound')} <button onClick={() => navigate('/campaigns')} className="text-brand-primary underline">{t('common.goBack')}</button>
</div>
)
}
@@ -244,9 +217,6 @@ export default function CampaignDetail() {
{campaign.start_date && campaign.end_date && (
<span>{format(new Date(campaign.start_date), 'MMM d')} {format(new Date(campaign.end_date), 'MMM d, yyyy')}</span>
)}
<span>
Budget: {campaign.budget > 0 ? `${campaign.budget.toLocaleString()} ${currencySymbol}` : 'Not set'}
</span>
{campaign.platforms && campaign.platforms.length > 0 && (
<PlatformIcons platforms={campaign.platforms} size={16} />
)}
@@ -263,109 +233,73 @@ export default function CampaignDetail() {
}`}
>
<MessageCircle className="w-4 h-4" />
Discussion
{t('campaigns.discussion')}
</button>
{canSetBudget && (
<button
onClick={() => { setBudgetValue(campaign.budget || ''); setEditingBudget(true) }}
className="flex items-center gap-1.5 px-3 py-2 bg-surface-tertiary text-text-secondary hover:bg-surface-tertiary/80 hover:text-text-primary rounded-lg text-sm font-medium transition-colors"
>
<DollarSign className="w-4 h-4" />
Budget
</button>
)}
{canManage && (
<button
onClick={() => setPanelCampaign(campaign)}
className="flex items-center gap-1.5 px-3 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm transition-colors"
>
<Settings className="w-4 h-4" />
Edit
{t('common.edit')}
</button>
)}
</div>
</div>
{/* Assigned Team */}
<div className="bg-white rounded-xl border border-border p-5">
{/* Budget Card */}
<div className="bg-surface rounded-xl border border-border p-5">
<div className="flex items-center justify-between mb-3">
<h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium flex items-center gap-1.5">
<Users className="w-3.5 h-3.5" /> Assigned Team
</h3>
{canAssign && (
<button
onClick={openAssignModal}
className="flex items-center gap-1.5 px-3 py-1.5 bg-brand-primary text-white rounded-lg text-xs font-medium hover:bg-brand-primary-light"
>
<UserPlus className="w-3.5 h-3.5" /> Assign Members
<h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium">{t('campaigns.budget')}</h3>
{canSetBudget && (
<button onClick={() => { setBudgetValue(campaign.budget || ''); setEditingBudget(true) }}
className="text-xs text-brand-primary hover:text-brand-primary-light font-medium">
{t('common.edit')}
</button>
)}
</div>
{assignments.length === 0 ? (
<p className="text-xs text-text-tertiary py-2">No team members assigned yet.</p>
) : (
<div className="flex flex-wrap gap-2">
{assignments.map(a => (
<div key={a.user_id} className="flex items-center gap-2 bg-surface-secondary rounded-full pl-1 pr-2 py-1">
<div className="w-6 h-6 rounded-full bg-brand-primary/10 text-brand-primary flex items-center justify-center text-[10px] font-bold shrink-0">
{a.user_avatar ? (
<img src={a.user_avatar} className="w-full h-full rounded-full object-cover" alt="" />
) : (
getInitials(a.user_name)
)}
</div>
<span className="text-xs font-medium text-text-primary">{a.user_name}</span>
{canAssign && (
<button
onClick={() => removeAssignment(a.user_id)}
className="p-0.5 rounded-full hover:bg-red-100 text-text-tertiary hover:text-red-500"
>
<X className="w-3 h-3" />
</button>
)}
</div>
))}
</div>
)}
</div>
{/* Aggregate Metrics */}
{tracks.length > 0 && (
<div className="bg-white rounded-xl border border-border p-5">
<h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium mb-4">Campaign Totals (from tracks)</h3>
<div className="grid grid-cols-3 md:grid-cols-6 gap-4">
<MetricBox icon={DollarSign} label="Allocated" value={`${totalAllocated.toLocaleString()}`} color="text-blue-600" />
<MetricBox icon={TrendingUp} label="Spent" value={`${totalSpent.toLocaleString()}`} color="text-amber-600" />
<MetricBox icon={Eye} label="Impressions" value={totalImpressions.toLocaleString()} color="text-purple-600" />
<MetricBox icon={MousePointer} label="Clicks" value={totalClicks.toLocaleString()} color="text-green-600" />
<MetricBox icon={Target} label="Conversions" value={totalConversions.toLocaleString()} color="text-red-600" />
<MetricBox icon={DollarSign} label="Revenue" value={`${totalRevenue.toLocaleString()}`} color="text-emerald-600" />
<div className="flex items-baseline gap-2 mb-3">
<span className="text-2xl font-bold text-text-primary">
{totalAllocated.toLocaleString()} {currencySymbol}
</span>
<span className="text-sm text-text-tertiary">{t('finance.allocated')}</span>
</div>
{totalAllocated > 0 && (
<div className="mt-4">
<BudgetBar budget={totalAllocated} spent={totalSpent} height="h-2" />
<>
<BudgetBar budget={totalAllocated} spent={totalSpent} height="h-2.5" />
<div className="flex justify-between mt-2 text-xs text-text-tertiary">
<span>{totalSpent.toLocaleString()} {currencySymbol} {t('dashboard.spent')}</span>
<span>{(totalAllocated - totalSpent).toLocaleString()} {currencySymbol} {t('dashboard.remaining')}</span>
</div>
</>
)}
{(totalImpressions > 0 || totalClicks > 0) && (
<div className="flex items-center gap-4 mt-4 pt-3 border-t border-border-light text-xs text-text-secondary">
<span><Eye className="w-3.5 h-3.5 inline me-1" />{totalImpressions.toLocaleString()}</span>
<span><MousePointer className="w-3.5 h-3.5 inline me-1" />{totalClicks.toLocaleString()}</span>
{totalConversions > 0 && <span><Target className="w-3.5 h-3.5 inline me-1" />{totalConversions.toLocaleString()}</span>}
{totalRevenue > 0 && <span><DollarSign className="w-3.5 h-3.5 inline me-1" />{totalRevenue.toLocaleString()} {currencySymbol}</span>}
</div>
)}
</div>
)}
{/* Tracks */}
<div className="bg-white rounded-xl border border-border overflow-hidden">
<div className="bg-surface rounded-xl border border-border overflow-clip">
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
<h3 className="font-semibold text-text-primary">Tracks</h3>
<h3 className="font-semibold text-text-primary">{t('campaigns.tracks')}</h3>
{canManage && (
<button
onClick={() => { setPanelTrack({}); setTrackScrollToMetrics(false) }}
className="flex items-center gap-1.5 px-3 py-1.5 bg-brand-primary text-white rounded-lg text-xs font-medium hover:bg-brand-primary-light"
>
<Plus className="w-3.5 h-3.5" /> Add Track
<Plus className="w-3.5 h-3.5" /> {t('campaigns.addTrack')}
</button>
)}
</div>
{tracks.length === 0 ? (
<div className="py-12 text-center text-sm text-text-tertiary">
No tracks yet. Add organic, paid, or SEO tracks to organize this campaign.
{t('campaigns.noTracks')}
</div>
) : (
<div className="divide-y divide-border-light">
@@ -403,9 +337,9 @@ export default function CampaignDetail() {
{/* Quick metrics */}
{(track.impressions > 0 || track.clicks > 0 || track.conversions > 0) && (
<div className="flex items-center gap-3 mt-1.5 text-[10px] text-text-tertiary">
{track.impressions > 0 && <span>👁 {track.impressions.toLocaleString()}</span>}
{track.clicks > 0 && <span>🖱 {track.clicks.toLocaleString()}</span>}
{track.conversions > 0 && <span>🎯 {track.conversions.toLocaleString()}</span>}
{track.impressions > 0 && <span><Eye className="w-3 h-3 inline" /> {track.impressions.toLocaleString()}</span>}
{track.clicks > 0 && <span><MousePointer className="w-3 h-3 inline" /> {track.clicks.toLocaleString()}</span>}
{track.conversions > 0 && <span><Target className="w-3 h-3 inline" /> {track.conversions.toLocaleString()}</span>}
{track.clicks > 0 && track.budget_spent > 0 && (
<span>CPC: {(track.budget_spent / track.clicks).toFixed(2)} {currencySymbol}</span>
)}
@@ -418,7 +352,7 @@ export default function CampaignDetail() {
{/* Linked posts count */}
{trackPosts.length > 0 && (
<div className="text-[10px] text-text-tertiary mt-1">
📝 {trackPosts.length} post{trackPosts.length !== 1 ? 's' : ''} linked
<FileText className="w-3 h-3 inline" /> {trackPosts.length} {t('campaigns.postsLinked')}
</div>
)}
@@ -461,21 +395,41 @@ export default function CampaignDetail() {
)}
</div>
{/* Team */}
{(assignments.length > 0 || canAssign) && (
<div className="flex items-center gap-3">
<span className="text-xs text-text-tertiary font-medium">{t('campaigns.team')}:</span>
<div className="flex -space-x-1.5">
{assignments.slice(0, 6).map(a => (
<div key={a.user_id} className="w-7 h-7 rounded-full bg-brand-primary/10 text-brand-primary flex items-center justify-center text-[10px] font-bold border-2 border-surface" title={a.user_name}>
{a.user_avatar ? <img src={a.user_avatar} className="w-full h-full rounded-full object-cover" alt="" loading="lazy" /> : getInitials(a.user_name)}
</div>
))}
{assignments.length > 6 && <div className="w-7 h-7 rounded-full bg-surface-tertiary flex items-center justify-center text-[10px] text-text-tertiary font-medium border-2 border-surface">+{assignments.length - 6}</div>}
</div>
{canAssign && (
<button onClick={openAssignModal} className="text-xs text-brand-primary hover:text-brand-primary-light font-medium">
{t('campaigns.assignMembers')}
</button>
)}
</div>
)}
{/* Linked Posts */}
{posts.length > 0 && (
<div className="bg-white rounded-xl border border-border overflow-hidden">
<div className="bg-surface rounded-xl border border-border overflow-clip">
<div className="px-5 py-4 border-b border-border">
<h3 className="font-semibold text-text-primary">Linked Posts ({posts.length})</h3>
<h3 className="font-semibold text-text-primary">{t('campaigns.linkedPosts')} ({posts.length})</h3>
</div>
<div className="divide-y divide-border-light">
{posts.map(post => (
<div
key={post.id}
onClick={() => setSelectedPost(post)}
onClick={() => navigate(`/posts/${post._id || post.id || post.Id}`)}
className="flex items-center gap-3 px-5 py-3 hover:bg-surface-secondary cursor-pointer transition-colors"
>
{post.thumbnail_url && (
<img src={post.thumbnail_url} alt="" className="w-10 h-10 rounded-lg object-cover shrink-0" />
<img src={post.thumbnail_url} alt="" className="w-10 h-10 rounded-lg object-cover shrink-0" loading="lazy" />
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
@@ -501,11 +455,11 @@ export default function CampaignDetail() {
{/* ─── DISCUSSION SIDEBAR ─── */}
{showDiscussion && (
<div className="w-[340px] shrink-0 bg-white rounded-xl border border-border flex flex-col self-start sticky top-4" style={{ maxHeight: 'calc(100vh - 6rem)' }}>
<div className="w-[340px] shrink-0 bg-surface rounded-xl border border-border flex flex-col self-start sticky top-4" style={{ maxHeight: 'calc(100vh - 6rem)' }}>
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-1.5">
<MessageCircle className="w-4 h-4" />
Discussion
{t('campaigns.discussion')}
</h3>
<button onClick={() => setShowDiscussion(false)} className="p-1 hover:bg-surface-tertiary rounded-lg text-text-tertiary">
<X className="w-4 h-4" />
@@ -557,7 +511,7 @@ export default function CampaignDetail() {
/>
<div className="w-7 h-7 rounded-full bg-brand-primary/10 text-brand-primary flex items-center justify-center text-[10px] font-bold shrink-0">
{u.avatar ? (
<img src={u.avatar} className="w-full h-full rounded-full object-cover" alt="" />
<img src={u.avatar} className="w-full h-full rounded-full object-cover" alt="" loading="lazy" />
) : (
getInitials(u.name)
)}
@@ -618,19 +572,6 @@ export default function CampaignDetail() {
</div>
</Modal>
{/* Post Detail Panel */}
{selectedPost && (
<PostDetailPanel
post={selectedPost}
onClose={() => setSelectedPost(null)}
onSave={handlePostPanelSave}
onDelete={handlePostPanelDelete}
brands={brands}
teamMembers={teamMembers}
campaigns={allCampaigns}
/>
)}
{/* Campaign Edit Panel */}
{panelCampaign && (
<CampaignDetailPanel
+117 -16
View File
@@ -12,8 +12,14 @@ import BrandBadge from '../components/BrandBadge'
import BudgetBar from '../components/BudgetBar'
import InteractiveTimeline from '../components/InteractiveTimeline'
import CampaignDetailPanel from '../components/CampaignDetailPanel'
import Modal from '../components/Modal'
import { SkeletonStatCard, SkeletonTable } from '../components/SkeletonLoader'
const EMPTY_CAMPAIGN = {
name: '', description: '', brand_id: '', status: 'planning',
start_date: '', end_date: '', budget: '', team_id: '',
}
function ROIBadge({ revenue, spent }) {
if (!spent || spent <= 0) return null
const roi = ((revenue - spent) / spent * 100).toFixed(0)
@@ -36,21 +42,24 @@ function MetricCard({ icon: Icon, label, value, color = 'text-text-primary' }) {
}
export default function Campaigns() {
const { brands, getBrandName } = useContext(AppContext)
const { lang, currencySymbol } = useLanguage()
const { brands, getBrandName, teams } = useContext(AppContext)
const { t, lang, currencySymbol } = useLanguage()
const { permissions } = useAuth()
const navigate = useNavigate()
const [campaigns, setCampaigns] = useState([])
const [loading, setLoading] = useState(true)
const [panelCampaign, setPanelCampaign] = useState(null)
const [filters, setFilters] = useState({ brand: '', status: '' })
const [showCreateModal, setShowCreateModal] = useState(false)
const [createForm, setCreateForm] = useState({ ...EMPTY_CAMPAIGN })
const [createSaving, setCreateSaving] = useState(false)
useEffect(() => { loadCampaigns() }, [])
const loadCampaigns = async () => {
try {
const res = await api.get('/campaigns')
setCampaigns(res.data || res || [])
setCampaigns(Array.isArray(res) ? res : [])
} catch (err) {
console.error('Failed to load campaigns:', err)
} finally {
@@ -73,7 +82,34 @@ export default function Campaigns() {
}
const openNew = () => {
setPanelCampaign({ status: 'planning', platforms: [] })
setCreateForm({ ...EMPTY_CAMPAIGN })
setShowCreateModal(true)
}
const handleCreate = async () => {
setCreateSaving(true)
try {
const data = {
name: createForm.name,
description: createForm.description,
brand_id: createForm.brand_id ? Number(createForm.brand_id) : null,
status: createForm.status,
start_date: createForm.start_date || null,
end_date: createForm.end_date || null,
budget: createForm.budget ? Number(createForm.budget) : null,
team_id: createForm.team_id ? Number(createForm.team_id) : null,
}
const created = await api.post('/campaigns', data)
setShowCreateModal(false)
loadCampaigns()
// Navigate to the new campaign detail page
const id = created?.Id || created?.id || created?._id
if (id) navigate(`/campaigns/${id}`)
} catch (err) {
console.error('Create campaign failed:', err)
} finally {
setCreateSaving(false)
}
}
const filtered = campaigns.filter(c => {
@@ -109,7 +145,7 @@ export default function Campaigns() {
<select
value={filters.brand}
onChange={e => setFilters(f => ({ ...f, brand: e.target.value }))}
className="text-sm border border-border rounded-lg px-3 py-2 bg-white text-text-secondary focus:outline-none"
className="text-sm border border-border rounded-lg px-3 py-2 bg-surface text-text-secondary focus:outline-none"
>
<option value="">All Brands</option>
{brands.map(b => <option key={b.id || b._id} value={b.id || b._id}>{lang === 'ar' && b.name_ar ? b.name_ar : b.name}</option>)}
@@ -118,7 +154,7 @@ export default function Campaigns() {
<select
value={filters.status}
onChange={e => setFilters(f => ({ ...f, status: e.target.value }))}
className="text-sm border border-border rounded-lg px-3 py-2 bg-white text-text-secondary focus:outline-none"
className="text-sm border border-border rounded-lg px-3 py-2 bg-surface text-text-secondary focus:outline-none"
>
<option value="">All Statuses</option>
<option value="planning">Planning</option>
@@ -131,7 +167,7 @@ export default function Campaigns() {
{permissions?.canCreateCampaigns && (
<button
onClick={openNew}
className="flex items-center gap-2 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm ml-auto"
className="flex items-center gap-2 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm ms-auto"
>
<Plus className="w-4 h-4" />
New Campaign
@@ -142,7 +178,7 @@ export default function Campaigns() {
{/* Summary Cards */}
{(totalBudget > 0 || totalSpent > 0) && (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3 stagger-children">
<div className="bg-white rounded-xl border border-border p-4">
<div className="bg-surface rounded-xl border border-border p-4">
<div className="flex items-center gap-2 mb-1">
<DollarSign className="w-4 h-4 text-blue-500" />
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Budget</span>
@@ -150,7 +186,7 @@ export default function Campaigns() {
<div className="text-lg font-bold text-text-primary">{totalBudget.toLocaleString()}</div>
<div className="text-[10px] text-text-tertiary">{currencySymbol} total</div>
</div>
<div className="bg-white rounded-xl border border-border p-4">
<div className="bg-surface rounded-xl border border-border p-4">
<div className="flex items-center gap-2 mb-1">
<TrendingUp className="w-4 h-4 text-amber-500" />
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Spent</span>
@@ -158,28 +194,28 @@ export default function Campaigns() {
<div className="text-lg font-bold text-text-primary">{totalSpent.toLocaleString()}</div>
<div className="text-[10px] text-text-tertiary">{currencySymbol} spent</div>
</div>
<div className="bg-white rounded-xl border border-border p-4">
<div className="bg-surface rounded-xl border border-border p-4">
<div className="flex items-center gap-2 mb-1">
<Eye className="w-4 h-4 text-purple-500" />
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Impressions</span>
</div>
<div className="text-lg font-bold text-text-primary">{totalImpressions.toLocaleString()}</div>
</div>
<div className="bg-white rounded-xl border border-border p-4">
<div className="bg-surface rounded-xl border border-border p-4">
<div className="flex items-center gap-2 mb-1">
<MousePointer className="w-4 h-4 text-green-500" />
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Clicks</span>
</div>
<div className="text-lg font-bold text-text-primary">{totalClicks.toLocaleString()}</div>
</div>
<div className="bg-white rounded-xl border border-border p-4">
<div className="bg-surface rounded-xl border border-border p-4">
<div className="flex items-center gap-2 mb-1">
<Target className="w-4 h-4 text-red-500" />
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Conversions</span>
</div>
<div className="text-lg font-bold text-text-primary">{totalConversions.toLocaleString()}</div>
</div>
<div className="bg-white rounded-xl border border-border p-4">
<div className="bg-surface rounded-xl border border-border p-4">
<div className="flex items-center gap-2 mb-1">
<BarChart3 className="w-4 h-4 text-emerald-500" />
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Revenue</span>
@@ -202,6 +238,7 @@ export default function Campaigns() {
status: campaign.status,
assigneeName: campaign.brandName || campaign.brand_name,
tags: campaign.platforms || [],
color: campaign.color,
})}
onDateChange={async (campaignId, { startDate, endDate }) => {
try {
@@ -212,13 +249,22 @@ export default function Campaigns() {
loadCampaigns()
}
}}
onColorChange={async (campaignId, color) => {
try {
await api.patch(`/campaigns/${campaignId}`, { color: color || '' })
} catch (err) {
console.error('Color update failed:', err)
} finally {
loadCampaigns()
}
}}
onItemClick={(campaign) => {
navigate(`/campaigns/${campaign._id || campaign.id}`)
}}
/>
{/* Campaign list */}
<div className="bg-white rounded-xl border border-border overflow-hidden">
<div className="bg-surface rounded-xl border border-border overflow-clip">
<div className="px-5 py-4 border-b border-border">
<h3 className="font-semibold text-text-primary">All Campaigns</h3>
</div>
@@ -262,7 +308,7 @@ export default function Campaigns() {
)}
</div>
</div>
<div className="text-right shrink-0">
<div className="text-end shrink-0">
<StatusBadge status={campaign.status} size="xs" />
<div className="text-xs text-text-tertiary mt-1">
{campaign.startDate && campaign.endDate ? (
@@ -285,7 +331,62 @@ export default function Campaigns() {
</div>
</div>
{/* Campaign Panel */}
{/* Create Campaign Modal */}
<Modal isOpen={showCreateModal} onClose={() => setShowCreateModal(false)} title={t('campaigns.newCampaign') || 'New Campaign'} size="md">
<div className="space-y-4">
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.name')} *</label>
<input type="text" value={createForm.name} onChange={e => setCreateForm(f => ({ ...f, name: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary" autoFocus />
</div>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.description')}</label>
<textarea value={createForm.description} onChange={e => setCreateForm(f => ({ ...f, description: e.target.value }))} rows={2}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary resize-none" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.brand')}</label>
<select value={createForm.brand_id} onChange={e => setCreateForm(f => ({ ...f, brand_id: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary">
<option value="">{t('posts.allBrands')}</option>
{brands.map(b => <option key={b.id || b._id} value={b.id || b._id}>{lang === 'ar' && b.name_ar ? b.name_ar : b.name}</option>)}
</select>
</div>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('common.team')}</label>
<select value={createForm.team_id} onChange={e => setCreateForm(f => ({ ...f, team_id: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary">
<option value="">{t('common.noTeam')}</option>
{(teams || []).map(team => <option key={team.id || team._id} value={team.id || team._id}>{team.name}</option>)}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.startDate')}</label>
<input type="date" value={createForm.start_date} onChange={e => setCreateForm(f => ({ ...f, start_date: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary" />
</div>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.endDate')}</label>
<input type="date" value={createForm.end_date} onChange={e => setCreateForm(f => ({ ...f, end_date: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary" />
</div>
</div>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.budget')}</label>
<input type="number" value={createForm.budget} onChange={e => setCreateForm(f => ({ ...f, budget: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary" placeholder="0" />
</div>
<button onClick={handleCreate} disabled={!createForm.name || createSaving}
className={`w-full px-4 py-2.5 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm ${createSaving ? 'btn-loading' : ''}`}>
{t('campaigns.newCampaign') || 'Create Campaign'}
</button>
</div>
</Modal>
{/* Campaign Panel (edit only) */}
{panelCampaign && (
<CampaignDetailPanel
campaign={panelCampaign}
+158 -199
View File
@@ -1,11 +1,11 @@
import { useContext, useEffect, useState, useMemo } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { format, isAfter, isBefore, addDays } from 'date-fns'
import { FileText, Megaphone, AlertTriangle, ArrowRight, Clock, Wallet, TrendingUp, TrendingDown, DollarSign, Landmark, CheckSquare, FolderKanban } from 'lucide-react'
import { FileText, Megaphone, AlertTriangle, ArrowRight, Clock, Landmark, CheckSquare, FolderKanban } from 'lucide-react'
import { AppContext } from '../App'
import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../i18n/LanguageContext'
import { api, PRIORITY_CONFIG } from '../utils/api'
import StatCard from '../components/StatCard'
import StatusBadge from '../components/StatusBadge'
import BrandBadge from '../components/BrandBadge'
import DatePresetPicker from '../components/DatePresetPicker'
@@ -17,24 +17,17 @@ function getBudgetBarColor(percentage) {
return 'bg-emerald-500'
}
function FinanceMini({ finance }) {
function BudgetSummary({ finance }) {
const { t, currencySymbol } = useLanguage()
if (!finance) return null
const totalReceived = finance.totalReceived || 0
const spent = finance.spent || 0
const remaining = finance.remaining || 0
const roi = finance.roi || 0
const totalExpenses = finance.totalExpenses || 0
const campaignBudget = finance.totalCampaignBudget || 0
const projectBudget = finance.totalProjectBudget || 0
const unallocated = finance.unallocated ?? (totalReceived - campaignBudget - projectBudget)
const pct = totalReceived > 0 ? (spent / totalReceived) * 100 : 0
const mainAvailable = finance.mainAvailable != null ? finance.mainAvailable : (finance.remaining || 0)
const consumed = totalReceived - mainAvailable
const pct = totalReceived > 0 ? (consumed / totalReceived) * 100 : 0
const barColor = getBudgetBarColor(pct)
const campPct = totalReceived > 0 ? (campaignBudget / totalReceived) * 100 : 0
const projPct = totalReceived > 0 ? (projectBudget / totalReceived) * 100 : 0
return (
<div className="bg-white rounded-xl border border-border p-5">
<div className="bg-surface rounded-xl border border-border p-5">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-text-primary">{t('dashboard.budgetOverview')}</h3>
<Link to="/finance" className="text-sm text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1">
@@ -48,58 +41,15 @@ function FinanceMini({ finance }) {
</div>
) : (
<>
{/* Spending bar */}
<div className="mb-3">
<div className="flex justify-between text-xs text-text-tertiary mb-1">
<span>{spent.toLocaleString()} {currencySymbol} {t('dashboard.spent')}</span>
<span>{consumed.toLocaleString()} {currencySymbol} {t('dashboard.spent')}</span>
<span>{totalReceived.toLocaleString()} {currencySymbol} {t('dashboard.received')}</span>
</div>
<div className="h-2.5 bg-surface-tertiary rounded-full overflow-hidden">
<div className={`h-full ${barColor} rounded-full transition-all`} style={{ width: `${Math.min(pct, 100)}%` }} />
</div>
</div>
{/* Allocation bar */}
{(campaignBudget > 0 || projectBudget > 0) && (
<div className="mb-3">
<div className="text-[10px] text-text-tertiary mb-1 font-medium uppercase tracking-wide">Allocation</div>
<div className="h-2 bg-surface-tertiary rounded-full overflow-hidden flex">
{campPct > 0 && <div className="h-full bg-blue-500" style={{ width: `${campPct}%` }} />}
{projPct > 0 && <div className="h-full bg-purple-500" style={{ width: `${projPct}%` }} />}
</div>
<div className="flex gap-3 mt-1 text-[10px] text-text-tertiary">
{campaignBudget > 0 && <span><span className="inline-block w-1.5 h-1.5 rounded-full bg-blue-500 mr-1" />{campaignBudget.toLocaleString()}</span>}
{projectBudget > 0 && <span><span className="inline-block w-1.5 h-1.5 rounded-full bg-purple-500 mr-1" />{projectBudget.toLocaleString()}</span>}
{unallocated > 0 && <span><span className="inline-block w-1.5 h-1.5 rounded-full bg-gray-300 mr-1" />{unallocated.toLocaleString()} free</span>}
</div>
</div>
)}
{/* Key numbers */}
<div className={`grid ${totalExpenses > 0 ? 'grid-cols-3' : 'grid-cols-2'} gap-3`}>
<div className="text-center p-2 bg-surface-secondary rounded-lg">
<Landmark className="w-4 h-4 mx-auto mb-1 text-emerald-500" />
<div className={`text-sm font-bold ${remaining >= 0 ? 'text-emerald-600' : 'text-red-600'}`}>
{remaining.toLocaleString()}
</div>
<div className="text-[10px] text-text-tertiary">{t('dashboard.remaining')}</div>
</div>
{totalExpenses > 0 && (
<div className="text-center p-2 bg-surface-secondary rounded-lg">
<TrendingDown className="w-4 h-4 mx-auto mb-1 text-red-500" />
<div className="text-sm font-bold text-red-600">
{totalExpenses.toLocaleString()}
</div>
<div className="text-[10px] text-text-tertiary">{t('dashboard.expenses')}</div>
</div>
)}
<div className="text-center p-2 bg-surface-secondary rounded-lg">
<TrendingUp className="w-4 h-4 mx-auto mb-1 text-blue-500" />
<div className={`text-sm font-bold ${roi >= 0 ? 'text-emerald-600' : 'text-red-600'}`}>
{roi.toFixed(0)}%
</div>
<div className="text-[10px] text-text-tertiary">{t('dashboard.roi')}</div>
</div>
<div className={`mt-3 text-sm font-semibold ${mainAvailable >= 0 ? 'text-emerald-600' : 'text-red-600'}`}>
{mainAvailable.toLocaleString()} {currencySymbol} {t('dashboard.remaining')}
</div>
</>
)}
@@ -145,13 +95,6 @@ function ActiveCampaignsList({ campaigns, finance }) {
</div>
)}
</div>
<div className="text-right shrink-0">
{cd.tracks_impressions > 0 && (
<div className="text-[10px] text-text-tertiary">
{cd.tracks_impressions.toLocaleString()} imp. / {cd.tracks_clicks.toLocaleString()} clicks
</div>
)}
</div>
</Link>
)
})}
@@ -161,12 +104,12 @@ function ActiveCampaignsList({ campaigns, finance }) {
}
function MyTasksList({ tasks, currentUserId, navigate, t }) {
const myTasks = tasks
const myTasks = useMemo(() => tasks
.filter(task => {
const assignedId = task.assigned_to_id || task.assignedTo
return assignedId === currentUserId && task.status !== 'done'
})
.slice(0, 5)
.slice(0, 5), [tasks, currentUserId])
return (
<div className="section-card">
@@ -186,10 +129,10 @@ function MyTasksList({ tasks, currentUserId, navigate, t }) {
</div>
) : (
myTasks.map(task => (
<div
<button
key={task._id || task.id}
onClick={() => navigate('/tasks')}
className="flex items-center gap-3 px-5 py-3 hover:bg-surface-secondary transition-colors cursor-pointer"
className="w-full flex items-center gap-3 px-5 py-3 hover:bg-surface-secondary transition-colors text-start"
>
<div className={`w-2 h-2 rounded-full shrink-0 ${(PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium).color}`} />
<div className="flex-1 min-w-0">
@@ -202,7 +145,7 @@ function MyTasksList({ tasks, currentUserId, navigate, t }) {
{format(new Date(task.dueDate), 'MMM d')}
</div>
)}
</div>
</button>
))
)}
</div>
@@ -260,10 +203,85 @@ function ProjectProgress({ projects, tasks, t }) {
)
}
function ActivityFeed({ posts, deadlines, navigate, t }) {
const [tab, setTab] = useState('posts')
const hasPosts = posts.length > 0
const hasDeadlines = deadlines.length > 0
return (
<div className="section-card">
<div className="section-card-header flex items-center justify-between">
<div className="flex items-center gap-1">
<button
onClick={() => setTab('posts')}
className={`px-3 py-1 text-sm font-medium rounded-md transition-colors ${
tab === 'posts' ? 'bg-brand-primary/10 text-brand-primary' : 'text-text-tertiary hover:text-text-secondary'
}`}
>
{t('dashboard.recentPosts')}
</button>
<button
onClick={() => setTab('deadlines')}
className={`px-3 py-1 text-sm font-medium rounded-md transition-colors ${
tab === 'deadlines' ? 'bg-brand-primary/10 text-brand-primary' : 'text-text-tertiary hover:text-text-secondary'
}`}
>
{t('dashboard.upcomingDeadlines')}
</button>
</div>
<Link to={tab === 'posts' ? '/posts' : '/tasks'} className="text-sm text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1">
{t('dashboard.viewAll')} <ArrowRight className="w-3.5 h-3.5" />
</Link>
</div>
<div className="divide-y divide-border-light">
{tab === 'posts' ? (
!hasPosts ? (
<div className="py-10 text-center text-sm text-text-tertiary">{t('dashboard.noPostsYet')}</div>
) : (
posts.slice(0, 6).map(post => (
<button key={post._id} onClick={() => navigate('/posts')} className="w-full flex items-center gap-3 px-5 py-3 hover:bg-surface-secondary transition-colors text-start">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{post.title}</p>
<div className="flex items-center gap-2 mt-1">
{post.brand && <BrandBadge brand={post.brand} />}
</div>
</div>
<StatusBadge status={post.status} size="xs" />
</button>
))
)
) : (
!hasDeadlines ? (
<div className="py-10 text-center text-sm text-text-tertiary">{t('dashboard.noUpcomingDeadlines')}</div>
) : (
deadlines.map(task => (
<button key={task._id} onClick={() => navigate('/tasks')} className="w-full flex items-center gap-3 px-5 py-3 hover:bg-surface-secondary transition-colors text-start">
<div className={`w-2 h-2 rounded-full ${(PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium).color}`} />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{task.title}</p>
<div className="flex items-center gap-2 mt-0.5">
<StatusBadge status={task.status} size="xs" />
{task.assignedName && <span className="text-xs text-text-tertiary truncate">{task.assignedName}</span>}
</div>
</div>
<div className="flex items-center gap-1 text-xs text-text-tertiary shrink-0">
<Clock className="w-3.5 h-3.5" />
{format(new Date(task.dueDate), 'MMM d')}
</div>
</button>
))
)
)}
</div>
</div>
)
}
export default function Dashboard() {
const { t, currencySymbol } = useLanguage()
const navigate = useNavigate()
const { currentUser, teamMembers } = useContext(AppContext)
const { currentUser } = useContext(AppContext)
const { hasModule } = useAuth()
const [posts, setPosts] = useState([])
const [campaigns, setCampaigns] = useState([])
const [tasks, setTasks] = useState([])
@@ -271,7 +289,6 @@ export default function Dashboard() {
const [finance, setFinance] = useState(null)
const [loading, setLoading] = useState(true)
// Date filtering
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
const [activePreset, setActivePreset] = useState('')
@@ -282,18 +299,29 @@ export default function Dashboard() {
const loadData = async () => {
try {
const [postsRes, campaignsRes, tasksRes, financeRes, projectsRes] = await Promise.allSettled([
api.get('/posts?limit=50&sort=-createdAt'),
api.get('/campaigns'),
api.get('/tasks'),
api.get('/finance/summary'),
api.get('/projects'),
])
setPosts(postsRes.status === 'fulfilled' ? (postsRes.value.data || postsRes.value || []) : [])
setCampaigns(campaignsRes.status === 'fulfilled' ? (campaignsRes.value.data || campaignsRes.value || []) : [])
setTasks(tasksRes.status === 'fulfilled' ? (tasksRes.value.data || tasksRes.value || []) : [])
setFinance(financeRes.status === 'fulfilled' ? (financeRes.value.data || financeRes.value || null) : null)
setProjects(projectsRes.status === 'fulfilled' ? (projectsRes.value.data || projectsRes.value || []) : [])
const fetches = []
if (hasModule('marketing')) {
fetches.push(api.get('/posts?limit=50&sort=-createdAt').then(r => ({ key: 'posts', data: Array.isArray(r) ? r : [] })))
fetches.push(api.get('/campaigns').then(r => ({ key: 'campaigns', data: Array.isArray(r) ? r : [] })))
}
if (hasModule('projects')) {
fetches.push(api.get('/tasks').then(r => ({ key: 'tasks', data: Array.isArray(r) ? r : [] })))
fetches.push(api.get('/projects').then(r => ({ key: 'projects', data: Array.isArray(r) ? r : [] })))
}
if (hasModule('finance')) {
fetches.push(api.get('/finance/summary').then(r => ({ key: 'finance', data: r || null })))
}
const results = await Promise.allSettled(fetches)
results.forEach(r => {
if (r.status !== 'fulfilled') return
const { key, data } = r.value
if (key === 'posts') setPosts(data)
else if (key === 'campaigns') setCampaigns(data)
else if (key === 'tasks') setTasks(data)
else if (key === 'projects') setProjects(data)
else if (key === 'finance') setFinance(data)
})
} catch (err) {
console.error('Dashboard load error:', err)
} finally {
@@ -301,7 +329,6 @@ export default function Dashboard() {
}
}
// Filtered data based on date range
const filteredPosts = useMemo(() => {
if (!dateFrom && !dateTo) return posts
return posts.filter(p => {
@@ -329,7 +356,7 @@ export default function Dashboard() {
t.dueDate && new Date(t.dueDate) < new Date() && t.status !== 'done'
).length
const upcomingDeadlines = filteredTasks
const upcomingDeadlines = useMemo(() => filteredTasks
.filter(t => {
if (!t.dueDate || t.status === 'done') return false
const due = new Date(t.dueDate)
@@ -337,24 +364,27 @@ export default function Dashboard() {
return isAfter(due, now) && isBefore(due, addDays(now, 7))
})
.sort((a, b) => new Date(a.dueDate) - new Date(b.dueDate))
.slice(0, 8)
.slice(0, 6), [filteredTasks])
if (loading) {
return <SkeletonDashboard />
// Inline stat values no card component needed
const stats = []
if (hasModule('marketing')) {
stats.push({ label: t('dashboard.totalPosts'), value: filteredPosts.length, detail: `${filteredPosts.filter(p => p.status === 'published').length} ${t('dashboard.published')}`, icon: FileText, accent: 'text-indigo-600' })
stats.push({ label: t('dashboard.activeCampaigns'), value: activeCampaigns, detail: `${campaigns.length} ${t('dashboard.total')}`, icon: Megaphone, accent: 'text-pink-600' })
}
if (hasModule('projects')) {
stats.push({ label: t('dashboard.overdueTasks'), value: overdueTasks, detail: overdueTasks > 0 ? t('dashboard.needsAttention') : t('dashboard.allOnTrack'), icon: AlertTriangle, accent: overdueTasks > 0 ? 'text-red-600' : 'text-emerald-600' })
}
if (loading) return <SkeletonDashboard />
return (
<div className="space-y-6 animate-fade-in">
{/* Welcome + Date presets */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<h1 className="text-2xl font-bold text-gradient">
<p className="text-lg font-medium text-text-primary">
{t('dashboard.welcomeBack')}, {currentUser?.name || 'there'}
</h1>
<p className="text-text-secondary mt-1">
{t('dashboard.happeningToday')}
</p>
</div>
<DatePresetPicker
activePreset={activePreset}
onSelect={(from, to, key) => { setDateFrom(from); setDateTo(to); setActivePreset(key) }}
@@ -362,122 +392,51 @@ export default function Dashboard() {
/>
</div>
{/* Stats */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 stagger-children">
<StatCard
icon={FileText}
label={t('dashboard.totalPosts')}
value={filteredPosts.length || 0}
subtitle={`${filteredPosts.filter(p => p.status === 'published').length} ${t('dashboard.published')}`}
color="brand-primary"
/>
<StatCard
icon={Megaphone}
label={t('dashboard.activeCampaigns')}
value={activeCampaigns}
subtitle={`${campaigns.length} ${t('dashboard.total')}`}
color="brand-secondary"
/>
<StatCard
icon={Landmark}
label={t('dashboard.budgetRemaining')}
value={`${(finance?.remaining ?? 0).toLocaleString()}`}
subtitle={finance?.totalReceived ? `${(finance.spent || 0).toLocaleString()} ${t('dashboard.spent')} ${t('dashboard.of')} ${finance.totalReceived.toLocaleString()} ${currencySymbol}` : t('dashboard.noBudget')}
color="brand-tertiary"
/>
<StatCard
icon={AlertTriangle}
label={t('dashboard.overdueTasks')}
value={overdueTasks}
subtitle={overdueTasks > 0 ? t('dashboard.needsAttention') : t('dashboard.allOnTrack')}
color="brand-quaternary"
/>
{/* Stats — compact inline row, no cards */}
{stats.length > 0 && (
<div className="flex flex-wrap gap-6">
{stats.map((s, i) => (
<div key={i} className="flex items-center gap-3">
<s.icon className={`w-5 h-5 ${s.accent}`} />
<div>
<span className="text-2xl font-bold text-text-primary">{s.value}</span>
<span className="text-sm text-text-tertiary ms-1.5">{s.label}</span>
<p className="text-xs text-text-tertiary">{s.detail}</p>
</div>
</div>
))}
</div>
)}
{/* My Tasks + Project Progress */}
{hasModule('projects') && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<MyTasksList tasks={filteredTasks} currentUserId={currentUser?.id || currentUser?._id} navigate={navigate} t={t} />
<ProjectProgress projects={projects} tasks={tasks} t={t} />
</div>
)}
{/* Budget + Active Campaigns */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<FinanceMini finance={finance} />
<div className="lg:col-span-2">
{(hasModule('finance') || hasModule('marketing')) && (
<div className={`grid grid-cols-1 ${hasModule('finance') && hasModule('marketing') ? 'lg:grid-cols-3' : ''} gap-6`}>
{hasModule('finance') && <BudgetSummary finance={finance} />}
{hasModule('marketing') && (
<div className={hasModule('finance') ? 'lg:col-span-2' : ''}>
<ActiveCampaignsList campaigns={campaigns} finance={finance} />
</div>
</div>
{/* Recent Posts + Upcoming Deadlines */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Recent Posts */}
<div className="section-card">
<div className="section-card-header flex items-center justify-between">
<h3 className="font-semibold text-text-primary">{t('dashboard.recentPosts')}</h3>
<Link to="/posts" className="text-sm text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1">
{t('dashboard.viewAll')} <ArrowRight className="w-3.5 h-3.5" />
</Link>
</div>
<div className="divide-y divide-border-light">
{filteredPosts.length === 0 ? (
<div className="py-12 text-center text-sm text-text-tertiary">
{t('dashboard.noPostsYet')}
</div>
) : (
filteredPosts.slice(0, 8).map((post) => (
<div
key={post._id}
onClick={() => navigate('/posts')}
className="flex items-center gap-3 px-5 py-3 hover:bg-surface-secondary transition-colors cursor-pointer"
>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{post.title}</p>
<div className="flex items-center gap-2 mt-1">
{post.brand && <BrandBadge brand={post.brand} />}
</div>
</div>
<StatusBadge status={post.status} size="xs" />
</div>
))
)}
</div>
</div>
{/* Upcoming Deadlines */}
<div className="section-card">
<div className="section-card-header flex items-center justify-between">
<h3 className="font-semibold text-text-primary">{t('dashboard.upcomingDeadlines')}</h3>
<Link to="/tasks" className="text-sm text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1">
{t('dashboard.viewAll')} <ArrowRight className="w-3.5 h-3.5" />
</Link>
</div>
<div className="divide-y divide-border-light">
{upcomingDeadlines.length === 0 ? (
<div className="py-12 text-center text-sm text-text-tertiary">
{t('dashboard.noUpcomingDeadlines')}
</div>
) : (
upcomingDeadlines.map((task) => (
<div
key={task._id}
onClick={() => navigate('/tasks')}
className="flex items-center gap-3 px-5 py-3 hover:bg-surface-secondary transition-colors cursor-pointer"
>
<div className={`w-2 h-2 rounded-full ${(PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium).color}`} />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{task.title}</p>
<StatusBadge status={task.status} size="xs" />
</div>
<div className="flex items-center gap-1 text-xs text-text-tertiary shrink-0">
<Clock className="w-3.5 h-3.5" />
{format(new Date(task.dueDate), 'MMM d')}
</div>
</div>
))
)}
</div>
</div>
</div>
{/* Activity — merged posts + deadlines */}
{(hasModule('marketing') || hasModule('projects')) && (
<ActivityFeed
posts={hasModule('marketing') ? filteredPosts : []}
deadlines={hasModule('projects') ? upcomingDeadlines : []}
navigate={navigate}
t={t}
/>
)}
</div>
)
}
+262 -46
View File
@@ -1,14 +1,16 @@
import { useState, useEffect, useContext } from 'react'
import { DollarSign, TrendingUp, TrendingDown, Wallet, Landmark, Eye, MousePointer, Target, Briefcase, ArrowRight, Receipt } from 'lucide-react'
import { DollarSign, TrendingUp, TrendingDown, Wallet, Landmark, Eye, MousePointer, Target, Briefcase, ArrowRight, Receipt, Plus, X } from 'lucide-react'
import { Link } from 'react-router-dom'
import { AppContext } from '../App'
import { api } from '../utils/api'
import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../i18n/LanguageContext'
import StatusBadge from '../components/StatusBadge'
import Modal from '../components/Modal'
import { useToast } from '../components/ToastContainer'
import { SkeletonStatCard, SkeletonTable } from '../components/SkeletonLoader'
function FinanceStatCard({ icon: Icon, label, value, sub, color = 'text-text-primary', bgColor = 'bg-white' }) {
function FinanceStatCard({ icon: Icon, label, value, sub, color = 'text-text-primary', bgColor = 'bg-surface' }) {
return (
<div className={`${bgColor} rounded-xl border border-border p-5`}>
<div className="flex items-center gap-2 mb-2">
@@ -40,19 +42,36 @@ function ProgressRing({ pct, size = 80, stroke = 8, color = '#10b981' }) {
)
}
const BUDGET_REQUEST_STATUS_COLORS = {
pending: 'bg-amber-100 text-amber-800',
approved: 'bg-emerald-100 text-emerald-800',
rejected: 'bg-red-100 text-red-800',
cancelled: 'bg-gray-100 text-gray-600',
}
export default function Finance() {
const { brands } = useContext(AppContext)
const { permissions } = useAuth()
const { currencySymbol } = useLanguage()
const { permissions, user } = useAuth()
const { t, currencySymbol } = useLanguage()
const toast = useToast()
const [summary, setSummary] = useState(null)
const [loading, setLoading] = useState(true)
const [budgetRequests, setBudgetRequests] = useState([])
const [showRequestModal, setShowRequestModal] = useState(false)
const [requestForm, setRequestForm] = useState({ amount: '', justification: '', earmark_type: '', earmark_id: '' })
const [submittingRequest, setSubmittingRequest] = useState(false)
const isSuperadmin = user?.role === 'superadmin'
useEffect(() => { loadAll() }, [])
const loadAll = async () => {
try {
const sum = await api.get('/finance/summary')
const fetches = [api.get('/finance/summary')]
if (isSuperadmin) fetches.push(api.get('/budget-requests').catch(() => []))
const [sum, reqs] = await Promise.all(fetches)
setSummary(sum.data || sum || {})
if (reqs) setBudgetRequests(Array.isArray(reqs) ? reqs : [])
} catch (err) {
console.error('Failed to load finance:', err)
} finally {
@@ -60,6 +79,41 @@ export default function Finance() {
}
}
const handleSubmitRequest = async () => {
if (!requestForm.amount || !requestForm.justification.trim()) return
setSubmittingRequest(true)
try {
const body = {
amount: Number(requestForm.amount),
justification: requestForm.justification.trim(),
}
if (requestForm.earmark_type === 'campaign' && requestForm.earmark_id) {
body.earmarked_campaign_id = Number(requestForm.earmark_id)
} else if (requestForm.earmark_type === 'project' && requestForm.earmark_id) {
body.earmarked_project_id = Number(requestForm.earmark_id)
}
await api.post('/budget-requests', body)
toast.success(t('finance.requestBudget') + ' — ' + t('common.success'))
setShowRequestModal(false)
setRequestForm({ amount: '', justification: '', earmark_type: '', earmark_id: '' })
loadAll()
} catch (err) {
toast.error(err.message || t('common.error'))
} finally {
setSubmittingRequest(false)
}
}
const handleCancelRequest = async (id) => {
try {
await api.patch(`/budget-requests/${id}/cancel`)
toast.success(t('common.success'))
loadAll()
} catch (err) {
toast.error(err.message || t('common.error'))
}
}
if (loading) {
return (
<div className="space-y-4">
@@ -86,18 +140,35 @@ export default function Finance() {
const projectPct = totalReceived > 0 ? (totalProjectBudget / totalReceived) * 100 : 0
const unallocatedPct = totalReceived > 0 ? (Math.max(0, unallocated) / totalReceived) * 100 : 0
const campaigns = s.campaigns || []
const projects = s.projects || []
const pendingCount = budgetRequests.filter(r => r.status === 'pending').length
return (
<div className="space-y-6 animate-fade-in">
{/* Request Budget button (superadmin) */}
{isSuperadmin && (
<div className="flex justify-end">
<button
onClick={() => setShowRequestModal(true)}
className="flex items-center gap-2 px-4 py-2.5 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light transition-colors shadow-sm"
>
<Plus className="w-4 h-4" />
{t('finance.requestBudget')}
</button>
</div>
)}
{/* Top metrics */}
<div className={`grid grid-cols-2 ${totalExpenses > 0 ? 'lg:grid-cols-6' : 'lg:grid-cols-5'} gap-4 stagger-children`}>
<FinanceStatCard icon={Wallet} label="Total Received" value={`${totalReceived.toLocaleString()} ${currencySymbol}`} color="text-blue-600" />
<FinanceStatCard icon={TrendingUp} label="Total Spent" value={`${totalSpent.toLocaleString()} ${currencySymbol}`} sub={`${spendPct.toFixed(1)}% of budget`} color="text-amber-600" />
<FinanceStatCard icon={Wallet} label={t('finance.totalReceived')} value={`${totalReceived.toLocaleString()} ${currencySymbol}`} color="text-blue-600" />
<FinanceStatCard icon={TrendingUp} label={t('finance.totalSpent')} value={`${totalSpent.toLocaleString()} ${currencySymbol}`} sub={`${spendPct.toFixed(1)}% ${t('finance.ofBudget')}`} color="text-amber-600" />
{totalExpenses > 0 && (
<FinanceStatCard icon={Receipt} label="Expenses" value={`${totalExpenses.toLocaleString()} ${currencySymbol}`} color="text-red-600" />
<FinanceStatCard icon={Receipt} label={t('finance.expenses')} value={`${totalExpenses.toLocaleString()} ${currencySymbol}`} color="text-red-600" />
)}
<FinanceStatCard icon={Landmark} label="Remaining" value={`${remaining.toLocaleString()} ${currencySymbol}`} color={remaining >= 0 ? 'text-emerald-600' : 'text-red-600'} />
<FinanceStatCard icon={DollarSign} label="Revenue" value={`${totalRevenue.toLocaleString()} ${currencySymbol}`} color="text-purple-600" />
<FinanceStatCard icon={roi >= 0 ? TrendingUp : TrendingDown} label="Global ROI"
<FinanceStatCard icon={Landmark} label={t('finance.remaining')} value={`${remaining.toLocaleString()} ${currencySymbol}`} color={remaining >= 0 ? 'text-emerald-600' : 'text-red-600'} />
<FinanceStatCard icon={DollarSign} label={t('finance.revenue')} value={`${totalRevenue.toLocaleString()} ${currencySymbol}`} color="text-purple-600" />
<FinanceStatCard icon={roi >= 0 ? TrendingUp : TrendingDown} label={t('finance.globalROI')}
value={`${roi.toFixed(1)}%`}
color={roi >= 0 ? 'text-emerald-600' : 'text-red-600'} />
</div>
@@ -106,9 +177,9 @@ export default function Finance() {
{totalReceived > 0 && (
<div className="section-card p-5">
<div className="flex items-center justify-between mb-3">
<h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium">Budget Allocation</h3>
<h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium">{t('finance.budgetAllocation')}</h3>
<Link to="/budgets" className="text-xs text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1">
Manage Budgets <ArrowRight className="w-3 h-3" />
{t('finance.manageBudgets')} <ArrowRight className="w-3 h-3" />
</Link>
</div>
<div className="h-4 bg-surface-tertiary rounded-full overflow-hidden flex">
@@ -122,17 +193,17 @@ export default function Finance() {
<div className="flex items-center gap-4 mt-2.5 text-xs">
<div className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-full bg-blue-500" />
<span className="text-text-secondary">Campaigns: <span className="font-semibold text-text-primary">{totalCampaignBudget.toLocaleString()} {currencySymbol}</span></span>
<span className="text-text-secondary">{t('finance.campaigns')}: <span className="font-semibold text-text-primary">{totalCampaignBudget.toLocaleString()} {currencySymbol}</span></span>
<span className="text-text-tertiary">({campaignPct.toFixed(0)}%)</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-full bg-purple-500" />
<span className="text-text-secondary">Projects: <span className="font-semibold text-text-primary">{totalProjectBudget.toLocaleString()} {currencySymbol}</span></span>
<span className="text-text-secondary">{t('finance.projects')}: <span className="font-semibold text-text-primary">{totalProjectBudget.toLocaleString()} {currencySymbol}</span></span>
<span className="text-text-tertiary">({projectPct.toFixed(0)}%)</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-full bg-gray-300" />
<span className="text-text-secondary">Unallocated: <span className="font-semibold text-text-primary">{Math.max(0, unallocated).toLocaleString()} {currencySymbol}</span></span>
<span className="text-text-secondary">{t('finance.unallocated')}: <span className="font-semibold text-text-primary">{Math.max(0, unallocated).toLocaleString()} {currencySymbol}</span></span>
<span className="text-text-tertiary">({unallocatedPct.toFixed(0)}%)</span>
</div>
</div>
@@ -143,7 +214,7 @@ export default function Finance() {
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{/* Utilization ring */}
<div className="section-card p-5 flex flex-col items-center justify-center">
<h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium mb-4">Budget Utilization</h3>
<h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium mb-4">{t('finance.budgetUtilization')}</h3>
<ProgressRing
pct={spendPct}
size={120}
@@ -151,23 +222,23 @@ export default function Finance() {
color={spendPct > 90 ? '#ef4444' : spendPct > 70 ? '#f59e0b' : '#10b981'}
/>
<div className="text-xs text-text-tertiary mt-3">
{totalSpent.toLocaleString()} of {totalReceived.toLocaleString()} {currencySymbol}
{totalSpent.toLocaleString()} {t('finance.of')} {totalReceived.toLocaleString()} {currencySymbol}
</div>
</div>
{/* Global performance */}
<div className="section-card p-5 lg:col-span-2">
<h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium mb-4">Global Performance</h3>
<h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium mb-4">{t('finance.globalPerformance')}</h3>
<div className="grid grid-cols-3 gap-6">
<div className="text-center">
<Eye className="w-5 h-5 mx-auto mb-1 text-purple-500" />
<div className="text-xl font-bold text-text-primary">{(s.impressions || 0).toLocaleString()}</div>
<div className="text-xs text-text-tertiary">Impressions</div>
<div className="text-xs text-text-tertiary">{t('finance.impressions')}</div>
</div>
<div className="text-center">
<MousePointer className="w-5 h-5 mx-auto mb-1 text-blue-500" />
<div className="text-xl font-bold text-text-primary">{(s.clicks || 0).toLocaleString()}</div>
<div className="text-xs text-text-tertiary">Clicks</div>
<div className="text-xs text-text-tertiary">{t('finance.clicks')}</div>
{s.clicks > 0 && s.spent > 0 && (
<div className="text-[10px] text-text-tertiary mt-0.5">CPC: {(s.spent / s.clicks).toFixed(2)} {currencySymbol}</div>
)}
@@ -175,7 +246,7 @@ export default function Finance() {
<div className="text-center">
<Target className="w-5 h-5 mx-auto mb-1 text-emerald-500" />
<div className="text-xl font-bold text-text-primary">{(s.conversions || 0).toLocaleString()}</div>
<div className="text-xs text-text-tertiary">Conversions</div>
<div className="text-xs text-text-tertiary">{t('finance.conversions')}</div>
{s.conversions > 0 && s.spent > 0 && (
<div className="text-[10px] text-text-tertiary mt-0.5">CPA: {(s.spent / s.conversions).toFixed(2)} {currencySymbol}</div>
)}
@@ -200,22 +271,22 @@ export default function Finance() {
<Target className="w-4 h-4 text-blue-600" />
</div>
<div>
<h3 className="font-semibold text-text-primary text-base">Campaign Breakdown</h3>
<p className="text-xs text-text-tertiary mt-0.5">{s.campaigns.length} campaigns &middot; Track-level budget allocation</p>
<h3 className="font-semibold text-text-primary">{t('finance.campaignBreakdown')}</h3>
<p className="text-xs text-text-tertiary mt-0.5">{t('finance.campaignCount').replace('{{count}}', s.campaigns.length)}</p>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-surface-secondary">
<th className="px-4 py-3 text-left text-xs font-medium text-text-tertiary">Campaign</th>
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">Budget Assigned</th>
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">Track Allocated</th>
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">Spent</th>
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">Expenses</th>
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">Revenue</th>
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">ROI</th>
<th className="px-4 py-3 text-center text-xs font-medium text-text-tertiary">Status</th>
<th className="px-4 py-3 text-start text-xs font-medium text-text-tertiary">{t('finance.campaign')}</th>
<th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">{t('finance.budgetAssigned')}</th>
<th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">{t('finance.trackAllocated')}</th>
<th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">{t('finance.spent')}</th>
<th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">{t('finance.expenses')}</th>
<th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">{t('finance.revenue')}</th>
<th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">{t('finance.roi')}</th>
<th className="px-4 py-3 text-center text-xs font-medium text-text-tertiary">{t('common.status')}</th>
</tr>
</thead>
<tbody className="divide-y divide-border-light">
@@ -225,20 +296,20 @@ export default function Finance() {
return (
<tr key={c.id} className="hover:bg-surface-secondary">
<td className="px-4 py-3 font-medium text-text-primary">{c.name}</td>
<td className="px-4 py-3 text-right">
<td className="px-4 py-3 text-end">
{c.budget_from_entries > 0 ? (
<span className="font-semibold text-blue-600">{c.budget_from_entries.toLocaleString()}</span>
) : <span className="text-text-tertiary">{'\u2014'}</span>}
</td>
<td className="px-4 py-3 text-right text-text-secondary">{c.tracks_allocated.toLocaleString()}</td>
<td className="px-4 py-3 text-right text-text-secondary">{c.tracks_spent.toLocaleString()}</td>
<td className="px-4 py-3 text-right">
<td className="px-4 py-3 text-end text-text-secondary">{c.tracks_allocated.toLocaleString()}</td>
<td className="px-4 py-3 text-end text-text-secondary">{c.tracks_spent.toLocaleString()}</td>
<td className="px-4 py-3 text-end">
{c.expenses > 0 ? (
<span className="font-semibold text-red-500">-{c.expenses.toLocaleString()}</span>
) : <span className="text-text-tertiary">{'\u2014'}</span>}
</td>
<td className="px-4 py-3 text-right text-text-secondary">{c.tracks_revenue.toLocaleString()}</td>
<td className="px-4 py-3 text-right">
<td className="px-4 py-3 text-end text-text-secondary">{c.tracks_revenue.toLocaleString()}</td>
<td className="px-4 py-3 text-end">
{totalCampaignConsumed > 0 ? (
<span className={`text-xs font-semibold px-1.5 py-0.5 rounded ${cRoi >= 0 ? 'text-emerald-600 bg-emerald-50' : 'text-red-600 bg-red-50'}`}>
{cRoi.toFixed(0)}%
@@ -263,26 +334,26 @@ export default function Finance() {
<Briefcase className="w-4 h-4 text-purple-600" />
</div>
<div>
<h3 className="font-semibold text-text-primary text-base">Allocated Funds</h3>
<p className="text-xs text-text-tertiary mt-0.5">{s.projects.filter(p => p.budget_allocated > 0 || p.expenses > 0).length} work orders with assigned budget</p>
<h3 className="font-semibold text-text-primary">{t('finance.allocatedFunds')}</h3>
<p className="text-xs text-text-tertiary mt-0.5">{t('finance.workOrderCount').replace('{{count}}', s.projects.filter(p => p.budget_allocated > 0 || p.expenses > 0).length)}</p>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-surface-secondary">
<th className="px-4 py-3 text-left text-xs font-medium text-text-tertiary">Work Order</th>
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">Budget Allocated</th>
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">Expenses</th>
<th className="px-4 py-3 text-center text-xs font-medium text-text-tertiary">Status</th>
<th className="px-4 py-3 text-start text-xs font-medium text-text-tertiary">{t('finance.workOrder')}</th>
<th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">{t('finance.budgetAllocated')}</th>
<th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">{t('finance.expenses')}</th>
<th className="px-4 py-3 text-center text-xs font-medium text-text-tertiary">{t('common.status')}</th>
</tr>
</thead>
<tbody className="divide-y divide-border-light">
{s.projects.filter(p => p.budget_allocated > 0 || p.expenses > 0).map(p => (
<tr key={p.id} className="hover:bg-surface-secondary">
<td className="px-4 py-3 font-medium text-text-primary">{p.name}</td>
<td className="px-4 py-3 text-right text-text-secondary">{p.budget_allocated.toLocaleString()} {currencySymbol}</td>
<td className="px-4 py-3 text-right">
<td className="px-4 py-3 text-end text-text-secondary">{p.budget_allocated.toLocaleString()} {currencySymbol}</td>
<td className="px-4 py-3 text-end">
{p.expenses > 0 ? (
<span className="font-semibold text-red-500">-{p.expenses.toLocaleString()}</span>
) : <span className="text-text-tertiary">{'\u2014'}</span>}
@@ -295,6 +366,151 @@ export default function Finance() {
</div>
</div>
)}
{/* Budget Requests (superadmin) */}
{isSuperadmin && (
<div className="section-card">
<div className="section-card-header flex items-center gap-3">
<div className="p-2 rounded-lg bg-amber-50">
<Wallet className="w-4 h-4 text-amber-600" />
</div>
<div>
<h3 className="font-semibold text-text-primary">{t('finance.budgetRequests')}</h3>
</div>
</div>
{pendingCount > 0 && (
<div className="mx-5 mt-4 px-4 py-2.5 bg-amber-50 border border-amber-200 rounded-lg text-sm text-amber-800 font-medium">
{pendingCount} {t('finance.requestPending')}
</div>
)}
{budgetRequests.length === 0 ? (
<div className="px-5 py-8 text-center text-sm text-text-tertiary">
{t('common.noData')}
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-surface-secondary">
<th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">{t('finance.amount')}</th>
<th className="px-4 py-3 text-start text-xs font-medium text-text-tertiary">{t('budgetApproval.justification')}</th>
<th className="px-4 py-3 text-center text-xs font-medium text-text-tertiary">{t('common.status')}</th>
<th className="px-4 py-3 text-start text-xs font-medium text-text-tertiary">{t('budgetApproval.earmarkedFor')}</th>
<th className="px-4 py-3 text-start text-xs font-medium text-text-tertiary">{t('common.date')}</th>
<th className="px-4 py-3 text-center text-xs font-medium text-text-tertiary"></th>
</tr>
</thead>
<tbody className="divide-y divide-border-light">
{budgetRequests.map(req => (
<tr key={req.id || req.Id} className="hover:bg-surface-secondary">
<td className="px-4 py-3 text-end font-semibold text-text-primary">
{Number(req.amount).toLocaleString()} {currencySymbol}
</td>
<td className="px-4 py-3 text-text-secondary max-w-[200px]">
<span title={req.justification}>
{req.justification?.length > 60 ? req.justification.slice(0, 60) + '...' : req.justification}
</span>
</td>
<td className="px-4 py-3 text-center">
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${BUDGET_REQUEST_STATUS_COLORS[req.status] || 'bg-gray-100 text-gray-600'}`}>
{req.status}
</span>
</td>
<td className="px-4 py-3 text-text-secondary text-xs">
{req.earmark_name || '\u2014'}
</td>
<td className="px-4 py-3 text-text-tertiary text-xs">
{req.created_at ? new Date(req.created_at).toLocaleDateString() : '\u2014'}
</td>
<td className="px-4 py-3 text-center">
{req.status === 'pending' && (
<button
onClick={() => handleCancelRequest(req.id || req.Id)}
className="text-xs text-red-600 hover:text-red-700 font-medium hover:bg-red-50 px-2 py-1 rounded transition-colors"
>
{t('common.cancel')}
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
{/* Budget Request Modal */}
<Modal isOpen={showRequestModal} onClose={() => setShowRequestModal(false)} title={t('finance.requestBudget')} size="md">
<div className="space-y-4">
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('finance.amount')}</label>
<div className="flex items-center gap-2">
<input
type="number"
min="1"
value={requestForm.amount}
onChange={e => setRequestForm(f => ({ ...f, amount: e.target.value }))}
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface"
placeholder="0"
autoFocus
/>
<span className="text-sm text-text-tertiary">{currencySymbol}</span>
</div>
</div>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('budgetApproval.justification')}</label>
<textarea
value={requestForm.justification}
onChange={e => setRequestForm(f => ({ ...f, justification: e.target.value }))}
rows={3}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface"
placeholder={t('budgetApproval.justification')}
/>
</div>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('budgetApproval.earmarkedFor')}</label>
<select
value={requestForm.earmark_type ? `${requestForm.earmark_type}:${requestForm.earmark_id}` : ''}
onChange={e => {
if (!e.target.value) {
setRequestForm(f => ({ ...f, earmark_type: '', earmark_id: '' }))
} else {
const [type, id] = e.target.value.split(':')
setRequestForm(f => ({ ...f, earmark_type: type, earmark_id: id }))
}
}}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface"
>
<option value="">{t('common.none')}</option>
{campaigns.length > 0 && (
<optgroup label={t('finance.campaigns')}>
{campaigns.map(c => (
<option key={`campaign:${c.id}`} value={`campaign:${c.id}`}>{c.name}</option>
))}
</optgroup>
)}
{projects.length > 0 && (
<optgroup label={t('finance.projects')}>
{projects.map(p => (
<option key={`project:${p.id}`} value={`project:${p.id}`}>{p.name}</option>
))}
</optgroup>
)}
</select>
</div>
<button
onClick={handleSubmitRequest}
disabled={!requestForm.amount || !requestForm.justification.trim() || submittingRequest}
className={`w-full px-4 py-2.5 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm transition-colors ${submittingRequest ? 'btn-loading' : ''}`}
>
{t('finance.requestBudget')}
</button>
</div>
</Modal>
</div>
)
}
+120
View File
@@ -0,0 +1,120 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { useLanguage } from '../i18n/LanguageContext'
import { Mail, AlertCircle, CheckCircle, ArrowLeft } from 'lucide-react'
import { api } from '../utils/api'
function MarkaLogo({ className = '' }) {
return (
<svg viewBox="0 0 32 32" fill="none" className={className}>
<path d="M4 26V6l10 10L4 26z" fill="currentColor" opacity="0.85" />
<path d="M18 26V6l10 10-10 10z" fill="currentColor" opacity="0.5" />
</svg>
)
}
export default function ForgotPassword() {
const { t } = useLanguage()
const [email, setEmail] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [sent, setSent] = useState(false)
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
setLoading(true)
try {
await api.post('/auth/forgot-password', { email })
setSent(true)
} catch (err) {
setError(err.message || t('forgotPassword.error'))
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen bg-slate-900 flex items-center justify-center px-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<div className="w-16 h-16 bg-brand-primary rounded-2xl flex items-center justify-center mx-auto mb-4">
<MarkaLogo className="w-9 h-9 text-white" />
</div>
<h1 className="text-3xl font-bold text-white mb-2">{t('forgotPassword.title')}</h1>
<p className="text-slate-400">{t('forgotPassword.subtitle')}</p>
</div>
<div className="bg-slate-800/50 backdrop-blur-sm rounded-2xl border border-slate-700/50 p-8 shadow-2xl">
{sent ? (
<div className="text-center space-y-4">
<div className="w-12 h-12 bg-green-500/20 rounded-full flex items-center justify-center mx-auto">
<CheckCircle className="w-6 h-6 text-green-400" />
</div>
<p className="text-slate-300 text-sm">{t('forgotPassword.success')}</p>
<Link
to="/login"
className="inline-flex items-center gap-2 text-sm text-blue-400 hover:text-blue-300 transition-colors"
>
<ArrowLeft className="w-4 h-4" />
{t('forgotPassword.backToLogin')}
</Link>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">{t('auth.email')}</label>
<div className="relative">
<Mail className="absolute start-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
dir="auto"
className="w-full ps-11 pe-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
placeholder={t('forgotPassword.emailPlaceholder')}
required
autoFocus
/>
</div>
</div>
{error && (
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
<AlertCircle className="w-5 h-5 text-red-400 shrink-0" />
<p className="text-sm text-red-400">{error}</p>
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full py-3 bg-brand-primary hover:bg-brand-primary-light text-white font-semibold rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? (
<span className="flex items-center justify-center gap-2">
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
{t('forgotPassword.sending')}
</span>
) : (
t('forgotPassword.submit')
)}
</button>
<div className="text-center">
<Link
to="/login"
className="inline-flex items-center gap-2 text-sm text-slate-400 hover:text-slate-300 transition-colors"
>
<ArrowLeft className="w-4 h-4" />
{t('forgotPassword.backToLogin')}
</Link>
</div>
</form>
)}
</div>
</div>
</div>
)
}
+168 -141
View File
@@ -1,64 +1,59 @@
import { useState, useEffect, useContext, useMemo } from 'react'
import { AlertCircle, Search, LayoutGrid, List, ChevronUp, ChevronDown } from 'lucide-react'
import { AlertCircle, Search, LayoutGrid, List, ChevronUp, ChevronDown, Link2 } from 'lucide-react'
import { AppContext } from '../App'
import { api } from '../utils/api'
import { api, STATUS_CONFIG, PRIORITY_CONFIG } from '../utils/api'
import { useLanguage } from '../i18n/LanguageContext'
import { useToast } from '../components/ToastContainer'
import IssueDetailPanel from '../components/IssueDetailPanel'
import IssueCard from '../components/IssueCard'
import KanbanBoard from '../components/KanbanBoard'
import KanbanCard from '../components/KanbanCard'
import EmptyState from '../components/EmptyState'
import { SkeletonTable, SkeletonKanbanBoard } from '../components/SkeletonLoader'
import BulkSelectBar from '../components/BulkSelectBar'
import Modal from '../components/Modal'
const TYPE_OPTIONS = [
{ value: 'request', label: 'Request' },
{ value: 'correction', label: 'Correction' },
{ value: 'complaint', label: 'Complaint' },
{ value: 'suggestion', label: 'Suggestion' },
{ value: 'other', label: 'Other' },
const TYPE_OPTION_KEYS = [
{ value: 'request', labelKey: 'issues.typeRequest' },
{ value: 'correction', labelKey: 'issues.typeCorrection' },
{ value: 'complaint', labelKey: 'issues.typeComplaint' },
{ value: 'suggestion', labelKey: 'issues.typeSuggestion' },
{ value: 'other', labelKey: 'issues.typeOther' },
]
const PRIORITY_CONFIG = {
low: { label: 'Low', bg: 'bg-surface-tertiary', text: 'text-text-secondary', dot: 'bg-text-tertiary' },
medium: { label: 'Medium', bg: 'bg-blue-100', text: 'text-blue-700', dot: 'bg-blue-500' },
high: { label: 'High', bg: 'bg-orange-100', text: 'text-orange-700', dot: 'bg-orange-500' },
urgent: { label: 'Urgent', bg: 'bg-red-100', text: 'text-red-700', dot: 'bg-red-500' },
// Issue-specific status order for the kanban board
const ISSUE_STATUS_CONFIG = {
new: STATUS_CONFIG.new,
acknowledged: STATUS_CONFIG.acknowledged,
in_progress: STATUS_CONFIG.in_progress,
resolved: STATUS_CONFIG.resolved,
declined: STATUS_CONFIG.declined,
}
const STATUS_CONFIG = {
new: { label: 'New', bg: 'bg-purple-100', text: 'text-purple-700', dot: 'bg-purple-500' },
acknowledged: { label: 'Acknowledged', bg: 'bg-blue-100', text: 'text-blue-700', dot: 'bg-blue-500' },
in_progress: { label: 'In Progress', bg: 'bg-amber-100', text: 'text-amber-700', dot: 'bg-amber-500' },
resolved: { label: 'Resolved', bg: 'bg-emerald-100', text: 'text-emerald-700', dot: 'bg-emerald-500' },
declined: { label: 'Declined', bg: 'bg-surface-tertiary', text: 'text-text-secondary', dot: 'bg-text-tertiary' },
}
const STATUS_ORDER = ['new', 'acknowledged', 'in_progress', 'resolved', 'declined']
export default function Issues() {
const { t } = useLanguage()
const toast = useToast()
const { brands } = useContext(AppContext)
const { brands, teams } = useContext(AppContext)
const [issues, setIssues] = useState([])
const [counts, setCounts] = useState({})
const [loading, setLoading] = useState(true)
const [selectedIssue, setSelectedIssue] = useState(null)
const [searchTerm, setSearchTerm] = useState('')
const [filters, setFilters] = useState({ status: '', category: '', type: '', priority: '', brand: '' })
const [filters, setFilters] = useState({ status: '', category: '', type: '', priority: '', brand: '', team: '' })
const [categories, setCategories] = useState([])
const [teamMembers, setTeamMembers] = useState([])
// View mode
const [viewMode, setViewMode] = useState('board')
// Drag and drop
const [draggedIssue, setDraggedIssue] = useState(null)
const [dragOverCol, setDragOverCol] = useState(null)
// List sorting
const [sortBy, setSortBy] = useState('created_at')
const [sortDir, setSortDir] = useState('desc')
const [selectedIds, setSelectedIds] = useState(new Set())
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false)
useEffect(() => { loadData() }, [])
const loadData = async () => {
@@ -72,7 +67,7 @@ export default function Issues() {
setIssues(issuesData.issues || [])
setCounts(issuesData.counts || {})
setCategories(categoriesData || [])
setTeamMembers(Array.isArray(teamData) ? teamData : teamData.data || [])
setTeamMembers(Array.isArray(teamData) ? teamData : [])
} catch (err) {
console.error('Failed to load issues:', err)
} finally {
@@ -97,6 +92,7 @@ export default function Issues() {
if (filters.type) filtered = filtered.filter(i => i.type === filters.type)
if (filters.priority) filtered = filtered.filter(i => i.priority === filters.priority)
if (filters.brand) filtered = filtered.filter(i => String(i.brand_id) === String(filters.brand))
if (filters.team) filtered = filtered.filter(i => String(i.team_id) === String(filters.team))
return filtered
}, [issues, searchTerm, filters])
@@ -121,7 +117,7 @@ export default function Issues() {
const updateFilter = (key, value) => setFilters(f => ({ ...f, [key]: value }))
const clearFilters = () => {
setFilters({ status: '', category: '', type: '', priority: '', brand: '' })
setFilters({ status: '', category: '', type: '', priority: '', brand: '', team: '' })
setSearchTerm('')
}
const hasActiveFilters = Object.values(filters).some(Boolean) || searchTerm
@@ -139,41 +135,57 @@ export default function Issues() {
// Drag and drop handlers
const handleMoveIssue = async (issueId, newStatus) => {
// Optimistic update move the card instantly
const prev = issues
setIssues(issues.map(i => (i.Id || i.id) === issueId ? { ...i, status: newStatus } : i))
setCounts(c => {
const old = prev.find(i => (i.Id || i.id) === issueId)
if (!old || old.status === newStatus) return c
return { ...c, [old.status]: (c[old.status] || 1) - 1, [newStatus]: (c[newStatus] || 0) + 1 }
})
try {
await api.patch(`/issues/${issueId}`, { status: newStatus })
toast.success(t('issues.statusUpdated'))
loadData()
} catch (err) {
console.error('Move issue failed:', err)
toast.error('Failed to update status')
toast.error(t('issues.failedToUpdateStatus'))
// Rollback on error
setIssues(prev)
}
}
const handleDragStart = (e, issue) => {
setDraggedIssue(issue)
e.dataTransfer.effectAllowed = 'move'
setTimeout(() => { if (e.target) e.target.style.opacity = '0.4' }, 0)
const handleBulkDelete = async () => {
try {
await api.post('/issues/bulk-delete', { ids: [...selectedIds] })
toast.success(t('issues.issuesDeleted'))
setSelectedIds(new Set())
setShowBulkDeleteConfirm(false)
loadData()
} catch (err) {
console.error('Bulk delete failed:', err)
toast.error(t('common.deleteFailed'))
}
const handleDragEnd = (e) => {
e.target.style.opacity = '1'
setDraggedIssue(null)
setDragOverCol(null)
}
const handleDragOver = (e, colStatus) => {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
setDragOverCol(colStatus)
const toggleSelect = (id) => {
setSelectedIds(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const handleDragLeave = (e) => {
if (!e.currentTarget.contains(e.relatedTarget)) setDragOverCol(null)
const toggleSelectAll = () => {
if (selectedIds.size === sortedIssues.length) setSelectedIds(new Set())
else setSelectedIds(new Set(sortedIssues.map(i => i.Id || i.id)))
}
const handleDrop = (e, colStatus) => {
e.preventDefault()
setDragOverCol(null)
if (draggedIssue && draggedIssue.status !== colStatus) {
handleMoveIssue(draggedIssue.Id || draggedIssue.id, colStatus)
}
setDraggedIssue(null)
const copyPublicLink = () => {
const base = `${window.location.origin}/submit-issue`
const url = filters.team ? `${base}?team=${filters.team}` : base
navigator.clipboard.writeText(url)
toast.success(t('issues.linkCopied'))
}
const toggleSort = (col) => {
@@ -184,8 +196,8 @@ export default function Issues() {
const SortIcon = ({ col }) => {
if (sortBy !== col) return null
return sortDir === 'asc'
? <ChevronUp className="w-3 h-3 inline ml-0.5" />
: <ChevronDown className="w-3 h-3 inline ml-0.5" />
? <ChevronUp className="w-3 h-3 inline ms-0.5" />
: <ChevronDown className="w-3 h-3 inline ms-0.5" />
}
if (loading) {
@@ -199,14 +211,16 @@ export default function Issues() {
return (
<div className="space-y-4 animate-fade-in">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-text-primary flex items-center gap-2">
<AlertCircle className="w-7 h-7" />
Issues
</h1>
<p className="text-text-secondary mt-1">Track and manage issue submissions</p>
</div>
<div className="flex items-center justify-end">
<div className="flex items-center gap-3">
<button
onClick={copyPublicLink}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium border border-border rounded-lg hover:bg-surface-secondary transition-colors text-text-secondary"
title={t('issues.copyPublicLink')}
>
<Link2 className="w-3.5 h-3.5" />
{t('issues.copyPublicLink')}
</button>
{/* View switcher */}
<div className="flex items-center bg-surface-tertiary rounded-lg p-0.5">
@@ -219,7 +233,7 @@ export default function Issues() {
onClick={() => setViewMode(mode)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
viewMode === mode
? 'bg-white text-text-primary shadow-sm'
? 'bg-surface text-text-primary shadow-sm'
: 'text-text-tertiary hover:text-text-secondary'
}`}
>
@@ -229,10 +243,11 @@ export default function Issues() {
))}
</div>
</div>
</div>
{/* Status Counts */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 stagger-children">
{Object.entries(STATUS_CONFIG).map(([status, config]) => (
{Object.entries(ISSUE_STATUS_CONFIG).map(([status, config]) => (
<div
key={status}
className={`bg-surface rounded-lg border p-4 cursor-pointer hover:shadow-sm transition-all ${
@@ -253,13 +268,13 @@ export default function Issues() {
<div className="flex items-center gap-3 flex-wrap">
{/* Search */}
<div className="relative flex-1 min-w-[200px] max-w-xs">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<Search className="absolute start-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<input
type="text"
placeholder="Search issues..."
placeholder={t('issues.searchPlaceholder')}
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 bg-surface"
className="w-full ps-10 pe-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 bg-surface"
/>
</div>
@@ -268,8 +283,8 @@ export default function Issues() {
onChange={e => updateFilter('status', e.target.value)}
className="px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 bg-surface"
>
<option value="">All Statuses</option>
{Object.entries(STATUS_CONFIG).map(([key, config]) => (
<option value="">{t('issues.allStatuses')}</option>
{Object.entries(ISSUE_STATUS_CONFIG).map(([key, config]) => (
<option key={key} value={key}>{config.label}</option>
))}
</select>
@@ -279,7 +294,7 @@ export default function Issues() {
onChange={e => updateFilter('category', e.target.value)}
className="px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 bg-surface"
>
<option value="">All Categories</option>
<option value="">{t('issues.allCategories')}</option>
{categories.map(cat => <option key={cat} value={cat}>{cat}</option>)}
</select>
@@ -288,8 +303,8 @@ export default function Issues() {
onChange={e => updateFilter('type', e.target.value)}
className="px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 bg-surface"
>
<option value="">All Types</option>
{TYPE_OPTIONS.map(opt => <option key={opt.value} value={opt.value}>{opt.label}</option>)}
<option value="">{t('issues.allTypes')}</option>
{TYPE_OPTION_KEYS.map(opt => <option key={opt.value} value={opt.value}>{t(opt.labelKey)}</option>)}
</select>
<select
@@ -297,18 +312,29 @@ export default function Issues() {
onChange={e => updateFilter('brand', e.target.value)}
className="px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 bg-surface"
>
<option value="">All Brands</option>
<option value="">{t('issues.allBrands')}</option>
{(brands || []).map(b => (
<option key={b._id || b.Id} value={b._id || b.Id}>{b.name}</option>
))}
</select>
<select
value={filters.team || ''}
onChange={e => updateFilter('team', e.target.value)}
className="px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 bg-surface"
>
<option value="">{t('issues.allTeams')}</option>
{(teams || []).map(tm => (
<option key={tm.id || tm.Id} value={tm.id || tm.Id}>{tm.name}</option>
))}
</select>
<select
value={filters.priority}
onChange={e => updateFilter('priority', e.target.value)}
className="px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 bg-surface"
>
<option value="">All Priorities</option>
<option value="">{t('issues.allPriorities')}</option>
{Object.entries(PRIORITY_CONFIG).map(([key, config]) => (
<option key={key} value={key}>{config.label}</option>
))}
@@ -316,7 +342,7 @@ export default function Issues() {
{hasActiveFilters && (
<button onClick={clearFilters} className="px-3 py-2 rounded-lg text-sm font-medium text-text-tertiary hover:text-text-primary">
Clear All
{t('issues.clearAll')}
</button>
)}
</div>
@@ -326,58 +352,32 @@ export default function Issues() {
filteredIssues.length === 0 ? (
<EmptyState
icon={AlertCircle}
title="No issues found"
description={hasActiveFilters ? 'Try adjusting your filters' : 'No issues have been submitted yet'}
title={t('issues.noIssuesFound')}
description={hasActiveFilters ? t('issues.tryAdjustingFilters') : t('issues.noIssuesSubmitted')}
/>
) : (
<div className="flex gap-4 overflow-x-auto pb-4">
{STATUS_ORDER.map(status => {
const config = STATUS_CONFIG[status]
const columnIssues = filteredIssues.filter(i => i.status === status)
return (
<div
key={status}
className={`flex-shrink-0 w-72 rounded-xl border transition-colors ${
dragOverCol === status ? 'border-brand-primary bg-brand-primary/5' : 'border-border bg-surface-secondary/50'
}`}
onDragOver={e => handleDragOver(e, status)}
onDragLeave={handleDragLeave}
onDrop={e => handleDrop(e, status)}
>
{/* Column header */}
<div className="px-3 py-3 border-b border-border">
<div className="flex items-center gap-2">
<span className={`w-2.5 h-2.5 rounded-full ${config.dot}`} />
<span className="text-sm font-semibold text-text-primary">{config.label}</span>
<span className="text-xs bg-surface-tertiary text-text-tertiary px-1.5 py-0.5 rounded-full font-medium">
{columnIssues.length}
<KanbanBoard
columns={Object.entries(ISSUE_STATUS_CONFIG).map(([id, cfg]) => ({ id, label: cfg.label, color: cfg.dot }))}
items={filteredIssues}
getItemId={(i) => i.Id || i.id}
onMove={handleMoveIssue}
emptyLabel={t('issues.noIssuesInColumn')}
renderCard={(issue) => (
<KanbanCard
title={issue.title}
thumbnail={issue.thumbnail_url}
brandName={issue.brand_name}
assigneeName={issue.submitter_name}
date={issue.created_at || issue.CreatedAt}
onClick={() => setSelectedIssue(issue)}
tags={issue.category && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-surface-tertiary text-text-secondary font-medium">
{issue.category}
</span>
</div>
</div>
{/* Cards */}
<div className="p-2 space-y-2 min-h-[120px]">
{columnIssues.length === 0 ? (
<div className="text-center py-6 text-xs text-text-tertiary">
{t('issues.noIssuesInColumn')}
</div>
) : (
columnIssues.map(issue => (
<div
key={issue.Id || issue.id}
draggable
onDragStart={e => handleDragStart(e, issue)}
onDragEnd={handleDragEnd}
>
<IssueCard issue={issue} onClick={setSelectedIssue} />
</div>
))
)}
</div>
</div>
)
})}
</div>
/>
)}
/>
)
)}
@@ -386,31 +386,41 @@ export default function Issues() {
sortedIssues.length === 0 ? (
<EmptyState
icon={AlertCircle}
title="No issues found"
description={hasActiveFilters ? 'Try adjusting your filters' : 'No issues have been submitted yet'}
title={t('issues.noIssuesFound')}
description={hasActiveFilters ? t('issues.tryAdjustingFilters') : t('issues.noIssuesSubmitted')}
/>
) : (
<div className="bg-surface rounded-lg border border-border overflow-hidden">
{selectedIds.size > 0 && (
<BulkSelectBar
selectedCount={selectedIds.size}
onClearSelection={() => setSelectedIds(new Set())}
onDelete={() => setShowBulkDeleteConfirm(true)}
/>
)}
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-surface-secondary border-b border-border">
<tr>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleSort('title')}>
Title <SortIcon col="title" />
<th className="w-10 px-3 py-3" onClick={e => e.stopPropagation()}>
<input type="checkbox" checked={selectedIds.size === sortedIssues.length && sortedIssues.length > 0} onChange={toggleSelectAll} className="rounded border-border" />
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">Submitter</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">Brand</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">Category</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">Type</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleSort('priority')}>
Priority <SortIcon col="priority" />
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleSort('title')}>
{t('issues.tableTitle')} <SortIcon col="title" />
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleSort('status')}>
Status <SortIcon col="status" />
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('issues.tableSubmitter')}</th>
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('issues.tableBrand')}</th>
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('issues.tableCategory')}</th>
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('issues.tableType')}</th>
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleSort('priority')}>
{t('issues.tablePriority')} <SortIcon col="priority" />
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">Assigned To</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleSort('created_at')}>
Created <SortIcon col="created_at" />
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleSort('status')}>
{t('issues.tableStatus')} <SortIcon col="status" />
</th>
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('issues.tableAssignedTo')}</th>
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleSort('created_at')}>
{t('issues.tableCreated')} <SortIcon col="created_at" />
</th>
</tr>
</thead>
@@ -424,6 +434,9 @@ export default function Issues() {
onClick={() => setSelectedIssue(issue)}
className="hover:bg-surface-secondary cursor-pointer transition-colors"
>
<td className="w-10 px-3 py-3" onClick={e => e.stopPropagation()}>
<input type="checkbox" checked={selectedIds.has(issue.Id || issue.id)} onChange={() => toggleSelect(issue.Id || issue.id)} className="rounded border-border" />
</td>
<td className="px-4 py-3 text-sm font-medium text-text-primary">{issue.title}</td>
<td className="px-4 py-3 text-sm text-text-secondary">
<div>{issue.submitter_name}</div>
@@ -433,7 +446,7 @@ export default function Issues() {
<td className="px-4 py-3 text-sm text-text-secondary">{issue.category || '—'}</td>
<td className="px-4 py-3 text-sm">
<span className="text-xs px-2 py-1 rounded-full bg-surface-tertiary text-text-secondary">
{TYPE_OPTIONS.find(t => t.value === issue.type)?.label || issue.type}
{(() => { const opt = TYPE_OPTION_KEYS.find(o => o.value === issue.type); return opt ? t(opt.labelKey) : issue.type })()}
</span>
</td>
<td className="px-4 py-3 text-sm">
@@ -459,6 +472,19 @@ export default function Issues() {
)
)}
{/* Bulk Delete Confirm */}
<Modal
isOpen={showBulkDeleteConfirm}
onClose={() => setShowBulkDeleteConfirm(false)}
title={t('common.bulkDeleteConfirm').replace('{count}', selectedIds.size)}
isConfirm
danger
confirmText={t('common.deleteSelected')}
onConfirm={handleBulkDelete}
>
{t('common.bulkDeleteDesc')}
</Modal>
{/* Detail Panel */}
{selectedIssue && (
<IssueDetailPanel
@@ -466,6 +492,7 @@ export default function Issues() {
onClose={() => setSelectedIssue(null)}
onUpdate={loadData}
teamMembers={teamMembers}
teams={teams}
/>
)}
</div>
+51 -37
View File
@@ -1,10 +1,19 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { useNavigate, Link } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../i18n/LanguageContext'
import { Megaphone, Lock, Mail, AlertCircle, User, CheckCircle } from 'lucide-react'
import { Lock, Mail, AlertCircle, User, CheckCircle } from 'lucide-react'
import { api } from '../utils/api'
function MarkaLogo({ className = '' }) {
return (
<svg viewBox="0 0 32 32" fill="none" className={className}>
<path d="M4 26V6l10 10L4 26z" fill="currentColor" opacity="0.85" />
<path d="M18 26V6l10 10-10 10z" fill="currentColor" opacity="0.5" />
</svg>
)
}
export default function Login() {
const navigate = useNavigate()
const { login } = useAuth()
@@ -34,7 +43,7 @@ export default function Login() {
await login(email, password)
navigate('/')
} catch (err) {
setError(err.message || 'Invalid email or password')
setError(err.message || t('login.invalidCredentials'))
} finally {
setLoading(false)
}
@@ -44,7 +53,7 @@ export default function Login() {
e.preventDefault()
setError('')
if (setupPassword !== setupConfirm) {
setError('Passwords do not match')
setError(t('login.passwordMismatch'))
return
}
setLoading(true)
@@ -55,7 +64,7 @@ export default function Login() {
setNeedsSetup(false)
setEmail(setupEmail)
} catch (err) {
setError(err.message || 'Setup failed')
setError(err.message || t('login.setupFailed'))
} finally {
setLoading(false)
}
@@ -63,25 +72,25 @@ export default function Login() {
if (needsSetup === null) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-blue-900 to-slate-900 flex items-center justify-center">
<div className="min-h-screen bg-slate-900 flex items-center justify-center">
<div className="w-8 h-8 border-2 border-white/30 border-t-white rounded-full animate-spin" />
</div>
)
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-blue-900 to-slate-900 flex items-center justify-center px-4">
<div className="min-h-screen bg-slate-900 flex items-center justify-center px-4">
<div className="w-full max-w-md">
{/* Logo & Title */}
<div className="text-center mb-8">
<div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-purple-600 rounded-2xl flex items-center justify-center mx-auto mb-4 shadow-lg">
<Megaphone className="w-8 h-8 text-white" />
<div className="w-16 h-16 bg-brand-primary rounded-2xl flex items-center justify-center mx-auto mb-4">
<MarkaLogo className="w-9 h-9 text-white" />
</div>
<h1 className="text-3xl font-bold text-white mb-2">
{needsSetup ? 'Initial Setup' : t('login.title')}
{needsSetup ? t('login.initialSetup') : t('login.title')}
</h1>
<p className="text-slate-400">
{needsSetup ? 'Create your superadmin account to get started' : t('login.subtitle')}
{needsSetup ? t('login.initialSetupDesc') : t('login.subtitle')}
</p>
</div>
@@ -89,7 +98,7 @@ export default function Login() {
{setupDone && (
<div className="flex items-center gap-2 p-3 mb-4 bg-green-500/10 border border-green-500/30 rounded-lg">
<CheckCircle className="w-5 h-5 text-green-400 shrink-0" />
<p className="text-sm text-green-400">Account created. You can now log in.</p>
<p className="text-sm text-green-400">{t('login.accountCreated')}</p>
</div>
)}
@@ -99,32 +108,33 @@ export default function Login() {
<form onSubmit={handleSetup} className="space-y-5">
{/* Name */}
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">Name</label>
<label className="block text-sm font-medium text-slate-300 mb-2">{t('login.fullName')}</label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<User className="absolute start-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<input
type="text"
value={setupName}
onChange={(e) => setSetupName(e.target.value)}
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
placeholder="Your name"
className="w-full ps-11 pe-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
placeholder={t('login.fullNamePlaceholder')}
required
autoFocus
aria-describedby={error ? 'setup-error' : undefined}
/>
</div>
</div>
{/* Email */}
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">Email</label>
<label className="block text-sm font-medium text-slate-300 mb-2">{t('login.email')}</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<Mail className="absolute start-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<input
type="email"
value={setupEmail}
onChange={(e) => setSetupEmail(e.target.value)}
dir="auto"
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
className="w-full ps-11 pe-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
placeholder="admin@company.com"
required
/>
@@ -133,15 +143,15 @@ export default function Login() {
{/* Password */}
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">Password</label>
<label className="block text-sm font-medium text-slate-300 mb-2">{t('login.password')}</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<Lock className="absolute start-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<input
type="password"
value={setupPassword}
onChange={(e) => setSetupPassword(e.target.value)}
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
placeholder="Choose a strong password"
className="w-full ps-11 pe-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
placeholder={t('login.passwordPlaceholder')}
required
minLength={6}
/>
@@ -150,15 +160,15 @@ export default function Login() {
{/* Confirm Password */}
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">Confirm Password</label>
<label className="block text-sm font-medium text-slate-300 mb-2">{t('login.confirmPassword')}</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<Lock className="absolute start-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<input
type="password"
value={setupConfirm}
onChange={(e) => setSetupConfirm(e.target.value)}
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
placeholder="Re-enter your password"
className="w-full ps-11 pe-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
placeholder={t('login.confirmPasswordPlaceholder')}
required
minLength={6}
/>
@@ -167,7 +177,7 @@ export default function Login() {
{/* Error */}
{error && (
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
<div id="setup-error" className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg" role="alert">
<AlertCircle className="w-5 h-5 text-red-400 shrink-0" />
<p className="text-sm text-red-400">{error}</p>
</div>
@@ -177,15 +187,15 @@ export default function Login() {
<button
type="submit"
disabled={loading}
className="w-full py-3 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed"
className="w-full py-3 bg-brand-primary hover:bg-brand-primary-light text-white font-semibold rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? (
<span className="flex items-center justify-center gap-2">
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
Creating account...
{t('login.creatingAccount')}
</span>
) : (
'Create Superadmin Account'
t('login.createAccount')
)}
</button>
</form>
@@ -197,16 +207,17 @@ export default function Login() {
{t('auth.email')}
</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<Mail className="absolute start-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
dir="auto"
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
className="w-full ps-11 pe-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
placeholder="user@company.com"
required
autoFocus
aria-describedby={error ? 'login-error' : undefined}
/>
</div>
</div>
@@ -217,21 +228,22 @@ export default function Login() {
{t('auth.password')}
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<Lock className="absolute start-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
className="w-full ps-11 pe-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
placeholder="••••••••"
required
aria-describedby={error ? 'login-error' : undefined}
/>
</div>
</div>
{/* Error */}
{error && (
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
<div id="login-error" className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg" role="alert">
<AlertCircle className="w-5 h-5 text-red-400 shrink-0" />
<p className="text-sm text-red-400">{error}</p>
</div>
@@ -241,7 +253,7 @@ export default function Login() {
<button
type="submit"
disabled={loading}
className="w-full py-3 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed"
className="w-full py-3 bg-brand-primary hover:bg-brand-primary-light text-white font-semibold rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? (
<span className="flex items-center justify-center gap-2">
@@ -259,7 +271,9 @@ export default function Login() {
{!needsSetup && (
<div className="mt-6 pt-6 border-t border-slate-700/50">
<p className="text-xs text-slate-500 text-center">
<Link to="/forgot-password" className="hover:text-slate-300 transition-colors underline">
{t('login.forgotPassword')}
</Link>
</p>
</div>
)}
+72 -30
View File
@@ -1,12 +1,12 @@
import { useState, useEffect, useContext } from 'react'
import { ChevronLeft, ChevronRight, Calendar as CalendarIcon } from 'lucide-react'
import { ChevronLeft, ChevronRight, Calendar as CalendarIcon, CalendarDays } from 'lucide-react'
import { AppContext } from '../App'
import { useLanguage } from '../i18n/LanguageContext'
import { api, PLATFORMS } from '../utils/api'
import PostDetailPanel from '../components/PostDetailPanel'
import { SkeletonCalendar } from '../components/SkeletonLoader'
const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
const DAY_KEYS = ['calendar.sun', 'calendar.mon', 'calendar.tue', 'calendar.wed', 'calendar.thu', 'calendar.fri', 'calendar.sat']
const STATUS_COLORS = {
draft: 'bg-surface-tertiary text-text-secondary',
@@ -39,6 +39,19 @@ function getMonthData(year, month) {
return cells
}
function getWeekData(startDate) {
const cells = []
const start = new Date(startDate)
// Align to Sunday
start.setDate(start.getDate() - start.getDay())
for (let i = 0; i < 7; i++) {
const d = new Date(start)
d.setDate(start.getDate() + i)
cells.push({ day: d.getDate(), current: true, date: d })
}
return cells
}
function dateKey(d) {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
@@ -53,6 +66,10 @@ export default function PostCalendar() {
const [loading, setLoading] = useState(true)
const [filters, setFilters] = useState({ brand: '', platform: '', status: '' })
const [selectedPost, setSelectedPost] = useState(null)
const [calView, setCalView] = useState('month') // 'month' | 'week'
const [weekStart, setWeekStart] = useState(() => {
const d = new Date(); d.setDate(d.getDate() - d.getDay()); return d
})
useEffect(() => {
loadPosts()
@@ -61,7 +78,7 @@ export default function PostCalendar() {
const loadPosts = async () => {
try {
const res = await api.get('/posts')
setPosts(res.data || res || [])
setPosts(Array.isArray(res) ? res : [])
} catch (err) {
console.error('Failed to load posts:', err)
} finally {
@@ -69,7 +86,7 @@ export default function PostCalendar() {
}
}
const cells = getMonthData(year, month)
const cells = calView === 'month' ? getMonthData(year, month) : getWeekData(weekStart)
const todayKey = dateKey(today)
// Filter posts
@@ -105,9 +122,22 @@ export default function PostCalendar() {
if (month === 11) { setMonth(0); setYear(y => y + 1) }
else setMonth(m => m + 1)
}
const goToday = () => { setYear(today.getFullYear()); setMonth(today.getMonth()) }
const prevWeek = () => setWeekStart(d => { const n = new Date(d); n.setDate(n.getDate() - 7); return n })
const nextWeek = () => setWeekStart(d => { const n = new Date(d); n.setDate(n.getDate() + 7); return n })
const goToday = () => {
setYear(today.getFullYear()); setMonth(today.getMonth())
const d = new Date(); d.setDate(d.getDate() - d.getDay()); setWeekStart(d)
}
const monthLabel = new Date(year, month).toLocaleString('default', { month: 'long', year: 'numeric' })
const weekLabel = (() => {
const start = new Date(weekStart)
start.setDate(start.getDate() - start.getDay())
const end = new Date(start); end.setDate(start.getDate() + 6)
const fmt = (d) => d.toLocaleString('default', { month: 'short', day: 'numeric' })
return `${fmt(start)} ${fmt(end)}, ${end.getFullYear()}`
})()
const handlePostClick = (post) => {
setSelectedPost(post)
@@ -128,14 +158,6 @@ export default function PostCalendar() {
return (
<div className="space-y-4 animate-fade-in">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-text-primary">Content Calendar</h1>
<p className="text-sm text-text-secondary mt-1">Schedule and plan your posts</p>
</div>
</div>
{/* Filters */}
<div className="flex flex-wrap items-center gap-3">
<select
@@ -172,28 +194,48 @@ export default function PostCalendar() {
</div>
{/* Calendar */}
<div className="bg-surface rounded-xl border border-border overflow-hidden">
<div className="bg-surface rounded-xl border border-border overflow-clip">
{/* Nav */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<div className="flex items-center gap-3">
<button onClick={prevMonth} className="p-2 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors">
<button onClick={calView === 'month' ? prevMonth : prevWeek} className="p-2 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors">
<ChevronLeft className="w-5 h-5" />
</button>
<h3 className="text-lg font-semibold text-text-primary min-w-[180px] text-center">{monthLabel}</h3>
<button onClick={nextMonth} className="p-2 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors">
<h3 className="text-lg font-semibold text-text-primary min-w-[220px] text-center">
{calView === 'month' ? monthLabel : weekLabel}
</h3>
<button onClick={calView === 'month' ? nextMonth : nextWeek} className="p-2 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors">
<ChevronRight className="w-5 h-5" />
</button>
</div>
<button onClick={goToday} className="px-4 py-2 text-sm font-medium text-brand-primary hover:bg-brand-primary/5 rounded-lg transition-colors">
Today
<div className="flex items-center gap-2">
<div className="flex bg-surface-tertiary rounded-lg p-0.5">
<button
onClick={() => setCalView('month')}
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors flex items-center gap-1.5 ${calView === 'month' ? 'bg-surface shadow-sm text-text-primary' : 'text-text-tertiary'}`}
>
<CalendarIcon className="w-3.5 h-3.5" />
{t('calendar.month')}
</button>
<button
onClick={() => setCalView('week')}
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors flex items-center gap-1.5 ${calView === 'week' ? 'bg-surface shadow-sm text-text-primary' : 'text-text-tertiary'}`}
>
<CalendarDays className="w-3.5 h-3.5" />
{t('calendar.week')}
</button>
</div>
<button onClick={goToday} className="px-4 py-2 text-sm font-medium text-brand-primary hover:bg-brand-primary/5 rounded-lg transition-colors">
{t('calendar.today')}
</button>
</div>
</div>
{/* Day headers */}
<div className="grid grid-cols-7 border-b border-border bg-surface-secondary">
{DAYS.map(d => (
<div key={d} className="text-center text-xs font-semibold text-text-tertiary uppercase py-3">
{d}
{DAY_KEYS.map(k => (
<div key={k} className="text-center text-xs font-semibold text-text-tertiary uppercase py-3">
{t(k)}
</div>
))}
</div>
@@ -207,7 +249,7 @@ export default function PostCalendar() {
return (
<div
key={i}
className={`border-r border-b border-border min-h-[110px] p-2 ${
className={`border-r border-b border-border ${calView === 'week' ? 'min-h-[300px]' : 'min-h-[110px]'} p-2 ${
cell.current ? 'bg-surface' : 'bg-surface-secondary/30'
} ${i % 7 === 6 ? 'border-r-0' : ''}`}
>
@@ -217,11 +259,11 @@ export default function PostCalendar() {
{cell.day}
</div>
<div className="space-y-1">
{dayPosts.slice(0, 3).map(post => (
{dayPosts.slice(0, calView === 'week' ? 10 : 3).map(post => (
<button
key={post.Id || post._id}
onClick={() => handlePostClick(post)}
className={`w-full text-left text-[10px] px-2 py-1 rounded font-medium hover:opacity-80 transition-opacity truncate ${
className={`w-full text-start text-[10px] px-2 py-1 rounded font-medium hover:opacity-80 transition-opacity truncate ${
STATUS_COLORS[post.status] || 'bg-surface-tertiary text-text-secondary'
}`}
title={post.title}
@@ -229,9 +271,9 @@ export default function PostCalendar() {
{post.title}
</button>
))}
{dayPosts.length > 3 && (
{dayPosts.length > (calView === 'week' ? 10 : 3) && (
<div className="text-[9px] text-text-tertiary text-center font-medium">
+{dayPosts.length - 3} more
+{dayPosts.length - (calView === 'week' ? 10 : 3)} more
</div>
)}
</div>
@@ -244,13 +286,13 @@ export default function PostCalendar() {
{/* Unscheduled Posts */}
{unscheduled.length > 0 && (
<div className="bg-surface rounded-xl border border-border p-6">
<h3 className="text-sm font-semibold text-text-tertiary uppercase mb-3">Unscheduled Posts</h3>
<h3 className="text-sm font-semibold text-text-tertiary uppercase mb-3">{t('calendar.unscheduledPosts')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{unscheduled.map(post => (
<button
key={post.Id || post._id}
onClick={() => handlePostClick(post)}
className="text-left bg-surface-secondary border border-border rounded-lg p-3 hover:border-brand-primary/30 transition-colors"
className="text-start bg-surface-secondary border border-border rounded-lg p-3 hover:border-brand-primary/30 transition-colors"
>
<div className="flex items-center gap-2 mb-1">
<span className={`text-xs px-2 py-0.5 rounded-full ${STATUS_COLORS[post.status] || 'bg-surface-tertiary text-text-secondary'}`}>
@@ -269,7 +311,7 @@ export default function PostCalendar() {
{/* Legend */}
<div className="bg-surface rounded-xl border border-border p-4">
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-3">Status Legend</h4>
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-3">{t('calendar.statusLegend')}</h4>
<div className="flex flex-wrap gap-3">
{Object.entries(STATUS_COLORS).map(([status, color]) => (
<div key={status} className="flex items-center gap-2">
+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>
)
}
+197 -111
View File
@@ -1,15 +1,18 @@
import { useState, useEffect, useContext } from 'react'
import { Plus, LayoutGrid, List, Search, X, FileText } from 'lucide-react'
import { useState, useEffect, useContext, useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import { Plus, LayoutGrid, List, Search, X, FileText, Filter } from 'lucide-react'
import { AppContext } from '../App'
import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../i18n/LanguageContext'
import { api, PLATFORMS } from '../utils/api'
import KanbanBoard from '../components/KanbanBoard'
import KanbanCard from '../components/KanbanCard'
import PostCard from '../components/PostCard'
import PostDetailPanel from '../components/PostDetailPanel'
import DatePresetPicker from '../components/DatePresetPicker'
import { SkeletonKanbanBoard, SkeletonTable } from '../components/SkeletonLoader'
import EmptyState from '../components/EmptyState'
import Modal from '../components/Modal'
import BulkSelectBar from '../components/BulkSelectBar'
import { useToast } from '../components/ToastContainer'
const EMPTY_POST = {
@@ -20,28 +23,31 @@ const EMPTY_POST = {
export default function PostProduction() {
const { t, lang } = useLanguage()
const { teamMembers, brands } = useContext(AppContext)
const navigate = useNavigate()
const { teamMembers, brands, getBrandName } = useContext(AppContext)
const { canEditResource } = useAuth()
const toast = useToast()
const [posts, setPosts] = useState([])
const [loading, setLoading] = useState(true)
const [view, setView] = useState('kanban')
const [panelPost, setPanelPost] = useState(null)
const [campaigns, setCampaigns] = useState([])
const [filters, setFilters] = useState({ brand: '', platform: '', assignedTo: '', campaign: '', periodFrom: '', periodTo: '' })
const [searchTerm, setSearchTerm] = useState('')
const [activePreset, setActivePreset] = useState('')
const [moveError, setMoveError] = useState('')
const [selectedIds, setSelectedIds] = useState(new Set())
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false)
const [showFilters, setShowFilters] = useState(false)
useEffect(() => {
loadPosts()
api.get('/campaigns').then(r => setCampaigns(Array.isArray(r) ? r : (r.data || []))).catch(() => {})
api.get('/campaigns').then(r => setCampaigns(Array.isArray(r) ? r : [])).catch(() => {})
}, [])
const loadPosts = async () => {
try {
const res = await api.get('/posts')
setPosts(res.data || res || [])
setPosts(Array.isArray(res) ? res : [])
} catch (err) {
console.error('Failed to load posts:', err)
} finally {
@@ -50,12 +56,15 @@ export default function PostProduction() {
}
const handleMovePost = async (postId, newStatus) => {
// Optimistic update move the card instantly
const prev = posts
setPosts(posts.map(p => p._id === postId ? { ...p, status: newStatus } : p))
try {
await api.patch(`/posts/${postId}`, { status: newStatus })
toast.success(t('posts.statusUpdated'))
loadPosts()
} catch (err) {
console.error('Move failed:', err)
setPosts(prev)
if (err.message?.includes('Cannot publish')) {
setMoveError(t('posts.publishRequired'))
setTimeout(() => setMoveError(''), 5000)
@@ -66,17 +75,6 @@ export default function PostProduction() {
}
}
const handlePanelSave = async (postId, data) => {
if (postId) {
await api.patch(`/posts/${postId}`, data)
toast.success(t('posts.updated'))
} else {
await api.post('/posts', data)
toast.success(t('posts.created'))
}
loadPosts()
}
const handlePanelDelete = async (postId) => {
try {
await api.delete(`/posts/${postId}`)
@@ -88,19 +86,50 @@ export default function PostProduction() {
}
}
const handleBulkDelete = async () => {
try {
await api.post('/posts/bulk-delete', { ids: [...selectedIds] })
toast.success(t('posts.deleted'))
setSelectedIds(new Set())
setShowBulkDeleteConfirm(false)
loadPosts()
} catch (err) {
console.error('Bulk delete failed:', err)
toast.error(t('common.deleteFailed'))
}
}
const toggleSelect = (id) => {
setSelectedIds(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const toggleSelectAll = () => {
if (selectedIds.size === filteredPosts.length) setSelectedIds(new Set())
else setSelectedIds(new Set(filteredPosts.map(p => p._id || p.id || p.Id)))
}
const openEdit = (post) => {
if (!canEditResource('post', post)) {
alert('You can only edit your own posts')
return
}
setPanelPost(post)
const postId = post._id || post.id || post.Id
navigate(`/posts/${postId}`)
}
const openNew = () => {
setPanelPost(EMPTY_POST)
const openNew = async () => {
try {
const result = await api.post('/posts', { title: '', status: 'draft', platforms: [] })
const newId = result._id || result.id || result.Id
toast.success(t('posts.created'))
navigate(`/posts/${newId}`)
} catch {
toast.error(t('common.saveFailed'))
}
}
const filteredPosts = posts.filter(p => {
const filteredPosts = useMemo(() => posts.filter(p => {
if (filters.brand && String(p.brandId || p.brand_id) !== filters.brand) return false
if (filters.platform && !(p.platforms || []).includes(filters.platform) && p.platform !== filters.platform) return false
if (filters.assignedTo && String(p.assignedTo || p.assigned_to) !== filters.assignedTo) return false
@@ -114,7 +143,7 @@ export default function PostProduction() {
if (filters.periodTo && d > filters.periodTo) return false
}
return true
})
}), [posts, filters, searchTerm])
if (loading) {
return view === 'kanban' ? <SkeletonKanbanBoard /> : <SkeletonTable rows={8} cols={6} />
@@ -123,85 +152,41 @@ export default function PostProduction() {
return (
<div className="space-y-4 animate-fade-in">
{/* Toolbar */}
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-3">
<div className="relative flex-1 min-w-[200px] max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<Search className="absolute start-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<input
type="text"
placeholder={t('posts.searchPosts')}
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-white"
className="w-full ps-10 pe-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface"
/>
</div>
<div data-tutorial="filters" className="flex flex-col gap-2">
<div className="flex items-center gap-2 flex-wrap">
<select
value={filters.brand}
onChange={e => setFilters(f => ({ ...f, brand: e.target.value }))}
className="text-xs border border-border rounded-lg px-2.5 py-1.5 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
<button
data-tutorial="filters"
onClick={() => setShowFilters(f => !f)}
className={`relative flex items-center gap-1.5 px-3 py-2 text-sm border rounded-lg transition-colors ${showFilters ? 'border-brand-primary bg-brand-primary/5 text-brand-primary' : 'border-border bg-surface text-text-secondary hover:border-brand-primary/40'}`}
>
<option value="">{t('posts.allBrands')}</option>
{brands.map(b => <option key={b._id} value={b._id}>{lang === 'ar' && b.name_ar ? b.name_ar : b.name}</option>)}
</select>
<Filter className="w-4 h-4" />
{t('common.filter')}
{(filters.brand || filters.platform || filters.assignedTo || filters.periodFrom || filters.periodTo) && (
<span className="w-1.5 h-1.5 rounded-full bg-brand-primary" />
)}
</button>
<select
value={filters.platform}
onChange={e => setFilters(f => ({ ...f, platform: e.target.value }))}
className="text-xs border border-border rounded-lg px-2.5 py-1.5 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
>
<option value="">{t('posts.allPlatforms')}</option>
{Object.entries(PLATFORMS).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
</select>
<select
value={filters.assignedTo}
onChange={e => setFilters(f => ({ ...f, assignedTo: e.target.value }))}
className="text-xs border border-border rounded-lg px-2.5 py-1.5 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
>
<option value="">{t('posts.allPeople')}</option>
{teamMembers.map(m => <option key={m._id} value={String(m._id)}>{m.name}</option>)}
</select>
</div>
<div className="flex items-center gap-2 flex-wrap">
<DatePresetPicker
activePreset={activePreset}
onSelect={(from, to, key) => { setFilters(f => ({ ...f, periodFrom: from, periodTo: to })); setActivePreset(key) }}
onClear={() => { setFilters(f => ({ ...f, periodFrom: '', periodTo: '' })); setActivePreset('') }}
/>
<div className="flex items-center gap-1.5">
<input
type="date"
value={filters.periodFrom}
onChange={e => { setFilters(f => ({ ...f, periodFrom: e.target.value })); setActivePreset('') }}
title={t('posts.periodFrom')}
className="text-xs border border-border rounded-lg px-2 py-1.5 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
/>
<span className="text-xs text-text-tertiary"></span>
<input
type="date"
value={filters.periodTo}
onChange={e => { setFilters(f => ({ ...f, periodTo: e.target.value })); setActivePreset('') }}
title={t('posts.periodTo')}
className="text-xs border border-border rounded-lg px-2 py-1.5 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
/>
</div>
</div>
</div>
<div className="flex bg-surface-tertiary rounded-lg p-0.5 ml-auto">
<div className="flex bg-surface-tertiary rounded-lg p-0.5 ms-auto">
<button
onClick={() => setView('kanban')}
className={`p-2 rounded-md ${view === 'kanban' ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary'}`}
className={`p-2 rounded-md ${view === 'kanban' ? 'bg-surface shadow-sm text-text-primary' : 'text-text-tertiary'}`}
>
<LayoutGrid className="w-4 h-4" />
</button>
<button
onClick={() => setView('list')}
className={`p-2 rounded-md ${view === 'list' ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary'}`}
className={`p-2 rounded-md ${view === 'list' ? 'bg-surface shadow-sm text-text-primary' : 'text-text-tertiary'}`}
>
<List className="w-4 h-4" />
</button>
@@ -217,6 +202,62 @@ export default function PostProduction() {
</button>
</div>
{showFilters && (
<div className="flex items-center gap-2 flex-wrap animate-fade-in">
<select
value={filters.brand}
onChange={e => setFilters(f => ({ ...f, brand: e.target.value }))}
className="text-xs border border-border rounded-lg px-2.5 py-1.5 bg-surface text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
>
<option value="">{t('posts.allBrands')}</option>
{brands.map(b => <option key={b._id} value={b._id}>{lang === 'ar' && b.name_ar ? b.name_ar : b.name}</option>)}
</select>
<select
value={filters.platform}
onChange={e => setFilters(f => ({ ...f, platform: e.target.value }))}
className="text-xs border border-border rounded-lg px-2.5 py-1.5 bg-surface text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
>
<option value="">{t('posts.allPlatforms')}</option>
{Object.entries(PLATFORMS).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
</select>
<select
value={filters.assignedTo}
onChange={e => setFilters(f => ({ ...f, assignedTo: e.target.value }))}
className="text-xs border border-border rounded-lg px-2.5 py-1.5 bg-surface text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
>
<option value="">{t('posts.allPeople')}</option>
{teamMembers.map(m => <option key={m._id} value={String(m._id)}>{m.name}</option>)}
</select>
<DatePresetPicker
activePreset={activePreset}
onSelect={(from, to, key) => { setFilters(f => ({ ...f, periodFrom: from, periodTo: to })); setActivePreset(key) }}
onClear={() => { setFilters(f => ({ ...f, periodFrom: '', periodTo: '' })); setActivePreset('') }}
/>
<div className="flex items-center gap-1.5">
<input
type="date"
value={filters.periodFrom}
onChange={e => { setFilters(f => ({ ...f, periodFrom: e.target.value })); setActivePreset('') }}
title={t('posts.periodFrom')}
className="text-xs border border-border rounded-lg px-2 py-1.5 bg-surface text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
/>
<span className="text-xs text-text-tertiary"></span>
<input
type="date"
value={filters.periodTo}
onChange={e => { setFilters(f => ({ ...f, periodTo: e.target.value })); setActivePreset('') }}
title={t('posts.periodTo')}
className="text-xs border border-border rounded-lg px-2 py-1.5 bg-surface text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
/>
</div>
</div>
)}
</div>
{moveError && (
<div className="p-3 bg-amber-50 border border-amber-200 rounded-lg text-sm text-amber-700 flex items-center justify-between">
<span>{moveError}</span>
@@ -227,9 +268,35 @@ export default function PostProduction() {
)}
{view === 'kanban' ? (
<KanbanBoard posts={filteredPosts} onPostClick={openEdit} onMovePost={handleMovePost} />
<KanbanBoard
columns={[
{ id: 'draft', label: t('posts.status.draft'), color: 'bg-gray-400' },
{ id: 'in_review', label: t('posts.status.in_review'), color: 'bg-amber-400' },
{ id: 'approved', label: t('posts.status.approved'), color: 'bg-blue-400' },
{ id: 'rejected', label: t('posts.status.rejected'), color: 'bg-red-400' },
{ id: 'scheduled', label: t('posts.status.scheduled'), color: 'bg-purple-400' },
{ id: 'published', label: t('posts.status.published'), color: 'bg-emerald-400' },
]}
items={filteredPosts}
getItemId={(p) => p._id}
onMove={(id, status) => handleMovePost(id, status)}
renderCard={(post) => {
const brandName = getBrandName(post.brand_id || post.brandId) || post.brand_name || post.brand
const assignee = post.assignedToName || post.assignedName || post.assigned_name
return (
<KanbanCard
title={post.title}
thumbnail={post.thumbnail_url}
brandName={brandName}
assigneeName={assignee}
date={post.scheduledDate}
onClick={() => openEdit(post)}
/>
)
}}
/>
) : (
<div className="bg-white rounded-xl border border-border overflow-hidden">
<div className="bg-surface rounded-xl border border-border overflow-clip">
{filteredPosts.length === 0 ? (
<EmptyState
icon={FileText}
@@ -244,39 +311,58 @@ export default function PostProduction() {
}}
/>
) : (
<>
{selectedIds.size > 0 && (
<div className="px-4 pt-3">
<BulkSelectBar selectedCount={selectedIds.size} onDelete={() => setShowBulkDeleteConfirm(true)} onClear={() => setSelectedIds(new Set())} />
</div>
)}
<table className="w-full">
<thead>
<tr className="border-b border-border bg-surface-secondary">
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.postTitle')}</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.brand')}</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.status')}</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.platforms')}</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.assignTo')}</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.scheduledDate')}</th>
<th className="w-10 px-3 py-3" onClick={e => e.stopPropagation()}>
<input type="checkbox" checked={selectedIds.size === filteredPosts.length && filteredPosts.length > 0} onChange={toggleSelectAll} className="rounded border-border" />
</th>
<th className="text-start px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.postTitle')}</th>
<th className="text-start px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.brand')}</th>
<th className="text-start px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.status')}</th>
<th className="text-start px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.platforms')}</th>
<th className="text-start px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.assignTo')}</th>
<th className="text-start px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.scheduledDate')}</th>
</tr>
</thead>
<tbody className="divide-y divide-border-light">
{filteredPosts.map(post => (
<PostCard key={post._id} post={post} onClick={() => openEdit(post)} />
))}
{filteredPosts.map(post => {
const postId = post._id || post.id || post.Id
return (
<PostCard
key={postId}
post={post}
onClick={() => openEdit(post)}
checkboxSlot={<input type="checkbox" checked={selectedIds.has(postId)} onChange={() => toggleSelect(postId)} className="rounded border-border" />}
/>
)
})}
</tbody>
</table>
</>
)}
</div>
)}
{/* Post Detail Panel */}
{panelPost && (
<PostDetailPanel
post={panelPost}
onClose={() => setPanelPost(null)}
onSave={handlePanelSave}
onDelete={handlePanelDelete}
brands={brands}
teamMembers={teamMembers}
campaigns={campaigns}
/>
)}
{/* Bulk Delete Confirmation */}
<Modal
isOpen={showBulkDeleteConfirm}
onClose={() => setShowBulkDeleteConfirm(false)}
title={t('common.bulkDeleteConfirm').replace('{count}', selectedIds.size)}
isConfirm
danger
confirmText={t('common.deleteSelected')}
onConfirm={handleBulkDelete}
>
{t('common.bulkDeleteDesc')}
</Modal>
</div>
)
}
+158 -36
View File
@@ -50,15 +50,15 @@ export default function ProjectDetail() {
useEffect(() => { loadProject() }, [id])
useEffect(() => {
api.get('/users/assignable').then(res => setAssignableUsers(res.data || res || [])).catch(() => {})
api.get('/users/assignable').then(res => setAssignableUsers(Array.isArray(res) ? res : [])).catch(() => {})
}, [])
const loadProject = async () => {
try {
const proj = await api.get(`/projects/${id}`)
setProject(proj.data || proj)
setProject(proj)
const tasksRes = await api.get(`/tasks?project_id=${id}`)
setTasks(Array.isArray(tasksRes) ? tasksRes : (tasksRes.data || []))
setTasks(Array.isArray(tasksRes) ? tasksRes : [])
} catch (err) {
console.error('Failed to load project:', err)
} finally {
@@ -223,14 +223,14 @@ export default function ProjectDetail() {
</button>
{/* Project header */}
<div className="bg-white rounded-xl border border-border overflow-hidden">
<div className="bg-surface rounded-xl border border-border overflow-clip">
{/* Thumbnail banner */}
{(project.thumbnail_url || project.thumbnailUrl) && (
<div className="relative w-full h-40 overflow-hidden">
<img src={project.thumbnail_url || project.thumbnailUrl} alt="" className="w-full h-full object-cover" />
<img src={project.thumbnail_url || project.thumbnailUrl} alt="" className="w-full h-full object-cover" loading="lazy" />
<div className="absolute inset-0 bg-gradient-to-t from-black/30 to-transparent" />
{canEditProject && (
<div className="absolute top-2 right-2 flex items-center gap-1">
<div className="absolute top-2 end-2 flex items-center gap-1">
<button
onClick={() => thumbnailInputRef.current?.click()}
className="px-2 py-1 text-xs bg-black/40 hover:bg-black/60 rounded text-white transition-colors"
@@ -341,7 +341,7 @@ export default function ProjectDetail() {
key={v.id}
onClick={() => setView(v.id)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
view === v.id ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary hover:text-text-secondary'
view === v.id ? 'bg-surface shadow-sm text-text-primary' : 'text-text-tertiary hover:text-text-secondary'
}`}
>
<v.icon className="w-4 h-4" />
@@ -411,21 +411,21 @@ export default function ProjectDetail() {
{/* ─── LIST VIEW ─── */}
{view === 'list' && (
<div className="bg-white rounded-xl border border-border overflow-hidden">
<div className="bg-surface rounded-xl border border-border overflow-clip">
<table className="w-full">
<thead>
<tr className="border-b border-border bg-surface-secondary">
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider w-8"></th>
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Task</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Status</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Priority</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Assignee</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Due</th>
<th className="text-start px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider w-8"></th>
<th className="text-start px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Task</th>
<th className="text-start px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Status</th>
<th className="text-start px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Priority</th>
<th className="text-start px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Assignee</th>
<th className="text-start px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Due</th>
</tr>
</thead>
<tbody className="divide-y divide-border-light">
{tasks.length === 0 ? (
<tr><td colSpan={6} className="py-12 text-center text-sm text-text-tertiary">No tasks yet</td></tr>
<tr><td colSpan={6} className="py-12 text-center text-sm text-text-tertiary">{t('tasks.noTasks')}</td></tr>
) : (
tasks.map(task => {
const prio = PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium
@@ -458,12 +458,19 @@ export default function ProjectDetail() {
)}
{/* ─── GANTT / TIMELINE VIEW ─── */}
{view === 'gantt' && <GanttView tasks={tasks} project={project} onEditTask={openEditTask} />}
{view === 'gantt' && <GanttView tasks={tasks} project={project} onEditTask={openEditTask} onTaskColorChange={async (taskId, color) => {
try {
await api.patch(`/tasks/${taskId}`, { color: color || '' })
loadProject()
} catch (err) {
console.error('Task color update failed:', err)
}
}} />}
</div>{/* end main content */}
{/* ─── DISCUSSION SIDEBAR ─── */}
{showDiscussion && (
<div className="w-[340px] shrink-0 bg-white rounded-xl border border-border flex flex-col self-start sticky top-4" style={{ maxHeight: 'calc(100vh - 6rem)' }}>
<div className="w-[340px] shrink-0 bg-surface rounded-xl border border-border flex flex-col self-start sticky top-4" style={{ maxHeight: 'calc(100vh - 6rem)' }}>
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-1.5">
<MessageCircle className="w-4 h-4" />
@@ -532,7 +539,7 @@ function TaskKanbanCard({ task, canEdit, canDelete, onClick, onDelete, onStatusC
onDragStart={(e) => canEdit && onDragStart(e, task)}
onDragEnd={onDragEnd}
onClick={onClick}
className={`bg-white rounded-lg border border-border p-3 group hover:border-brand-primary/30 hover:shadow-sm transition-all cursor-pointer ${canEdit ? 'active:cursor-grabbing' : ''}`}
className={`bg-surface rounded-lg border border-border p-3 group hover:border-brand-primary/30 hover:shadow-sm transition-all cursor-pointer ${canEdit ? 'active:cursor-grabbing' : ''}`}
>
<div className="flex items-start gap-2">
<div className={`w-2 h-2 rounded-full ${priority.color} mt-1.5 shrink-0`} title={priority.label} />
@@ -565,7 +572,7 @@ function TaskKanbanCard({ task, canEdit, canDelete, onClick, onDelete, onStatusC
)}
{canDelete && (
<button onClick={(e) => { e.stopPropagation(); onDelete() }}
className="text-[10px] text-red-400 hover:bg-red-50 px-2 py-0.5 rounded-full flex items-center gap-1 ml-auto">
className="text-[10px] text-red-400 hover:bg-red-50 px-2 py-0.5 rounded-full flex items-center gap-1 ms-auto">
<Trash2 className="w-3 h-3" />
</button>
)}
@@ -576,10 +583,38 @@ function TaskKanbanCard({ task, canEdit, canDelete, onClick, onDelete, onStatusC
}
// Gantt / Timeline View
function GanttView({ tasks, project, onEditTask }) {
const GANTT_ZOOM = [
{ key: 'month', label: 'Month', pxPerDay: 8 },
{ key: 'week', label: 'Week', pxPerDay: 20 },
{ key: 'day', label: 'Day', pxPerDay: 48 },
]
const GANTT_COLOR_PALETTE = [
'#6366f1', '#ec4899', '#10b981', '#f59e0b',
'#8b5cf6', '#06b6d4', '#f43f5e', '#14b8a6',
'#3b82f6', '#ef4444', '#84cc16', '#a855f7',
]
function GanttView({ tasks, project, onEditTask, onTaskColorChange }) {
const [zoomIdx, setZoomIdx] = useState(0)
const ganttRef = useRef(null)
const [colorPicker, setColorPicker] = useState(null)
const colorPickerRef = useRef(null)
useEffect(() => {
if (!colorPicker) return
const handleClick = (e) => {
if (colorPickerRef.current && !colorPickerRef.current.contains(e.target)) {
setColorPicker(null)
}
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [colorPicker])
if (tasks.length === 0) {
return (
<div className="bg-white rounded-xl border border-border py-16 text-center">
<div className="bg-surface rounded-xl border border-border py-16 text-center">
<GanttChart className="w-12 h-12 text-text-tertiary mx-auto mb-3" />
<p className="text-text-secondary font-medium">No tasks to display</p>
<p className="text-sm text-text-tertiary mt-1">Add tasks with due dates to see the timeline</p>
@@ -590,17 +625,19 @@ function GanttView({ tasks, project, onEditTask }) {
const today = startOfDay(new Date())
// Calculate range
let earliest = today
let latest = addDays(today, 21)
let earliest = addDays(today, -7)
let latest = addDays(today, 30)
tasks.forEach(t => {
const created = t.createdAt ? startOfDay(new Date(t.createdAt)) : today
const start = t.startDate || t.start_date ? startOfDay(new Date(t.startDate || t.start_date)) : created
const due = t.dueDate ? startOfDay(new Date(t.dueDate)) : null
if (isBefore(created, earliest)) earliest = created
if (due && isAfter(due, latest)) latest = addDays(due, 2)
if (isBefore(start, earliest)) earliest = addDays(start, -3)
if (isBefore(created, earliest)) earliest = addDays(created, -3)
if (due && isAfter(due, latest)) latest = addDays(due, 7)
})
if (project.dueDate) {
const pd = startOfDay(new Date(project.dueDate))
if (isAfter(pd, latest)) latest = addDays(pd, 2)
if (isAfter(pd, latest)) latest = addDays(pd, 7)
}
const totalDays = differenceInDays(latest, earliest) + 1
@@ -610,7 +647,7 @@ function GanttView({ tasks, project, onEditTask }) {
days.push(addDays(earliest, i))
}
const dayWidth = Math.max(36, Math.min(60, 800 / totalDays))
const dayWidth = GANTT_ZOOM[zoomIdx].pxPerDay
const getBarStyle = (task) => {
const start = task.startDate || task.start_date
@@ -629,8 +666,39 @@ function GanttView({ tasks, project, onEditTask }) {
}
return (
<div className="bg-white rounded-xl border border-border overflow-hidden">
<div className="overflow-x-auto">
<div className="bg-surface rounded-xl border border-border overflow-clip">
{/* Zoom toolbar */}
<div className="flex items-center justify-between px-4 py-2 border-b border-border bg-surface-secondary">
<div className="flex items-center gap-2">
{GANTT_ZOOM.map((z, i) => (
<button
key={z.key}
onClick={() => setZoomIdx(i)}
className={`px-3 py-1 text-xs font-medium rounded-md transition-colors ${
zoomIdx === i
? 'bg-brand-primary text-white shadow-sm'
: 'text-text-tertiary hover:text-text-primary hover:bg-surface-tertiary'
}`}
>
{z.label}
</button>
))}
</div>
<button
onClick={() => {
if (ganttRef.current) {
const todayOff = differenceInDays(today, earliest) * dayWidth
ganttRef.current.scrollTo({ left: Math.max(0, todayOff - 200), behavior: 'smooth' })
}
}}
className="flex items-center gap-1.5 px-3 py-1 text-xs font-medium text-brand-primary hover:bg-brand-primary/10 rounded-md transition-colors"
>
<Calendar className="w-3.5 h-3.5" />
Today
</button>
</div>
<div ref={ganttRef} className="overflow-x-auto">
<div style={{ minWidth: `${totalDays * dayWidth + 200}px` }}>
{/* Day headers */}
<div className="flex border-b border-border bg-surface-secondary sticky top-0 z-10">
@@ -641,6 +709,8 @@ function GanttView({ tasks, project, onEditTask }) {
{days.map((day, i) => {
const isToday = differenceInDays(day, today) === 0
const isWeekend = day.getDay() === 0 || day.getDay() === 6
const isMonthStart = day.getDate() === 1
const isWeekStart = day.getDay() === 1
return (
<div
key={i}
@@ -648,10 +718,17 @@ function GanttView({ tasks, project, onEditTask }) {
className={`text-center py-2 border-r border-border-light text-[10px] ${
isToday ? 'bg-brand-primary/10 font-bold text-brand-primary' :
isWeekend ? 'bg-surface-tertiary/50 text-text-tertiary' : 'text-text-tertiary'
}`}
} ${isMonthStart ? 'border-l-2 border-l-text-tertiary/30' : ''}`}
>
<div>{format(day, 'd')}</div>
<div className="text-[8px] uppercase">{format(day, 'EEE')}</div>
{dayWidth >= 30 && <div>{format(day, 'd')}</div>}
{dayWidth >= 40 && <div className="text-[8px] uppercase">{format(day, 'EEE')}</div>}
{dayWidth >= 15 && dayWidth < 30 && day.getDate() % 7 === 1 && <div>{format(day, 'd')}</div>}
{dayWidth < 15 && isMonthStart && (
<div className="text-[8px] font-semibold whitespace-nowrap">{format(day, 'MMM')}</div>
)}
{dayWidth < 15 && !isMonthStart && isWeekStart && day.getDate() % 14 <= 7 && (
<div className="text-[8px]">{format(day, 'd')}</div>
)}
</div>
)
})}
@@ -665,9 +742,22 @@ function GanttView({ tasks, project, onEditTask }) {
return (
<div key={task._id} className="flex border-b border-border-light hover:bg-surface-secondary/50 group">
<div className="w-[200px] shrink-0 px-4 py-3 border-r border-border flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${prio.color} shrink-0`} />
{onTaskColorChange && (
<button
onClick={(e) => {
e.stopPropagation()
const taskId = task._id || task.id
const rect = e.currentTarget.getBoundingClientRect()
setColorPicker(colorPicker?.taskId === taskId ? null : { taskId, x: rect.left, y: rect.bottom + 4 })
}}
className={`w-3.5 h-3.5 rounded-full border border-white shadow-sm shrink-0 hover:scale-125 transition-transform ${!task.color ? (statusColors[task.status] || 'bg-gray-300') : ''}`}
style={task.color ? { backgroundColor: task.color } : undefined}
title="Change color"
/>
)}
{!onTaskColorChange && <div className={`w-2 h-2 rounded-full ${prio.color} shrink-0`} />}
<button onClick={() => onEditTask(task)}
className="text-xs font-medium text-text-primary truncate hover:text-brand-primary text-left">
className="text-xs font-medium text-text-primary truncate hover:text-brand-primary text-start">
{task.title}
</button>
</div>
@@ -681,8 +771,8 @@ function GanttView({ tasks, project, onEditTask }) {
)}
{/* Bar */}
<div
className={`absolute top-2.5 h-5 rounded-full ${statusColors[task.status] || 'bg-gray-300'} opacity-80 hover:opacity-100 transition-opacity cursor-pointer`}
style={barStyle}
className={`absolute top-2.5 h-5 rounded-full ${task.color ? '' : (statusColors[task.status] || 'bg-gray-300')} opacity-80 hover:opacity-100 transition-opacity cursor-pointer`}
style={{ ...barStyle, ...(task.color ? { backgroundColor: task.color } : {}) }}
onClick={() => onEditTask(task)}
title={`${task.title}${task.dueDate ? ` — Due ${format(new Date(task.dueDate), 'MMM d')}` : ''}`}
/>
@@ -692,6 +782,38 @@ function GanttView({ tasks, project, onEditTask }) {
})}
</div>
</div>
{/* Color Picker Popover */}
{colorPicker && onTaskColorChange && (
<div
ref={colorPickerRef}
className="fixed z-50 bg-surface rounded-lg shadow-xl border border-border p-2"
style={{ left: colorPicker.x, top: colorPicker.y }}
>
<div className="grid grid-cols-4 gap-1.5 mb-2">
{GANTT_COLOR_PALETTE.map(c => (
<button
key={c}
onClick={() => {
onTaskColorChange(colorPicker.taskId, c)
setColorPicker(null)
}}
className="w-7 h-7 rounded-full border-2 border-transparent hover:border-gray-400 hover:scale-110 transition-all"
style={{ backgroundColor: c }}
/>
))}
</div>
<button
onClick={() => {
onTaskColorChange(colorPicker.taskId, null)
setColorPicker(null)
}}
className="w-full text-[10px] text-text-tertiary hover:text-text-primary text-center py-1 hover:bg-surface-tertiary rounded transition-colors"
>
Reset to default
</button>
</div>
)}
</div>
)
}
+32 -7
View File
@@ -11,12 +11,12 @@ import { SkeletonCard } from '../components/SkeletonLoader'
const EMPTY_PROJECT = {
name: '', description: '', brand_id: '', status: 'active',
owner_id: '', start_date: '', due_date: '',
owner_id: '', start_date: '', due_date: '', team_id: '',
}
export default function Projects() {
const navigate = useNavigate()
const { teamMembers, brands } = useContext(AppContext)
const { teamMembers, brands, teams } = useContext(AppContext)
const { permissions } = useAuth()
const [projects, setProjects] = useState([])
const [loading, setLoading] = useState(true)
@@ -30,7 +30,7 @@ export default function Projects() {
const loadProjects = async () => {
try {
const res = await api.get('/projects')
setProjects(res.data || res || [])
setProjects(Array.isArray(res) ? res : [])
} catch (err) {
console.error('Failed to load projects:', err)
} finally {
@@ -45,6 +45,7 @@ export default function Projects() {
description: formData.description,
brand_id: formData.brand_id ? Number(formData.brand_id) : null,
owner_id: formData.owner_id ? Number(formData.owner_id) : null,
team_id: formData.team_id ? Number(formData.team_id) : null,
status: formData.status,
start_date: formData.start_date || null,
due_date: formData.due_date || null,
@@ -79,13 +80,13 @@ export default function Projects() {
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-3">
<div className="relative flex-1 min-w-[200px] max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<Search className="absolute start-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<input
type="text"
placeholder="Search projects..."
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-white"
className="w-full ps-10 pe-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface"
/>
</div>
@@ -99,7 +100,7 @@ export default function Projects() {
key={v.id}
onClick={() => setView(v.id)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
view === v.id ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary hover:text-text-secondary'
view === v.id ? 'bg-surface shadow-sm text-text-primary' : 'text-text-tertiary hover:text-text-secondary'
}`}
>
<v.icon className="w-4 h-4" />
@@ -111,7 +112,7 @@ export default function Projects() {
{permissions?.canCreateProjects && (
<button
onClick={() => setShowModal(true)}
className="flex items-center gap-2 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm ml-auto"
className="flex items-center gap-2 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm ms-auto"
>
<Plus className="w-4 h-4" />
New Project
@@ -146,6 +147,7 @@ export default function Projects() {
assigneeName: project.ownerName || project.owner_name,
thumbnailUrl: project.thumbnail_url || project.thumbnailUrl,
tags: [project.status, project.priority].filter(Boolean),
color: project.color,
})}
onDateChange={async (projectId, { startDate, endDate }) => {
try {
@@ -156,6 +158,15 @@ export default function Projects() {
loadProjects()
}
}}
onColorChange={async (projectId, color) => {
try {
await api.patch(`/projects/${projectId}`, { color: color || '' })
} catch (err) {
console.error('Color update failed:', err)
} finally {
loadProjects()
}
}}
onItemClick={(project) => {
navigate(`/projects/${project._id || project.id}`)
}}
@@ -226,6 +237,20 @@ export default function Projects() {
{teamMembers.map(m => <option key={m._id} value={m._id}>{m.name}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Team</label>
<select
value={formData.team_id}
onChange={e => setFormData(f => ({ ...f, team_id: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
<option value="">No team</option>
{teams.map(t => <option key={t.id || t._id} value={t.id || t._id}>{t.name}</option>)}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Start Date</label>
<input
+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>
)
}
+221 -186
View File
@@ -1,61 +1,175 @@
import { useState } from 'react'
import { AlertCircle, Send, CheckCircle2, Upload, X } from 'lucide-react'
import { useState, useEffect } from 'react'
import { AlertCircle, Send, CheckCircle2, Upload, X, Globe } from 'lucide-react'
import { api } from '../utils/api'
import FormInput from '../components/FormInput'
import { useToast } from '../components/ToastContainer'
// Bilingual translations
const T = {
en: {
pageTitle: 'Submit an Issue',
pageSubtitle: 'Report issues, request corrections, or make suggestions. We\'ll track your submission and keep you updated.',
yourInfo: 'Your Information',
name: 'Name',
namePlaceholder: 'Your full name',
nameRequired: 'Name is required',
email: 'Email',
emailPlaceholder: 'your.email@example.com',
emailRequired: 'Email is required',
emailInvalid: 'Invalid email address',
phone: 'Phone (Optional)',
phonePlaceholder: '+966 5X XXX XXXX',
teamQuestion: 'Which team should handle your issue?',
selectTeam: 'Select a team',
issueDetails: 'Issue Details',
category: 'Category',
categoryPlaceholder: 'e.g., Marketing, IT, Operations',
type: 'Type',
priority: 'Priority',
title: 'Title',
titlePlaceholder: 'Brief summary of the issue',
titleRequired: 'Title is required',
description: 'Description',
descriptionPlaceholder: 'Provide detailed information about the issue...',
descriptionRequired: 'Description is required',
attachment: 'Attachment (Optional)',
uploadPrompt: 'Click to upload a file (screenshots, documents, etc.)',
submit: 'Submit Issue',
submitting: 'Submitting...',
submitFailed: 'Failed to submit issue. Please try again.',
footerNote: 'You\'ll receive a tracking link to monitor the progress of your issue.',
// Success page
successTitle: 'Issue Submitted Successfully!',
successMessage: 'Thank you for submitting your issue. You can track its progress using the link below.',
trackingLink: 'Your Tracking Link',
copy: 'Copy',
copied: 'Copied to clipboard!',
trackIssue: 'Track Your Issue',
submitAnother: 'Submit Another Issue',
// Options
request: 'Request', correction: 'Correction', complaint: 'Complaint', suggestion: 'Suggestion', other: 'Other',
low: 'Low', medium: 'Medium', high: 'High', urgent: 'Urgent',
},
ar: {
pageTitle: 'تقديم مشكلة',
pageSubtitle: 'أبلغ عن مشاكل، اطلب تصحيحات، أو قدّم اقتراحات. سنتابع طلبك ونبقيك على اطلاع.',
yourInfo: 'معلوماتك',
name: 'الاسم',
namePlaceholder: 'الاسم الكامل',
nameRequired: 'الاسم مطلوب',
email: 'البريد الإلكتروني',
emailPlaceholder: 'your.email@example.com',
emailRequired: 'البريد الإلكتروني مطلوب',
emailInvalid: 'بريد إلكتروني غير صالح',
phone: 'الهاتف (اختياري)',
phonePlaceholder: '+966 5X XXX XXXX',
teamQuestion: 'أي فريق يجب أن يتعامل مع مشكلتك؟',
selectTeam: 'اختر فريقاً',
issueDetails: 'تفاصيل المشكلة',
category: 'الفئة',
categoryPlaceholder: 'مثال: تسويق، تقنية، عمليات',
type: 'النوع',
priority: 'الأولوية',
title: 'العنوان',
titlePlaceholder: 'ملخص موجز للمشكلة',
titleRequired: 'العنوان مطلوب',
description: 'الوصف',
descriptionPlaceholder: 'قدّم معلومات مفصلة عن المشكلة...',
descriptionRequired: 'الوصف مطلوب',
attachment: 'مرفق (اختياري)',
uploadPrompt: 'اضغط لرفع ملف (لقطات شاشة، مستندات، إلخ)',
submit: 'تقديم المشكلة',
submitting: 'جارٍ التقديم...',
submitFailed: 'فشل في تقديم المشكلة. يرجى المحاولة مرة أخرى.',
footerNote: 'ستتلقى رابط تتبع لمراقبة تقدم مشكلتك.',
successTitle: 'تم تقديم المشكلة بنجاح!',
successMessage: 'شكراً لتقديم مشكلتك. يمكنك تتبع تقدمها من خلال الرابط أدناه.',
trackingLink: 'رابط التتبع',
copy: 'نسخ',
copied: 'تم النسخ!',
trackIssue: 'تتبع مشكلتك',
submitAnother: 'تقديم مشكلة أخرى',
request: 'طلب', correction: 'تصحيح', complaint: 'شكوى', suggestion: 'اقتراح', other: 'أخرى',
low: 'منخفضة', medium: 'متوسطة', high: 'عالية', urgent: 'عاجلة',
},
}
function detectLang() {
const nav = navigator.language || navigator.userLanguage || ''
return nav.startsWith('ar') ? 'ar' : 'en'
}
function LangToggle({ lang, setLang }) {
return (
<button
onClick={() => setLang(lang === 'en' ? 'ar' : 'en')}
className="fixed top-4 end-4 z-50 flex items-center gap-1.5 px-3 py-1.5 bg-surface border border-border rounded-full shadow-sm hover:bg-surface-secondary transition-colors text-sm font-medium text-text-primary"
>
<Globe className="w-4 h-4" />
{lang === 'en' ? 'العربية' : 'English'}
</button>
)
}
export default function PublicIssueSubmit() {
const toast = useToast()
const [lang, setLang] = useState(detectLang)
const t = (key) => T[lang]?.[key] || T.en[key] || key
const dir = lang === 'ar' ? 'rtl' : 'ltr'
useEffect(() => {
document.documentElement.dir = dir
document.documentElement.lang = lang
return () => { document.documentElement.dir = 'ltr'; document.documentElement.lang = 'en' }
}, [lang, dir])
const TYPE_OPTIONS = [
{ value: 'request', label: 'Request' },
{ value: 'correction', label: 'Correction' },
{ value: 'complaint', label: 'Complaint' },
{ value: 'suggestion', label: 'Suggestion' },
{ value: 'other', label: 'Other' },
{ value: 'request', label: t('request') },
{ value: 'correction', label: t('correction') },
{ value: 'complaint', label: t('complaint') },
{ value: 'suggestion', label: t('suggestion') },
{ value: 'other', label: t('other') },
]
const PRIORITY_OPTIONS = [
{ value: 'low', label: 'Low' },
{ value: 'medium', label: 'Medium' },
{ value: 'high', label: 'High' },
{ value: 'urgent', label: 'Urgent' },
{ value: 'low', label: t('low') },
{ value: 'medium', label: t('medium') },
{ value: 'high', label: t('high') },
{ value: 'urgent', label: t('urgent') },
]
export default function PublicIssueSubmit() {
const urlParams = new URLSearchParams(window.location.search)
const teamParam = urlParams.get('team')
const [form, setForm] = useState({
name: '',
email: '',
phone: '',
category: 'Marketing',
type: 'request',
priority: 'medium',
title: '',
description: '',
name: '', email: '', phone: '', category: 'Marketing',
type: 'request', priority: 'medium', title: '', description: '', team_id: teamParam || '',
})
const [file, setFile] = useState(null)
const [submitting, setSubmitting] = useState(false)
const [submitted, setSubmitted] = useState(false)
const [trackingToken, setTrackingToken] = useState('')
const [errors, setErrors] = useState({})
const [teams, setTeams] = useState([])
useEffect(() => {
if (!teamParam) {
api.get('/public/teams').then(r => setTeams(Array.isArray(r) ? r : [])).catch(() => {})
}
}, [])
const updateForm = (field, value) => {
setForm((f) => ({ ...f, [field]: value }))
if (errors[field]) {
setErrors((e) => ({ ...e, [field]: '' }))
}
}
const handleFileChange = (e) => {
const selectedFile = e.target.files?.[0]
if (selectedFile) {
setFile(selectedFile)
}
if (errors[field]) setErrors((e) => ({ ...e, [field]: '' }))
}
const validate = () => {
const newErrors = {}
if (!form.name.trim()) newErrors.name = 'Name is required'
if (!form.email.trim()) newErrors.email = 'Email is required'
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) newErrors.email = 'Invalid email address'
if (!form.title.trim()) newErrors.title = 'Title is required'
if (!form.description.trim()) newErrors.description = 'Description is required'
if (!form.name.trim()) newErrors.name = t('nameRequired')
if (!form.email.trim()) newErrors.email = t('emailRequired')
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) newErrors.email = t('emailInvalid')
if (!form.title.trim()) newErrors.title = t('titleRequired')
if (!form.description.trim()) newErrors.description = t('descriptionRequired')
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
@@ -63,7 +177,6 @@ export default function PublicIssueSubmit() {
const handleSubmit = async (e) => {
e.preventDefault()
if (!validate() || submitting) return
try {
setSubmitting(true)
const formData = new FormData()
@@ -75,16 +188,14 @@ export default function PublicIssueSubmit() {
formData.append('priority', form.priority)
formData.append('title', form.title)
formData.append('description', form.description)
if (file) {
formData.append('file', file)
}
if (form.team_id) formData.append('team_id', form.team_id)
if (file) formData.append('file', file)
const result = await api.upload('/public/issues', formData)
setTrackingToken(result.token)
setSubmitted(true)
} catch (err) {
console.error('Submit error:', err)
alert('Failed to submit issue. Please try again.')
toast.error(t('submitFailed'))
} finally {
setSubmitting(false)
}
@@ -93,64 +204,40 @@ export default function PublicIssueSubmit() {
if (submitted) {
const trackingUrl = `${window.location.origin}/track/${trackingToken}`
return (
<div className="min-h-screen bg-surface-secondary py-8 px-4">
<div className="min-h-screen bg-surface-secondary py-8 px-4" dir={dir}>
<LangToggle lang={lang} setLang={setLang} />
<div className="max-w-lg mx-auto bg-surface rounded-xl border border-border p-6">
<div className="text-center">
<div className="w-16 h-16 bg-emerald-100 rounded-full flex items-center justify-center mx-auto mb-4">
<CheckCircle2 className="w-10 h-10 text-emerald-600" />
</div>
<h1 className="text-2xl font-bold text-text-primary mb-2">Issue Submitted Successfully!</h1>
<p className="text-sm text-text-tertiary mb-6">
Thank you for submitting your issue. You can track its progress using the link below.
</p>
<h1 className="text-2xl font-bold text-text-primary mb-2">{t('successTitle')}</h1>
<p className="text-sm text-text-tertiary mb-6">{t('successMessage')}</p>
<div className="bg-surface-secondary rounded-lg p-4 mb-6">
<label className="block text-xs font-semibold text-text-tertiary uppercase mb-2">Your Tracking Link</label>
<label className="block text-xs font-semibold text-text-tertiary uppercase mb-2">{t('trackingLink')}</label>
<div className="flex gap-2">
<input
type="text"
value={trackingUrl}
readOnly
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 transition-colors"
/>
<button
onClick={() => {
navigator.clipboard.writeText(trackingUrl)
alert('Copied to clipboard!')
}}
className="px-4 py-2 bg-brand-primary text-white rounded-lg font-medium hover:bg-brand-primary-light transition-colors"
>
Copy
<input type="text" value={trackingUrl} readOnly
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg bg-surface text-text-primary focus:outline-none" dir="ltr" />
<button onClick={() => { navigator.clipboard.writeText(trackingUrl); toast.success(t('copied')) }}
className="px-4 py-2 bg-brand-primary text-white rounded-lg font-medium hover:bg-brand-primary-light transition-colors">
{t('copy')}
</button>
</div>
</div>
<div className="space-y-2">
<a
href={`/track/${trackingToken}`}
className="block w-full px-4 py-2 bg-brand-primary text-white rounded-lg font-medium hover:bg-brand-primary-light transition-colors"
>
Track Your Issue
<a href={`/track/${trackingToken}`}
className="block w-full px-4 py-2 bg-brand-primary text-white rounded-lg font-medium hover:bg-brand-primary-light transition-colors">
{t('trackIssue')}
</a>
<button
onClick={() => {
setSubmitted(false)
setTrackingToken('')
setForm({
name: '',
email: '',
phone: '',
category: 'Marketing',
type: 'request',
priority: 'medium',
title: '',
description: '',
})
<button onClick={() => {
setSubmitted(false); setTrackingToken('')
setForm({ name: '', email: '', phone: '', category: 'Marketing', type: 'request', priority: 'medium', title: '', description: '', team_id: teamParam || '' })
setFile(null)
}}
className="block w-full px-3 py-1.5 border border-border text-text-secondary rounded-lg hover:bg-surface-tertiary transition-colors"
>
Submit Another Issue
className="block w-full px-3 py-1.5 border border-border text-text-secondary rounded-lg hover:bg-surface-tertiary transition-colors">
{t('submitAnother')}
</button>
</div>
</div>
@@ -160,177 +247,125 @@ export default function PublicIssueSubmit() {
}
return (
<div className="min-h-screen bg-surface-secondary py-8 px-4">
<div className="min-h-screen bg-surface-secondary py-8 px-4" dir={dir}>
<LangToggle lang={lang} setLang={setLang} />
<div className="max-w-lg mx-auto bg-surface rounded-xl border border-border p-6">
{/* Header */}
<div className="text-center mb-6">
<div className="w-12 h-12 bg-brand-primary/10 rounded-full flex items-center justify-center mx-auto mb-3">
<AlertCircle className="w-6 h-6 text-brand-primary" />
</div>
<h1 className="text-2xl font-bold text-text-primary mb-2">Submit an Issue</h1>
<p className="text-sm text-text-tertiary">
Report issues, request corrections, or make suggestions. We'll track your submission and keep you updated.
</p>
<h1 className="text-2xl font-bold text-text-primary mb-2">{t('pageTitle')}</h1>
<p className="text-sm text-text-tertiary">{t('pageSubtitle')}</p>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-4">
{/* Contact Information */}
<div>
<h2 className="text-sm font-semibold text-text-primary mb-2">Your Information</h2>
<h2 className="text-sm font-semibold text-text-primary mb-2">{t('yourInfo')}</h2>
<div className="space-y-3">
<FormInput
label="Name"
value={form.name}
onChange={(e) => updateForm('name', e.target.value)}
placeholder="Your full name"
required
error={errors.name}
/>
<FormInput
label="Email"
type="email"
value={form.email}
onChange={(e) => updateForm('email', e.target.value)}
placeholder="your.email@example.com"
required
error={errors.email}
/>
<FormInput
label="Phone (Optional)"
value={form.phone}
onChange={(e) => updateForm('phone', e.target.value)}
placeholder="+966 5X XXX XXXX"
/>
<FormInput label={t('name')} value={form.name} onChange={(e) => updateForm('name', e.target.value)}
placeholder={t('namePlaceholder')} required error={errors.name} />
<FormInput label={t('email')} type="email" value={form.email} onChange={(e) => updateForm('email', e.target.value)}
placeholder={t('emailPlaceholder')} required error={errors.email} />
<FormInput label={t('phone')} value={form.phone} onChange={(e) => updateForm('phone', e.target.value)}
placeholder={t('phonePlaceholder')} />
</div>
</div>
{/* Team Selection */}
{!teamParam && teams.length > 0 && (
<div>
<h2 className="text-sm font-semibold text-text-primary mb-2">{t('teamQuestion')}</h2>
<select value={form.team_id} onChange={(e) => updateForm('team_id', e.target.value)}
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 transition-colors">
<option value="">{t('selectTeam')}</option>
{teams.map((team) => <option key={team.id} value={team.id}>{team.name}</option>)}
</select>
</div>
)}
{/* Issue Details */}
<div>
<h2 className="text-sm font-semibold text-text-primary mb-2">Issue Details</h2>
<h2 className="text-sm font-semibold text-text-primary mb-2">{t('issueDetails')}</h2>
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-text-primary mb-1.5">
Category <span className="text-red-500">*</span>
{t('category')} <span className="text-red-500">*</span>
</label>
<input
type="text"
value={form.category}
onChange={(e) => updateForm('category', e.target.value)}
placeholder="e.g., Marketing, IT, Operations"
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 transition-colors"
/>
<input type="text" value={form.category} onChange={(e) => updateForm('category', e.target.value)}
placeholder={t('categoryPlaceholder')}
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 transition-colors" />
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1.5">
Type <span className="text-red-500">*</span>
{t('type')} <span className="text-red-500">*</span>
</label>
<select
value={form.type}
onChange={(e) => updateForm('type', e.target.value)}
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 transition-colors"
>
{TYPE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
<select value={form.type} onChange={(e) => updateForm('type', e.target.value)}
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 transition-colors">
{TYPE_OPTIONS.map((opt) => <option key={opt.value} value={opt.value}>{opt.label}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1.5">
Priority <span className="text-red-500">*</span>
{t('priority')} <span className="text-red-500">*</span>
</label>
<select
value={form.priority}
onChange={(e) => updateForm('priority', e.target.value)}
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 transition-colors"
>
{PRIORITY_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
<select value={form.priority} onChange={(e) => updateForm('priority', e.target.value)}
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 transition-colors">
{PRIORITY_OPTIONS.map((opt) => <option key={opt.value} value={opt.value}>{opt.label}</option>)}
</select>
</div>
<FormInput
label="Title"
value={form.title}
onChange={(e) => updateForm('title', e.target.value)}
placeholder="Brief summary of the issue"
required
error={errors.title}
/>
<FormInput
label="Description"
type="textarea"
value={form.description}
<FormInput label={t('title')} value={form.title} onChange={(e) => updateForm('title', e.target.value)}
placeholder={t('titlePlaceholder')} required error={errors.title} />
<FormInput label={t('description')} type="textarea" value={form.description}
onChange={(e) => updateForm('description', e.target.value)}
placeholder="Provide detailed information about the issue..."
rows={6}
required
error={errors.description}
/>
placeholder={t('descriptionPlaceholder')} rows={6} required error={errors.description} />
</div>
</div>
{/* File Upload */}
<div>
<h2 className="text-sm font-semibold text-text-primary mb-2">Attachment (Optional)</h2>
<h2 className="text-sm font-semibold text-text-primary mb-2">{t('attachment')}</h2>
<label className="block cursor-pointer">
<input type="file" onChange={handleFileChange} className="hidden" />
<input type="file" onChange={(e) => { if (e.target.files?.[0]) setFile(e.target.files[0]) }} className="hidden" />
<div className="border-2 border-dashed border-border rounded-lg p-4 text-center hover:bg-surface-secondary/50 transition-colors">
<Upload className="w-6 h-6 mx-auto mb-2 text-text-tertiary" />
{file ? (
<div className="flex items-center justify-center gap-2">
<p className="text-sm text-text-primary font-medium">{file.name}</p>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
setFile(null)
}}
className="p-1 hover:bg-surface-tertiary rounded"
>
<button type="button" onClick={(e) => { e.stopPropagation(); setFile(null) }} className="p-1 hover:bg-surface-tertiary rounded">
<X className="w-4 h-4 text-red-600" />
</button>
</div>
) : (
<p className="text-sm text-text-tertiary">Click to upload a file (screenshots, documents, etc.)</p>
<p className="text-sm text-text-tertiary">{t('uploadPrompt')}</p>
)}
</div>
</label>
</div>
{/* Submit Button */}
<button
type="submit"
disabled={submitting}
className="w-full px-4 py-2 bg-brand-primary text-white rounded-lg font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center justify-center gap-2"
>
{/* Submit */}
<button type="submit" disabled={submitting}
className="w-full px-4 py-2 bg-brand-primary text-white rounded-lg font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center justify-center gap-2">
{submitting ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
Submitting...
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
{t('submitting')}
</>
) : (
<>
<Send className="w-5 h-5" />
Submit Issue
{t('submit')}
</>
)}
</button>
</form>
{/* Footer Note */}
<p className="text-xs text-text-tertiary text-center mt-4">
You'll receive a tracking link to monitor the progress of your issue.
</p>
<p className="text-xs text-text-tertiary text-center mt-4">{t('footerNote')}</p>
</div>
</div>
)
+176 -144
View File
@@ -1,54 +1,126 @@
import { useState, useEffect } from 'react'
import { useParams } from 'react-router-dom'
import { AlertCircle, Clock, CheckCircle2, XCircle, MessageCircle, Upload, FileText, Send } from 'lucide-react'
import { AlertCircle, Clock, CheckCircle2, XCircle, MessageCircle, Upload, FileText, Send, Globe } from 'lucide-react'
import { api } from '../utils/api'
import { useToast } from '../components/ToastContainer'
const STATUS_CONFIG = {
new: { label: 'New', bg: 'bg-purple-100', text: 'text-purple-700', dot: 'bg-purple-500', icon: AlertCircle },
acknowledged: { label: 'Acknowledged', bg: 'bg-blue-100', text: 'text-blue-700', dot: 'bg-blue-500', icon: CheckCircle2 },
in_progress: { label: 'In Progress', bg: 'bg-amber-100', text: 'text-amber-700', dot: 'bg-amber-500', icon: Clock },
resolved: { label: 'Resolved', bg: 'bg-emerald-100', text: 'text-emerald-700', dot: 'bg-emerald-500', icon: CheckCircle2 },
declined: { label: 'Declined', bg: 'bg-gray-100', text: 'text-gray-700', dot: 'bg-gray-500', icon: XCircle },
// Bilingual translations
const T = {
en: {
description: 'Description',
submitted: 'Submitted',
lastUpdated: 'Last Updated',
resolution: 'Resolution',
declined: 'Declined',
progressUpdates: 'Progress Updates',
noUpdates: 'No updates yet. We\'ll post updates here as we work on your issue.',
team: 'Team', you: 'You',
attachments: 'Attachments',
download: 'Download',
addComment: 'Add a Comment',
yourName: 'Your Name (Optional)',
yourNamePlaceholder: 'Your name',
message: 'Message',
messagePlaceholder: 'Add additional information or ask a question...',
sendComment: 'Send Comment',
sending: 'Sending...',
uploadFile: 'Upload File',
uploading: 'Uploading...',
bookmarkNote: 'Bookmark this page to check your issue status anytime.',
notFoundTitle: 'Issue Not Found',
notFoundMessage: 'The tracking link you used is invalid or the issue has been removed.',
submitNew: 'Submit a New Issue',
failedComment: 'Failed to add comment',
failedUpload: 'Failed to upload file',
priority: 'Priority',
// Status
new: 'New', acknowledged: 'Acknowledged', in_progress: 'In Progress', resolved: 'Resolved', declined_status: 'Declined',
// Priority
low: 'Low', medium: 'Medium', high: 'High', urgent: 'Urgent',
},
ar: {
description: 'الوصف',
submitted: 'تم التقديم',
lastUpdated: 'آخر تحديث',
resolution: 'الحل',
declined: 'مرفوض',
progressUpdates: 'تحديثات التقدم',
noUpdates: 'لا توجد تحديثات بعد. سننشر التحديثات هنا أثناء العمل على مشكلتك.',
team: 'الفريق', you: 'أنت',
attachments: 'المرفقات',
download: 'تحميل',
addComment: 'إضافة تعليق',
yourName: 'اسمك (اختياري)',
yourNamePlaceholder: 'اسمك',
message: 'الرسالة',
messagePlaceholder: 'أضف معلومات إضافية أو اطرح سؤالاً...',
sendComment: 'إرسال التعليق',
sending: 'جارٍ الإرسال...',
uploadFile: 'رفع ملف',
uploading: 'جارٍ الرفع...',
bookmarkNote: 'احفظ هذه الصفحة للاطلاع على حالة مشكلتك في أي وقت.',
notFoundTitle: 'المشكلة غير موجودة',
notFoundMessage: 'رابط التتبع الذي استخدمته غير صالح أو تمت إزالة المشكلة.',
submitNew: 'تقديم مشكلة جديدة',
failedComment: 'فشل في إضافة التعليق',
failedUpload: 'فشل في رفع الملف',
priority: 'الأولوية',
new: 'جديد', acknowledged: 'تم الاستلام', in_progress: 'قيد التنفيذ', resolved: 'تم الحل', declined_status: 'مرفوض',
low: 'منخفضة', medium: 'متوسطة', high: 'عالية', urgent: 'عاجلة',
},
}
const PRIORITY_CONFIG = {
low: { label: 'Low', color: 'text-gray-700' },
medium: { label: 'Medium', color: 'text-blue-700' },
high: { label: 'High', color: 'text-orange-700' },
urgent: { label: 'Urgent', color: 'text-red-700' },
function detectLang() {
const nav = navigator.language || navigator.userLanguage || ''
return nav.startsWith('ar') ? 'ar' : 'en'
}
function LangToggle({ lang, setLang }) {
return (
<button
onClick={() => setLang(lang === 'en' ? 'ar' : 'en')}
className="fixed top-4 end-4 z-50 flex items-center gap-1.5 px-3 py-1.5 bg-surface border border-border rounded-full shadow-sm hover:bg-surface-secondary transition-colors text-sm font-medium text-text-primary"
>
<Globe className="w-4 h-4" />
{lang === 'en' ? 'العربية' : 'English'}
</button>
)
}
export default function PublicIssueTracker() {
const { token } = useParams()
const toast = useToast()
const [lang, setLang] = useState(detectLang)
const t = (key) => T[lang]?.[key] || T.en[key] || key
const dir = lang === 'ar' ? 'rtl' : 'ltr'
useEffect(() => {
document.documentElement.dir = dir
document.documentElement.lang = lang
return () => { document.documentElement.dir = 'ltr'; document.documentElement.lang = 'en' }
}, [lang, dir])
const [issue, setIssue] = useState(null)
const [updates, setUpdates] = useState([])
const [attachments, setAttachments] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
// Comment form
const [commentName, setCommentName] = useState('')
const [commentMessage, setCommentMessage] = useState('')
const [submittingComment, setSubmittingComment] = useState(false)
// File upload
const [uploadingFile, setUploadingFile] = useState(false)
useEffect(() => {
loadIssue()
}, [token])
useEffect(() => { loadIssue() }, [token])
const loadIssue = async () => {
try {
setLoading(true)
setError(null)
setLoading(true); setError(null)
const data = await api.get(`/public/issues/${token}`)
setIssue(data.issue)
setUpdates(data.updates || [])
setAttachments(data.attachments || [])
} catch (err) {
console.error('Failed to load issue:', err)
setError(err.response?.status === 404 ? 'Issue not found' : 'Failed to load issue')
setError(err.response?.status === 404 ? 'notFound' : 'error')
} finally {
setLoading(false)
}
@@ -57,27 +129,18 @@ export default function PublicIssueTracker() {
const handleAddComment = async (e) => {
e.preventDefault()
if (!commentMessage.trim() || submittingComment) return
try {
setSubmittingComment(true)
await api.post(`/public/issues/${token}/comment`, {
name: commentName.trim() || 'Anonymous',
message: commentMessage,
})
await api.post(`/public/issues/${token}/comment`, { name: commentName.trim() || 'Anonymous', message: commentMessage })
setCommentMessage('')
await loadIssue()
} catch (err) {
console.error('Failed to add comment:', err)
alert('Failed to add comment')
} finally {
setSubmittingComment(false)
}
} catch { toast.error(t('failedComment')) }
finally { setSubmittingComment(false) }
}
const handleFileUpload = async (e) => {
const file = e.target.files?.[0]
if (!file) return
try {
setUploadingFile(true)
const formData = new FormData()
@@ -85,52 +148,52 @@ export default function PublicIssueTracker() {
formData.append('name', commentName.trim() || 'Anonymous')
await api.upload(`/public/issues/${token}/attachments`, formData)
await loadIssue()
e.target.value = '' // Reset input
} catch (err) {
console.error('Failed to upload file:', err)
alert('Failed to upload file')
} finally {
setUploadingFile(false)
}
e.target.value = ''
} catch { toast.error(t('failedUpload')) }
finally { setUploadingFile(false) }
}
const formatDate = (dateStr) => {
const dateFmt = (dateStr) => {
if (!dateStr) return ''
const date = new Date(dateStr)
return date.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })
return new Date(dateStr).toLocaleDateString(lang === 'ar' ? 'ar-SA' : 'en-US', { month: 'long', day: 'numeric', year: 'numeric' })
}
const formatDateTime = (dateStr) => {
const dateTimeFmt = (dateStr) => {
if (!dateStr) return ''
const date = new Date(dateStr)
return date.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
return new Date(dateStr).toLocaleString(lang === 'ar' ? 'ar-SA' : 'en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' })
}
const formatFileSize = (bytes) => {
const fileSize = (bytes) => {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
const STATUS_CONFIG = {
new: { label: t('new'), bg: 'bg-purple-100', text: 'text-purple-700', dot: 'bg-purple-500', icon: AlertCircle },
acknowledged: { label: t('acknowledged'), bg: 'bg-blue-100', text: 'text-blue-700', dot: 'bg-blue-500', icon: CheckCircle2 },
in_progress: { label: t('in_progress'), bg: 'bg-amber-100', text: 'text-amber-700', dot: 'bg-amber-500', icon: Clock },
resolved: { label: t('resolved'), bg: 'bg-emerald-100', text: 'text-emerald-700', dot: 'bg-emerald-500', icon: CheckCircle2 },
declined: { label: t('declined_status'), bg: 'bg-gray-100', text: 'text-text-secondary', dot: 'bg-gray-500', icon: XCircle },
}
const PRIORITY_CONFIG = {
low: { label: t('low'), color: 'text-text-secondary' },
medium: { label: t('medium'), color: 'text-blue-700' },
high: { label: t('high'), color: 'text-orange-700' },
urgent: { label: t('urgent'), color: 'text-red-700' },
}
if (loading) {
return (
<div className="min-h-screen bg-surface-secondary py-8 px-4">
<div className="min-h-screen bg-surface-secondary py-8 px-4" dir={dir}>
<LangToggle lang={lang} setLang={setLang} />
<div className="max-w-2xl mx-auto space-y-6 animate-pulse">
<div className="bg-surface rounded-xl border border-border p-6 space-y-4">
<div className="h-5 bg-surface-tertiary rounded w-24"></div>
<div className="h-7 bg-surface-tertiary rounded w-3/4"></div>
<div className="h-4 bg-surface-tertiary rounded w-full"></div>
<div className="h-4 bg-surface-tertiary rounded w-2/3"></div>
</div>
<div className="bg-surface rounded-xl border border-border p-6 space-y-4">
<div className="h-5 bg-surface-tertiary rounded w-40"></div>
<div className="h-20 bg-surface-tertiary rounded"></div>
<div className="h-5 bg-surface-tertiary rounded w-24" />
<div className="h-7 bg-surface-tertiary rounded w-3/4" />
<div className="h-4 bg-surface-tertiary rounded w-full" />
<div className="h-4 bg-surface-tertiary rounded w-2/3" />
</div>
</div>
</div>
@@ -139,20 +202,16 @@ export default function PublicIssueTracker() {
if (error) {
return (
<div className="min-h-screen bg-surface-secondary py-8 px-4 ">
<div className="min-h-screen bg-surface-secondary py-8 px-4" dir={dir}>
<LangToggle lang={lang} setLang={setLang} />
<div className="max-w-md mx-auto bg-surface rounded-xl border border-border p-6 text-center">
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
<XCircle className="w-10 h-10 text-red-600" />
</div>
<h1 className="text-2xl font-bold text-text-primary mb-2">Issue Not Found</h1>
<p className="text-sm text-text-tertiary mb-6">
The tracking link you used is invalid or the issue has been removed.
</p>
<a
href="/submit-issue"
className="inline-block px-4 py-2 bg-brand-primary text-white rounded-lg font-medium hover:bg-brand-primary-light transition-colors"
>
Submit a New Issue
<h1 className="text-2xl font-bold text-text-primary mb-2">{t('notFoundTitle')}</h1>
<p className="text-sm text-text-tertiary mb-6">{t('notFoundMessage')}</p>
<a href="/submit-issue" className="inline-block px-4 py-2 bg-brand-primary text-white rounded-lg font-medium hover:bg-brand-primary-light transition-colors">
{t('submitNew')}
</a>
</div>
</div>
@@ -162,22 +221,22 @@ export default function PublicIssueTracker() {
if (!issue) return null
const statusConfig = STATUS_CONFIG[issue.status] || STATUS_CONFIG.new
const StatusIcon = statusConfig.icon
const priorityConfig = PRIORITY_CONFIG[issue.priority] || PRIORITY_CONFIG.medium
return (
<div className="min-h-screen bg-surface-secondary py-8 px-4 ">
<div className="min-h-screen bg-surface-secondary py-8 px-4" dir={dir}>
<LangToggle lang={lang} setLang={setLang} />
<div className="max-w-2xl mx-auto">
{/* Header */}
<div className="bg-surface rounded-xl border border-border p-6 mb-6">
<div className="mb-4">
<div className="flex items-center gap-2 mb-3">
<span className={`text-xs px-2 py-1 rounded-full font-medium flex items-center gap-1.5 ${statusConfig.bg} ${statusConfig.text}`}>
<span className={`w-1.5 h-1.5 rounded-full ${statusConfig.dot}`}></span>
<span className={`w-1.5 h-1.5 rounded-full ${statusConfig.dot}`} />
{statusConfig.label}
</span>
<span className={`text-xs font-medium ${priorityConfig.color}`}>
{priorityConfig.label} Priority
{priorityConfig.label} {t('priority')}
</span>
<span className="text-xs text-text-tertiary"></span>
<span className="text-xs text-text-tertiary capitalize">{issue.type}</span>
@@ -186,18 +245,18 @@ export default function PublicIssueTracker() {
</div>
<div className="mb-4">
<h2 className="text-sm font-semibold text-text-primary mb-2">Description</h2>
<h2 className="text-sm font-semibold text-text-primary mb-2">{t('description')}</h2>
<p className="text-sm text-text-tertiary whitespace-pre-wrap">{issue.description}</p>
</div>
<div className="grid grid-cols-2 gap-4 text-sm pt-4 border-t border-border">
<div>
<span className="text-text-tertiary">Submitted:</span>
<span className="text-text-primary font-medium ml-2">{formatDate(issue.created_at)}</span>
<span className="text-text-tertiary">{t('submitted')}:</span>
<span className="text-text-primary font-medium ms-2">{dateFmt(issue.created_at)}</span>
</div>
<div>
<span className="text-text-tertiary">Last Updated:</span>
<span className="text-text-primary font-medium ml-2">{formatDate(issue.updated_at)}</span>
<span className="text-text-tertiary">{t('lastUpdated')}:</span>
<span className="text-text-primary font-medium ms-2">{dateFmt(issue.updated_at)}</span>
</div>
</div>
</div>
@@ -206,21 +265,19 @@ export default function PublicIssueTracker() {
{(issue.status === 'resolved' || issue.status === 'declined') && issue.resolution_summary && (
<div className={`rounded-2xl shadow-sm p-6 mb-6 ${issue.status === 'resolved' ? 'bg-emerald-50 border-2 border-emerald-200' : 'bg-gray-50 border-2 border-gray-200'}`}>
<div className="flex items-start gap-3">
{issue.status === 'resolved' ? (
<CheckCircle2 className="w-6 h-6 text-emerald-600 shrink-0 mt-1" />
) : (
<XCircle className="w-6 h-6 text-gray-600 shrink-0 mt-1" />
)}
{issue.status === 'resolved'
? <CheckCircle2 className="w-6 h-6 text-emerald-600 shrink-0 mt-1" />
: <XCircle className="w-6 h-6 text-text-secondary shrink-0 mt-1" />}
<div className="flex-1">
<h2 className={`text-lg font-bold mb-2 ${issue.status === 'resolved' ? 'text-emerald-900' : 'text-gray-900'}`}>
{issue.status === 'resolved' ? 'Resolution' : 'Declined'}
<h2 className={`text-lg font-bold mb-2 ${issue.status === 'resolved' ? 'text-emerald-900' : 'text-text-primary'}`}>
{issue.status === 'resolved' ? t('resolution') : t('declined')}
</h2>
<p className={`${issue.status === 'resolved' ? 'text-emerald-800' : 'text-gray-800'} whitespace-pre-wrap`}>
<p className={`${issue.status === 'resolved' ? 'text-emerald-800' : 'text-text-primary'} whitespace-pre-wrap`}>
{issue.resolution_summary}
</p>
{issue.resolved_at && (
<p className={`text-sm mt-2 ${issue.status === 'resolved' ? 'text-emerald-600' : 'text-gray-600'}`}>
{formatDate(issue.resolved_at)}
<p className={`text-sm mt-2 ${issue.status === 'resolved' ? 'text-emerald-600' : 'text-text-secondary'}`}>
{dateFmt(issue.resolved_at)}
</p>
)}
</div>
@@ -232,26 +289,25 @@ export default function PublicIssueTracker() {
<div className="bg-surface rounded-2xl shadow-sm p-8 mb-6">
<h2 className="text-xl font-bold text-text-primary mb-6 flex items-center gap-2">
<MessageCircle className="w-6 h-6" />
Progress Updates
{t('progressUpdates')}
</h2>
{updates.length === 0 ? (
<div className="text-center py-12">
<Clock className="w-12 h-12 text-text-tertiary mx-auto mb-3" />
<p className="text-text-secondary">No updates yet. We'll post updates here as we work on your issue.</p>
<p className="text-text-secondary">{t('noUpdates')}</p>
</div>
) : (
<div className="space-y-4">
{updates.map((update, idx) => (
<div key={update.Id || update.id || idx} className="border-l-4 border-brand-primary pl-4 py-2">
<div key={update.Id || update.id || idx} className="border-s-4 border-brand-primary ps-4 py-2">
<div className="flex items-start justify-between gap-3 mb-2">
<div className="flex items-center gap-2">
<span className="font-semibold text-text-primary">{update.author_name}</span>
<span className={`text-xs px-2 py-0.5 rounded-full ${update.author_type === 'staff' ? 'bg-purple-100 text-purple-700' : 'bg-gray-200 text-gray-700'}`}>
{update.author_type === 'staff' ? 'Team' : 'You'}
<span className={`text-xs px-2 py-0.5 rounded-full ${update.author_type === 'staff' ? 'bg-purple-100 text-purple-700' : 'bg-gray-200 text-text-secondary'}`}>
{update.author_type === 'staff' ? t('team') : t('you')}
</span>
</div>
<span className="text-sm text-text-tertiary">{formatDateTime(update.created_at)}</span>
<span className="text-sm text-text-tertiary">{dateTimeFmt(update.created_at)}</span>
</div>
<p className="text-text-secondary whitespace-pre-wrap">{update.message}</p>
</div>
@@ -265,7 +321,7 @@ export default function PublicIssueTracker() {
<div className="bg-surface rounded-2xl shadow-sm p-8 mb-6">
<h2 className="text-xl font-bold text-text-primary mb-6 flex items-center gap-2">
<FileText className="w-6 h-6" />
Attachments
{t('attachments')}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{attachments.map((att) => (
@@ -274,18 +330,12 @@ export default function PublicIssueTracker() {
<FileText className="w-5 h-5 text-text-tertiary shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{att.original_name}</p>
<p className="text-xs text-text-tertiary">
{formatFileSize(att.size)} {att.uploaded_by}
</p>
<p className="text-xs text-text-tertiary">{fileSize(att.size)} {att.uploaded_by}</p>
</div>
</div>
<a
href={`/api/uploads/${att.filename}`}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-brand-primary hover:underline ml-2"
>
Download
<a href={`/api/uploads/${att.filename}`} target="_blank" rel="noopener noreferrer"
className="text-xs text-brand-primary hover:underline ms-2">
{t('download')}
</a>
</div>
))}
@@ -293,55 +343,39 @@ export default function PublicIssueTracker() {
</div>
)}
{/* Add Comment Section */}
{/* Add Comment */}
{issue.status !== 'resolved' && issue.status !== 'declined' && (
<div className="bg-surface rounded-2xl shadow-sm p-8">
<h2 className="text-xl font-bold text-text-primary mb-6 flex items-center gap-2">
<MessageCircle className="w-6 h-6" />
Add a Comment
{t('addComment')}
</h2>
<form onSubmit={handleAddComment} className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-2">Your Name (Optional)</label>
<input
type="text"
value={commentName}
onChange={(e) => setCommentName(e.target.value)}
placeholder="Your name"
className="w-full px-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
/>
<label className="block text-sm font-medium text-text-primary mb-2">{t('yourName')}</label>
<input type="text" value={commentName} onChange={(e) => setCommentName(e.target.value)}
placeholder={t('yourNamePlaceholder')}
className="w-full px-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20" />
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-2">
Message <span className="text-red-500">*</span>
{t('message')} <span className="text-red-500">*</span>
</label>
<textarea
value={commentMessage}
onChange={(e) => setCommentMessage(e.target.value)}
placeholder="Add additional information or ask a question..."
rows={4}
required
className="w-full px-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
/>
<textarea value={commentMessage} onChange={(e) => setCommentMessage(e.target.value)}
placeholder={t('messagePlaceholder')} rows={4} required
className="w-full px-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20" />
</div>
<div className="flex gap-3">
<button
type="submit"
disabled={!commentMessage.trim() || submittingComment}
className="px-6 py-3 bg-brand-primary text-white rounded-lg font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center gap-2"
>
<button type="submit" disabled={!commentMessage.trim() || submittingComment}
className="px-6 py-3 bg-brand-primary text-white rounded-lg font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center gap-2">
<Send className="w-4 h-4" />
{submittingComment ? 'Sending...' : 'Send Comment'}
{submittingComment ? t('sending') : t('sendComment')}
</button>
<label className="cursor-pointer">
<input type="file" onChange={handleFileUpload} disabled={uploadingFile} className="hidden" />
<div className="px-6 py-3 bg-surface-secondary text-text-primary rounded-lg font-medium hover:bg-surface-tertiary transition-colors flex items-center gap-2">
<Upload className="w-4 h-4" />
{uploadingFile ? 'Uploading...' : 'Upload File'}
{uploadingFile ? t('uploading') : t('uploadFile')}
</div>
</label>
</div>
@@ -351,9 +385,7 @@ export default function PublicIssueTracker() {
{/* Footer */}
<div className="text-center mt-8">
<p className="text-sm text-text-tertiary">
Bookmark this page to check your issue status anytime.
</p>
<p className="text-sm text-text-tertiary">{t('bookmarkNote')}</p>
</div>
</div>
</div>
+345
View File
@@ -0,0 +1,345 @@
import { useState, useEffect } from 'react'
import { useParams } from 'react-router-dom'
import { CheckCircle, XCircle, FileText, Image as ImageIcon, Film, Music, User, Sparkles } from 'lucide-react'
import { useLanguage } from '../i18n/LanguageContext'
import { useToast } from '../components/ToastContainer'
import Modal from '../components/Modal'
export default function PublicPostReview() {
const { token } = useParams()
const { t } = useLanguage()
const toast = useToast()
const [post, setPost] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [submitting, setSubmitting] = useState(false)
const [success, setSuccess] = useState('')
const [reviewerName, setReviewerName] = useState('')
const [feedback, setFeedback] = useState('')
const [pendingAction, setPendingAction] = useState(null)
useEffect(() => { loadPost() }, [token])
const loadPost = async () => {
try {
const res = await fetch(`/api/public/review-post/${token}`)
if (!res.ok) {
const err = await res.json()
setError(err.error || t('review.loadFailed'))
setLoading(false)
return
}
const data = await res.json()
setPost(data)
if (data.approvers?.length === 1 && data.approvers[0].name) {
setReviewerName(data.approvers[0].name)
}
} catch {
setError(t('review.loadFailed'))
} finally {
setLoading(false)
}
}
const handleAction = (action) => {
if (!reviewerName.trim()) {
toast.error(t('review.enterName'))
return
}
if (action === 'reject' && !feedback.trim()) {
toast.error(t('review.feedbackRequiredError'))
return
}
setPendingAction(action)
}
const executeAction = async (action) => {
setSubmitting(true)
try {
const res = await fetch(`/api/public/review-post/${token}/${action}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ approved_by_name: reviewerName, feedback: feedback || undefined }),
})
if (!res.ok) {
const err = await res.json()
setError(err.error || t('review.actionFailed'))
setSubmitting(false)
return
}
const data = await res.json()
setSuccess(data.message || t('review.actionCompleted'))
setTimeout(() => loadPost(), 1500)
} catch {
setError(t('review.actionFailed'))
} finally {
setSubmitting(false)
}
}
if (loading) {
return (
<div className="min-h-screen bg-surface-secondary flex items-center justify-center">
<div className="max-w-3xl w-full mx-auto px-4 space-y-6 animate-pulse">
<div className="bg-surface rounded-2xl overflow-hidden">
<div className="h-24 bg-surface-tertiary" />
<div className="p-8 space-y-4">
<div className="h-6 bg-surface-tertiary rounded w-2/3" />
<div className="h-4 bg-surface-tertiary rounded w-1/2" />
<div className="h-32 bg-surface-tertiary rounded" />
</div>
</div>
</div>
</div>
)
}
if (error) {
return (
<div className="min-h-screen bg-surface-secondary flex items-center justify-center p-4">
<div className="max-w-md w-full bg-surface rounded-2xl shadow-sm p-8 text-center">
<div className="w-16 h-16 rounded-full bg-red-100 flex items-center justify-center mx-auto mb-4">
<XCircle className="w-8 h-8 text-red-600" />
</div>
<h2 className="text-2xl font-bold text-text-primary mb-2">{t('review.notAvailable')}</h2>
<p className="text-text-secondary">{error}</p>
</div>
</div>
)
}
if (success) {
return (
<div className="min-h-screen bg-surface-secondary flex items-center justify-center p-4">
<div className="max-w-md w-full bg-surface rounded-2xl shadow-sm p-8 text-center">
<div className="w-16 h-16 rounded-full bg-emerald-100 flex items-center justify-center mx-auto mb-4">
<CheckCircle className="w-8 h-8 text-emerald-600" />
</div>
<h2 className="text-2xl font-bold text-text-primary mb-2">{t('review.thankYou')}</h2>
<p className="text-text-secondary">{success}</p>
</div>
</div>
)
}
if (!post) return null
const images = (post.attachments || []).filter(a => (a.mime_type || '').startsWith('image/'))
const audio = (post.attachments || []).filter(a => (a.mime_type || '').startsWith('audio/'))
const videos = (post.attachments || []).filter(a => (a.mime_type || '').startsWith('video/'))
const others = (post.attachments || []).filter(a => {
const m = a.mime_type || ''
return !m.startsWith('image/') && !m.startsWith('audio/') && !m.startsWith('video/')
})
const platforms = Array.isArray(post.platforms) ? post.platforms : []
return (
<div className="min-h-screen bg-surface-secondary py-12 px-4">
<div className="max-w-3xl mx-auto">
{/* Header */}
<div className="bg-surface rounded-2xl shadow-sm overflow-hidden mb-6">
<div className="bg-brand-primary px-8 py-6">
<div className="flex items-center gap-3 mb-2">
<div className="w-10 h-10 rounded-xl bg-white/20 flex items-center justify-center">
<Sparkles className="w-6 h-6 text-white" />
</div>
<div>
<h1 className="text-2xl font-bold text-white">{t('review.postReview')}</h1>
<p className="text-white/80 text-sm">Rawaj</p>
</div>
</div>
</div>
<div className="p-8">
{/* Post Info */}
<div className="mb-6">
<h2 className="text-2xl font-bold text-text-primary mb-2">{post.title}</h2>
{post.description && (
<p className="text-text-secondary whitespace-pre-wrap mb-3">{post.description}</p>
)}
<div className="flex items-center gap-3 text-sm text-text-tertiary flex-wrap">
{post.brand_name && (
<span className="px-2 py-0.5 bg-brand-primary/10 text-brand-primary rounded-full text-xs font-medium">{post.brand_name}</span>
)}
{platforms.length > 0 && (
<span className="px-2 py-0.5 bg-surface-tertiary rounded-full text-xs">{platforms.join(', ')}</span>
)}
{post.creator_name && <span className="font-medium text-text-secondary">{t('review.createdBy')} <strong>{post.creator_name}</strong></span>}
{post.scheduled_date && <span> {new Date(post.scheduled_date).toLocaleDateString()}</span>}
</div>
</div>
{/* Images */}
{images.length > 0 && (
<div className="mb-6">
<div className="flex items-center gap-2 mb-3">
<ImageIcon className="w-4 h-4 text-text-tertiary" />
<h3 className="text-sm font-semibold text-text-tertiary uppercase">{t('posts.images')}</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{images.map((att, idx) => (
<a key={idx} href={att.url} target="_blank" rel="noopener noreferrer"
className="block rounded-xl overflow-hidden border-2 border-border hover:border-brand-primary transition-colors shadow-sm">
<img src={att.url} alt={att.original_name || `Image ${idx + 1}`} className="w-full h-64 object-cover" loading="lazy" />
{att.original_name && (
<div className="bg-surface-secondary px-4 py-2 border-t border-border">
<p className="text-sm text-text-secondary truncate">{att.original_name}</p>
</div>
)}
</a>
))}
</div>
</div>
)}
{/* Videos */}
{videos.length > 0 && (
<div className="mb-6">
<div className="flex items-center gap-2 mb-3">
<Film className="w-4 h-4 text-text-tertiary" />
<h3 className="text-sm font-semibold text-text-tertiary uppercase">{t('posts.videos')}</h3>
</div>
<div className="space-y-4">
{videos.map((att, idx) => (
<div key={idx} className="bg-surface-secondary rounded-xl overflow-hidden border border-border">
{att.original_name && (
<div className="px-4 py-2 bg-surface border-b border-border">
<span className="text-sm font-medium text-text-secondary">{att.original_name}</span>
</div>
)}
<video src={att.url} controls className="w-full" />
</div>
))}
</div>
</div>
)}
{/* Audio */}
{audio.length > 0 && (
<div className="mb-6">
<div className="flex items-center gap-2 mb-3">
<Music className="w-4 h-4 text-text-tertiary" />
<h3 className="text-sm font-semibold text-text-tertiary uppercase">{t('posts.audio')}</h3>
</div>
<div className="space-y-3">
{audio.map((att, idx) => (
<div key={idx} className="flex items-center gap-3 p-3 bg-surface-secondary rounded-xl border border-border">
<Music className="w-5 h-5 text-text-tertiary shrink-0" />
<span className="text-sm text-text-secondary truncate flex-1">{att.original_name}</span>
<audio src={att.url} controls className="h-8 max-w-[200px]" />
</div>
))}
</div>
</div>
)}
{/* Other files */}
{others.length > 0 && (
<div className="mb-6">
<h3 className="text-sm font-semibold text-text-tertiary uppercase mb-3">{t('posts.otherFiles')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{others.map((att, idx) => (
<a key={idx} href={att.url} target="_blank" rel="noopener noreferrer"
className="flex items-center gap-3 p-4 bg-surface-secondary rounded-xl border border-border hover:border-brand-primary transition-colors">
<FileText className="w-8 h-8 text-text-tertiary shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{att.original_name}</p>
{att.size && <p className="text-xs text-text-tertiary">{(att.size / 1024).toFixed(1)} KB</p>}
</div>
</a>
))}
</div>
</div>
)}
{/* Review Form */}
{post.status === 'in_review' && (
<div className="border-t border-border pt-6">
<h3 className="text-lg font-semibold text-text-primary mb-4">{t('review.yourReview')}</h3>
<div className="space-y-4 mb-6">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('review.reviewer')}</label>
{post.approvers?.length === 1 ? (
<div className="flex items-center gap-2 px-4 py-2 bg-surface-secondary border border-border rounded-lg">
<User className="w-4 h-4 text-text-tertiary" />
<span className="text-sm text-text-primary">{post.approvers[0].name}</span>
</div>
) : post.approvers?.length > 1 ? (
<select value={reviewerName} onChange={e => setReviewerName(e.target.value)}
className="w-full px-4 py-2 text-sm border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary">
<option value="">{t('review.selectYourName')}</option>
{post.approvers.map(a => <option key={a.id} value={a.name}>{a.name}</option>)}
</select>
) : (
<input type="text" value={reviewerName} onChange={e => setReviewerName(e.target.value)}
placeholder={t('review.enterYourName')}
className="w-full px-4 py-2 text-sm border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary" />
)}
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('review.feedbackRequired')}</label>
<textarea value={feedback} onChange={e => setFeedback(e.target.value)} rows={4}
placeholder={t('review.feedbackPlaceholder')}
className="w-full px-4 py-2 text-sm border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary" />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<button onClick={() => handleAction('approve')} disabled={submitting}
className="flex items-center justify-center gap-2 px-4 py-3 bg-emerald-600 text-white rounded-xl font-medium hover:bg-emerald-700 transition-colors disabled:opacity-50 shadow-sm">
<CheckCircle className="w-5 h-5" />
{t('review.approve')}
</button>
<button onClick={() => handleAction('reject')} disabled={submitting}
className="flex items-center justify-center gap-2 px-4 py-3 bg-red-600 text-white rounded-xl font-medium hover:bg-red-700 transition-colors disabled:opacity-50 shadow-sm">
<XCircle className="w-5 h-5" />
{t('review.reject')}
</button>
</div>
</div>
)}
{/* Already Reviewed */}
{post.status !== 'in_review' && (
<div className="border-t border-border pt-6">
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4 text-center">
<p className="text-blue-900 font-medium">{t('review.alreadyReviewed')}</p>
<p className="text-blue-700 text-sm mt-1">
{t('review.statusLabel')}: <span className="font-semibold capitalize">{post.status.replace('_', ' ')}</span>
</p>
{post.approved_by_name && (
<p className="text-blue-700 text-sm mt-1">
{t('review.reviewedBy')}: <span className="font-semibold">{post.approved_by_name}</span>
</p>
)}
{post.feedback && (
<p className="text-blue-700 text-sm mt-2 italic">"{post.feedback}"</p>
)}
</div>
</div>
)}
</div>
</div>
<div className="text-center text-text-tertiary text-sm">
<p>{t('review.poweredBy')}</p>
</div>
</div>
<Modal
isOpen={!!pendingAction}
onClose={() => setPendingAction(null)}
title={pendingAction === 'approve' ? t('review.confirmApprovePost') : t('review.confirmRejectPost')}
isConfirm
danger={pendingAction === 'reject'}
onConfirm={() => { const a = pendingAction; setPendingAction(null); executeAction(a) }}
confirmText={pendingAction === 'approve' ? t('review.approve') : t('review.reject')}
>
{pendingAction === 'approve' ? t('review.confirmApprovePostDesc') : t('review.confirmRejectPostDesc')}
</Modal>
</div>
)
}
+168 -46
View File
@@ -1,6 +1,9 @@
import { useState, useEffect } from 'react'
import { useParams } from 'react-router-dom'
import { CheckCircle, XCircle, AlertCircle, FileText, Image as ImageIcon, Film, Sparkles, Globe } from 'lucide-react'
import { CheckCircle, XCircle, AlertCircle, FileText, Image as ImageIcon, Film, Sparkles, Globe, User, ArrowRightLeft } from 'lucide-react'
import { useLanguage } from '../i18n/LanguageContext'
import { useToast } from '../components/ToastContainer'
import Modal from '../components/Modal'
const STATUS_ICONS = {
copy: FileText,
@@ -11,14 +14,22 @@ const STATUS_ICONS = {
export default function PublicReview() {
const { token } = useParams()
const { t } = useLanguage()
const toast = useToast()
const [artefact, setArtefact] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [submitting, setSubmitting] = useState(false)
const [success, setSuccess] = useState('')
const [successType, setSuccessType] = useState('review') // 'review' | 'redirect'
const [reviewerName, setReviewerName] = useState('')
const [feedback, setFeedback] = useState('')
const [showRedirect, setShowRedirect] = useState(false)
const [redirectTo, setRedirectTo] = useState('')
const [teamMembers, setTeamMembers] = useState([])
const [redirecting, setRedirecting] = useState(false)
const [selectedLanguage, setSelectedLanguage] = useState(0)
const [pendingAction, setPendingAction] = useState(null)
useEffect(() => {
loadArtefact()
@@ -29,32 +40,43 @@ export default function PublicReview() {
const res = await fetch(`/api/public/review/${token}`)
if (!res.ok) {
const err = await res.json()
setError(err.error || 'Failed to load artefact')
setError(err.error || t('review.loadFailed'))
setLoading(false)
return
}
const data = await res.json()
setArtefact(data)
// Auto-set reviewer name from the selected approver
if (data.approvers?.length > 0 && data.approvers[0].name) {
setReviewerName(data.approvers[0].name)
}
} catch (err) {
setError('Failed to load artefact')
setError(t('review.loadFailed'))
} finally {
setLoading(false)
}
}
const handleAction = async (action) => {
const handleAction = (action) => {
if (!reviewerName.trim()) {
alert('Please enter your name')
toast.error(t('review.enterName'))
return
}
if (action === 'approve' && !confirm('Approve this artefact?')) return
if (action === 'reject' && !confirm('Reject this artefact?')) return
if (action === 'revision' && !feedback.trim()) {
alert('Please provide feedback for revision request')
toast.error(t('review.feedbackRequired'))
return
}
if (action === 'approve' || action === 'reject') {
setPendingAction(action)
return
}
executeAction(action)
}
const executeAction = async (action) => {
setSubmitting(true)
try {
const res = await fetch(`/api/public/review/${token}/${action}`, {
@@ -68,23 +90,58 @@ export default function PublicReview() {
if (!res.ok) {
const err = await res.json()
setError(err.error || 'Action failed')
setError(err.error || t('review.actionFailed'))
setSubmitting(false)
return
}
const data = await res.json()
setSuccess(data.message || 'Action completed successfully')
setSuccess(data.message || t('review.actionCompleted'))
setTimeout(() => {
loadArtefact()
}, 1500)
} catch (err) {
setError('Action failed')
setError(t('review.actionFailed'))
} finally {
setSubmitting(false)
}
}
const handleOpenRedirect = async () => {
try {
const res = await fetch(`/api/public/review-redirect/${token}/team`)
const data = await res.json()
setTeamMembers(Array.isArray(data) ? data : [])
setShowRedirect(true)
} catch {
toast.error(t('review.actionFailed'))
}
}
const handleRedirect = async () => {
if (!redirectTo) return
setRedirecting(true)
try {
const res = await fetch(`/api/public/review-redirect/${token}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ new_approver_id: Number(redirectTo) }),
})
const data = await res.json()
if (!res.ok) {
toast.error(data.error || t('review.actionFailed'))
return
}
setSuccessType('redirect')
setSuccess(data.message || t('review.redirected'))
setShowRedirect(false)
} catch {
toast.error(t('review.actionFailed'))
} finally {
setRedirecting(false)
}
}
const extractDriveFileId = (url) => {
const patterns = [
/\/file\/d\/([^\/]+)/,
@@ -129,7 +186,7 @@ export default function PublicReview() {
<div className="w-16 h-16 rounded-full bg-red-100 flex items-center justify-center mx-auto mb-4">
<XCircle className="w-8 h-8 text-red-600" />
</div>
<h2 className="text-2xl font-bold text-text-primary mb-2">Review Not Available</h2>
<h2 className="text-2xl font-bold text-text-primary mb-2">{t('review.notAvailable')}</h2>
<p className="text-text-secondary">{error}</p>
</div>
</div>
@@ -140,10 +197,15 @@ export default function PublicReview() {
return (
<div className="min-h-screen bg-surface-secondary flex items-center justify-center p-4">
<div className="max-w-md w-full bg-surface rounded-2xl shadow-sm p-8 text-center">
<div className="w-16 h-16 rounded-full bg-emerald-100 flex items-center justify-center mx-auto mb-4">
<CheckCircle className="w-8 h-8 text-emerald-600" />
<div className={`w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4 ${successType === 'redirect' ? 'bg-blue-100' : 'bg-emerald-100'}`}>
{successType === 'redirect'
? <ArrowRightLeft className="w-8 h-8 text-blue-600" />
: <CheckCircle className="w-8 h-8 text-emerald-600" />
}
</div>
<h2 className="text-2xl font-bold text-text-primary mb-2">Thank You!</h2>
<h2 className="text-2xl font-bold text-text-primary mb-2">
{successType === 'redirect' ? t('review.redirected') : t('review.thankYou')}
</h2>
<p className="text-text-secondary">{success}</p>
</div>
</div>
@@ -166,8 +228,8 @@ export default function PublicReview() {
<Sparkles className="w-6 h-6 text-white" />
</div>
<div>
<h1 className="text-2xl font-bold text-white">Content Review</h1>
<p className="text-white/80 text-sm">Samaya Digital Hub</p>
<h1 className="text-2xl font-bold text-white">{t('review.contentReview')}</h1>
<p className="text-white/80 text-sm">Rawaj</p>
</div>
</div>
</div>
@@ -183,10 +245,11 @@ export default function PublicReview() {
{artefact.description && (
<p className="text-text-secondary mb-2">{artefact.description}</p>
)}
<div className="flex items-center gap-3 text-sm text-text-tertiary">
<div className="flex items-center gap-3 text-sm text-text-tertiary flex-wrap">
<span className="px-2 py-0.5 bg-surface-tertiary rounded-full capitalize">{artefact.type}</span>
{artefact.brand_name && <span> {artefact.brand_name}</span>}
{artefact.version_number && <span> Version {artefact.version_number}</span>}
{artefact.version_number && <span> {t('review.version')} {artefact.version_number}</span>}
{artefact.creator_name && <span className="font-medium text-text-secondary">{t('review.createdBy')} <strong>{artefact.creator_name}</strong></span>}
</div>
</div>
</div>
@@ -196,7 +259,7 @@ export default function PublicReview() {
<div className="mb-6">
<div className="flex items-center gap-2 mb-3">
<Globe className="w-4 h-4 text-text-tertiary" />
<h3 className="text-sm font-semibold text-text-tertiary uppercase">Content Languages</h3>
<h3 className="text-sm font-semibold text-text-tertiary uppercase">{t('review.contentLanguages')}</h3>
</div>
{/* Language tabs */}
@@ -222,7 +285,7 @@ export default function PublicReview() {
{/* Selected language content */}
<div className="bg-surface-secondary rounded-xl p-6 border border-border">
<div className="mb-2 text-xs font-semibold text-text-tertiary uppercase">
{artefact.texts[selectedLanguage].language_label} Content
{artefact.texts[selectedLanguage].language_label} {t('review.content')}
</div>
<div className="text-text-primary whitespace-pre-wrap leading-relaxed">
{artefact.texts[selectedLanguage].content}
@@ -234,7 +297,7 @@ export default function PublicReview() {
{/* Legacy content field (for backward compatibility) */}
{artefact.content && (!artefact.texts || artefact.texts.length === 0) && (
<div className="mb-6">
<h3 className="text-sm font-semibold text-text-tertiary uppercase mb-2">Content</h3>
<h3 className="text-sm font-semibold text-text-tertiary uppercase mb-2">{t('review.content')}</h3>
<div className="bg-surface-secondary rounded-xl p-4 border border-border">
<pre className="text-text-primary whitespace-pre-wrap font-sans text-sm leading-relaxed">
{artefact.content}
@@ -248,7 +311,7 @@ export default function PublicReview() {
<div className="mb-6">
<div className="flex items-center gap-2 mb-3">
<ImageIcon className="w-4 h-4 text-text-tertiary" />
<h3 className="text-sm font-semibold text-text-tertiary uppercase">Design Files</h3>
<h3 className="text-sm font-semibold text-text-tertiary uppercase">{t('review.designFiles')}</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{artefact.attachments.map((att, idx) => (
@@ -263,6 +326,7 @@ export default function PublicReview() {
src={att.url}
alt={att.original_name || `Design ${idx + 1}`}
className="w-full h-64 object-cover"
loading="lazy"
/>
{att.original_name && (
<div className="bg-surface-secondary px-4 py-2 border-t border-border">
@@ -280,7 +344,7 @@ export default function PublicReview() {
<div className="mb-6">
<div className="flex items-center gap-2 mb-3">
<Film className="w-4 h-4 text-text-tertiary" />
<h3 className="text-sm font-semibold text-text-tertiary uppercase">Videos</h3>
<h3 className="text-sm font-semibold text-text-tertiary uppercase">{t('review.videos')}</h3>
</div>
<div className="space-y-4">
{artefact.attachments.map((att, idx) => (
@@ -289,7 +353,7 @@ export default function PublicReview() {
<div>
<div className="px-4 py-2 bg-surface border-b border-border flex items-center gap-2">
<Globe className="w-4 h-4 text-text-tertiary" />
<span className="text-sm font-medium text-text-secondary">Google Drive Video</span>
<span className="text-sm font-medium text-text-secondary">{t('review.googleDriveVideo')}</span>
</div>
<iframe
src={getDriveEmbedUrl(att.drive_url)}
@@ -321,7 +385,7 @@ export default function PublicReview() {
{/* OTHER TYPE: Generic Attachments */}
{artefact.type === 'other' && artefact.attachments && artefact.attachments.length > 0 && (
<div className="mb-6">
<h3 className="text-sm font-semibold text-text-tertiary uppercase mb-3">Attachments</h3>
<h3 className="text-sm font-semibold text-text-tertiary uppercase mb-3">{t('review.attachments')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{artefact.attachments.map((att, idx) => (
<div key={idx}>
@@ -336,6 +400,7 @@ export default function PublicReview() {
src={att.url}
alt={att.original_name}
className="w-full h-48 object-cover"
loading="lazy"
/>
<div className="bg-surface-secondary px-3 py-2 border-t border-border">
<p className="text-xs text-text-secondary truncate">{att.original_name}</p>
@@ -368,7 +433,7 @@ export default function PublicReview() {
{/* Comments */}
{artefact.comments && artefact.comments.length > 0 && (
<div className="mb-6">
<h3 className="text-sm font-semibold text-text-tertiary uppercase mb-3">Previous Comments</h3>
<h3 className="text-sm font-semibold text-text-tertiary uppercase mb-3">{t('review.previousComments')}</h3>
<div className="space-y-3">
{artefact.comments.map((comment, idx) => (
<div key={idx} className="bg-surface-secondary rounded-lg p-3 border border-border">
@@ -392,28 +457,26 @@ export default function PublicReview() {
{/* Review Form */}
{artefact.status === 'pending_review' && (
<div className="border-t border-border pt-6">
<h3 className="text-lg font-semibold text-text-primary mb-4">Your Review</h3>
<h3 className="text-lg font-semibold text-text-primary mb-4">{t('review.yourReview')}</h3>
<div className="space-y-4 mb-6">
{/* Reviewer identity */}
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Your Name *</label>
<input
type="text"
value={reviewerName}
onChange={e => setReviewerName(e.target.value)}
placeholder="Enter your name"
className="w-full px-4 py-2 border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface transition-colors"
/>
<label className="block text-sm font-medium text-text-primary mb-1">{t('review.reviewer')}</label>
<div className="flex items-center gap-2 px-4 py-2 bg-surface-secondary border border-border rounded-lg">
<User className="w-4 h-4 text-text-tertiary" />
<span className="text-sm text-text-primary">{artefact.approvers?.[0]?.name || reviewerName || '—'}</span>
</div>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Feedback (optional)</label>
<label className="block text-sm font-medium text-text-primary mb-1">{t('review.feedbackOptional')}</label>
<textarea
value={feedback}
onChange={e => setFeedback(e.target.value)}
rows={4}
placeholder="Share your thoughts, suggestions, or required changes..."
className="w-full px-4 py-2 border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface transition-colors"
placeholder={t('review.feedbackPlaceholder')}
className="w-full px-4 py-2 text-sm border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary transition-colors"
/>
</div>
</div>
@@ -425,7 +488,7 @@ export default function PublicReview() {
className="flex items-center justify-center gap-2 px-4 py-3 bg-emerald-600 text-white rounded-xl font-medium hover:bg-emerald-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
>
<CheckCircle className="w-5 h-5" />
Approve
{t('review.approve')}
</button>
<button
onClick={() => handleAction('revision')}
@@ -433,7 +496,7 @@ export default function PublicReview() {
className="flex items-center justify-center gap-2 px-4 py-3 bg-amber-500 text-white rounded-xl font-medium hover:bg-amber-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
>
<AlertCircle className="w-5 h-5" />
Request Revision
{t('review.requestRevision')}
</button>
<button
onClick={() => handleAction('reject')}
@@ -441,9 +504,51 @@ export default function PublicReview() {
className="flex items-center justify-center gap-2 px-4 py-3 bg-red-600 text-white rounded-xl font-medium hover:bg-red-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
>
<XCircle className="w-5 h-5" />
Reject
{t('review.reject')}
</button>
</div>
{/* Redirect to another reviewer */}
<div className="pt-3 border-t border-border-light">
{!showRedirect ? (
<button
onClick={handleOpenRedirect}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 text-sm text-text-secondary hover:text-text-primary hover:bg-surface-secondary rounded-lg transition-colors"
>
<ArrowRightLeft className="w-4 h-4" />
{t('review.redirectReview')}
</button>
) : (
<div className="space-y-3">
<p className="text-sm text-text-secondary">{t('review.redirectDesc')}</p>
<select
value={redirectTo}
onChange={e => setRedirectTo(e.target.value)}
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface text-text-primary"
>
<option value="">{t('review.selectNewReviewer')}</option>
{teamMembers.map(u => (
<option key={u.id} value={u.id}>{u.name}</option>
))}
</select>
<div className="flex gap-2">
<button
onClick={() => setShowRedirect(false)}
className="flex-1 px-3 py-2 text-sm text-text-secondary hover:bg-surface-secondary rounded-lg transition-colors"
>
{t('common.cancel')}
</button>
<button
onClick={handleRedirect}
disabled={!redirectTo || redirecting}
className="flex-1 px-3 py-2 text-sm bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors disabled:opacity-50"
>
{redirecting ? '...' : t('review.redirect')}
</button>
</div>
</div>
)}
</div>
</div>
)}
@@ -452,14 +557,14 @@ export default function PublicReview() {
<div className="border-t border-border pt-6">
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4 text-center">
<p className="text-blue-900 font-medium">
This artefact has already been reviewed.
{t('review.alreadyReviewed')}
</p>
<p className="text-blue-700 text-sm mt-1">
Status: <span className="font-semibold capitalize">{artefact.status.replace('_', ' ')}</span>
{t('review.statusLabel')}: <span className="font-semibold capitalize">{artefact.status.replace('_', ' ')}</span>
</p>
{artefact.approved_by_name && (
<p className="text-blue-700 text-sm mt-1">
Reviewed by: <span className="font-semibold">{artefact.approved_by_name}</span>
{t('review.reviewedBy')}: <span className="font-semibold">{artefact.approved_by_name}</span>
</p>
)}
</div>
@@ -470,9 +575,26 @@ export default function PublicReview() {
{/* Footer */}
<div className="text-center text-text-tertiary text-sm">
<p>Powered by Samaya Digital Hub</p>
<p>{t('review.poweredBy')}</p>
</div>
</div>
{/* Approve / Reject Confirmation */}
<Modal
isOpen={!!pendingAction}
onClose={() => setPendingAction(null)}
title={pendingAction === 'approve' ? t('review.confirmApprove') : t('review.confirmReject')}
isConfirm
danger={pendingAction === 'reject'}
onConfirm={() => {
const action = pendingAction
setPendingAction(null)
executeAction(action)
}}
confirmText={pendingAction === 'approve' ? t('review.approve') : t('review.reject')}
>
{pendingAction === 'approve' ? t('review.confirmApproveDesc') : t('review.confirmRejectDesc')}
</Modal>
</div>
)
}
@@ -0,0 +1,542 @@
import { useState, useEffect, useMemo } from 'react'
import { useParams } from 'react-router-dom'
import { CheckCircle, XCircle, AlertCircle, Languages, Globe, User, Check, PenLine, Copy, Lock } from 'lucide-react'
import { useLanguage } from '../i18n/LanguageContext'
import { AVAILABLE_LANGUAGES, isTextSelected, groupTextsByLanguage } from '../utils/translations'
import { useToast } from '../components/ToastContainer'
import Modal from '../components/Modal'
export default function PublicTranslationReview() {
const { token } = useParams()
const { t } = useLanguage()
const toast = useToast()
const [translation, setTranslation] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [submitting, setSubmitting] = useState(false)
const [success, setSuccess] = useState('')
const [reviewerName, setReviewerName] = useState('')
const [feedback, setFeedback] = useState('')
const [pendingAction, setPendingAction] = useState(null)
const [suggestingLang, setSuggestingLang] = useState(null)
const [suggestionContent, setSuggestionContent] = useState('')
const [selectingId, setSelectingId] = useState(null)
const [copiedId, setCopiedId] = useState(null)
useEffect(() => {
loadTranslation()
}, [token])
const loadTranslation = async () => {
try {
const res = await fetch(`/api/public/review-translation/${token}`)
if (!res.ok) {
const err = await res.json()
setError(err.error || t('review.loadFailed'))
setLoading(false)
return
}
const data = await res.json()
setTranslation(data)
if (data.approvers?.length === 1 && data.approvers[0].name) {
setReviewerName(data.approvers[0].name)
}
} catch (err) {
setError(t('review.loadFailed'))
} finally {
setLoading(false)
}
}
const handleAction = async (action) => {
if ((action === 'approve' || action === 'reject') && !reviewerName.trim()) {
toast.error(t('review.nameRequired'))
return
}
if ((action === 'reject' || action === 'revision') && !feedback.trim()) {
toast.error(t('review.feedbackRequiredError'))
return
}
setSubmitting(true)
try {
const res = await fetch(`/api/public/review-translation/${token}/${action}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
approved_by_name: reviewerName || 'Anonymous',
feedback: feedback || '',
}),
})
if (!res.ok) {
const err = await res.json()
throw new Error(err.error || 'Action failed')
}
if (action === 'approve') setSuccess(t('review.approveSuccess'))
else if (action === 'reject') setSuccess(t('review.rejectSuccess'))
else setSuccess(t('review.revisionSuccess'))
setPendingAction(null)
} catch (err) {
toast.error(err.message)
} finally {
setSubmitting(false)
}
}
const handleSelect = async (textId) => {
setSelectingId(textId)
try {
const res = await fetch(`/api/public/review-translation/${token}/select`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text_id: textId }),
})
if (!res.ok) {
const err = await res.json()
throw new Error(err.error || 'Selection failed')
}
setTranslation(prev => ({
...prev,
texts: prev.texts.map(txt => ({
...txt,
is_selected: txt.language_code === prev.texts.find(t => (t.Id || t.id) === textId)?.language_code
? (txt.Id || txt.id) === textId
: txt.is_selected,
})),
}))
toast.success(t('translations.optionSelected'))
} catch (err) {
toast.error(err.message)
} finally {
setSelectingId(null)
}
}
const handleSuggest = async (langCode) => {
if (!suggestionContent.trim()) return
setSubmitting(true)
try {
const langDef = AVAILABLE_LANGUAGES.find(l => l.code === langCode)
const res = await fetch(`/api/public/review-translation/${token}/suggest`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
language_code: langCode,
language_label: langDef?.label || langCode,
content: suggestionContent,
suggested_by: reviewerName || 'Reviewer',
}),
})
if (!res.ok) {
const err = await res.json()
throw new Error(err.error || 'Suggestion failed')
}
const newText = await res.json()
setTranslation(prev => ({
...prev,
texts: [...(prev.texts || []), newText],
}))
setSuggestingLang(null)
setSuggestionContent('')
toast.success(t('translations.suggestionAdded'))
} catch (err) {
toast.error(err.message)
} finally {
setSubmitting(false)
}
}
const copyContent = (text, id) => {
navigator.clipboard.writeText(text)
setCopiedId(id)
toast.success(t('translations.copiedToClipboard'))
setTimeout(() => setCopiedId(null), 2000)
}
// Group texts by language (memoized)
const textsByLanguage = useMemo(
() => translation?.texts ? groupTextsByLanguage(translation.texts) : {},
[translation?.texts]
)
const isPendingReview = translation?.status === 'pending_review'
const isApproved = translation?.status === 'approved'
const isRejected = translation?.status === 'rejected'
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-surface-secondary">
<div className="w-8 h-8 border-4 border-brand-primary border-t-transparent rounded-full animate-spin" />
</div>
)
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center bg-surface-secondary">
<div className="text-center">
<AlertCircle className="w-12 h-12 text-red-500 mx-auto mb-3" />
<h2 className="text-lg font-semibold text-text-primary mb-1">{t('review.errorTitle')}</h2>
<p className="text-text-secondary">{error}</p>
</div>
</div>
)
}
if (success) {
return (
<div className="min-h-screen flex items-center justify-center bg-surface-secondary">
<div className="text-center">
<CheckCircle className="w-12 h-12 text-emerald-500 mx-auto mb-3" />
<h2 className="text-lg font-semibold text-text-primary mb-1">{success}</h2>
<p className="text-text-secondary">{t('review.thankYou')}</p>
</div>
</div>
)
}
if (!translation) return null
return (
<div className="min-h-screen bg-surface-secondary">
<div className="max-w-4xl mx-auto px-4 py-8">
{/* Header */}
<div className="bg-surface rounded-xl border border-border p-6 mb-6">
<div className="flex items-start gap-4">
<div className="w-12 h-12 rounded-xl bg-brand-primary/10 flex items-center justify-center shrink-0">
<Languages className="w-6 h-6 text-brand-primary" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h2 className="text-2xl font-bold text-text-primary">{translation.title}</h2>
{isApproved && (
<span className="text-xs px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-700 font-medium flex items-center gap-1">
<Lock className="w-3 h-3" />
{t('review.approved')}
</span>
)}
{isRejected && (
<span className="text-xs px-2 py-0.5 rounded-full bg-red-100 text-red-700 font-medium">
{t('review.rejected')}
</span>
)}
</div>
<div className="flex items-center gap-2 text-sm text-text-tertiary flex-wrap">
{translation.brand_name && <span>{translation.brand_name}</span>}
{translation.creator_name && <span className="font-medium text-text-secondary">{t('review.createdBy')} <strong>{translation.creator_name}</strong></span>}
</div>
</div>
</div>
</div>
{/* Source Content */}
<div className="bg-surface rounded-xl border border-border p-6 mb-6">
<div className="flex items-center gap-2 mb-3">
<Globe className="w-5 h-5 text-blue-600" />
<h3 className="text-lg font-semibold text-text-primary">
{t('translations.sourceContent')}
</h3>
<span className="text-xs px-2 py-0.5 rounded-full bg-blue-50 text-blue-600 font-medium">
{translation.source_language}
</span>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<p className="text-sm text-blue-900 whitespace-pre-wrap leading-relaxed">{translation.source_content}</p>
</div>
</div>
{/* Translation Options by Language */}
{Object.keys(textsByLanguage).length > 0 && (
<div className="bg-surface rounded-xl border border-border p-6 mb-6">
<h3 className="text-lg font-semibold text-text-primary mb-4">
{t('translations.translationTexts')}
</h3>
<div className="space-y-6">
{Object.entries(textsByLanguage).map(([langCode, options]) => {
const langLabel = options[0]?.language_label || langCode
const hasSelected = options.some(isTextSelected)
return (
<div key={langCode}>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-text-primary">{langLabel}</span>
<span className="text-xs text-text-tertiary">({langCode})</span>
<span className="text-xs text-text-tertiary">
{options.length} {options.length === 1 ? t('translations.option') : t('translations.options')}
</span>
</div>
{isPendingReview && (
<button
onClick={() => {
setSuggestingLang(langCode)
setSuggestionContent('')
}}
className="flex items-center gap-1 text-xs text-brand-primary hover:text-brand-primary/80 font-medium"
>
<PenLine className="w-3.5 h-3.5" />
{t('translations.suggestAlternative')}
</button>
)}
</div>
<div className="space-y-2">
{options.map((text) => {
const textId = text.Id || text.id
const isSelected = isTextSelected(text)
// When approved, show only the selected option prominently; others are dimmed
const isDimmed = isApproved && hasSelected && !isSelected
return (
<div
key={textId}
className={`rounded-lg p-4 border transition-all ${
isSelected
? 'bg-emerald-50 border-emerald-300 ring-1 ring-emerald-200'
: isDimmed
? 'bg-surface-secondary border-border opacity-50'
: 'bg-surface-secondary border-border hover:border-brand-primary/30'
}`}
>
<div className="flex items-start gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-medium text-text-tertiary">
{t('translations.optionLabel')} {text.option_number || 1}
</span>
{isSelected && (
<span className="text-xs px-1.5 py-0.5 rounded bg-emerald-100 text-emerald-700 font-medium flex items-center gap-1">
<Check className="w-3 h-3" />
{t('translations.selected')}
</span>
)}
</div>
<p className="text-sm text-text-secondary whitespace-pre-wrap leading-relaxed">{text.content}</p>
</div>
<div className="flex items-center gap-1 shrink-0">
{/* Copy button — always available, especially useful for approved */}
{(isApproved || isSelected) && (
<button
onClick={() => copyContent(text.content, textId)}
className="p-1.5 rounded-lg text-text-tertiary hover:text-text-primary hover:bg-surface-tertiary transition-colors"
title={t('translations.copyContent')}
>
{copiedId === textId ? <Check className="w-4 h-4 text-emerald-600" /> : <Copy className="w-4 h-4" />}
</button>
)}
{/* Select button — only when pending review */}
{isPendingReview && (
<button
onClick={() => handleSelect(textId)}
disabled={selectingId === textId || isSelected}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
isSelected
? 'bg-emerald-100 text-emerald-700 cursor-default'
: 'bg-brand-primary/10 text-brand-primary hover:bg-brand-primary/20'
}`}
>
{selectingId === textId ? '...' : isSelected ? t('translations.selected') : t('translations.selectThis')}
</button>
)}
</div>
</div>
</div>
)
})}
</div>
{/* Inline suggestion form for this language */}
{suggestingLang === langCode && (
<div className="mt-3 bg-amber-50 border border-amber-200 rounded-lg p-4">
<p className="text-sm font-medium text-amber-800 mb-2">{t('translations.suggestForLang')} {langLabel}</p>
<textarea
value={suggestionContent}
onChange={e => setSuggestionContent(e.target.value)}
placeholder={t('translations.enterSuggestion')}
className="w-full px-3 py-2 text-sm border border-amber-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-amber-400/30 min-h-[80px] resize-y bg-surface"
/>
<div className="flex items-center gap-2 mt-2">
<button
onClick={() => handleSuggest(langCode)}
disabled={submitting || !suggestionContent.trim()}
className="px-3 py-1.5 bg-amber-600 text-white text-xs font-medium rounded-lg hover:bg-amber-700 disabled:opacity-50 transition-colors"
>
{submitting ? '...' : t('translations.submitSuggestion')}
</button>
<button
onClick={() => setSuggestingLang(null)}
className="px-3 py-1.5 text-xs text-text-secondary hover:text-text-primary transition-colors"
>
{t('common.cancel')}
</button>
</div>
</div>
)}
</div>
)
})}
</div>
</div>
)}
{/* Review Actions — only pending_review */}
{isPendingReview && (
<div className="bg-surface rounded-xl border border-border p-6">
<h3 className="text-lg font-semibold text-text-primary mb-4">{t('review.yourReview')}</h3>
{/* Reviewer identity */}
<div className="mb-4">
{translation.approvers?.length === 1 ? (
<div className="flex items-center gap-2 mb-3">
<User className="w-4 h-4 text-text-tertiary" />
<span className="text-sm text-text-primary">{translation.approvers[0].name}</span>
</div>
) : translation.approvers?.length > 1 ? (
<div className="mb-3">
<label className="block text-sm font-medium text-text-primary mb-1">{t('review.selectYourName')}</label>
<select
value={reviewerName}
onChange={e => setReviewerName(e.target.value)}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
>
<option value="">{t('review.selectApprover')}</option>
{translation.approvers.map(a => (
<option key={a.id} value={a.name}>{a.name}</option>
))}
</select>
</div>
) : (
<div className="mb-3">
<label className="block text-sm font-medium text-text-primary mb-1">{t('review.yourName')}</label>
<input
type="text"
value={reviewerName}
onChange={e => setReviewerName(e.target.value)}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
placeholder={t('review.enterYourName')}
/>
</div>
)}
</div>
{/* Feedback */}
<div className="mb-4">
<label className="block text-sm font-medium text-text-primary mb-1">{t('review.feedback')}</label>
<textarea
value={feedback}
onChange={e => setFeedback(e.target.value)}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 min-h-[100px] resize-y"
placeholder={t('review.feedbackPlaceholder')}
/>
</div>
{/* Action buttons */}
<div className="flex items-center gap-3">
<button
onClick={() => handleAction('approve')}
disabled={submitting || !reviewerName.trim()}
className="flex-1 flex items-center justify-center gap-2 py-3 bg-emerald-600 text-white rounded-lg font-medium hover:bg-emerald-700 disabled:opacity-50 transition-colors"
>
<CheckCircle className="w-5 h-5" />
{t('review.approve')}
</button>
<button
onClick={() => handleAction('revision')}
disabled={submitting || !feedback.trim()}
className="flex-1 flex items-center justify-center gap-2 py-3 bg-amber-500 text-white rounded-lg font-medium hover:bg-amber-600 disabled:opacity-50 transition-colors"
>
<AlertCircle className="w-5 h-5" />
{t('review.requestRevision')}
</button>
<button
onClick={() => setPendingAction('reject')}
disabled={submitting}
className="flex-1 flex items-center justify-center gap-2 py-3 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 disabled:opacity-50 transition-colors"
>
<XCircle className="w-5 h-5" />
{t('review.reject')}
</button>
</div>
</div>
)}
{/* Approved state — read-only with copy buttons */}
{isApproved && (
<div className="bg-surface rounded-xl border border-border p-6">
<div className="flex items-center gap-3 mb-3">
<CheckCircle className="w-6 h-6 text-emerald-500" />
<div>
<p className="text-text-primary font-semibold">{t('review.approved')}</p>
{translation.approved_by_name && (
<p className="text-sm text-text-secondary">
{t('review.reviewedBy')} <strong>{translation.approved_by_name}</strong>
</p>
)}
</div>
</div>
{translation.feedback && (
<div className="bg-surface-secondary rounded-lg p-3 mt-3">
<p className="text-xs font-medium text-text-tertiary mb-1">{t('review.feedback')}</p>
<p className="text-sm text-text-secondary whitespace-pre-wrap">{translation.feedback}</p>
</div>
)}
</div>
)}
{/* Rejected state */}
{isRejected && (
<div className="bg-surface rounded-xl border border-border p-6">
<div className="flex items-center gap-3 mb-3">
<XCircle className="w-6 h-6 text-red-500" />
<div>
<p className="text-text-primary font-semibold">{t('review.rejected')}</p>
{translation.approved_by_name && (
<p className="text-sm text-text-secondary">
{t('review.reviewedBy')} <strong>{translation.approved_by_name}</strong>
</p>
)}
</div>
</div>
{translation.feedback && (
<div className="bg-red-50 border border-red-200 rounded-lg p-3 mt-3">
<p className="text-xs font-medium text-red-700 mb-1">{t('review.feedback')}</p>
<p className="text-sm text-red-800 whitespace-pre-wrap">{translation.feedback}</p>
</div>
)}
</div>
)}
{/* Other statuses (revision_requested, draft) */}
{!isPendingReview && !isApproved && !isRejected && (
<div className="bg-surface rounded-xl border border-border p-6">
<div className="text-center py-4">
<AlertCircle className="w-10 h-10 text-amber-500 mx-auto mb-2" />
<p className="text-text-primary font-medium">
{t('review.statusLabel')}: <span className="font-semibold capitalize">{translation.status.replace('_', ' ')}</span>
</p>
{translation.approved_by_name && (
<p className="text-sm text-text-secondary mt-1">
{t('review.reviewedBy')} <strong>{translation.approved_by_name}</strong>
</p>
)}
</div>
</div>
)}
</div>
{/* Reject confirmation modal */}
<Modal
isOpen={pendingAction === 'reject'}
onClose={() => setPendingAction(null)}
title={t('review.confirmReject')}
isConfirm
danger
onConfirm={() => handleAction('reject')}
confirmText={t('review.reject')}
confirmDisabled={!feedback.trim() || !reviewerName.trim()}
>
<p className="text-sm text-text-secondary mb-3">{t('review.rejectConfirmDesc')}</p>
{!feedback.trim() && (
<p className="text-sm text-amber-600">{t('review.feedbackRequiredForReject')}</p>
)}
</Modal>
</div>
)
}
+150
View File
@@ -0,0 +1,150 @@
import { useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { useLanguage } from '../i18n/LanguageContext'
import { Lock, AlertCircle, CheckCircle, ArrowLeft } from 'lucide-react'
import { api } from '../utils/api'
function MarkaLogo({ className = '' }) {
return (
<svg viewBox="0 0 32 32" fill="none" className={className}>
<path d="M4 26V6l10 10L4 26z" fill="currentColor" opacity="0.85" />
<path d="M18 26V6l10 10-10 10z" fill="currentColor" opacity="0.5" />
</svg>
)
}
export default function ResetPassword() {
const { t } = useLanguage()
const [searchParams] = useSearchParams()
const token = searchParams.get('token')
const [password, setPassword] = useState('')
const [confirm, setConfirm] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState(false)
if (!token) {
return (
<div className="min-h-screen bg-slate-900 flex items-center justify-center px-4">
<div className="w-full max-w-md text-center">
<div className="bg-slate-800/50 backdrop-blur-sm rounded-2xl border border-slate-700/50 p-8 shadow-2xl">
<AlertCircle className="w-12 h-12 text-red-400 mx-auto mb-4" />
<p className="text-slate-300 mb-4">{t('resetPassword.invalidToken')}</p>
<Link to="/login" className="text-sm text-blue-400 hover:text-blue-300 transition-colors">
{t('resetPassword.goToLogin')}
</Link>
</div>
</div>
</div>
)
}
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
if (password !== confirm) {
setError(t('resetPassword.passwordMismatch'))
return
}
setLoading(true)
try {
await api.post('/auth/reset-password', { token, password })
setSuccess(true)
} catch (err) {
setError(err.message || t('resetPassword.error'))
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen bg-slate-900 flex items-center justify-center px-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<div className="w-16 h-16 bg-brand-primary rounded-2xl flex items-center justify-center mx-auto mb-4">
<MarkaLogo className="w-9 h-9 text-white" />
</div>
<h1 className="text-3xl font-bold text-white mb-2">{t('resetPassword.title')}</h1>
<p className="text-slate-400">{t('resetPassword.subtitle')}</p>
</div>
<div className="bg-slate-800/50 backdrop-blur-sm rounded-2xl border border-slate-700/50 p-8 shadow-2xl">
{success ? (
<div className="text-center space-y-4">
<div className="w-12 h-12 bg-green-500/20 rounded-full flex items-center justify-center mx-auto">
<CheckCircle className="w-6 h-6 text-green-400" />
</div>
<p className="text-slate-300 text-sm">{t('resetPassword.success')}</p>
<Link
to="/login"
className="inline-flex items-center gap-2 text-sm text-blue-400 hover:text-blue-300 transition-colors"
>
<ArrowLeft className="w-4 h-4" />
{t('resetPassword.goToLogin')}
</Link>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">{t('resetPassword.newPassword')}</label>
<div className="relative">
<Lock className="absolute start-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full ps-11 pe-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
placeholder="••••••••"
required
minLength={6}
autoFocus
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">{t('resetPassword.confirmPassword')}</label>
<div className="relative">
<Lock className="absolute start-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<input
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
className="w-full ps-11 pe-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
placeholder="••••••••"
required
minLength={6}
/>
</div>
</div>
{error && (
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
<AlertCircle className="w-5 h-5 text-red-400 shrink-0" />
<p className="text-sm text-red-400">{error}</p>
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full py-3 bg-brand-primary hover:bg-brand-primary-light text-white font-semibold rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? (
<span className="flex items-center justify-center gap-2">
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
{t('resetPassword.resetting')}
</span>
) : (
t('resetPassword.submit')
)}
</button>
</form>
)}
</div>
</div>
</div>
)
}
+223 -23
View File
@@ -1,19 +1,37 @@
import { useState, useEffect } from 'react'
import { Settings as SettingsIcon, Play, CheckCircle, Languages, Coins, Upload } from 'lucide-react'
import { useState, useEffect, useContext } from 'react'
import { Settings as SettingsIcon, Play, CheckCircle, Languages, Coins, Upload, Tag, Plus, Pencil, Trash2, X, Mail } from 'lucide-react'
import { api } from '../utils/api'
import { useLanguage } from '../i18n/LanguageContext'
import { useToast } from '../components/ToastContainer'
import { CURRENCIES } from '../i18n/LanguageContext'
import { AppContext } from '../App'
import { useAuth } from '../contexts/AuthContext'
import Modal from '../components/Modal'
const ROLE_COLORS = [
'#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6',
'#EC4899', '#06B6D4', '#F97316', '#6366F1', '#14B8A6',
]
export default function Settings() {
const { t, lang, setLang, currency, setCurrency } = useLanguage()
const toast = useToast()
const { user } = useAuth()
const { roles, loadRoles } = useContext(AppContext)
const [restarting, setRestarting] = useState(false)
const [success, setSuccess] = useState(false)
const [maxSizeMB, setMaxSizeMB] = useState(50)
const [sizeSaving, setSizeSaving] = useState(false)
const [sizeSaved, setSizeSaved] = useState(false)
const [ceoEmail, setCeoEmail] = useState('')
const [ceoSaving, setCeoSaving] = useState(false)
const [ceoSaved, setCeoSaved] = useState(false)
useEffect(() => {
api.get('/settings/app').then(s => setMaxSizeMB(s.uploadMaxSizeMB || 50)).catch(() => {})
api.get('/settings/app').then(s => {
setMaxSizeMB(s.uploadMaxSizeMB || 50)
if (s.ceoEmail) setCeoEmail(s.ceoEmail)
}).catch(() => {})
}, [])
const handleSaveMaxSize = async () => {
@@ -25,7 +43,7 @@ export default function Settings() {
setSizeSaved(true)
setTimeout(() => setSizeSaved(false), 2000)
} catch (err) {
alert(err.message || 'Failed to save')
toast.error(err.message || t('settings.saveFailed'))
} finally {
setSizeSaving(false)
}
@@ -42,7 +60,7 @@ export default function Settings() {
}, 1500)
} catch (err) {
console.error('Failed to restart tutorial:', err)
alert('Failed to restart tutorial')
toast.error(t('settings.restartTutorialFailed'))
} finally {
setRestarting(false)
}
@@ -50,19 +68,12 @@ export default function Settings() {
return (
<div className="space-y-6 animate-fade-in max-w-3xl">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-text-primary flex items-center gap-3">
<SettingsIcon className="w-7 h-7 text-brand-primary" />
{t('settings.title')}
</h1>
<p className="text-sm text-text-tertiary mt-1">{t('settings.preferences')}</p>
</div>
<p className="text-sm text-text-tertiary">{t('settings.preferences')}</p>
{/* General Settings */}
<div className="bg-white rounded-xl border border-border overflow-hidden">
<div className="bg-surface rounded-xl border border-border overflow-clip">
<div className="px-6 py-4 border-b border-border">
<h2 className="text-lg font-semibold text-text-primary">{t('settings.general')}</h2>
<h3 className="font-semibold text-text-primary">{t('settings.general')}</h3>
</div>
<div className="p-6 space-y-4">
{/* Language Selector */}
@@ -74,7 +85,7 @@ export default function Settings() {
<select
value={lang}
onChange={(e) => setLang(e.target.value)}
className="w-full max-w-xs px-4 py-2.5 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-white"
className="w-full max-w-xs px-4 py-2.5 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface"
>
<option value="en">{t('settings.english')}</option>
<option value="ar">{t('settings.arabic')}</option>
@@ -90,7 +101,7 @@ export default function Settings() {
<select
value={currency}
onChange={(e) => setCurrency(e.target.value)}
className="w-full max-w-xs px-4 py-2.5 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-white"
className="w-full max-w-xs px-4 py-2.5 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface"
>
{CURRENCIES.map(c => (
<option key={c.code} value={c.code}>
@@ -104,12 +115,12 @@ export default function Settings() {
</div>
{/* Uploads Section */}
<div className="bg-white rounded-xl border border-border overflow-hidden">
<div className="bg-surface rounded-xl border border-border overflow-clip">
<div className="px-6 py-4 border-b border-border">
<h2 className="text-lg font-semibold text-text-primary flex items-center gap-2">
<h3 className="font-semibold text-text-primary flex items-center gap-2">
<Upload className="w-5 h-5 text-brand-primary" />
{t('settings.uploads')}
</h2>
</h3>
</div>
<div className="p-6 space-y-4">
<div>
@@ -123,7 +134,7 @@ export default function Settings() {
max="500"
value={maxSizeMB}
onChange={(e) => setMaxSizeMB(Number(e.target.value))}
className="w-24 px-3 py-2.5 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-white"
className="w-24 px-3 py-2.5 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface"
/>
<span className="text-sm text-text-secondary">{t('settings.mb')}</span>
<button
@@ -142,9 +153,9 @@ export default function Settings() {
</div>
{/* Tutorial Section */}
<div className="bg-white rounded-xl border border-border overflow-hidden">
<div className="bg-surface rounded-xl border border-border overflow-clip">
<div className="px-6 py-4 border-b border-border">
<h2 className="text-lg font-semibold text-text-primary">{t('settings.onboardingTutorial')}</h2>
<h3 className="font-semibold text-text-primary">{t('settings.onboardingTutorial')}</h3>
</div>
<div className="p-6 space-y-4">
<p className="text-sm text-text-secondary">
@@ -174,6 +185,195 @@ export default function Settings() {
)}
</div>
</div>
{/* Budget Approval (Superadmin only) */}
{user?.role === 'superadmin' && (
<div className="bg-surface rounded-xl border border-border overflow-clip">
<div className="px-6 py-4 border-b border-border">
<h3 className="font-semibold text-text-primary flex items-center gap-2">
<Mail className="w-5 h-5 text-brand-primary" />
{t('settings.budgetApproval') || 'Budget Approval'}
</h3>
</div>
<div className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-2">
{t('settings.ceoEmail')}
</label>
<div className="flex items-center gap-3">
<input
type="email"
value={ceoEmail}
onChange={(e) => setCeoEmail(e.target.value)}
placeholder="ceo@company.com"
className="flex-1 max-w-sm px-4 py-2.5 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface"
/>
<button
onClick={async () => {
setCeoSaving(true)
setCeoSaved(false)
try {
await api.patch('/settings/app', { ceoEmail })
setCeoSaved(true)
setTimeout(() => setCeoSaved(false), 2000)
} catch (err) {
toast.error(err.message || t('settings.saveFailed'))
} finally {
setCeoSaving(false)
}
}}
disabled={ceoSaving}
className="px-4 py-2.5 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 transition-colors"
>
{ceoSaved ? (
<span className="flex items-center gap-1.5"><CheckCircle className="w-4 h-4" />{t('settings.saved')}</span>
) : ceoSaving ? '...' : t('common.save')}
</button>
</div>
<p className="text-xs text-text-tertiary mt-1.5">{t('settings.ceoEmailHint')}</p>
</div>
</div>
</div>
)}
{/* Roles Management (Superadmin only) */}
{user?.role === 'superadmin' && <RolesSection roles={roles} loadRoles={loadRoles} t={t} toast={toast} />}
</div>
)
}
function RolesSection({ roles, loadRoles, t, toast }) {
const [editingRole, setEditingRole] = useState(null)
const [showAddModal, setShowAddModal] = useState(false)
const [modalForm, setModalForm] = useState({ name: '', color: ROLE_COLORS[0] })
const [saving, setSaving] = useState(false)
const openAddModal = () => {
setModalForm({ name: '', color: ROLE_COLORS[roles.length % ROLE_COLORS.length] })
setShowAddModal(true)
}
const handleCreate = async () => {
setSaving(true)
try {
await api.post('/roles', { name: modalForm.name, color: modalForm.color })
await loadRoles()
setShowAddModal(false)
} catch (err) {
toast.error(err.message || t('common.error'))
} finally {
setSaving(false)
}
}
const handleSave = async (role) => {
setSaving(true)
try {
await api.patch(`/roles/${role.Id || role.id}`, { name: role.name, color: role.color })
await loadRoles()
setEditingRole(null)
} catch (err) {
toast.error(err.message || t('common.error'))
} finally {
setSaving(false)
}
}
const handleDelete = async (role) => {
if (!confirm(t('settings.deleteRoleConfirm'))) return
try {
await api.delete(`/roles/${role.Id || role.id}`)
await loadRoles()
} catch (err) {
toast.error(err.message || t('common.error'))
}
}
return (
<>
<div className="bg-surface dark:bg-surface-primary rounded-xl border border-border overflow-clip">
<div className="px-6 py-4 border-b border-border flex items-center justify-between">
<h3 className="font-semibold text-text-primary flex items-center gap-2">
<Tag className="w-5 h-5 text-brand-primary" />
{t('settings.roles')}
</h3>
<button
onClick={openAddModal}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors"
>
<Plus className="w-4 h-4" />
{t('settings.addRole')}
</button>
</div>
<div className="p-6">
<p className="text-sm text-text-tertiary mb-4">{t('settings.rolesDesc')}</p>
<div className="space-y-2">
{roles.map(role => (
<div key={role.Id || role.id} className="flex items-center gap-3 p-3 rounded-lg border border-border hover:bg-surface-secondary transition-colors">
{editingRole && (editingRole.Id || editingRole.id) === (role.Id || role.id) ? (
<div className="flex items-center gap-3 flex-1">
<input type="color" value={editingRole.color || '#94A3B8'} onChange={e => setEditingRole({ ...editingRole, color: e.target.value })}
className="w-8 h-8 rounded-lg border border-border cursor-pointer" />
<input type="text" value={editingRole.name} onChange={e => setEditingRole({ ...editingRole, name: e.target.value })}
placeholder={t('settings.roleName')} autoFocus
className="flex-1 px-3 py-1.5 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary" />
<button onClick={() => handleSave(editingRole)} disabled={!editingRole.name || saving}
className="px-3 py-1.5 text-sm font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light disabled:opacity-50 transition-colors">
{saving ? '...' : t('common.save')}
</button>
<button onClick={() => setEditingRole(null)} className="p-1.5 text-text-tertiary hover:text-text-primary rounded-lg hover:bg-surface-tertiary transition-colors">
<X className="w-4 h-4" />
</button>
</div>
) : (
<>
<div className="w-4 h-4 rounded-full shrink-0" style={{ backgroundColor: role.color || '#94A3B8' }} />
<span className="flex-1 text-sm font-medium text-text-primary">{role.name}</span>
<button onClick={() => setEditingRole({ ...role })} className="p-1.5 text-text-tertiary hover:text-brand-primary rounded-lg hover:bg-surface-tertiary transition-colors">
<Pencil className="w-4 h-4" />
</button>
<button onClick={() => handleDelete(role)} className="p-1.5 text-text-tertiary hover:text-red-500 rounded-lg hover:bg-red-50 transition-colors">
<Trash2 className="w-4 h-4" />
</button>
</>
)}
</div>
))}
{roles.length === 0 && (
<p className="text-sm text-text-tertiary text-center py-6">{t('settings.noRoles')}</p>
)}
</div>
</div>
</div>
<Modal isOpen={showAddModal} onClose={() => setShowAddModal(false)} title={t('settings.addRole')} size="sm">
<div className="space-y-4">
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('settings.roleName')}</label>
<input type="text" value={modalForm.name} onChange={e => setModalForm(f => ({ ...f, name: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
placeholder={t('settings.roleName')} autoFocus />
</div>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('settings.roleColor') || 'Color'}</label>
<div className="flex items-center gap-3">
<input type="color" value={modalForm.color} onChange={e => setModalForm(f => ({ ...f, color: e.target.value }))}
className="w-10 h-10 rounded-lg border border-border cursor-pointer" />
<div className="flex flex-wrap gap-1.5">
{ROLE_COLORS.map(c => (
<button key={c} type="button" onClick={() => setModalForm(f => ({ ...f, color: c }))}
className={`w-6 h-6 rounded-full border-2 transition-colors ${modalForm.color === c ? 'border-text-primary scale-110' : 'border-transparent'}`}
style={{ backgroundColor: c }} />
))}
</div>
</div>
</div>
<button onClick={handleCreate} disabled={!modalForm.name || saving}
className={`w-full px-4 py-2.5 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm ${saving ? 'btn-loading' : ''}`}>
{t('settings.addRole')}
</button>
</div>
</Modal>
</>
)
}
+205 -129
View File
@@ -1,11 +1,15 @@
import { useState, useEffect, useContext, useMemo } from 'react'
import { Plus, CheckSquare, Trash2, Search, LayoutGrid, List, CalendarDays, X, SlidersHorizontal } from 'lucide-react'
import { Plus, CheckSquare, Search, LayoutGrid, List, CalendarDays, X, SlidersHorizontal } from 'lucide-react'
import { AppContext } from '../App'
import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../i18n/LanguageContext'
import { api, PRIORITY_CONFIG, getBrandColor } from '../utils/api'
import TaskCard from '../components/TaskCard'
import KanbanBoard from '../components/KanbanBoard'
import KanbanCard from '../components/KanbanCard'
import TaskDetailPanel from '../components/TaskDetailPanel'
import BulkSelectBar from '../components/BulkSelectBar'
import Modal from '../components/Modal'
import TaskCalendarView from '../components/TaskCalendarView'
import DatePresetPicker from '../components/DatePresetPicker'
import EmptyState from '../components/EmptyState'
@@ -29,8 +33,6 @@ export default function Tasks() {
// UI state
const [viewMode, setViewMode] = useState('board')
const [selectedTask, setSelectedTask] = useState(null)
const [draggedTask, setDraggedTask] = useState(null)
const [dragOverCol, setDragOverCol] = useState(null)
// Filters
const [searchQuery, setSearchQuery] = useState('')
@@ -45,6 +47,11 @@ export default function Tasks() {
const [filterOverdue, setFilterOverdue] = useState(false)
const [activePreset, setActivePreset] = useState('')
const [showFilters, setShowFilters] = useState(false)
const [selectedIds, setSelectedIds] = useState(new Set())
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false)
const [showCreateModal, setShowCreateModal] = useState(false)
const [createForm, setCreateForm] = useState({ title: '', project_id: '', brand_id: '', priority: 'medium', assigned_to: '' })
const [createSaving, setCreateSaving] = useState(false)
// Assignable users & team
const [assignableUsers, setAssignableUsers] = useState([])
@@ -54,17 +61,17 @@ export default function Tasks() {
useEffect(() => { loadTasks() }, [currentUser])
useEffect(() => {
api.get('/users/assignable').then(res => setAssignableUsers(res.data || res || [])).catch(() => {})
api.get('/projects').then(res => setProjects(res.data || res || [])).catch(() => {})
api.get('/users/assignable').then(res => setAssignableUsers(Array.isArray(res) ? res : [])).catch(() => {})
api.get('/projects').then(res => setProjects(Array.isArray(res) ? res : [])).catch(() => {})
if (isSuperadmin) {
api.get('/team').then(res => setUsers(res.data || res || [])).catch(() => {})
api.get('/team').then(res => setUsers(Array.isArray(res) ? res : [])).catch(() => {})
}
}, [isSuperadmin])
const loadTasks = async () => {
try {
const res = await api.get('/tasks')
setTasks(res.data || res || [])
setTasks(Array.isArray(res) ? res : [])
} catch (err) {
console.error('Failed to load tasks:', err)
} finally {
@@ -177,12 +184,68 @@ export default function Tasks() {
}
}
const handleCreateTask = async () => {
setCreateSaving(true)
try {
const data = {
title: createForm.title,
priority: createForm.priority,
status: 'todo',
project_id: createForm.project_id ? Number(createForm.project_id) : null,
brand_id: createForm.brand_id ? Number(createForm.brand_id) : null,
assigned_to: createForm.assigned_to ? Number(createForm.assigned_to) : null,
is_personal: false,
}
const created = await api.post('/tasks', data)
setShowCreateModal(false)
toast.success(t('tasks.created'))
loadTasks()
// Open detail panel for further editing
if (created) setSelectedTask(created)
} catch (err) {
console.error('Create task failed:', err)
toast.error(t('common.saveFailed'))
} finally {
setCreateSaving(false)
}
}
const handleBulkDelete = async () => {
try {
await api.post('/tasks/bulk-delete', { ids: [...selectedIds] })
toast.success(t('tasks.deleted'))
setSelectedIds(new Set())
setShowBulkDeleteConfirm(false)
loadTasks()
} catch (err) {
console.error('Bulk delete failed:', err)
toast.error(t('common.deleteFailed'))
}
}
const toggleSelect = (id) => {
setSelectedIds(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const toggleSelectAll = () => {
if (selectedIds.size === sortedListTasks.length) setSelectedIds(new Set())
else setSelectedIds(new Set(sortedListTasks.map(t => t._id || t.id)))
}
const handleMove = async (taskId, newStatus) => {
// Optimistic update move the card instantly
const prev = tasks
setTasks(tasks.map(t => (t._id || t.id) === taskId ? { ...t, status: newStatus } : t))
try {
await api.patch(`/tasks/${taskId}`, { status: newStatus })
toast.success(t('tasks.statusUpdated'))
loadTasks()
} catch (err) {
setTasks(prev)
if (err.message?.includes('403')) toast.error(t('tasks.canOnlyEditOwn'))
else toast.error(t('common.updateFailed'))
}
@@ -192,45 +255,6 @@ export default function Tasks() {
setSelectedTask(task)
}
// Drag and drop (Kanban)
const handleDragStart = (e, task) => {
setDraggedTask(task)
e.dataTransfer.effectAllowed = 'move'
setTimeout(() => { if (e.target) e.target.style.opacity = '0.4' }, 0)
}
const handleDragEnd = (e) => {
e.target.style.opacity = '1'
setDraggedTask(null)
setDragOverCol(null)
}
const handleDragOver = (e, colStatus) => {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
setDragOverCol(colStatus)
}
const handleDragLeave = (e) => {
if (!e.currentTarget.contains(e.relatedTarget)) setDragOverCol(null)
}
const handleDrop = (e, colStatus) => {
e.preventDefault()
setDragOverCol(null)
if (draggedTask && draggedTask.status !== colStatus) {
handleMove(draggedTask._id || draggedTask.id, colStatus)
}
setDraggedTask(null)
}
// Kanban columns
const todoTasks = filteredTasks.filter(t => t.status === 'todo')
const inProgressTasks = filteredTasks.filter(t => t.status === 'in_progress')
const doneTasks = filteredTasks.filter(t => t.status === 'done')
const columns = [
{ label: t('tasks.todo'), status: 'todo', items: todoTasks, color: 'bg-gray-400' },
{ label: t('tasks.in_progress'), status: 'in_progress', items: inProgressTasks, color: 'bg-blue-400' },
{ label: t('tasks.done'), status: 'done', items: doneTasks, color: 'bg-emerald-400' },
]
// List view sorting
const [sortBy, setSortBy] = useState('due_date')
const [sortDir, setSortDir] = useState('asc')
@@ -301,16 +325,16 @@ export default function Tasks() {
<div className="flex items-center gap-3 flex-1 min-w-0">
{/* Search */}
<div className="relative flex-1 max-w-xs">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<Search className="absolute start-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<input
type="text"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder={t('tasks.search')}
className="w-full pl-9 pr-3 py-1.5 text-sm border border-border rounded-lg bg-surface-primary text-text-primary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
className="w-full ps-9 pe-3 py-1.5 text-sm border border-border rounded-lg bg-surface-primary text-text-primary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
/>
{searchQuery && (
<button onClick={() => setSearchQuery('')} className="absolute right-2 top-1/2 -translate-y-1/2 p-0.5 rounded text-text-tertiary hover:text-text-primary">
<button onClick={() => setSearchQuery('')} className="absolute end-2 top-1/2 -translate-y-1/2 p-0.5 rounded text-text-tertiary hover:text-text-primary">
<X className="w-3.5 h-3.5" />
</button>
)}
@@ -326,7 +350,7 @@ export default function Tasks() {
onClick={() => setViewMode(mode)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
viewMode === mode
? 'bg-white text-text-primary shadow-sm'
? 'bg-surface text-text-primary shadow-sm'
: 'text-text-tertiary hover:text-text-secondary'
}`}
>
@@ -360,7 +384,7 @@ export default function Tasks() {
</div>
<button
onClick={() => { setSelectedTask({ title: '', description: '', priority: 'medium', start_date: '', due_date: '', status: 'todo', assigned_to: '', project_id: '' }) }}
onClick={() => { setCreateForm({ title: '', project_id: '', brand_id: '', priority: 'medium', assigned_to: '' }); setShowCreateModal(true) }}
className="flex items-center gap-2 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm shrink-0"
>
<Plus className="w-4 h-4" />
@@ -375,7 +399,7 @@ export default function Tasks() {
<select
value={filterProject}
onChange={e => setFilterProject(e.target.value)}
className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-white text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
>
<option value="">{t('tasks.allProjects')}</option>
{taskProjects.map(p => (
@@ -387,7 +411,7 @@ export default function Tasks() {
<select
value={filterBrand}
onChange={e => setFilterBrand(e.target.value)}
className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-white text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
>
<option value="">{t('tasks.allBrands')}</option>
{taskBrands.map(b => (
@@ -416,7 +440,7 @@ export default function Tasks() {
className={`px-2.5 py-1 text-[11px] font-medium rounded-full border transition-colors ${
active
? 'bg-brand-primary/10 border-brand-primary/20 text-brand-primary'
: 'bg-white border-border text-text-tertiary'
: 'bg-surface border-border text-text-tertiary'
}`}
>
{t(`tasks.${s}`)}
@@ -429,7 +453,7 @@ export default function Tasks() {
<select
value={filterPriority}
onChange={e => setFilterPriority(e.target.value)}
className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-white text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
>
<option value="">{t('tasks.allPriorities')}</option>
<option value="low">{t('tasks.priority.low')}</option>
@@ -442,7 +466,7 @@ export default function Tasks() {
<select
value={filterAssignee}
onChange={e => setFilterAssignee(e.target.value)}
className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-white text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
>
<option value="">{t('tasks.allAssignees')}</option>
{(assignableUsers || []).map(m => (
@@ -455,7 +479,7 @@ export default function Tasks() {
<select
value={filterCreator}
onChange={e => setFilterCreator(e.target.value)}
className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-white text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
>
<option value="">{t('tasks.allCreators')}</option>
{users.map(m => (
@@ -477,7 +501,7 @@ export default function Tasks() {
type="date"
value={filterDateFrom}
onChange={e => { setFilterDateFrom(e.target.value); setActivePreset('') }}
className="px-2 py-1.5 text-xs border border-border rounded-lg bg-white text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
className="px-2 py-1.5 text-xs border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
title={t('posts.periodFrom')}
/>
<span className="text-text-tertiary text-xs">-</span>
@@ -485,7 +509,7 @@ export default function Tasks() {
type="date"
value={filterDateTo}
onChange={e => { setFilterDateTo(e.target.value); setActivePreset('') }}
className="px-2 py-1.5 text-xs border border-border rounded-lg bg-white text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
className="px-2 py-1.5 text-xs border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
title={t('posts.periodTo')}
/>
</div>
@@ -496,7 +520,7 @@ export default function Tasks() {
className={`px-2.5 py-1 text-[11px] font-medium rounded-full border transition-colors ${
filterOverdue
? 'bg-red-50 border-red-200 text-red-600'
: 'bg-white border-border text-text-tertiary'
: 'bg-surface border-border text-text-tertiary'
}`}
>
{t('tasks.overdue')}
@@ -529,99 +553,89 @@ export default function Tasks() {
<>
{/* ─── Board View ──────────────────────── */}
{viewMode === 'board' && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{columns.map(col => {
const isOver = dragOverCol === col.status && draggedTask?.status !== col.status
<KanbanBoard
columns={[
{ id: 'todo', label: t('tasks.todo'), color: 'bg-gray-400' },
{ id: 'in_progress', label: t('tasks.in_progress'), color: 'bg-blue-400' },
{ id: 'done', label: t('tasks.done'), color: 'bg-emerald-400' },
]}
items={filteredTasks}
getItemId={(t) => t._id || t.id}
onMove={handleMove}
emptyLabel={t('tasks.noTasks')}
renderCard={(task) => {
const dueDate = task.due_date || task.dueDate
const isOverdue = dueDate && new Date(dueDate) < new Date() && task.status !== 'done'
const assignee = task.assigned_name || task.assignedName
const brandName = task.brand_name || task.brandName
const projectName = task.project_name || task.projectName
return (
<div key={col.status}>
<div className="flex items-center gap-2 mb-3">
<div className={`w-2.5 h-2.5 rounded-full ${col.color}`} />
<h4 className="text-sm font-semibold text-text-primary">{col.label}</h4>
<span className="text-xs font-medium text-text-tertiary bg-surface-tertiary px-2 py-0.5 rounded-full">
{col.items.length}
<KanbanCard
title={task.title}
thumbnail={task.thumbnail_url}
brandName={brandName}
assigneeName={assignee}
date={dueDate}
dateOverdue={isOverdue}
onClick={() => openTask(task)}
tags={projectName && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-surface-tertiary text-text-tertiary">
{projectName}
</span>
</div>
<div
className={`kanban-column rounded-xl p-2 space-y-2 min-h-[200px] border-2 transition-colors ${
isOver
? 'bg-brand-primary/5 border-brand-primary/40 border-dashed'
: 'bg-surface-secondary border-border-light border-solid'
}`}
onDragOver={(e) => handleDragOver(e, col.status)}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, col.status)}
>
{col.items.length === 0 ? (
<div className={`py-8 text-center text-xs ${isOver ? 'text-brand-primary font-medium' : 'text-text-tertiary'}`}>
{isOver ? t('posts.dropHere') : t('tasks.noTasks')}
</div>
) : (
col.items.map(task => {
const canEdit = canEditResource('task', task)
const canDelete = canDeleteResource('task', task)
return (
<div
key={task._id || task.id}
draggable={canEdit}
onDragStart={(e) => canEdit && handleDragStart(e, task)}
onDragEnd={handleDragEnd}
className={canEdit ? 'cursor-grab active:cursor-grabbing' : ''}
>
<div className="relative group" onClick={() => openTask(task)}>
<TaskCard task={task} onMove={canEdit ? handleMove : undefined} showProject />
{canDelete && (
<div className="absolute top-2 right-2 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => { e.stopPropagation(); handlePanelDelete(task._id || task.id) }}
className="p-1 hover:bg-red-50 rounded text-text-tertiary hover:text-red-500"
title={t('common.delete')}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
)}
</div>
</div>
/>
)
})
)}
</div>
</div>
)
})}
</div>
}}
/>
)}
{/* ─── List View ───────────────────────── */}
{viewMode === 'list' && (
<div className="bg-white rounded-xl border border-border overflow-hidden">
<>
{selectedIds.size > 0 && (
<BulkSelectBar
selectedCount={selectedIds.size}
onClear={() => setSelectedIds(new Set())}
onDelete={() => setShowBulkDeleteConfirm(true)}
/>
)}
<div className="bg-surface rounded-xl border border-border overflow-clip">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-surface-secondary/50">
<th className="w-8 px-3 py-2.5">
<input
type="checkbox"
checked={sortedListTasks.length > 0 && selectedIds.size === sortedListTasks.length}
onChange={toggleSelectAll}
className="w-3.5 h-3.5 rounded border-border text-brand-primary cursor-pointer"
onClick={e => e.stopPropagation()}
/>
</th>
<th className="w-8 px-3 py-2.5"></th>
<th
className="text-left px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase cursor-pointer hover:text-text-primary"
className="text-start px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase cursor-pointer hover:text-text-primary"
onClick={() => toggleSort('title')}
>
{t('tasks.taskTitle')} {sortBy === 'title' && (sortDir === 'asc' ? '↑' : '↓')}
</th>
<th className="text-left px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase">{t('tasks.project')}</th>
<th className="text-left px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase">{t('tasks.brand')}</th>
<th className="text-start px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase">{t('tasks.project')}</th>
<th className="text-start px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase">{t('tasks.brand')}</th>
<th
className="text-left px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase cursor-pointer hover:text-text-primary"
className="text-start px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase cursor-pointer hover:text-text-primary"
onClick={() => toggleSort('status')}
>
{t('tasks.status')} {sortBy === 'status' && (sortDir === 'asc' ? '↑' : '↓')}
</th>
<th className="text-left px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase">{t('tasks.assignee')}</th>
<th className="text-start px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase">{t('tasks.assignee')}</th>
<th
className="text-left px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase cursor-pointer hover:text-text-primary"
className="text-start px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase cursor-pointer hover:text-text-primary"
onClick={() => toggleSort('due_date')}
>
{t('tasks.dueDate')} {sortBy === 'due_date' && (sortDir === 'asc' ? '↑' : '↓')}
</th>
<th
className="text-left px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase cursor-pointer hover:text-text-primary"
className="text-start px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase cursor-pointer hover:text-text-primary"
onClick={() => toggleSort('priority')}
>
{t('tasks.priority')} {sortBy === 'priority' && (sortDir === 'asc' ? '↑' : '↓')}
@@ -637,7 +651,7 @@ export default function Tasks() {
const brandName = task.brand_name || task.brandName
const assignedName = task.assigned_name || task.assignedName
const statusLabels = { todo: t('tasks.todo'), in_progress: t('tasks.in_progress'), done: t('tasks.done') }
const statusColors = { todo: 'bg-gray-100 text-gray-600', in_progress: 'bg-blue-100 text-blue-700', done: 'bg-emerald-100 text-emerald-700' }
const statusColors = { todo: 'bg-gray-100 text-text-secondary', in_progress: 'bg-blue-100 text-blue-700', done: 'bg-emerald-100 text-emerald-700' }
return (
<tr
@@ -645,6 +659,14 @@ export default function Tasks() {
onClick={() => openTask(task)}
className="border-b border-border-light hover:bg-surface-secondary/30 cursor-pointer transition-colors group"
>
<td className="px-3 py-2.5" onClick={e => e.stopPropagation()}>
<input
type="checkbox"
checked={selectedIds.has(task._id || task.id)}
onChange={() => toggleSelect(task._id || task.id)}
className="w-3.5 h-3.5 rounded border-border text-brand-primary cursor-pointer"
/>
</td>
<td className="px-3 py-2.5">
<div className={`w-2.5 h-2.5 rounded-full ${priority.color}`} title={priority.label} />
</td>
@@ -653,7 +675,7 @@ export default function Tasks() {
{task.title}
</span>
{(task.comment_count || task.commentCount) > 0 && (
<span className="ml-2 text-[10px] text-text-tertiary">💬 {task.comment_count || task.commentCount}</span>
<span className="ms-2 text-[10px] text-text-tertiary">💬 {task.comment_count || task.commentCount}</span>
)}
</td>
<td className="px-3 py-2.5 text-text-tertiary text-xs">{projectName || '—'}</td>
@@ -686,6 +708,7 @@ export default function Tasks() {
</tbody>
</table>
</div>
</>
)}
{/* ─── Calendar View ───────────────────── */}
@@ -695,7 +718,60 @@ export default function Tasks() {
</>
)}
{/* ─── Task Detail Side Panel ──────────────── */}
{/* ─── Create Task Modal ──────────────────── */}
<Modal isOpen={showCreateModal} onClose={() => setShowCreateModal(false)} title={t('tasks.newTask')} size="md">
<div className="space-y-4">
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.taskTitle')} *</label>
<input type="text" value={createForm.title} onChange={e => setCreateForm(f => ({ ...f, title: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary" autoFocus />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.project')}</label>
<select value={createForm.project_id} onChange={e => setCreateForm(f => ({ ...f, project_id: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary">
<option value=""></option>
{projects.map(p => <option key={p._id || p.id} value={p._id || p.id}>{p.name}</option>)}
</select>
</div>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.priority')}</label>
<select value={createForm.priority} onChange={e => setCreateForm(f => ({ ...f, priority: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary">
{Object.entries(PRIORITY_CONFIG).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
</select>
</div>
</div>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.assignedTo')}</label>
<select value={createForm.assigned_to} onChange={e => setCreateForm(f => ({ ...f, assigned_to: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary">
<option value="">{t('common.unassigned')}</option>
{assignableUsers.map(u => <option key={u._id || u.id} value={u._id || u.id}>{u.name}</option>)}
</select>
</div>
<button onClick={handleCreateTask} disabled={!createForm.title || createSaving}
className={`w-full px-4 py-2.5 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm ${createSaving ? 'btn-loading' : ''}`}>
{t('tasks.newTask')}
</button>
</div>
</Modal>
{/* ─── Bulk Delete Confirmation Modal ─────── */}
<Modal
isOpen={showBulkDeleteConfirm}
onClose={() => setShowBulkDeleteConfirm(false)}
title={t('common.bulkDeleteConfirm').replace('{count}', selectedIds.size)}
isConfirm
danger
confirmText={t('common.deleteSelected')}
onConfirm={handleBulkDelete}
>
{t('common.bulkDeleteDesc')}
</Modal>
{/* ─── Task Detail Side Panel (edit only) ─── */}
{selectedTask && (
<TaskDetailPanel
task={selectedTask}
+320 -43
View File
@@ -1,7 +1,7 @@
import { useState, useEffect, useContext } from 'react'
import { Plus, Users, ArrowLeft, User as UserIcon, Edit2, LayoutGrid, Network } from 'lucide-react'
import { useState, useEffect, useContext, useRef, useMemo } from 'react'
import { Plus, Users, ArrowLeft, User as UserIcon, Edit2, LayoutGrid, Network, Link2, ChevronDown, Check, X } from 'lucide-react'
import { getInitials } from '../utils/api'
import { AppContext } from '../App'
import { AppContext, PERMISSION_LEVELS } from '../App'
import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../i18n/LanguageContext'
import { api } from '../utils/api'
@@ -10,10 +10,26 @@ import StatusBadge from '../components/StatusBadge'
import BrandBadge from '../components/BrandBadge'
import TeamMemberPanel from '../components/TeamMemberPanel'
import TeamPanel from '../components/TeamPanel'
import Modal from '../components/Modal'
import { useToast } from '../components/ToastContainer'
const ALL_MODULES = ['marketing', 'projects', 'finance']
const MODULE_LABELS = { marketing: 'Marketing', projects: 'Projects', finance: 'Finance' }
const MODULE_COLORS = {
marketing: { on: 'bg-emerald-100 text-emerald-700 border-emerald-300', off: 'bg-gray-100 text-text-tertiary border-gray-200' },
projects: { on: 'bg-blue-100 text-blue-700 border-blue-300', off: 'bg-gray-100 text-text-tertiary border-gray-200' },
finance: { on: 'bg-amber-100 text-amber-700 border-amber-300', off: 'bg-gray-100 text-text-tertiary border-gray-200' },
}
const EMPTY_MEMBER = {
name: '', email: '', password: '', permission_level: 'contributor',
role_id: '', brands: [], phone: '', modules: [...ALL_MODULES], team_ids: [], preferred_language: 'en',
}
export default function Team() {
const { t } = useLanguage()
const { teamMembers, loadTeam, currentUser, teams, loadTeams, brands } = useContext(AppContext)
const { t, lang } = useLanguage()
const toast = useToast()
const { teamMembers, loadTeam, currentUser, teams, loadTeams, brands, roles } = useContext(AppContext)
const { user } = useAuth()
const [panelMember, setPanelMember] = useState(null)
const [panelIsEditingSelf, setPanelIsEditingSelf] = useState(false)
@@ -25,11 +41,87 @@ export default function Team() {
const [teamFilter, setTeamFilter] = useState(null)
const [viewMode, setViewMode] = useState('grid') // 'grid' | 'teams'
// Add member modal state
const [showAddModal, setShowAddModal] = useState(false)
const [addForm, setAddForm] = useState({ ...EMPTY_MEMBER })
const [addConfirmPassword, setAddConfirmPassword] = useState('')
const [addPasswordError, setAddPasswordError] = useState('')
const [addSaving, setAddSaving] = useState(false)
const [showAddBrandsDropdown, setShowAddBrandsDropdown] = useState(false)
const addBrandsRef = useRef(null)
const canManageTeam = user?.role === 'superadmin' || user?.role === 'manager'
const copyIssueLink = (teamId) => {
const base = `${window.location.origin}/submit-issue`
const url = teamId ? `${base}?team=${teamId}` : base
navigator.clipboard.writeText(url)
toast.success(t('issues.linkCopied'))
}
// Close brands dropdown on outside click
useEffect(() => {
const handler = (e) => {
if (addBrandsRef.current && !addBrandsRef.current.contains(e.target)) setShowAddBrandsDropdown(false)
}
if (showAddBrandsDropdown) document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [showAddBrandsDropdown])
const openNew = () => {
setPanelMember({ role: 'content_writer' })
setPanelIsEditingSelf(false)
setAddForm({ ...EMPTY_MEMBER })
setAddConfirmPassword('')
setAddPasswordError('')
setShowAddModal(true)
}
const handleAddMember = async () => {
setAddPasswordError('')
if (addForm.password && addForm.password !== addConfirmPassword) {
setAddPasswordError(t('team.passwordsDoNotMatch'))
return
}
setAddSaving(true)
try {
const payload = {
name: addForm.name,
email: addForm.email,
role: addForm.permission_level,
role_id: addForm.role_id || null,
brands: addForm.brands,
phone: addForm.phone,
modules: addForm.modules,
preferred_language: addForm.preferred_language || 'en',
}
if (addForm.password) payload.password = addForm.password
const created = await api.post('/users/team', payload)
const memberId = created?.id || created?.Id
// Sync team memberships
if (addForm.team_ids.length > 0 && memberId) {
for (const teamId of addForm.team_ids) {
await api.post(`/teams/${teamId}/members`, { user_id: memberId })
}
}
await loadTeam()
await loadTeams()
setShowAddModal(false)
toast.success(t('team.memberAdded') || 'Member added')
} catch (err) {
console.error('Add member failed:', err)
toast.error(err.message || t('common.failedToSave'))
} finally {
setAddSaving(false)
}
}
const updateAdd = (field, value) => setAddForm(f => ({ ...f, [field]: value }))
const toggleAddBrand = (name) => {
setAddForm(f => ({
...f,
brands: f.brands.includes(name) ? f.brands.filter(b => b !== name) : [...f.brands, name],
}))
}
const openEdit = (member) => {
@@ -40,18 +132,17 @@ export default function Team() {
const handlePanelSave = async (memberId, data, isEditingSelf) => {
try {
if (isEditingSelf) {
if (isEditingSelf && user?.role !== 'superadmin') {
await api.patch('/users/me/profile', {
name: data.name,
team_role: data.role,
brands: data.brands,
phone: data.phone,
})
} else {
const payload = {
name: data.name,
email: data.email,
team_role: data.role,
role: data.role,
role_id: data.role_id,
brands: data.brands,
phone: data.phone,
modules: data.modules,
@@ -67,7 +158,7 @@ export default function Team() {
}
// Sync team memberships if team_ids provided
if (data.team_ids !== undefined && memberId && !isEditingSelf) {
if (data.team_ids !== undefined && memberId) {
const member = teamMembers.find(m => (m.id || m._id) === memberId)
const currentTeamIds = member?.teams ? member.teams.map(t => t.id) : []
const targetTeamIds = data.team_ids || []
@@ -83,11 +174,11 @@ export default function Team() {
}
}
loadTeam()
loadTeams()
await loadTeam()
await loadTeams()
} catch (err) {
console.error('Save failed:', err)
alert(err.message || 'Failed to save')
toast.error(err.message || t('common.failedToSave'))
}
}
@@ -98,11 +189,11 @@ export default function Team() {
} else {
await api.post('/teams', data)
}
loadTeams()
loadTeam()
await loadTeams()
await loadTeam()
} catch (err) {
console.error('Team save failed:', err)
alert(err.message || 'Failed to save team')
toast.error(err.message || t('team.failedToSaveTeam'))
}
}
@@ -111,8 +202,8 @@ export default function Team() {
await api.delete(`/teams/${teamId}`)
setPanelTeam(null)
if (teamFilter === teamId) setTeamFilter(null)
loadTeams()
loadTeam()
await loadTeams()
await loadTeam()
} catch (err) {
console.error('Team delete failed:', err)
}
@@ -124,7 +215,7 @@ export default function Team() {
setSelectedMember(null)
}
setPanelMember(null)
loadTeam()
await loadTeam()
}
const openMemberDetail = async (member) => {
@@ -147,9 +238,11 @@ export default function Team() {
// Member detail view
if (selectedMember) {
const todoCount = memberTasks.filter(t => t.status === 'todo').length
const inProgressCount = memberTasks.filter(t => t.status === 'in_progress').length
const doneCount = memberTasks.filter(t => t.status === 'done').length
const { todoCount, inProgressCount, doneCount } = useMemo(() => ({
todoCount: memberTasks.filter(t => t.status === 'todo').length,
inProgressCount: memberTasks.filter(t => t.status === 'in_progress').length,
doneCount: memberTasks.filter(t => t.status === 'done').length,
}), [memberTasks])
return (
<div className="space-y-6 animate-fade-in">
@@ -162,14 +255,14 @@ export default function Team() {
</button>
{/* Member profile */}
<div className="bg-white rounded-xl border border-border p-6">
<div className="bg-surface rounded-xl border border-border p-6">
<div className="flex items-start gap-4">
<div className={`w-16 h-16 rounded-full bg-gradient-to-br from-indigo-400 to-purple-500 flex items-center justify-center text-white text-xl font-bold`}>
{selectedMember.name?.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase()}
</div>
<div className="flex-1">
<h2 className="text-xl font-bold text-text-primary">{selectedMember.name}</h2>
<p className="text-sm text-text-secondary capitalize">{(selectedMember.team_role || selectedMember.role)?.replace('_', ' ')}</p>
<p className="text-sm text-text-secondary capitalize">{selectedMember.role_name || selectedMember.team_role || ''}</p>
{selectedMember.email && (
<p className="text-sm text-text-tertiary mt-1">{selectedMember.email}</p>
)}
@@ -190,19 +283,19 @@ export default function Team() {
{/* Workload stats */}
<div className="grid grid-cols-4 gap-4">
<div className="bg-white rounded-xl border border-border p-4 text-center">
<div className="bg-surface rounded-xl border border-border p-4 text-center">
<p className="text-2xl font-bold text-text-primary">{memberTasks.length}</p>
<p className="text-xs text-text-tertiary">{t('team.totalTasks')}</p>
</div>
<div className="bg-white rounded-xl border border-border p-4 text-center">
<div className="bg-surface rounded-xl border border-border p-4 text-center">
<p className="text-2xl font-bold text-amber-500">{todoCount}</p>
<p className="text-xs text-text-tertiary">{t('team.toDo')}</p>
</div>
<div className="bg-white rounded-xl border border-border p-4 text-center">
<div className="bg-surface rounded-xl border border-border p-4 text-center">
<p className="text-2xl font-bold text-blue-500">{inProgressCount}</p>
<p className="text-xs text-text-tertiary">{t('team.inProgress')}</p>
</div>
<div className="bg-white rounded-xl border border-border p-4 text-center">
<div className="bg-surface rounded-xl border border-border p-4 text-center">
<p className="text-2xl font-bold text-emerald-500">{doneCount}</p>
<p className="text-xs text-text-tertiary">{t('tasks.done')}</p>
</div>
@@ -211,7 +304,7 @@ export default function Team() {
{/* Tasks & Posts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Tasks */}
<div className="bg-white rounded-xl border border-border">
<div className="bg-surface rounded-xl border border-border">
<div className="px-5 py-4 border-b border-border">
<h3 className="font-semibold text-text-primary">{t('tasks.title')} ({memberTasks.length})</h3>
</div>
@@ -236,7 +329,7 @@ export default function Team() {
</div>
{/* Posts */}
<div className="bg-white rounded-xl border border-border">
<div className="bg-surface rounded-xl border border-border">
<div className="px-5 py-4 border-b border-border">
<h3 className="font-semibold text-text-primary">{t('nav.posts')} ({memberPosts.length})</h3>
</div>
@@ -303,7 +396,7 @@ export default function Team() {
{displayedMembers.length} {displayedMembers.length !== 1 ? t('team.membersPlural') : t('team.member')}
</p>
{/* View toggle */}
<div className="flex items-center bg-white border border-border rounded-lg overflow-hidden">
<div className="flex items-center bg-surface border border-border rounded-lg overflow-hidden">
<button
onClick={() => setViewMode('grid')}
className={`p-2 transition-colors ${viewMode === 'grid' ? 'bg-brand-primary text-white' : 'text-text-tertiary hover:text-text-primary'}`}
@@ -321,13 +414,23 @@ export default function Team() {
</div>
</div>
<div className="flex gap-2">
{/* Copy generic issue link */}
<button
onClick={() => copyIssueLink()}
className="flex items-center gap-2 px-4 py-2 bg-surface border border-border text-text-primary rounded-lg text-sm font-medium hover:bg-surface-secondary transition-colors"
title={t('team.copyGenericIssueLink')}
>
<Link2 className="w-4 h-4" />
{t('issues.copyPublicLink')}
</button>
{/* Edit own profile button */}
<button
onClick={() => {
const self = teamMembers.find(m => m._id === user?.id || m.id === user?.id)
if (self) openEdit(self)
}}
className="flex items-center gap-2 px-4 py-2 bg-white border border-border text-text-primary rounded-lg text-sm font-medium hover:bg-surface-secondary transition-colors"
className="flex items-center gap-2 px-4 py-2 bg-surface border border-border text-text-primary rounded-lg text-sm font-medium hover:bg-surface-secondary transition-colors"
>
<UserIcon className="w-4 h-4" />
{t('team.myProfile')}
@@ -337,7 +440,7 @@ export default function Team() {
{canManageTeam && (
<button
onClick={() => setPanelTeam({})}
className="flex items-center gap-2 px-4 py-2 bg-white border border-border text-text-primary rounded-lg text-sm font-medium hover:bg-surface-secondary transition-colors"
className="flex items-center gap-2 px-4 py-2 bg-surface border border-border text-text-primary rounded-lg text-sm font-medium hover:bg-surface-secondary transition-colors"
>
<Users className="w-4 h-4" />
{t('teams.createTeam')}
@@ -367,7 +470,7 @@ export default function Team() {
<button
onClick={() => setTeamFilter(null)}
className={`text-xs px-3 py-1.5 rounded-full border font-medium transition-colors ${
!teamFilter ? 'bg-brand-primary text-white border-brand-primary' : 'bg-white text-text-secondary border-border hover:bg-surface-tertiary'
!teamFilter ? 'bg-brand-primary text-white border-brand-primary' : 'bg-surface text-text-secondary border-border hover:bg-surface-tertiary'
}`}
>
{t('common.all')}
@@ -380,7 +483,7 @@ export default function Team() {
<button
onClick={() => setTeamFilter(active ? null : tid)}
className={`text-xs px-3 py-1.5 rounded-full border font-medium transition-colors ${
active ? 'bg-blue-600 text-white border-blue-600' : 'bg-white text-text-secondary border-border hover:bg-surface-tertiary'
active ? 'bg-blue-600 text-white border-blue-600' : 'bg-surface text-text-secondary border-border hover:bg-surface-tertiary'
}`}
>
{team.name} ({team.member_count || 0})
@@ -430,7 +533,7 @@ export default function Team() {
const tid = team.id || team._id
const members = teamMembers.filter(m => m.teams?.some(t => t.id === tid))
return (
<div key={tid} className="bg-white rounded-xl border border-border overflow-hidden">
<div key={tid} className="bg-surface rounded-xl border border-border overflow-clip">
{/* Team header */}
<div className="flex items-center justify-between px-5 py-4 bg-gradient-to-r from-blue-50 to-indigo-50 border-b border-border">
<div className="flex items-center gap-3">
@@ -445,15 +548,24 @@ export default function Team() {
</p>
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => copyIssueLink(tid)}
className="px-2 py-1.5 text-sm text-text-tertiary hover:text-blue-600 hover:bg-blue-100 rounded-lg transition-colors"
title={t('team.copyIssueLink')}
>
<Link2 className="w-4 h-4" />
</button>
{canManageTeam && (
<button
onClick={() => setPanelTeam(team)}
className="px-3 py-1.5 text-sm font-medium text-blue-600 hover:bg-blue-100 rounded-lg transition-colors"
className="px-2 py-1.5 text-sm font-medium text-blue-600 hover:bg-blue-100 rounded-lg transition-colors"
>
<Edit2 className="w-4 h-4" />
</button>
)}
</div>
</div>
{/* Team members */}
{members.length === 0 ? (
@@ -473,7 +585,7 @@ export default function Team() {
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary">{member.name}</p>
<p className="text-xs text-text-tertiary capitalize">{(member.team_role || member.role)?.replace('_', ' ')}</p>
<p className="text-xs text-text-tertiary capitalize">{member.role_name || member.team_role || ''}</p>
</div>
{member.brands && member.brands.length > 0 && (
<div className="flex flex-wrap gap-1 shrink-0">
@@ -491,7 +603,7 @@ export default function Team() {
{/* Unassigned members */}
{unassignedMembers.length > 0 && (
<div className="bg-white rounded-xl border border-border overflow-hidden">
<div className="bg-surface rounded-xl border border-border overflow-clip">
<div className="flex items-center gap-3 px-5 py-4 bg-gray-50 border-b border-border">
<div className="w-10 h-10 rounded-lg bg-gray-400 flex items-center justify-center text-white">
<UserIcon className="w-5 h-5" />
@@ -517,7 +629,7 @@ export default function Team() {
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary">{member.name}</p>
<p className="text-xs text-text-tertiary capitalize">{(member.team_role || member.role)?.replace('_', ' ')}</p>
<p className="text-xs text-text-tertiary capitalize">{member.role_name || member.team_role || ''}</p>
</div>
{member.brands && member.brands.length > 0 && (
<div className="flex flex-wrap gap-1 shrink-0">
@@ -535,7 +647,172 @@ export default function Team() {
</div>
)}
{/* Team Member Panel */}
{/* Add Member Modal */}
<Modal isOpen={showAddModal} onClose={() => setShowAddModal(false)} title={t('team.addMember')} size="md">
<div className="space-y-4">
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.fullName')} *</label>
<input type="text" value={addForm.name} onChange={e => updateAdd('name', e.target.value)}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary" placeholder={t('team.fullName')} />
</div>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.email')} *</label>
<input type="email" value={addForm.email} onChange={e => updateAdd('email', e.target.value)}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary" placeholder="email@example.com" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.password')}</label>
<input type="password" value={addForm.password} onChange={e => updateAdd('password', e.target.value)}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary" placeholder="••••••••" />
{!addForm.password && <p className="text-xs text-text-tertiary mt-1">{t('team.defaultPassword')}</p>}
</div>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.confirmPassword')}</label>
<input type="password" value={addConfirmPassword}
onChange={e => { setAddConfirmPassword(e.target.value); setAddPasswordError('') }}
disabled={!addForm.password}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary disabled:opacity-50" placeholder="••••••••" />
{addPasswordError && <p className="text-xs text-red-500 mt-1">{addPasswordError}</p>}
</div>
</div>
<div className="grid grid-cols-2 gap-3">
{user?.role === 'superadmin' && (
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.permissionLevel')}</label>
<select value={addForm.permission_level} onChange={e => updateAdd('permission_level', e.target.value)}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary">
{PERMISSION_LEVELS.map(p => <option key={p.value} value={p.value}>{p.label}</option>)}
</select>
</div>
)}
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.role')}</label>
<select value={addForm.role_id || ''} onChange={e => updateAdd('role_id', e.target.value ? Number(e.target.value) : null)}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary">
<option value="">{t('team.selectRole')}</option>
{roles.map(r => <option key={r.Id || r.id} value={r.Id || r.id}>{r.name}</option>)}
</select>
</div>
</div>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.phone')}</label>
<input type="text" value={addForm.phone} onChange={e => updateAdd('phone', e.target.value)}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary" placeholder="+966 ..." />
</div>
{/* Brands multi-select */}
<div ref={addBrandsRef} className="relative">
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.brands')}</label>
<button type="button" onClick={() => setShowAddBrandsDropdown(p => !p)}
className="w-full flex items-center justify-between px-3 py-2 text-sm border border-border rounded-lg bg-surface text-start focus:outline-none focus:ring-2 focus:ring-brand-primary/20">
<span className={`flex-1 truncate ${addForm.brands.length === 0 ? 'text-text-tertiary' : 'text-text-primary'}`}>
{addForm.brands.length === 0 ? t('team.selectBrands') : addForm.brands.join(', ')}
</span>
<ChevronDown className={`w-4 h-4 text-text-tertiary shrink-0 transition-transform ${showAddBrandsDropdown ? 'rotate-180' : ''}`} />
</button>
{addForm.brands.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1.5">
{addForm.brands.map(b => (
<span key={b} className="inline-flex items-center gap-1 text-[10px] px-2 py-0.5 rounded-full bg-brand-primary/10 text-brand-primary font-medium">
{b}
<button type="button" onClick={() => toggleAddBrand(b)} className="hover:text-red-500"><X className="w-2.5 h-2.5" /></button>
</span>
))}
</div>
)}
{showAddBrandsDropdown && (
<div className="absolute z-20 mt-1 w-full bg-surface border border-border rounded-lg shadow-lg max-h-48 overflow-y-auto">
{brands.map(brand => {
const name = lang === 'ar' && brand.name_ar ? brand.name_ar : brand.name
const checked = addForm.brands.includes(name)
return (
<button key={brand.id || brand._id} type="button" onClick={() => toggleAddBrand(name)}
className={`w-full flex items-center gap-2.5 px-3 py-2 hover:bg-surface-secondary transition-colors text-start ${checked ? 'bg-brand-primary/5' : ''}`}>
<div className={`w-4 h-4 rounded border flex items-center justify-center shrink-0 ${checked ? 'bg-brand-primary border-brand-primary' : 'border-border'}`}>
{checked && <Check className="w-3 h-3 text-white" />}
</div>
<span className="text-sm text-text-primary">{brand.icon ? `${brand.icon} ` : ''}{name}</span>
</button>
)
})}
</div>
)}
</div>
{/* Modules */}
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.modules')}</label>
<div className="flex flex-wrap gap-2">
{ALL_MODULES.map(mod => {
const active = addForm.modules.includes(mod)
const colors = MODULE_COLORS[mod]
return (
<button key={mod} type="button"
onClick={() => updateAdd('modules', active ? addForm.modules.filter(m => m !== mod) : [...addForm.modules, mod])}
className={`text-xs px-3 py-1.5 rounded-full border font-medium transition-colors ${active ? colors.on : colors.off}`}>
{MODULE_LABELS[mod]}
</button>
)
})}
</div>
</div>
{/* Teams */}
{teams.length > 0 && (
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('teams.teams')}</label>
<div className="flex flex-wrap gap-2">
{teams.map(team => {
const tid = team.id || team._id
const active = addForm.team_ids.includes(tid)
return (
<button key={tid} type="button"
onClick={() => updateAdd('team_ids', active ? addForm.team_ids.filter(id => id !== tid) : [...addForm.team_ids, tid])}
className={`text-xs px-3 py-1.5 rounded-full border font-medium transition-colors ${active ? 'bg-blue-100 text-blue-700 border-blue-300' : 'bg-gray-100 text-text-tertiary border-gray-200'}`}>
{team.name}
</button>
)
})}
</div>
</div>
)}
{/* Preferred Language */}
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('users.preferredLanguage')}</label>
<div className="grid grid-cols-2 gap-2">
{[{ value: 'en', label: 'English', flag: '🇬🇧' }, { value: 'ar', label: 'العربية', flag: '🇸🇦' }].map(l => (
<button
key={l.value}
type="button"
onClick={() => updateAdd('preferred_language', l.value)}
className={`p-2 rounded-lg border-2 text-center transition-all ${
addForm.preferred_language === l.value
? 'border-brand-primary bg-brand-primary/5'
: 'border-border hover:border-brand-primary/30'
}`}
>
<span className="text-lg">{l.flag}</span>
<span className="text-xs font-medium text-text-primary ms-1.5">{l.label}</span>
</button>
))}
</div>
</div>
<button onClick={handleAddMember}
disabled={!addForm.name || !addForm.email || addSaving}
className={`w-full px-4 py-2.5 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm ${addSaving ? 'btn-loading' : ''}`}>
{t('team.addMember')}
</button>
</div>
</Modal>
{/* Team Member Panel (edit only) */}
{panelMember && (
<TeamMemberPanel
member={panelMember}
+551
View File
@@ -0,0 +1,551 @@
import { useState, useEffect, useContext, useMemo } from 'react'
import { Plus, Search, LayoutGrid, List, ChevronUp, ChevronDown, Languages, Globe, FileEdit } from 'lucide-react'
import { AppContext } from '../App'
import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../i18n/LanguageContext'
import { api } from '../utils/api'
import Modal from '../components/Modal'
import BulkSelectBar from '../components/BulkSelectBar'
import { useToast } from '../components/ToastContainer'
import { SkeletonCard, SkeletonTable } from '../components/SkeletonLoader'
import TranslationDetailPanel from '../components/TranslationDetailPanel'
import ApproverMultiSelect from '../components/ApproverMultiSelect'
import { AVAILABLE_LANGUAGES, TRANSLATION_STATUS_COLORS } from '../utils/translations'
const SORT_OPTIONS = [
{ value: 'updated_at', dir: 'desc', labelKey: 'translations.sortRecentlyUpdated' },
{ value: 'created_at', dir: 'desc', labelKey: 'translations.sortNewest' },
{ value: 'created_at', dir: 'asc', labelKey: 'translations.sortOldest' },
{ value: 'title', dir: 'asc', labelKey: 'translations.sortTitleAZ' },
]
export default function Translations() {
const { t } = useLanguage()
const { brands, teamMembers } = useContext(AppContext)
const { user, canDeleteResource } = useAuth()
const toast = useToast()
const [translations, setTranslations] = useState([])
const [loading, setLoading] = useState(true)
const [filters, setFilters] = useState({ brand: '', status: '', creator: '' })
const [searchTerm, setSearchTerm] = useState('')
const [showCreateModal, setShowCreateModal] = useState(false)
const [selectedTranslation, setSelectedTranslation] = useState(null)
const [newTranslation, setNewTranslation] = useState({ title: '', source_language: 'EN', source_content: '', brand_id: '', post_id: '', approver_ids: [] })
const [saving, setSaving] = useState(false)
const [posts, setPosts] = useState([])
const [showCreatePost, setShowCreatePost] = useState(false)
const [newPostTitle, setNewPostTitle] = useState('')
const [creatingPost, setCreatingPost] = useState(false)
// Bulk select
const [selectedIds, setSelectedIds] = useState(new Set())
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false)
// View + sort
const [viewMode, setViewMode] = useState('list')
const [sortOption, setSortOption] = useState(0)
const [listSortBy, setListSortBy] = useState('updated_at')
const [listSortDir, setListSortDir] = useState('desc')
const [assignableUsers, setAssignableUsers] = useState([])
useEffect(() => {
loadTranslations()
api.get('/users/assignable').then(res => setAssignableUsers(Array.isArray(res) ? res : [])).catch(() => {})
api.get('/posts').then(res => setPosts(Array.isArray(res) ? res : [])).catch(() => {})
}, [])
const loadTranslations = async () => {
try {
const res = await api.get('/translations')
setTranslations(Array.isArray(res) ? res : [])
} catch (err) {
console.error('Failed to load translations:', err)
toast.error(t('translations.loadFailed'))
} finally {
setLoading(false)
}
}
const handleCreate = async () => {
if (!newTranslation.title) {
toast.error(t('translations.titleRequired'))
return
}
if (!newTranslation.source_content) {
toast.error(t('translations.sourceContentRequired'))
return
}
setSaving(true)
try {
const created = await api.post('/translations', {
...newTranslation,
approver_ids: newTranslation.approver_ids.length > 0 ? newTranslation.approver_ids.join(',') : null,
post_id: newTranslation.post_id || null,
})
toast.success(t('translations.created'))
setShowCreateModal(false)
setNewTranslation({ title: '', source_language: 'EN', source_content: '', brand_id: '', post_id: '', approver_ids: [] })
loadTranslations()
setSelectedTranslation(created)
} catch (err) {
console.error('Create failed:', err)
toast.error(t('translations.createFailed'))
} finally {
setSaving(false)
}
}
const handleDelete = async (id) => {
try {
await api.delete(`/translations/${id}`)
toast.success(t('translations.deleted'))
setSelectedTranslation(null)
loadTranslations()
} catch (err) {
toast.error(t('translations.deleteFailed'))
}
}
const handleCreatePost = async () => {
if (!newPostTitle.trim()) return
setCreatingPost(true)
try {
const created = await api.post('/posts', { title: newPostTitle, status: 'draft' })
const postId = created.Id || created.id || created._id
setPosts(prev => [created, ...prev])
setNewTranslation(f => ({ ...f, post_id: String(postId) }))
setShowCreatePost(false)
setNewPostTitle('')
toast.success(t('translations.postCreated'))
} catch (err) {
toast.error(t('translations.postCreateFailed'))
} finally {
setCreatingPost(false)
}
}
const handleBulkDelete = async () => {
try {
await api.post('/translations/bulk-delete', { ids: [...selectedIds] })
toast.success(t('translations.deleted'))
setSelectedIds(new Set())
setShowBulkDeleteConfirm(false)
loadTranslations()
} catch (err) {
toast.error(t('translations.deleteFailed'))
}
}
const toggleSelect = (id) => {
setSelectedIds(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const toggleSelectAll = () => {
if (selectedIds.size === sortedTranslations.length) setSelectedIds(new Set())
else setSelectedIds(new Set(sortedTranslations.map(t => t.Id)))
}
const filteredTranslations = useMemo(() => {
return translations.filter(t => {
if (filters.brand && String(t.brand_id) !== filters.brand) return false
if (filters.status && t.status !== filters.status) return false
if (filters.creator && String(t.created_by_user_id) !== filters.creator) return false
if (searchTerm && !t.title?.toLowerCase().includes(searchTerm.toLowerCase())) return false
return true
})
}, [translations, filters, searchTerm])
const sortedTranslations = useMemo(() => {
const sBy = viewMode === 'grid' ? SORT_OPTIONS[sortOption].value : listSortBy
const sDir = viewMode === 'grid' ? SORT_OPTIONS[sortOption].dir : listSortDir
return [...filteredTranslations].sort((a, b) => {
let cmp = 0
if (sBy === 'updated_at') {
cmp = (a.UpdatedAt || a.updated_at || '').localeCompare(b.UpdatedAt || b.updated_at || '')
} else if (sBy === 'created_at') {
cmp = (a.CreatedAt || a.created_at || '').localeCompare(b.CreatedAt || b.created_at || '')
} else if (sBy === 'title') {
cmp = (a.title || '').localeCompare(b.title || '')
} else if (sBy === 'status') {
cmp = (a.status || '').localeCompare(b.status || '')
}
return sDir === 'asc' ? cmp : -cmp
})
}, [filteredTranslations, viewMode, sortOption, listSortBy, listSortDir])
const toggleListSort = (col) => {
if (listSortBy === col) setListSortDir(d => d === 'asc' ? 'desc' : 'asc')
else { setListSortBy(col); setListSortDir('asc') }
}
const SortIcon = ({ col }) => {
if (listSortBy !== col) return null
return listSortDir === 'asc'
? <ChevronUp className="w-3 h-3 inline ms-0.5" />
: <ChevronDown className="w-3 h-3 inline ms-0.5" />
}
const formatDate = (dateStr) => {
if (!dateStr) return '—'
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
}
const getLangLabel = (code) => AVAILABLE_LANGUAGES.find(l => l.code === code)?.label || code
return (
<div className="space-y-4 animate-fade-in">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-text-primary">{t('translations.title')}</h1>
<p className="text-sm text-text-secondary mt-1">{t('translations.subtitle')}</p>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center bg-surface-tertiary rounded-lg p-0.5">
{[
{ mode: 'grid', icon: LayoutGrid, label: t('translations.grid') },
{ mode: 'list', icon: List, label: t('translations.list') },
].map(({ mode, icon: Icon, label }) => (
<button
key={mode}
onClick={() => setViewMode(mode)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
viewMode === mode
? 'bg-surface text-text-primary shadow-sm'
: 'text-text-tertiary hover:text-text-secondary'
}`}
>
<Icon className="w-3.5 h-3.5" />
{label}
</button>
))}
</div>
<button
onClick={() => setShowCreateModal(true)}
className="flex items-center gap-2 px-4 py-2 bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors shadow-sm"
>
<Plus className="w-4 h-4" />
<span className="font-medium">{t('translations.newTranslation')}</span>
</button>
</div>
</div>
{/* Filters */}
<div className="flex flex-wrap items-center gap-3">
<div className="relative flex-1 min-w-[200px] max-w-md">
<Search className="absolute start-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<input
type="text"
placeholder={t('translations.searchTranslations')}
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
className="w-full ps-10 pe-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface transition-colors"
/>
</div>
<select
value={filters.brand}
onChange={e => setFilters(f => ({ ...f, brand: e.target.value }))}
className="text-sm border border-border rounded-lg px-3 py-2 bg-surface text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 transition-colors"
>
<option value="">{t('translations.allBrands')}</option>
{brands.map(b => <option key={b._id} value={b._id}>{b.name}</option>)}
</select>
<select
value={filters.status}
onChange={e => setFilters(f => ({ ...f, status: e.target.value }))}
className="text-sm border border-border rounded-lg px-3 py-2 bg-surface text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 transition-colors"
>
<option value="">{t('translations.allStatuses')}</option>
<option value="draft">{t('translations.status.draft')}</option>
<option value="pending_review">{t('translations.status.pendingReview')}</option>
<option value="approved">{t('translations.status.approved')}</option>
<option value="rejected">{t('translations.status.rejected')}</option>
<option value="revision_requested">{t('translations.status.revisionRequested')}</option>
</select>
<select
value={filters.creator}
onChange={e => setFilters(f => ({ ...f, creator: e.target.value }))}
className="text-sm border border-border rounded-lg px-3 py-2 bg-surface text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 transition-colors"
>
<option value="">{t('translations.allCreators')}</option>
{teamMembers.map(m => <option key={m._id} value={m._id}>{m.name}</option>)}
</select>
{viewMode === 'grid' && (
<select
value={sortOption}
onChange={e => setSortOption(Number(e.target.value))}
className="text-sm border border-border rounded-lg px-3 py-2 bg-surface text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 transition-colors"
>
{SORT_OPTIONS.map((opt, i) => <option key={i} value={i}>{t(opt.labelKey)}</option>)}
</select>
)}
</div>
{/* Bulk select bar */}
{selectedIds.size > 0 && (
<BulkSelectBar
count={selectedIds.size}
onClear={() => setSelectedIds(new Set())}
onDelete={() => setShowBulkDeleteConfirm(true)}
/>
)}
{/* Content */}
{loading ? (
viewMode === 'grid' ? <SkeletonCard count={6} /> : <SkeletonTable rows={5} cols={7} />
) : viewMode === 'grid' ? (
/* Grid View */
sortedTranslations.length === 0 ? (
<div className="text-center py-16">
<Languages className="w-12 h-12 text-text-tertiary mx-auto mb-3" />
<p className="text-text-secondary">{t('translations.noTranslations')}</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{sortedTranslations.map(tr => (
<div
key={tr.Id}
onClick={() => setSelectedTranslation(tr)}
className="bg-surface rounded-xl border border-border p-5 hover:shadow-md transition-all cursor-pointer group"
>
<div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-text-primary line-clamp-1">{tr.title}</h3>
<div className="flex items-center gap-2 mt-1">
<span className={`text-xs px-2 py-0.5 rounded-full ${TRANSLATION_STATUS_COLORS[tr.status] || 'bg-surface-tertiary text-text-secondary'}`}>
{tr.status?.replace('_', ' ')}
</span>
<span className="text-xs px-2 py-0.5 rounded-full bg-blue-50 text-blue-600 font-medium">
{getLangLabel(tr.source_language)}
</span>
</div>
</div>
</div>
<div className="flex items-center gap-2 text-xs text-text-tertiary flex-wrap">
{tr.brand_name && <span>{tr.brand_name}</span>}
{tr.post_name && <span className="flex items-center gap-1"><FileEdit className="w-3 h-3" />{tr.post_name}</span>}
{tr.creator_name && <span>by {tr.creator_name}</span>}
<span>{tr.translation_count || 0} {t('translations.languagesCount')}</span>
</div>
</div>
))}
</div>
)
) : (
/* List View */
sortedTranslations.length === 0 ? (
<div className="text-center py-16">
<Languages className="w-12 h-12 text-text-tertiary mx-auto mb-3" />
<p className="text-text-secondary">{t('translations.noTranslations')}</p>
</div>
) : (
<div className="bg-surface rounded-xl border border-border overflow-clip">
<table className="w-full">
<thead>
<tr className="border-b border-border bg-surface-secondary">
<th className="px-4 py-3 text-start w-10">
<input type="checkbox" checked={selectedIds.size === sortedTranslations.length && sortedTranslations.length > 0} onChange={toggleSelectAll} className="rounded border-border" />
</th>
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase cursor-pointer" onClick={() => toggleListSort('title')}>
{t('translations.titleLabel')} <SortIcon col="title" />
</th>
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">
{t('translations.sourceLanguage')}
</th>
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase cursor-pointer" onClick={() => toggleListSort('status')}>
{t('translations.status')} <SortIcon col="status" />
</th>
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('translations.brand')}</th>
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('translations.creator')}</th>
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase">{t('translations.languagesLabel')}</th>
<th className="px-4 py-3 text-start text-xs font-semibold text-text-secondary uppercase cursor-pointer" onClick={() => toggleListSort('updated_at')}>
{t('translations.updated')} <SortIcon col="updated_at" />
</th>
</tr>
</thead>
<tbody>
{sortedTranslations.map(tr => (
<tr
key={tr.Id}
onClick={() => setSelectedTranslation(tr)}
className="border-b border-border last:border-0 hover:bg-surface-secondary cursor-pointer transition-colors"
>
<td className="px-4 py-3" onClick={e => e.stopPropagation()}>
<input type="checkbox" checked={selectedIds.has(tr.Id)} onChange={() => toggleSelect(tr.Id)} className="rounded border-border" />
</td>
<td className="px-4 py-3 text-sm font-medium text-text-primary">{tr.title}</td>
<td className="px-4 py-3">
<span className="text-xs px-2 py-0.5 rounded-full bg-blue-50 text-blue-600 font-medium">
{getLangLabel(tr.source_language)}
</span>
</td>
<td className="px-4 py-3">
<span className={`text-xs px-2 py-0.5 rounded-full ${TRANSLATION_STATUS_COLORS[tr.status] || 'bg-surface-tertiary text-text-secondary'}`}>
{tr.status?.replace('_', ' ')}
</span>
</td>
<td className="px-4 py-3 text-sm text-text-secondary">{tr.brand_name || '—'}</td>
<td className="px-4 py-3 text-sm text-text-secondary">{tr.creator_name || '—'}</td>
<td className="px-4 py-3 text-sm text-text-secondary">{tr.translation_count || 0}</td>
<td className="px-4 py-3 text-sm text-text-tertiary">{formatDate(tr.UpdatedAt || tr.updated_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
)
)}
{/* Detail Panel */}
{selectedTranslation && (
<TranslationDetailPanel
translation={selectedTranslation}
onClose={() => setSelectedTranslation(null)}
onUpdate={loadTranslations}
onDelete={handleDelete}
assignableUsers={assignableUsers}
posts={posts}
/>
)}
{/* Create Modal */}
<Modal isOpen={showCreateModal} onClose={() => setShowCreateModal(false)} title={t('translations.createTranslation')} size="md">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('translations.titleLabel')} *</label>
<input
type="text"
value={newTranslation.title}
onChange={e => setNewTranslation(f => ({ ...f, title: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
placeholder={t('translations.titlePlaceholder')}
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('translations.sourceLanguage')} *</label>
<select
value={newTranslation.source_language}
onChange={e => setNewTranslation(f => ({ ...f, source_language: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
>
{AVAILABLE_LANGUAGES.map(l => <option key={l.code} value={l.code}>{l.label} ({l.code})</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('translations.sourceContent')} *</label>
<textarea
value={newTranslation.source_content}
onChange={e => setNewTranslation(f => ({ ...f, source_content: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 min-h-[120px] resize-y"
placeholder={t('translations.sourceContentPlaceholder')}
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('translations.brand')}</label>
<select
value={newTranslation.brand_id}
onChange={e => setNewTranslation(f => ({ ...f, brand_id: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
>
<option value=""></option>
{brands.map(b => <option key={b._id} value={b._id}>{b.name}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('translations.linkedPost')}</label>
{showCreatePost ? (
<div className="flex items-center gap-2">
<input
type="text"
value={newPostTitle}
onChange={e => setNewPostTitle(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleCreatePost()}
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
placeholder={t('translations.newPostTitle')}
autoFocus
/>
<button
onClick={handleCreatePost}
disabled={creatingPost || !newPostTitle.trim()}
className="px-3 py-2 bg-brand-primary text-white text-sm rounded-lg hover:bg-brand-primary-light disabled:opacity-50"
>
{creatingPost ? '...' : t('common.create')}
</button>
<button
onClick={() => setShowCreatePost(false)}
className="px-2 py-2 text-sm text-text-secondary hover:text-text-primary"
>
{t('common.cancel')}
</button>
</div>
) : (
<div className="flex items-center gap-2">
<select
value={newTranslation.post_id}
onChange={e => setNewTranslation(f => ({ ...f, post_id: e.target.value }))}
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
>
<option value=""></option>
{posts.map(p => <option key={p.Id || p.id || p._id} value={p.Id || p.id || p._id}>{p.title}</option>)}
</select>
<button
onClick={() => setShowCreatePost(true)}
className="flex items-center gap-1 px-3 py-2 text-sm text-brand-primary hover:text-brand-primary/80 font-medium whitespace-nowrap"
>
<Plus className="w-3.5 h-3.5" />
{t('translations.createPost')}
</button>
</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('translations.approvers')}</label>
<ApproverMultiSelect
selected={newTranslation.approver_ids}
onChange={ids => setNewTranslation(f => ({ ...f, approver_ids: ids }))}
users={assignableUsers}
/>
</div>
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
<button onClick={() => setShowCreateModal(false)} className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg">
{t('common.cancel')}
</button>
<button
onClick={handleCreate}
disabled={saving || !newTranslation.title || !newTranslation.source_content}
className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 shadow-sm"
>
{saving ? t('translations.creating') : t('common.create')}
</button>
</div>
</div>
</Modal>
{/* Bulk delete confirm */}
<Modal
isOpen={showBulkDeleteConfirm}
onClose={() => setShowBulkDeleteConfirm(false)}
title={t('translations.deleteTranslation')}
isConfirm
danger
onConfirm={handleBulkDelete}
confirmText={t('common.delete')}
>
{t('translations.bulkDeleteDesc', { count: selectedIds.size })}
</Modal>
</div>
)
}
-316
View File
@@ -1,316 +0,0 @@
import { useState, useEffect } from 'react'
import { Plus, Shield, Edit2, Trash2, UserCheck } from 'lucide-react'
import { api } from '../utils/api'
import Modal from '../components/Modal'
import { useAuth } from '../contexts/AuthContext'
import { SkeletonTable } from '../components/SkeletonLoader'
const ROLES = [
{ value: 'superadmin', label: 'Superadmin', color: 'bg-purple-100 text-purple-700', icon: '👑' },
{ value: 'manager', label: 'Manager', color: 'bg-blue-100 text-blue-700', icon: '📊' },
{ value: 'contributor', label: 'Contributor', color: 'bg-green-100 text-green-700', icon: '✏️' },
]
const EMPTY_FORM = {
name: '', email: '', password: '', role: 'contributor', avatar: '',
}
function RoleBadge({ role }) {
const roleInfo = ROLES.find(r => r.value === role) || ROLES[2]
return (
<span className={`inline-flex items-center gap-1 text-xs font-medium px-2.5 py-1 rounded-full ${roleInfo.color}`}>
<span>{roleInfo.icon}</span>
{roleInfo.label}
</span>
)
}
export default function Users() {
const { user: currentUser } = useAuth()
const [users, setUsers] = useState([])
const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false)
const [editingUser, setEditingUser] = useState(null)
const [form, setForm] = useState(EMPTY_FORM)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [userToDelete, setUserToDelete] = useState(null)
useEffect(() => { loadUsers() }, [])
const loadUsers = async () => {
try {
const res = await api.get('/users')
setUsers(res)
} catch (err) {
console.error('Failed to load users:', err)
} finally {
setLoading(false)
}
}
const handleSave = async () => {
try {
const data = {
name: form.name,
email: form.email,
role: form.role,
avatar: form.avatar || null,
}
if (form.password) data.password = form.password
if (editingUser) {
await api.patch(`/users/${editingUser.id}`, data)
} else {
if (!form.password) {
alert('Password is required for new users')
return
}
data.password = form.password
await api.post('/users', data)
}
setShowModal(false)
setEditingUser(null)
setForm(EMPTY_FORM)
loadUsers()
} catch (err) {
console.error('Save failed:', err)
alert('Failed to save user: ' + err.message)
}
}
const openEdit = (user) => {
setEditingUser(user)
setForm({
name: user.name || '',
email: user.email || '',
password: '',
role: user.role || 'contributor',
avatar: user.avatar || '',
})
setShowModal(true)
}
const openNew = () => {
setEditingUser(null)
setForm(EMPTY_FORM)
setShowModal(true)
}
const confirmDelete = async () => {
if (!userToDelete) return
try {
await api.delete(`/users/${userToDelete.id}`)
loadUsers()
setUserToDelete(null)
} catch (err) {
console.error('Delete failed:', err)
alert('Failed to delete user')
}
}
if (loading) {
return (
<div className="space-y-6">
<div className="h-12 bg-surface-tertiary rounded-xl animate-pulse"></div>
<SkeletonTable rows={5} cols={5} />
</div>
)
}
return (
<div className="space-y-6 animate-fade-in">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-text-primary flex items-center gap-3">
<Shield className="w-7 h-7 text-purple-600" />
User Management
</h1>
<p className="text-sm text-text-tertiary mt-1">{users.length} user{users.length !== 1 ? 's' : ''}</p>
</div>
<button
onClick={openNew}
className="flex items-center gap-2 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm"
>
<Plus className="w-4 h-4" />
Add User
</button>
</div>
{/* Users List */}
<div className="bg-white rounded-xl border border-border overflow-hidden">
<table className="w-full">
<thead>
<tr className="border-b border-border bg-surface-secondary">
<th className="text-left px-5 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">User</th>
<th className="text-left px-5 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Email</th>
<th className="text-left px-5 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Role</th>
<th className="text-left px-5 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Created</th>
<th className="text-right px-5 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider w-24">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-border-light">
{users.length === 0 ? (
<tr>
<td colSpan={5} className="py-12 text-center text-sm text-text-tertiary">
No users found
</td>
</tr>
) : (
users.map(user => {
const isCurrentUser = currentUser?.id === user.id
const roleInfo = ROLES.find(r => r.value === user.role) || ROLES[2]
return (
<tr key={user.id} className="hover:bg-surface-secondary group">
<td className="px-5 py-4">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-full bg-gradient-to-br ${user.role === 'superadmin' ? 'from-purple-500 to-pink-500' : 'from-blue-500 to-indigo-500'} flex items-center justify-center text-white font-bold text-sm`}>
{user.name?.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase()}
</div>
<div>
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-text-primary">{user.name}</p>
{isCurrentUser && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-100 text-emerald-700 font-medium">
You
</span>
)}
</div>
</div>
</div>
</td>
<td className="px-5 py-4 text-sm text-text-secondary">{user.email}</td>
<td className="px-5 py-4">
<RoleBadge role={user.role} />
</td>
<td className="px-5 py-4 text-sm text-text-tertiary">
{user.createdAt ? new Date(user.createdAt).toLocaleDateString() : '—'}
</td>
<td className="px-5 py-4">
<div className="flex items-center justify-end gap-1">
<button
onClick={() => openEdit(user)}
className="p-1.5 hover:bg-surface-tertiary rounded-lg text-text-tertiary hover:text-text-primary"
title="Edit user"
>
<Edit2 className="w-4 h-4" />
</button>
{!isCurrentUser && (
<button
onClick={() => { setUserToDelete(user); setShowDeleteConfirm(true) }}
className="p-1.5 hover:bg-red-50 rounded-lg text-text-tertiary hover:text-red-500"
title="Delete user"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
</td>
</tr>
)
})
)}
</tbody>
</table>
</div>
{/* Add/Edit User Modal */}
<Modal
isOpen={showModal}
onClose={() => { setShowModal(false); setEditingUser(null) }}
title={editingUser ? 'Edit User' : 'Add New User'}
size="md"
>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Name *</label>
<input
type="text"
value={form.name}
onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
placeholder="Full name"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Email *</label>
<input
type="email"
value={form.email}
onChange={e => setForm(f => ({ ...f, email: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
placeholder="user@company.com"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">
Password {editingUser && '(leave blank to keep current)'}
</label>
<input
type="password"
value={form.password}
onChange={e => setForm(f => ({ ...f, password: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
placeholder="••••••••"
required={!editingUser}
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Role *</label>
<div className="grid grid-cols-3 gap-2">
{ROLES.map(r => (
<button
key={r.value}
type="button"
onClick={() => setForm(f => ({ ...f, role: r.value }))}
className={`p-3 rounded-lg border-2 text-center transition-all ${
form.role === r.value
? 'border-brand-primary bg-brand-primary/5'
: 'border-border hover:border-brand-primary/30'
}`}
>
<div className="text-2xl mb-1">{r.icon}</div>
<div className="text-xs font-medium text-text-primary">{r.label}</div>
</button>
))}
</div>
</div>
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
<button
onClick={() => { setShowModal(false); setEditingUser(null) }}
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
>
Cancel
</button>
<button
onClick={handleSave}
disabled={!form.name || !form.email || (!editingUser && !form.password)}
className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
>
{editingUser ? 'Save Changes' : 'Add User'}
</button>
</div>
</div>
</Modal>
{/* Delete Confirmation */}
<Modal
isOpen={showDeleteConfirm}
onClose={() => { setShowDeleteConfirm(false); setUserToDelete(null) }}
title="Delete User?"
isConfirm
danger
confirmText="Delete User"
onConfirm={confirmDelete}
>
Are you sure you want to delete <strong>{userToDelete?.name}</strong>? This action cannot be undone.
</Modal>
</div>
)
}
+36 -8
View File
@@ -37,8 +37,10 @@ const normalize = (data) => {
const handleResponse = async (r, label) => {
if (!r.ok) {
if (r.status === 401) {
// Unauthorized (not logged in) - redirect to login if not already there
if (!window.location.pathname.includes('/login')) {
// Unauthorized redirect to login unless on a public page
const p = window.location.pathname;
const isPublic = p.startsWith('/review/') || p.startsWith('/review-post/') || p.startsWith('/submit-issue') || p.startsWith('/track/');
if (!p.includes('/login') && !isPublic) {
window.location.href = '/login';
}
}
@@ -77,11 +79,32 @@ export const api = {
credentials: 'include',
}).then(r => handleResponse(r, `DELETE ${path}`)),
upload: (path, formData) => fetch(`${API}${path}`, {
upload: (path, formData, opts = {}) => {
if (opts.onUploadProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open('POST', `${API}${path}`)
xhr.withCredentials = true
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) opts.onUploadProgress({ loaded: e.loaded, total: e.total })
}
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
try { resolve(normalize(JSON.parse(xhr.responseText))) } catch { resolve(xhr.responseText) }
} else {
reject(new Error(`Upload failed: ${xhr.status}`))
}
}
xhr.onerror = () => reject(new Error('Upload failed'))
xhr.send(formData)
})
}
return fetch(`${API}${path}`, {
method: 'POST',
credentials: 'include',
body: formData,
}).then(r => handleResponse(r, `UPLOAD ${path}`)),
}).then(r => handleResponse(r, `UPLOAD ${path}`))
},
};
// Brand color palette — dynamically assigned from a rotating palette
@@ -139,16 +162,21 @@ export const STATUS_CONFIG = {
completed: { label: 'Completed', bg: 'bg-blue-50', text: 'text-blue-700', dot: 'bg-blue-400' },
cancelled: { label: 'Cancelled', bg: 'bg-red-50', text: 'text-red-700', dot: 'bg-red-400' },
planning: { label: 'Planning', bg: 'bg-gray-100', text: 'text-gray-600', dot: 'bg-gray-400' },
// Issue-specific statuses
new: { label: 'New', bg: 'bg-purple-100', text: 'text-purple-700', dot: 'bg-purple-500' },
acknowledged: { label: 'Acknowledged', bg: 'bg-blue-100', text: 'text-blue-700', dot: 'bg-blue-500' },
resolved: { label: 'Resolved', bg: 'bg-emerald-100', text: 'text-emerald-700', dot: 'bg-emerald-500' },
declined: { label: 'Declined', bg: 'bg-surface-tertiary', text: 'text-text-secondary', dot: 'bg-text-tertiary' },
};
export const getStatusConfig = (status) => STATUS_CONFIG[status] || STATUS_CONFIG['draft'];
// Priority config
export const PRIORITY_CONFIG = {
low: { label: 'Low', color: 'bg-gray-400' },
medium: { label: 'Medium', color: 'bg-amber-400' },
high: { label: 'High', color: 'bg-orange-500' },
urgent: { label: 'Urgent', color: 'bg-red-500' },
low: { label: 'Low', color: 'bg-gray-400', bg: 'bg-surface-tertiary', text: 'text-text-secondary', dot: 'bg-text-tertiary' },
medium: { label: 'Medium', color: 'bg-amber-400', bg: 'bg-blue-100', text: 'text-blue-700', dot: 'bg-blue-500' },
high: { label: 'High', color: 'bg-orange-500', bg: 'bg-orange-100', text: 'text-orange-700', dot: 'bg-orange-500' },
urgent: { label: 'Urgent', color: 'bg-red-500', bg: 'bg-red-100', text: 'text-red-700', dot: 'bg-red-500' },
};
// Shared helper: extract initials from a name string
+39
View File
@@ -0,0 +1,39 @@
export const PLATFORM_FORMATS = {
instagram: [
{ key: 'ig_feed', label: 'Feed (1:1)', ratio: '1:1' },
{ key: 'ig_story', label: 'Story (9:16)', ratio: '9:16' },
{ key: 'ig_reel', label: 'Reel (9:16)', ratio: '9:16' },
],
tiktok: [
{ key: 'tt_video', label: 'TikTok (9:16)', ratio: '9:16' },
],
youtube: [
{ key: 'yt_video', label: 'YouTube (16:9)', ratio: '16:9' },
{ key: 'yt_short', label: 'Short (9:16)', ratio: '9:16' },
{ key: 'yt_thumb', label: 'Thumbnail (16:9)', ratio: '16:9' },
],
facebook: [
{ key: 'fb_post', label: 'Post (1:1)', ratio: '1:1' },
{ key: 'fb_story', label: 'Story (9:16)', ratio: '9:16' },
],
twitter: [
{ key: 'tw_post', label: 'Post (16:9)', ratio: '16:9' },
],
linkedin: [
{ key: 'li_post', label: 'Post (1:1)', ratio: '1:1' },
],
snapchat: [
{ key: 'sc_snap', label: 'Snap (9:16)', ratio: '9:16' },
],
}
export function getFormatsForPlatforms(platforms = []) {
const formats = []
const seen = new Set()
for (const p of platforms) {
for (const f of (PLATFORM_FORMATS[p] || [])) {
if (!seen.has(f.key)) { seen.add(f.key); formats.push(f) }
}
}
return formats
}
+30
View File
@@ -0,0 +1,30 @@
export const AVAILABLE_LANGUAGES = [
{ code: 'AR', label: 'العربية' },
{ code: 'EN', label: 'English' },
{ code: 'FR', label: 'Français' },
{ code: 'ID', label: 'Bahasa Indonesia' },
]
export const TRANSLATION_STATUS_COLORS = {
draft: 'bg-surface-tertiary text-text-secondary',
pending_review: 'bg-amber-100 text-amber-700',
approved: 'bg-emerald-100 text-emerald-700',
rejected: 'bg-red-100 text-red-700',
revision_requested: 'bg-orange-100 text-orange-700',
}
export function isTextSelected(text) {
return text.is_selected === true || text.is_selected === 1
}
export function groupTextsByLanguage(texts) {
const grouped = {}
for (const text of texts) {
if (!grouped[text.language_code]) grouped[text.language_code] = []
grouped[text.language_code].push(text)
}
for (const code in grouped) {
grouped[code].sort((a, b) => (a.option_number || 1) - (b.option_number || 1))
}
return grouped
}
File diff suppressed because it is too large Load Diff

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