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:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -4,3 +4,5 @@ dist/
|
|||||||
*.db-shm
|
*.db-shm
|
||||||
*.db-wal
|
*.db-wal
|
||||||
.vite/
|
.vite/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
|||||||
29
client/package-lock.json
generated
29
client/package-lock.json
generated
@@ -18,8 +18,6 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
"@types/react": "^19.2.5",
|
|
||||||
"@types/react-dom": "^19.2.3",
|
|
||||||
"@vitejs/plugin-react": "^5.1.1",
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
"eslint": "^9.39.1",
|
"eslint": "^9.39.1",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
@@ -1627,26 +1625,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@vitejs/plugin-react": {
|
||||||
"version": "5.1.3",
|
"version": "5.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.3.tgz",
|
||||||
@@ -1903,13 +1881,6 @@
|
|||||||
"node": ">= 8"
|
"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": {
|
"node_modules/date-fns": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||||
|
|||||||
@@ -20,8 +20,6 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
"@types/react": "^19.2.5",
|
|
||||||
"@types/react-dom": "^19.2.3",
|
|
||||||
"@vitejs/plugin-react": "^5.1.1",
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
"eslint": "^9.39.1",
|
"eslint": "^9.39.1",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
|||||||
@@ -1,35 +1,38 @@
|
|||||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
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 { AuthProvider, useAuth } from './contexts/AuthContext'
|
||||||
import { LanguageProvider } from './i18n/LanguageContext'
|
import { LanguageProvider } from './i18n/LanguageContext'
|
||||||
import { ToastProvider } from './components/ToastContainer'
|
import { ToastProvider } from './components/ToastContainer'
|
||||||
|
import ErrorBoundary from './components/ErrorBoundary'
|
||||||
import Layout from './components/Layout'
|
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 Tutorial from './components/Tutorial'
|
||||||
import Modal from './components/Modal'
|
import Modal from './components/Modal'
|
||||||
import { api } from './utils/api'
|
import { api } from './utils/api'
|
||||||
import { useLanguage } from './i18n/LanguageContext'
|
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 = [
|
const TEAM_ROLES = [
|
||||||
{ value: 'manager', label: 'Manager' },
|
{ value: 'manager', label: 'Manager' },
|
||||||
{ value: 'approver', label: 'Approver' },
|
{ value: 'approver', label: 'Approver' },
|
||||||
@@ -87,7 +90,7 @@ function AppContent() {
|
|||||||
const loadTeam = async () => {
|
const loadTeam = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await api.get('/users/team')
|
const data = await api.get('/users/team')
|
||||||
const members = Array.isArray(data) ? data : (data.data || [])
|
const members = Array.isArray(data) ? data : []
|
||||||
setTeamMembers(members)
|
setTeamMembers(members)
|
||||||
return members
|
return members
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -99,7 +102,7 @@ function AppContent() {
|
|||||||
const loadTeams = async () => {
|
const loadTeams = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await api.get('/teams')
|
const data = await api.get('/teams')
|
||||||
setTeams(Array.isArray(data) ? data : (data.data || []))
|
setTeams(Array.isArray(data) ? data : [])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load teams:', err)
|
console.error('Failed to load teams:', err)
|
||||||
}
|
}
|
||||||
@@ -109,7 +112,7 @@ function AppContent() {
|
|||||||
try {
|
try {
|
||||||
const [, brandsData] = await Promise.all([
|
const [, brandsData] = await Promise.all([
|
||||||
loadTeam(),
|
loadTeam(),
|
||||||
api.get('/brands').then(d => Array.isArray(d) ? d : (d.data || [])).catch(() => []),
|
api.get('/brands').then(d => Array.isArray(d) ? d : []).catch(() => []),
|
||||||
loadTeams(),
|
loadTeams(),
|
||||||
])
|
])
|
||||||
setBrands(brandsData)
|
setBrands(brandsData)
|
||||||
@@ -270,40 +273,44 @@ function AppContent() {
|
|||||||
{/* Tutorial overlay */}
|
{/* Tutorial overlay */}
|
||||||
{showTutorial && <Tutorial onComplete={handleTutorialComplete} />}
|
{showTutorial && <Tutorial onComplete={handleTutorialComplete} />}
|
||||||
|
|
||||||
<Routes>
|
<ErrorBoundary>
|
||||||
<Route path="/login" element={user ? <Navigate to="/" replace /> : <Login />} />
|
<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>}>
|
||||||
<Route path="/review/:token" element={<PublicReview />} />
|
<Routes>
|
||||||
<Route path="/submit-issue" element={<PublicIssueSubmit />} />
|
<Route path="/login" element={user ? <Navigate to="/" replace /> : <Login />} />
|
||||||
<Route path="/track/:token" element={<PublicIssueTracker />} />
|
<Route path="/review/:token" element={<PublicReview />} />
|
||||||
<Route path="/" element={user ? <Layout /> : <Navigate to="/login" replace />}>
|
<Route path="/submit-issue" element={<PublicIssueSubmit />} />
|
||||||
<Route index element={<Dashboard />} />
|
<Route path="/track/:token" element={<PublicIssueTracker />} />
|
||||||
{hasModule('marketing') && <>
|
<Route path="/" element={user ? <Layout /> : <Navigate to="/login" replace />}>
|
||||||
<Route path="posts" element={<PostProduction />} />
|
<Route index element={<Dashboard />} />
|
||||||
<Route path="calendar" element={<PostCalendar />} />
|
{hasModule('marketing') && <>
|
||||||
<Route path="artefacts" element={<Artefacts />} />
|
<Route path="posts" element={<PostProduction />} />
|
||||||
<Route path="assets" element={<Assets />} />
|
<Route path="calendar" element={<PostCalendar />} />
|
||||||
<Route path="campaigns" element={<Campaigns />} />
|
<Route path="artefacts" element={<Artefacts />} />
|
||||||
<Route path="campaigns/:id" element={<CampaignDetail />} />
|
<Route path="assets" element={<Assets />} />
|
||||||
<Route path="brands" element={<Brands />} />
|
<Route path="campaigns" element={<Campaigns />} />
|
||||||
</>}
|
<Route path="campaigns/:id" element={<CampaignDetail />} />
|
||||||
{hasModule('finance') && (user?.role === 'superadmin' || user?.role === 'manager') && <>
|
<Route path="brands" element={<Brands />} />
|
||||||
<Route path="finance" element={<Finance />} />
|
</>}
|
||||||
<Route path="budgets" element={<Budgets />} />
|
{hasModule('finance') && (user?.role === 'superadmin' || user?.role === 'manager') && <>
|
||||||
</>}
|
<Route path="finance" element={<Finance />} />
|
||||||
{hasModule('projects') && <>
|
<Route path="budgets" element={<Budgets />} />
|
||||||
<Route path="projects" element={<Projects />} />
|
</>}
|
||||||
<Route path="projects/:id" element={<ProjectDetail />} />
|
{hasModule('projects') && <>
|
||||||
<Route path="tasks" element={<Tasks />} />
|
<Route path="projects" element={<Projects />} />
|
||||||
</>}
|
<Route path="projects/:id" element={<ProjectDetail />} />
|
||||||
{hasModule('issues') && <Route path="issues" element={<Issues />} />}
|
<Route path="tasks" element={<Tasks />} />
|
||||||
<Route path="team" element={<Team />} />
|
</>}
|
||||||
<Route path="settings" element={<Settings />} />
|
{hasModule('issues') && <Route path="issues" element={<Issues />} />}
|
||||||
{user?.role === 'superadmin' && (
|
<Route path="team" element={<Team />} />
|
||||||
<Route path="users" element={<Users />} />
|
<Route path="settings" element={<Settings />} />
|
||||||
)}
|
{user?.role === 'superadmin' && (
|
||||||
</Route>
|
<Route path="users" element={<Users />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
)}
|
||||||
</Routes>
|
</Route>
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</Suspense>
|
||||||
|
</ErrorBoundary>
|
||||||
</AppContext.Provider>
|
</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 () => {
|
const loadComments = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await api.get(`/comments/${entityType}/${entityId}`)
|
const data = await api.get(`/comments/${entityType}/${entityId}`)
|
||||||
setComments(Array.isArray(data) ? data : (data.data || []))
|
setComments(Array.isArray(data) ? data : [])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load comments:', 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 = [
|
const ZOOM_LEVELS = [
|
||||||
{ key: 'day', label: 'Day', pxPerDay: 48 },
|
{ key: 'month', label: 'Month', pxPerDay: 8 },
|
||||||
{ key: 'week', label: 'Week', pxPerDay: 20 },
|
{ 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) {
|
function getInitials(name) {
|
||||||
@@ -42,7 +49,7 @@ function getInitials(name) {
|
|||||||
return name.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2)
|
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 containerRef = useRef(null)
|
||||||
const didDragRef = useRef(false)
|
const didDragRef = useRef(false)
|
||||||
const optimisticRef = useRef({}) // { [itemId]: { startDate, endDate } } — keeps bar in place until fresh data arrives
|
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 [tooltip, setTooltip] = useState(null)
|
||||||
const [dragState, setDragState] = useState(null) // { itemId, mode: 'move'|'resize-left'|'resize-right', startX, origStart, origEnd }
|
const [dragState, setDragState] = useState(null) // { itemId, mode: 'move'|'resize-left'|'resize-right', startX, origStart, origEnd }
|
||||||
const dragStateRef = useRef(null)
|
const dragStateRef = useRef(null)
|
||||||
|
const [colorPicker, setColorPicker] = useState(null) // { itemId, x, y }
|
||||||
|
const colorPickerRef = useRef(null)
|
||||||
|
|
||||||
const pxPerDay = ZOOM_LEVELS[zoomIdx].pxPerDay
|
const pxPerDay = ZOOM_LEVELS[zoomIdx].pxPerDay
|
||||||
const today = useMemo(() => startOfDay(new Date()), [])
|
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
|
// Clear optimistic overrides when fresh data arrives
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
optimisticRef.current = {}
|
optimisticRef.current = {}
|
||||||
@@ -273,6 +294,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
|
|||||||
const isToday = differenceInDays(day, today) === 0
|
const isToday = differenceInDays(day, today) === 0
|
||||||
const isWeekend = day.getDay() === 0 || day.getDay() === 6
|
const isWeekend = day.getDay() === 0 || day.getDay() === 6
|
||||||
const isMonthStart = day.getDate() === 1
|
const isMonthStart = day.getDate() === 1
|
||||||
|
const isWeekStart = day.getDay() === 1 // Monday
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
@@ -285,7 +307,13 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
|
|||||||
>
|
>
|
||||||
{pxPerDay >= 30 && <div>{format(day, 'd')}</div>}
|
{pxPerDay >= 30 && <div>{format(day, 'd')}</div>}
|
||||||
{pxPerDay >= 40 && <div className="text-[8px] uppercase">{format(day, 'EEE')}</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>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -295,7 +323,8 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
|
|||||||
{/* Rows */}
|
{/* Rows */}
|
||||||
{mapped.map((item, idx) => {
|
{mapped.map((item, idx) => {
|
||||||
const { left, width } = getBarPosition(item)
|
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 priorityRing = PRIORITY_BORDER[item.priority] || ''
|
||||||
const isDragging = dragState?.itemId === item.id
|
const isDragging = dragState?.itemId === item.id
|
||||||
|
|
||||||
@@ -313,6 +342,18 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
|
|||||||
{isExpanded ? (
|
{isExpanded ? (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-2">
|
<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 ? (
|
{item.thumbnailUrl ? (
|
||||||
<div className="w-8 h-8 rounded overflow-hidden shrink-0">
|
<div className="w-8 h-8 rounded overflow-hidden shrink-0">
|
||||||
<img src={item.thumbnailUrl} alt="" className="w-full h-full object-cover" />
|
<img src={item.thumbnailUrl} alt="" className="w-full h-full object-cover" />
|
||||||
@@ -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 ? (
|
{item.thumbnailUrl ? (
|
||||||
<div className="w-6 h-6 rounded overflow-hidden shrink-0">
|
<div className="w-6 h-6 rounded overflow-hidden shrink-0">
|
||||||
<img src={item.thumbnailUrl} alt="" className="w-full h-full object-cover" />
|
<img src={item.thumbnailUrl} alt="" className="w-full h-full object-cover" />
|
||||||
@@ -377,6 +430,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
|
|||||||
width: `${width}px`,
|
width: `${width}px`,
|
||||||
height: `${barHeight}px`,
|
height: `${barHeight}px`,
|
||||||
top: isExpanded ? '8px' : '8px',
|
top: isExpanded ? '8px' : '8px',
|
||||||
|
...(hasCustomColor ? { backgroundColor: item.color } : {}),
|
||||||
}}
|
}}
|
||||||
onMouseDown={(e) => handleMouseDown(e, item, 'move')}
|
onMouseDown={(e) => handleMouseDown(e, item, 'move')}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -476,6 +530,38 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
||||||
{tooltip && !dragState && (
|
{tooltip && !dragState && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,28 +1,17 @@
|
|||||||
import { useState, useEffect, useContext } from 'react'
|
import { useState, useEffect, useContext } from 'react'
|
||||||
import { X, Copy, Eye, Lock, Send, Upload, FileText, Trash2, Check, Clock, CheckCircle2, XCircle } from 'lucide-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 SlidePanel from './SlidePanel'
|
||||||
import FormInput from './FormInput'
|
import FormInput from './FormInput'
|
||||||
import Modal from './Modal'
|
import Modal from './Modal'
|
||||||
|
import { useToast } from './ToastContainer'
|
||||||
import { AppContext } from '../App'
|
import { AppContext } from '../App'
|
||||||
|
import { useLanguage } from '../i18n/LanguageContext'
|
||||||
|
|
||||||
const STATUS_CONFIG = {
|
export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers, teams = [] }) {
|
||||||
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 }) {
|
|
||||||
const { brands } = useContext(AppContext)
|
const { brands } = useContext(AppContext)
|
||||||
|
const toast = useToast()
|
||||||
|
const { t } = useLanguage()
|
||||||
const [issueData, setIssueData] = useState(null)
|
const [issueData, setIssueData] = useState(null)
|
||||||
const [updates, setUpdates] = useState([])
|
const [updates, setUpdates] = useState([])
|
||||||
const [attachments, setAttachments] = useState([])
|
const [attachments, setAttachments] = useState([])
|
||||||
@@ -32,6 +21,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
|||||||
|
|
||||||
// Form state
|
// Form state
|
||||||
const [assignedTo, setAssignedTo] = useState('')
|
const [assignedTo, setAssignedTo] = useState('')
|
||||||
|
const [teamId, setTeamId] = useState('')
|
||||||
const [internalNotes, setInternalNotes] = useState('')
|
const [internalNotes, setInternalNotes] = useState('')
|
||||||
const [resolutionSummary, setResolutionSummary] = useState('')
|
const [resolutionSummary, setResolutionSummary] = useState('')
|
||||||
const [newUpdate, setNewUpdate] = useState('')
|
const [newUpdate, setNewUpdate] = useState('')
|
||||||
@@ -40,6 +30,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
|||||||
// Modals
|
// Modals
|
||||||
const [showResolveModal, setShowResolveModal] = useState(false)
|
const [showResolveModal, setShowResolveModal] = useState(false)
|
||||||
const [showDeclineModal, setShowDeclineModal] = useState(false)
|
const [showDeclineModal, setShowDeclineModal] = useState(false)
|
||||||
|
const [confirmDeleteAttId, setConfirmDeleteAttId] = useState(null)
|
||||||
|
|
||||||
const issueId = issue?.Id || issue?.id
|
const issueId = issue?.Id || issue?.id
|
||||||
|
|
||||||
@@ -54,6 +45,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
|||||||
setUpdates(data.updates || [])
|
setUpdates(data.updates || [])
|
||||||
setAttachments(data.attachments || [])
|
setAttachments(data.attachments || [])
|
||||||
setAssignedTo(data.assigned_to_id || '')
|
setAssignedTo(data.assigned_to_id || '')
|
||||||
|
setTeamId(data.team_id || '')
|
||||||
setInternalNotes(data.internal_notes || '')
|
setInternalNotes(data.internal_notes || '')
|
||||||
setResolutionSummary(data.resolution_summary || '')
|
setResolutionSummary(data.resolution_summary || '')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -72,7 +64,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
|||||||
await loadIssueDetails()
|
await loadIssueDetails()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to update status:', err)
|
console.error('Failed to update status:', err)
|
||||||
alert('Failed to update status')
|
toast.error(t('issues.failedToUpdateStatus'))
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
@@ -88,7 +80,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
|||||||
await loadIssueDetails()
|
await loadIssueDetails()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to resolve issue:', err)
|
console.error('Failed to resolve issue:', err)
|
||||||
alert('Failed to resolve issue')
|
toast.error(t('issues.failedToResolve'))
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
@@ -104,7 +96,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
|||||||
await loadIssueDetails()
|
await loadIssueDetails()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to decline issue:', err)
|
console.error('Failed to decline issue:', err)
|
||||||
alert('Failed to decline issue')
|
toast.error(t('issues.failedToDecline'))
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
@@ -117,7 +109,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
|||||||
await onUpdate()
|
await onUpdate()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to update assignment:', 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 })
|
await api.patch(`/issues/${issueId}`, { internal_notes: internalNotes })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to save notes:', err)
|
console.error('Failed to save notes:', err)
|
||||||
alert('Failed to save notes')
|
toast.error(t('issues.failedToSaveNotes'))
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
@@ -144,7 +136,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
|||||||
await loadIssueDetails()
|
await loadIssueDetails()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to add update:', err)
|
console.error('Failed to add update:', err)
|
||||||
alert('Failed to add update')
|
toast.error(t('issues.failedToAddUpdate'))
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
@@ -162,27 +154,26 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
|||||||
e.target.value = '' // Reset input
|
e.target.value = '' // Reset input
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to upload file:', err)
|
console.error('Failed to upload file:', err)
|
||||||
alert('Failed to upload file')
|
toast.error(t('issues.failedToUploadFile'))
|
||||||
} finally {
|
} finally {
|
||||||
setUploadingFile(false)
|
setUploadingFile(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDeleteAttachment = async (attachmentId) => {
|
const handleDeleteAttachment = async (attachmentId) => {
|
||||||
if (!confirm('Delete this attachment?')) return
|
|
||||||
try {
|
try {
|
||||||
await api.delete(`/issue-attachments/${attachmentId}`)
|
await api.delete(`/issue-attachments/${attachmentId}`)
|
||||||
await loadIssueDetails()
|
await loadIssueDetails()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to delete attachment:', err)
|
console.error('Failed to delete attachment:', err)
|
||||||
alert('Failed to delete attachment')
|
toast.error(t('issues.failedToDeleteAttachment'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const copyTrackingLink = () => {
|
const copyTrackingLink = () => {
|
||||||
const url = `${window.location.origin}/track/${issueData.tracking_token}`
|
const url = `${window.location.origin}/track/${issueData.tracking_token}`
|
||||||
navigator.clipboard.writeText(url)
|
navigator.clipboard.writeText(url)
|
||||||
alert('Tracking link copied to clipboard!')
|
toast.success(t('issues.trackingLinkCopied'))
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatDate = (dateStr) => {
|
const formatDate = (dateStr) => {
|
||||||
@@ -283,6 +274,33 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</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 */}
|
{/* Brand */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-semibold text-text-primary mb-2">Brand</label>
|
<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
|
Download
|
||||||
</a>
|
</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" />
|
<Trash2 className="w-4 h-4 text-red-600" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -579,6 +597,19 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
|||||||
</div>
|
</div>
|
||||||
</Modal>
|
</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 StatusBadge from './StatusBadge'
|
||||||
import { PlatformIcons } from './PlatformIcon'
|
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 { t } = useLanguage()
|
||||||
const { getBrandName } = useContext(AppContext)
|
const { getBrandName } = useContext(AppContext)
|
||||||
const brandName = getBrandName(post.brand_id || post.brandId) || post.brand_name || post.brand
|
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
|
// Table row view
|
||||||
return (
|
return (
|
||||||
<tr onClick={onClick} className="hover:bg-surface-secondary cursor-pointer group">
|
<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">
|
<td className="px-4 py-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="shrink-0">
|
<div className="shrink-0">
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
|
|||||||
if (!postId) return
|
if (!postId) return
|
||||||
try {
|
try {
|
||||||
const data = await api.get(`/posts/${postId}/attachments`)
|
const data = await api.get(`/posts/${postId}/attachments`)
|
||||||
setAttachments(Array.isArray(data) ? data : (data.data || []))
|
setAttachments(Array.isArray(data) ? data : [])
|
||||||
} catch {
|
} catch {
|
||||||
setAttachments([])
|
setAttachments([])
|
||||||
}
|
}
|
||||||
@@ -163,7 +163,7 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
|
|||||||
const openAssetPicker = async () => {
|
const openAssetPicker = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await api.get('/assets')
|
const data = await api.get('/assets')
|
||||||
setAvailableAssets(Array.isArray(data) ? data : (data.data || []))
|
setAvailableAssets(Array.isArray(data) ? data : [])
|
||||||
} catch {
|
} catch {
|
||||||
setAvailableAssets([])
|
setAvailableAssets([])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react'
|
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 { PRIORITY_CONFIG } from '../utils/api'
|
||||||
import { useLanguage } from '../i18n/LanguageContext'
|
import { useLanguage } from '../i18n/LanguageContext'
|
||||||
|
|
||||||
@@ -27,6 +27,18 @@ function getMonthData(year, month) {
|
|||||||
return cells
|
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) {
|
function dateKey(d) {
|
||||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
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 today = new Date()
|
||||||
const [year, setYear] = useState(today.getFullYear())
|
const [year, setYear] = useState(today.getFullYear())
|
||||||
const [month, setMonth] = useState(today.getMonth())
|
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)
|
const todayKey = dateKey(today)
|
||||||
|
|
||||||
// Group tasks by due_date
|
// Group tasks by due_date
|
||||||
@@ -62,9 +78,22 @@ export default function TaskCalendarView({ tasks, onTaskClick }) {
|
|||||||
if (month === 11) { setMonth(0); setYear(y => y + 1) }
|
if (month === 11) { setMonth(0); setYear(y => y + 1) }
|
||||||
else setMonth(m => m + 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 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 getPillColor = (task) => {
|
||||||
const p = task.priority || 'medium'
|
const p = task.priority || 'medium'
|
||||||
@@ -81,17 +110,37 @@ export default function TaskCalendarView({ tasks, onTaskClick }) {
|
|||||||
{/* Nav */}
|
{/* Nav */}
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div className="flex items-center gap-2">
|
<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" />
|
<ChevronLeft className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<h3 className="text-sm font-semibold text-text-primary min-w-[150px] text-center">{monthLabel}</h3>
|
<h3 className="text-sm font-semibold text-text-primary min-w-[180px] text-center">
|
||||||
<button onClick={nextMonth} className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors">
|
{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" />
|
<ChevronRight className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
<div className="flex items-center gap-2">
|
||||||
{t('tasks.today')}
|
<div className="flex bg-surface-tertiary rounded-lg p-0.5">
|
||||||
</button>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Day headers */}
|
{/* Day headers */}
|
||||||
@@ -112,7 +161,7 @@ export default function TaskCalendarView({ tasks, onTaskClick }) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={i}
|
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'
|
cell.current ? 'bg-white' : 'bg-surface-secondary/50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -122,7 +171,7 @@ export default function TaskCalendarView({ tasks, onTaskClick }) {
|
|||||||
{cell.day}
|
{cell.day}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{dayTasks.slice(0, 3).map(task => (
|
{dayTasks.slice(0, calView === 'week' ? 10 : 3).map(task => (
|
||||||
<button
|
<button
|
||||||
key={task._id || task.id}
|
key={task._id || task.id}
|
||||||
onClick={() => onTaskClick(task)}
|
onClick={() => onTaskClick(task)}
|
||||||
@@ -134,9 +183,9 @@ export default function TaskCalendarView({ tasks, onTaskClick }) {
|
|||||||
{task.title}
|
{task.title}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
{dayTasks.length > 3 && (
|
{dayTasks.length > (calView === 'week' ? 10 : 3) && (
|
||||||
<div className="text-[9px] text-text-tertiary text-center font-medium">
|
<div className="text-[9px] text-text-tertiary text-center font-medium">
|
||||||
+{dayTasks.length - 3} more
|
+{dayTasks.length - (calView === 'week' ? 10 : 3)} more
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ export default function TaskDetailPanel({ task, onClose, onSave, onDelete, proje
|
|||||||
if (!taskId) return
|
if (!taskId) return
|
||||||
try {
|
try {
|
||||||
const data = await api.get(`/tasks/${taskId}/attachments`)
|
const data = await api.get(`/tasks/${taskId}/attachments`)
|
||||||
setAttachments(Array.isArray(data) ? data : (data.data || []))
|
setAttachments(Array.isArray(data) ? data : [])
|
||||||
} catch {
|
} catch {
|
||||||
setAttachments([])
|
setAttachments([])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -486,5 +486,162 @@
|
|||||||
"artefacts.sortRecentlyUpdated": "آخر تحديث",
|
"artefacts.sortRecentlyUpdated": "آخر تحديث",
|
||||||
"artefacts.sortNewest": "الأحدث أولاً",
|
"artefacts.sortNewest": "الأحدث أولاً",
|
||||||
"artefacts.sortOldest": "الأقدم أولاً",
|
"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.sortRecentlyUpdated": "Recently Updated",
|
||||||
"artefacts.sortNewest": "Newest First",
|
"artefacts.sortNewest": "Newest First",
|
||||||
"artefacts.sortOldest": "Oldest 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 { api } from '../utils/api'
|
||||||
import AssetCard from '../components/AssetCard'
|
import AssetCard from '../components/AssetCard'
|
||||||
import Modal from '../components/Modal'
|
import Modal from '../components/Modal'
|
||||||
|
import BulkSelectBar from '../components/BulkSelectBar'
|
||||||
import CommentsSection from '../components/CommentsSection'
|
import CommentsSection from '../components/CommentsSection'
|
||||||
import { SkeletonAssetGrid } from '../components/SkeletonLoader'
|
import { SkeletonAssetGrid } from '../components/SkeletonLoader'
|
||||||
|
import { useLanguage } from '../i18n/LanguageContext'
|
||||||
|
import { useToast } from '../components/ToastContainer'
|
||||||
|
|
||||||
export default function Assets() {
|
export default function Assets() {
|
||||||
|
const { t } = useLanguage()
|
||||||
|
const toast = useToast()
|
||||||
const [assets, setAssets] = useState([])
|
const [assets, setAssets] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [filters, setFilters] = useState({ brand: '', tag: '', folder: '', search: '' })
|
const [filters, setFilters] = useState({ brand: '', tag: '', folder: '', search: '' })
|
||||||
@@ -18,13 +23,15 @@ export default function Assets() {
|
|||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||||
const [assetToDelete, setAssetToDelete] = useState(null)
|
const [assetToDelete, setAssetToDelete] = useState(null)
|
||||||
const fileRef = useRef(null)
|
const fileRef = useRef(null)
|
||||||
|
const [selectedIds, setSelectedIds] = useState(new Set())
|
||||||
|
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false)
|
||||||
|
|
||||||
useEffect(() => { loadAssets() }, [])
|
useEffect(() => { loadAssets() }, [])
|
||||||
|
|
||||||
const loadAssets = async () => {
|
const loadAssets = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await api.get('/assets')
|
const res = await api.get('/assets')
|
||||||
const assetsData = res.data || res || []
|
const assetsData = Array.isArray(res) ? res : []
|
||||||
// Map assets to include URL for thumbnails
|
// Map assets to include URL for thumbnails
|
||||||
const assetsWithUrls = assetsData.map(asset => ({
|
const assetsWithUrls = assetsData.map(asset => ({
|
||||||
...asset,
|
...asset,
|
||||||
@@ -91,7 +98,7 @@ export default function Assets() {
|
|||||||
setUploadProgress(0)
|
setUploadProgress(0)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Upload failed:', err)
|
console.error('Upload failed:', err)
|
||||||
alert('Upload failed: ' + err.message)
|
toast.error(t('assets.uploadFailed') + ': ' + err.message)
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false)
|
setUploading(false)
|
||||||
}
|
}
|
||||||
@@ -111,10 +118,36 @@ export default function Assets() {
|
|||||||
loadAssets()
|
loadAssets()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Delete failed:', 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) => {
|
const handleDrop = (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setDragOver(false)
|
setDragOver(false)
|
||||||
@@ -212,6 +245,10 @@ export default function Assets() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{selectedIds.size > 0 && (
|
||||||
|
<BulkSelectBar selectedCount={selectedIds.size} onDelete={() => setShowBulkDeleteConfirm(true)} onClear={() => setSelectedIds(new Set())} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Asset grid */}
|
{/* Asset grid */}
|
||||||
{filteredAssets.length === 0 ? (
|
{filteredAssets.length === 0 ? (
|
||||||
<div className="py-20 text-center">
|
<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">
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4 stagger-children">
|
||||||
{filteredAssets.map(asset => (
|
{filteredAssets.map(asset => (
|
||||||
<div key={asset._id || asset.id}>
|
<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} />
|
<AssetCard asset={asset} onClick={setSelectedAsset} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -343,6 +383,18 @@ export default function Assets() {
|
|||||||
)}
|
)}
|
||||||
</Modal>
|
</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 */}
|
{/* Delete Asset Confirmation */}
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={showDeleteConfirm}
|
isOpen={showDeleteConfirm}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export default function Brands() {
|
|||||||
const loadBrands = async () => {
|
const loadBrands = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await api.get('/brands')
|
const data = await api.get('/brands')
|
||||||
setBrands(Array.isArray(data) ? data : (data.data || []))
|
setBrands(Array.isArray(data) ? data : [])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load brands:', err)
|
console.error('Failed to load brands:', err)
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export default function CampaignDetail() {
|
|||||||
|
|
||||||
useEffect(() => { loadAll() }, [id])
|
useEffect(() => { loadAll() }, [id])
|
||||||
useEffect(() => {
|
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 () => {
|
const loadAll = async () => {
|
||||||
@@ -82,10 +82,10 @@ export default function CampaignDetail() {
|
|||||||
api.get(`/campaigns/${id}/posts`),
|
api.get(`/campaigns/${id}/posts`),
|
||||||
api.get(`/campaigns/${id}/assignments`),
|
api.get(`/campaigns/${id}/assignments`),
|
||||||
])
|
])
|
||||||
setCampaign(campRes.data || campRes || null)
|
setCampaign(campRes)
|
||||||
setTracks(tracksRes.data || tracksRes || [])
|
setTracks(Array.isArray(tracksRes) ? tracksRes : [])
|
||||||
setPosts(postsRes.data || postsRes || [])
|
setPosts(Array.isArray(postsRes) ? postsRes : [])
|
||||||
setAssignments(Array.isArray(assignRes) ? assignRes : (assignRes.data || []))
|
setAssignments(Array.isArray(assignRes) ? assignRes : [])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load campaign:', err)
|
console.error('Failed to load campaign:', err)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -96,7 +96,7 @@ export default function CampaignDetail() {
|
|||||||
const loadUsersForAssign = async () => {
|
const loadUsersForAssign = async () => {
|
||||||
try {
|
try {
|
||||||
const users = await api.get('/users/team?all=true')
|
const users = await api.get('/users/team?all=true')
|
||||||
setAllUsers(Array.isArray(users) ? users : (users.data || []))
|
setAllUsers(Array.isArray(users) ? users : [])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load users:', err)
|
console.error('Failed to load users:', err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export default function Campaigns() {
|
|||||||
const loadCampaigns = async () => {
|
const loadCampaigns = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await api.get('/campaigns')
|
const res = await api.get('/campaigns')
|
||||||
setCampaigns(res.data || res || [])
|
setCampaigns(Array.isArray(res) ? res : [])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load campaigns:', err)
|
console.error('Failed to load campaigns:', err)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -202,6 +202,7 @@ export default function Campaigns() {
|
|||||||
status: campaign.status,
|
status: campaign.status,
|
||||||
assigneeName: campaign.brandName || campaign.brand_name,
|
assigneeName: campaign.brandName || campaign.brand_name,
|
||||||
tags: campaign.platforms || [],
|
tags: campaign.platforms || [],
|
||||||
|
color: campaign.color,
|
||||||
})}
|
})}
|
||||||
onDateChange={async (campaignId, { startDate, endDate }) => {
|
onDateChange={async (campaignId, { startDate, endDate }) => {
|
||||||
try {
|
try {
|
||||||
@@ -212,6 +213,15 @@ export default function Campaigns() {
|
|||||||
loadCampaigns()
|
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) => {
|
onItemClick={(campaign) => {
|
||||||
navigate(`/campaigns/${campaign._id || campaign.id}`)
|
navigate(`/campaigns/${campaign._id || campaign.id}`)
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -287,15 +287,15 @@ export default function Dashboard() {
|
|||||||
const fetches = []
|
const fetches = []
|
||||||
// Only fetch data for modules the user has access to
|
// Only fetch data for modules the user has access to
|
||||||
if (hasModule('marketing')) {
|
if (hasModule('marketing')) {
|
||||||
fetches.push(api.get('/posts?limit=50&sort=-createdAt').then(r => ({ key: 'posts', data: 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: r.data || r || [] })))
|
fetches.push(api.get('/campaigns').then(r => ({ key: 'campaigns', data: Array.isArray(r) ? r : [] })))
|
||||||
}
|
}
|
||||||
if (hasModule('projects')) {
|
if (hasModule('projects')) {
|
||||||
fetches.push(api.get('/tasks').then(r => ({ key: 'tasks', 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: r.data || r || [] })))
|
fetches.push(api.get('/projects').then(r => ({ key: 'projects', data: Array.isArray(r) ? r : [] })))
|
||||||
}
|
}
|
||||||
if (hasModule('finance')) {
|
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)
|
const results = await Promise.allSettled(fetches)
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { useState, useEffect, useContext, useMemo } from 'react'
|
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 { AppContext } from '../App'
|
||||||
import { api } from '../utils/api'
|
import { api, STATUS_CONFIG, PRIORITY_CONFIG } from '../utils/api'
|
||||||
import { useLanguage } from '../i18n/LanguageContext'
|
import { useLanguage } from '../i18n/LanguageContext'
|
||||||
import { useToast } from '../components/ToastContainer'
|
import { useToast } from '../components/ToastContainer'
|
||||||
import IssueDetailPanel from '../components/IssueDetailPanel'
|
import IssueDetailPanel from '../components/IssueDetailPanel'
|
||||||
import IssueCard from '../components/IssueCard'
|
import IssueCard from '../components/IssueCard'
|
||||||
import EmptyState from '../components/EmptyState'
|
import EmptyState from '../components/EmptyState'
|
||||||
import { SkeletonTable, SkeletonKanbanBoard } from '../components/SkeletonLoader'
|
import { SkeletonTable, SkeletonKanbanBoard } from '../components/SkeletonLoader'
|
||||||
|
import BulkSelectBar from '../components/BulkSelectBar'
|
||||||
|
import Modal from '../components/Modal'
|
||||||
|
|
||||||
const TYPE_OPTIONS = [
|
const TYPE_OPTIONS = [
|
||||||
{ value: 'request', label: 'Request' },
|
{ value: 'request', label: 'Request' },
|
||||||
@@ -17,19 +19,13 @@ const TYPE_OPTIONS = [
|
|||||||
{ value: 'other', label: 'Other' },
|
{ value: 'other', label: 'Other' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const PRIORITY_CONFIG = {
|
// Issue-specific status order for the kanban board
|
||||||
low: { label: 'Low', bg: 'bg-surface-tertiary', text: 'text-text-secondary', dot: 'bg-text-tertiary' },
|
const ISSUE_STATUS_CONFIG = {
|
||||||
medium: { label: 'Medium', bg: 'bg-blue-100', text: 'text-blue-700', dot: 'bg-blue-500' },
|
new: STATUS_CONFIG.new,
|
||||||
high: { label: 'High', bg: 'bg-orange-100', text: 'text-orange-700', dot: 'bg-orange-500' },
|
acknowledged: STATUS_CONFIG.acknowledged,
|
||||||
urgent: { label: 'Urgent', bg: 'bg-red-100', text: 'text-red-700', dot: 'bg-red-500' },
|
in_progress: STATUS_CONFIG.in_progress,
|
||||||
}
|
resolved: STATUS_CONFIG.resolved,
|
||||||
|
declined: STATUS_CONFIG.declined,
|
||||||
const STATUS_CONFIG = {
|
|
||||||
new: { label: 'New', bg: 'bg-purple-100', text: 'text-purple-700', dot: 'bg-purple-500' },
|
|
||||||
acknowledged: { label: 'Acknowledged', bg: 'bg-blue-100', text: 'text-blue-700', dot: 'bg-blue-500' },
|
|
||||||
in_progress: { label: 'In Progress', bg: 'bg-amber-100', text: 'text-amber-700', dot: 'bg-amber-500' },
|
|
||||||
resolved: { label: 'Resolved', bg: 'bg-emerald-100', text: 'text-emerald-700', dot: 'bg-emerald-500' },
|
|
||||||
declined: { label: 'Declined', bg: 'bg-surface-tertiary', text: 'text-text-secondary', dot: 'bg-text-tertiary' },
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_ORDER = ['new', 'acknowledged', 'in_progress', 'resolved', 'declined']
|
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() {
|
export default function Issues() {
|
||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { brands } = useContext(AppContext)
|
const { brands, teams } = useContext(AppContext)
|
||||||
|
|
||||||
const [issues, setIssues] = useState([])
|
const [issues, setIssues] = useState([])
|
||||||
const [counts, setCounts] = useState({})
|
const [counts, setCounts] = useState({})
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [selectedIssue, setSelectedIssue] = useState(null)
|
const [selectedIssue, setSelectedIssue] = useState(null)
|
||||||
const [searchTerm, setSearchTerm] = useState('')
|
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 [categories, setCategories] = useState([])
|
||||||
const [teamMembers, setTeamMembers] = useState([])
|
const [teamMembers, setTeamMembers] = useState([])
|
||||||
|
|
||||||
@@ -59,6 +55,9 @@ export default function Issues() {
|
|||||||
const [sortBy, setSortBy] = useState('created_at')
|
const [sortBy, setSortBy] = useState('created_at')
|
||||||
const [sortDir, setSortDir] = useState('desc')
|
const [sortDir, setSortDir] = useState('desc')
|
||||||
|
|
||||||
|
const [selectedIds, setSelectedIds] = useState(new Set())
|
||||||
|
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false)
|
||||||
|
|
||||||
useEffect(() => { loadData() }, [])
|
useEffect(() => { loadData() }, [])
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
@@ -72,7 +71,7 @@ export default function Issues() {
|
|||||||
setIssues(issuesData.issues || [])
|
setIssues(issuesData.issues || [])
|
||||||
setCounts(issuesData.counts || {})
|
setCounts(issuesData.counts || {})
|
||||||
setCategories(categoriesData || [])
|
setCategories(categoriesData || [])
|
||||||
setTeamMembers(Array.isArray(teamData) ? teamData : teamData.data || [])
|
setTeamMembers(Array.isArray(teamData) ? teamData : [])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load issues:', err)
|
console.error('Failed to load issues:', err)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -97,6 +96,7 @@ export default function Issues() {
|
|||||||
if (filters.type) filtered = filtered.filter(i => i.type === filters.type)
|
if (filters.type) filtered = filtered.filter(i => i.type === filters.type)
|
||||||
if (filters.priority) filtered = filtered.filter(i => i.priority === filters.priority)
|
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.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
|
return filtered
|
||||||
}, [issues, searchTerm, filters])
|
}, [issues, searchTerm, filters])
|
||||||
|
|
||||||
@@ -121,7 +121,7 @@ export default function Issues() {
|
|||||||
|
|
||||||
const updateFilter = (key, value) => setFilters(f => ({ ...f, [key]: value }))
|
const updateFilter = (key, value) => setFilters(f => ({ ...f, [key]: value }))
|
||||||
const clearFilters = () => {
|
const clearFilters = () => {
|
||||||
setFilters({ status: '', category: '', type: '', priority: '', brand: '' })
|
setFilters({ status: '', category: '', type: '', priority: '', brand: '', team: '' })
|
||||||
setSearchTerm('')
|
setSearchTerm('')
|
||||||
}
|
}
|
||||||
const hasActiveFilters = Object.values(filters).some(Boolean) || searchTerm
|
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) => {
|
const handleDragStart = (e, issue) => {
|
||||||
setDraggedIssue(issue)
|
setDraggedIssue(issue)
|
||||||
e.dataTransfer.effectAllowed = 'move'
|
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>
|
<p className="text-text-secondary mt-1">Track and manage issue submissions</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* View switcher */}
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex items-center bg-surface-tertiary rounded-lg p-0.5">
|
<button
|
||||||
{[
|
onClick={copyPublicLink}
|
||||||
{ mode: 'board', icon: LayoutGrid, label: t('issues.board') },
|
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"
|
||||||
{ mode: 'list', icon: List, label: t('issues.list') },
|
title={t('issues.copyPublicLink')}
|
||||||
].map(({ mode, icon: Icon, label }) => (
|
>
|
||||||
<button
|
<Link2 className="w-3.5 h-3.5" />
|
||||||
key={mode}
|
{t('issues.copyPublicLink')}
|
||||||
onClick={() => setViewMode(mode)}
|
</button>
|
||||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
|
|
||||||
viewMode === mode
|
{/* View switcher */}
|
||||||
? 'bg-white text-text-primary shadow-sm'
|
<div className="flex items-center bg-surface-tertiary rounded-lg p-0.5">
|
||||||
: 'text-text-tertiary hover:text-text-secondary'
|
{[
|
||||||
}`}
|
{ mode: 'board', icon: LayoutGrid, label: t('issues.board') },
|
||||||
>
|
{ mode: 'list', icon: List, label: t('issues.list') },
|
||||||
<Icon className="w-3.5 h-3.5" />
|
].map(({ mode, icon: Icon, label }) => (
|
||||||
{label}
|
<button
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status Counts */}
|
{/* Status Counts */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 stagger-children">
|
<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
|
<div
|
||||||
key={status}
|
key={status}
|
||||||
className={`bg-surface rounded-lg border p-4 cursor-pointer hover:shadow-sm transition-all ${
|
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"
|
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>
|
<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>
|
<option key={key} value={key}>{config.label}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -303,6 +348,17 @@ export default function Issues() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</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
|
<select
|
||||||
value={filters.priority}
|
value={filters.priority}
|
||||||
onChange={e => updateFilter('priority', e.target.value)}
|
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">
|
<div className="flex gap-4 overflow-x-auto pb-4">
|
||||||
{STATUS_ORDER.map(status => {
|
{STATUS_ORDER.map(status => {
|
||||||
const config = STATUS_CONFIG[status]
|
const config = ISSUE_STATUS_CONFIG[status]
|
||||||
const columnIssues = filteredIssues.filter(i => i.status === status)
|
const columnIssues = filteredIssues.filter(i => i.status === status)
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -391,10 +447,20 @@ export default function Issues() {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-surface rounded-lg border border-border overflow-hidden">
|
<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">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead className="bg-surface-secondary border-b border-border">
|
<thead className="bg-surface-secondary border-b border-border">
|
||||||
<tr>
|
<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')}>
|
<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" />
|
Title <SortIcon col="title" />
|
||||||
</th>
|
</th>
|
||||||
@@ -424,6 +490,9 @@ export default function Issues() {
|
|||||||
onClick={() => setSelectedIssue(issue)}
|
onClick={() => setSelectedIssue(issue)}
|
||||||
className="hover:bg-surface-secondary cursor-pointer transition-colors"
|
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 font-medium text-text-primary">{issue.title}</td>
|
||||||
<td className="px-4 py-3 text-sm text-text-secondary">
|
<td className="px-4 py-3 text-sm text-text-secondary">
|
||||||
<div>{issue.submitter_name}</div>
|
<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 */}
|
{/* Detail Panel */}
|
||||||
{selectedIssue && (
|
{selectedIssue && (
|
||||||
<IssueDetailPanel
|
<IssueDetailPanel
|
||||||
@@ -466,6 +548,7 @@ export default function Issues() {
|
|||||||
onClose={() => setSelectedIssue(null)}
|
onClose={() => setSelectedIssue(null)}
|
||||||
onUpdate={loadData}
|
onUpdate={loadData}
|
||||||
teamMembers={teamMembers}
|
teamMembers={teamMembers}
|
||||||
|
teams={teams}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export default function Login() {
|
|||||||
await login(email, password)
|
await login(email, password)
|
||||||
navigate('/')
|
navigate('/')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message || 'Invalid email or password')
|
setError(err.message || t('login.invalidCredentials'))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -44,7 +44,7 @@ export default function Login() {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError('')
|
setError('')
|
||||||
if (setupPassword !== setupConfirm) {
|
if (setupPassword !== setupConfirm) {
|
||||||
setError('Passwords do not match')
|
setError(t('login.passwordMismatch'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -55,7 +55,7 @@ export default function Login() {
|
|||||||
setNeedsSetup(false)
|
setNeedsSetup(false)
|
||||||
setEmail(setupEmail)
|
setEmail(setupEmail)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message || 'Setup failed')
|
setError(err.message || t('login.setupFailed'))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -78,10 +78,10 @@ export default function Login() {
|
|||||||
<Megaphone className="w-8 h-8 text-white" />
|
<Megaphone className="w-8 h-8 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-3xl font-bold text-white mb-2">
|
<h1 className="text-3xl font-bold text-white mb-2">
|
||||||
{needsSetup ? 'Initial Setup' : t('login.title')}
|
{needsSetup ? t('login.initialSetup') : t('login.title')}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-slate-400">
|
<p className="text-slate-400">
|
||||||
{needsSetup ? 'Create your superadmin account to get started' : t('login.subtitle')}
|
{needsSetup ? t('login.initialSetupDesc') : t('login.subtitle')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -89,7 +89,7 @@ export default function Login() {
|
|||||||
{setupDone && (
|
{setupDone && (
|
||||||
<div className="flex items-center gap-2 p-3 mb-4 bg-green-500/10 border border-green-500/30 rounded-lg">
|
<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" />
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -99,7 +99,7 @@ export default function Login() {
|
|||||||
<form onSubmit={handleSetup} className="space-y-5">
|
<form onSubmit={handleSetup} className="space-y-5">
|
||||||
{/* Name */}
|
{/* Name */}
|
||||||
<div>
|
<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">
|
<div className="relative">
|
||||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
||||||
<input
|
<input
|
||||||
@@ -107,7 +107,7 @@ export default function Login() {
|
|||||||
value={setupName}
|
value={setupName}
|
||||||
onChange={(e) => setSetupName(e.target.value)}
|
onChange={(e) => setSetupName(e.target.value)}
|
||||||
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
className="w-full 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
|
required
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
@@ -116,7 +116,7 @@ export default function Login() {
|
|||||||
|
|
||||||
{/* Email */}
|
{/* Email */}
|
||||||
<div>
|
<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">
|
<div className="relative">
|
||||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
||||||
<input
|
<input
|
||||||
@@ -133,7 +133,7 @@ export default function Login() {
|
|||||||
|
|
||||||
{/* Password */}
|
{/* Password */}
|
||||||
<div>
|
<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">
|
<div className="relative">
|
||||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
||||||
<input
|
<input
|
||||||
@@ -141,7 +141,7 @@ export default function Login() {
|
|||||||
value={setupPassword}
|
value={setupPassword}
|
||||||
onChange={(e) => setSetupPassword(e.target.value)}
|
onChange={(e) => setSetupPassword(e.target.value)}
|
||||||
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
className="w-full 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
|
required
|
||||||
minLength={6}
|
minLength={6}
|
||||||
/>
|
/>
|
||||||
@@ -150,7 +150,7 @@ export default function Login() {
|
|||||||
|
|
||||||
{/* Confirm Password */}
|
{/* Confirm Password */}
|
||||||
<div>
|
<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">
|
<div className="relative">
|
||||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
||||||
<input
|
<input
|
||||||
@@ -158,7 +158,7 @@ export default function Login() {
|
|||||||
value={setupConfirm}
|
value={setupConfirm}
|
||||||
onChange={(e) => setSetupConfirm(e.target.value)}
|
onChange={(e) => setSetupConfirm(e.target.value)}
|
||||||
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
className="w-full 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
|
required
|
||||||
minLength={6}
|
minLength={6}
|
||||||
/>
|
/>
|
||||||
@@ -182,10 +182,10 @@ export default function Login() {
|
|||||||
{loading ? (
|
{loading ? (
|
||||||
<span className="flex items-center justify-center gap-2">
|
<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" />
|
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||||
Creating account...
|
{t('login.creatingAccount')}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
'Create Superadmin Account'
|
t('login.createAccount')
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useContext } from 'react'
|
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 { AppContext } from '../App'
|
||||||
import { useLanguage } from '../i18n/LanguageContext'
|
import { useLanguage } from '../i18n/LanguageContext'
|
||||||
import { api, PLATFORMS } from '../utils/api'
|
import { api, PLATFORMS } from '../utils/api'
|
||||||
@@ -39,6 +39,19 @@ function getMonthData(year, month) {
|
|||||||
return cells
|
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) {
|
function dateKey(d) {
|
||||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
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 [loading, setLoading] = useState(true)
|
||||||
const [filters, setFilters] = useState({ brand: '', platform: '', status: '' })
|
const [filters, setFilters] = useState({ brand: '', platform: '', status: '' })
|
||||||
const [selectedPost, setSelectedPost] = useState(null)
|
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(() => {
|
useEffect(() => {
|
||||||
loadPosts()
|
loadPosts()
|
||||||
@@ -61,7 +78,7 @@ export default function PostCalendar() {
|
|||||||
const loadPosts = async () => {
|
const loadPosts = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await api.get('/posts')
|
const res = await api.get('/posts')
|
||||||
setPosts(res.data || res || [])
|
setPosts(Array.isArray(res) ? res : [])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load posts:', err)
|
console.error('Failed to load posts:', err)
|
||||||
} finally {
|
} 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)
|
const todayKey = dateKey(today)
|
||||||
|
|
||||||
// Filter posts
|
// Filter posts
|
||||||
@@ -105,9 +122,22 @@ export default function PostCalendar() {
|
|||||||
if (month === 11) { setMonth(0); setYear(y => y + 1) }
|
if (month === 11) { setMonth(0); setYear(y => y + 1) }
|
||||||
else setMonth(m => m + 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 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) => {
|
const handlePostClick = (post) => {
|
||||||
setSelectedPost(post)
|
setSelectedPost(post)
|
||||||
@@ -176,17 +206,37 @@ export default function PostCalendar() {
|
|||||||
{/* Nav */}
|
{/* Nav */}
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<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" />
|
<ChevronLeft className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
<h3 className="text-lg font-semibold text-text-primary min-w-[180px] text-center">{monthLabel}</h3>
|
<h3 className="text-lg font-semibold text-text-primary min-w-[220px] text-center">
|
||||||
<button onClick={nextMonth} className="p-2 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors">
|
{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" />
|
<ChevronRight className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={goToday} className="px-4 py-2 text-sm font-medium text-brand-primary hover:bg-brand-primary/5 rounded-lg transition-colors">
|
<div className="flex items-center gap-2">
|
||||||
Today
|
<div className="flex bg-surface-tertiary rounded-lg p-0.5">
|
||||||
</button>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Day headers */}
|
{/* Day headers */}
|
||||||
@@ -207,7 +257,7 @@ export default function PostCalendar() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={i}
|
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'
|
cell.current ? 'bg-surface' : 'bg-surface-secondary/30'
|
||||||
} ${i % 7 === 6 ? 'border-r-0' : ''}`}
|
} ${i % 7 === 6 ? 'border-r-0' : ''}`}
|
||||||
>
|
>
|
||||||
@@ -217,7 +267,7 @@ export default function PostCalendar() {
|
|||||||
{cell.day}
|
{cell.day}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{dayPosts.slice(0, 3).map(post => (
|
{dayPosts.slice(0, calView === 'week' ? 10 : 3).map(post => (
|
||||||
<button
|
<button
|
||||||
key={post.Id || post._id}
|
key={post.Id || post._id}
|
||||||
onClick={() => handlePostClick(post)}
|
onClick={() => handlePostClick(post)}
|
||||||
@@ -229,9 +279,9 @@ export default function PostCalendar() {
|
|||||||
{post.title}
|
{post.title}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
{dayPosts.length > 3 && (
|
{dayPosts.length > (calView === 'week' ? 10 : 3) && (
|
||||||
<div className="text-[9px] text-text-tertiary text-center font-medium">
|
<div className="text-[9px] text-text-tertiary text-center font-medium">
|
||||||
+{dayPosts.length - 3} more
|
+{dayPosts.length - (calView === 'week' ? 10 : 3)} more
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import PostDetailPanel from '../components/PostDetailPanel'
|
|||||||
import DatePresetPicker from '../components/DatePresetPicker'
|
import DatePresetPicker from '../components/DatePresetPicker'
|
||||||
import { SkeletonKanbanBoard, SkeletonTable } from '../components/SkeletonLoader'
|
import { SkeletonKanbanBoard, SkeletonTable } from '../components/SkeletonLoader'
|
||||||
import EmptyState from '../components/EmptyState'
|
import EmptyState from '../components/EmptyState'
|
||||||
|
import BulkSelectBar from '../components/BulkSelectBar'
|
||||||
|
import Modal from '../components/Modal'
|
||||||
import { useToast } from '../components/ToastContainer'
|
import { useToast } from '../components/ToastContainer'
|
||||||
|
|
||||||
const EMPTY_POST = {
|
const EMPTY_POST = {
|
||||||
@@ -32,16 +34,18 @@ export default function PostProduction() {
|
|||||||
const [searchTerm, setSearchTerm] = useState('')
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
const [activePreset, setActivePreset] = useState('')
|
const [activePreset, setActivePreset] = useState('')
|
||||||
const [moveError, setMoveError] = useState('')
|
const [moveError, setMoveError] = useState('')
|
||||||
|
const [selectedIds, setSelectedIds] = useState(new Set())
|
||||||
|
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadPosts()
|
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 () => {
|
const loadPosts = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await api.get('/posts')
|
const res = await api.get('/posts')
|
||||||
setPosts(res.data || res || [])
|
setPosts(Array.isArray(res) ? res : [])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load posts:', err)
|
console.error('Failed to load posts:', err)
|
||||||
} finally {
|
} 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) => {
|
const openEdit = (post) => {
|
||||||
if (!canEditResource('post', post)) {
|
if (!canEditResource('post', post)) {
|
||||||
alert('You can only edit your own posts')
|
toast.error(t('posts.canOnlyEditOwn'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setPanelPost(post)
|
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">
|
<table className="w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-border bg-surface-secondary">
|
<tr className="border-b border-border bg-surface-secondary">
|
||||||
|
<th className="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.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.brand')}</th>
|
||||||
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.status')}</th>
|
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.status')}</th>
|
||||||
@@ -256,15 +296,37 @@ export default function PostProduction() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-border-light">
|
<tbody className="divide-y divide-border-light">
|
||||||
{filteredPosts.map(post => (
|
{filteredPosts.map(post => {
|
||||||
<PostCard key={post._id} post={post} onClick={() => openEdit(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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 */}
|
{/* Post Detail Panel */}
|
||||||
{panelPost && (
|
{panelPost && (
|
||||||
<PostDetailPanel
|
<PostDetailPanel
|
||||||
|
|||||||
@@ -50,15 +50,15 @@ export default function ProjectDetail() {
|
|||||||
|
|
||||||
useEffect(() => { loadProject() }, [id])
|
useEffect(() => { loadProject() }, [id])
|
||||||
useEffect(() => {
|
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 () => {
|
const loadProject = async () => {
|
||||||
try {
|
try {
|
||||||
const proj = await api.get(`/projects/${id}`)
|
const proj = await api.get(`/projects/${id}`)
|
||||||
setProject(proj.data || proj)
|
setProject(proj)
|
||||||
const tasksRes = await api.get(`/tasks?project_id=${id}`)
|
const tasksRes = await api.get(`/tasks?project_id=${id}`)
|
||||||
setTasks(Array.isArray(tasksRes) ? tasksRes : (tasksRes.data || []))
|
setTasks(Array.isArray(tasksRes) ? tasksRes : [])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load project:', err)
|
console.error('Failed to load project:', err)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -458,7 +458,14 @@ export default function ProjectDetail() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ─── GANTT / TIMELINE VIEW ─── */}
|
{/* ─── 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 */}
|
</div>{/* end main content */}
|
||||||
|
|
||||||
{/* ─── DISCUSSION SIDEBAR ─── */}
|
{/* ─── DISCUSSION SIDEBAR ─── */}
|
||||||
@@ -576,7 +583,35 @@ function TaskKanbanCard({ task, canEdit, canDelete, onClick, onDelete, onStatusC
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── Gantt / Timeline View ──────────────────────────
|
// ─── 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) {
|
if (tasks.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-xl border border-border py-16 text-center">
|
<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())
|
const today = startOfDay(new Date())
|
||||||
|
|
||||||
// Calculate range
|
// Calculate range
|
||||||
let earliest = today
|
let earliest = addDays(today, -7)
|
||||||
let latest = addDays(today, 21)
|
let latest = addDays(today, 30)
|
||||||
tasks.forEach(t => {
|
tasks.forEach(t => {
|
||||||
const created = t.createdAt ? startOfDay(new Date(t.createdAt)) : today
|
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
|
const due = t.dueDate ? startOfDay(new Date(t.dueDate)) : null
|
||||||
if (isBefore(created, earliest)) earliest = created
|
if (isBefore(start, earliest)) earliest = addDays(start, -3)
|
||||||
if (due && isAfter(due, latest)) latest = addDays(due, 2)
|
if (isBefore(created, earliest)) earliest = addDays(created, -3)
|
||||||
|
if (due && isAfter(due, latest)) latest = addDays(due, 7)
|
||||||
})
|
})
|
||||||
if (project.dueDate) {
|
if (project.dueDate) {
|
||||||
const pd = startOfDay(new Date(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
|
const totalDays = differenceInDays(latest, earliest) + 1
|
||||||
|
|
||||||
@@ -610,7 +647,7 @@ function GanttView({ tasks, project, onEditTask }) {
|
|||||||
days.push(addDays(earliest, i))
|
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 getBarStyle = (task) => {
|
||||||
const start = task.startDate || task.start_date
|
const start = task.startDate || task.start_date
|
||||||
@@ -630,7 +667,38 @@ function GanttView({ tasks, project, onEditTask }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
<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` }}>
|
<div style={{ minWidth: `${totalDays * dayWidth + 200}px` }}>
|
||||||
{/* Day headers */}
|
{/* Day headers */}
|
||||||
<div className="flex border-b border-border bg-surface-secondary sticky top-0 z-10">
|
<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) => {
|
{days.map((day, i) => {
|
||||||
const isToday = differenceInDays(day, today) === 0
|
const isToday = differenceInDays(day, today) === 0
|
||||||
const isWeekend = day.getDay() === 0 || day.getDay() === 6
|
const isWeekend = day.getDay() === 0 || day.getDay() === 6
|
||||||
|
const isMonthStart = day.getDate() === 1
|
||||||
|
const isWeekStart = day.getDay() === 1
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
@@ -648,10 +718,17 @@ function GanttView({ tasks, project, onEditTask }) {
|
|||||||
className={`text-center py-2 border-r border-border-light text-[10px] ${
|
className={`text-center py-2 border-r border-border-light text-[10px] ${
|
||||||
isToday ? 'bg-brand-primary/10 font-bold text-brand-primary' :
|
isToday ? 'bg-brand-primary/10 font-bold text-brand-primary' :
|
||||||
isWeekend ? 'bg-surface-tertiary/50 text-text-tertiary' : 'text-text-tertiary'
|
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>
|
{dayWidth >= 30 && <div>{format(day, 'd')}</div>}
|
||||||
<div className="text-[8px] uppercase">{format(day, 'EEE')}</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>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -665,7 +742,20 @@ function GanttView({ tasks, project, onEditTask }) {
|
|||||||
return (
|
return (
|
||||||
<div key={task._id} className="flex border-b border-border-light hover:bg-surface-secondary/50 group">
|
<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-[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)}
|
<button onClick={() => onEditTask(task)}
|
||||||
className="text-xs font-medium text-text-primary truncate hover:text-brand-primary text-left">
|
className="text-xs font-medium text-text-primary truncate hover:text-brand-primary text-left">
|
||||||
{task.title}
|
{task.title}
|
||||||
@@ -681,8 +771,8 @@ function GanttView({ tasks, project, onEditTask }) {
|
|||||||
)}
|
)}
|
||||||
{/* Bar */}
|
{/* Bar */}
|
||||||
<div
|
<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`}
|
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}
|
style={{ ...barStyle, ...(task.color ? { backgroundColor: task.color } : {}) }}
|
||||||
onClick={() => onEditTask(task)}
|
onClick={() => onEditTask(task)}
|
||||||
title={`${task.title}${task.dueDate ? ` — Due ${format(new Date(task.dueDate), 'MMM d')}` : ''}`}
|
title={`${task.title}${task.dueDate ? ` — Due ${format(new Date(task.dueDate), 'MMM d')}` : ''}`}
|
||||||
/>
|
/>
|
||||||
@@ -692,6 +782,38 @@ function GanttView({ tasks, project, onEditTask }) {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export default function Projects() {
|
|||||||
const loadProjects = async () => {
|
const loadProjects = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await api.get('/projects')
|
const res = await api.get('/projects')
|
||||||
setProjects(res.data || res || [])
|
setProjects(Array.isArray(res) ? res : [])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load projects:', err)
|
console.error('Failed to load projects:', err)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -146,6 +146,7 @@ export default function Projects() {
|
|||||||
assigneeName: project.ownerName || project.owner_name,
|
assigneeName: project.ownerName || project.owner_name,
|
||||||
thumbnailUrl: project.thumbnail_url || project.thumbnailUrl,
|
thumbnailUrl: project.thumbnail_url || project.thumbnailUrl,
|
||||||
tags: [project.status, project.priority].filter(Boolean),
|
tags: [project.status, project.priority].filter(Boolean),
|
||||||
|
color: project.color,
|
||||||
})}
|
})}
|
||||||
onDateChange={async (projectId, { startDate, endDate }) => {
|
onDateChange={async (projectId, { startDate, endDate }) => {
|
||||||
try {
|
try {
|
||||||
@@ -156,6 +157,15 @@ export default function Projects() {
|
|||||||
loadProjects()
|
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) => {
|
onItemClick={(project) => {
|
||||||
navigate(`/projects/${project._id || project.id}`)
|
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 { AlertCircle, Send, CheckCircle2, Upload, X } from 'lucide-react'
|
||||||
import { api } from '../utils/api'
|
import { api } from '../utils/api'
|
||||||
import FormInput from '../components/FormInput'
|
import FormInput from '../components/FormInput'
|
||||||
|
import { useToast } from '../components/ToastContainer'
|
||||||
|
|
||||||
const TYPE_OPTIONS = [
|
const TYPE_OPTIONS = [
|
||||||
{ value: 'request', label: 'Request' },
|
{ value: 'request', label: 'Request' },
|
||||||
@@ -19,6 +20,12 @@ const PRIORITY_OPTIONS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export default function PublicIssueSubmit() {
|
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({
|
const [form, setForm] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
email: '',
|
email: '',
|
||||||
@@ -28,12 +35,20 @@ export default function PublicIssueSubmit() {
|
|||||||
priority: 'medium',
|
priority: 'medium',
|
||||||
title: '',
|
title: '',
|
||||||
description: '',
|
description: '',
|
||||||
|
team_id: teamParam || '',
|
||||||
})
|
})
|
||||||
const [file, setFile] = useState(null)
|
const [file, setFile] = useState(null)
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const [submitted, setSubmitted] = useState(false)
|
const [submitted, setSubmitted] = useState(false)
|
||||||
const [trackingToken, setTrackingToken] = useState('')
|
const [trackingToken, setTrackingToken] = useState('')
|
||||||
const [errors, setErrors] = 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) => {
|
const updateForm = (field, value) => {
|
||||||
setForm((f) => ({ ...f, [field]: value }))
|
setForm((f) => ({ ...f, [field]: value }))
|
||||||
@@ -75,6 +90,9 @@ export default function PublicIssueSubmit() {
|
|||||||
formData.append('priority', form.priority)
|
formData.append('priority', form.priority)
|
||||||
formData.append('title', form.title)
|
formData.append('title', form.title)
|
||||||
formData.append('description', form.description)
|
formData.append('description', form.description)
|
||||||
|
if (form.team_id) {
|
||||||
|
formData.append('team_id', form.team_id)
|
||||||
|
}
|
||||||
if (file) {
|
if (file) {
|
||||||
formData.append('file', file)
|
formData.append('file', file)
|
||||||
}
|
}
|
||||||
@@ -84,7 +102,7 @@ export default function PublicIssueSubmit() {
|
|||||||
setSubmitted(true)
|
setSubmitted(true)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Submit error:', err)
|
console.error('Submit error:', err)
|
||||||
alert('Failed to submit issue. Please try again.')
|
toast.error('Failed to submit issue. Please try again.')
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false)
|
setSubmitting(false)
|
||||||
}
|
}
|
||||||
@@ -116,7 +134,7 @@ export default function PublicIssueSubmit() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigator.clipboard.writeText(trackingUrl)
|
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"
|
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',
|
priority: 'medium',
|
||||||
title: '',
|
title: '',
|
||||||
description: '',
|
description: '',
|
||||||
|
team_id: teamParam || '',
|
||||||
})
|
})
|
||||||
setFile(null)
|
setFile(null)
|
||||||
}}
|
}}
|
||||||
@@ -205,10 +224,27 @@ export default function PublicIssueSubmit() {
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Issue Details */}
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-sm font-semibold text-text-primary mb-2">Issue Details</h2>
|
<h2 className="text-sm font-semibold text-text-primary mb-2">Issue Details</h2>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1.5">
|
<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 { useParams } from 'react-router-dom'
|
||||||
import { AlertCircle, Clock, CheckCircle2, XCircle, MessageCircle, Upload, FileText, Send } from 'lucide-react'
|
import { AlertCircle, Clock, CheckCircle2, XCircle, MessageCircle, Upload, FileText, Send } from 'lucide-react'
|
||||||
import { api } from '../utils/api'
|
import { api } from '../utils/api'
|
||||||
|
import { useToast } from '../components/ToastContainer'
|
||||||
|
|
||||||
const STATUS_CONFIG = {
|
const STATUS_CONFIG = {
|
||||||
new: { label: 'New', bg: 'bg-purple-100', text: 'text-purple-700', dot: 'bg-purple-500', icon: AlertCircle },
|
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() {
|
export default function PublicIssueTracker() {
|
||||||
const { token } = useParams()
|
const { token } = useParams()
|
||||||
|
const toast = useToast()
|
||||||
const [issue, setIssue] = useState(null)
|
const [issue, setIssue] = useState(null)
|
||||||
const [updates, setUpdates] = useState([])
|
const [updates, setUpdates] = useState([])
|
||||||
const [attachments, setAttachments] = useState([])
|
const [attachments, setAttachments] = useState([])
|
||||||
@@ -68,7 +70,7 @@ export default function PublicIssueTracker() {
|
|||||||
await loadIssue()
|
await loadIssue()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to add comment:', err)
|
console.error('Failed to add comment:', err)
|
||||||
alert('Failed to add comment')
|
toast.error('Failed to add comment')
|
||||||
} finally {
|
} finally {
|
||||||
setSubmittingComment(false)
|
setSubmittingComment(false)
|
||||||
}
|
}
|
||||||
@@ -88,7 +90,7 @@ export default function PublicIssueTracker() {
|
|||||||
e.target.value = '' // Reset input
|
e.target.value = '' // Reset input
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to upload file:', err)
|
console.error('Failed to upload file:', err)
|
||||||
alert('Failed to upload file')
|
toast.error('Failed to upload file')
|
||||||
} finally {
|
} finally {
|
||||||
setUploadingFile(false)
|
setUploadingFile(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useParams } from 'react-router-dom'
|
import { useParams } from 'react-router-dom'
|
||||||
import { CheckCircle, XCircle, AlertCircle, FileText, Image as ImageIcon, Film, Sparkles, Globe, User } from 'lucide-react'
|
import { CheckCircle, XCircle, AlertCircle, FileText, Image as ImageIcon, Film, Sparkles, Globe, User } from 'lucide-react'
|
||||||
|
import { useLanguage } from '../i18n/LanguageContext'
|
||||||
|
import { useToast } from '../components/ToastContainer'
|
||||||
|
import Modal from '../components/Modal'
|
||||||
|
|
||||||
const STATUS_ICONS = {
|
const STATUS_ICONS = {
|
||||||
copy: FileText,
|
copy: FileText,
|
||||||
@@ -11,6 +14,8 @@ const STATUS_ICONS = {
|
|||||||
|
|
||||||
export default function PublicReview() {
|
export default function PublicReview() {
|
||||||
const { token } = useParams()
|
const { token } = useParams()
|
||||||
|
const { t } = useLanguage()
|
||||||
|
const toast = useToast()
|
||||||
const [artefact, setArtefact] = useState(null)
|
const [artefact, setArtefact] = useState(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
@@ -19,6 +24,7 @@ export default function PublicReview() {
|
|||||||
const [reviewerName, setReviewerName] = useState('')
|
const [reviewerName, setReviewerName] = useState('')
|
||||||
const [feedback, setFeedback] = useState('')
|
const [feedback, setFeedback] = useState('')
|
||||||
const [selectedLanguage, setSelectedLanguage] = useState(0)
|
const [selectedLanguage, setSelectedLanguage] = useState(0)
|
||||||
|
const [pendingAction, setPendingAction] = useState(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadArtefact()
|
loadArtefact()
|
||||||
@@ -29,7 +35,7 @@ export default function PublicReview() {
|
|||||||
const res = await fetch(`/api/public/review/${token}`)
|
const res = await fetch(`/api/public/review/${token}`)
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json()
|
const err = await res.json()
|
||||||
setError(err.error || 'Failed to load artefact')
|
setError(err.error || t('review.loadFailed'))
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -40,25 +46,32 @@ export default function PublicReview() {
|
|||||||
setReviewerName(data.approvers[0].name)
|
setReviewerName(data.approvers[0].name)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('Failed to load artefact')
|
setError(t('review.loadFailed'))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAction = async (action) => {
|
const handleAction = (action) => {
|
||||||
if (!reviewerName.trim()) {
|
if (!reviewerName.trim()) {
|
||||||
alert('Please select or enter your name')
|
toast.error(t('review.enterName'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === 'approve' && !confirm('Approve this artefact?')) return
|
|
||||||
if (action === 'reject' && !confirm('Reject this artefact?')) return
|
|
||||||
if (action === 'revision' && !feedback.trim()) {
|
if (action === 'revision' && !feedback.trim()) {
|
||||||
alert('Please provide feedback for revision request')
|
toast.error(t('review.feedbackRequired'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (action === 'approve' || action === 'reject') {
|
||||||
|
setPendingAction(action)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
executeAction(action)
|
||||||
|
}
|
||||||
|
|
||||||
|
const executeAction = async (action) => {
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/public/review/${token}/${action}`, {
|
const res = await fetch(`/api/public/review/${token}/${action}`, {
|
||||||
@@ -72,18 +85,18 @@ export default function PublicReview() {
|
|||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json()
|
const err = await res.json()
|
||||||
setError(err.error || 'Action failed')
|
setError(err.error || t('review.actionFailed'))
|
||||||
setSubmitting(false)
|
setSubmitting(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
setSuccess(data.message || 'Action completed successfully')
|
setSuccess(data.message || t('review.actionCompleted'))
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
loadArtefact()
|
loadArtefact()
|
||||||
}, 1500)
|
}, 1500)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('Action failed')
|
setError(t('review.actionFailed'))
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false)
|
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">
|
<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" />
|
<XCircle className="w-8 h-8 text-red-600" />
|
||||||
</div>
|
</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>
|
<p className="text-text-secondary">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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" />
|
<CheckCircle className="w-8 h-8 text-emerald-600" />
|
||||||
</div>
|
</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>
|
<p className="text-text-secondary">{success}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -170,7 +183,7 @@ export default function PublicReview() {
|
|||||||
<Sparkles className="w-6 h-6 text-white" />
|
<Sparkles className="w-6 h-6 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<p className="text-white/80 text-sm">Samaya Digital Hub</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -190,7 +203,7 @@ export default function PublicReview() {
|
|||||||
<div className="flex items-center gap-3 text-sm text-text-tertiary">
|
<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>
|
<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.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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -200,7 +213,7 @@ export default function PublicReview() {
|
|||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<Globe className="w-4 h-4 text-text-tertiary" />
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Language tabs */}
|
{/* Language tabs */}
|
||||||
@@ -226,7 +239,7 @@ export default function PublicReview() {
|
|||||||
{/* Selected language content */}
|
{/* Selected language content */}
|
||||||
<div className="bg-surface-secondary rounded-xl p-6 border border-border">
|
<div className="bg-surface-secondary rounded-xl p-6 border border-border">
|
||||||
<div className="mb-2 text-xs font-semibold text-text-tertiary uppercase">
|
<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>
|
||||||
<div className="text-text-primary whitespace-pre-wrap leading-relaxed">
|
<div className="text-text-primary whitespace-pre-wrap leading-relaxed">
|
||||||
{artefact.texts[selectedLanguage].content}
|
{artefact.texts[selectedLanguage].content}
|
||||||
@@ -238,7 +251,7 @@ export default function PublicReview() {
|
|||||||
{/* Legacy content field (for backward compatibility) */}
|
{/* Legacy content field (for backward compatibility) */}
|
||||||
{artefact.content && (!artefact.texts || artefact.texts.length === 0) && (
|
{artefact.content && (!artefact.texts || artefact.texts.length === 0) && (
|
||||||
<div className="mb-6">
|
<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">
|
<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">
|
<pre className="text-text-primary whitespace-pre-wrap font-sans text-sm leading-relaxed">
|
||||||
{artefact.content}
|
{artefact.content}
|
||||||
@@ -252,7 +265,7 @@ export default function PublicReview() {
|
|||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<ImageIcon className="w-4 h-4 text-text-tertiary" />
|
<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>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{artefact.attachments.map((att, idx) => (
|
{artefact.attachments.map((att, idx) => (
|
||||||
@@ -284,7 +297,7 @@ export default function PublicReview() {
|
|||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<Film className="w-4 h-4 text-text-tertiary" />
|
<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>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{artefact.attachments.map((att, idx) => (
|
{artefact.attachments.map((att, idx) => (
|
||||||
@@ -293,7 +306,7 @@ export default function PublicReview() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="px-4 py-2 bg-surface border-b border-border flex items-center gap-2">
|
<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" />
|
<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>
|
</div>
|
||||||
<iframe
|
<iframe
|
||||||
src={getDriveEmbedUrl(att.drive_url)}
|
src={getDriveEmbedUrl(att.drive_url)}
|
||||||
@@ -325,7 +338,7 @@ export default function PublicReview() {
|
|||||||
{/* OTHER TYPE: Generic Attachments */}
|
{/* OTHER TYPE: Generic Attachments */}
|
||||||
{artefact.type === 'other' && artefact.attachments && artefact.attachments.length > 0 && (
|
{artefact.type === 'other' && artefact.attachments && artefact.attachments.length > 0 && (
|
||||||
<div className="mb-6">
|
<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">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
{artefact.attachments.map((att, idx) => (
|
{artefact.attachments.map((att, idx) => (
|
||||||
<div key={idx}>
|
<div key={idx}>
|
||||||
@@ -372,7 +385,7 @@ export default function PublicReview() {
|
|||||||
{/* Comments */}
|
{/* Comments */}
|
||||||
{artefact.comments && artefact.comments.length > 0 && (
|
{artefact.comments && artefact.comments.length > 0 && (
|
||||||
<div className="mb-6">
|
<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">
|
<div className="space-y-3">
|
||||||
{artefact.comments.map((comment, idx) => (
|
{artefact.comments.map((comment, idx) => (
|
||||||
<div key={idx} className="bg-surface-secondary rounded-lg p-3 border border-border">
|
<div key={idx} className="bg-surface-secondary rounded-lg p-3 border border-border">
|
||||||
@@ -396,12 +409,12 @@ export default function PublicReview() {
|
|||||||
{/* Review Form */}
|
{/* Review Form */}
|
||||||
{artefact.status === 'pending_review' && (
|
{artefact.status === 'pending_review' && (
|
||||||
<div className="border-t border-border pt-6">
|
<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">
|
<div className="space-y-4 mb-6">
|
||||||
{/* Reviewer identity */}
|
{/* Reviewer identity */}
|
||||||
<div>
|
<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 ? (
|
{artefact.approvers?.length === 1 ? (
|
||||||
<div className="flex items-center gap-2 px-4 py-2 bg-surface-secondary border border-border rounded-lg">
|
<div className="flex items-center gap-2 px-4 py-2 bg-surface-secondary border border-border rounded-lg">
|
||||||
<User className="w-4 h-4 text-text-tertiary" />
|
<User className="w-4 h-4 text-text-tertiary" />
|
||||||
@@ -413,7 +426,7 @@ export default function PublicReview() {
|
|||||||
onChange={e => setReviewerName(e.target.value)}
|
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"
|
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 => (
|
{artefact.approvers.map(a => (
|
||||||
<option key={a.id} value={a.name}>{a.name}</option>
|
<option key={a.id} value={a.name}>{a.name}</option>
|
||||||
))}
|
))}
|
||||||
@@ -423,19 +436,19 @@ export default function PublicReview() {
|
|||||||
type="text"
|
type="text"
|
||||||
value={reviewerName}
|
value={reviewerName}
|
||||||
onChange={e => setReviewerName(e.target.value)}
|
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"
|
className="w-full px-4 py-2 text-sm border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary transition-colors"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<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
|
<textarea
|
||||||
value={feedback}
|
value={feedback}
|
||||||
onChange={e => setFeedback(e.target.value)}
|
onChange={e => setFeedback(e.target.value)}
|
||||||
rows={4}
|
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"
|
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>
|
||||||
@@ -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"
|
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" />
|
<CheckCircle className="w-5 h-5" />
|
||||||
Approve
|
{t('review.approve')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleAction('revision')}
|
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"
|
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" />
|
<AlertCircle className="w-5 h-5" />
|
||||||
Request Revision
|
{t('review.requestRevision')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleAction('reject')}
|
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"
|
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" />
|
<XCircle className="w-5 h-5" />
|
||||||
Reject
|
{t('review.reject')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -475,14 +488,14 @@ export default function PublicReview() {
|
|||||||
<div className="border-t border-border pt-6">
|
<div className="border-t border-border pt-6">
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4 text-center">
|
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4 text-center">
|
||||||
<p className="text-blue-900 font-medium">
|
<p className="text-blue-900 font-medium">
|
||||||
This artefact has already been reviewed.
|
{t('review.alreadyReviewed')}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-blue-700 text-sm mt-1">
|
<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>
|
</p>
|
||||||
{artefact.approved_by_name && (
|
{artefact.approved_by_name && (
|
||||||
<p className="text-blue-700 text-sm mt-1">
|
<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>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -493,9 +506,26 @@ export default function PublicReview() {
|
|||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="text-center text-text-tertiary text-sm">
|
<div className="text-center text-text-tertiary text-sm">
|
||||||
<p>Powered by Samaya Digital Hub</p>
|
<p>{t('review.poweredBy')}</p>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ import { useState, useEffect } from 'react'
|
|||||||
import { Settings as SettingsIcon, Play, CheckCircle, Languages, Coins, Upload } from 'lucide-react'
|
import { Settings as SettingsIcon, Play, CheckCircle, Languages, Coins, Upload } from 'lucide-react'
|
||||||
import { api } from '../utils/api'
|
import { api } from '../utils/api'
|
||||||
import { useLanguage } from '../i18n/LanguageContext'
|
import { useLanguage } from '../i18n/LanguageContext'
|
||||||
|
import { useToast } from '../components/ToastContainer'
|
||||||
import { CURRENCIES } from '../i18n/LanguageContext'
|
import { CURRENCIES } from '../i18n/LanguageContext'
|
||||||
|
|
||||||
export default function Settings() {
|
export default function Settings() {
|
||||||
const { t, lang, setLang, currency, setCurrency } = useLanguage()
|
const { t, lang, setLang, currency, setCurrency } = useLanguage()
|
||||||
|
const toast = useToast()
|
||||||
const [restarting, setRestarting] = useState(false)
|
const [restarting, setRestarting] = useState(false)
|
||||||
const [success, setSuccess] = useState(false)
|
const [success, setSuccess] = useState(false)
|
||||||
const [maxSizeMB, setMaxSizeMB] = useState(50)
|
const [maxSizeMB, setMaxSizeMB] = useState(50)
|
||||||
@@ -25,7 +27,7 @@ export default function Settings() {
|
|||||||
setSizeSaved(true)
|
setSizeSaved(true)
|
||||||
setTimeout(() => setSizeSaved(false), 2000)
|
setTimeout(() => setSizeSaved(false), 2000)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(err.message || 'Failed to save')
|
toast.error(err.message || t('settings.saveFailed'))
|
||||||
} finally {
|
} finally {
|
||||||
setSizeSaving(false)
|
setSizeSaving(false)
|
||||||
}
|
}
|
||||||
@@ -42,7 +44,7 @@ export default function Settings() {
|
|||||||
}, 1500)
|
}, 1500)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to restart tutorial:', err)
|
console.error('Failed to restart tutorial:', err)
|
||||||
alert('Failed to restart tutorial')
|
toast.error(t('settings.restartTutorialFailed'))
|
||||||
} finally {
|
} finally {
|
||||||
setRestarting(false)
|
setRestarting(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { useLanguage } from '../i18n/LanguageContext'
|
|||||||
import { api, PRIORITY_CONFIG, getBrandColor } from '../utils/api'
|
import { api, PRIORITY_CONFIG, getBrandColor } from '../utils/api'
|
||||||
import TaskCard from '../components/TaskCard'
|
import TaskCard from '../components/TaskCard'
|
||||||
import TaskDetailPanel from '../components/TaskDetailPanel'
|
import TaskDetailPanel from '../components/TaskDetailPanel'
|
||||||
|
import BulkSelectBar from '../components/BulkSelectBar'
|
||||||
|
import Modal from '../components/Modal'
|
||||||
import TaskCalendarView from '../components/TaskCalendarView'
|
import TaskCalendarView from '../components/TaskCalendarView'
|
||||||
import DatePresetPicker from '../components/DatePresetPicker'
|
import DatePresetPicker from '../components/DatePresetPicker'
|
||||||
import EmptyState from '../components/EmptyState'
|
import EmptyState from '../components/EmptyState'
|
||||||
@@ -45,6 +47,8 @@ export default function Tasks() {
|
|||||||
const [filterOverdue, setFilterOverdue] = useState(false)
|
const [filterOverdue, setFilterOverdue] = useState(false)
|
||||||
const [activePreset, setActivePreset] = useState('')
|
const [activePreset, setActivePreset] = useState('')
|
||||||
const [showFilters, setShowFilters] = useState(false)
|
const [showFilters, setShowFilters] = useState(false)
|
||||||
|
const [selectedIds, setSelectedIds] = useState(new Set())
|
||||||
|
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false)
|
||||||
|
|
||||||
// Assignable users & team
|
// Assignable users & team
|
||||||
const [assignableUsers, setAssignableUsers] = useState([])
|
const [assignableUsers, setAssignableUsers] = useState([])
|
||||||
@@ -54,17 +58,17 @@ export default function Tasks() {
|
|||||||
|
|
||||||
useEffect(() => { loadTasks() }, [currentUser])
|
useEffect(() => { loadTasks() }, [currentUser])
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.get('/users/assignable').then(res => setAssignableUsers(res.data || res || [])).catch(() => {})
|
api.get('/users/assignable').then(res => setAssignableUsers(Array.isArray(res) ? res : [])).catch(() => {})
|
||||||
api.get('/projects').then(res => setProjects(res.data || res || [])).catch(() => {})
|
api.get('/projects').then(res => setProjects(Array.isArray(res) ? res : [])).catch(() => {})
|
||||||
if (isSuperadmin) {
|
if (isSuperadmin) {
|
||||||
api.get('/team').then(res => setUsers(res.data || res || [])).catch(() => {})
|
api.get('/team').then(res => setUsers(Array.isArray(res) ? res : [])).catch(() => {})
|
||||||
}
|
}
|
||||||
}, [isSuperadmin])
|
}, [isSuperadmin])
|
||||||
|
|
||||||
const loadTasks = async () => {
|
const loadTasks = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await api.get('/tasks')
|
const res = await api.get('/tasks')
|
||||||
setTasks(res.data || res || [])
|
setTasks(Array.isArray(res) ? res : [])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load tasks:', err)
|
console.error('Failed to load tasks:', err)
|
||||||
} finally {
|
} 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) => {
|
const handleMove = async (taskId, newStatus) => {
|
||||||
try {
|
try {
|
||||||
await api.patch(`/tasks/${taskId}`, { status: newStatus })
|
await api.patch(`/tasks/${taskId}`, { status: newStatus })
|
||||||
@@ -594,10 +625,27 @@ export default function Tasks() {
|
|||||||
|
|
||||||
{/* ─── List View ───────────────────────── */}
|
{/* ─── List View ───────────────────────── */}
|
||||||
{viewMode === 'list' && (
|
{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">
|
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-border bg-surface-secondary/50">
|
<tr className="border-b border-border bg-surface-secondary/50">
|
||||||
|
<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="w-8 px-3 py-2.5"></th>
|
||||||
<th
|
<th
|
||||||
className="text-left px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase cursor-pointer hover:text-text-primary"
|
className="text-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)}
|
onClick={() => openTask(task)}
|
||||||
className="border-b border-border-light hover:bg-surface-secondary/30 cursor-pointer transition-colors group"
|
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">
|
<td className="px-3 py-2.5">
|
||||||
<div className={`w-2.5 h-2.5 rounded-full ${priority.color}`} title={priority.label} />
|
<div className={`w-2.5 h-2.5 rounded-full ${priority.color}`} title={priority.label} />
|
||||||
</td>
|
</td>
|
||||||
@@ -686,6 +742,7 @@ export default function Tasks() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ─── Calendar View ───────────────────── */}
|
{/* ─── 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 ──────────────── */}
|
{/* ─── Task Detail Side Panel ──────────────── */}
|
||||||
{selectedTask && (
|
{selectedTask && (
|
||||||
<TaskDetailPanel
|
<TaskDetailPanel
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useContext } from 'react'
|
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 { getInitials } from '../utils/api'
|
||||||
import { AppContext } from '../App'
|
import { AppContext } from '../App'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
@@ -10,9 +10,11 @@ import StatusBadge from '../components/StatusBadge'
|
|||||||
import BrandBadge from '../components/BrandBadge'
|
import BrandBadge from '../components/BrandBadge'
|
||||||
import TeamMemberPanel from '../components/TeamMemberPanel'
|
import TeamMemberPanel from '../components/TeamMemberPanel'
|
||||||
import TeamPanel from '../components/TeamPanel'
|
import TeamPanel from '../components/TeamPanel'
|
||||||
|
import { useToast } from '../components/ToastContainer'
|
||||||
|
|
||||||
export default function Team() {
|
export default function Team() {
|
||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
|
const toast = useToast()
|
||||||
const { teamMembers, loadTeam, currentUser, teams, loadTeams, brands } = useContext(AppContext)
|
const { teamMembers, loadTeam, currentUser, teams, loadTeams, brands } = useContext(AppContext)
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const [panelMember, setPanelMember] = useState(null)
|
const [panelMember, setPanelMember] = useState(null)
|
||||||
@@ -27,6 +29,13 @@ export default function Team() {
|
|||||||
|
|
||||||
const canManageTeam = user?.role === 'superadmin' || user?.role === 'manager'
|
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 = () => {
|
const openNew = () => {
|
||||||
setPanelMember({ role: 'content_writer' })
|
setPanelMember({ role: 'content_writer' })
|
||||||
setPanelIsEditingSelf(false)
|
setPanelIsEditingSelf(false)
|
||||||
@@ -85,7 +94,7 @@ export default function Team() {
|
|||||||
await loadTeams()
|
await loadTeams()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Save failed:', 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()
|
await loadTeam()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Team save failed:', 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>
|
</div>
|
||||||
<div className="flex gap-2">
|
<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 */}
|
{/* Edit own profile button */}
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -443,14 +462,23 @@ export default function Team() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{canManageTeam && (
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => setPanelTeam(team)}
|
onClick={() => copyIssueLink(tid)}
|
||||||
className="px-3 py-1.5 text-sm font-medium text-blue-600 hover:bg-blue-100 rounded-lg transition-colors"
|
className="px-2 py-1.5 text-sm 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>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Team members */}
|
{/* Team members */}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { Plus, Shield, Edit2, Trash2, UserCheck } from 'lucide-react'
|
|||||||
import { api } from '../utils/api'
|
import { api } from '../utils/api'
|
||||||
import Modal from '../components/Modal'
|
import Modal from '../components/Modal'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
import { useLanguage } from '../i18n/LanguageContext'
|
||||||
|
import { useToast } from '../components/ToastContainer'
|
||||||
import { SkeletonTable } from '../components/SkeletonLoader'
|
import { SkeletonTable } from '../components/SkeletonLoader'
|
||||||
|
|
||||||
const ROLES = [
|
const ROLES = [
|
||||||
@@ -27,6 +29,8 @@ function RoleBadge({ role }) {
|
|||||||
|
|
||||||
export default function Users() {
|
export default function Users() {
|
||||||
const { user: currentUser } = useAuth()
|
const { user: currentUser } = useAuth()
|
||||||
|
const { t } = useLanguage()
|
||||||
|
const toast = useToast()
|
||||||
const [users, setUsers] = useState([])
|
const [users, setUsers] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [showModal, setShowModal] = useState(false)
|
const [showModal, setShowModal] = useState(false)
|
||||||
@@ -53,7 +57,7 @@ export default function Users() {
|
|||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setPasswordError('')
|
setPasswordError('')
|
||||||
if (form.password && form.password !== confirmPassword) {
|
if (form.password && form.password !== confirmPassword) {
|
||||||
setPasswordError('Passwords do not match')
|
setPasswordError(t('users.passwordMismatch'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -69,7 +73,7 @@ export default function Users() {
|
|||||||
await api.patch(`/users/${editingUser.id}`, data)
|
await api.patch(`/users/${editingUser.id}`, data)
|
||||||
} else {
|
} else {
|
||||||
if (!form.password) {
|
if (!form.password) {
|
||||||
alert('Password is required for new users')
|
toast.error(t('users.passwordRequired'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
data.password = form.password
|
data.password = form.password
|
||||||
@@ -81,7 +85,7 @@ export default function Users() {
|
|||||||
loadUsers()
|
loadUsers()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Save failed:', 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)
|
setUserToDelete(null)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Delete failed:', 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>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-text-primary flex items-center gap-3">
|
<h1 className="text-2xl font-bold text-text-primary flex items-center gap-3">
|
||||||
<Shield className="w-7 h-7 text-purple-600" />
|
<Shield className="w-7 h-7 text-purple-600" />
|
||||||
User Management
|
{t('users.title')}
|
||||||
</h1>
|
</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>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={openNew}
|
onClick={openNew}
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm"
|
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" />
|
<Plus className="w-4 h-4" />
|
||||||
Add User
|
{t('users.addUser')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -153,18 +157,18 @@ export default function Users() {
|
|||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-border bg-surface-secondary">
|
<tr className="border-b border-border bg-surface-secondary">
|
||||||
<th className="text-left px-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">{t('users.userSingular')}</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">{t('users.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">{t('users.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-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">Actions</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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-border-light">
|
<tbody className="divide-y divide-border-light">
|
||||||
{users.length === 0 ? (
|
{users.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={5} className="py-12 text-center text-sm text-text-tertiary">
|
<td colSpan={5} className="py-12 text-center text-sm text-text-tertiary">
|
||||||
No users found
|
{t('users.noUsers')}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
@@ -183,7 +187,7 @@ export default function Users() {
|
|||||||
<p className="text-sm font-medium text-text-primary">{user.name}</p>
|
<p className="text-sm font-medium text-text-primary">{user.name}</p>
|
||||||
{isCurrentUser && (
|
{isCurrentUser && (
|
||||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-100 text-emerald-700 font-medium">
|
<span className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-100 text-emerald-700 font-medium">
|
||||||
You
|
{t('users.you')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -202,7 +206,7 @@ export default function Users() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => openEdit(user)}
|
onClick={() => openEdit(user)}
|
||||||
className="p-1.5 hover:bg-surface-tertiary rounded-lg text-text-tertiary hover:text-text-primary"
|
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" />
|
<Edit2 className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -210,7 +214,7 @@ export default function Users() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => { setUserToDelete(user); setShowDeleteConfirm(true) }}
|
onClick={() => { setUserToDelete(user); setShowDeleteConfirm(true) }}
|
||||||
className="p-1.5 hover:bg-red-50 rounded-lg text-text-tertiary hover:text-red-500"
|
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" />
|
<Trash2 className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -229,24 +233,24 @@ export default function Users() {
|
|||||||
<Modal
|
<Modal
|
||||||
isOpen={showModal}
|
isOpen={showModal}
|
||||||
onClose={() => { setShowModal(false); setEditingUser(null) }}
|
onClose={() => { setShowModal(false); setEditingUser(null) }}
|
||||||
title={editingUser ? 'Edit User' : 'Add New User'}
|
title={editingUser ? t('users.editUser') : t('users.addNewUser')}
|
||||||
size="md"
|
size="md"
|
||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={form.name}
|
value={form.name}
|
||||||
onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
|
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"
|
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
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
value={form.email}
|
value={form.email}
|
||||||
@@ -259,7 +263,7 @@ export default function Users() {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">
|
<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>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
@@ -273,7 +277,7 @@ export default function Users() {
|
|||||||
|
|
||||||
{form.password && (
|
{form.password && (
|
||||||
<div>
|
<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
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={confirmPassword}
|
value={confirmPassword}
|
||||||
@@ -288,7 +292,7 @@ export default function Users() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
<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">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
{ROLES.map(r => (
|
{ROLES.map(r => (
|
||||||
<button
|
<button
|
||||||
@@ -313,14 +317,14 @@ export default function Users() {
|
|||||||
onClick={() => { setShowModal(false); setEditingUser(null) }}
|
onClick={() => { setShowModal(false); setEditingUser(null) }}
|
||||||
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
|
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
|
||||||
>
|
>
|
||||||
Cancel
|
{t('common.cancel')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={!form.name || !form.email || (!editingUser && !form.password)}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -330,13 +334,13 @@ export default function Users() {
|
|||||||
<Modal
|
<Modal
|
||||||
isOpen={showDeleteConfirm}
|
isOpen={showDeleteConfirm}
|
||||||
onClose={() => { setShowDeleteConfirm(false); setUserToDelete(null) }}
|
onClose={() => { setShowDeleteConfirm(false); setUserToDelete(null) }}
|
||||||
title="Delete User?"
|
title={t('users.deleteUserConfirmTitle')}
|
||||||
isConfirm
|
isConfirm
|
||||||
danger
|
danger
|
||||||
confirmText="Delete User"
|
confirmText={t('users.deleteUser')}
|
||||||
onConfirm={confirmDelete}
|
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>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -139,16 +139,21 @@ export const STATUS_CONFIG = {
|
|||||||
completed: { label: 'Completed', bg: 'bg-blue-50', text: 'text-blue-700', dot: 'bg-blue-400' },
|
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' },
|
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' },
|
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'];
|
export const getStatusConfig = (status) => STATUS_CONFIG[status] || STATUS_CONFIG['draft'];
|
||||||
|
|
||||||
// Priority config
|
// Priority config
|
||||||
export const PRIORITY_CONFIG = {
|
export const PRIORITY_CONFIG = {
|
||||||
low: { label: 'Low', color: 'bg-gray-400' },
|
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' },
|
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' },
|
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' },
|
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
|
// Shared helper: extract initials from a name string
|
||||||
|
|||||||
30
server/config.js
Normal file
30
server/config.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
// server/config.js
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const PORT = 3001;
|
||||||
|
const UPLOADS_DIR = path.join(__dirname, 'uploads');
|
||||||
|
const SETTINGS_PATH = path.join(__dirname, 'app-settings.json');
|
||||||
|
|
||||||
|
const DEFAULTS = {
|
||||||
|
uploadMaxSizeMB: 50,
|
||||||
|
cacheTTLMs: 60000,
|
||||||
|
sessionMaxAge: 7 * 24 * 60 * 60 * 1000,
|
||||||
|
tokenExpiryDays: 7,
|
||||||
|
};
|
||||||
|
|
||||||
|
const QUERY_LIMITS = {
|
||||||
|
small: 200,
|
||||||
|
medium: 500,
|
||||||
|
large: 1000,
|
||||||
|
max: 5000,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ALL_MODULES = ['marketing', 'projects', 'finance'];
|
||||||
|
|
||||||
|
// NocoDB table name mapping for ownership checks
|
||||||
|
const TABLE_NAME_MAP = { posts: 'Posts', tasks: 'Tasks', campaigns: 'Campaigns', projects: 'Projects' };
|
||||||
|
|
||||||
|
// Entity types allowed for comments
|
||||||
|
const COMMENT_ENTITY_TYPES = new Set(['post', 'task', 'project', 'campaign', 'asset']);
|
||||||
|
|
||||||
|
module.exports = { PORT, UPLOADS_DIR, SETTINGS_PATH, DEFAULTS, QUERY_LIMITS, ALL_MODULES, TABLE_NAME_MAP, COMMENT_ENTITY_TYPES };
|
||||||
99
server/helpers.js
Normal file
99
server/helpers.js
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
// server/helpers.js
|
||||||
|
const nocodb = require('./nocodb');
|
||||||
|
const { DEFAULTS } = require('./config');
|
||||||
|
|
||||||
|
// Name lookup cache
|
||||||
|
const _nameCache = {};
|
||||||
|
|
||||||
|
// Clear cache periodically
|
||||||
|
setInterval(() => { Object.keys(_nameCache).forEach(k => delete _nameCache[k]); }, DEFAULTS.cacheTTLMs);
|
||||||
|
|
||||||
|
// Get a single record's display name
|
||||||
|
async function getRecordName(table, id) {
|
||||||
|
if (!id) return null;
|
||||||
|
const key = `${table}:${id}`;
|
||||||
|
if (_nameCache[key] !== undefined) return _nameCache[key];
|
||||||
|
try {
|
||||||
|
const r = await nocodb.get(table, id);
|
||||||
|
const name = r?.name || r?.title || r?.Name || null;
|
||||||
|
_nameCache[key] = name;
|
||||||
|
return name;
|
||||||
|
} catch {
|
||||||
|
_nameCache[key] = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch resolve names for multiple IDs across tables
|
||||||
|
// Usage: await batchResolveNames({ brand: { table: 'Brands', ids: [1,2,3] }, user: { table: 'Users', ids: [4,5] } })
|
||||||
|
// Returns: { 'brand:1': 'BrandA', 'user:4': 'Alice', ... }
|
||||||
|
async function batchResolveNames(groups) {
|
||||||
|
// groups is an object like: { brand: { table: 'Brands', ids: [1,2,3] }, user: { table: 'Users', ids: [4,5] } }
|
||||||
|
const names = {};
|
||||||
|
const fetches = [];
|
||||||
|
|
||||||
|
for (const [prefix, { table, ids }] of Object.entries(groups)) {
|
||||||
|
const uniqueIds = [...new Set(ids.filter(Boolean))];
|
||||||
|
for (const id of uniqueIds) {
|
||||||
|
const key = `${prefix}:${id}`;
|
||||||
|
if (_nameCache[`${table}:${id}`] !== undefined) {
|
||||||
|
names[key] = _nameCache[`${table}:${id}`];
|
||||||
|
} else {
|
||||||
|
fetches.push(
|
||||||
|
getRecordName(table, id).then(name => { names[key] = name; })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(fetches);
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse comma-separated approver IDs
|
||||||
|
function parseApproverIds(str) {
|
||||||
|
if (!str) return [];
|
||||||
|
return str.split(',').map(s => s.trim()).filter(Boolean).map(Number);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safely parse JSON with fallback
|
||||||
|
function safeJsonParse(str, fallback = null) {
|
||||||
|
if (!str || typeof str !== 'string') return fallback;
|
||||||
|
try { return JSON.parse(str); } catch { return fallback; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pick allowed fields from request body
|
||||||
|
function pickBodyFields(body, fields) {
|
||||||
|
const data = {};
|
||||||
|
for (const f of fields) {
|
||||||
|
if (body[f] !== undefined) data[f] = body[f];
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize a value for use in NocoDB WHERE clauses
|
||||||
|
// Prevents injection by removing NocoDB query operators
|
||||||
|
function sanitizeWhereValue(val) {
|
||||||
|
if (val === null || val === undefined) return '';
|
||||||
|
const str = String(val);
|
||||||
|
// Remove characters that could manipulate NocoDB query syntax
|
||||||
|
return str.replace(/[~(),$]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build user modules list from user record
|
||||||
|
function getUserModules(user, allModules) {
|
||||||
|
if (user.role === 'superadmin') return allModules;
|
||||||
|
if (user.modules) return safeJsonParse(user.modules, allModules);
|
||||||
|
return allModules;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getRecordName,
|
||||||
|
batchResolveNames,
|
||||||
|
parseApproverIds,
|
||||||
|
safeJsonParse,
|
||||||
|
pickBodyFields,
|
||||||
|
sanitizeWhereValue,
|
||||||
|
getUserModules,
|
||||||
|
_nameCache,
|
||||||
|
};
|
||||||
599
server/server.js
599
server/server.js
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user