diff --git a/.gitignore b/.gitignore index c9a9d4d..b46ab24 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ dist/ *.db-shm *.db-wal .vite/ +.env +.env.* diff --git a/client/package-lock.json b/client/package-lock.json index b398ee0..53e6f9c 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -18,8 +18,6 @@ }, "devDependencies": { "@eslint/js": "^9.39.1", - "@types/react": "^19.2.5", - "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.1", "eslint": "^9.39.1", "eslint-plugin-react-hooks": "^7.0.1", @@ -1627,26 +1625,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/react": { - "version": "19.2.13", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.13.tgz", - "integrity": "sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "csstype": "^3.2.2" - } - }, - "node_modules/@types/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^19.2.0" - } - }, "node_modules/@vitejs/plugin-react": { "version": "5.1.3", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.3.tgz", @@ -1903,13 +1881,6 @@ "node": ">= 8" } }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, - "license": "MIT" - }, "node_modules/date-fns": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", diff --git a/client/package.json b/client/package.json index ce3999a..8e65b78 100644 --- a/client/package.json +++ b/client/package.json @@ -20,8 +20,6 @@ }, "devDependencies": { "@eslint/js": "^9.39.1", - "@types/react": "^19.2.5", - "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.1", "eslint": "^9.39.1", "eslint-plugin-react-hooks": "^7.0.1", diff --git a/client/src/App.jsx b/client/src/App.jsx index e226545..f011b00 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -1,35 +1,38 @@ import { Routes, Route, Navigate } from 'react-router-dom' -import { useState, useEffect, createContext } from 'react' +import { useState, useEffect, createContext, lazy, Suspense } from 'react' import { AuthProvider, useAuth } from './contexts/AuthContext' import { LanguageProvider } from './i18n/LanguageContext' import { ToastProvider } from './components/ToastContainer' +import ErrorBoundary from './components/ErrorBoundary' import Layout from './components/Layout' -import Dashboard from './pages/Dashboard' -import PostProduction from './pages/PostProduction' -import Assets from './pages/Assets' -import Campaigns from './pages/Campaigns' -import CampaignDetail from './pages/CampaignDetail' -import Finance from './pages/Finance' -import Budgets from './pages/Budgets' -import Projects from './pages/Projects' -import ProjectDetail from './pages/ProjectDetail' -import Tasks from './pages/Tasks' -import Team from './pages/Team' -import Users from './pages/Users' -import Settings from './pages/Settings' -import Brands from './pages/Brands' -import Login from './pages/Login' -import Artefacts from './pages/Artefacts' -import PostCalendar from './pages/PostCalendar' -import PublicReview from './pages/PublicReview' -import Issues from './pages/Issues' -import PublicIssueSubmit from './pages/PublicIssueSubmit' -import PublicIssueTracker from './pages/PublicIssueTracker' import Tutorial from './components/Tutorial' import Modal from './components/Modal' import { api } from './utils/api' import { useLanguage } from './i18n/LanguageContext' +// Lazy-loaded page components +const Dashboard = lazy(() => import('./pages/Dashboard')) +const PostProduction = lazy(() => import('./pages/PostProduction')) +const Assets = lazy(() => import('./pages/Assets')) +const Campaigns = lazy(() => import('./pages/Campaigns')) +const CampaignDetail = lazy(() => import('./pages/CampaignDetail')) +const Finance = lazy(() => import('./pages/Finance')) +const Budgets = lazy(() => import('./pages/Budgets')) +const Projects = lazy(() => import('./pages/Projects')) +const ProjectDetail = lazy(() => import('./pages/ProjectDetail')) +const Tasks = lazy(() => import('./pages/Tasks')) +const Team = lazy(() => import('./pages/Team')) +const Users = lazy(() => import('./pages/Users')) +const Settings = lazy(() => import('./pages/Settings')) +const Brands = lazy(() => import('./pages/Brands')) +const Login = lazy(() => import('./pages/Login')) +const Artefacts = lazy(() => import('./pages/Artefacts')) +const PostCalendar = lazy(() => import('./pages/PostCalendar')) +const PublicReview = lazy(() => import('./pages/PublicReview')) +const Issues = lazy(() => import('./pages/Issues')) +const PublicIssueSubmit = lazy(() => import('./pages/PublicIssueSubmit')) +const PublicIssueTracker = lazy(() => import('./pages/PublicIssueTracker')) + const TEAM_ROLES = [ { value: 'manager', label: 'Manager' }, { value: 'approver', label: 'Approver' }, @@ -87,7 +90,7 @@ function AppContent() { const loadTeam = async () => { try { const data = await api.get('/users/team') - const members = Array.isArray(data) ? data : (data.data || []) + const members = Array.isArray(data) ? data : [] setTeamMembers(members) return members } catch (err) { @@ -99,7 +102,7 @@ function AppContent() { const loadTeams = async () => { try { const data = await api.get('/teams') - setTeams(Array.isArray(data) ? data : (data.data || [])) + setTeams(Array.isArray(data) ? data : []) } catch (err) { console.error('Failed to load teams:', err) } @@ -109,7 +112,7 @@ function AppContent() { try { const [, brandsData] = await Promise.all([ loadTeam(), - api.get('/brands').then(d => Array.isArray(d) ? d : (d.data || [])).catch(() => []), + api.get('/brands').then(d => Array.isArray(d) ? d : []).catch(() => []), loadTeams(), ]) setBrands(brandsData) @@ -270,40 +273,44 @@ function AppContent() { {/* Tutorial overlay */} {showTutorial && } - - : } /> - } /> - } /> - } /> - : }> - } /> - {hasModule('marketing') && <> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } - {hasModule('finance') && (user?.role === 'superadmin' || user?.role === 'manager') && <> - } /> - } /> - } - {hasModule('projects') && <> - } /> - } /> - } /> - } - {hasModule('issues') && } />} - } /> - } /> - {user?.role === 'superadmin' && ( - } /> - )} - - } /> - + +
Loading...
}> + + : } /> + } /> + } /> + } /> + : }> + } /> + {hasModule('marketing') && <> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } + {hasModule('finance') && (user?.role === 'superadmin' || user?.role === 'manager') && <> + } /> + } /> + } + {hasModule('projects') && <> + } /> + } /> + } /> + } + {hasModule('issues') && } />} + } /> + } /> + {user?.role === 'superadmin' && ( + } /> + )} + + } /> + +
+
) } diff --git a/client/src/components/ApproverMultiSelect.jsx b/client/src/components/ApproverMultiSelect.jsx new file mode 100644 index 0000000..1a4206c --- /dev/null +++ b/client/src/components/ApproverMultiSelect.jsx @@ -0,0 +1,86 @@ +import { useState, useRef, useEffect } from 'react' +import { Check, ChevronDown, X } from 'lucide-react' + +export default function ApproverMultiSelect({ users = [], selected = [], onChange }) { + const [open, setOpen] = useState(false) + const wrapperRef = useRef(null) + + // Close dropdown when clicking outside + useEffect(() => { + if (!open) return + const handleClick = (e) => { + if (wrapperRef.current && !wrapperRef.current.contains(e.target)) { + setOpen(false) + } + } + document.addEventListener('mousedown', handleClick) + return () => document.removeEventListener('mousedown', handleClick) + }, [open]) + + const toggle = (userId) => { + const id = String(userId) + const next = selected.includes(id) ? selected.filter(s => s !== id) : [...selected, id] + onChange(next) + } + + const remove = (id) => { + onChange(selected.filter(s => s !== String(id))) + } + + const selectedUsers = selected.map(id => users.find(u => String(u._id || u.id || u.Id) === String(id))).filter(Boolean) + + return ( +
+
setOpen(!open)} + className={`w-full min-h-[38px] px-3 py-1.5 text-sm border rounded-lg bg-surface cursor-pointer flex items-center flex-wrap gap-1.5 transition-colors ${ + open ? 'border-brand-primary ring-2 ring-brand-primary/20' : 'border-border' + }`} + > + {selectedUsers.length === 0 && ( + Select approvers... + )} + {selectedUsers.map(u => ( + + {u.name} + + + ))} + +
+ {open && ( +
+ {users.map(u => { + const uid = String(u._id || u.id || u.Id) + const isSelected = selected.includes(uid) + return ( + + ) + })} + {users.length === 0 && ( +
No users available
+ )} +
+ )} +
+ ) +} diff --git a/client/src/components/ArtefactDetailPanel.jsx b/client/src/components/ArtefactDetailPanel.jsx new file mode 100644 index 0000000..a1f183d --- /dev/null +++ b/client/src/components/ArtefactDetailPanel.jsx @@ -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 ( + +
+
+
+
+ ) + } + + const TypeIcon = TYPE_ICONS[artefact.type] || Sparkles + + return ( + +
+
+ +
+
+ setEditTitle(e.target.value)} + className="w-full text-lg font-semibold text-text-primary bg-transparent border-0 border-b border-transparent hover:border-border focus:border-brand-primary focus:outline-none focus:ring-0 px-0 py-0.5 transition-colors" + /> +
+ + {artefact.status?.replace('_', ' ')} + + {artefact.type} +
+
+
+ + {onDelete && ( + + )} +
+
+ + }> +
+ {/* Description */} +
+

Description

+