49e1a796ed
Critical fixes: - XSS: escapeHtml() on all user-supplied text in email notifications - Budget PATCH: added mutex lock + availability validation (prevents corruption) - batchResolveNames: fixed wrong signature for budget request earmark names Dead code cleanup: - Deleted 8 unused PostComposition* files (replaced by PostDetail full page) Performance: - budget-helpers: single-fetch with computeFromEntries(), optional prefetch param - post-composition: parallelized text + thumbnail fetches with Promise.all Consistency: - PostDetail.jsx: native <select> → PortalSelect (matches all panels) - Finance.jsx: 11 hardcoded English table headers → t() with i18n keys - PostCalendar.jsx: day names, Month/Week labels → t() with i18n keys - App.jsx Suspense: "Loading..." → brand spinner (can't use i18n in fallback) - UploadZone: proper useRef pattern, no vanilla JS document.createElement - All file inputs: className="hidden" → absolute w-0 h-0 opacity-0 - ArtefactDetailPanel: removed campaign/project selects (inherited from post) - TranslationDetailPanel: removed brand/linked post selects (inherited from post) - ApproverMultiSelect: portal-based dropdown (fixes clipping in modals) - Thumbnail fix: post-composition constructs URL from filename (was undefined) - Upload fix: UploadZone with drag-and-drop for design + video artefacts Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
612 lines
26 KiB
React
612 lines
26 KiB
React
import { useState, useEffect, useContext } from 'react'
|
|
import { Copy, Eye, Lock, Send, FileText, Trash2, Check, Clock, CheckCircle2, XCircle, FileEdit, Wrench, MessageSquare, Paperclip } from 'lucide-react'
|
|
import UploadZone from './UploadZone'
|
|
import { api, STATUS_CONFIG, PRIORITY_CONFIG } from '../utils/api'
|
|
import TabbedModal from './TabbedModal'
|
|
import Modal from './Modal'
|
|
import { useToast } from './ToastContainer'
|
|
import { AppContext } from '../App'
|
|
import { useLanguage } from '../i18n/LanguageContext'
|
|
import PortalSelect from './PortalSelect'
|
|
|
|
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([])
|
|
const [initialLoading, setInitialLoading] = useState(true)
|
|
const [saving, setSaving] = useState(false)
|
|
const [uploadingFile, setUploadingFile] = useState(false)
|
|
const [activeTab, setActiveTab] = useState('details')
|
|
|
|
// Form state
|
|
const [assignedTo, setAssignedTo] = useState('')
|
|
const [teamId, setTeamId] = useState('')
|
|
const [internalNotes, setInternalNotes] = useState('')
|
|
const [resolutionSummary, setResolutionSummary] = useState('')
|
|
const [newUpdate, setNewUpdate] = useState('')
|
|
const [updateIsPublic, setUpdateIsPublic] = useState(false)
|
|
|
|
// Modals
|
|
const [showResolveModal, setShowResolveModal] = useState(false)
|
|
const [showDeclineModal, setShowDeclineModal] = useState(false)
|
|
const [confirmDeleteAttId, setConfirmDeleteAttId] = useState(null)
|
|
|
|
const issueId = issue?.Id || issue?.id
|
|
|
|
useEffect(() => {
|
|
if (issueId) loadIssueDetails()
|
|
}, [issueId])
|
|
|
|
const loadIssueDetails = async () => {
|
|
try {
|
|
const data = await api.get(`/issues/${issueId}`)
|
|
setIssueData(data)
|
|
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) {
|
|
console.error('Failed to load issue:', err)
|
|
} finally {
|
|
setInitialLoading(false)
|
|
}
|
|
}
|
|
|
|
const handleUpdateStatus = async (newStatus) => {
|
|
if (saving) return
|
|
try {
|
|
setSaving(true)
|
|
await api.patch(`/issues/${issueId}`, { status: newStatus })
|
|
await onUpdate()
|
|
await loadIssueDetails()
|
|
} catch (err) {
|
|
console.error('Failed to update status:', err)
|
|
toast.error(t('issues.failedToUpdateStatus'))
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
const handleResolve = async () => {
|
|
if (saving || !resolutionSummary.trim()) return
|
|
try {
|
|
setSaving(true)
|
|
await api.patch(`/issues/${issueId}`, { status: 'resolved', resolution_summary: resolutionSummary })
|
|
await onUpdate()
|
|
setShowResolveModal(false)
|
|
await loadIssueDetails()
|
|
} catch (err) {
|
|
console.error('Failed to resolve issue:', err)
|
|
toast.error(t('issues.failedToResolve'))
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
const handleDecline = async () => {
|
|
if (saving || !resolutionSummary.trim()) return
|
|
try {
|
|
setSaving(true)
|
|
await api.patch(`/issues/${issueId}`, { status: 'declined', resolution_summary: resolutionSummary })
|
|
await onUpdate()
|
|
setShowDeclineModal(false)
|
|
await loadIssueDetails()
|
|
} catch (err) {
|
|
console.error('Failed to decline issue:', err)
|
|
toast.error(t('issues.failedToDecline'))
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
const handleAssignmentChange = async (newAssignedTo) => {
|
|
try {
|
|
setAssignedTo(newAssignedTo)
|
|
await api.patch(`/issues/${issueId}`, { assigned_to_id: newAssignedTo || null })
|
|
await onUpdate()
|
|
} catch (err) {
|
|
console.error('Failed to update assignment:', err)
|
|
toast.error(t('issues.failedToUpdateAssignment'))
|
|
}
|
|
}
|
|
|
|
const handleNotesChange = async () => {
|
|
if (saving) return
|
|
try {
|
|
setSaving(true)
|
|
await api.patch(`/issues/${issueId}`, { internal_notes: internalNotes })
|
|
} catch (err) {
|
|
console.error('Failed to save notes:', err)
|
|
toast.error(t('issues.failedToSaveNotes'))
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
const handleAddUpdate = async () => {
|
|
if (!newUpdate.trim() || saving) return
|
|
try {
|
|
setSaving(true)
|
|
await api.post(`/issues/${issueId}/updates`, { message: newUpdate, is_public: updateIsPublic })
|
|
setNewUpdate('')
|
|
setUpdateIsPublic(false)
|
|
await loadIssueDetails()
|
|
} catch (err) {
|
|
console.error('Failed to add update:', err)
|
|
toast.error(t('issues.failedToAddUpdate'))
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
const handleFileUpload = async (e) => {
|
|
const file = e.target.files?.[0]
|
|
if (!file) return
|
|
try {
|
|
setUploadingFile(true)
|
|
const formData = new FormData()
|
|
formData.append('file', file)
|
|
await api.upload(`/issues/${issueId}/attachments`, formData)
|
|
await loadIssueDetails()
|
|
e.target.value = '' // Reset input
|
|
} catch (err) {
|
|
console.error('Failed to upload file:', err)
|
|
toast.error(t('issues.failedToUploadFile'))
|
|
} finally {
|
|
setUploadingFile(false)
|
|
}
|
|
}
|
|
|
|
const handleDeleteAttachment = async (attachmentId) => {
|
|
try {
|
|
await api.delete(`/issue-attachments/${attachmentId}`)
|
|
await loadIssueDetails()
|
|
} catch (err) {
|
|
console.error('Failed to delete attachment:', err)
|
|
toast.error(t('issues.failedToDeleteAttachment'))
|
|
}
|
|
}
|
|
|
|
const copyTrackingLink = () => {
|
|
const url = `${window.location.origin}/track/${issueData.tracking_token}`
|
|
navigator.clipboard.writeText(url)
|
|
toast.success(t('issues.trackingLinkCopied'))
|
|
}
|
|
|
|
const formatDate = (dateStr) => {
|
|
if (!dateStr) return ''
|
|
const date = new Date(dateStr)
|
|
return date.toLocaleString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' })
|
|
}
|
|
|
|
const formatFileSize = (bytes) => {
|
|
if (bytes < 1024) return bytes + ' B'
|
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
|
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
|
}
|
|
|
|
if (initialLoading || !issueData) {
|
|
return (
|
|
<TabbedModal onClose={onClose} size="lg">
|
|
<div className="flex items-center justify-center h-96">
|
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-primary"></div>
|
|
</div>
|
|
</TabbedModal>
|
|
)
|
|
}
|
|
|
|
const statusConfig = STATUS_CONFIG[issueData.status] || STATUS_CONFIG.new
|
|
const priorityConfig = PRIORITY_CONFIG[issueData.priority] || PRIORITY_CONFIG.medium
|
|
|
|
const tabs = [
|
|
{ key: 'details', label: t('issues.details') || 'Details', icon: FileEdit },
|
|
{ key: 'actions', label: t('issues.actions') || 'Actions', icon: Wrench },
|
|
{ key: 'updates', label: t('issues.updates') || 'Updates', icon: MessageSquare, badge: updates.length },
|
|
{ key: 'attachments', label: t('issues.attachments') || 'Attachments', icon: Paperclip, badge: attachments.length },
|
|
]
|
|
|
|
return (
|
|
<>
|
|
<TabbedModal
|
|
onClose={onClose}
|
|
size="lg"
|
|
header={
|
|
<>
|
|
<h2 className="text-lg font-bold text-text-primary">{issueData.title}</h2>
|
|
<div className="flex items-center gap-2 flex-wrap mt-2">
|
|
<span className={`text-xs px-2 py-1 rounded-full font-medium flex items-center gap-1.5 ${statusConfig.bg} ${statusConfig.text}`}>
|
|
<span className={`w-1.5 h-1.5 rounded-full ${statusConfig.dot}`}></span>
|
|
{statusConfig.label}
|
|
</span>
|
|
<span className={`text-xs px-2 py-1 rounded-full font-medium ${priorityConfig.bg} ${priorityConfig.text}`}>
|
|
{priorityConfig.label}
|
|
</span>
|
|
<span className="text-xs px-2 py-1 rounded-full bg-surface-tertiary text-text-secondary capitalize">
|
|
{issueData.type}
|
|
</span>
|
|
<span className="text-xs px-2 py-1 rounded-full bg-surface-tertiary text-text-secondary">
|
|
{issueData.category}
|
|
</span>
|
|
{issueData.brand_name && (
|
|
<span className="text-xs px-2 py-1 rounded-full bg-brand-primary/10 text-brand-primary font-medium">
|
|
{issueData.brand_name}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</>
|
|
}
|
|
tabs={tabs}
|
|
activeTab={activeTab}
|
|
onTabChange={setActiveTab}
|
|
footer={
|
|
<>
|
|
<button
|
|
onClick={copyTrackingLink}
|
|
className="px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light transition-colors flex items-center gap-2"
|
|
>
|
|
<Copy className="w-4 h-4" />
|
|
{t('issues.publicTrackingLink')}
|
|
</button>
|
|
<button
|
|
onClick={onClose}
|
|
className="px-4 py-2 bg-surface-secondary text-text-primary rounded-lg text-sm font-medium hover:bg-surface-tertiary transition-colors"
|
|
>
|
|
{t('common.close') || 'Close'}
|
|
</button>
|
|
</>
|
|
}
|
|
>
|
|
{/* Details Tab */}
|
|
{activeTab === 'details' && (
|
|
<div className="p-6 space-y-5">
|
|
{/* Submitter Info */}
|
|
<div className="bg-surface-secondary rounded-lg p-4">
|
|
<h3 className="text-sm font-semibold text-text-primary mb-2">{t('issues.submitterInfo')}</h3>
|
|
<div className="space-y-1 text-sm">
|
|
<div><span className="text-text-tertiary">{t('issues.nameLabel')}</span> <span className="text-text-primary font-medium">{issueData.submitter_name}</span></div>
|
|
<div><span className="text-text-tertiary">{t('issues.emailLabel')}</span> <span className="text-text-primary">{issueData.submitter_email}</span></div>
|
|
{issueData.submitter_phone && (
|
|
<div><span className="text-text-tertiary">{t('issues.phoneLabel')}</span> <span className="text-text-primary">{issueData.submitter_phone}</span></div>
|
|
)}
|
|
<div><span className="text-text-tertiary">{t('issues.submittedLabel')}</span> <span className="text-text-primary">{formatDate(issueData.created_at)}</span></div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Description */}
|
|
<div>
|
|
<h3 className="text-sm font-semibold text-text-primary mb-2">{t('issues.description')}</h3>
|
|
<p className="text-sm text-text-secondary whitespace-pre-wrap">{issueData.description || t('issues.noDescription')}</p>
|
|
</div>
|
|
|
|
{/* Assigned To */}
|
|
<div>
|
|
<label className="block text-sm font-semibold text-text-primary mb-2">{t('issues.assignedTo')}</label>
|
|
<PortalSelect
|
|
value={assignedTo}
|
|
onChange={val => handleAssignmentChange(val)}
|
|
options={[{ value: '', label: t('issues.unassigned') }, ...teamMembers.map(member => ({ value: member.id || member._id, label: member.name }))]}
|
|
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"
|
|
/>
|
|
</div>
|
|
|
|
{/* Team */}
|
|
{teams.length > 0 && (
|
|
<div>
|
|
<label className="block text-sm font-semibold text-text-primary mb-2">{t('issues.team')}</label>
|
|
<PortalSelect
|
|
value={teamId}
|
|
onChange={async (val) => {
|
|
const resolvedVal = val || null
|
|
setTeamId(resolvedVal || '')
|
|
try {
|
|
await api.patch(`/issues/${issueId}`, { team_id: resolvedVal })
|
|
await onUpdate()
|
|
await loadIssueDetails()
|
|
} catch (err) {
|
|
console.error('Failed to update team:', err)
|
|
}
|
|
}}
|
|
options={[{ value: '', label: t('issues.allTeams') }, ...teams.map(team => ({ value: team.id || team._id, label: team.name }))]}
|
|
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"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Brand */}
|
|
<div>
|
|
<label className="block text-sm font-semibold text-text-primary mb-2">{t('issues.brandLabel')}</label>
|
|
<PortalSelect
|
|
value={issueData.brand_id || ''}
|
|
onChange={async (val) => {
|
|
const resolvedVal = val || null;
|
|
try {
|
|
await api.patch(`/issues/${issueId}`, { brand_id: resolvedVal });
|
|
loadIssueDetails();
|
|
onUpdate();
|
|
} catch {}
|
|
}}
|
|
options={[{ value: '', label: t('issues.noBrand') }, ...(brands || []).map(b => ({ value: b._id || b.Id, label: b.name }))]}
|
|
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"
|
|
/>
|
|
</div>
|
|
|
|
{/* Internal Notes */}
|
|
<div>
|
|
<label className="block text-sm font-semibold text-text-primary mb-2 flex items-center gap-2">
|
|
<Lock className="w-4 h-4" />
|
|
{t('issues.internalNotes')}
|
|
</label>
|
|
<textarea
|
|
value={internalNotes}
|
|
onChange={(e) => setInternalNotes(e.target.value)}
|
|
onBlur={handleNotesChange}
|
|
rows={4}
|
|
placeholder={t('issues.internalNotesPlaceholder')}
|
|
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"
|
|
/>
|
|
</div>
|
|
|
|
{/* Resolution Summary (if resolved/declined) */}
|
|
{(issueData.status === 'resolved' || issueData.status === 'declined') && issueData.resolution_summary && (
|
|
<div className="bg-emerald-50 border border-emerald-200 rounded-lg p-4">
|
|
<h3 className="text-sm font-semibold text-emerald-900 mb-2 flex items-center gap-2">
|
|
<CheckCircle2 className="w-4 h-4" />
|
|
{t('issues.resolutionSummary')}
|
|
</h3>
|
|
<p className="text-sm text-emerald-800 whitespace-pre-wrap">{issueData.resolution_summary}</p>
|
|
{issueData.resolved_at && (
|
|
<p className="text-xs text-emerald-600 mt-2">{t('issues.resolvedOn')} {formatDate(issueData.resolved_at)}</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Actions Tab */}
|
|
{activeTab === 'actions' && (
|
|
<div className="p-6 space-y-5">
|
|
{issueData.status !== 'resolved' && issueData.status !== 'declined' ? (
|
|
<div className="space-y-3">
|
|
{issueData.status === 'new' && (
|
|
<button
|
|
onClick={() => handleUpdateStatus('acknowledged')}
|
|
disabled={saving}
|
|
className="w-full px-4 py-3 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50 flex items-center justify-center gap-2"
|
|
>
|
|
<Check className="w-4 h-4" />
|
|
{t('issues.acknowledge')}
|
|
</button>
|
|
)}
|
|
{(issueData.status === 'new' || issueData.status === 'acknowledged') && (
|
|
<button
|
|
onClick={() => handleUpdateStatus('in_progress')}
|
|
disabled={saving}
|
|
className="w-full px-4 py-3 bg-amber-600 text-white rounded-lg text-sm font-medium hover:bg-amber-700 disabled:opacity-50 flex items-center justify-center gap-2"
|
|
>
|
|
<Clock className="w-4 h-4" />
|
|
{t('issues.startWork')}
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={() => setShowResolveModal(true)}
|
|
disabled={saving}
|
|
className="w-full px-4 py-3 bg-emerald-600 text-white rounded-lg text-sm font-medium hover:bg-emerald-700 disabled:opacity-50 flex items-center justify-center gap-2"
|
|
>
|
|
<CheckCircle2 className="w-4 h-4" />
|
|
{t('issues.resolve')}
|
|
</button>
|
|
<button
|
|
onClick={() => setShowDeclineModal(true)}
|
|
disabled={saving}
|
|
className="w-full px-4 py-3 bg-gray-600 text-white rounded-lg text-sm font-medium hover:bg-gray-700 disabled:opacity-50 flex items-center justify-center gap-2"
|
|
>
|
|
<XCircle className="w-4 h-4" />
|
|
{t('issues.decline')}
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="text-center py-12">
|
|
<CheckCircle2 className="w-10 h-10 mx-auto mb-3 text-text-tertiary" />
|
|
<p className="text-sm text-text-tertiary">
|
|
{issueData.status === 'resolved' ? t('issues.issueResolved') || 'This issue has been resolved.' : t('issues.issueDeclined') || 'This issue has been declined.'}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Updates Tab */}
|
|
{activeTab === 'updates' && (
|
|
<div className="p-6 space-y-5">
|
|
{/* Add Update */}
|
|
<div className="bg-surface-secondary rounded-lg p-3">
|
|
<textarea
|
|
value={newUpdate}
|
|
onChange={(e) => setNewUpdate(e.target.value)}
|
|
placeholder={t('issues.addUpdatePlaceholder')}
|
|
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 mb-2"
|
|
/>
|
|
<div className="flex items-center justify-between">
|
|
<label className="flex items-center gap-2 text-sm text-text-secondary cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={updateIsPublic}
|
|
onChange={(e) => setUpdateIsPublic(e.target.checked)}
|
|
className="rounded"
|
|
/>
|
|
<Eye className="w-4 h-4" />
|
|
{t('issues.makePublic')}
|
|
</label>
|
|
<button
|
|
onClick={handleAddUpdate}
|
|
disabled={!newUpdate.trim() || saving}
|
|
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 flex items-center gap-2"
|
|
>
|
|
<Send className="w-4 h-4" />
|
|
{t('issues.addUpdate')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Updates List */}
|
|
<div className="space-y-3">
|
|
{updates.map((update) => (
|
|
<div
|
|
key={update.Id || update.id}
|
|
className={`p-3 rounded-lg border ${update.is_public ? 'bg-brand-primary/5 border-brand-primary/20' : 'bg-surface-secondary border-border'}`}
|
|
>
|
|
<div className="flex items-start justify-between gap-2 mb-2">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm font-medium text-text-primary">{update.author_name}</span>
|
|
<span className={`text-xs px-2 py-0.5 rounded-full ${update.author_type === 'staff' ? 'bg-purple-100 text-purple-700' : 'bg-surface-tertiary text-text-secondary'}`}>
|
|
{update.author_type}
|
|
</span>
|
|
{update.is_public ? (
|
|
<Eye className="w-3.5 h-3.5 text-blue-600" title="Public" />
|
|
) : (
|
|
<Lock className="w-3.5 h-3.5 text-text-secondary" title="Internal only" />
|
|
)}
|
|
</div>
|
|
<span className="text-xs text-text-tertiary">{formatDate(update.created_at)}</span>
|
|
</div>
|
|
<p className="text-sm text-text-secondary whitespace-pre-wrap">{update.message}</p>
|
|
</div>
|
|
))}
|
|
{updates.length === 0 && (
|
|
<p className="text-sm text-text-tertiary text-center py-6">{t('issues.noUpdates')}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Attachments Tab */}
|
|
{activeTab === 'attachments' && (
|
|
<div className="p-6 space-y-5">
|
|
{/* Upload */}
|
|
<UploadZone
|
|
onUpload={handleFileUpload}
|
|
uploading={uploadingFile}
|
|
label={t('issues.clickToUpload')}
|
|
compact
|
|
/>
|
|
|
|
{/* Attachments List */}
|
|
<div className="space-y-2">
|
|
{attachments.map((att) => (
|
|
<div key={att.Id || att.id} className="flex items-center justify-between p-3 bg-surface-secondary rounded-lg">
|
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
|
<FileText className="w-5 h-5 text-text-tertiary shrink-0" />
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-medium text-text-primary truncate">{att.original_name}</p>
|
|
<p className="text-xs text-text-tertiary">
|
|
{formatFileSize(att.size)} • {att.uploaded_by}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<a
|
|
href={`/api/uploads/${att.filename}`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-xs text-brand-primary hover:underline"
|
|
>
|
|
{t('issues.download')}
|
|
</a>
|
|
<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>
|
|
</div>
|
|
))}
|
|
{attachments.length === 0 && (
|
|
<p className="text-sm text-text-tertiary text-center py-4">{t('issues.noAttachments')}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</TabbedModal>
|
|
|
|
{/* Resolve Modal */}
|
|
{showResolveModal && (
|
|
<Modal isOpen title={t('issues.resolveIssue')} onClose={() => setShowResolveModal(false)}>
|
|
<div className="space-y-4">
|
|
<p className="text-sm text-text-secondary">{t('issues.resolveSummaryHint')}</p>
|
|
<textarea
|
|
value={resolutionSummary}
|
|
onChange={(e) => setResolutionSummary(e.target.value)}
|
|
placeholder={t('issues.resolutionPlaceholder')}
|
|
rows={5}
|
|
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"
|
|
/>
|
|
<div className="flex gap-2 justify-end">
|
|
<button
|
|
onClick={() => setShowResolveModal(false)}
|
|
className="px-4 py-2 bg-surface-secondary text-text-primary rounded-lg text-sm font-medium hover:bg-surface-tertiary"
|
|
>
|
|
{t('common.cancel')}
|
|
</button>
|
|
<button
|
|
onClick={handleResolve}
|
|
disabled={!resolutionSummary.trim() || saving}
|
|
className="px-4 py-2 bg-emerald-600 text-white rounded-lg text-sm font-medium hover:bg-emerald-700 disabled:opacity-50"
|
|
>
|
|
{saving ? t('issues.resolving') : t('issues.markAsResolved')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
)}
|
|
|
|
{/* Decline Modal */}
|
|
{showDeclineModal && (
|
|
<Modal isOpen title={t('issues.declineIssue')} onClose={() => setShowDeclineModal(false)}>
|
|
<div className="space-y-4">
|
|
<p className="text-sm text-text-secondary">{t('issues.declineReasonHint')}</p>
|
|
<textarea
|
|
value={resolutionSummary}
|
|
onChange={(e) => setResolutionSummary(e.target.value)}
|
|
placeholder={t('issues.declinePlaceholder')}
|
|
rows={5}
|
|
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"
|
|
/>
|
|
<div className="flex gap-2 justify-end">
|
|
<button
|
|
onClick={() => setShowDeclineModal(false)}
|
|
className="px-4 py-2 bg-surface-secondary text-text-primary rounded-lg text-sm font-medium hover:bg-surface-tertiary"
|
|
>
|
|
{t('common.cancel')}
|
|
</button>
|
|
<button
|
|
onClick={handleDecline}
|
|
disabled={!resolutionSummary.trim() || saving}
|
|
className="px-4 py-2 bg-gray-600 text-white rounded-lg text-sm font-medium hover:bg-gray-700 disabled:opacity-50"
|
|
>
|
|
{saving ? t('issues.declining') : t('issues.declineIssue')}
|
|
</button>
|
|
</div>
|
|
</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>
|
|
</>
|
|
)
|
|
}
|