diff --git a/.gitignore b/.gitignore
index c9a9d4d..b46ab24 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,5 @@ dist/
*.db-shm
*.db-wal
.vite/
+.env
+.env.*
diff --git a/client/package-lock.json b/client/package-lock.json
index b398ee0..53e6f9c 100644
--- a/client/package-lock.json
+++ b/client/package-lock.json
@@ -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",
diff --git a/client/package.json b/client/package.json
index ce3999a..8e65b78 100644
--- a/client/package.json
+++ b/client/package.json
@@ -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",
diff --git a/client/src/App.jsx b/client/src/App.jsx
index e226545..f011b00 100644
--- a/client/src/App.jsx
+++ b/client/src/App.jsx
@@ -1,35 +1,38 @@
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 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'
+// Lazy-loaded page components
+const Dashboard = lazy(() => import('./pages/Dashboard'))
+const PostProduction = lazy(() => import('./pages/PostProduction'))
+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'))
+const Users = lazy(() => import('./pages/Users'))
+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 Issues = lazy(() => import('./pages/Issues'))
+const PublicIssueSubmit = lazy(() => import('./pages/PublicIssueSubmit'))
+const PublicIssueTracker = lazy(() => import('./pages/PublicIssueTracker'))
+
const TEAM_ROLES = [
{ value: 'manager', label: 'Manager' },
{ value: 'approver', label: 'Approver' },
@@ -87,7 +90,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,7 +102,7 @@ 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)
}
@@ -109,7 +112,7 @@ function AppContent() {
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(),
])
setBrands(brandsData)
@@ -270,40 +273,44 @@ function AppContent() {
{/* Tutorial overlay */}
{showTutorial && }
-
- : } />
- } />
- } />
- } />
- : }>
- } />
- {hasModule('marketing') && <>
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- >}
- {hasModule('finance') && (user?.role === 'superadmin' || user?.role === 'manager') && <>
- } />
- } />
- >}
- {hasModule('projects') && <>
- } />
- } />
- } />
- >}
- {hasModule('issues') && } />}
- } />
- } />
- {user?.role === 'superadmin' && (
- } />
- )}
-
- } />
-
+
+ Loading...
}>
+
+ : } />
+ } />
+ } />
+ } />
+ : }>
+ } />
+ {hasModule('marketing') && <>
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ >}
+ {hasModule('finance') && (user?.role === 'superadmin' || user?.role === 'manager') && <>
+ } />
+ } />
+ >}
+ {hasModule('projects') && <>
+ } />
+ } />
+ } />
+ >}
+ {hasModule('issues') && } />}
+ } />
+ } />
+ {user?.role === 'superadmin' && (
+ } />
+ )}
+
+ } />
+
+
+
)
}
diff --git a/client/src/components/ApproverMultiSelect.jsx b/client/src/components/ApproverMultiSelect.jsx
new file mode 100644
index 0000000..1a4206c
--- /dev/null
+++ b/client/src/components/ApproverMultiSelect.jsx
@@ -0,0 +1,86 @@
+import { useState, useRef, useEffect } from 'react'
+import { Check, ChevronDown, X } from 'lucide-react'
+
+export default function ApproverMultiSelect({ users = [], selected = [], onChange }) {
+ const [open, setOpen] = useState(false)
+ const wrapperRef = useRef(null)
+
+ // Close dropdown when clicking outside
+ useEffect(() => {
+ if (!open) return
+ const handleClick = (e) => {
+ if (wrapperRef.current && !wrapperRef.current.contains(e.target)) {
+ setOpen(false)
+ }
+ }
+ document.addEventListener('mousedown', handleClick)
+ return () => document.removeEventListener('mousedown', handleClick)
+ }, [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 (
+
+ {checkboxSlot && | e.stopPropagation()}>{checkboxSlot} | }
diff --git a/client/src/components/PostDetailPanel.jsx b/client/src/components/PostDetailPanel.jsx
index 2ec508d..5abc15a 100644
--- a/client/src/components/PostDetailPanel.jsx
+++ b/client/src/components/PostDetailPanel.jsx
@@ -129,7 +129,7 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
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([])
}
@@ -163,7 +163,7 @@ 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 || []))
+ setAvailableAssets(Array.isArray(data) ? data : [])
} catch {
setAvailableAssets([])
}
diff --git a/client/src/components/TaskCalendarView.jsx b/client/src/components/TaskCalendarView.jsx
index 8c3cf1f..12c89b5 100644
--- a/client/src/components/TaskCalendarView.jsx
+++ b/client/src/components/TaskCalendarView.jsx
@@ -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,9 +78,22 @@ 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'
@@ -81,17 +110,37 @@ export default function TaskCalendarView({ tasks, onTaskClick }) {
{/* Nav */}
-
-
- {t('tasks.today')}
-
+
+
+ setCalView('month')}
+ className={`px-2.5 py-1 text-[11px] font-medium rounded-md transition-colors flex items-center gap-1 ${calView === 'month' ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary'}`}
+ >
+
+ Month
+
+ setCalView('week')}
+ className={`px-2.5 py-1 text-[11px] font-medium rounded-md transition-colors flex items-center gap-1 ${calView === 'week' ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary'}`}
+ >
+
+ Week
+
+
+
+ {t('tasks.today')}
+
+
{/* Day headers */}
@@ -112,7 +161,7 @@ export default function TaskCalendarView({ tasks, onTaskClick }) {
return (
@@ -122,7 +171,7 @@ export default function TaskCalendarView({ tasks, onTaskClick }) {
{cell.day}
- {dayTasks.slice(0, 3).map(task => (
+ {dayTasks.slice(0, calView === 'week' ? 10 : 3).map(task => (
onTaskClick(task)}
@@ -134,9 +183,9 @@ export default function TaskCalendarView({ tasks, onTaskClick }) {
{task.title}
))}
- {dayTasks.length > 3 && (
+ {dayTasks.length > (calView === 'week' ? 10 : 3) && (
- +{dayTasks.length - 3} more
+ +{dayTasks.length - (calView === 'week' ? 10 : 3)} more
)}
diff --git a/client/src/components/TaskDetailPanel.jsx b/client/src/components/TaskDetailPanel.jsx
index c2f6ca5..4705556 100644
--- a/client/src/components/TaskDetailPanel.jsx
+++ b/client/src/components/TaskDetailPanel.jsx
@@ -120,7 +120,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([])
}
diff --git a/client/src/i18n/ar.json b/client/src/i18n/ar.json
index 033ce50..d2fc58e 100644
--- a/client/src/i18n/ar.json
+++ b/client/src/i18n/ar.json
@@ -486,5 +486,162 @@
"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.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": "مدعوم بواسطة Samaya Digital Hub",
+ "review.loadFailed": "فشل في تحميل المحتوى",
+ "review.actionFailed": "فشل الإجراء",
+ "review.actionCompleted": "تم الإجراء بنجاح",
+ "review.enterName": "يرجى اختيار أو إدخال اسمك",
+ "review.confirmApprove": "هل تريد الموافقة على هذا المحتوى؟",
+ "review.confirmReject": "هل تريد رفض هذا المحتوى؟",
+ "review.feedbackRequired": "يرجى تقديم ملاحظات لطلب التعديل",
+ "review.contentLanguages": "لغات المحتوى",
+ "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.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": "نسخ رابط المشاكل العام"
}
\ No newline at end of file
diff --git a/client/src/i18n/en.json b/client/src/i18n/en.json
index 786ba5d..33d9ffb 100644
--- a/client/src/i18n/en.json
+++ b/client/src/i18n/en.json
@@ -486,5 +486,162 @@
"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.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 Samaya Digital Hub",
+ "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.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.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"
}
\ No newline at end of file
diff --git a/client/src/pages/Artefacts.jsx b/client/src/pages/Artefacts.jsx
index 5acd663..9bb43d9 100644
--- a/client/src/pages/Artefacts.jsx
+++ b/client/src/pages/Artefacts.jsx
@@ -5,10 +5,13 @@ 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 SlidePanel from '../components/SlidePanel'
import { useToast } from '../components/ToastContainer'
import ArtefactVersionTimeline from '../components/ArtefactVersionTimeline'
import { SkeletonCard, SkeletonTable } from '../components/SkeletonLoader'
+import ArtefactDetailPanel from '../components/ArtefactDetailPanel'
+import ApproverMultiSelect from '../components/ApproverMultiSelect'
const STATUS_COLORS = {
draft: 'bg-surface-tertiary text-text-secondary',
@@ -56,8 +59,12 @@ export default function Artefacts() {
const [newArtefact, setNewArtefact] = useState({ title: '', description: '', type: 'copy', brand_id: '', content: '', project_id: '', campaign_id: '', approver_ids: [] })
const [saving, setSaving] = useState(false)
+ // Bulk select
+ const [selectedIds, setSelectedIds] = useState(new Set())
+ const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false)
+
// View + sort
- const [viewMode, setViewMode] = useState('grid')
+ const [viewMode, setViewMode] = useState('list')
const [sortOption, setSortOption] = useState(0) // index into SORT_OPTIONS
const [listSortBy, setListSortBy] = useState('updated_at')
const [listSortDir, setListSortDir] = useState('desc')
@@ -69,18 +76,18 @@ export default function Artefacts() {
useEffect(() => {
loadArtefacts()
- api.get('/projects').then(res => setProjects(res.data || res || [])).catch(() => {})
- api.get('/campaigns').then(res => setCampaigns(res.data || res || [])).catch(() => {})
- api.get('/users/assignable').then(res => setAssignableUsers(res.data || res || [])).catch(() => {})
+ api.get('/projects').then(res => setProjects(Array.isArray(res) ? res : [])).catch(() => {})
+ api.get('/campaigns').then(res => setCampaigns(Array.isArray(res) ? res : [])).catch(() => {})
+ api.get('/users/assignable').then(res => setAssignableUsers(Array.isArray(res) ? res : [])).catch(() => {})
}, [])
const loadArtefacts = async () => {
try {
const res = await api.get('/artefacts')
- setArtefacts(res.data || res || [])
+ setArtefacts(Array.isArray(res) ? res : [])
} catch (err) {
console.error('Failed to load artefacts:', err)
- toast.error('Failed to load artefacts')
+ toast.error(t('artefacts.loadFailed'))
} finally {
setLoading(false)
}
@@ -88,7 +95,7 @@ export default function Artefacts() {
const handleCreate = async () => {
if (!newArtefact.title) {
- toast.error('Title is required')
+ toast.error(t('artefacts.titleRequired'))
return
}
setSaving(true)
@@ -97,14 +104,14 @@ export default function Artefacts() {
...newArtefact,
approver_ids: newArtefact.approver_ids.length > 0 ? newArtefact.approver_ids.join(',') : null,
})
- toast.success('Artefact created')
+ toast.success(t('artefacts.created'))
setShowCreateModal(false)
setNewArtefact({ title: '', description: '', type: 'copy', brand_id: '', content: '', project_id: '', campaign_id: '', approver_ids: [] })
loadArtefacts()
- setSelectedArtefact(created.data || created)
+ setSelectedArtefact(created)
} catch (err) {
console.error('Create failed:', err)
- toast.error('Failed to create artefact')
+ toast.error(t('artefacts.createFailed'))
} finally {
setSaving(false)
}
@@ -113,15 +120,42 @@ export default function Artefacts() {
const handleDelete = async (artefactId) => {
try {
await api.delete(`/artefacts/${artefactId}`)
- toast.success('Artefact deleted')
+ toast.success(t('artefacts.deleted'))
setSelectedArtefact(null)
loadArtefacts()
} catch (err) {
console.error('Delete failed:', err)
- toast.error('Failed to delete artefact')
+ toast.error(t('artefacts.deleteFailed'))
}
}
+ const handleBulkDelete = async () => {
+ try {
+ await api.post('/artefacts/bulk-delete', { ids: [...selectedIds] })
+ toast.success(t('artefacts.deleted'))
+ setSelectedIds(new Set())
+ setShowBulkDeleteConfirm(false)
+ loadArtefacts()
+ } catch (err) {
+ console.error('Bulk delete failed:', err)
+ toast.error(t('artefacts.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 === sortedArtefacts.length) setSelectedIds(new Set())
+ else setSelectedIds(new Set(sortedArtefacts.map(a => a.Id)))
+ }
+
// Filter
const filteredArtefacts = useMemo(() => {
return artefacts.filter(a => {
@@ -179,8 +213,8 @@ export default function Artefacts() {
{/* Header */}
- Artefacts
- Content approval workflow with versioning
+ {t('artefacts.title')}
+ {t('artefacts.subtitle')}
{/* View switcher */}
@@ -209,7 +243,7 @@ export default function Artefacts() {
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"
>
- New Artefact
+ {t('artefacts.newArtefact')}
@@ -220,7 +254,7 @@ export default function Artefacts() {
setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface transition-colors"
@@ -232,7 +266,7 @@ export default function Artefacts() {
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"
>
-
+
{brands.map(b => )}
@@ -241,12 +275,12 @@ export default function Artefacts() {
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"
>
-
-
-
-
-
-
+
+
+
+
+
+
@@ -307,7 +341,7 @@ export default function Artefacts() {
) : sortedArtefacts.length === 0 ? (
- No artefacts found
+ {t('artefacts.noArtefacts')}
) : (
@@ -368,31 +402,39 @@ export default function Artefacts() {
) : sortedArtefacts.length === 0 ? (
- No artefacts found
+ {t('artefacts.noArtefacts')}
) : (
+ {selectedIds.size > 0 && (
+
+ setShowBulkDeleteConfirm(true)} onClear={() => setSelectedIds(new Set())} />
+
+ )}
+ | e.stopPropagation()}>
+ 0} onChange={toggleSelectAll} className="rounded border-border" />
+ |
toggleListSort('title')}>
- Title
+ {t('artefacts.titleLabel')}
|
toggleListSort('type')}>
- Type
+ {t('artefacts.type')}
|
toggleListSort('status')}>
- Status
+ {t('artefacts.status')}
|
- Brand |
+ {t('artefacts.brand')} |
{t('artefacts.project')} |
{t('artefacts.campaign')} |
- Creator |
- Approvers |
- Version |
+ {t('artefacts.creator')} |
+ {t('artefacts.approvers')} |
+ {t('artefacts.version')} |
toggleListSort('updated_at')}>
- Updated
+ {t('artefacts.updated')}
|
@@ -405,6 +447,9 @@ export default function Artefacts() {
onClick={() => setSelectedArtefact(artefact)}
className="hover:bg-surface-secondary cursor-pointer transition-colors"
>
+ e.stopPropagation()}>
+ toggleSelect(artefact.Id)} className="rounded border-border" />
+ |
{artefact.title} |
@@ -439,20 +484,20 @@ export default function Artefacts() {
)}
{/* Create Modal */}
- setShowCreateModal(false)} title="Create Artefact" size="md">
+ setShowCreateModal(false)} title={t('artefacts.createArtefact')} size="md">
-
+
setNewArtefact(f => ({ ...f, title: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
- placeholder="Artefact title"
+ placeholder={t('artefacts.titlePlaceholder')}
/>
-
+
-
+
-
+
-
+
@@ -517,19 +562,32 @@ export default function Artefacts() {
onClick={() => setShowCreateModal(false)}
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
>
- Cancel
+ {t('common.cancel')}
- {saving ? 'Creating...' : 'Create'}
+ {saving ? t('artefacts.creating') : t('common.create')}
+ {/* Bulk Delete Confirmation */}
+ setShowBulkDeleteConfirm(false)}
+ title={t('common.bulkDeleteConfirm').replace('{count}', selectedIds.size)}
+ isConfirm
+ danger
+ confirmText={t('common.deleteSelected')}
+ onConfirm={handleBulkDelete}
+ >
+ {t('common.bulkDeleteDesc')}
+
+
{/* Detail Panel */}
{selectedArtefact && (
)
}
-
-function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDelete, projects = [], campaigns = [], 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 [reviewUrl, setReviewUrl] = useState('')
- const [copied, setCopied] = useState(false)
-
- // Editable fields
- const [editTitle, setEditTitle] = useState(artefact.title || '')
- const [editDescription, setEditDescription] = useState(artefact.description || '')
- const [editProjectId, setEditProjectId] = useState(artefact.project_id || '')
- const [editCampaignId, setEditCampaignId] = useState(artefact.campaign_id || '')
- const [editApproverIds, setEditApproverIds] = useState(
- artefact.approvers?.map(a => String(a.id)) || (artefact.approver_ids ? artefact.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : [])
- )
- const [savingDraft, setSavingDraft] = useState(false)
- const [deleting, setDeleting] = useState(false)
-
- // Language management (for copy type)
- const [showLanguageModal, setShowLanguageModal] = useState(false)
- const [languageForm, setLanguageForm] = useState({ language_code: '', language_label: '', content: '' })
- const [savingLanguage, setSavingLanguage] = useState(false)
-
- // New version modal
- const [showNewVersionModal, setShowNewVersionModal] = useState(false)
- const [newVersionNotes, setNewVersionNotes] = useState('')
- const [copyFromPrevious, setCopyFromPrevious] = useState(true)
- const [creatingVersion, setCreatingVersion] = useState(false)
-
- // File upload (for design/video)
- const [uploading, setUploading] = useState(false)
-
- // Video modal (for video type with Drive link)
- const [showVideoModal, setShowVideoModal] = useState(false)
- const [videoMode, setVideoMode] = useState('upload') // 'upload' or 'drive'
- const [driveUrl, setDriveUrl] = useState('')
-
- // Comments
- const [comments, setComments] = useState([])
- const [newComment, setNewComment] = useState('')
- const [addingComment, setAddingComment] = useState(false)
-
- useEffect(() => {
- loadVersions()
- }, [artefact.Id])
-
- useEffect(() => {
- setEditTitle(artefact.title || '')
- setEditDescription(artefact.description || '')
- setEditProjectId(artefact.project_id || '')
- setEditCampaignId(artefact.campaign_id || '')
- setEditApproverIds(
- artefact.approvers?.map(a => String(a.id)) || (artefact.approver_ids ? artefact.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : [])
- )
- }, [artefact.Id])
-
- const loadVersions = async () => {
- try {
- const res = await api.get(`/artefacts/${artefact.Id}/versions`)
- const versionsList = res.data || 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('Failed to load versions')
- } 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('Failed to load version data')
- }
- }
-
- const handleSelectVersion = (version) => {
- setSelectedVersion(version)
- loadVersionData(version.Id)
- }
-
- const handleCreateVersion = async () => {
- setCreatingVersion(true)
- try {
- await api.post(`/artefacts/${artefact.Id}/versions`, {
- notes: newVersionNotes || `Version ${(versions[versions.length - 1]?.version_number || 0) + 1}`,
- copy_from_previous: artefact.type === 'copy' ? copyFromPrevious : false,
- })
-
- toast.success('New version created')
- setShowNewVersionModal(false)
- setNewVersionNotes('')
- setCopyFromPrevious(true)
- loadVersions()
- onUpdate()
- } catch (err) {
- console.error('Create version failed:', err)
- toast.error('Failed to create version')
- } finally {
- setCreatingVersion(false)
- }
- }
-
- const handleAddLanguage = async () => {
- if (!languageForm.language_code || !languageForm.language_label || !languageForm.content) {
- toast.error('All fields are required')
- return
- }
-
- setSavingLanguage(true)
- try {
- await api.post(`/artefacts/${artefact.Id}/versions/${selectedVersion.Id}/texts`, languageForm)
- toast.success('Language added')
- setShowLanguageModal(false)
- setLanguageForm({ language_code: '', language_label: '', content: '' })
- loadVersionData(selectedVersion.Id)
- } catch (err) {
- console.error('Add language failed:', err)
- toast.error('Failed to add language')
- } finally {
- setSavingLanguage(false)
- }
- }
-
- const handleDeleteLanguage = async (textId) => {
- if (!confirm('Delete this language?')) return
-
- try {
- await api.delete(`/artefact-version-texts/${textId}`)
- toast.success('Language deleted')
- loadVersionData(selectedVersion.Id)
- } catch (err) {
- toast.error('Failed to delete language')
- }
- }
-
- const handleFileUpload = async (e) => {
- const file = e.target.files?.[0]
- if (!file) return
-
- setUploading(true)
- try {
- const formData = new FormData()
- formData.append('file', file)
- await api.upload(`/artefacts/${artefact.Id}/versions/${selectedVersion.Id}/attachments`, formData)
- toast.success('File uploaded')
- loadVersionData(selectedVersion.Id)
- } catch (err) {
- console.error('Upload failed:', err)
- toast.error('Upload failed')
- } finally {
- setUploading(false)
- }
- }
-
- const handleAddDriveVideo = async () => {
- if (!driveUrl.trim()) {
- toast.error('Please enter a Google Drive URL')
- return
- }
-
- setUploading(true)
- try {
- await api.post(`/artefacts/${artefact.Id}/versions/${selectedVersion.Id}/attachments`, {
- drive_url: driveUrl,
- })
- toast.success('Video link added')
- setShowVideoModal(false)
- setDriveUrl('')
- loadVersionData(selectedVersion.Id)
- } catch (err) {
- console.error('Add Drive link failed:', err)
- toast.error('Failed to add video link')
- } finally {
- setUploading(false)
- }
- }
-
- const handleDeleteAttachment = async (attId) => {
- if (!confirm('Delete this attachment?')) return
-
- try {
- await api.delete(`/artefact-attachments/${attId}`)
- toast.success('Attachment deleted')
- loadVersionData(selectedVersion.Id)
- } catch (err) {
- toast.error('Failed to delete attachment')
- }
- }
-
- const handleSubmitReview = async () => {
- setSubmitting(true)
- try {
- const res = await api.post(`/artefacts/${artefact.Id}/submit-review`)
- setReviewUrl(res.reviewUrl || res.data?.reviewUrl || '')
- toast.success('Submitted for review!')
- onUpdate()
- } catch (err) {
- toast.error('Failed to submit for review')
- } finally {
- setSubmitting(false)
- }
- }
-
- const copyReviewLink = () => {
- navigator.clipboard.writeText(reviewUrl)
- setCopied(true)
- toast.success('Link copied to clipboard')
- 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('Comment added')
- setNewComment('')
- loadVersionData(selectedVersion.Id)
- } catch (err) {
- toast.error('Failed to add comment')
- } finally {
- setAddingComment(false)
- }
- }
-
- const handleUpdateField = async (field, value) => {
- try {
- await api.patch(`/artefacts/${artefact.Id}`, { [field]: value || null })
- toast.success('Updated')
- onUpdate()
- } catch (err) {
- toast.error('Failed to update')
- }
- }
-
- const handleSaveDraft = async () => {
- if (!editTitle.trim()) {
- toast.error('Title is required')
- return
- }
- setSavingDraft(true)
- try {
- await api.patch(`/artefacts/${artefact.Id}`, {
- title: editTitle.trim(),
- description: editDescription.trim() || null,
- })
- toast.success('Draft saved')
- onUpdate()
- } catch (err) {
- toast.error('Failed to save draft')
- } finally {
- setSavingDraft(false)
- }
- }
-
- const handleDeleteArtefact = async () => {
- if (!confirm('Are you sure you want to delete this artefact? This cannot be undone.')) return
- setDeleting(true)
- try {
- await onDelete(artefact.Id || artefact.id || artefact._id)
- } catch (err) {
- toast.error('Failed to delete')
- 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
- }
-
- if (loading) {
- return (
-
-
-
- )
- }
-
- const TypeIcon = TYPE_ICONS[artefact.type] || Sparkles
-
- return (
-
-
-
-
-
-
- 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"
- />
-
-
- {artefact.status?.replace('_', ' ')}
-
- {artefact.type}
-
-
-
-
-
- {savingDraft ? 'Saving...' : 'Save'}
-
- {onDelete && (
-
-
-
- )}
-
-
-
- }>
-
- {/* Description */}
-
- Description
-
-
- {/* Project & Campaign dropdowns */}
-
-
- {t('artefacts.project')}
-
-
-
- {t('artefacts.campaign')}
-
-
-
-
- {/* Approvers */}
-
- Approvers
- {
- setEditApproverIds(ids)
- handleUpdateField('approver_ids', ids.length > 0 ? ids.join(',') : '')
- }}
- />
-
-
- {/* Version Timeline */}
-
-
- Versions
- setShowNewVersionModal(true)}
- className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors"
- >
-
- New Version
-
-
-
-
-
- {/* Type-specific content */}
- {versionData && selectedVersion && (
-
- {/* COPY TYPE: Language entries */}
- {artefact.type === 'copy' && (
-
-
- Languages
- 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"
- >
-
- Add Language
-
-
-
- {versionData.texts && versionData.texts.length > 0 ? (
-
- {versionData.texts.map(text => (
-
-
-
-
- {text.language_code}
-
- {text.language_label}
-
- handleDeleteLanguage(text.Id)}
- className="text-red-600 hover:text-red-700"
- >
-
-
-
-
- {text.content}
-
-
- ))}
-
- ) : (
-
-
- No languages added yet
-
- )}
-
- )}
-
- {/* DESIGN TYPE: Image gallery */}
- {artefact.type === 'design' && (
-
-
- Images
-
-
-
- {versionData.attachments && versionData.attachments.length > 0 ? (
-
- {versionData.attachments.map(att => (
-
- 
-
- handleDeleteAttachment(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"
- >
-
-
-
-
- {att.original_name}
-
-
- ))}
-
- ) : (
-
-
- No images uploaded yet
-
- )}
-
- )}
-
- {/* VIDEO TYPE: Files and Drive links */}
- {artefact.type === 'video' && (
-
-
- Videos
- setShowVideoModal(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"
- >
-
- Add Video
-
-
-
- {versionData.attachments && versionData.attachments.length > 0 ? (
-
- {versionData.attachments.map(att => (
-
- {att.drive_url ? (
-
-
- Google Drive Video
- handleDeleteAttachment(att.Id)}
- className="text-red-600 hover:text-red-700"
- >
-
-
-
-
-
- ) : (
-
-
- {att.original_name}
- handleDeleteAttachment(att.Id)}
- className="text-red-600 hover:text-red-700"
- >
-
-
-
-
-
- )}
-
- ))}
-
- ) : (
-
-
- No videos added yet
-
- )}
-
- )}
-
- )}
-
- {/* Comments */}
- {selectedVersion && (
-
-
- Comments ({comments.length})
-
-
-
- {comments.map(comment => (
-
-
- {comment.user_avatar ? (
- 
- ) : (
-
- )}
-
-
-
- {comment.user_name || 'Anonymous'}
-
- {new Date(comment.CreatedAt).toLocaleString()}
-
-
- {comment.content}
-
-
- ))}
-
-
-
- setNewComment(e.target.value)}
- onKeyPress={e => e.key === 'Enter' && handleAddComment()}
- placeholder="Add a comment..."
- 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"
- />
-
- Send
-
-
-
- )}
-
- {/* Submit for Review */}
- {['draft', 'revision_requested', 'rejected'].includes(artefact.status) && (
-
-
-
- {submitting ? 'Submitting...' : 'Submit for Review'}
-
-
- )}
-
- {/* Review Link */}
- {reviewUrl && (
-
- Review Link (expires in 7 days)
-
-
-
- {copied ? : }
-
-
-
- )}
-
- {/* Feedback */}
- {artefact.feedback && (
-
- Feedback
- {artefact.feedback}
-
- )}
-
- {/* Approval Info */}
- {artefact.status === 'approved' && artefact.approved_by_name && (
-
- Approved by {artefact.approved_by_name}
- {artefact.approved_at && (
-
- {new Date(artefact.approved_at).toLocaleString()}
-
- )}
-
- )}
-
-
- {/* Language Modal */}
- setShowLanguageModal(false)} title="Add Language" size="md">
-
-
-
-
-
-
-
-
-
- setShowLanguageModal(false)}
- className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
- >
- Cancel
-
-
- {savingLanguage ? 'Saving...' : 'Save'}
-
-
-
-
-
- {/* New Version Modal */}
- setShowNewVersionModal(false)} title="Create New Version" size="sm">
-
-
-
-
- {artefact.type === 'copy' && versions.length > 0 && (
-
- )}
-
- setShowNewVersionModal(false)}
- className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
- >
- Cancel
-
-
- {creatingVersion ? 'Creating...' : 'Create Version'}
-
-
-
-
-
- {/* Video Modal */}
- setShowVideoModal(false)} title="Add Video" size="md">
-
-
- setVideoMode('upload')}
- className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
- videoMode === 'upload'
- ? 'bg-brand-primary text-white'
- : 'bg-surface-secondary text-text-secondary hover:bg-surface-tertiary'
- }`}
- >
- Upload File
-
- setVideoMode('drive')}
- className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
- videoMode === 'drive'
- ? 'bg-brand-primary text-white'
- : 'bg-surface-secondary text-text-secondary hover:bg-surface-tertiary'
- }`}
- >
- Google Drive Link
-
-
-
- {videoMode === 'upload' ? (
-
-
-
- ) : (
-
-
- setDriveUrl(e.target.value)}
- placeholder="https://drive.google.com/file/d/..."
- className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
- />
-
- Paste a Google Drive share link. Make sure the file is publicly accessible.
-
-
- setShowVideoModal(false)}
- className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
- >
- Cancel
-
-
- {uploading ? 'Adding...' : 'Add Link'}
-
-
-
- )}
-
-
-
- )
-}
-
-function ApproverMultiSelect({ users = [], selected = [], onChange }) {
- const [open, setOpen] = useState(false)
- const wrapperRef = useRef(null)
-
- // Close dropdown when clicking outside
- useEffect(() => {
- if (!open) return
- const handleClick = (e) => {
- if (wrapperRef.current && !wrapperRef.current.contains(e.target)) {
- setOpen(false)
- }
- }
- document.addEventListener('mousedown', handleClick)
- return () => document.removeEventListener('mousedown', handleClick)
- }, [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 (
-
- setOpen(!open)}
- 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 && (
- Select approvers...
- )}
- {selectedUsers.map(u => (
-
- {u.name}
- { e.stopPropagation(); remove(u._id || u.id || u.Id) }}
- className="hover:text-amber-950"
- >
-
-
-
- ))}
-
-
- {open && (
-
- {users.map(u => {
- const uid = String(u._id || u.id || u.Id)
- const isSelected = selected.includes(uid)
- return (
- toggle(uid)}
- className={`w-full text-left px-3 py-2 text-sm hover:bg-surface-secondary flex items-center justify-between ${
- isSelected ? 'text-brand-primary font-medium' : 'text-text-primary'
- }`}
- >
- {u.name}
- {isSelected && }
-
- )
- })}
- {users.length === 0 && (
- No users available
- )}
-
- )}
-
- )
-}
diff --git a/client/src/pages/Assets.jsx b/client/src/pages/Assets.jsx
index e84bf7d..75871d8 100644
--- a/client/src/pages/Assets.jsx
+++ b/client/src/pages/Assets.jsx
@@ -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)
@@ -212,6 +245,10 @@ export default function Assets() {
)}
+ {selectedIds.size > 0 && (
+ setShowBulkDeleteConfirm(true)} onClear={() => setSelectedIds(new Set())} />
+ )}
+
{/* Asset grid */}
{filteredAssets.length === 0 ? (
@@ -222,7 +259,10 @@ export default function Assets() {
) : (
{filteredAssets.map(asset => (
-
+
))}
@@ -343,6 +383,18 @@ export default function Assets() {
)}
+ setShowBulkDeleteConfirm(false)}
+ title={t('common.bulkDeleteConfirm').replace('{count}', selectedIds.size)}
+ isConfirm
+ danger
+ confirmText={t('common.deleteSelected')}
+ onConfirm={handleBulkDelete}
+ >
+ {t('common.bulkDeleteDesc')}
+
+
{/* Delete Asset Confirmation */}
{
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 {
diff --git a/client/src/pages/CampaignDetail.jsx b/client/src/pages/CampaignDetail.jsx
index 8ffcb53..7cc367a 100644
--- a/client/src/pages/CampaignDetail.jsx
+++ b/client/src/pages/CampaignDetail.jsx
@@ -71,7 +71,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 +82,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 +96,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)
}
diff --git a/client/src/pages/Campaigns.jsx b/client/src/pages/Campaigns.jsx
index afbaffb..330f086 100644
--- a/client/src/pages/Campaigns.jsx
+++ b/client/src/pages/Campaigns.jsx
@@ -50,7 +50,7 @@ export default function Campaigns() {
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 {
@@ -202,6 +202,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,6 +213,15 @@ 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}`)
}}
diff --git a/client/src/pages/Dashboard.jsx b/client/src/pages/Dashboard.jsx
index 4c7fb91..3be2518 100644
--- a/client/src/pages/Dashboard.jsx
+++ b/client/src/pages/Dashboard.jsx
@@ -287,15 +287,15 @@ export default function Dashboard() {
const fetches = []
// Only fetch data for modules the user has access to
if (hasModule('marketing')) {
- fetches.push(api.get('/posts?limit=50&sort=-createdAt').then(r => ({ key: 'posts', data: r.data || r || [] })))
- fetches.push(api.get('/campaigns').then(r => ({ key: 'campaigns', data: r.data || r || [] })))
+ fetches.push(api.get('/posts?limit=50&sort=-createdAt').then(r => ({ key: 'posts', data: Array.isArray(r) ? r : [] })))
+ fetches.push(api.get('/campaigns').then(r => ({ key: 'campaigns', data: Array.isArray(r) ? r : [] })))
}
if (hasModule('projects')) {
- fetches.push(api.get('/tasks').then(r => ({ key: 'tasks', data: r.data || r || [] })))
- fetches.push(api.get('/projects').then(r => ({ key: 'projects', data: r.data || r || [] })))
+ 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.data || r || null })))
+ fetches.push(api.get('/finance/summary').then(r => ({ key: 'finance', data: r || null })))
}
const results = await Promise.allSettled(fetches)
diff --git a/client/src/pages/Issues.jsx b/client/src/pages/Issues.jsx
index 9abe09f..5fdd61f 100644
--- a/client/src/pages/Issues.jsx
+++ b/client/src/pages/Issues.jsx
@@ -1,13 +1,15 @@
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 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' },
@@ -17,19 +19,13 @@ const TYPE_OPTIONS = [
{ value: 'other', label: 'Other' },
]
-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' },
-}
-
-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' },
+// 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_ORDER = ['new', 'acknowledged', 'in_progress', 'resolved', 'declined']
@@ -37,14 +33,14 @@ const STATUS_ORDER = ['new', 'acknowledged', 'in_progress', 'resolved', 'decline
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([])
@@ -59,6 +55,9 @@ export default function Issues() {
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 +71,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 +96,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 +121,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
@@ -149,6 +149,40 @@ export default function Issues() {
}
}
+ const handleBulkDelete = async () => {
+ try {
+ await api.post('/issues/bulk-delete', { ids: [...selectedIds] })
+ toast.success('Issues deleted')
+ setSelectedIds(new Set())
+ setShowBulkDeleteConfirm(false)
+ loadData()
+ } 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 === sortedIssues.length) setSelectedIds(new Set())
+ else setSelectedIds(new Set(sortedIssues.map(i => i.Id || i.id)))
+ }
+
+ 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 handleDragStart = (e, issue) => {
setDraggedIssue(issue)
e.dataTransfer.effectAllowed = 'move'
@@ -208,31 +242,42 @@ export default function Issues() {
Track and manage issue submissions
- {/* View switcher */}
-
- {[
- { mode: 'board', icon: LayoutGrid, label: t('issues.board') },
- { mode: 'list', icon: List, label: t('issues.list') },
- ].map(({ mode, icon: Icon, label }) => (
- 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'
- : 'text-text-tertiary hover:text-text-secondary'
- }`}
- >
-
- {label}
-
- ))}
+
+
+
+ {t('issues.copyPublicLink')}
+
+
+ {/* View switcher */}
+
+ {[
+ { mode: 'board', icon: LayoutGrid, label: t('issues.board') },
+ { mode: 'list', icon: List, label: t('issues.list') },
+ ].map(({ mode, icon: Icon, label }) => (
+ 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'
+ : 'text-text-tertiary hover:text-text-secondary'
+ }`}
+ >
+
+ {label}
+
+ ))}
+
{/* Status Counts */}
- {Object.entries(STATUS_CONFIG).map(([status, config]) => (
+ {Object.entries(ISSUE_STATUS_CONFIG).map(([status, config]) => (
- {Object.entries(STATUS_CONFIG).map(([key, config]) => (
+ {Object.entries(ISSUE_STATUS_CONFIG).map(([key, config]) => (
))}
@@ -303,6 +348,17 @@ export default function Issues() {
))}
+
+
| |