import { useState } from 'react' import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query' import { useAuth } from '../App' interface DisputeLineItem { id: number product_name: string product_code: string | null quantity_ordered: number | null quantity_received: number | null quantity_difference: number | null unit_price_quoted: number | null unit_price_charged: number | null price_difference: number | null total_charged: number total_expected: number | null notes: string | null } interface DisputeAttachment { id: number file_name: string file_type: string file_size_bytes: number attachment_type: string description: string | null uploaded_at: string uploaded_by_username: string | null public_hash: string | null public_url: string | null } interface DisputeActivity { id: number activity_type: string description: string created_at: string created_by_username: string | null } interface DisputeDetail { id: number invoice_id: number invoice_number: string | null supplier_name: string dispute_type: string status: string priority: string title: string description: string disputed_amount: number expected_amount: number | null difference_amount: number supplier_contacted_at: string | null supplier_response: string | null supplier_contact_name: string | null resolved_amount: number | null opened_at: string opened_by: string resolved_at: string | null closed_at: string | null tags: string[] | null line_items: DisputeLineItem[] attachments: DisputeAttachment[] activity_log: DisputeActivity[] } interface DisputeDetailModalProps { disputeId: number onClose: () => void onUpdate?: () => void } const statusOptions = [ { value: 'new', label: 'New' }, { value: 'contacted', label: 'Contacted' }, { value: 'awaiting_credit', label: 'Awaiting Credit' }, { value: 'awaiting_replacement', label: 'Awaiting Replacement' }, { value: 'resolved', label: 'Resolved' }, ] const priorityOptions = [ { value: 'low', label: 'Low' }, { value: 'medium', label: 'Medium' }, { value: 'high', label: 'High' }, { value: 'urgent', label: 'Urgent' }, ] const statusColors: Record = { new: '#e94560', contacted: '#f0ad4e', awaiting_credit: '#9b59b6', awaiting_replacement: '#8b7ec8', resolved: '#5cb85c', } const priorityColors: Record = { low: '#5cb85c', medium: '#5bc0de', high: '#f0ad4e', urgent: '#e94560', } export default function DisputeDetailModal({ disputeId, onClose, onUpdate }: DisputeDetailModalProps) { const { token, user } = useAuth() const queryClient = useQueryClient() const [note, setNote] = useState('') const [showUploadAttachment, setShowUploadAttachment] = useState(false) const [isEditing, setIsEditing] = useState(false) const [editedTitle, setEditedTitle] = useState('') const [editedDescription, setEditedDescription] = useState('') const [uploadFile, setUploadFile] = useState(null) const [uploadDescription, setUploadDescription] = useState('') const [isUploading, setIsUploading] = useState(false) const [copiedHash, setCopiedHash] = useState(null) // LLM FEATURE — see LLM-MANIFEST.md for removal instructions const [aiEmailLoading, setAiEmailLoading] = useState(false) const [aiEmailSubject, setAiEmailSubject] = useState('') const [aiEmailBody, setAiEmailBody] = useState('') const [aiEmailError, setAiEmailError] = useState(null) // LLM FEATURE — settings query for LLM enabled check const { data: settings } = useQuery<{ llm_enabled?: boolean; anthropic_api_key_set?: boolean }>({ queryKey: ['settings'], queryFn: async () => { const res = await fetch('/kitchen/api/settings', { headers: { Authorization: `Bearer ${token}` } }) if (!res.ok) return {} return res.json() }, enabled: !!token, staleTime: 60000, }) const { data: dispute, isLoading, error } = useQuery({ queryKey: ['dispute', disputeId], queryFn: async () => { const res = await fetch(`/kitchen/api/disputes/${disputeId}`, { headers: { Authorization: `Bearer ${token}` }, }) if (!res.ok) throw new Error('Failed to fetch dispute') return res.json() }, enabled: !!token, }) const updateMutation = useMutation({ mutationFn: async (data: { status?: string; priority?: string; supplier_response?: string; supplier_contact_name?: string; title?: string; description?: string }) => { const res = await fetch(`/kitchen/api/disputes/${disputeId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, }, body: JSON.stringify(data), }) if (!res.ok) { const error = await res.json() throw new Error(error.detail || 'Failed to update dispute') } return res.json() }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['dispute', disputeId] }) queryClient.invalidateQueries({ queryKey: ['disputes'] }) queryClient.invalidateQueries({ queryKey: ['dispute-stats'] }) if (onUpdate) onUpdate() setNote('') }, }) const deleteMutation = useMutation({ mutationFn: async () => { const res = await fetch(`/kitchen/api/disputes/${disputeId}`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}`, }, }) if (!res.ok) { const error = await res.json() throw new Error(error.detail || 'Failed to delete dispute') } return res.json() }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['disputes'] }) queryClient.invalidateQueries({ queryKey: ['dispute-stats'] }) if (onUpdate) onUpdate() onClose() }, }) const handleStatusChange = (status: string) => { updateMutation.mutate({ status: status.toUpperCase() }) } const handlePriorityChange = (priority: string) => { updateMutation.mutate({ priority: priority.toLowerCase() }) } const handleAddNote = () => { if (!note.trim()) { alert('Please enter a note') return } updateMutation.mutate({ supplier_response: note.trim(), }) } const handleDelete = () => { if (!confirm(`Are you sure you want to permanently delete this dispute?\n\nDispute: ${dispute?.title}\nInvoice #${dispute?.invoice_number}\n\nThis action cannot be undone.`)) { return } deleteMutation.mutate() } const handleEdit = () => { if (dispute) { setEditedTitle(dispute.title) setEditedDescription(dispute.description || '') setIsEditing(true) } } const handleCancelEdit = () => { setIsEditing(false) setEditedTitle('') setEditedDescription('') } const handleSaveEdit = () => { if (!editedTitle.trim()) { alert('Title cannot be empty') return } updateMutation.mutate({ title: editedTitle.trim(), description: editedDescription.trim() || undefined } as any) setIsEditing(false) } // LLM FEATURE — see LLM-MANIFEST.md for removal instructions const handleDraftEmail = async () => { setAiEmailLoading(true) setAiEmailError(null) setAiEmailSubject('') setAiEmailBody('') try { const res = await fetch(`/kitchen/api/disputes/${disputeId}/draft-email`, { method: 'POST', headers: { Authorization: `Bearer ${token}` }, }) if (!res.ok) { const err = await res.json() throw new Error(err.detail || 'Failed to draft email') } const data = await res.json() if (data.llm_status === 'success' || data.llm_status === 'cached') { setAiEmailSubject(data.email_subject || '') setAiEmailBody(data.email_body || '') } else { setAiEmailError(data.error || 'AI drafting unavailable') } } catch (err) { setAiEmailError(err instanceof Error ? err.message : 'Failed to draft email') } finally { setAiEmailLoading(false) } } const handleUploadAttachment = async () => { if (!uploadFile) { alert('Please select a file') return } setIsUploading(true) try { const formData = new FormData() formData.append('file', uploadFile) const params = new URLSearchParams() params.append('attachment_type', 'photo') // Default to photo if (uploadDescription.trim()) { params.append('description', uploadDescription.trim()) } const res = await fetch(`/kitchen/api/disputes/${disputeId}/attachments?${params}`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, }, body: formData, }) if (!res.ok) { const error = await res.json() throw new Error(error.detail || 'Failed to upload attachment') } // Refresh dispute data queryClient.invalidateQueries({ queryKey: ['dispute', disputeId] }) if (onUpdate) onUpdate() // Reset form setUploadFile(null) setUploadDescription('') setShowUploadAttachment(false) } catch (err) { alert(err instanceof Error ? err.message : 'Upload failed') } finally { setIsUploading(false) } } const copyPublicLink = (att: DisputeAttachment) => { if (!att.public_url) return // Build full URL const fullUrl = `${window.location.origin}${att.public_url}` navigator.clipboard.writeText(fullUrl).then(() => { setCopiedHash(att.public_hash) setTimeout(() => setCopiedHash(null), 2000) }).catch(() => { // Fallback for older browsers const textarea = document.createElement('textarea') textarea.value = fullUrl document.body.appendChild(textarea) textarea.select() document.execCommand('copy') document.body.removeChild(textarea) setCopiedHash(att.public_hash) setTimeout(() => setCopiedHash(null), 2000) }) } const viewAttachment = (att: DisputeAttachment) => { if (att.public_url) { window.open(att.public_url, '_blank') } } if (isLoading) { return (
e.stopPropagation()}>
Loading dispute details...
) } if (error || !dispute) { return (
e.stopPropagation()}>
Error loading dispute: {error?.message || 'Unknown error'}
) } return (
e.stopPropagation()}>
{dispute.supplier_name} - Invoice #{dispute.invoice_number || dispute.invoice_id} - {dispute.dispute_type.replace(/_/g, ' ').toUpperCase()}
{isEditing ? ( setEditedTitle(e.target.value)} style={{ ...styles.modalTitle, border: '2px solid #e94560', padding: '0.5rem' }} maxLength={200} /> ) : (

{dispute.title}

)}
{isEditing ? ( <> ) : ( <> {user?.is_admin && ( )} )}
{/* Status and Priority */}
{statusOptions.map((opt) => ( ))}
{priorityOptions.map((opt) => ( ))}
{/* Financial Summary */}

Financial Impact

Disputed Amount
£{Number(dispute.disputed_amount).toFixed(2)}
{dispute.expected_amount != null && (
Expected Amount
£{Number(dispute.expected_amount).toFixed(2)}
)}
Difference
£{Number(Math.abs(dispute.difference_amount)).toFixed(2)}
{/* LLM FEATURE — Draft Email button — see LLM-MANIFEST.md for removal instructions */} {settings?.llm_enabled && settings?.anthropic_api_key_set && (

Draft Supplier Email

{aiEmailError && (
{aiEmailError}
)} {aiEmailSubject && (
setAiEmailSubject(e.target.value)} style={{ ...styles.input, marginBottom: 0, borderColor: '#d4c5f0' }} />