feat: bulk delete, team dispatch, calendar views, timeline colors
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:
fahed
2026-03-01 14:55:36 +03:00
parent 20d76dea8b
commit 42a5f17d0b
40 changed files with 3050 additions and 1625 deletions

View 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>
)
}

View 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>
)
}

View File

@@ -0,0 +1,30 @@
import { Trash2, X } from 'lucide-react'
import { useLanguage } from '../i18n/LanguageContext'
export default function BulkSelectBar({ selectedCount, onDelete, onClear }) {
const { t } = useLanguage()
if (selectedCount === 0) return null
return (
<div className="flex items-center justify-between px-4 py-2.5 bg-red-50 border border-red-200 rounded-lg animate-fade-in">
<span className="text-sm font-medium text-red-800">
{selectedCount} {t('common.selected')}
</span>
<div className="flex items-center gap-2">
<button
onClick={onClear}
className="text-xs text-text-tertiary hover:text-text-primary transition-colors"
>
{t('common.clearSelection')}
</button>
<button
onClick={onDelete}
className="flex items-center gap-1.5 px-3 py-1.5 bg-red-600 text-white rounded-lg text-xs font-medium hover:bg-red-700 transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
{t('common.deleteSelected')}
</button>
</div>
</div>
)
}

View File

@@ -33,7 +33,7 @@ export default function CommentsSection({ entityType, entityId }) {
const loadComments = async () => {
try {
const data = await api.get(`/comments/${entityType}/${entityId}`)
setComments(Array.isArray(data) ? data : (data.data || []))
setComments(Array.isArray(data) ? data : [])
} catch (err) {
console.error('Failed to load comments:', err)
}

View File

@@ -0,0 +1,41 @@
import { Component } from 'react'
import { AlertTriangle } from 'lucide-react'
export default class ErrorBoundary extends Component {
constructor(props) {
super(props)
this.state = { hasError: false, error: null }
}
static getDerivedStateFromError(error) {
return { hasError: true, error }
}
componentDidCatch(error, errorInfo) {
console.error('ErrorBoundary caught:', error, errorInfo)
}
render() {
if (this.state.hasError) {
return (
<div className="min-h-screen bg-surface-secondary flex items-center justify-center p-4">
<div className="max-w-md w-full bg-surface rounded-2xl shadow-sm p-8 text-center">
<div className="w-16 h-16 rounded-full bg-red-100 flex items-center justify-center mx-auto mb-4">
<AlertTriangle className="w-8 h-8 text-red-600" />
</div>
<h2 className="text-2xl font-bold text-text-primary mb-2">Something went wrong</h2>
<p className="text-text-secondary mb-6">An unexpected error occurred. Please try refreshing the page.</p>
<button
onClick={() => window.location.reload()}
className="px-6 py-2.5 bg-brand-primary text-white rounded-lg font-medium hover:bg-brand-primary-light transition-colors"
>
Refresh Page
</button>
</div>
</div>
)
}
return this.props.children
}
}

View File

@@ -33,8 +33,15 @@ const PRIORITY_BORDER = {
}
const ZOOM_LEVELS = [
{ key: 'day', label: 'Day', pxPerDay: 48 },
{ key: 'month', label: 'Month', pxPerDay: 8 },
{ key: 'week', label: 'Week', pxPerDay: 20 },
{ key: 'day', label: 'Day', pxPerDay: 48 },
]
const COLOR_PALETTE = [
'#6366f1', '#ec4899', '#10b981', '#f59e0b',
'#8b5cf6', '#06b6d4', '#f43f5e', '#14b8a6',
'#3b82f6', '#ef4444', '#84cc16', '#a855f7',
]
function getInitials(name) {
@@ -42,7 +49,7 @@ function getInitials(name) {
return name.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2)
}
export default function InteractiveTimeline({ items = [], mapItem, onDateChange, onItemClick, readOnly = false }) {
export default function InteractiveTimeline({ items = [], mapItem, onDateChange, onColorChange, onItemClick, readOnly = false }) {
const containerRef = useRef(null)
const didDragRef = useRef(false)
const optimisticRef = useRef({}) // { [itemId]: { startDate, endDate } } — keeps bar in place until fresh data arrives
@@ -51,10 +58,24 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
const [tooltip, setTooltip] = useState(null)
const [dragState, setDragState] = useState(null) // { itemId, mode: 'move'|'resize-left'|'resize-right', startX, origStart, origEnd }
const dragStateRef = useRef(null)
const [colorPicker, setColorPicker] = useState(null) // { itemId, x, y }
const colorPickerRef = useRef(null)
const pxPerDay = ZOOM_LEVELS[zoomIdx].pxPerDay
const today = useMemo(() => startOfDay(new Date()), [])
// Close color picker on outside click
useEffect(() => {
if (!colorPicker) return
const handleClick = (e) => {
if (colorPickerRef.current && !colorPickerRef.current.contains(e.target)) {
setColorPicker(null)
}
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [colorPicker])
// Clear optimistic overrides when fresh data arrives
useEffect(() => {
optimisticRef.current = {}
@@ -273,6 +294,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
const isToday = differenceInDays(day, today) === 0
const isWeekend = day.getDay() === 0 || day.getDay() === 6
const isMonthStart = day.getDate() === 1
const isWeekStart = day.getDay() === 1 // Monday
return (
<div
key={i}
@@ -285,7 +307,13 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
>
{pxPerDay >= 30 && <div>{format(day, 'd')}</div>}
{pxPerDay >= 40 && <div className="text-[8px] uppercase">{format(day, 'EEE')}</div>}
{pxPerDay < 30 && day.getDate() % 7 === 1 && <div>{format(day, 'd')}</div>}
{pxPerDay >= 15 && pxPerDay < 30 && day.getDate() % 7 === 1 && <div>{format(day, 'd')}</div>}
{pxPerDay < 15 && isMonthStart && (
<div className="text-[8px] font-semibold whitespace-nowrap">{format(day, 'MMM')}</div>
)}
{pxPerDay < 15 && !isMonthStart && isWeekStart && day.getDate() % 14 <= 7 && (
<div className="text-[8px]">{format(day, 'd')}</div>
)}
</div>
)
})}
@@ -295,7 +323,8 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
{/* Rows */}
{mapped.map((item, idx) => {
const { left, width } = getBarPosition(item)
const statusColor = STATUS_COLORS[item.status] || STATUS_COLORS[item.type] || 'bg-gray-400'
const hasCustomColor = !!item.color
const statusColor = hasCustomColor ? '' : (STATUS_COLORS[item.status] || STATUS_COLORS[item.type] || 'bg-gray-400')
const priorityRing = PRIORITY_BORDER[item.priority] || ''
const isDragging = dragState?.itemId === item.id
@@ -313,6 +342,18 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
{isExpanded ? (
<>
<div className="flex items-center gap-2">
{onColorChange && (
<button
onClick={(e) => {
e.stopPropagation()
const rect = e.currentTarget.getBoundingClientRect()
setColorPicker(colorPicker?.itemId === item.id ? null : { itemId: item.id, x: rect.left, y: rect.bottom + 4 })
}}
className={`w-5 h-5 rounded-full border-2 border-white shadow-sm shrink-0 hover:scale-110 transition-transform ${!item.color ? (STATUS_COLORS[item.status] || 'bg-gray-400') : ''}`}
style={item.color ? { backgroundColor: item.color } : undefined}
title="Change color"
/>
)}
{item.thumbnailUrl ? (
<div className="w-8 h-8 rounded overflow-hidden shrink-0">
<img src={item.thumbnailUrl} alt="" className="w-full h-full object-cover" />
@@ -337,6 +378,18 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
</>
) : (
<>
{onColorChange && (
<button
onClick={(e) => {
e.stopPropagation()
const rect = e.currentTarget.getBoundingClientRect()
setColorPicker(colorPicker?.itemId === item.id ? null : { itemId: item.id, x: rect.left, y: rect.bottom + 4 })
}}
className={`w-4 h-4 rounded-full border-2 border-white shadow-sm shrink-0 hover:scale-110 transition-transform ${!item.color ? (STATUS_COLORS[item.status] || 'bg-gray-400') : ''}`}
style={item.color ? { backgroundColor: item.color } : undefined}
title="Change color"
/>
)}
{item.thumbnailUrl ? (
<div className="w-6 h-6 rounded overflow-hidden shrink-0">
<img src={item.thumbnailUrl} alt="" className="w-full h-full object-cover" />
@@ -377,6 +430,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
width: `${width}px`,
height: `${barHeight}px`,
top: isExpanded ? '8px' : '8px',
...(hasCustomColor ? { backgroundColor: item.color } : {}),
}}
onMouseDown={(e) => handleMouseDown(e, item, 'move')}
onClick={(e) => {
@@ -476,6 +530,38 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
</div>
</div>
{/* Color Picker Popover */}
{colorPicker && onColorChange && (
<div
ref={colorPickerRef}
className="fixed z-50 bg-white rounded-lg shadow-xl border border-border p-2"
style={{ left: colorPicker.x, top: colorPicker.y }}
>
<div className="grid grid-cols-4 gap-1.5 mb-2">
{COLOR_PALETTE.map(c => (
<button
key={c}
onClick={() => {
onColorChange(colorPicker.itemId, c)
setColorPicker(null)
}}
className="w-7 h-7 rounded-full border-2 border-transparent hover:border-gray-400 hover:scale-110 transition-all"
style={{ backgroundColor: c }}
/>
))}
</div>
<button
onClick={() => {
onColorChange(colorPicker.itemId, null)
setColorPicker(null)
}}
className="w-full text-[10px] text-text-tertiary hover:text-text-primary text-center py-1 hover:bg-surface-tertiary rounded transition-colors"
>
Reset to default
</button>
</div>
)}
{/* Tooltip */}
{tooltip && !dragState && (
<div

View File

@@ -1,28 +1,17 @@
import { useState, useEffect, useContext } from 'react'
import { X, Copy, Eye, Lock, Send, Upload, FileText, Trash2, Check, Clock, CheckCircle2, XCircle } from 'lucide-react'
import { api } from '../utils/api'
import { api, STATUS_CONFIG, PRIORITY_CONFIG } from '../utils/api'
import SlidePanel from './SlidePanel'
import FormInput from './FormInput'
import Modal from './Modal'
import { useToast } from './ToastContainer'
import { AppContext } from '../App'
import { useLanguage } from '../i18n/LanguageContext'
const STATUS_CONFIG = {
new: { label: 'New', bg: 'bg-purple-100', text: 'text-purple-700', dot: 'bg-purple-500' },
acknowledged: { label: 'Acknowledged', bg: 'bg-blue-100', text: 'text-blue-700', dot: 'bg-blue-500' },
in_progress: { label: 'In Progress', bg: 'bg-amber-100', text: 'text-amber-700', dot: 'bg-amber-500' },
resolved: { label: 'Resolved', bg: 'bg-emerald-100', text: 'text-emerald-700', dot: 'bg-emerald-500' },
declined: { label: 'Declined', bg: 'bg-surface-tertiary', text: 'text-text-secondary', dot: 'bg-text-tertiary' },
}
const PRIORITY_CONFIG = {
low: { label: 'Low', bg: 'bg-surface-tertiary', text: 'text-text-secondary' },
medium: { label: 'Medium', bg: 'bg-blue-100', text: 'text-blue-700' },
high: { label: 'High', bg: 'bg-orange-100', text: 'text-orange-700' },
urgent: { label: 'Urgent', bg: 'bg-red-100', text: 'text-red-700' },
}
export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers }) {
export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers, teams = [] }) {
const { brands } = useContext(AppContext)
const toast = useToast()
const { t } = useLanguage()
const [issueData, setIssueData] = useState(null)
const [updates, setUpdates] = useState([])
const [attachments, setAttachments] = useState([])
@@ -32,6 +21,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
// Form state
const [assignedTo, setAssignedTo] = useState('')
const [teamId, setTeamId] = useState('')
const [internalNotes, setInternalNotes] = useState('')
const [resolutionSummary, setResolutionSummary] = useState('')
const [newUpdate, setNewUpdate] = useState('')
@@ -40,6 +30,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
// Modals
const [showResolveModal, setShowResolveModal] = useState(false)
const [showDeclineModal, setShowDeclineModal] = useState(false)
const [confirmDeleteAttId, setConfirmDeleteAttId] = useState(null)
const issueId = issue?.Id || issue?.id
@@ -54,6 +45,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
setUpdates(data.updates || [])
setAttachments(data.attachments || [])
setAssignedTo(data.assigned_to_id || '')
setTeamId(data.team_id || '')
setInternalNotes(data.internal_notes || '')
setResolutionSummary(data.resolution_summary || '')
} catch (err) {
@@ -72,7 +64,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
await loadIssueDetails()
} catch (err) {
console.error('Failed to update status:', err)
alert('Failed to update status')
toast.error(t('issues.failedToUpdateStatus'))
} finally {
setSaving(false)
}
@@ -88,7 +80,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
await loadIssueDetails()
} catch (err) {
console.error('Failed to resolve issue:', err)
alert('Failed to resolve issue')
toast.error(t('issues.failedToResolve'))
} finally {
setSaving(false)
}
@@ -104,7 +96,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
await loadIssueDetails()
} catch (err) {
console.error('Failed to decline issue:', err)
alert('Failed to decline issue')
toast.error(t('issues.failedToDecline'))
} finally {
setSaving(false)
}
@@ -117,7 +109,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
await onUpdate()
} catch (err) {
console.error('Failed to update assignment:', err)
alert('Failed to update assignment')
toast.error(t('issues.failedToUpdateAssignment'))
}
}
@@ -128,7 +120,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
await api.patch(`/issues/${issueId}`, { internal_notes: internalNotes })
} catch (err) {
console.error('Failed to save notes:', err)
alert('Failed to save notes')
toast.error(t('issues.failedToSaveNotes'))
} finally {
setSaving(false)
}
@@ -144,7 +136,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
await loadIssueDetails()
} catch (err) {
console.error('Failed to add update:', err)
alert('Failed to add update')
toast.error(t('issues.failedToAddUpdate'))
} finally {
setSaving(false)
}
@@ -162,27 +154,26 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
e.target.value = '' // Reset input
} catch (err) {
console.error('Failed to upload file:', err)
alert('Failed to upload file')
toast.error(t('issues.failedToUploadFile'))
} finally {
setUploadingFile(false)
}
}
const handleDeleteAttachment = async (attachmentId) => {
if (!confirm('Delete this attachment?')) return
try {
await api.delete(`/issue-attachments/${attachmentId}`)
await loadIssueDetails()
} catch (err) {
console.error('Failed to delete attachment:', err)
alert('Failed to delete attachment')
toast.error(t('issues.failedToDeleteAttachment'))
}
}
const copyTrackingLink = () => {
const url = `${window.location.origin}/track/${issueData.tracking_token}`
navigator.clipboard.writeText(url)
alert('Tracking link copied to clipboard!')
toast.success(t('issues.trackingLinkCopied'))
}
const formatDate = (dateStr) => {
@@ -283,6 +274,33 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
</select>
</div>
{/* Team */}
{teams.length > 0 && (
<div>
<label className="block text-sm font-semibold text-text-primary mb-2">{t('issues.team')}</label>
<select
value={teamId}
onChange={async (e) => {
const val = e.target.value || null
setTeamId(val || '')
try {
await api.patch(`/issues/${issueId}`, { team_id: val })
await onUpdate()
await loadIssueDetails()
} catch (err) {
console.error('Failed to update team:', err)
}
}}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
>
<option value="">{t('issues.allTeams')}</option>
{teams.map((team) => (
<option key={team.id || team._id} value={team.id || team._id}>{team.name}</option>
))}
</select>
</div>
)}
{/* Brand */}
<div>
<label className="block text-sm font-semibold text-text-primary mb-2">Brand</label>
@@ -504,7 +522,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
>
Download
</a>
<button onClick={() => handleDeleteAttachment(att.Id || att.id)} className="p-1 hover:bg-surface-tertiary rounded">
<button onClick={() => setConfirmDeleteAttId(att.Id || att.id)} className="p-1 hover:bg-surface-tertiary rounded">
<Trash2 className="w-4 h-4 text-red-600" />
</button>
</div>
@@ -579,6 +597,19 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
</div>
</Modal>
)}
{/* Delete Attachment Confirmation */}
<Modal
isOpen={!!confirmDeleteAttId}
onClose={() => setConfirmDeleteAttId(null)}
title={t('issues.deleteAttachment')}
isConfirm
danger
onConfirm={() => handleDeleteAttachment(confirmDeleteAttId)}
confirmText={t('common.delete')}
>
{t('issues.deleteAttachmentDesc')}
</Modal>
</>
)
}

View File

@@ -8,7 +8,7 @@ import BrandBadge from './BrandBadge'
import StatusBadge from './StatusBadge'
import { PlatformIcons } from './PlatformIcon'
export default function PostCard({ post, onClick, onMove, compact = false }) {
export default function PostCard({ post, onClick, onMove, compact = false, checkboxSlot }) {
const { t } = useLanguage()
const { getBrandName } = useContext(AppContext)
const brandName = getBrandName(post.brand_id || post.brandId) || post.brand_name || post.brand
@@ -97,6 +97,7 @@ export default function PostCard({ post, onClick, onMove, compact = false }) {
// Table row view
return (
<tr onClick={onClick} className="hover:bg-surface-secondary cursor-pointer group">
{checkboxSlot && <td className="w-10 px-3 py-3" onClick={e => e.stopPropagation()}>{checkboxSlot}</td>}
<td className="px-4 py-3">
<div className="flex items-center gap-3">
<div className="shrink-0">

View File

@@ -129,7 +129,7 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
if (!postId) return
try {
const data = await api.get(`/posts/${postId}/attachments`)
setAttachments(Array.isArray(data) ? data : (data.data || []))
setAttachments(Array.isArray(data) ? data : [])
} catch {
setAttachments([])
}
@@ -163,7 +163,7 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
const openAssetPicker = async () => {
try {
const data = await api.get('/assets')
setAvailableAssets(Array.isArray(data) ? data : (data.data || []))
setAvailableAssets(Array.isArray(data) ? data : [])
} catch {
setAvailableAssets([])
}

View File

@@ -1,5 +1,5 @@
import { useState } from 'react'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { ChevronLeft, ChevronRight, Calendar as CalendarIcon, CalendarDays } from 'lucide-react'
import { PRIORITY_CONFIG } from '../utils/api'
import { useLanguage } from '../i18n/LanguageContext'
@@ -27,6 +27,18 @@ function getMonthData(year, month) {
return cells
}
function getWeekData(startDate) {
const cells = []
const start = new Date(startDate)
start.setDate(start.getDate() - start.getDay())
for (let i = 0; i < 7; i++) {
const d = new Date(start)
d.setDate(start.getDate() + i)
cells.push({ day: d.getDate(), current: true, date: d })
}
return cells
}
function dateKey(d) {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
@@ -36,8 +48,12 @@ export default function TaskCalendarView({ tasks, onTaskClick }) {
const today = new Date()
const [year, setYear] = useState(today.getFullYear())
const [month, setMonth] = useState(today.getMonth())
const [calView, setCalView] = useState('month')
const [weekStart, setWeekStart] = useState(() => {
const d = new Date(); d.setDate(d.getDate() - d.getDay()); return d
})
const cells = getMonthData(year, month)
const cells = calView === 'month' ? getMonthData(year, month) : getWeekData(weekStart)
const todayKey = dateKey(today)
// Group tasks by due_date
@@ -62,9 +78,22 @@ export default function TaskCalendarView({ tasks, onTaskClick }) {
if (month === 11) { setMonth(0); setYear(y => y + 1) }
else setMonth(m => m + 1)
}
const goToday = () => { setYear(today.getFullYear()); setMonth(today.getMonth()) }
const prevWeek = () => setWeekStart(d => { const n = new Date(d); n.setDate(n.getDate() - 7); return n })
const nextWeek = () => setWeekStart(d => { const n = new Date(d); n.setDate(n.getDate() + 7); return n })
const goToday = () => {
setYear(today.getFullYear()); setMonth(today.getMonth())
const d = new Date(); d.setDate(d.getDate() - d.getDay()); setWeekStart(d)
}
const monthLabel = new Date(year, month).toLocaleString('default', { month: 'long', year: 'numeric' })
const weekLabel = (() => {
const start = new Date(weekStart)
start.setDate(start.getDate() - start.getDay())
const end = new Date(start); end.setDate(start.getDate() + 6)
const fmt = (d) => d.toLocaleString('default', { month: 'short', day: 'numeric' })
return `${fmt(start)} ${fmt(end)}, ${end.getFullYear()}`
})()
const getPillColor = (task) => {
const p = task.priority || 'medium'
@@ -81,17 +110,37 @@ export default function TaskCalendarView({ tasks, onTaskClick }) {
{/* Nav */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<button onClick={prevMonth} className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors">
<button onClick={calView === 'month' ? prevMonth : prevWeek} className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors">
<ChevronLeft className="w-4 h-4" />
</button>
<h3 className="text-sm font-semibold text-text-primary min-w-[150px] text-center">{monthLabel}</h3>
<button onClick={nextMonth} className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors">
<h3 className="text-sm font-semibold text-text-primary min-w-[180px] text-center">
{calView === 'month' ? monthLabel : weekLabel}
</h3>
<button onClick={calView === 'month' ? nextMonth : nextWeek} className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors">
<ChevronRight className="w-4 h-4" />
</button>
</div>
<button onClick={goToday} className="px-3 py-1 text-xs font-medium text-brand-primary hover:bg-brand-primary/5 rounded-lg transition-colors">
{t('tasks.today')}
</button>
<div className="flex items-center gap-2">
<div className="flex bg-surface-tertiary rounded-lg p-0.5">
<button
onClick={() => setCalView('month')}
className={`px-2.5 py-1 text-[11px] font-medium rounded-md transition-colors flex items-center gap-1 ${calView === 'month' ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary'}`}
>
<CalendarIcon className="w-3 h-3" />
Month
</button>
<button
onClick={() => setCalView('week')}
className={`px-2.5 py-1 text-[11px] font-medium rounded-md transition-colors flex items-center gap-1 ${calView === 'week' ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary'}`}
>
<CalendarDays className="w-3 h-3" />
Week
</button>
</div>
<button onClick={goToday} className="px-3 py-1 text-xs font-medium text-brand-primary hover:bg-brand-primary/5 rounded-lg transition-colors">
{t('tasks.today')}
</button>
</div>
</div>
{/* Day headers */}
@@ -112,7 +161,7 @@ export default function TaskCalendarView({ tasks, onTaskClick }) {
return (
<div
key={i}
className={`border-r border-b border-border min-h-[90px] p-1 ${
className={`border-r border-b border-border ${calView === 'week' ? 'min-h-[300px]' : 'min-h-[90px]'} p-1 ${
cell.current ? 'bg-white' : 'bg-surface-secondary/50'
}`}
>
@@ -122,7 +171,7 @@ export default function TaskCalendarView({ tasks, onTaskClick }) {
{cell.day}
</div>
<div className="space-y-0.5">
{dayTasks.slice(0, 3).map(task => (
{dayTasks.slice(0, calView === 'week' ? 10 : 3).map(task => (
<button
key={task._id || task.id}
onClick={() => onTaskClick(task)}
@@ -134,9 +183,9 @@ export default function TaskCalendarView({ tasks, onTaskClick }) {
{task.title}
</button>
))}
{dayTasks.length > 3 && (
{dayTasks.length > (calView === 'week' ? 10 : 3) && (
<div className="text-[9px] text-text-tertiary text-center font-medium">
+{dayTasks.length - 3} more
+{dayTasks.length - (calView === 'week' ? 10 : 3)} more
</div>
)}
</div>

View File

@@ -120,7 +120,7 @@ export default function TaskDetailPanel({ task, onClose, onSave, onDelete, proje
if (!taskId) return
try {
const data = await api.get(`/tasks/${taskId}/attachments`)
setAttachments(Array.isArray(data) ? data : (data.data || []))
setAttachments(Array.isArray(data) ? data : [])
} catch {
setAttachments([])
}