feat: bulk delete, team dispatch, calendar views, timeline colors
All checks were successful
Deploy / deploy (push) Successful in 11s
All checks were successful
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>
This commit is contained in:
29
client/package-lock.json
generated
29
client/package-lock.json
generated
@@ -18,8 +18,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
@@ -1627,26 +1625,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.2.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.13.tgz",
|
||||
"integrity": "sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-dom": {
|
||||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "5.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.3.tgz",
|
||||
@@ -1903,13 +1881,6 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||
|
||||
@@ -20,8 +20,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
|
||||
@@ -1,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 && <Tutorial onComplete={handleTutorialComplete} />}
|
||||
|
||||
<Routes>
|
||||
<Route path="/login" element={user ? <Navigate to="/" replace /> : <Login />} />
|
||||
<Route path="/review/:token" element={<PublicReview />} />
|
||||
<Route path="/submit-issue" element={<PublicIssueSubmit />} />
|
||||
<Route path="/track/:token" element={<PublicIssueTracker />} />
|
||||
<Route path="/" element={user ? <Layout /> : <Navigate to="/login" replace />}>
|
||||
<Route index element={<Dashboard />} />
|
||||
{hasModule('marketing') && <>
|
||||
<Route path="posts" element={<PostProduction />} />
|
||||
<Route path="calendar" element={<PostCalendar />} />
|
||||
<Route path="artefacts" element={<Artefacts />} />
|
||||
<Route path="assets" element={<Assets />} />
|
||||
<Route path="campaigns" element={<Campaigns />} />
|
||||
<Route path="campaigns/:id" element={<CampaignDetail />} />
|
||||
<Route path="brands" element={<Brands />} />
|
||||
</>}
|
||||
{hasModule('finance') && (user?.role === 'superadmin' || user?.role === 'manager') && <>
|
||||
<Route path="finance" element={<Finance />} />
|
||||
<Route path="budgets" element={<Budgets />} />
|
||||
</>}
|
||||
{hasModule('projects') && <>
|
||||
<Route path="projects" element={<Projects />} />
|
||||
<Route path="projects/:id" element={<ProjectDetail />} />
|
||||
<Route path="tasks" element={<Tasks />} />
|
||||
</>}
|
||||
{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>
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={<div className="min-h-screen bg-surface-secondary flex items-center justify-center"><div className="animate-pulse text-text-tertiary">Loading...</div></div>}>
|
||||
<Routes>
|
||||
<Route path="/login" element={user ? <Navigate to="/" replace /> : <Login />} />
|
||||
<Route path="/review/:token" element={<PublicReview />} />
|
||||
<Route path="/submit-issue" element={<PublicIssueSubmit />} />
|
||||
<Route path="/track/:token" element={<PublicIssueTracker />} />
|
||||
<Route path="/" element={user ? <Layout /> : <Navigate to="/login" replace />}>
|
||||
<Route index element={<Dashboard />} />
|
||||
{hasModule('marketing') && <>
|
||||
<Route path="posts" element={<PostProduction />} />
|
||||
<Route path="calendar" element={<PostCalendar />} />
|
||||
<Route path="artefacts" element={<Artefacts />} />
|
||||
<Route path="assets" element={<Assets />} />
|
||||
<Route path="campaigns" element={<Campaigns />} />
|
||||
<Route path="campaigns/:id" element={<CampaignDetail />} />
|
||||
<Route path="brands" element={<Brands />} />
|
||||
</>}
|
||||
{hasModule('finance') && (user?.role === 'superadmin' || user?.role === 'manager') && <>
|
||||
<Route path="finance" element={<Finance />} />
|
||||
<Route path="budgets" element={<Budgets />} />
|
||||
</>}
|
||||
{hasModule('projects') && <>
|
||||
<Route path="projects" element={<Projects />} />
|
||||
<Route path="projects/:id" element={<ProjectDetail />} />
|
||||
<Route path="tasks" element={<Tasks />} />
|
||||
</>}
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
86
client/src/components/ApproverMultiSelect.jsx
Normal file
86
client/src/components/ApproverMultiSelect.jsx
Normal file
@@ -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 (
|
||||
<div className="relative" ref={wrapperRef}>
|
||||
<div
|
||||
onClick={() => 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 && (
|
||||
<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"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<ChevronDown className={`w-4 h-4 text-text-tertiary ml-auto shrink-0 transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||
</div>
|
||||
{open && (
|
||||
<div className="absolute z-50 mt-1 w-full bg-surface border border-border rounded-lg shadow-lg max-h-48 overflow-y-auto">
|
||||
{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-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'
|
||||
}`}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
961
client/src/components/ArtefactDetailPanel.jsx
Normal file
961
client/src/components/ArtefactDetailPanel.jsx
Normal file
@@ -0,0 +1,961 @@
|
||||
import { useState, useEffect, useContext } from 'react'
|
||||
import { Plus, Copy, Check, ExternalLink, Upload, Globe, Trash2, FileText, Image as ImageIcon, Film, Sparkles, MessageSquare, Save } from 'lucide-react'
|
||||
import { AppContext } from '../App'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import { api } from '../utils/api'
|
||||
import Modal from './Modal'
|
||||
import SlidePanel from './SlidePanel'
|
||||
import { useToast } from './ToastContainer'
|
||||
import ArtefactVersionTimeline from './ArtefactVersionTimeline'
|
||||
import ApproverMultiSelect from './ApproverMultiSelect'
|
||||
|
||||
const STATUS_COLORS = {
|
||||
draft: 'bg-surface-tertiary text-text-secondary',
|
||||
pending_review: 'bg-amber-100 text-amber-700',
|
||||
approved: 'bg-emerald-100 text-emerald-700',
|
||||
rejected: 'bg-red-100 text-red-700',
|
||||
revision_requested: 'bg-orange-100 text-orange-700',
|
||||
}
|
||||
|
||||
const AVAILABLE_LANGUAGES = [
|
||||
{ code: 'AR', label: '\u0627\u0644\u0639\u0631\u0628\u064A\u0629' },
|
||||
{ code: 'EN', label: 'English' },
|
||||
{ code: 'FR', label: 'Fran\u00E7ais' },
|
||||
{ code: 'ID', label: 'Bahasa Indonesia' },
|
||||
]
|
||||
|
||||
const TYPE_ICONS = {
|
||||
copy: FileText,
|
||||
design: ImageIcon,
|
||||
video: Film,
|
||||
other: Sparkles,
|
||||
}
|
||||
|
||||
export default 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)
|
||||
const [confirmDeleteLangId, setConfirmDeleteLangId] = useState(null)
|
||||
const [confirmDeleteAttId, setConfirmDeleteAttId] = useState(null)
|
||||
const [showDeleteArtefactConfirm, setShowDeleteArtefactConfirm] = useState(false)
|
||||
|
||||
// Language management (for copy type)
|
||||
const [showLanguageModal, setShowLanguageModal] = useState(false)
|
||||
const [languageForm, setLanguageForm] = useState({ language_code: '', language_label: '', content: '' })
|
||||
const [savingLanguage, setSavingLanguage] = useState(false)
|
||||
|
||||
// New version modal
|
||||
const [showNewVersionModal, setShowNewVersionModal] = useState(false)
|
||||
const [newVersionNotes, setNewVersionNotes] = useState('')
|
||||
const [copyFromPrevious, setCopyFromPrevious] = useState(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 = 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('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) => {
|
||||
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) => {
|
||||
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 () => {
|
||||
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 (
|
||||
<SlidePanel onClose={onClose} maxWidth="700px">
|
||||
<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>
|
||||
</SlidePanel>
|
||||
)
|
||||
}
|
||||
|
||||
const TypeIcon = TYPE_ICONS[artefact.type] || Sparkles
|
||||
|
||||
return (
|
||||
<SlidePanel onClose={onClose} maxWidth="700px" header={
|
||||
<div className="px-6 py-4 border-b border-border">
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
onClick={handleSaveDraft}
|
||||
disabled={savingDraft}
|
||||
className="flex items-center gap-1.5 px-3 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="Save draft"
|
||||
>
|
||||
<Save className="w-3.5 h-3.5" />
|
||||
{savingDraft ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
{onDelete && (
|
||||
<button
|
||||
onClick={() => setShowDeleteArtefactConfirm(true)}
|
||||
disabled={deleting}
|
||||
className="p-1.5 text-text-tertiary hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title="Delete artefact"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Description */}
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-2">Description</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="Add a description..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Project & Campaign dropdowns */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1.5">{t('artefacts.project')}</h4>
|
||||
<select
|
||||
value={editProjectId}
|
||||
onChange={e => {
|
||||
setEditProjectId(e.target.value)
|
||||
handleUpdateField('project_id', e.target.value)
|
||||
}}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 bg-surface"
|
||||
>
|
||||
<option value="">—</option>
|
||||
{projects.map(p => <option key={p.Id || p._id || p.id} value={p.Id || p._id || p.id}>{p.name || p.title}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1.5">{t('artefacts.campaign')}</h4>
|
||||
<select
|
||||
value={editCampaignId}
|
||||
onChange={e => {
|
||||
setEditCampaignId(e.target.value)
|
||||
handleUpdateField('campaign_id', e.target.value)
|
||||
}}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 bg-surface"
|
||||
>
|
||||
<option value="">—</option>
|
||||
{campaigns.map(c => <option key={c.Id || c._id || c.id} value={c.Id || c._id || c.id}>{c.name || c.title}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Approvers */}
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1.5">Approvers</h4>
|
||||
<ApproverMultiSelect
|
||||
users={assignableUsers}
|
||||
selected={editApproverIds}
|
||||
onChange={ids => {
|
||||
setEditApproverIds(ids)
|
||||
handleUpdateField('approver_ids', ids.length > 0 ? ids.join(',') : '')
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Version Timeline */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase">Versions</h4>
|
||||
<button
|
||||
onClick={() => setShowNewVersionModal(true)}
|
||||
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
New Version
|
||||
</button>
|
||||
</div>
|
||||
<ArtefactVersionTimeline
|
||||
versions={versions}
|
||||
activeVersionId={selectedVersion?.Id}
|
||||
onSelectVersion={handleSelectVersion}
|
||||
artefactType={artefact.type}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Type-specific content */}
|
||||
{versionData && selectedVersion && (
|
||||
<div className="border-t border-border pt-6">
|
||||
{/* 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">Languages</h4>
|
||||
<button
|
||||
onClick={() => setShowLanguageModal(true)}
|
||||
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
Add Language
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{versionData.texts && versionData.texts.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{versionData.texts.map(text => (
|
||||
<div key={text.Id} className="bg-surface-secondary rounded-lg p-4 border border-border">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="px-2 py-0.5 bg-surface border border-border rounded text-xs font-mono font-medium">
|
||||
{text.language_code}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-text-primary">{text.language_label}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setConfirmDeleteLangId(text.Id)}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="bg-surface rounded border border-border p-3 text-sm text-text-primary whitespace-pre-wrap font-sans">
|
||||
{text.content}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 bg-surface-secondary rounded-lg border-2 border-dashed border-border">
|
||||
<Globe className="w-8 h-8 text-text-tertiary mx-auto mb-2" />
|
||||
<p className="text-sm text-text-secondary">No languages added yet</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* DESIGN TYPE: Image gallery */}
|
||||
{artefact.type === 'design' && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase">Images</h4>
|
||||
<label className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors cursor-pointer">
|
||||
<Upload className="w-3 h-3" />
|
||||
{uploading ? 'Uploading...' : 'Upload Image'}
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept="image/*"
|
||||
onChange={handleFileUpload}
|
||||
disabled={uploading}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{versionData.attachments && versionData.attachments.length > 0 ? (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{versionData.attachments.map(att => (
|
||||
<div key={att.Id} className="relative group">
|
||||
<img
|
||||
src={att.url}
|
||||
alt={att.original_name}
|
||||
className="w-full h-48 object-cover rounded-lg border border-border"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors rounded-lg flex items-center justify-center">
|
||||
<button
|
||||
onClick={() => setConfirmDeleteAttId(att.Id)}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity px-3 py-2 bg-red-600 text-white rounded-lg text-xs font-medium hover:bg-red-700"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-1 px-2 py-1 bg-surface-secondary rounded text-xs text-text-secondary truncate">
|
||||
{att.original_name}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 bg-surface-secondary rounded-lg border-2 border-dashed border-border">
|
||||
<ImageIcon className="w-8 h-8 text-text-tertiary mx-auto mb-2" />
|
||||
<p className="text-sm text-text-secondary">No images uploaded yet</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* VIDEO TYPE: Files and Drive links */}
|
||||
{artefact.type === 'video' && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase">Videos</h4>
|
||||
<button
|
||||
onClick={() => 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"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
Add Video
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{versionData.attachments && versionData.attachments.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{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">Google Drive Video</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>
|
||||
) : (
|
||||
<div className="text-center py-8 bg-surface-secondary rounded-lg border-2 border-dashed border-border">
|
||||
<Film className="w-8 h-8 text-text-tertiary mx-auto mb-2" />
|
||||
<p className="text-sm text-text-secondary">No videos added yet</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Comments */}
|
||||
{selectedVersion && (
|
||||
<div className="border-t border-border pt-6">
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-3">
|
||||
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="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"
|
||||
/>
|
||||
<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"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit for Review */}
|
||||
{['draft', 'revision_requested', 'rejected'].includes(artefact.status) && (
|
||||
<div className="border-t border-border pt-6">
|
||||
<button
|
||||
onClick={handleSubmitReview}
|
||||
disabled={submitting}
|
||||
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 ? 'Submitting...' : 'Submit for Review'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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">Review Link (expires in 7 days)</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">Feedback</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">Approved by {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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Language Modal */}
|
||||
<Modal isOpen={showLanguageModal} onClose={() => setShowLanguageModal(false)} title="Add Language" size="md">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Language *</label>
|
||||
<select
|
||||
value={languageForm.language_code}
|
||||
onChange={e => {
|
||||
const lang = AVAILABLE_LANGUAGES.find(l => l.code === e.target.value)
|
||||
if (lang) setLanguageForm(f => ({ ...f, language_code: lang.code, language_label: lang.label }))
|
||||
else setLanguageForm(f => ({ ...f, language_code: '', language_label: '' }))
|
||||
}}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||
>
|
||||
<option value="">Select a language...</option>
|
||||
{AVAILABLE_LANGUAGES
|
||||
.filter(lang => !(versionData?.texts || []).some(t => t.language_code === lang.code))
|
||||
.map(lang => (
|
||||
<option key={lang.code} value={lang.code}>{lang.label} ({lang.code})</option>
|
||||
))
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Content *</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="Enter the content in this language..."
|
||||
/>
|
||||
</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"
|
||||
>
|
||||
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 ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* New Version Modal */}
|
||||
<Modal isOpen={showNewVersionModal} onClose={() => setShowNewVersionModal(false)} title="Create New Version" size="sm">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Version Notes</label>
|
||||
<textarea
|
||||
value={newVersionNotes}
|
||||
onChange={e => setNewVersionNotes(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||
placeholder="What changed in this version?"
|
||||
/>
|
||||
</div>
|
||||
{artefact.type === 'copy' && versions.length > 0 && (
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={copyFromPrevious}
|
||||
onChange={e => setCopyFromPrevious(e.target.checked)}
|
||||
className="w-4 h-4 text-brand-primary border-border rounded focus:ring-brand-primary"
|
||||
/>
|
||||
<span className="text-sm text-text-secondary">Copy languages from previous version</span>
|
||||
</label>
|
||||
)}
|
||||
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
|
||||
<button
|
||||
onClick={() => setShowNewVersionModal(false)}
|
||||
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreateVersion}
|
||||
disabled={creatingVersion}
|
||||
className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 shadow-sm"
|
||||
>
|
||||
{creatingVersion ? 'Creating...' : 'Create Version'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Video Modal */}
|
||||
<Modal isOpen={showVideoModal} onClose={() => setShowVideoModal(false)} title="Add Video" size="md">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 border-b border-border pb-3">
|
||||
<button
|
||||
onClick={() => 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
|
||||
</button>
|
||||
<button
|
||||
onClick={() => 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
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{videoMode === 'upload' ? (
|
||||
<div>
|
||||
<label className="flex flex-col items-center gap-3 px-6 py-8 border-2 border-dashed border-border rounded-lg hover:border-brand-primary/30 transition-colors cursor-pointer">
|
||||
<Upload className="w-8 h-8 text-text-tertiary" />
|
||||
<div className="text-center">
|
||||
<span className="text-sm font-medium text-text-primary">
|
||||
{uploading ? 'Uploading...' : 'Choose video file'}
|
||||
</span>
|
||||
<p className="text-xs text-text-tertiary mt-1">MP4, MOV, AVI, etc.</p>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept="video/*"
|
||||
onChange={handleFileUpload}
|
||||
disabled={uploading}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-2">Google Drive URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={driveUrl}
|
||||
onChange={e => 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"
|
||||
/>
|
||||
<p className="text-xs text-text-tertiary mt-2">
|
||||
Paste a Google Drive share link. Make sure the file is publicly accessible.
|
||||
</p>
|
||||
<div className="flex justify-end gap-3 mt-4">
|
||||
<button
|
||||
onClick={() => setShowVideoModal(false)}
|
||||
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAddDriveVideo}
|
||||
disabled={uploading || !driveUrl.trim()}
|
||||
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"
|
||||
>
|
||||
{uploading ? 'Adding...' : 'Add Link'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Delete Language Confirmation */}
|
||||
<Modal
|
||||
isOpen={!!confirmDeleteLangId}
|
||||
onClose={() => setConfirmDeleteLangId(null)}
|
||||
title={t('artefacts.deleteLanguage')}
|
||||
isConfirm
|
||||
danger
|
||||
onConfirm={() => handleDeleteLanguage(confirmDeleteLangId)}
|
||||
confirmText={t('common.delete')}
|
||||
>
|
||||
{t('artefacts.deleteLanguageDesc')}
|
||||
</Modal>
|
||||
|
||||
{/* Delete Attachment Confirmation */}
|
||||
<Modal
|
||||
isOpen={!!confirmDeleteAttId}
|
||||
onClose={() => setConfirmDeleteAttId(null)}
|
||||
title={t('artefacts.deleteAttachment')}
|
||||
isConfirm
|
||||
danger
|
||||
onConfirm={() => handleDeleteAttachment(confirmDeleteAttId)}
|
||||
confirmText={t('common.delete')}
|
||||
>
|
||||
{t('artefacts.deleteAttachmentDesc')}
|
||||
</Modal>
|
||||
|
||||
{/* Delete Artefact Confirmation */}
|
||||
<Modal
|
||||
isOpen={showDeleteArtefactConfirm}
|
||||
onClose={() => setShowDeleteArtefactConfirm(false)}
|
||||
title={t('artefacts.deleteArtefact')}
|
||||
isConfirm
|
||||
danger
|
||||
onConfirm={handleDeleteArtefact}
|
||||
confirmText={t('common.delete')}
|
||||
>
|
||||
{t('artefacts.deleteArtefactDesc')}
|
||||
</Modal>
|
||||
</SlidePanel>
|
||||
)
|
||||
}
|
||||
30
client/src/components/BulkSelectBar.jsx
Normal file
30
client/src/components/BulkSelectBar.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
41
client/src/components/ErrorBoundary.jsx
Normal file
41
client/src/components/ErrorBoundary.jsx
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -33,8 +33,15 @@ const PRIORITY_BORDER = {
|
||||
}
|
||||
|
||||
const ZOOM_LEVELS = [
|
||||
{ key: 'day', label: 'Day', pxPerDay: 48 },
|
||||
{ key: 'month', label: 'Month', pxPerDay: 8 },
|
||||
{ key: 'week', label: 'Week', pxPerDay: 20 },
|
||||
{ key: 'day', label: 'Day', pxPerDay: 48 },
|
||||
]
|
||||
|
||||
const COLOR_PALETTE = [
|
||||
'#6366f1', '#ec4899', '#10b981', '#f59e0b',
|
||||
'#8b5cf6', '#06b6d4', '#f43f5e', '#14b8a6',
|
||||
'#3b82f6', '#ef4444', '#84cc16', '#a855f7',
|
||||
]
|
||||
|
||||
function getInitials(name) {
|
||||
@@ -42,7 +49,7 @@ 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 containerRef = useRef(null)
|
||||
const didDragRef = useRef(false)
|
||||
const optimisticRef = useRef({}) // { [itemId]: { startDate, endDate } } — keeps bar in place until fresh data arrives
|
||||
@@ -51,10 +58,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 = {}
|
||||
@@ -273,6 +294,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
|
||||
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 +307,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 +323,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
|
||||
|
||||
@@ -313,6 +342,18 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
|
||||
{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="Change color"
|
||||
/>
|
||||
)}
|
||||
{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" />
|
||||
@@ -337,6 +378,18 @@ 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="Change color"
|
||||
/>
|
||||
)}
|
||||
{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" />
|
||||
@@ -377,6 +430,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) => {
|
||||
@@ -476,6 +530,38 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Color Picker Popover */}
|
||||
{colorPicker && onColorChange && (
|
||||
<div
|
||||
ref={colorPickerRef}
|
||||
className="fixed z-50 bg-white 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"
|
||||
>
|
||||
Reset to default
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tooltip */}
|
||||
{tooltip && !dragState && (
|
||||
<div
|
||||
|
||||
@@ -1,28 +1,17 @@
|
||||
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 { api, STATUS_CONFIG, PRIORITY_CONFIG } from '../utils/api'
|
||||
import SlidePanel from './SlidePanel'
|
||||
import FormInput from './FormInput'
|
||||
import Modal from './Modal'
|
||||
import { useToast } from './ToastContainer'
|
||||
import { AppContext } from '../App'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
|
||||
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([])
|
||||
@@ -32,6 +21,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
|
||||
// Form state
|
||||
const [assignedTo, setAssignedTo] = useState('')
|
||||
const [teamId, setTeamId] = useState('')
|
||||
const [internalNotes, setInternalNotes] = useState('')
|
||||
const [resolutionSummary, setResolutionSummary] = useState('')
|
||||
const [newUpdate, setNewUpdate] = useState('')
|
||||
@@ -40,6 +30,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 +45,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 +64,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 +80,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 +96,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 +109,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 +120,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 +136,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 +154,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) => {
|
||||
@@ -283,6 +274,33 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Team */}
|
||||
{teams.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-text-primary mb-2">{t('issues.team')}</label>
|
||||
<select
|
||||
value={teamId}
|
||||
onChange={async (e) => {
|
||||
const val = e.target.value || null
|
||||
setTeamId(val || '')
|
||||
try {
|
||||
await api.patch(`/issues/${issueId}`, { team_id: val })
|
||||
await onUpdate()
|
||||
await loadIssueDetails()
|
||||
} catch (err) {
|
||||
console.error('Failed to update team:', err)
|
||||
}
|
||||
}}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||
>
|
||||
<option value="">{t('issues.allTeams')}</option>
|
||||
{teams.map((team) => (
|
||||
<option key={team.id || team._id} value={team.id || team._id}>{team.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Brand */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-text-primary mb-2">Brand</label>
|
||||
@@ -504,7 +522,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
>
|
||||
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>
|
||||
@@ -579,6 +597,19 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
</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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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">
|
||||
|
||||
@@ -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([])
|
||||
}
|
||||
|
||||
@@ -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 */}
|
||||
<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>
|
||||
<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 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-white shadow-sm text-text-primary' : 'text-text-tertiary'}`}
|
||||
>
|
||||
<CalendarIcon className="w-3 h-3" />
|
||||
Month
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCalView('week')}
|
||||
className={`px-2.5 py-1 text-[11px] font-medium rounded-md transition-colors flex items-center gap-1 ${calView === 'week' ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary'}`}
|
||||
>
|
||||
<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 */}
|
||||
@@ -112,7 +161,7 @@ export default function TaskCalendarView({ tasks, onTaskClick }) {
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`border-r border-b border-border min-h-[90px] p-1 ${
|
||||
className={`border-r border-b border-border ${calView === 'week' ? 'min-h-[300px]' : 'min-h-[90px]'} p-1 ${
|
||||
cell.current ? 'bg-white' : 'bg-surface-secondary/50'
|
||||
}`}
|
||||
>
|
||||
@@ -122,7 +171,7 @@ 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)}
|
||||
@@ -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>
|
||||
|
||||
@@ -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([])
|
||||
}
|
||||
|
||||
@@ -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": "نسخ رابط المشاكل العام"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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() {
|
||||
</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 left-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>
|
||||
))}
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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}`)
|
||||
}}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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() {
|
||||
<p className="text-text-secondary mt-1">Track and manage issue submissions</p>
|
||||
</div>
|
||||
|
||||
{/* View switcher */}
|
||||
<div className="flex items-center bg-surface-tertiary rounded-lg p-0.5">
|
||||
{[
|
||||
{ mode: 'board', icon: LayoutGrid, label: t('issues.board') },
|
||||
{ mode: 'list', icon: List, label: t('issues.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-white text-text-primary shadow-sm'
|
||||
: 'text-text-tertiary hover:text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
<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">
|
||||
{[
|
||||
{ mode: 'board', icon: LayoutGrid, label: t('issues.board') },
|
||||
{ mode: 'list', icon: List, label: t('issues.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-white text-text-primary shadow-sm'
|
||||
: 'text-text-tertiary hover:text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</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 ${
|
||||
@@ -269,7 +314,7 @@ export default function Issues() {
|
||||
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]) => (
|
||||
{Object.entries(ISSUE_STATUS_CONFIG).map(([key, config]) => (
|
||||
<option key={key} value={key}>{config.label}</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -303,6 +348,17 @@ export default function Issues() {
|
||||
))}
|
||||
</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)}
|
||||
@@ -332,7 +388,7 @@ export default function Issues() {
|
||||
) : (
|
||||
<div className="flex gap-4 overflow-x-auto pb-4">
|
||||
{STATUS_ORDER.map(status => {
|
||||
const config = STATUS_CONFIG[status]
|
||||
const config = ISSUE_STATUS_CONFIG[status]
|
||||
const columnIssues = filteredIssues.filter(i => i.status === status)
|
||||
return (
|
||||
<div
|
||||
@@ -391,10 +447,20 @@ export default function Issues() {
|
||||
/>
|
||||
) : (
|
||||
<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="w-10 px-3 py-3" onClick={e => e.stopPropagation()}>
|
||||
<input type="checkbox" checked={selectedIds.size === sortedIssues.length && sortedIssues.length > 0} onChange={toggleSelectAll} className="rounded border-border" />
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleSort('title')}>
|
||||
Title <SortIcon col="title" />
|
||||
</th>
|
||||
@@ -424,6 +490,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>
|
||||
@@ -459,6 +528,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 +548,7 @@ export default function Issues() {
|
||||
onClose={() => setSelectedIssue(null)}
|
||||
onUpdate={loadData}
|
||||
teamMembers={teamMembers}
|
||||
teams={teams}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -34,7 +34,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 +44,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 +55,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)
|
||||
}
|
||||
@@ -78,10 +78,10 @@ export default function Login() {
|
||||
<Megaphone className="w-8 h-8 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 +89,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,7 +99,7 @@ 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" />
|
||||
<input
|
||||
@@ -107,7 +107,7 @@ export default function Login() {
|
||||
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"
|
||||
placeholder={t('login.fullNamePlaceholder')}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
@@ -116,7 +116,7 @@ export default function Login() {
|
||||
|
||||
{/* 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" />
|
||||
<input
|
||||
@@ -133,7 +133,7 @@ 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" />
|
||||
<input
|
||||
@@ -141,7 +141,7 @@ export default function Login() {
|
||||
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"
|
||||
placeholder={t('login.passwordPlaceholder')}
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
@@ -150,7 +150,7 @@ 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" />
|
||||
<input
|
||||
@@ -158,7 +158,7 @@ export default function Login() {
|
||||
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"
|
||||
placeholder={t('login.confirmPasswordPlaceholder')}
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
@@ -182,10 +182,10 @@ export default function Login() {
|
||||
{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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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'
|
||||
@@ -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)
|
||||
@@ -176,17 +206,37 @@ export default function PostCalendar() {
|
||||
{/* 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
|
||||
</button>
|
||||
<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-white shadow-sm text-text-primary' : 'text-text-tertiary'}`}
|
||||
>
|
||||
<CalendarIcon className="w-3.5 h-3.5" />
|
||||
Month
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCalView('week')}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors flex items-center gap-1.5 ${calView === 'week' ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary'}`}
|
||||
>
|
||||
<CalendarDays className="w-3.5 h-3.5" />
|
||||
Week
|
||||
</button>
|
||||
</div>
|
||||
<button onClick={goToday} className="px-4 py-2 text-sm font-medium text-brand-primary hover:bg-brand-primary/5 rounded-lg transition-colors">
|
||||
Today
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Day headers */}
|
||||
@@ -207,7 +257,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,7 +267,7 @@ 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)}
|
||||
@@ -229,9 +279,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>
|
||||
|
||||
@@ -10,6 +10,8 @@ import PostDetailPanel from '../components/PostDetailPanel'
|
||||
import DatePresetPicker from '../components/DatePresetPicker'
|
||||
import { SkeletonKanbanBoard, SkeletonTable } from '../components/SkeletonLoader'
|
||||
import EmptyState from '../components/EmptyState'
|
||||
import BulkSelectBar from '../components/BulkSelectBar'
|
||||
import Modal from '../components/Modal'
|
||||
import { useToast } from '../components/ToastContainer'
|
||||
|
||||
const EMPTY_POST = {
|
||||
@@ -32,16 +34,18 @@ export default function PostProduction() {
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [activePreset, setActivePreset] = useState('')
|
||||
const [moveError, setMoveError] = useState('')
|
||||
const [selectedIds, setSelectedIds] = useState(new Set())
|
||||
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = 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 {
|
||||
@@ -88,9 +92,36 @@ 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')
|
||||
toast.error(t('posts.canOnlyEditOwn'))
|
||||
return
|
||||
}
|
||||
setPanelPost(post)
|
||||
@@ -244,9 +275,18 @@ 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="w-10 px-3 py-3" onClick={e => e.stopPropagation()}>
|
||||
<input type="checkbox" checked={selectedIds.size === filteredPosts.length && filteredPosts.length > 0} onChange={toggleSelectAll} className="rounded border-border" />
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.postTitle')}</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.brand')}</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.status')}</th>
|
||||
@@ -256,15 +296,37 @@ export default function PostProduction() {
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* Post Detail Panel */}
|
||||
{panelPost && (
|
||||
<PostDetailPanel
|
||||
|
||||
@@ -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 {
|
||||
@@ -458,7 +458,14 @@ 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 ─── */}
|
||||
@@ -576,7 +583,35 @@ 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">
|
||||
@@ -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
|
||||
@@ -630,7 +667,38 @@ function GanttView({ tasks, project, onEditTask }) {
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
{/* 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,7 +742,20 @@ 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">
|
||||
{task.title}
|
||||
@@ -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-white 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -146,6 +146,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 +157,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}`)
|
||||
}}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { AlertCircle, Send, CheckCircle2, Upload, X } from 'lucide-react'
|
||||
import { api } from '../utils/api'
|
||||
import FormInput from '../components/FormInput'
|
||||
import { useToast } from '../components/ToastContainer'
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: 'request', label: 'Request' },
|
||||
@@ -19,6 +20,12 @@ const PRIORITY_OPTIONS = [
|
||||
]
|
||||
|
||||
export default function PublicIssueSubmit() {
|
||||
const toast = useToast()
|
||||
|
||||
// Team pre-selection from URL
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const teamParam = urlParams.get('team')
|
||||
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
@@ -28,12 +35,20 @@ export default function PublicIssueSubmit() {
|
||||
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 }))
|
||||
@@ -75,6 +90,9 @@ export default function PublicIssueSubmit() {
|
||||
formData.append('priority', form.priority)
|
||||
formData.append('title', form.title)
|
||||
formData.append('description', form.description)
|
||||
if (form.team_id) {
|
||||
formData.append('team_id', form.team_id)
|
||||
}
|
||||
if (file) {
|
||||
formData.append('file', file)
|
||||
}
|
||||
@@ -84,7 +102,7 @@ export default function PublicIssueSubmit() {
|
||||
setSubmitted(true)
|
||||
} catch (err) {
|
||||
console.error('Submit error:', err)
|
||||
alert('Failed to submit issue. Please try again.')
|
||||
toast.error('Failed to submit issue. Please try again.')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
@@ -116,7 +134,7 @@ export default function PublicIssueSubmit() {
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(trackingUrl)
|
||||
alert('Copied to clipboard!')
|
||||
toast.success('Copied to clipboard!')
|
||||
}}
|
||||
className="px-4 py-2 bg-brand-primary text-white rounded-lg font-medium hover:bg-brand-primary-light transition-colors"
|
||||
>
|
||||
@@ -145,6 +163,7 @@ export default function PublicIssueSubmit() {
|
||||
priority: 'medium',
|
||||
title: '',
|
||||
description: '',
|
||||
team_id: teamParam || '',
|
||||
})
|
||||
setFile(null)
|
||||
}}
|
||||
@@ -205,10 +224,27 @@ export default function PublicIssueSubmit() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Team Selection */}
|
||||
{!teamParam && teams.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-text-primary mb-2">Which team should handle your issue?</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="">Select a team</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>
|
||||
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1.5">
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { AlertCircle, Clock, CheckCircle2, XCircle, MessageCircle, Upload, FileText, Send } 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 },
|
||||
@@ -20,6 +21,7 @@ const PRIORITY_CONFIG = {
|
||||
|
||||
export default function PublicIssueTracker() {
|
||||
const { token } = useParams()
|
||||
const toast = useToast()
|
||||
const [issue, setIssue] = useState(null)
|
||||
const [updates, setUpdates] = useState([])
|
||||
const [attachments, setAttachments] = useState([])
|
||||
@@ -68,7 +70,7 @@ export default function PublicIssueTracker() {
|
||||
await loadIssue()
|
||||
} catch (err) {
|
||||
console.error('Failed to add comment:', err)
|
||||
alert('Failed to add comment')
|
||||
toast.error('Failed to add comment')
|
||||
} finally {
|
||||
setSubmittingComment(false)
|
||||
}
|
||||
@@ -88,7 +90,7 @@ export default function PublicIssueTracker() {
|
||||
e.target.value = '' // Reset input
|
||||
} catch (err) {
|
||||
console.error('Failed to upload file:', err)
|
||||
alert('Failed to upload file')
|
||||
toast.error('Failed to upload file')
|
||||
} finally {
|
||||
setUploadingFile(false)
|
||||
}
|
||||
|
||||
@@ -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, User } from 'lucide-react'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import { useToast } from '../components/ToastContainer'
|
||||
import Modal from '../components/Modal'
|
||||
|
||||
const STATUS_ICONS = {
|
||||
copy: FileText,
|
||||
@@ -11,6 +14,8 @@ 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('')
|
||||
@@ -19,6 +24,7 @@ export default function PublicReview() {
|
||||
const [reviewerName, setReviewerName] = useState('')
|
||||
const [feedback, setFeedback] = useState('')
|
||||
const [selectedLanguage, setSelectedLanguage] = useState(0)
|
||||
const [pendingAction, setPendingAction] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadArtefact()
|
||||
@@ -29,7 +35,7 @@ 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
|
||||
}
|
||||
@@ -40,25 +46,32 @@ export default function PublicReview() {
|
||||
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 select or 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}`, {
|
||||
@@ -72,18 +85,18 @@ 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)
|
||||
}
|
||||
@@ -133,7 +146,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>
|
||||
@@ -147,7 +160,7 @@ export default function PublicReview() {
|
||||
<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">Thank You!</h2>
|
||||
<h2 className="text-2xl font-bold text-text-primary mb-2">{t('review.thankYou')}</h2>
|
||||
<p className="text-text-secondary">{success}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -170,7 +183,7 @@ 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>
|
||||
<h1 className="text-2xl font-bold text-white">{t('review.contentReview')}</h1>
|
||||
<p className="text-white/80 text-sm">Samaya Digital Hub</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -190,7 +203,7 @@ export default function PublicReview() {
|
||||
<div className="flex items-center gap-3 text-sm text-text-tertiary">
|
||||
<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>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -200,7 +213,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 */}
|
||||
@@ -226,7 +239,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}
|
||||
@@ -238,7 +251,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}
|
||||
@@ -252,7 +265,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) => (
|
||||
@@ -284,7 +297,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) => (
|
||||
@@ -293,7 +306,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)}
|
||||
@@ -325,7 +338,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}>
|
||||
@@ -372,7 +385,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">
|
||||
@@ -396,12 +409,12 @@ 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">Reviewer</label>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('review.reviewer')}</label>
|
||||
{artefact.approvers?.length === 1 ? (
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-surface-secondary border border-border rounded-lg">
|
||||
<User className="w-4 h-4 text-text-tertiary" />
|
||||
@@ -413,7 +426,7 @@ export default function PublicReview() {
|
||||
onChange={e => setReviewerName(e.target.value)}
|
||||
className="w-full px-4 py-2 text-sm border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary transition-colors"
|
||||
>
|
||||
<option value="">Select your name...</option>
|
||||
<option value="">{t('review.selectYourName')}</option>
|
||||
{artefact.approvers.map(a => (
|
||||
<option key={a.id} value={a.name}>{a.name}</option>
|
||||
))}
|
||||
@@ -423,19 +436,19 @@ export default function PublicReview() {
|
||||
type="text"
|
||||
value={reviewerName}
|
||||
onChange={e => setReviewerName(e.target.value)}
|
||||
placeholder="Enter your name"
|
||||
placeholder={t('review.enterYourName')}
|
||||
className="w-full px-4 py-2 text-sm border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary transition-colors"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<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..."
|
||||
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>
|
||||
@@ -448,7 +461,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')}
|
||||
@@ -456,7 +469,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')}
|
||||
@@ -464,7 +477,7 @@ 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>
|
||||
</div>
|
||||
@@ -475,14 +488,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>
|
||||
@@ -493,9 +506,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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@ import { useState, useEffect } from 'react'
|
||||
import { Settings as SettingsIcon, Play, CheckCircle, Languages, Coins, Upload } from 'lucide-react'
|
||||
import { api } from '../utils/api'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import { useToast } from '../components/ToastContainer'
|
||||
import { CURRENCIES } from '../i18n/LanguageContext'
|
||||
|
||||
export default function Settings() {
|
||||
const { t, lang, setLang, currency, setCurrency } = useLanguage()
|
||||
const toast = useToast()
|
||||
const [restarting, setRestarting] = useState(false)
|
||||
const [success, setSuccess] = useState(false)
|
||||
const [maxSizeMB, setMaxSizeMB] = useState(50)
|
||||
@@ -25,7 +27,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 +44,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)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import { useLanguage } from '../i18n/LanguageContext'
|
||||
import { api, PRIORITY_CONFIG, getBrandColor } from '../utils/api'
|
||||
import TaskCard from '../components/TaskCard'
|
||||
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'
|
||||
@@ -45,6 +47,8 @@ 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)
|
||||
|
||||
// Assignable users & team
|
||||
const [assignableUsers, setAssignableUsers] = useState([])
|
||||
@@ -54,17 +58,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,6 +181,33 @@ export default function Tasks() {
|
||||
}
|
||||
}
|
||||
|
||||
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) => {
|
||||
try {
|
||||
await api.patch(`/tasks/${taskId}`, { status: newStatus })
|
||||
@@ -594,10 +625,27 @@ export default function Tasks() {
|
||||
|
||||
{/* ─── List View ───────────────────────── */}
|
||||
{viewMode === 'list' && (
|
||||
<>
|
||||
{selectedIds.size > 0 && (
|
||||
<BulkSelectBar
|
||||
selectedCount={selectedIds.size}
|
||||
onClear={() => setSelectedIds(new Set())}
|
||||
onDelete={() => setShowBulkDeleteConfirm(true)}
|
||||
/>
|
||||
)}
|
||||
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
||||
<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"
|
||||
@@ -645,6 +693,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>
|
||||
@@ -686,6 +742,7 @@ export default function Tasks() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ─── Calendar View ───────────────────── */}
|
||||
@@ -695,6 +752,19 @@ export default function Tasks() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ─── 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 ──────────────── */}
|
||||
{selectedTask && (
|
||||
<TaskDetailPanel
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useContext } from 'react'
|
||||
import { Plus, Users, ArrowLeft, User as UserIcon, Edit2, LayoutGrid, Network } from 'lucide-react'
|
||||
import { Plus, Users, ArrowLeft, User as UserIcon, Edit2, LayoutGrid, Network, Link2 } from 'lucide-react'
|
||||
import { getInitials } from '../utils/api'
|
||||
import { AppContext } from '../App'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
@@ -10,9 +10,11 @@ import StatusBadge from '../components/StatusBadge'
|
||||
import BrandBadge from '../components/BrandBadge'
|
||||
import TeamMemberPanel from '../components/TeamMemberPanel'
|
||||
import TeamPanel from '../components/TeamPanel'
|
||||
import { useToast } from '../components/ToastContainer'
|
||||
|
||||
export default function Team() {
|
||||
const { t } = useLanguage()
|
||||
const toast = useToast()
|
||||
const { teamMembers, loadTeam, currentUser, teams, loadTeams, brands } = useContext(AppContext)
|
||||
const { user } = useAuth()
|
||||
const [panelMember, setPanelMember] = useState(null)
|
||||
@@ -27,6 +29,13 @@ export default function Team() {
|
||||
|
||||
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'))
|
||||
}
|
||||
|
||||
const openNew = () => {
|
||||
setPanelMember({ role: 'content_writer' })
|
||||
setPanelIsEditingSelf(false)
|
||||
@@ -85,7 +94,7 @@ export default function Team() {
|
||||
await loadTeams()
|
||||
} catch (err) {
|
||||
console.error('Save failed:', err)
|
||||
alert(err.message || 'Failed to save')
|
||||
toast.error(err.message || t('common.failedToSave'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,7 +109,7 @@ export default function Team() {
|
||||
await loadTeam()
|
||||
} catch (err) {
|
||||
console.error('Team save failed:', err)
|
||||
alert(err.message || 'Failed to save team')
|
||||
toast.error(err.message || t('team.failedToSaveTeam'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,6 +328,16 @@ 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-white 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={() => {
|
||||
@@ -443,14 +462,23 @@ export default function Team() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{canManageTeam && (
|
||||
<div className="flex items-center gap-1">
|
||||
<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"
|
||||
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')}
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
<Link2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
{canManageTeam && (
|
||||
<button
|
||||
onClick={() => setPanelTeam(team)}
|
||||
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 */}
|
||||
|
||||
@@ -3,6 +3,8 @@ 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 { useLanguage } from '../i18n/LanguageContext'
|
||||
import { useToast } from '../components/ToastContainer'
|
||||
import { SkeletonTable } from '../components/SkeletonLoader'
|
||||
|
||||
const ROLES = [
|
||||
@@ -27,6 +29,8 @@ function RoleBadge({ role }) {
|
||||
|
||||
export default function Users() {
|
||||
const { user: currentUser } = useAuth()
|
||||
const { t } = useLanguage()
|
||||
const toast = useToast()
|
||||
const [users, setUsers] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
@@ -53,7 +57,7 @@ export default function Users() {
|
||||
const handleSave = async () => {
|
||||
setPasswordError('')
|
||||
if (form.password && form.password !== confirmPassword) {
|
||||
setPasswordError('Passwords do not match')
|
||||
setPasswordError(t('users.passwordMismatch'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
@@ -69,7 +73,7 @@ export default function Users() {
|
||||
await api.patch(`/users/${editingUser.id}`, data)
|
||||
} else {
|
||||
if (!form.password) {
|
||||
alert('Password is required for new users')
|
||||
toast.error(t('users.passwordRequired'))
|
||||
return
|
||||
}
|
||||
data.password = form.password
|
||||
@@ -81,7 +85,7 @@ export default function Users() {
|
||||
loadUsers()
|
||||
} catch (err) {
|
||||
console.error('Save failed:', err)
|
||||
alert('Failed to save user: ' + err.message)
|
||||
toast.error(t('users.saveFailed') + ': ' + err.message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,7 +119,7 @@ export default function Users() {
|
||||
setUserToDelete(null)
|
||||
} catch (err) {
|
||||
console.error('Delete failed:', err)
|
||||
alert('Failed to delete user')
|
||||
toast.error(t('users.deleteFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,16 +139,16 @@ export default function Users() {
|
||||
<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
|
||||
{t('users.title')}
|
||||
</h1>
|
||||
<p className="text-sm text-text-tertiary mt-1">{users.length} user{users.length !== 1 ? 's' : ''}</p>
|
||||
<p className="text-sm text-text-tertiary mt-1">{users.length} {users.length !== 1 ? t('users.usersPlural') : t('users.userSingular')}</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
|
||||
{t('users.addUser')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -153,18 +157,18 @@ export default function Users() {
|
||||
<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>
|
||||
<th className="text-left px-5 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('users.userSingular')}</th>
|
||||
<th className="text-left px-5 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('users.email')}</th>
|
||||
<th className="text-left px-5 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('users.role')}</th>
|
||||
<th className="text-left px-5 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('users.created')}</th>
|
||||
<th className="text-right px-5 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider w-24">{t('users.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
|
||||
{t('users.noUsers')}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
@@ -183,7 +187,7 @@ export default function Users() {
|
||||
<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
|
||||
{t('users.you')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -202,7 +206,7 @@ export default function Users() {
|
||||
<button
|
||||
onClick={() => openEdit(user)}
|
||||
className="p-1.5 hover:bg-surface-tertiary rounded-lg text-text-tertiary hover:text-text-primary"
|
||||
title="Edit user"
|
||||
title={t('users.editUser')}
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
@@ -210,7 +214,7 @@ export default function Users() {
|
||||
<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"
|
||||
title={t('users.deleteUser')}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
@@ -229,24 +233,24 @@ export default function Users() {
|
||||
<Modal
|
||||
isOpen={showModal}
|
||||
onClose={() => { setShowModal(false); setEditingUser(null) }}
|
||||
title={editingUser ? 'Edit User' : 'Add New User'}
|
||||
title={editingUser ? t('users.editUser') : t('users.addNewUser')}
|
||||
size="md"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Name *</label>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('users.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"
|
||||
placeholder={t('users.fullNamePlaceholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Email *</label>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('users.email')} *</label>
|
||||
<input
|
||||
type="email"
|
||||
value={form.email}
|
||||
@@ -259,7 +263,7 @@ export default function Users() {
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">
|
||||
Password {editingUser && '(leave blank to keep current)'}
|
||||
{t('users.password')} {editingUser && `(${t('users.leaveBlankToKeep')})`}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
@@ -273,7 +277,7 @@ export default function Users() {
|
||||
|
||||
{form.password && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Confirm Password</label>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('users.confirmPassword')}</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
@@ -288,7 +292,7 @@ export default function Users() {
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Role *</label>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('users.role')} *</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{ROLES.map(r => (
|
||||
<button
|
||||
@@ -313,14 +317,14 @@ export default function Users() {
|
||||
onClick={() => { setShowModal(false); setEditingUser(null) }}
|
||||
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
|
||||
>
|
||||
Cancel
|
||||
{t('common.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'}
|
||||
{editingUser ? t('users.saveChanges') : t('users.addUser')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -330,13 +334,13 @@ export default function Users() {
|
||||
<Modal
|
||||
isOpen={showDeleteConfirm}
|
||||
onClose={() => { setShowDeleteConfirm(false); setUserToDelete(null) }}
|
||||
title="Delete User?"
|
||||
title={t('users.deleteUserConfirmTitle')}
|
||||
isConfirm
|
||||
danger
|
||||
confirmText="Delete User"
|
||||
confirmText={t('users.deleteUser')}
|
||||
onConfirm={confirmDelete}
|
||||
>
|
||||
Are you sure you want to delete <strong>{userToDelete?.name}</strong>? This action cannot be undone.
|
||||
{t('users.deleteConfirm')} <strong>{userToDelete?.name}</strong>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -139,16 +139,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
|
||||
|
||||
Reference in New Issue
Block a user