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:
86
client/src/components/ApproverMultiSelect.jsx
Normal file
86
client/src/components/ApproverMultiSelect.jsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Check, ChevronDown, X } from 'lucide-react'
|
||||
|
||||
export default function ApproverMultiSelect({ users = [], selected = [], onChange }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const wrapperRef = useRef(null)
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const handleClick = (e) => {
|
||||
if (wrapperRef.current && !wrapperRef.current.contains(e.target)) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
return () => document.removeEventListener('mousedown', handleClick)
|
||||
}, [open])
|
||||
|
||||
const toggle = (userId) => {
|
||||
const id = String(userId)
|
||||
const next = selected.includes(id) ? selected.filter(s => s !== id) : [...selected, id]
|
||||
onChange(next)
|
||||
}
|
||||
|
||||
const remove = (id) => {
|
||||
onChange(selected.filter(s => s !== String(id)))
|
||||
}
|
||||
|
||||
const selectedUsers = selected.map(id => users.find(u => String(u._id || u.id || u.Id) === String(id))).filter(Boolean)
|
||||
|
||||
return (
|
||||
<div className="relative" ref={wrapperRef}>
|
||||
<div
|
||||
onClick={() => setOpen(!open)}
|
||||
className={`w-full min-h-[38px] px-3 py-1.5 text-sm border rounded-lg bg-surface cursor-pointer flex items-center flex-wrap gap-1.5 transition-colors ${
|
||||
open ? 'border-brand-primary ring-2 ring-brand-primary/20' : 'border-border'
|
||||
}`}
|
||||
>
|
||||
{selectedUsers.length === 0 && (
|
||||
<span className="text-text-tertiary">Select approvers...</span>
|
||||
)}
|
||||
{selectedUsers.map(u => (
|
||||
<span
|
||||
key={u._id || u.id || u.Id}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-amber-100 text-amber-800 text-xs font-medium"
|
||||
>
|
||||
{u.name}
|
||||
<button
|
||||
type="button"
|
||||
onClick={e => { e.stopPropagation(); remove(u._id || u.id || u.Id) }}
|
||||
className="hover:text-amber-950"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<ChevronDown className={`w-4 h-4 text-text-tertiary ml-auto shrink-0 transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||
</div>
|
||||
{open && (
|
||||
<div className="absolute z-50 mt-1 w-full bg-surface border border-border rounded-lg shadow-lg max-h-48 overflow-y-auto">
|
||||
{users.map(u => {
|
||||
const uid = String(u._id || u.id || u.Id)
|
||||
const isSelected = selected.includes(uid)
|
||||
return (
|
||||
<button
|
||||
key={uid}
|
||||
type="button"
|
||||
onClick={() => toggle(uid)}
|
||||
className={`w-full text-left px-3 py-2 text-sm hover:bg-surface-secondary flex items-center justify-between ${
|
||||
isSelected ? 'text-brand-primary font-medium' : 'text-text-primary'
|
||||
}`}
|
||||
>
|
||||
<span>{u.name}</span>
|
||||
{isSelected && <Check className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{users.length === 0 && (
|
||||
<div className="px-3 py-4 text-sm text-text-tertiary text-center">No users available</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
961
client/src/components/ArtefactDetailPanel.jsx
Normal file
961
client/src/components/ArtefactDetailPanel.jsx
Normal file
@@ -0,0 +1,961 @@
|
||||
import { useState, useEffect, useContext } from 'react'
|
||||
import { Plus, Copy, Check, ExternalLink, Upload, Globe, Trash2, FileText, Image as ImageIcon, Film, Sparkles, MessageSquare, Save } from 'lucide-react'
|
||||
import { AppContext } from '../App'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import { api } from '../utils/api'
|
||||
import Modal from './Modal'
|
||||
import SlidePanel from './SlidePanel'
|
||||
import { useToast } from './ToastContainer'
|
||||
import ArtefactVersionTimeline from './ArtefactVersionTimeline'
|
||||
import ApproverMultiSelect from './ApproverMultiSelect'
|
||||
|
||||
const STATUS_COLORS = {
|
||||
draft: 'bg-surface-tertiary text-text-secondary',
|
||||
pending_review: 'bg-amber-100 text-amber-700',
|
||||
approved: 'bg-emerald-100 text-emerald-700',
|
||||
rejected: 'bg-red-100 text-red-700',
|
||||
revision_requested: 'bg-orange-100 text-orange-700',
|
||||
}
|
||||
|
||||
const AVAILABLE_LANGUAGES = [
|
||||
{ code: 'AR', label: '\u0627\u0644\u0639\u0631\u0628\u064A\u0629' },
|
||||
{ code: 'EN', label: 'English' },
|
||||
{ code: 'FR', label: 'Fran\u00E7ais' },
|
||||
{ code: 'ID', label: 'Bahasa Indonesia' },
|
||||
]
|
||||
|
||||
const TYPE_ICONS = {
|
||||
copy: FileText,
|
||||
design: ImageIcon,
|
||||
video: Film,
|
||||
other: Sparkles,
|
||||
}
|
||||
|
||||
export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDelete, projects = [], campaigns = [], assignableUsers = [] }) {
|
||||
const { t } = useLanguage()
|
||||
const { brands } = useContext(AppContext)
|
||||
const toast = useToast()
|
||||
const [versions, setVersions] = useState([])
|
||||
const [selectedVersion, setSelectedVersion] = useState(null)
|
||||
const [versionData, setVersionData] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [reviewUrl, setReviewUrl] = useState('')
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
// Editable fields
|
||||
const [editTitle, setEditTitle] = useState(artefact.title || '')
|
||||
const [editDescription, setEditDescription] = useState(artefact.description || '')
|
||||
const [editProjectId, setEditProjectId] = useState(artefact.project_id || '')
|
||||
const [editCampaignId, setEditCampaignId] = useState(artefact.campaign_id || '')
|
||||
const [editApproverIds, setEditApproverIds] = useState(
|
||||
artefact.approvers?.map(a => String(a.id)) || (artefact.approver_ids ? artefact.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : [])
|
||||
)
|
||||
const [savingDraft, setSavingDraft] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [confirmDeleteLangId, setConfirmDeleteLangId] = useState(null)
|
||||
const [confirmDeleteAttId, setConfirmDeleteAttId] = useState(null)
|
||||
const [showDeleteArtefactConfirm, setShowDeleteArtefactConfirm] = useState(false)
|
||||
|
||||
// Language management (for copy type)
|
||||
const [showLanguageModal, setShowLanguageModal] = useState(false)
|
||||
const [languageForm, setLanguageForm] = useState({ language_code: '', language_label: '', content: '' })
|
||||
const [savingLanguage, setSavingLanguage] = useState(false)
|
||||
|
||||
// New version modal
|
||||
const [showNewVersionModal, setShowNewVersionModal] = useState(false)
|
||||
const [newVersionNotes, setNewVersionNotes] = useState('')
|
||||
const [copyFromPrevious, setCopyFromPrevious] = useState(true)
|
||||
const [creatingVersion, setCreatingVersion] = useState(false)
|
||||
|
||||
// File upload (for design/video)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
|
||||
// Video modal (for video type with Drive link)
|
||||
const [showVideoModal, setShowVideoModal] = useState(false)
|
||||
const [videoMode, setVideoMode] = useState('upload') // 'upload' or 'drive'
|
||||
const [driveUrl, setDriveUrl] = useState('')
|
||||
|
||||
// Comments
|
||||
const [comments, setComments] = useState([])
|
||||
const [newComment, setNewComment] = useState('')
|
||||
const [addingComment, setAddingComment] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadVersions()
|
||||
}, [artefact.Id])
|
||||
|
||||
useEffect(() => {
|
||||
setEditTitle(artefact.title || '')
|
||||
setEditDescription(artefact.description || '')
|
||||
setEditProjectId(artefact.project_id || '')
|
||||
setEditCampaignId(artefact.campaign_id || '')
|
||||
setEditApproverIds(
|
||||
artefact.approvers?.map(a => String(a.id)) || (artefact.approver_ids ? artefact.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : [])
|
||||
)
|
||||
}, [artefact.Id])
|
||||
|
||||
const loadVersions = async () => {
|
||||
try {
|
||||
const res = await api.get(`/artefacts/${artefact.Id}/versions`)
|
||||
const versionsList = Array.isArray(res) ? res : []
|
||||
setVersions(versionsList)
|
||||
|
||||
// Select latest version by default
|
||||
if (versionsList.length > 0) {
|
||||
const latest = versionsList[versionsList.length - 1]
|
||||
setSelectedVersion(latest)
|
||||
loadVersionData(latest.Id)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load versions:', err)
|
||||
toast.error('Failed to load versions')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadVersionData = async (versionId) => {
|
||||
try {
|
||||
const [versionRes, commentsRes] = await Promise.all([
|
||||
api.get(`/artefacts/${artefact.Id}/versions/${versionId}`),
|
||||
api.get(`/artefacts/${artefact.Id}/versions/${versionId}/comments`),
|
||||
])
|
||||
|
||||
setVersionData(versionRes.data || versionRes)
|
||||
setComments(commentsRes.data || commentsRes || [])
|
||||
} catch (err) {
|
||||
console.error('Failed to load version data:', err)
|
||||
toast.error('Failed to load version data')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectVersion = (version) => {
|
||||
setSelectedVersion(version)
|
||||
loadVersionData(version.Id)
|
||||
}
|
||||
|
||||
const handleCreateVersion = async () => {
|
||||
setCreatingVersion(true)
|
||||
try {
|
||||
await api.post(`/artefacts/${artefact.Id}/versions`, {
|
||||
notes: newVersionNotes || `Version ${(versions[versions.length - 1]?.version_number || 0) + 1}`,
|
||||
copy_from_previous: artefact.type === 'copy' ? copyFromPrevious : false,
|
||||
})
|
||||
|
||||
toast.success('New version created')
|
||||
setShowNewVersionModal(false)
|
||||
setNewVersionNotes('')
|
||||
setCopyFromPrevious(true)
|
||||
loadVersions()
|
||||
onUpdate()
|
||||
} catch (err) {
|
||||
console.error('Create version failed:', err)
|
||||
toast.error('Failed to create version')
|
||||
} finally {
|
||||
setCreatingVersion(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddLanguage = async () => {
|
||||
if (!languageForm.language_code || !languageForm.language_label || !languageForm.content) {
|
||||
toast.error('All fields are required')
|
||||
return
|
||||
}
|
||||
|
||||
setSavingLanguage(true)
|
||||
try {
|
||||
await api.post(`/artefacts/${artefact.Id}/versions/${selectedVersion.Id}/texts`, languageForm)
|
||||
toast.success('Language added')
|
||||
setShowLanguageModal(false)
|
||||
setLanguageForm({ language_code: '', language_label: '', content: '' })
|
||||
loadVersionData(selectedVersion.Id)
|
||||
} catch (err) {
|
||||
console.error('Add language failed:', err)
|
||||
toast.error('Failed to add language')
|
||||
} finally {
|
||||
setSavingLanguage(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteLanguage = async (textId) => {
|
||||
try {
|
||||
await api.delete(`/artefact-version-texts/${textId}`)
|
||||
toast.success('Language deleted')
|
||||
loadVersionData(selectedVersion.Id)
|
||||
} catch (err) {
|
||||
toast.error('Failed to delete language')
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileUpload = async (e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
setUploading(true)
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
await api.upload(`/artefacts/${artefact.Id}/versions/${selectedVersion.Id}/attachments`, formData)
|
||||
toast.success('File uploaded')
|
||||
loadVersionData(selectedVersion.Id)
|
||||
} catch (err) {
|
||||
console.error('Upload failed:', err)
|
||||
toast.error('Upload failed')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddDriveVideo = async () => {
|
||||
if (!driveUrl.trim()) {
|
||||
toast.error('Please enter a Google Drive URL')
|
||||
return
|
||||
}
|
||||
|
||||
setUploading(true)
|
||||
try {
|
||||
await api.post(`/artefacts/${artefact.Id}/versions/${selectedVersion.Id}/attachments`, {
|
||||
drive_url: driveUrl,
|
||||
})
|
||||
toast.success('Video link added')
|
||||
setShowVideoModal(false)
|
||||
setDriveUrl('')
|
||||
loadVersionData(selectedVersion.Id)
|
||||
} catch (err) {
|
||||
console.error('Add Drive link failed:', err)
|
||||
toast.error('Failed to add video link')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteAttachment = async (attId) => {
|
||||
try {
|
||||
await api.delete(`/artefact-attachments/${attId}`)
|
||||
toast.success('Attachment deleted')
|
||||
loadVersionData(selectedVersion.Id)
|
||||
} catch (err) {
|
||||
toast.error('Failed to delete attachment')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmitReview = async () => {
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const res = await api.post(`/artefacts/${artefact.Id}/submit-review`)
|
||||
setReviewUrl(res.reviewUrl || res.data?.reviewUrl || '')
|
||||
toast.success('Submitted for review!')
|
||||
onUpdate()
|
||||
} catch (err) {
|
||||
toast.error('Failed to submit for review')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const copyReviewLink = () => {
|
||||
navigator.clipboard.writeText(reviewUrl)
|
||||
setCopied(true)
|
||||
toast.success('Link copied to clipboard')
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
const handleAddComment = async () => {
|
||||
if (!newComment.trim()) return
|
||||
|
||||
setAddingComment(true)
|
||||
try {
|
||||
await api.post(`/artefacts/${artefact.Id}/versions/${selectedVersion.Id}/comments`, {
|
||||
content: newComment.trim(),
|
||||
})
|
||||
toast.success('Comment added')
|
||||
setNewComment('')
|
||||
loadVersionData(selectedVersion.Id)
|
||||
} catch (err) {
|
||||
toast.error('Failed to add comment')
|
||||
} finally {
|
||||
setAddingComment(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateField = async (field, value) => {
|
||||
try {
|
||||
await api.patch(`/artefacts/${artefact.Id}`, { [field]: value || null })
|
||||
toast.success('Updated')
|
||||
onUpdate()
|
||||
} catch (err) {
|
||||
toast.error('Failed to update')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveDraft = async () => {
|
||||
if (!editTitle.trim()) {
|
||||
toast.error('Title is required')
|
||||
return
|
||||
}
|
||||
setSavingDraft(true)
|
||||
try {
|
||||
await api.patch(`/artefacts/${artefact.Id}`, {
|
||||
title: editTitle.trim(),
|
||||
description: editDescription.trim() || null,
|
||||
})
|
||||
toast.success('Draft saved')
|
||||
onUpdate()
|
||||
} catch (err) {
|
||||
toast.error('Failed to save draft')
|
||||
} finally {
|
||||
setSavingDraft(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteArtefact = async () => {
|
||||
setDeleting(true)
|
||||
try {
|
||||
await onDelete(artefact.Id || artefact.id || artefact._id)
|
||||
} catch (err) {
|
||||
toast.error('Failed to delete')
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const extractDriveFileId = (url) => {
|
||||
const patterns = [
|
||||
/\/file\/d\/([^\/]+)/,
|
||||
/id=([^&]+)/,
|
||||
/\/d\/([^\/]+)/,
|
||||
]
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = url.match(pattern)
|
||||
if (match) return match[1]
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const getDriveEmbedUrl = (url) => {
|
||||
const fileId = extractDriveFileId(url)
|
||||
return fileId ? `https://drive.google.com/file/d/${fileId}/preview` : url
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<SlidePanel onClose={onClose} maxWidth="700px">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="w-8 h-8 border-4 border-brand-primary border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
</SlidePanel>
|
||||
)
|
||||
}
|
||||
|
||||
const TypeIcon = TYPE_ICONS[artefact.type] || Sparkles
|
||||
|
||||
return (
|
||||
<SlidePanel onClose={onClose} maxWidth="700px" header={
|
||||
<div className="px-6 py-4 border-b border-border">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-brand-primary/10 flex items-center justify-center shrink-0">
|
||||
<TypeIcon className="w-5 h-5 text-brand-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<input
|
||||
type="text"
|
||||
value={editTitle}
|
||||
onChange={e => setEditTitle(e.target.value)}
|
||||
className="w-full text-lg font-semibold text-text-primary bg-transparent border-0 border-b border-transparent hover:border-border focus:border-brand-primary focus:outline-none focus:ring-0 px-0 py-0.5 transition-colors"
|
||||
/>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${STATUS_COLORS[artefact.status] || 'bg-surface-tertiary text-text-secondary'}`}>
|
||||
{artefact.status?.replace('_', ' ')}
|
||||
</span>
|
||||
<span className="text-xs text-text-tertiary capitalize">{artefact.type}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
onClick={handleSaveDraft}
|
||||
disabled={savingDraft}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light disabled:opacity-50 transition-colors"
|
||||
title="Save draft"
|
||||
>
|
||||
<Save className="w-3.5 h-3.5" />
|
||||
{savingDraft ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
{onDelete && (
|
||||
<button
|
||||
onClick={() => setShowDeleteArtefactConfirm(true)}
|
||||
disabled={deleting}
|
||||
className="p-1.5 text-text-tertiary hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title="Delete artefact"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Description */}
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-2">Description</h4>
|
||||
<textarea
|
||||
value={editDescription}
|
||||
onChange={e => setEditDescription(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 text-sm text-text-secondary bg-surface-secondary border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary resize-none"
|
||||
placeholder="Add a description..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Project & Campaign dropdowns */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1.5">{t('artefacts.project')}</h4>
|
||||
<select
|
||||
value={editProjectId}
|
||||
onChange={e => {
|
||||
setEditProjectId(e.target.value)
|
||||
handleUpdateField('project_id', e.target.value)
|
||||
}}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 bg-surface"
|
||||
>
|
||||
<option value="">—</option>
|
||||
{projects.map(p => <option key={p.Id || p._id || p.id} value={p.Id || p._id || p.id}>{p.name || p.title}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1.5">{t('artefacts.campaign')}</h4>
|
||||
<select
|
||||
value={editCampaignId}
|
||||
onChange={e => {
|
||||
setEditCampaignId(e.target.value)
|
||||
handleUpdateField('campaign_id', e.target.value)
|
||||
}}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 bg-surface"
|
||||
>
|
||||
<option value="">—</option>
|
||||
{campaigns.map(c => <option key={c.Id || c._id || c.id} value={c.Id || c._id || c.id}>{c.name || c.title}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Approvers */}
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1.5">Approvers</h4>
|
||||
<ApproverMultiSelect
|
||||
users={assignableUsers}
|
||||
selected={editApproverIds}
|
||||
onChange={ids => {
|
||||
setEditApproverIds(ids)
|
||||
handleUpdateField('approver_ids', ids.length > 0 ? ids.join(',') : '')
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Version Timeline */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase">Versions</h4>
|
||||
<button
|
||||
onClick={() => setShowNewVersionModal(true)}
|
||||
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
New Version
|
||||
</button>
|
||||
</div>
|
||||
<ArtefactVersionTimeline
|
||||
versions={versions}
|
||||
activeVersionId={selectedVersion?.Id}
|
||||
onSelectVersion={handleSelectVersion}
|
||||
artefactType={artefact.type}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Type-specific content */}
|
||||
{versionData && selectedVersion && (
|
||||
<div className="border-t border-border pt-6">
|
||||
{/* COPY TYPE: Language entries */}
|
||||
{artefact.type === 'copy' && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase">Languages</h4>
|
||||
<button
|
||||
onClick={() => setShowLanguageModal(true)}
|
||||
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
Add Language
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{versionData.texts && versionData.texts.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{versionData.texts.map(text => (
|
||||
<div key={text.Id} className="bg-surface-secondary rounded-lg p-4 border border-border">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="px-2 py-0.5 bg-surface border border-border rounded text-xs font-mono font-medium">
|
||||
{text.language_code}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-text-primary">{text.language_label}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setConfirmDeleteLangId(text.Id)}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="bg-surface rounded border border-border p-3 text-sm text-text-primary whitespace-pre-wrap font-sans">
|
||||
{text.content}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 bg-surface-secondary rounded-lg border-2 border-dashed border-border">
|
||||
<Globe className="w-8 h-8 text-text-tertiary mx-auto mb-2" />
|
||||
<p className="text-sm text-text-secondary">No languages added yet</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* DESIGN TYPE: Image gallery */}
|
||||
{artefact.type === 'design' && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase">Images</h4>
|
||||
<label className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors cursor-pointer">
|
||||
<Upload className="w-3 h-3" />
|
||||
{uploading ? 'Uploading...' : 'Upload Image'}
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept="image/*"
|
||||
onChange={handleFileUpload}
|
||||
disabled={uploading}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{versionData.attachments && versionData.attachments.length > 0 ? (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{versionData.attachments.map(att => (
|
||||
<div key={att.Id} className="relative group">
|
||||
<img
|
||||
src={att.url}
|
||||
alt={att.original_name}
|
||||
className="w-full h-48 object-cover rounded-lg border border-border"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors rounded-lg flex items-center justify-center">
|
||||
<button
|
||||
onClick={() => setConfirmDeleteAttId(att.Id)}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity px-3 py-2 bg-red-600 text-white rounded-lg text-xs font-medium hover:bg-red-700"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-1 px-2 py-1 bg-surface-secondary rounded text-xs text-text-secondary truncate">
|
||||
{att.original_name}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 bg-surface-secondary rounded-lg border-2 border-dashed border-border">
|
||||
<ImageIcon className="w-8 h-8 text-text-tertiary mx-auto mb-2" />
|
||||
<p className="text-sm text-text-secondary">No images uploaded yet</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* VIDEO TYPE: Files and Drive links */}
|
||||
{artefact.type === 'video' && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase">Videos</h4>
|
||||
<button
|
||||
onClick={() => setShowVideoModal(true)}
|
||||
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
Add Video
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{versionData.attachments && versionData.attachments.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{versionData.attachments.map(att => (
|
||||
<div key={att.Id} className="bg-surface-secondary rounded-lg p-4 border border-border">
|
||||
{att.drive_url ? (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-text-primary">Google Drive Video</span>
|
||||
<button
|
||||
onClick={() => setConfirmDeleteAttId(att.Id)}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<iframe
|
||||
src={getDriveEmbedUrl(att.drive_url)}
|
||||
className="w-full h-64 rounded border border-border"
|
||||
allow="autoplay"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-text-primary truncate">{att.original_name}</span>
|
||||
<button
|
||||
onClick={() => setConfirmDeleteAttId(att.Id)}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<video
|
||||
src={att.url}
|
||||
controls
|
||||
className="w-full rounded border border-border"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 bg-surface-secondary rounded-lg border-2 border-dashed border-border">
|
||||
<Film className="w-8 h-8 text-text-tertiary mx-auto mb-2" />
|
||||
<p className="text-sm text-text-secondary">No videos added yet</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Comments */}
|
||||
{selectedVersion && (
|
||||
<div className="border-t border-border pt-6">
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-3">
|
||||
Comments ({comments.length})
|
||||
</h4>
|
||||
|
||||
<div className="space-y-3 mb-4">
|
||||
{comments.map(comment => (
|
||||
<div key={comment.Id} className="flex gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-brand-primary/10 flex items-center justify-center shrink-0">
|
||||
{comment.user_avatar ? (
|
||||
<img src={comment.user_avatar} alt="" className="w-full h-full rounded-full object-cover" />
|
||||
) : (
|
||||
<MessageSquare className="w-4 h-4 text-brand-primary" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 bg-surface-secondary rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-sm font-medium text-text-primary">{comment.user_name || 'Anonymous'}</span>
|
||||
<span className="text-xs text-text-tertiary">
|
||||
{new Date(comment.CreatedAt).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-text-secondary whitespace-pre-wrap">{comment.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newComment}
|
||||
onChange={e => setNewComment(e.target.value)}
|
||||
onKeyPress={e => e.key === 'Enter' && handleAddComment()}
|
||||
placeholder="Add a comment..."
|
||||
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
/>
|
||||
<button
|
||||
onClick={handleAddComment}
|
||||
disabled={addingComment || !newComment.trim()}
|
||||
className="px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 transition-colors"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit for Review */}
|
||||
{['draft', 'revision_requested', 'rejected'].includes(artefact.status) && (
|
||||
<div className="border-t border-border pt-6">
|
||||
<button
|
||||
onClick={handleSubmitReview}
|
||||
disabled={submitting}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-amber-500 text-white rounded-lg hover:bg-amber-600 transition-colors font-medium disabled:opacity-50"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
{submitting ? 'Submitting...' : 'Submit for Review'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Review Link */}
|
||||
{reviewUrl && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div className="text-sm font-semibold text-blue-900 mb-2">Review Link (expires in 7 days)</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={reviewUrl}
|
||||
readOnly
|
||||
className="flex-1 px-3 py-2 text-sm bg-surface border border-border rounded"
|
||||
/>
|
||||
<button
|
||||
onClick={copyReviewLink}
|
||||
className="p-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Feedback */}
|
||||
{artefact.feedback && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
|
||||
<h4 className="text-sm font-semibold text-amber-900 mb-2">Feedback</h4>
|
||||
<p className="text-sm text-amber-800 whitespace-pre-wrap">{artefact.feedback}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Approval Info */}
|
||||
{artefact.status === 'approved' && artefact.approved_by_name && (
|
||||
<div className="bg-emerald-50 border border-emerald-200 rounded-lg p-4">
|
||||
<div className="font-medium text-emerald-900">Approved by {artefact.approved_by_name}</div>
|
||||
{artefact.approved_at && (
|
||||
<div className="text-sm text-emerald-700 mt-1">
|
||||
{new Date(artefact.approved_at).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Language Modal */}
|
||||
<Modal isOpen={showLanguageModal} onClose={() => setShowLanguageModal(false)} title="Add Language" size="md">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Language *</label>
|
||||
<select
|
||||
value={languageForm.language_code}
|
||||
onChange={e => {
|
||||
const lang = AVAILABLE_LANGUAGES.find(l => l.code === e.target.value)
|
||||
if (lang) setLanguageForm(f => ({ ...f, language_code: lang.code, language_label: lang.label }))
|
||||
else setLanguageForm(f => ({ ...f, language_code: '', language_label: '' }))
|
||||
}}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||
>
|
||||
<option value="">Select a language...</option>
|
||||
{AVAILABLE_LANGUAGES
|
||||
.filter(lang => !(versionData?.texts || []).some(t => t.language_code === lang.code))
|
||||
.map(lang => (
|
||||
<option key={lang.code} value={lang.code}>{lang.label} ({lang.code})</option>
|
||||
))
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Content *</label>
|
||||
<textarea
|
||||
value={languageForm.content}
|
||||
onChange={e => setLanguageForm(f => ({ ...f, content: e.target.value }))}
|
||||
rows={8}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 font-sans"
|
||||
placeholder="Enter the content in this language..."
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
|
||||
<button
|
||||
onClick={() => setShowLanguageModal(false)}
|
||||
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAddLanguage}
|
||||
disabled={savingLanguage}
|
||||
className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 shadow-sm"
|
||||
>
|
||||
{savingLanguage ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* New Version Modal */}
|
||||
<Modal isOpen={showNewVersionModal} onClose={() => setShowNewVersionModal(false)} title="Create New Version" size="sm">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Version Notes</label>
|
||||
<textarea
|
||||
value={newVersionNotes}
|
||||
onChange={e => setNewVersionNotes(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||
placeholder="What changed in this version?"
|
||||
/>
|
||||
</div>
|
||||
{artefact.type === 'copy' && versions.length > 0 && (
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={copyFromPrevious}
|
||||
onChange={e => setCopyFromPrevious(e.target.checked)}
|
||||
className="w-4 h-4 text-brand-primary border-border rounded focus:ring-brand-primary"
|
||||
/>
|
||||
<span className="text-sm text-text-secondary">Copy languages from previous version</span>
|
||||
</label>
|
||||
)}
|
||||
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
|
||||
<button
|
||||
onClick={() => setShowNewVersionModal(false)}
|
||||
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreateVersion}
|
||||
disabled={creatingVersion}
|
||||
className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 shadow-sm"
|
||||
>
|
||||
{creatingVersion ? 'Creating...' : 'Create Version'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Video Modal */}
|
||||
<Modal isOpen={showVideoModal} onClose={() => setShowVideoModal(false)} title="Add Video" size="md">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 border-b border-border pb-3">
|
||||
<button
|
||||
onClick={() => setVideoMode('upload')}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||
videoMode === 'upload'
|
||||
? 'bg-brand-primary text-white'
|
||||
: 'bg-surface-secondary text-text-secondary hover:bg-surface-tertiary'
|
||||
}`}
|
||||
>
|
||||
Upload File
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setVideoMode('drive')}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||
videoMode === 'drive'
|
||||
? 'bg-brand-primary text-white'
|
||||
: 'bg-surface-secondary text-text-secondary hover:bg-surface-tertiary'
|
||||
}`}
|
||||
>
|
||||
Google Drive Link
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{videoMode === 'upload' ? (
|
||||
<div>
|
||||
<label className="flex flex-col items-center gap-3 px-6 py-8 border-2 border-dashed border-border rounded-lg hover:border-brand-primary/30 transition-colors cursor-pointer">
|
||||
<Upload className="w-8 h-8 text-text-tertiary" />
|
||||
<div className="text-center">
|
||||
<span className="text-sm font-medium text-text-primary">
|
||||
{uploading ? 'Uploading...' : 'Choose video file'}
|
||||
</span>
|
||||
<p className="text-xs text-text-tertiary mt-1">MP4, MOV, AVI, etc.</p>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept="video/*"
|
||||
onChange={handleFileUpload}
|
||||
disabled={uploading}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-2">Google Drive URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={driveUrl}
|
||||
onChange={e => setDriveUrl(e.target.value)}
|
||||
placeholder="https://drive.google.com/file/d/..."
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||
/>
|
||||
<p className="text-xs text-text-tertiary mt-2">
|
||||
Paste a Google Drive share link. Make sure the file is publicly accessible.
|
||||
</p>
|
||||
<div className="flex justify-end gap-3 mt-4">
|
||||
<button
|
||||
onClick={() => setShowVideoModal(false)}
|
||||
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAddDriveVideo}
|
||||
disabled={uploading || !driveUrl.trim()}
|
||||
className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 shadow-sm"
|
||||
>
|
||||
{uploading ? 'Adding...' : 'Add Link'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Delete Language Confirmation */}
|
||||
<Modal
|
||||
isOpen={!!confirmDeleteLangId}
|
||||
onClose={() => setConfirmDeleteLangId(null)}
|
||||
title={t('artefacts.deleteLanguage')}
|
||||
isConfirm
|
||||
danger
|
||||
onConfirm={() => handleDeleteLanguage(confirmDeleteLangId)}
|
||||
confirmText={t('common.delete')}
|
||||
>
|
||||
{t('artefacts.deleteLanguageDesc')}
|
||||
</Modal>
|
||||
|
||||
{/* Delete Attachment Confirmation */}
|
||||
<Modal
|
||||
isOpen={!!confirmDeleteAttId}
|
||||
onClose={() => setConfirmDeleteAttId(null)}
|
||||
title={t('artefacts.deleteAttachment')}
|
||||
isConfirm
|
||||
danger
|
||||
onConfirm={() => handleDeleteAttachment(confirmDeleteAttId)}
|
||||
confirmText={t('common.delete')}
|
||||
>
|
||||
{t('artefacts.deleteAttachmentDesc')}
|
||||
</Modal>
|
||||
|
||||
{/* Delete Artefact Confirmation */}
|
||||
<Modal
|
||||
isOpen={showDeleteArtefactConfirm}
|
||||
onClose={() => setShowDeleteArtefactConfirm(false)}
|
||||
title={t('artefacts.deleteArtefact')}
|
||||
isConfirm
|
||||
danger
|
||||
onConfirm={handleDeleteArtefact}
|
||||
confirmText={t('common.delete')}
|
||||
>
|
||||
{t('artefacts.deleteArtefactDesc')}
|
||||
</Modal>
|
||||
</SlidePanel>
|
||||
)
|
||||
}
|
||||
30
client/src/components/BulkSelectBar.jsx
Normal file
30
client/src/components/BulkSelectBar.jsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Trash2, X } from 'lucide-react'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
|
||||
export default function BulkSelectBar({ selectedCount, onDelete, onClear }) {
|
||||
const { t } = useLanguage()
|
||||
if (selectedCount === 0) return null
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between px-4 py-2.5 bg-red-50 border border-red-200 rounded-lg animate-fade-in">
|
||||
<span className="text-sm font-medium text-red-800">
|
||||
{selectedCount} {t('common.selected')}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onClear}
|
||||
className="text-xs text-text-tertiary hover:text-text-primary transition-colors"
|
||||
>
|
||||
{t('common.clearSelection')}
|
||||
</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-red-600 text-white rounded-lg text-xs font-medium hover:bg-red-700 transition-colors"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
{t('common.deleteSelected')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -33,7 +33,7 @@ export default function CommentsSection({ entityType, entityId }) {
|
||||
const loadComments = async () => {
|
||||
try {
|
||||
const data = await api.get(`/comments/${entityType}/${entityId}`)
|
||||
setComments(Array.isArray(data) ? data : (data.data || []))
|
||||
setComments(Array.isArray(data) ? data : [])
|
||||
} catch (err) {
|
||||
console.error('Failed to load comments:', err)
|
||||
}
|
||||
|
||||
41
client/src/components/ErrorBoundary.jsx
Normal file
41
client/src/components/ErrorBoundary.jsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Component } from 'react'
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
|
||||
export default class ErrorBoundary extends Component {
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.state = { hasError: false, error: null }
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error) {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
|
||||
componentDidCatch(error, errorInfo) {
|
||||
console.error('ErrorBoundary caught:', error, errorInfo)
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="min-h-screen bg-surface-secondary flex items-center justify-center p-4">
|
||||
<div className="max-w-md w-full bg-surface rounded-2xl shadow-sm p-8 text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-red-100 flex items-center justify-center mx-auto mb-4">
|
||||
<AlertTriangle className="w-8 h-8 text-red-600" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-text-primary mb-2">Something went wrong</h2>
|
||||
<p className="text-text-secondary mb-6">An unexpected error occurred. Please try refreshing the page.</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-6 py-2.5 bg-brand-primary text-white rounded-lg font-medium hover:bg-brand-primary-light transition-colors"
|
||||
>
|
||||
Refresh Page
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
@@ -33,8 +33,15 @@ const PRIORITY_BORDER = {
|
||||
}
|
||||
|
||||
const ZOOM_LEVELS = [
|
||||
{ key: 'day', label: 'Day', pxPerDay: 48 },
|
||||
{ key: 'month', label: 'Month', pxPerDay: 8 },
|
||||
{ key: 'week', label: 'Week', pxPerDay: 20 },
|
||||
{ key: 'day', label: 'Day', pxPerDay: 48 },
|
||||
]
|
||||
|
||||
const COLOR_PALETTE = [
|
||||
'#6366f1', '#ec4899', '#10b981', '#f59e0b',
|
||||
'#8b5cf6', '#06b6d4', '#f43f5e', '#14b8a6',
|
||||
'#3b82f6', '#ef4444', '#84cc16', '#a855f7',
|
||||
]
|
||||
|
||||
function getInitials(name) {
|
||||
@@ -42,7 +49,7 @@ function getInitials(name) {
|
||||
return name.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2)
|
||||
}
|
||||
|
||||
export default function InteractiveTimeline({ items = [], mapItem, onDateChange, onItemClick, readOnly = false }) {
|
||||
export default function InteractiveTimeline({ items = [], mapItem, onDateChange, onColorChange, onItemClick, readOnly = false }) {
|
||||
const containerRef = useRef(null)
|
||||
const didDragRef = useRef(false)
|
||||
const optimisticRef = useRef({}) // { [itemId]: { startDate, endDate } } — keeps bar in place until fresh data arrives
|
||||
@@ -51,10 +58,24 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
|
||||
const [tooltip, setTooltip] = useState(null)
|
||||
const [dragState, setDragState] = useState(null) // { itemId, mode: 'move'|'resize-left'|'resize-right', startX, origStart, origEnd }
|
||||
const dragStateRef = useRef(null)
|
||||
const [colorPicker, setColorPicker] = useState(null) // { itemId, x, y }
|
||||
const colorPickerRef = useRef(null)
|
||||
|
||||
const pxPerDay = ZOOM_LEVELS[zoomIdx].pxPerDay
|
||||
const today = useMemo(() => startOfDay(new Date()), [])
|
||||
|
||||
// Close color picker on outside click
|
||||
useEffect(() => {
|
||||
if (!colorPicker) return
|
||||
const handleClick = (e) => {
|
||||
if (colorPickerRef.current && !colorPickerRef.current.contains(e.target)) {
|
||||
setColorPicker(null)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
return () => document.removeEventListener('mousedown', handleClick)
|
||||
}, [colorPicker])
|
||||
|
||||
// Clear optimistic overrides when fresh data arrives
|
||||
useEffect(() => {
|
||||
optimisticRef.current = {}
|
||||
@@ -273,6 +294,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
|
||||
const isToday = differenceInDays(day, today) === 0
|
||||
const isWeekend = day.getDay() === 0 || day.getDay() === 6
|
||||
const isMonthStart = day.getDate() === 1
|
||||
const isWeekStart = day.getDay() === 1 // Monday
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
@@ -285,7 +307,13 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
|
||||
>
|
||||
{pxPerDay >= 30 && <div>{format(day, 'd')}</div>}
|
||||
{pxPerDay >= 40 && <div className="text-[8px] uppercase">{format(day, 'EEE')}</div>}
|
||||
{pxPerDay < 30 && day.getDate() % 7 === 1 && <div>{format(day, 'd')}</div>}
|
||||
{pxPerDay >= 15 && pxPerDay < 30 && day.getDate() % 7 === 1 && <div>{format(day, 'd')}</div>}
|
||||
{pxPerDay < 15 && isMonthStart && (
|
||||
<div className="text-[8px] font-semibold whitespace-nowrap">{format(day, 'MMM')}</div>
|
||||
)}
|
||||
{pxPerDay < 15 && !isMonthStart && isWeekStart && day.getDate() % 14 <= 7 && (
|
||||
<div className="text-[8px]">{format(day, 'd')}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -295,7 +323,8 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
|
||||
{/* Rows */}
|
||||
{mapped.map((item, idx) => {
|
||||
const { left, width } = getBarPosition(item)
|
||||
const statusColor = STATUS_COLORS[item.status] || STATUS_COLORS[item.type] || 'bg-gray-400'
|
||||
const hasCustomColor = !!item.color
|
||||
const statusColor = hasCustomColor ? '' : (STATUS_COLORS[item.status] || STATUS_COLORS[item.type] || 'bg-gray-400')
|
||||
const priorityRing = PRIORITY_BORDER[item.priority] || ''
|
||||
const isDragging = dragState?.itemId === item.id
|
||||
|
||||
@@ -313,6 +342,18 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
|
||||
{isExpanded ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
{onColorChange && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
setColorPicker(colorPicker?.itemId === item.id ? null : { itemId: item.id, x: rect.left, y: rect.bottom + 4 })
|
||||
}}
|
||||
className={`w-5 h-5 rounded-full border-2 border-white shadow-sm shrink-0 hover:scale-110 transition-transform ${!item.color ? (STATUS_COLORS[item.status] || 'bg-gray-400') : ''}`}
|
||||
style={item.color ? { backgroundColor: item.color } : undefined}
|
||||
title="Change color"
|
||||
/>
|
||||
)}
|
||||
{item.thumbnailUrl ? (
|
||||
<div className="w-8 h-8 rounded overflow-hidden shrink-0">
|
||||
<img src={item.thumbnailUrl} alt="" className="w-full h-full object-cover" />
|
||||
@@ -337,6 +378,18 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{onColorChange && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
setColorPicker(colorPicker?.itemId === item.id ? null : { itemId: item.id, x: rect.left, y: rect.bottom + 4 })
|
||||
}}
|
||||
className={`w-4 h-4 rounded-full border-2 border-white shadow-sm shrink-0 hover:scale-110 transition-transform ${!item.color ? (STATUS_COLORS[item.status] || 'bg-gray-400') : ''}`}
|
||||
style={item.color ? { backgroundColor: item.color } : undefined}
|
||||
title="Change color"
|
||||
/>
|
||||
)}
|
||||
{item.thumbnailUrl ? (
|
||||
<div className="w-6 h-6 rounded overflow-hidden shrink-0">
|
||||
<img src={item.thumbnailUrl} alt="" className="w-full h-full object-cover" />
|
||||
@@ -377,6 +430,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
|
||||
width: `${width}px`,
|
||||
height: `${barHeight}px`,
|
||||
top: isExpanded ? '8px' : '8px',
|
||||
...(hasCustomColor ? { backgroundColor: item.color } : {}),
|
||||
}}
|
||||
onMouseDown={(e) => handleMouseDown(e, item, 'move')}
|
||||
onClick={(e) => {
|
||||
@@ -476,6 +530,38 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Color Picker Popover */}
|
||||
{colorPicker && onColorChange && (
|
||||
<div
|
||||
ref={colorPickerRef}
|
||||
className="fixed z-50 bg-white rounded-lg shadow-xl border border-border p-2"
|
||||
style={{ left: colorPicker.x, top: colorPicker.y }}
|
||||
>
|
||||
<div className="grid grid-cols-4 gap-1.5 mb-2">
|
||||
{COLOR_PALETTE.map(c => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => {
|
||||
onColorChange(colorPicker.itemId, c)
|
||||
setColorPicker(null)
|
||||
}}
|
||||
className="w-7 h-7 rounded-full border-2 border-transparent hover:border-gray-400 hover:scale-110 transition-all"
|
||||
style={{ backgroundColor: c }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
onColorChange(colorPicker.itemId, null)
|
||||
setColorPicker(null)
|
||||
}}
|
||||
className="w-full text-[10px] text-text-tertiary hover:text-text-primary text-center py-1 hover:bg-surface-tertiary rounded transition-colors"
|
||||
>
|
||||
Reset to default
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tooltip */}
|
||||
{tooltip && !dragState && (
|
||||
<div
|
||||
|
||||
@@ -1,28 +1,17 @@
|
||||
import { useState, useEffect, useContext } from 'react'
|
||||
import { X, Copy, Eye, Lock, Send, Upload, FileText, Trash2, Check, Clock, CheckCircle2, XCircle } from 'lucide-react'
|
||||
import { api } from '../utils/api'
|
||||
import { api, STATUS_CONFIG, PRIORITY_CONFIG } from '../utils/api'
|
||||
import SlidePanel from './SlidePanel'
|
||||
import FormInput from './FormInput'
|
||||
import Modal from './Modal'
|
||||
import { useToast } from './ToastContainer'
|
||||
import { AppContext } from '../App'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
new: { label: 'New', bg: 'bg-purple-100', text: 'text-purple-700', dot: 'bg-purple-500' },
|
||||
acknowledged: { label: 'Acknowledged', bg: 'bg-blue-100', text: 'text-blue-700', dot: 'bg-blue-500' },
|
||||
in_progress: { label: 'In Progress', bg: 'bg-amber-100', text: 'text-amber-700', dot: 'bg-amber-500' },
|
||||
resolved: { label: 'Resolved', bg: 'bg-emerald-100', text: 'text-emerald-700', dot: 'bg-emerald-500' },
|
||||
declined: { label: 'Declined', bg: 'bg-surface-tertiary', text: 'text-text-secondary', dot: 'bg-text-tertiary' },
|
||||
}
|
||||
|
||||
const PRIORITY_CONFIG = {
|
||||
low: { label: 'Low', bg: 'bg-surface-tertiary', text: 'text-text-secondary' },
|
||||
medium: { label: 'Medium', bg: 'bg-blue-100', text: 'text-blue-700' },
|
||||
high: { label: 'High', bg: 'bg-orange-100', text: 'text-orange-700' },
|
||||
urgent: { label: 'Urgent', bg: 'bg-red-100', text: 'text-red-700' },
|
||||
}
|
||||
|
||||
export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers }) {
|
||||
export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers, teams = [] }) {
|
||||
const { brands } = useContext(AppContext)
|
||||
const toast = useToast()
|
||||
const { t } = useLanguage()
|
||||
const [issueData, setIssueData] = useState(null)
|
||||
const [updates, setUpdates] = useState([])
|
||||
const [attachments, setAttachments] = useState([])
|
||||
@@ -32,6 +21,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
|
||||
// Form state
|
||||
const [assignedTo, setAssignedTo] = useState('')
|
||||
const [teamId, setTeamId] = useState('')
|
||||
const [internalNotes, setInternalNotes] = useState('')
|
||||
const [resolutionSummary, setResolutionSummary] = useState('')
|
||||
const [newUpdate, setNewUpdate] = useState('')
|
||||
@@ -40,6 +30,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
// Modals
|
||||
const [showResolveModal, setShowResolveModal] = useState(false)
|
||||
const [showDeclineModal, setShowDeclineModal] = useState(false)
|
||||
const [confirmDeleteAttId, setConfirmDeleteAttId] = useState(null)
|
||||
|
||||
const issueId = issue?.Id || issue?.id
|
||||
|
||||
@@ -54,6 +45,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
setUpdates(data.updates || [])
|
||||
setAttachments(data.attachments || [])
|
||||
setAssignedTo(data.assigned_to_id || '')
|
||||
setTeamId(data.team_id || '')
|
||||
setInternalNotes(data.internal_notes || '')
|
||||
setResolutionSummary(data.resolution_summary || '')
|
||||
} catch (err) {
|
||||
@@ -72,7 +64,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
await loadIssueDetails()
|
||||
} catch (err) {
|
||||
console.error('Failed to update status:', err)
|
||||
alert('Failed to update status')
|
||||
toast.error(t('issues.failedToUpdateStatus'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
@@ -88,7 +80,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
await loadIssueDetails()
|
||||
} catch (err) {
|
||||
console.error('Failed to resolve issue:', err)
|
||||
alert('Failed to resolve issue')
|
||||
toast.error(t('issues.failedToResolve'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
@@ -104,7 +96,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
await loadIssueDetails()
|
||||
} catch (err) {
|
||||
console.error('Failed to decline issue:', err)
|
||||
alert('Failed to decline issue')
|
||||
toast.error(t('issues.failedToDecline'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
@@ -117,7 +109,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
await onUpdate()
|
||||
} catch (err) {
|
||||
console.error('Failed to update assignment:', err)
|
||||
alert('Failed to update assignment')
|
||||
toast.error(t('issues.failedToUpdateAssignment'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,7 +120,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
await api.patch(`/issues/${issueId}`, { internal_notes: internalNotes })
|
||||
} catch (err) {
|
||||
console.error('Failed to save notes:', err)
|
||||
alert('Failed to save notes')
|
||||
toast.error(t('issues.failedToSaveNotes'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
@@ -144,7 +136,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
await loadIssueDetails()
|
||||
} catch (err) {
|
||||
console.error('Failed to add update:', err)
|
||||
alert('Failed to add update')
|
||||
toast.error(t('issues.failedToAddUpdate'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
@@ -162,27 +154,26 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
e.target.value = '' // Reset input
|
||||
} catch (err) {
|
||||
console.error('Failed to upload file:', err)
|
||||
alert('Failed to upload file')
|
||||
toast.error(t('issues.failedToUploadFile'))
|
||||
} finally {
|
||||
setUploadingFile(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteAttachment = async (attachmentId) => {
|
||||
if (!confirm('Delete this attachment?')) return
|
||||
try {
|
||||
await api.delete(`/issue-attachments/${attachmentId}`)
|
||||
await loadIssueDetails()
|
||||
} catch (err) {
|
||||
console.error('Failed to delete attachment:', err)
|
||||
alert('Failed to delete attachment')
|
||||
toast.error(t('issues.failedToDeleteAttachment'))
|
||||
}
|
||||
}
|
||||
|
||||
const copyTrackingLink = () => {
|
||||
const url = `${window.location.origin}/track/${issueData.tracking_token}`
|
||||
navigator.clipboard.writeText(url)
|
||||
alert('Tracking link copied to clipboard!')
|
||||
toast.success(t('issues.trackingLinkCopied'))
|
||||
}
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
@@ -283,6 +274,33 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Team */}
|
||||
{teams.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-text-primary mb-2">{t('issues.team')}</label>
|
||||
<select
|
||||
value={teamId}
|
||||
onChange={async (e) => {
|
||||
const val = e.target.value || null
|
||||
setTeamId(val || '')
|
||||
try {
|
||||
await api.patch(`/issues/${issueId}`, { team_id: val })
|
||||
await onUpdate()
|
||||
await loadIssueDetails()
|
||||
} catch (err) {
|
||||
console.error('Failed to update team:', err)
|
||||
}
|
||||
}}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||
>
|
||||
<option value="">{t('issues.allTeams')}</option>
|
||||
{teams.map((team) => (
|
||||
<option key={team.id || team._id} value={team.id || team._id}>{team.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Brand */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-text-primary mb-2">Brand</label>
|
||||
@@ -504,7 +522,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
>
|
||||
Download
|
||||
</a>
|
||||
<button onClick={() => handleDeleteAttachment(att.Id || att.id)} className="p-1 hover:bg-surface-tertiary rounded">
|
||||
<button onClick={() => setConfirmDeleteAttId(att.Id || att.id)} className="p-1 hover:bg-surface-tertiary rounded">
|
||||
<Trash2 className="w-4 h-4 text-red-600" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -579,6 +597,19 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* Delete Attachment Confirmation */}
|
||||
<Modal
|
||||
isOpen={!!confirmDeleteAttId}
|
||||
onClose={() => setConfirmDeleteAttId(null)}
|
||||
title={t('issues.deleteAttachment')}
|
||||
isConfirm
|
||||
danger
|
||||
onConfirm={() => handleDeleteAttachment(confirmDeleteAttId)}
|
||||
confirmText={t('common.delete')}
|
||||
>
|
||||
{t('issues.deleteAttachmentDesc')}
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import BrandBadge from './BrandBadge'
|
||||
import StatusBadge from './StatusBadge'
|
||||
import { PlatformIcons } from './PlatformIcon'
|
||||
|
||||
export default function PostCard({ post, onClick, onMove, compact = false }) {
|
||||
export default function PostCard({ post, onClick, onMove, compact = false, checkboxSlot }) {
|
||||
const { t } = useLanguage()
|
||||
const { getBrandName } = useContext(AppContext)
|
||||
const brandName = getBrandName(post.brand_id || post.brandId) || post.brand_name || post.brand
|
||||
@@ -97,6 +97,7 @@ export default function PostCard({ post, onClick, onMove, compact = false }) {
|
||||
// Table row view
|
||||
return (
|
||||
<tr onClick={onClick} className="hover:bg-surface-secondary cursor-pointer group">
|
||||
{checkboxSlot && <td className="w-10 px-3 py-3" onClick={e => e.stopPropagation()}>{checkboxSlot}</td>}
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="shrink-0">
|
||||
|
||||
@@ -129,7 +129,7 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
|
||||
if (!postId) return
|
||||
try {
|
||||
const data = await api.get(`/posts/${postId}/attachments`)
|
||||
setAttachments(Array.isArray(data) ? data : (data.data || []))
|
||||
setAttachments(Array.isArray(data) ? data : [])
|
||||
} catch {
|
||||
setAttachments([])
|
||||
}
|
||||
@@ -163,7 +163,7 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
|
||||
const openAssetPicker = async () => {
|
||||
try {
|
||||
const data = await api.get('/assets')
|
||||
setAvailableAssets(Array.isArray(data) ? data : (data.data || []))
|
||||
setAvailableAssets(Array.isArray(data) ? data : [])
|
||||
} catch {
|
||||
setAvailableAssets([])
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { ChevronLeft, ChevronRight, Calendar as CalendarIcon, CalendarDays } from 'lucide-react'
|
||||
import { PRIORITY_CONFIG } from '../utils/api'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
|
||||
@@ -27,6 +27,18 @@ function getMonthData(year, month) {
|
||||
return cells
|
||||
}
|
||||
|
||||
function getWeekData(startDate) {
|
||||
const cells = []
|
||||
const start = new Date(startDate)
|
||||
start.setDate(start.getDate() - start.getDay())
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const d = new Date(start)
|
||||
d.setDate(start.getDate() + i)
|
||||
cells.push({ day: d.getDate(), current: true, date: d })
|
||||
}
|
||||
return cells
|
||||
}
|
||||
|
||||
function dateKey(d) {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
@@ -36,8 +48,12 @@ export default function TaskCalendarView({ tasks, onTaskClick }) {
|
||||
const today = new Date()
|
||||
const [year, setYear] = useState(today.getFullYear())
|
||||
const [month, setMonth] = useState(today.getMonth())
|
||||
const [calView, setCalView] = useState('month')
|
||||
const [weekStart, setWeekStart] = useState(() => {
|
||||
const d = new Date(); d.setDate(d.getDate() - d.getDay()); return d
|
||||
})
|
||||
|
||||
const cells = getMonthData(year, month)
|
||||
const cells = calView === 'month' ? getMonthData(year, month) : getWeekData(weekStart)
|
||||
const todayKey = dateKey(today)
|
||||
|
||||
// Group tasks by due_date
|
||||
@@ -62,9 +78,22 @@ export default function TaskCalendarView({ tasks, onTaskClick }) {
|
||||
if (month === 11) { setMonth(0); setYear(y => y + 1) }
|
||||
else setMonth(m => m + 1)
|
||||
}
|
||||
const goToday = () => { setYear(today.getFullYear()); setMonth(today.getMonth()) }
|
||||
const prevWeek = () => setWeekStart(d => { const n = new Date(d); n.setDate(n.getDate() - 7); return n })
|
||||
const nextWeek = () => setWeekStart(d => { const n = new Date(d); n.setDate(n.getDate() + 7); return n })
|
||||
|
||||
const goToday = () => {
|
||||
setYear(today.getFullYear()); setMonth(today.getMonth())
|
||||
const d = new Date(); d.setDate(d.getDate() - d.getDay()); setWeekStart(d)
|
||||
}
|
||||
|
||||
const monthLabel = new Date(year, month).toLocaleString('default', { month: 'long', year: 'numeric' })
|
||||
const weekLabel = (() => {
|
||||
const start = new Date(weekStart)
|
||||
start.setDate(start.getDate() - start.getDay())
|
||||
const end = new Date(start); end.setDate(start.getDate() + 6)
|
||||
const fmt = (d) => d.toLocaleString('default', { month: 'short', day: 'numeric' })
|
||||
return `${fmt(start)} – ${fmt(end)}, ${end.getFullYear()}`
|
||||
})()
|
||||
|
||||
const getPillColor = (task) => {
|
||||
const p = task.priority || 'medium'
|
||||
@@ -81,17 +110,37 @@ export default function TaskCalendarView({ tasks, onTaskClick }) {
|
||||
{/* Nav */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={prevMonth} className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors">
|
||||
<button onClick={calView === 'month' ? prevMonth : prevWeek} className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors">
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<h3 className="text-sm font-semibold text-text-primary min-w-[150px] text-center">{monthLabel}</h3>
|
||||
<button onClick={nextMonth} className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors">
|
||||
<h3 className="text-sm font-semibold text-text-primary min-w-[180px] text-center">
|
||||
{calView === 'month' ? monthLabel : weekLabel}
|
||||
</h3>
|
||||
<button onClick={calView === 'month' ? nextMonth : nextWeek} className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors">
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<button onClick={goToday} className="px-3 py-1 text-xs font-medium text-brand-primary hover:bg-brand-primary/5 rounded-lg transition-colors">
|
||||
{t('tasks.today')}
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex bg-surface-tertiary rounded-lg p-0.5">
|
||||
<button
|
||||
onClick={() => setCalView('month')}
|
||||
className={`px-2.5 py-1 text-[11px] font-medium rounded-md transition-colors flex items-center gap-1 ${calView === 'month' ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary'}`}
|
||||
>
|
||||
<CalendarIcon className="w-3 h-3" />
|
||||
Month
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCalView('week')}
|
||||
className={`px-2.5 py-1 text-[11px] font-medium rounded-md transition-colors flex items-center gap-1 ${calView === 'week' ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary'}`}
|
||||
>
|
||||
<CalendarDays className="w-3 h-3" />
|
||||
Week
|
||||
</button>
|
||||
</div>
|
||||
<button onClick={goToday} className="px-3 py-1 text-xs font-medium text-brand-primary hover:bg-brand-primary/5 rounded-lg transition-colors">
|
||||
{t('tasks.today')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Day headers */}
|
||||
@@ -112,7 +161,7 @@ export default function TaskCalendarView({ tasks, onTaskClick }) {
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`border-r border-b border-border min-h-[90px] p-1 ${
|
||||
className={`border-r border-b border-border ${calView === 'week' ? 'min-h-[300px]' : 'min-h-[90px]'} p-1 ${
|
||||
cell.current ? 'bg-white' : 'bg-surface-secondary/50'
|
||||
}`}
|
||||
>
|
||||
@@ -122,7 +171,7 @@ export default function TaskCalendarView({ tasks, onTaskClick }) {
|
||||
{cell.day}
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{dayTasks.slice(0, 3).map(task => (
|
||||
{dayTasks.slice(0, calView === 'week' ? 10 : 3).map(task => (
|
||||
<button
|
||||
key={task._id || task.id}
|
||||
onClick={() => onTaskClick(task)}
|
||||
@@ -134,9 +183,9 @@ export default function TaskCalendarView({ tasks, onTaskClick }) {
|
||||
{task.title}
|
||||
</button>
|
||||
))}
|
||||
{dayTasks.length > 3 && (
|
||||
{dayTasks.length > (calView === 'week' ? 10 : 3) && (
|
||||
<div className="text-[9px] text-text-tertiary text-center font-medium">
|
||||
+{dayTasks.length - 3} more
|
||||
+{dayTasks.length - (calView === 'week' ? 10 : 3)} more
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -120,7 +120,7 @@ export default function TaskDetailPanel({ task, onClose, onSave, onDelete, proje
|
||||
if (!taskId) return
|
||||
try {
|
||||
const data = await api.get(`/tasks/${taskId}/attachments`)
|
||||
setAttachments(Array.isArray(data) ? data : (data.data || []))
|
||||
setAttachments(Array.isArray(data) ? data : [])
|
||||
} catch {
|
||||
setAttachments([])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user