Files
spq-v2/src/pages/RODetailModal.tsx
T
2026-07-12 14:42:44 -04:00

1730 lines
94 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState, useCallback, useMemo } from 'react';
import { useParams, useLocation, useNavigate } from 'react-router-dom';
import { pb } from '../lib/pocketbase';
import { computeROTotals, roServiceTotal } from '../lib/totals';
import type { RepairOrder, ROService, ServiceItem, ROEvent, ROFinancialAuditEntry } from '../types';
import { getStatusConfig } from '../lib/status';
import { formatCurrency, formatDateTime, formatElapsed } from '../lib/format';
import { useToast } from '../components/ui/Toast';
import LoadingSpinner from '../components/ui/LoadingSpinner';
import {
X, Wrench, Search, Plus, Check, History,
FileText, Printer, Download, Mail, Phone, Ban, Clock3,
ArrowLeft, AlertTriangle, DollarSign, Tag,
Package, Trash2, Loader2, Layers, Save, BellRing
} from 'lucide-react';
import {
readEvents, appendROEvent, readPromisedOverride,
setPromisedOverride, isVoided, setVoided,
clockRunning, clockElapsedMinutes, clockStart, clockStop
} from '../lib/roEvents';
import { openEmailDraft, openSmsDraft } from '../lib/contactLinks';
import { downloadROPDF, printROPDF } from '../lib/roPdf';
import { useSettings } from '../store/settings';
import { logError } from '../lib/userMessages';
import { parseRepairOrderServices, syncAssignmentsForROServices } from '../lib/technicianData';
import { formatServiceTextFields } from '../lib/dataFormatting';
import { spellCheckProps } from '../lib/spellcheck';
type TabId = 'details' | 'services' | 'financial' | 'history' | 'timeline';
const TABS: { id: TabId; label: string; icon: typeof Wrench }[] = [
{ id: 'details', label: 'Details', icon: FileText },
{ id: 'services', label: 'Services', icon: Wrench },
{ id: 'financial', label: 'Financial', icon: DollarSign },
{ id: 'history', label: 'History', icon: History },
{ id: 'timeline', label: 'Timeline', icon: Clock3 },
];
/* ── Shared helpers (inlined from ServiceCatalog) ─── */
function getServiceLaborPrice(service: Pick<ServiceItem, 'laborHours' | 'laborRate'>): number {
return (Number(service.laborHours) || 0) * (Number(service.laborRate) || 0);
}
function getServicePricingMode(service: Pick<ServiceItem, 'laborHours' | 'laborRate' | 'partsCost'>): 'full' | 'split' {
return getServiceLaborPrice(service) > 0 || (Number(service.partsCost) || 0) > 0 ? 'split' : 'full';
}
interface ServiceFormState {
name: string;
category: string;
description: string;
pricingMode: 'full' | 'split';
fullPrice: number;
laborPrice: number;
partsPrice: number;
}
function createEmptyServiceForm(): ServiceFormState {
return {
name: '',
category: '',
description: '',
pricingMode: 'full',
fullPrice: 0,
laborPrice: 0,
partsPrice: 0,
};
}
/* ── Helpers ───────────────────────────────────────── */
function formatDueDateTime(ro: RepairOrder): string {
const override = readPromisedOverride(ro);
if (override) return formatDateTime(override);
if (ro.promisedTime) return formatDateTime(ro.promisedTime);
if (ro.writeupTime) {
const d = new Date(ro.writeupTime);
d.setHours(d.getHours() + 4);
return formatDateTime(d.toISOString());
}
return '—';
}
function formatTimeRemaining(ro: RepairOrder, now: number): string {
const override = readPromisedOverride(ro);
const target = override || ro.promisedTime || (ro.writeupTime ? new Date(new Date(ro.writeupTime).getTime() + 4 * 60 * 60 * 1000).toISOString() : '');
if (!target) return '—';
const diff = new Date(target).getTime() - now;
if (diff <= 0) return 'Overdue';
const hours = Math.floor(diff / 3600000);
const minutes = Math.floor((diff % 3600000) / 60000);
return `${hours}h ${minutes}m`;
}
function formatCompletedTime(ro: RepairOrder): string {
return formatDateTime(ro.completedTime || ro.completedDate || '');
}
function getUrgencyClass(ro: RepairOrder, now: number): '' | 'warning' | 'urgent' {
const override = readPromisedOverride(ro);
const target = override || ro.promisedTime || (ro.writeupTime ? new Date(new Date(ro.writeupTime).getTime() + 4 * 60 * 60 * 1000).toISOString() : '');
if (!target) return '';
const diff = new Date(target).getTime() - now;
if (diff < 0) return 'urgent';
if (diff < 60 * 60 * 1000) return 'warning';
return '';
}
function StatusBadge({ status, config }: { status: string; config: Record<string, { label: string; colors: string }> }) {
const cfg = config[status as keyof typeof config] || { label: status, colors: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300' };
return <span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${cfg.colors}`}>{cfg.label}</span>;
}
function TimelineEntry({ ev }: { ev: ROEvent }) {
const desc = (() => {
switch (ev.type) {
case 'created': return 'Repair order created';
case 'edit': return 'Repair order edited';
case 'status': return `Status changed: ${ev.data?.from}${ev.data?.to}`;
case 'add_time': return `Added ${ev.data?.minutes} min (total: ${ev.data?.newTotal} min)`;
case 'clock_start': return 'Technician clock started';
case 'clock_stop': return `Technician clock stopped (${ev.data?.elapsed} min)`;
case 'satisfaction': return `Customer rated ${ev.data?.rating}★`;
case 'notify': return `Notified via ${ev.data?.channel} to ${ev.data?.to}`;
case 'financial': return `Financial field updated: ${ev.data?.field}`;
default: return ev.type;
}
})();
return (
<li className="flex items-start gap-3 py-2">
<span className="mt-0.5 h-2 w-2 shrink-0 rounded-full bg-blue-400" />
<div className="min-w-0 flex-1">
<p className="text-sm text-gray-700 dark:text-gray-300">{desc}</p>
<p className="text-xs text-gray-400">{formatDateTime(ev.at)}</p>
</div>
</li>
);
}
/* ── AddTimeModal ──────────────────────────────────── */
function AddTimeModal({ open, initialMinutes, onCancel, onConfirm }: { open: boolean; initialMinutes: number; onCancel: () => void; onConfirm: (minutes: number) => void }) {
const [minutes, setMinutes] = useState(initialMinutes);
useEffect(() => { setMinutes(initialMinutes); }, [initialMinutes]);
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30" onClick={onCancel}>
<div className="w-80 rounded-xl bg-white p-6 shadow-xl dark:bg-gray-800" onClick={(e) => e.stopPropagation()}>
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">Add Time</h3>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">Extend the remaining time on this RO.</p>
<input type="number" min={1} value={minutes} onChange={(e) => setMinutes(Number(e.target.value) || 1)} className="mt-3 w-full rounded border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100" />
<div className="mt-4 flex justify-end gap-2">
<button onClick={onCancel} className="rounded-lg border border-gray-300 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300">Cancel</button>
<button onClick={() => onConfirm(minutes)} className="rounded-lg bg-blue-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-blue-700">Add</button>
</div>
</div>
</div>
);
}
/* ── Service Row (editable RO service row) ─────────── */
function PricingBadge({ mode }: { mode: 'full' | 'split' }) {
if (mode === 'full') {
return <span className="inline-flex items-center rounded-full bg-green-100 px-2 py-0.5 text-[10px] font-medium text-green-700 dark:bg-green-900/30 dark:text-green-300">Full Price</span>;
}
return <span className="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-[10px] font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">Split</span>;
}
function EditableServiceRow({ service, index, onChange, onRemove, defaultLaborRate = 145, spellCheck, lang }: {
service: Partial<ROService>;
index: number;
onChange: (index: number, field: string, value: string | number | boolean) => void;
onRemove: (index: number) => void;
defaultLaborRate?: number;
spellCheck?: boolean;
lang?: string;
}) {
const pricingMode = service.pricingMode || 'full';
const isSplit = pricingMode === 'split';
const total = roServiceTotal(service);
const scOn = service.applyShopCharge !== false;
return (
<div className="rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-800/50">
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-gray-500 dark:text-gray-400">Service #{index + 1}</span>
{/* Pricing mode toggle */}
<div className="flex rounded-lg border border-gray-300 dark:border-gray-600 overflow-hidden text-xs">
<button
type="button"
onClick={() => {
if (isSplit) {
onChange(index, 'pricingMode', 'full');
}
}}
className={`px-2 py-0.5 font-medium transition-colors ${
!isSplit
? 'bg-blue-600 text-white'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
>
Full
</button>
<button
type="button"
onClick={() => {
if (!isSplit) {
const flatTotal = service.total || total;
const rate = defaultLaborRate;
const hours = rate > 0 ? Math.round((flatTotal / rate) * 100) / 100 : 0;
onChange(index, 'pricingMode', 'split');
onChange(index, 'laborHours', hours);
onChange(index, 'laborRate', rate);
onChange(index, 'partsCost', 0);
}
}}
className={`px-2 py-0.5 font-medium transition-colors ${
isSplit
? 'bg-blue-600 text-white'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
>
Split
</button>
</div>
<PricingBadge mode={pricingMode} />
<button
type="button"
onClick={() => onChange(index, 'applyShopCharge', !scOn)}
className={`rounded-md px-2 py-0.5 text-[11px] font-medium transition-colors ${
scOn
? 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'
: 'bg-gray-100 text-gray-400 dark:bg-gray-700 dark:text-gray-500'
}`}
title={scOn ? 'Shop charge applies' : 'Shop charge excluded'}
>
{scOn ? 'Shop Charge On' : 'Shop Charge Off'}
</button>
</div>
<button onClick={() => onRemove(index)} className="rounded p-1 text-gray-400 hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-900/20">
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-5">
<div className="sm:col-span-2 lg:col-span-1">
<label className="block text-xs text-gray-500 dark:text-gray-400">Name</label>
<input value={service.name || ''} onChange={(e) => onChange(index, 'name', e.target.value)} placeholder="Service name" className="mt-0.5 w-full rounded border border-gray-300 bg-white px-2 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100" />
</div>
<div className="sm:col-span-2 lg:col-span-1">
<label className="block text-xs text-gray-500 dark:text-gray-400">Description</label>
<textarea value={service.description || ''} onChange={(e) => onChange(index, 'description', e.target.value)} placeholder="Description..." rows={1} spellCheck={spellCheck} lang={lang} className="mt-0.5 w-full rounded border border-gray-300 bg-white px-2 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100" />
</div>
{pricingMode === 'full' ? (
<>
<div>
<label className="block text-xs text-gray-500 dark:text-gray-400">Price ($)</label>
<input type="number" min="0" step="0.01" value={service.total != null ? service.total : ''} onChange={(e) => onChange(index, 'total', parseFloat(e.target.value) || 0)} placeholder="0" className="mt-0.5 w-full rounded border border-gray-300 bg-white px-2 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100" />
</div>
<div></div>
<div></div>
<div>
<label className="block text-xs text-gray-500 dark:text-gray-400">Total</label>
<div className="mt-0.5 flex h-[34px] items-center rounded border border-gray-200 bg-gray-100 px-2 text-sm font-medium text-gray-900 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100">
{formatCurrency(total)}
</div>
</div>
</>
) : (
<>
<div>
<label className="block text-xs text-gray-500 dark:text-gray-400">Labor Hrs</label>
<input type="number" min="0" step="0.1" value={service.laborHours || ''} onChange={(e) => onChange(index, 'laborHours', parseFloat(e.target.value) || 0)} placeholder="0" className="mt-0.5 w-full rounded border border-gray-300 bg-white px-2 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100" />
</div>
<div>
<label className="block text-xs text-gray-500 dark:text-gray-400">Rate/hr</label>
<input type="number" min="0" step="1" value={service.laborRate || ''} onChange={(e) => onChange(index, 'laborRate', parseFloat(e.target.value) || 0)} placeholder="145" className="mt-0.5 w-full rounded border border-gray-300 bg-white px-2 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100" />
</div>
<div>
<label className="block text-xs text-gray-500 dark:text-gray-400">Parts $</label>
<input type="number" min="0" step="0.01" value={service.partsCost || ''} onChange={(e) => onChange(index, 'partsCost', parseFloat(e.target.value) || 0)} placeholder="0" className="mt-0.5 w-full rounded border border-gray-300 bg-white px-2 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100" />
</div>
<div>
<label className="block text-xs text-gray-500 dark:text-gray-400">Total</label>
<div className="mt-0.5 flex h-[34px] items-center rounded border border-gray-200 bg-gray-100 px-2 text-sm font-medium text-gray-900 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100">
{formatCurrency(total)}
</div>
</div>
</>
)}
</div>
</div>
);
}
/* ── Form helper components (inlined) ────────────────── */
function FormField({ label, children }: { label: string; children: React.ReactNode }) {
return <div className="space-y-1.5"><label className="block text-sm font-medium text-gray-700 dark:text-gray-300">{label}</label>{children}</div>;
}
function Input({ value, onChange, placeholder, type = 'text', icon }: { value: string | number; onChange: (value: string) => void; placeholder?: string; type?: string; icon?: React.ReactNode }) {
return (
<div className="relative">
{icon && <div className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">{icon}</div>}
<input type={type} value={value} onChange={(e) => onChange(e.target.value)} placeholder={placeholder} className={`w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 ${icon ? 'pl-9' : ''}`} />
</div>
);
}
function Textarea({ value, onChange, placeholder, rows = 3, spellCheck, lang }: { value: string; onChange: (value: string) => void; placeholder?: string; rows?: number; spellCheck?: boolean; lang?: string }) {
return (
<textarea value={value} onChange={(e) => onChange(e.target.value)} placeholder={placeholder} rows={rows}
spellCheck={spellCheck}
lang={lang}
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
/>
);
}
/* ── Main Modal Component ──────────────────────────── */
export default function RODetailModal() {
const { id } = useParams<{ id: string }>();
const location = useLocation();
const navigate = useNavigate();
const { showToast } = useToast();
const settings = useSettings();
const serviceTextSpellCheck = spellCheckProps(settings, settings.spellCheckServiceText);
const internalNotesSpellCheck = spellCheckProps(settings, settings.spellCheckInternalNotes);
const statusConfig = useMemo(() => getStatusConfig(settings), [settings]);
const modalBackground = (location.state as { backgroundLocation?: unknown })?.backgroundLocation;
const isModal = Boolean(modalBackground);
const [ro, setRo] = useState<RepairOrder | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<TabId>('details');
const [saving, setSaving] = useState(false);
const [now, setNow] = useState(Date.now());
// Details tab state
const [showAddTimeModal, setShowAddTimeModal] = useState(false);
const [showPromisedPicker, setShowPromisedPicker] = useState(false);
const [promisedValue, setPromisedValue] = useState('');
// Services tab state
const [catalogServices, setCatalogServices] = useState<ServiceItem[]>([]);
const [catalogSearch, setCatalogSearch] = useState('');
const [showNewServiceForm, setShowNewServiceForm] = useState(false);
const [newServiceForm, setNewServiceForm] = useState<ServiceFormState>(createEmptyServiceForm());
const [localServices, setLocalServices] = useState<ROService[]>([]);
const [servicesDirty, setServicesDirty] = useState(false);
const [servicesSaved, setServicesSaved] = useState(false);
// Reminder state
const [showReminderForm, setShowReminderForm] = useState(false);
const [reminderMileage, setReminderMileage] = useState('');
const [reminderInterval, setReminderInterval] = useState('5000');
const [reminderNotes, setReminderNotes] = useState('');
const [savingReminder, setSavingReminder] = useState(false);
// History tab state
const [historyData, setHistoryData] = useState<{ type: 'ro' | 'quote'; id: string; date: string; title: string; vehicleInfo: string; total: number; status: string }[]>([]);
const [historyLoading, setHistoryLoading] = useState(false);
// Timeline
const events = useMemo(() => (ro ? readEvents(ro) : []), [ro]);
// Financial Audit Trail
const [financialAuditEntries, setFinancialAuditEntries] = useState<ROFinancialAuditEntry[]>([]);
const [financialAuditLoading, setFinancialAuditLoading] = useState(false);
// Financial tab state
const [finCpTotal, setFinCpTotal] = useState('');
const [finCpCost, setFinCpCost] = useState('');
const [finWarrTotal, setFinWarrTotal] = useState('');
const [finWarrCost, setFinWarrCost] = useState('');
const [finShopCharge, setFinShopCharge] = useState('');
const [finSaving, setFinSaving] = useState(false);
const [finSaved, setFinSaved] = useState(false);
const [finSeeded, setFinSeeded] = useState(false);
const userId = pb.authStore.model?.id;
const closeModal = useCallback(() => {
if (isModal) navigate(-1);
else navigate('/repair-orders');
}, [isModal, navigate]);
useEffect(() => {
if (!isModal) return;
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') closeModal();
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [isModal, closeModal]);
// Live tick
useEffect(() => {
const tick = () => {
const ts = Date.now();
const mb = Math.floor(ts / 60000);
setNow((prev) => (Math.floor(prev / 60000) === mb ? prev : ts));
};
const id = setInterval(tick, 30000);
return () => clearInterval(id);
}, []);
// Fast tick when clock is running
useEffect(() => {
if (!ro || !clockRunning(ro)) return;
const id = setInterval(() => setNow(Date.now()), 5000);
return () => clearInterval(id);
}, [ro]);
const fetchRO = useCallback(async () => {
if (!id || !userId) { setLoading(false); return; }
setLoading(true);
setError(null);
try {
const record = await pb.collection('repairOrders').getOne(id);
const normalized = (() => {
const item = record as Record<string, unknown>;
let services: ROService[] = [];
const raw = item.services;
if (Array.isArray(raw)) services = raw as ROService[];
else if (typeof raw === 'string' && raw.trim()) { try { services = JSON.parse(raw); } catch (err) { logError('Failed to parse RO services JSON', err); } }
return { ...item, services } as unknown as RepairOrder;
})();
setRo(normalized);
setLocalServices(normalized.services || []);
setServicesDirty(false);
setServicesSaved(false);
setPromisedValue(readPromisedOverride(normalized) ? new Date(readPromisedOverride(normalized)!).toISOString().slice(0, 16) : '');
} catch (err) {
logError('Failed to load RO', err);
setError('Repair order could not be loaded.');
} finally {
setLoading(false);
}
}, [id, userId]);
const fetchCatalogServices = useCallback(async () => {
if (!userId) return;
try {
const records = await pb.collection('services').getFullList({ filter: `userId="${userId}"`, sort: 'name' });
setCatalogServices(records.map((r: Record<string, unknown>) => ({
id: r.id as string,
name: (r.name as string) || '',
category: (r.category as string) || '',
description: (r.explanation as string) || (r.description as string) || '',
laborHours: Number(r.laborHours) || 0,
laborRate: Number(r.laborRate) || 0,
partsCost: Number(r.partsCost) || 0,
price: Number(r.price) || 0,
active: r.active !== false,
created: (r.created as string) || '',
updated: (r.updated as string) || '',
})));
} catch { /* non-critical */ }
}, [userId]);
useEffect(() => {
fetchRO();
fetchCatalogServices();
}, [fetchRO, fetchCatalogServices]);
const loadHistory = useCallback(async () => {
if (!ro || !ro.vin || !ro.vin.trim()) {
setHistoryData([]);
return;
}
setHistoryLoading(true);
const results: { type: 'ro' | 'quote'; id: string; date: string; title: string; vehicleInfo: string; total: number; status: string }[] = [];
try {
const roRecords = await pb.collection('repairOrders').getFullList({
filter: `userId="${userId}" && vin="${ro.vin.trim()}"`,
sort: '-created',
fields: 'id,roNumber,customerName,vehicleInfo,vin,total,status,created',
});
for (const r of roRecords as Record<string, unknown>[]) {
if (r.id === ro.id) continue;
results.push({
type: 'ro',
id: r.id as string,
date: (r.created as string) || '',
title: `RO ${(r.roNumber as string) || (r.id as string)?.slice(0, 8) || ''}`,
vehicleInfo: (r.vehicleInfo as string) || '',
total: Number(r.total) || 0,
status: (r.status as string) || '',
});
}
} catch { /* ok */ }
try {
const qRecords = await pb.collection('quotes').getFullList({
filter: `userId="${userId}" && vin="${ro.vin.trim()}"`,
sort: '-created',
fields: 'id,customerInfo,vehicleInfo,vin,total,status,created',
});
for (const q of qRecords as Record<string, unknown>[]) {
const ci = q.customerInfo as Record<string, unknown> | undefined;
results.push({
type: 'quote',
id: q.id as string,
date: (q.created as string) || '',
title: `Quote #${(q.id as string)?.slice(0, 8) || ''}`,
vehicleInfo: (q.vehicleInfo as string) || (ci?.vehicleInfo as string) || '',
total: Number(q.total) || 0,
status: (q.status as string) || '',
});
}
} catch { /* ok */ }
results.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
setHistoryData(results);
setHistoryLoading(false);
}, [ro, userId]);
useEffect(() => {
if (activeTab === 'history') loadHistory();
}, [activeTab, loadHistory]);
const loadFinancialAudit = useCallback(async () => {
if (!ro) return;
setFinancialAuditLoading(true);
try {
const records = await pb.collection('roFinancialAudit').getFullList({
filter: `roId = '${ro.id}'`,
sort: '-at',
});
setFinancialAuditEntries(records.map((r: Record<string, unknown>) => ({
id: r.id as string,
roId: r.roId as string,
field: r.field as string,
from: r.from,
to: r.to,
by: r.by as string,
at: r.at as string,
prevHash: r.prevHash as string,
hash: r.hash as string,
})));
} catch {
setFinancialAuditEntries([]);
} finally {
setFinancialAuditLoading(false);
}
}, [ro]);
useEffect(() => {
if (activeTab === 'timeline' && ro) loadFinancialAudit();
}, [activeTab, ro, loadFinancialAudit]);
// Pre-seed financial inputs from RO data when opening the Financial tab
// for the first time (or when the RO changes).
useEffect(() => {
if (activeTab !== 'financial' || !ro || finSeeded) return;
const f = (() => {
const raw = ro.financial;
if (typeof raw === 'string') { try { return JSON.parse(raw) as Record<string, any>; } catch { return {}; } }
return (typeof raw === 'object' && raw ? raw : {}) as Record<string, any>;
})();
const existingCpTotal = f.grossTotal ?? f.cpTotal;
const existingCpCost = f.grossCost ?? f.cpCost;
const existingWarrTotal = f.warrTotal ?? f.whTotal;
const existingWarrCost = f.warrCost ?? f.whCost;
const existingShopCharge = f.shopCharge;
const totals = computeROTotals({ services: ro.services || [], discount: ro.discount, settings });
// CP Total is shop revenue before tax. Tax is pass-through and should not
// be saved as customer-pay financial revenue.
setFinCpTotal(existingCpTotal != null ? String(existingCpTotal) : totals.taxableBase.toFixed(2));
setFinCpCost(existingCpCost != null ? String(existingCpCost) : '');
setFinWarrTotal(existingWarrTotal != null ? String(existingWarrTotal) : '');
setFinWarrCost(existingWarrCost != null ? String(existingWarrCost) : '');
setFinShopCharge(existingShopCharge != null ? String(existingShopCharge) : totals.shopCharge.toFixed(2));
setFinSeeded(true);
}, [activeTab, ro, finSeeded, settings]);
const voided = ro ? isVoided(ro) : false;
const isActiveStatus = ro && ro.status !== 'completed' && ro.status !== 'final_close' && ro.status !== 'delivered' && !voided;
const isCompletedStatus = ro && (ro.status === 'completed' || ro.status === 'final_close' || ro.status === 'delivered') && !voided;
const urgency = ro && isActiveStatus ? getUrgencyClass(ro, now) : '';
const clockActive = ro ? !!clockRunning(ro) : false;
const clockElapsedMin = ro ? clockElapsedMinutes(ro) : 0;
/* ── Handlers ────────────────────────────────────── */
const handleStatusChange = async (status: RepairOrder['status']) => {
if (!ro) return;
const prevStatus = ro.status;
let updatedServices: ROService[] | undefined;
let updatedCompletedTime: string | undefined;
if (status === 'completed') {
updatedCompletedTime = new Date().toISOString();
const allDone = (ro.services || []).every((s) => s.status === 'completed');
if (!allDone) updatedServices = (ro.services || []).map((s) => ({ ...s, status: 'completed' as const }));
} else if (['waiting_pickup', 'active', 'in_progress', 'waiting_parts'].includes(status)) {
updatedCompletedTime = '';
}
setRo((prev) => prev ? { ...prev, status, completedTime: updatedCompletedTime, completedDate: updatedCompletedTime, services: updatedServices ?? prev.services } : prev);
const update: Record<string, unknown> = { status };
if (updatedCompletedTime !== undefined) update.completedTime = updatedCompletedTime;
if (updatedServices) update.services = JSON.stringify(updatedServices);
try {
await pb.collection('repairOrders').update(ro.id, update);
try {
const linkedAppointment = await pb.collection('appointments').getFirstListItem(
`repairOrderId = '${ro.id}' && userId = '${userId}'`
);
const as = status === 'completed' || status === 'final_close' || status === 'delivered' ? 'completed' : 'checked_in';
if (linkedAppointment?.status !== as) await pb.collection('appointments').update(linkedAppointment.id, { status: as });
} catch {}
try { await appendROEvent(ro.id, 'status', { from: prevStatus, to: status }); } catch (err) { logError('Failed to append RO status event', err); showToast('Saved, but the timeline event could not be recorded.', 'error'); }
showToast(`Status changed to ${status}.`, 'success');
} catch {
setRo((prev) => prev ? { ...prev, status: prevStatus, completedTime: undefined, completedDate: undefined } : prev);
showToast('Failed to update status.', 'error');
}
};
const handleClockStart = async () => {
if (!ro) return;
try { await clockStart(ro.id); setRo((prev) => prev ? { ...prev } : prev); showToast('Clock started.', 'success'); }
catch { showToast('Failed to start clock.', 'error'); }
};
const handleClockStop = async () => {
if (!ro) return;
try { await clockStop(ro.id); setRo((prev) => prev ? { ...prev } : prev); showToast('Clock stopped.', 'success'); }
catch { showToast('Failed to stop clock.', 'error'); }
};
const handleSaveReminder = async () => {
if (!ro || !userId) return;
const mileage = parseInt(reminderMileage) || 0;
const interval = parseInt(reminderInterval) || 0;
if (!mileage || !interval) { showToast('Please enter mileage and interval.', 'error'); return; }
setSavingReminder(true);
try {
await pb.collection('reminders').create({
customerId: ro.customerId || '',
customerName: ro.customerName || '',
vehicleInfo: ro.vehicleInfo || '',
shopUserId: userId,
mileageAtService: mileage,
intervalMileage: interval,
dueMileage: mileage + interval,
notes: reminderNotes,
active: true,
});
showToast('Reminder set successfully.', 'success');
setShowReminderForm(false);
setReminderNotes('');
} catch {
showToast('Failed to set reminder.', 'error');
} finally {
setSavingReminder(false);
}
};
const handleSetPromisedOverride = async () => {
if (!ro) return;
try {
await setPromisedOverride(ro.id, promisedValue ? new Date(promisedValue).toISOString() : null);
setRo((prev) => prev ? { ...prev } : prev);
showToast(promisedValue ? 'Promised time pinned.' : 'Override cleared.', 'success');
} catch { showToast('Failed to set promised time.', 'error'); }
setShowPromisedPicker(false);
};
const handleSetSatisfaction = async (rating: number) => {
if (!ro) return;
try {
await pb.collection('repairOrders').update(ro.id, { satisfaction: rating });
try { await appendROEvent(ro.id, 'satisfaction', { rating }); } catch (err) { logError('Failed to append RO satisfaction event', err); showToast('Saved, but the timeline event could not be recorded.', 'error'); }
setRo((prev) => prev ? { ...prev, satisfaction: rating } : prev);
showToast(`Rated ${rating}★.`, 'success');
} catch { showToast('Failed to save rating.', 'error'); }
};
const handleNotifyCustomer = async (channel: 'email' | 'sms') => {
if (!ro) return;
const subject = `Your vehicle ${ro.vehicleInfo || ''} is ready for pickup — RO ${ro.roNumber || ''}`;
const body = [
`Hi ${ro.customerName || ''},`,
'',
`Your vehicle${ro.vehicleInfo ? ` (${ro.vehicleInfo})` : ''} is ready for pickup.`,
`Repair Order: ${ro.roNumber || ''}`,
ro.total ? `Invoice total: ${formatCurrency(ro.total)}` : '',
'',
'Please give us a call if you have any questions.',
].filter(Boolean).join('\n');
if (channel === 'email') openEmailDraft(ro.customerEmail || '', subject, body);
else openSmsDraft(ro.customerPhone || '', body);
try { await appendROEvent(ro.id, 'notify', { channel, to: channel === 'email' ? ro.customerEmail : ro.customerPhone }); } catch (err) { logError('Failed to append RO notify event', err); showToast('Saved, but the timeline event could not be recorded.', 'error'); }
showToast(`Opened ${channel === 'email' ? 'email' : 'SMS'} draft.`, 'success');
};
const handleVoid = async () => {
if (!ro) return;
if (!window.confirm(`Void RO ${ro.roNumber || ro.id.slice(0, 8)}?`)) return;
try { await setVoided(ro.id, !voided); setRo((prev) => prev ? { ...prev } : prev); showToast(voided ? 'RO un-voided.' : 'RO voided.', 'success'); }
catch { showToast('Failed to toggle void.', 'error'); }
};
const handleGenerateQuote = () => {
if (!ro) return;
const params = new URLSearchParams({
fromRO: ro.id,
name: ro.customerName || '',
phone: ro.customerPhone || '',
vehicleInfo: ro.vehicleInfo || '',
vin: ro.vin || '',
mileage: ro.mileage || '',
roNumber: ro.roNumber || '',
serviceAdvisor: ro.advisorName || '',
});
navigate(`/quote?${params.toString()}`);
};
const handlePrintRO = async () => {
if (!ro) return;
try { await printROPDF(ro, settings); } catch { showToast('Failed to open print window.', 'error'); }
};
const handleDownloadRO = async () => {
if (!ro) return;
try { await downloadROPDF(ro, settings); showToast('RO PDF downloaded.', 'success'); } catch { showToast('Failed to generate PDF.', 'error'); }
};
const handleAddTime = async (extraMinutes: number) => {
if (!ro) return;
const newDuration = (ro.estimatedDuration || 60) + extraMinutes;
setRo((prev) => prev ? { ...prev, estimatedDuration: newDuration } : prev);
try {
await pb.collection('repairOrders').update(ro.id, { estimatedTime: (newDuration / 60).toFixed(1) });
try { await appendROEvent(ro.id, 'add_time', { minutes: extraMinutes, newTotal: newDuration }); } catch (err) { logError('Failed to append RO add_time event', err); showToast('Saved, but the timeline event could not be recorded.', 'error'); }
} catch {
showToast('Failed to add time.', 'error');
fetchRO();
}
};
/* ── Financial tab handlers ─────────────────────────── */
// Live computed values — warranty deducted from CP, shop charge deducted
// from CP gross profit (per shop owner's business rules).
const finCpTotalNum = parseFloat(finCpTotal) || 0;
const finCpCostNum = parseFloat(finCpCost) || 0;
const finWarrTotalNum = parseFloat(finWarrTotal) || 0;
const finWarrCostNum = parseFloat(finWarrCost) || 0;
const finShopChargeNum = parseFloat(finShopCharge) || 0;
const cpNetTotal = Math.max(0, finCpTotalNum - finWarrTotalNum);
const cpNetCost = Math.max(0, finCpCostNum - finWarrCostNum);
const cpGrossProfit = cpNetTotal - cpNetCost - finShopChargeNum;
const warrGrossProfit = finWarrTotalNum - finWarrCostNum;
const overallGrossProfit = cpGrossProfit + warrGrossProfit;
const handleSaveFinancial = async () => {
if (!ro) return;
setFinSaving(true);
setFinSaved(false);
try {
// Read existing financial blob to preserve other keys (e.g. customerType)
let existing: Record<string, any> = {};
const raw = ro.financial;
if (typeof raw === 'string') { try { existing = JSON.parse(raw); } catch { /* ignore */ } }
else if (typeof raw === 'object' && raw) { existing = raw as Record<string, any>; }
const financial = {
...existing,
grossTotal: finCpTotalNum,
grossCost: finCpCostNum,
warrTotal: finWarrTotalNum,
warrCost: finWarrCostNum,
shopCharge: finShopChargeNum,
cpProfit: cpGrossProfit,
whProfit: warrGrossProfit,
grossProfit: overallGrossProfit,
totalAmount: finCpTotalNum + finWarrTotalNum,
totalCost: finCpCostNum + finWarrCostNum,
completedAt: existing.completedAt || new Date().toISOString(),
};
await pb.collection('repairOrders').update(ro.id, {
financial: JSON.stringify(financial),
});
try {
await appendROEvent(ro.id, 'financial', { field: 'grossProfit' });
} catch (err) {
logError('Failed to append RO financial event', err);
}
setRo((prev) => prev ? { ...prev, financial } : prev);
setFinSaved(true);
showToast('Financial data saved.', 'success');
setTimeout(() => setFinSaved(false), 3000);
} catch (err) {
logError('Failed to save financial data', err);
showToast('Failed to save financial data.', 'error');
} finally {
setFinSaving(false);
}
};
/* ── Service handlers ────────────────────────────────── */
const handleServiceChange = (index: number, field: string, value: string | number | boolean) => {
setLocalServices((prev) => {
const next = [...prev];
next[index] = { ...next[index], [field]: value };
return next;
});
setServicesDirty(true);
setServicesSaved(false);
};
const handleRemoveService = (index: number) => {
setLocalServices((prev) => prev.filter((_, i) => i !== index));
setServicesDirty(true);
setServicesSaved(false);
};
const handleAddService = () => {
setLocalServices((prev) => [...prev, {
id: `new-${Date.now()}`,
name: '',
description: '',
laborHours: 0,
laborRate: 0,
partsCost: 0,
total: 0,
status: 'pending',
technician: '',
pricingMode: settings.defaultPricingMode,
applyShopCharge: true,
}]);
setServicesDirty(true);
setServicesSaved(false);
};
const handleAddCatalogService = (svc: ServiceItem) => {
const laborPrice = getServiceLaborPrice(svc);
const isSplit = getServicePricingMode(svc) === 'split';
setLocalServices((prev) => [...prev, {
id: `catalog-${svc.id}-${Date.now()}`,
name: svc.name,
description: svc.description,
laborHours: isSplit ? 1 : 0,
laborRate: isSplit ? laborPrice : 0,
partsCost: isSplit ? svc.partsCost : 0,
total: svc.price,
status: 'pending',
technician: '',
pricingMode: isSplit ? 'split' : settings.defaultPricingMode,
applyShopCharge: true,
}]);
setServicesDirty(true);
setServicesSaved(false);
showToast(`Added "${svc.name}" to RO.`, 'success');
};
const handleSaveServices = async () => {
if (!ro) return;
setSaving(true);
try {
const cleaned = localServices.map((s) => {
const service = {
id: s.id,
name: s.name,
description: s.description || '',
laborHours: s.laborHours || 0,
laborRate: s.laborRate || 0,
partsCost: s.partsCost || 0,
total: roServiceTotal(s),
status: s.status || 'pending',
technician: s.technician || '',
pricingMode: s.pricingMode || 'full',
applyShopCharge: s.applyShopCharge !== false,
};
return settings.applyServiceFormattingToROs ? formatServiceTextFields(service, settings) : service;
});
const payload = { services: JSON.stringify(cleaned) };
await pb.collection('repairOrders').update(ro.id, payload);
const savedRecord = await pb.collection('repairOrders').getOne(ro.id);
const savedServices = parseRepairOrderServices((savedRecord as Record<string, unknown>).services);
const savedIds = new Set(savedServices.map((s) => s.id));
const missingSavedService = cleaned.some((s) => !savedIds.has(s.id));
if (cleaned.length > 0 && (savedServices.length === 0 || missingSavedService)) {
throw new Error('Saved repair order did not return the expected services.');
}
syncAssignmentsForROServices(ro.id, savedServices).catch((err) => {
logError('Failed to sync technician assignments after service save', err);
});
try { await appendROEvent(ro.id, 'edit', {}); } catch (err) { logError('Failed to append RO edit event', err); showToast('Saved, but the timeline event could not be recorded.', 'error'); }
setRo((prev) => prev ? { ...prev, services: savedServices } : prev);
setLocalServices(savedServices);
setServicesDirty(false);
setServicesSaved(true);
showToast('Services saved.', 'success');
setTimeout(() => setServicesSaved(false), 3000);
} catch (err) {
logError('Failed to save services', err);
showToast('Failed to save services.', 'error');
} finally {
setSaving(false);
}
};
/* ── New catalog service in modal ────────────────── */
const handleSaveNewService = async () => {
if (!newServiceForm.name.trim()) { showToast('Service name is required.', 'error'); return; }
if (!userId) return;
const fullPrice = Number(newServiceForm.fullPrice) || 0;
const laborPrice = Number(newServiceForm.laborPrice) || 0;
const partsPrice = Number(newServiceForm.partsPrice) || 0;
const isSplit = newServiceForm.pricingMode === 'split';
const description = newServiceForm.description.trim();
const data = formatServiceTextFields({
name: newServiceForm.name.trim(),
category: newServiceForm.category.trim(),
description,
explanation: description,
laborHours: isSplit && laborPrice > 0 ? 1 : 0,
laborRate: isSplit ? laborPrice : 0,
partsCost: isSplit ? partsPrice : 0,
price: isSplit ? laborPrice + partsPrice : fullPrice,
active: true,
userId,
}, settings.applyServiceFormattingToCatalog ? settings : { ...settings, serviceNameFormat: 'preserve', serviceDescriptionFormat: 'preserve', serviceRecommendationFormat: 'preserve', serviceCategoryFormat: 'preserve', trimServiceText: true, addPeriodToServiceDescriptions: false });
try {
const created = await pb.collection('services').create(data) as Record<string, unknown>;
const newSvc: ServiceItem = {
id: created.id as string,
name: data.name,
category: data.category,
description: data.description,
laborHours: data.laborHours,
laborRate: data.laborRate,
partsCost: data.partsCost,
price: data.price,
active: true,
created: '',
updated: '',
};
setCatalogServices((prev) => [...prev, newSvc].sort((a, b) => a.name.localeCompare(b.name)));
handleAddCatalogService(newSvc);
setNewServiceForm(createEmptyServiceForm());
setShowNewServiceForm(false);
showToast('Service created and added to RO.', 'success');
} catch {
showToast('Failed to create service.', 'error');
}
};
/* ── Render ──────────────────────────────────────── */
if (loading) return <div className="flex min-h-[60vh] items-center justify-center"><LoadingSpinner text="Loading repair order..." /></div>;
if (error || !ro) {
const content = (
<div className="flex flex-col items-center justify-center py-24">
<AlertTriangle className="h-12 w-12 text-red-400" />
<p className="mt-4 text-sm text-gray-500">{error || 'Repair order not found.'}</p>
<button onClick={closeModal} className="mt-4 rounded-lg border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300">Close</button>
</div>
);
if (!isModal) return content;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-950/45 backdrop-blur-sm">
<div className="rounded-xl bg-white p-8 dark:bg-gray-900">{content}</div>
</div>
);
}
const totals = computeROTotals({ services: localServices, discount: ro.discount, settings });
const filteredCatalog = catalogServices.filter((s) => {
if (!catalogSearch.trim()) return true;
const q = catalogSearch.toLowerCase();
return s.name.toLowerCase().includes(q) || s.category.toLowerCase().includes(q) || s.description.toLowerCase().includes(q);
});
/* ── Tab content builders ────────────────────────── */
const detailsContent = (
<div className={voided ? 'opacity-60' : ''}>
{voided && (
<div className="mb-4 flex items-center gap-2 rounded-lg border border-red-300 bg-red-50 px-4 py-3 text-sm font-medium text-red-700 dark:border-red-700 dark:bg-red-900/20 dark:text-red-300">
<Ban className="h-4 w-4" /> This repair order has been voided.
</div>
)}
{/* Header */}
<div className="mb-4 flex flex-wrap items-center gap-3">
<div className="min-w-0 flex-1">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">{ro.roNumber || `RO #${ro.id.slice(0, 8)}`}</h2>
<p className="truncate text-sm text-gray-500 dark:text-gray-400">{ro.customerName} {ro.vehicleInfo || 'No vehicle'}</p>
</div>
<div className="flex flex-wrap items-center gap-1.5">
<button onClick={handlePrintRO} className="inline-flex items-center gap-1.5 rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"><Printer className="h-3.5 w-3.5" /> Print</button>
<button onClick={handleDownloadRO} className="inline-flex items-center gap-1.5 rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"><Download className="h-3.5 w-3.5" /> PDF</button>
<button onClick={handleGenerateQuote} className="inline-flex items-center gap-1.5 rounded-lg bg-green-600 px-3 py-1.5 text-xs font-medium text-white shadow-sm hover:bg-green-700"><FileText className="h-3.5 w-3.5" /> Generate Quote</button>
<button onClick={() => { setReminderMileage(ro.mileage || ''); setReminderInterval('5000'); setReminderNotes(''); setShowReminderForm(!showReminderForm); }} className={`inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium ${showReminderForm ? 'border-blue-300 bg-blue-50 text-blue-700 dark:border-blue-700 dark:bg-blue-900/20 dark:text-blue-300' : 'border-gray-300 bg-white text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'}`}><BellRing className="h-3.5 w-3.5" /> Set Reminder</button>
</div>
</div>
{/* Set Reminder inline form */}
{showReminderForm && (
<div className="mb-4 rounded-xl border border-blue-200 bg-blue-50 p-4 dark:border-blue-700 dark:bg-blue-900/20">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-sm font-semibold text-blue-800 dark:text-blue-300">
<BellRing className="mr-1.5 inline h-4 w-4" />
Set Maintenance Reminder
</h3>
<button onClick={() => setShowReminderForm(false)} className="rounded p-1 text-blue-400 hover:text-blue-600 dark:hover:text-blue-300">
<X className="h-4 w-4" />
</button>
</div>
<div className="mb-3 text-xs text-blue-700 dark:text-blue-400">
Customer: <strong>{ro.customerName}</strong> Vehicle: <strong>{ro.vehicleInfo}</strong>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div>
<label className="block text-xs font-medium text-blue-700 dark:text-blue-300">Mileage at Service</label>
<input
type="number"
value={reminderMileage}
onChange={(e) => setReminderMileage(e.target.value)}
placeholder={ro.mileage || 'Enter mileage'}
className="mt-1 w-full rounded border border-blue-300 bg-white px-2 py-1.5 text-sm dark:border-blue-700 dark:bg-gray-800 dark:text-gray-100"
/>
</div>
<div>
<label className="block text-xs font-medium text-blue-700 dark:text-blue-300">Interval (miles)</label>
<input
type="number"
value={reminderInterval}
onChange={(e) => setReminderInterval(e.target.value)}
placeholder="5000"
className="mt-1 w-full rounded border border-blue-300 bg-white px-2 py-1.5 text-sm dark:border-blue-700 dark:bg-gray-800 dark:text-gray-100"
/>
</div>
<div>
<label className="block text-xs font-medium text-blue-700 dark:text-blue-300">Due Mileage (computed)</label>
<div className="mt-1 flex h-[34px] items-center rounded border border-blue-200 bg-blue-100 px-2 text-sm font-medium text-blue-800 dark:border-blue-700 dark:bg-blue-900/40 dark:text-blue-300">
{((parseInt(reminderMileage) || 0) + (parseInt(reminderInterval) || 0)).toLocaleString()}
</div>
</div>
</div>
<div className="mt-3">
<label className="block text-xs font-medium text-blue-700 dark:text-blue-300">Notes (optional)</label>
<textarea
value={reminderNotes}
onChange={(e) => setReminderNotes(e.target.value)}
placeholder="e.g. Next oil change reminder"
rows={2}
{...internalNotesSpellCheck}
className="mt-1 w-full rounded border border-blue-300 bg-white px-2 py-1.5 text-sm dark:border-blue-700 dark:bg-gray-800 dark:text-gray-100"
/>
</div>
<div className="mt-3 flex justify-end gap-2">
<button
onClick={() => setShowReminderForm(false)}
className="rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300"
>
Cancel
</button>
<button
onClick={handleSaveReminder}
disabled={savingReminder}
className="inline-flex items-center gap-1.5 rounded-lg bg-blue-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{savingReminder ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <BellRing className="h-3.5 w-3.5" />}
{savingReminder ? 'Saving...' : 'Save Reminder'}
</button>
</div>
</div>
)}
{/* Info bar */}
<div className="mb-4 rounded-xl bg-white p-4 ring-1 ring-gray-200 dark:bg-gray-800 dark:ring-gray-700">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
<div>
<p className="text-xs font-medium text-gray-500 dark:text-gray-400">Customer</p>
<p className="mt-1 text-sm font-medium text-gray-900 dark:text-gray-100">{ro.customerName || '—'}</p>
{ro.customerPhone && <p className="text-xs text-gray-400">{ro.customerPhone}</p>}
</div>
<div>
<p className="text-xs font-medium text-gray-500 dark:text-gray-400">Vehicle</p>
<p className="mt-1 text-sm text-gray-900 dark:text-gray-100">{ro.vehicleInfo || '—'}</p>
{ro.vin && <p className="text-xs text-gray-400">VIN: {ro.vin}</p>}
{ro.mileage && <p className="text-xs text-gray-400">Mileage: {ro.mileage}</p>}
</div>
<div>
<p className="text-xs font-medium text-gray-500 dark:text-gray-400">Status</p>
<div className="mt-1"><StatusBadge status={voided ? 'voided' : (ro.status || '')} config={statusConfig} /></div>
</div>
<div>
<p className="text-xs font-medium text-gray-500 dark:text-gray-400">Write-up</p>
<p className="mt-1 text-sm text-gray-900 dark:text-gray-100">{formatDateTime(ro.writeupTime || ro.created)}</p>
</div>
<div>
<p className="text-xs font-medium text-gray-500 dark:text-gray-400">Promised</p>
<div className="mt-1 flex items-center gap-1.5">
<span className="text-sm text-gray-900 dark:text-gray-100">{formatDueDateTime(ro)}</span>
{isActiveStatus && (
<button onClick={() => { setPromisedValue(readPromisedOverride(ro) ? new Date(readPromisedOverride(ro)!).toISOString().slice(0, 16) : ''); setShowPromisedPicker(true); }}
className="text-xs text-blue-600 hover:underline dark:text-blue-400">{readPromisedOverride(ro) ? 'clear' : 'pin'}</button>
)}
</div>
</div>
<div>
<p className="text-xs font-medium text-gray-500 dark:text-gray-400">{isCompletedStatus ? 'Completed' : 'Time Remaining'}</p>
<div className="mt-1 flex items-center gap-2">
{isActiveStatus ? (
<>
<span className={`text-sm ${urgency === 'urgent' ? 'font-semibold text-red-600 dark:text-red-400' : urgency === 'warning' ? 'font-medium text-amber-600 dark:text-amber-400' : 'text-gray-600 dark:text-gray-400'}`}>
{urgency === 'urgent' && <AlertTriangle className="mr-1 inline h-3.5 w-3.5 text-red-500" />}
{formatTimeRemaining(ro, now)}
</span>
<button onClick={() => setShowAddTimeModal(true)} className="inline-flex items-center gap-1 rounded border border-blue-300 bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-700 hover:bg-blue-100 dark:border-blue-700 dark:bg-blue-900/20 dark:text-blue-400">+ Add Time</button>
</>
) : isCompletedStatus ? (
<span className="text-sm text-gray-900 dark:text-gray-100">{formatCompletedTime(ro)}</span>
) : <span className="text-sm text-gray-400"></span>}
</div>
</div>
</div>
</div>
{/* Services summary */}
<div className="mb-4 rounded-xl bg-white p-4 ring-1 ring-gray-200 dark:bg-gray-800 dark:ring-gray-700">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div>
<h3 className="text-sm font-semibold text-gray-800 dark:text-gray-100">Services on this RO</h3>
<p className="text-xs text-gray-500 dark:text-gray-400">
{localServices.filter((s) => s.name?.trim()).length} service{localServices.filter((s) => s.name?.trim()).length === 1 ? '' : 's'} · {formatCurrency(totals.subtotal)} subtotal
</p>
</div>
<button
type="button"
onClick={() => setActiveTab('services')}
className="inline-flex items-center gap-1.5 rounded-lg border border-green-300 bg-green-50 px-3 py-1.5 text-xs font-medium text-green-700 hover:bg-green-100 dark:border-green-700 dark:bg-green-900/20 dark:text-green-300"
>
<Wrench className="h-3.5 w-3.5" /> Edit Services
</button>
</div>
{localServices.filter((s) => s.name?.trim()).length > 0 ? (
<div className="space-y-2">
{localServices.filter((s) => s.name?.trim()).map((svc, i) => (
<div key={`${svc.id}-${i}`} className="flex flex-wrap items-start justify-between gap-3 rounded-lg border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-700 dark:bg-gray-900/50">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className="truncate text-sm font-medium text-gray-900 dark:text-gray-100">{svc.name}</p>
<PricingBadge mode={svc.pricingMode || 'full'} />
<span className={`rounded-md px-2 py-0.5 text-[11px] font-medium ${svc.applyShopCharge !== false ? 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400' : 'bg-gray-100 text-gray-400 dark:bg-gray-700 dark:text-gray-500'}`}>
{svc.applyShopCharge !== false ? 'Shop Charge On' : 'Shop Charge Off'}
</span>
</div>
{svc.description && <p className="mt-1 line-clamp-2 text-xs text-gray-500 dark:text-gray-400">{svc.description}</p>}
</div>
<div className="text-right">
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">{formatCurrency(roServiceTotal(svc))}</p>
<p className="text-[11px] text-gray-400">Line total</p>
</div>
</div>
))}
</div>
) : (
<div className="rounded-lg border border-dashed border-gray-300 bg-gray-50 px-4 py-6 text-center dark:border-gray-700 dark:bg-gray-900/40">
<p className="text-sm font-medium text-gray-600 dark:text-gray-300">No services have been added to this RO yet.</p>
<button
type="button"
onClick={() => setActiveTab('services')}
className="mt-3 inline-flex items-center gap-1.5 rounded-lg bg-green-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-green-700"
>
<Plus className="h-3.5 w-3.5" /> Add Services
</button>
</div>
)}
</div>
{/* Promised-time override */}
{showPromisedPicker && (
<div className="mb-4 flex flex-wrap items-center gap-2 rounded-lg border border-orange-200 bg-orange-50 p-3 text-sm dark:border-orange-700 dark:bg-orange-900/20">
<label className="text-xs font-medium text-orange-700 dark:text-orange-300">Pin ready-by time:</label>
<input type="datetime-local" value={promisedValue} onChange={(e) => setPromisedValue(e.target.value)} className="rounded border border-orange-300 bg-white px-2 py-1 text-sm dark:border-orange-700 dark:bg-gray-800 dark:text-gray-100" />
<button onClick={handleSetPromisedOverride} className="rounded bg-orange-600 px-3 py-1 text-xs font-medium text-white hover:bg-orange-700">Set</button>
<button onClick={() => { handleSetPromisedOverride(); setShowPromisedPicker(false); }} className="rounded border border-gray-300 bg-white px-3 py-1 text-xs font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300">Clear</button>
<button onClick={() => setShowPromisedPicker(false)} className="rounded px-2 py-1 text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400">Cancel</button>
</div>
)}
{/* Quick actions */}
<div className="mb-4 flex flex-wrap items-center gap-1.5">
{!voided && ro.status !== 'active' && (
<button onClick={() => handleStatusChange('active')} className="rounded border border-blue-300 bg-blue-50 px-2.5 py-1 text-xs font-medium text-blue-700 hover:bg-blue-100 dark:border-blue-700 dark:bg-blue-900/20 dark:text-blue-400">Active</button>
)}
{!voided && !['in_progress', 'waiting_pickup', 'completed', 'final_close', 'delivered'].includes(ro.status) && (
<button onClick={() => handleStatusChange('in_progress')} className="rounded border border-yellow-300 bg-yellow-50 px-2.5 py-1 text-xs font-medium text-yellow-700 hover:bg-yellow-100 dark:border-yellow-700 dark:bg-yellow-900/20 dark:text-yellow-400">In Progress</button>
)}
{!voided && !['waiting_parts', 'waiting_pickup', 'completed', 'final_close', 'delivered'].includes(ro.status) && (
<button onClick={() => handleStatusChange('waiting_parts')} className="rounded border border-orange-300 bg-orange-50 px-2.5 py-1 text-xs font-medium text-orange-700 hover:bg-orange-100 dark:border-orange-700 dark:bg-orange-900/20 dark:text-orange-400">Waiting Parts</button>
)}
{!voided && !['waiting_pickup', 'completed', 'final_close', 'delivered'].includes(ro.status) && (
<button onClick={() => handleStatusChange('waiting_pickup')} className="rounded border border-teal-300 bg-teal-50 px-2.5 py-1 text-xs font-medium text-teal-700 hover:bg-teal-100 dark:border-teal-700 dark:bg-teal-900/20 dark:text-teal-400">Waiting Pick-up</button>
)}
{!voided && !['completed', 'final_close', 'delivered'].includes(ro.status) && (
<button onClick={() => handleStatusChange('completed')} className="rounded border border-green-300 bg-green-50 px-2.5 py-1 text-xs font-medium text-green-700 hover:bg-green-100 dark:border-green-700 dark:bg-green-900/20 dark:text-green-400">Complete</button>
)}
{!voided && ro.status === 'completed' && (
<button onClick={() => handleStatusChange('final_close')} className="rounded border border-gray-300 bg-gray-50 px-2.5 py-1 text-xs font-medium text-gray-700 hover:bg-gray-100 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300">Final Close</button>
)}
{isCompletedStatus && !voided && (
<button onClick={() => handleStatusChange('active')} className="rounded border border-gray-300 bg-gray-50 px-2.5 py-1 text-xs font-medium text-gray-700 hover:bg-gray-100 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300">Reopen</button>
)}
{ro.status === 'waiting_pickup' && !voided && (
<>
<button onClick={() => handleNotifyCustomer('sms')} className="inline-flex items-center gap-1 rounded border border-teal-300 bg-teal-50 px-2.5 py-1 text-xs font-medium text-teal-700 hover:bg-teal-100 dark:border-teal-700 dark:bg-teal-900/20 dark:text-teal-300"><Phone className="h-3 w-3" /> Notify SMS</button>
<button onClick={() => handleNotifyCustomer('email')} className="inline-flex items-center gap-1 rounded border border-teal-300 bg-teal-50 px-2.5 py-1 text-xs font-medium text-teal-700 hover:bg-teal-100 dark:border-teal-700 dark:bg-teal-900/20 dark:text-teal-300"><Mail className="h-3 w-3" /> Notify Email</button>
</>
)}
<span className="mx-1 h-4 w-px bg-gray-200 dark:bg-gray-700" />
<button onClick={handleVoid} className={`inline-flex items-center gap-1 rounded border px-2.5 py-1 text-xs font-medium ${voided ? 'border-green-300 bg-green-50 text-green-700 hover:bg-green-100 dark:border-green-700 dark:bg-green-900/20 dark:text-green-300' : 'border-red-300 bg-red-50 text-red-700 hover:bg-red-100 dark:border-red-700 dark:bg-red-900/20 dark:text-red-300'}`}>
<Ban className="h-3 w-3" /> {voided ? 'Un-void' : 'Void RO'}
</button>
<span className="mx-1 h-4 w-px bg-gray-200 dark:bg-gray-700" />
<div className="flex items-center gap-1.5">
<Clock3 className={`h-4 w-4 ${clockActive ? 'text-indigo-500' : 'text-gray-400'}`} />
<span className="text-sm tabular-nums text-gray-700 dark:text-gray-200">{formatElapsed(clockElapsedMin)}</span>
{clockActive ? (
<button onClick={handleClockStop} className="rounded border border-indigo-300 bg-indigo-50 px-2 py-0.5 text-xs font-medium text-indigo-700 hover:bg-indigo-100 dark:border-indigo-700 dark:bg-indigo-900/20 dark:text-indigo-300">Stop</button>
) : (
<button onClick={handleClockStart} className="rounded border border-gray-300 bg-white px-2 py-0.5 text-xs font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300">Start</button>
)}
</div>
</div>
{/* Customer satisfaction */}
{isCompletedStatus && !voided && (
<div className="rounded-xl bg-white p-4 ring-1 ring-gray-200 dark:bg-gray-800 dark:ring-gray-700">
<p className="mb-1 text-xs font-medium text-gray-500 dark:text-gray-400">Customer Satisfaction</p>
<div className="flex gap-0.5">
{[1, 2, 3, 4, 5].map((star) => (
<button key={star} onClick={() => handleSetSatisfaction(star)}
className={`h-6 w-6 transition-colors ${(ro.satisfaction || 0) >= star ? 'text-yellow-400 hover:text-yellow-500' : 'text-gray-300 hover:text-yellow-300 dark:text-gray-600 dark:hover:text-yellow-500'}`}>
<span className="text-xl leading-none"></span>
</button>
))}
{!ro.satisfaction && <span className="ml-2 self-center text-xs text-gray-400">Not rated</span>}
</div>
</div>
)}
</div>
);
const servicesContent = (
<div>
{/* Sticky save bar */}
<div className="sticky top-0 z-10 flex items-center justify-end gap-2 border-b border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-700 dark:bg-gray-950">
{servicesSaved && <span className="text-xs text-green-600 dark:text-green-400">Saved</span>}
<button onClick={handleSaveServices} disabled={saving || !servicesDirty}
className="inline-flex items-center gap-1.5 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50">
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
{saving ? 'Saving...' : 'Save Services'}
</button>
</div>
{/* Catalog search */}
<div className="mb-4 rounded-xl bg-white p-4 ring-1 ring-gray-200 dark:bg-gray-800 dark:ring-gray-700">
<h3 className="mb-3 text-sm font-semibold text-gray-700 dark:text-gray-300">Service Catalog</h3>
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<input type="text" value={catalogSearch} onChange={(e) => setCatalogSearch(e.target.value)}
placeholder="Search services from catalog..."
className="w-full rounded-lg border border-gray-300 bg-white py-2 pl-9 pr-3 text-sm text-gray-900 placeholder-gray-400 focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" />
</div>
{filteredCatalog.length > 0 && (
<div className="mt-3 max-h-48 space-y-1.5 overflow-y-auto">
{filteredCatalog.map((svc) => (
<div key={svc.id} className="flex items-center gap-2 rounded-lg border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-700 dark:bg-gray-700/50">
<div className="min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">{svc.name}</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{svc.category && `${svc.category} — `}${svc.price.toFixed(2)}</p>
</div>
<button onClick={() => handleAddCatalogService(svc)}
className="inline-flex shrink-0 items-center gap-1 rounded-lg bg-green-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-green-700">
<Plus className="h-3 w-3" /> Add
</button>
</div>
))}
</div>
)}
{catalogSearch && filteredCatalog.length === 0 && (
<p className="mt-2 text-xs text-gray-400">No matching services. <button onClick={() => setShowNewServiceForm(true)} className="text-blue-600 hover:underline dark:text-blue-400">Create one</button>.</p>
)}
{!showNewServiceForm && (
<button onClick={() => setShowNewServiceForm(true)}
className="mt-3 inline-flex items-center gap-1 text-xs font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400">
<Plus className="h-3 w-3" /> Create new catalog service
</button>
)}
</div>
{/* New service form */}
{showNewServiceForm && (
<div className="mb-4 rounded-2xl border border-green-200 bg-green-50 p-5 shadow-sm dark:border-green-700 dark:bg-green-900/10">
<div className="mb-4 flex items-center justify-between gap-3">
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100">Add New Service</h2>
<button onClick={() => { setShowNewServiceForm(false); setNewServiceForm(createEmptyServiceForm()); }} className="text-gray-400 hover:text-gray-600"><X className="h-4 w-4" /></button>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<FormField label="Service Name"><Input value={newServiceForm.name} onChange={(v) => setNewServiceForm((p) => ({ ...p, name: v }))} placeholder="e.g. Oil Change" icon={<Wrench className="h-4 w-4" />} /></FormField>
<FormField label="Category"><Input value={newServiceForm.category} onChange={(v) => setNewServiceForm((p) => ({ ...p, category: v }))} placeholder="e.g. Maintenance" icon={<Layers className="h-4 w-4" />} /></FormField>
</div>
<div className="mt-3">
<FormField label="Description">
<div className="space-y-2">
<Textarea value={newServiceForm.description} onChange={(v) => setNewServiceForm((p) => ({ ...p, description: v }))} placeholder="Brief description..." rows={2} {...serviceTextSpellCheck} />
</div>
</FormField>
</div>
<div className="mt-3 rounded-xl border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-950/40">
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">Pricing</p>
<div className="mt-3 grid gap-2 sm:grid-cols-2">
<button type="button" onClick={() => setNewServiceForm((p) => ({ ...p, pricingMode: 'full' }))}
className={`rounded-lg border px-4 py-3 text-left text-sm transition-colors ${newServiceForm.pricingMode === 'full' ? 'border-green-500 bg-green-50 text-green-700 dark:border-green-500 dark:bg-green-900/20 dark:text-green-300' : 'border-gray-300 bg-white text-gray-700 hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:hover:bg-gray-800'}`}>
<span className="block font-medium">Full Price</span>
<span className="block text-xs text-gray-500 dark:text-gray-400">Single flat price</span>
</button>
<button type="button" onClick={() => setNewServiceForm((p) => ({ ...p, pricingMode: 'split' }))}
className={`rounded-lg border px-4 py-3 text-left text-sm transition-colors ${newServiceForm.pricingMode === 'split' ? 'border-green-500 bg-green-50 text-green-700 dark:border-green-500 dark:bg-green-900/20 dark:text-green-300' : 'border-gray-300 bg-white text-gray-700 hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:hover:bg-gray-800'}`}>
<span className="block font-medium">Labor + Parts</span>
<span className="block text-xs text-gray-500 dark:text-gray-400">Split pricing</span>
</button>
</div>
{newServiceForm.pricingMode === 'full' ? (
<div className="mt-3">
<FormField label="Full Price ($)"><Input type="number" value={newServiceForm.fullPrice} onChange={(v) => setNewServiceForm((p) => ({ ...p, fullPrice: Number(v) || 0 }))} icon={<Tag className="h-4 w-4" />} /></FormField>
</div>
) : (
<div className="mt-3 grid gap-3 sm:grid-cols-3">
<FormField label="Labor Price ($)"><Input type="number" value={newServiceForm.laborPrice} onChange={(v) => setNewServiceForm((p) => ({ ...p, laborPrice: Number(v) || 0 }))} icon={<DollarSign className="h-4 w-4" />} /></FormField>
<FormField label="Parts Price ($)"><Input type="number" value={newServiceForm.partsPrice} onChange={(v) => setNewServiceForm((p) => ({ ...p, partsPrice: Number(v) || 0 }))} icon={<Package className="h-4 w-4" />} /></FormField>
<FormField label="Total">
<div className="flex h-10 items-center rounded-lg border border-gray-300 bg-gray-50 px-3 text-sm font-medium text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100">
<Tag className="mr-2 h-4 w-4 text-gray-400" />${(newServiceForm.laborPrice + newServiceForm.partsPrice).toFixed(2)}
</div>
</FormField>
</div>
)}
</div>
<div className="mt-4 flex justify-end gap-2">
<button onClick={() => { setShowNewServiceForm(false); setNewServiceForm(createEmptyServiceForm()); }}
className="inline-flex items-center gap-1.5 rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-800">
<X className="h-4 w-4" /> Cancel
</button>
<button onClick={handleSaveNewService}
className="inline-flex items-center gap-1.5 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700">
<Check className="h-4 w-4" /> Create & Add
</button>
</div>
</div>
)}
{/* Current RO services */}
<div className="mb-4 rounded-xl bg-white p-4 ring-1 ring-gray-200 dark:bg-gray-800 dark:ring-gray-700">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300">RO Services</h3>
<button onClick={handleAddService}
className="inline-flex items-center gap-1 rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700">
<Plus className="h-3.5 w-3.5" /> Add Manual
</button>
</div>
<div className="space-y-3">
{localServices.map((svc, i) => (
<EditableServiceRow key={i} service={svc} index={i} onChange={handleServiceChange} onRemove={handleRemoveService} defaultLaborRate={settings.defaultLaborRate || 145} {...serviceTextSpellCheck} />
))}
{localServices.length === 0 && (
<p className="text-center text-sm text-gray-400">No services on this RO yet.</p>
)}
</div>
</div>
{/* Totals + Save */}
{localServices.filter((s) => s.name?.trim()).length > 0 && (
<div className="mb-4 rounded-xl bg-white p-4 ring-1 ring-gray-200 dark:bg-gray-800 dark:ring-gray-700">
<div className="mx-auto max-w-md overflow-hidden rounded-xl border border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-900/40">
<div className="border-b border-gray-200 px-4 py-3 dark:border-gray-700">
<h3 className="text-sm font-semibold text-gray-800 dark:text-gray-100">RO Total</h3>
<p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">Services and required charges only</p>
</div>
<div className="space-y-2 px-4 py-3 text-sm">
<div className="flex justify-between"><span className="text-gray-500 dark:text-gray-400">Labor</span><span className="font-medium text-gray-900 dark:text-gray-100">{formatCurrency(totals.laborTotal)}</span></div>
<div className="flex justify-between"><span className="text-gray-500 dark:text-gray-400">Parts</span><span className="font-medium text-gray-900 dark:text-gray-100">{formatCurrency(totals.partsTotal)}</span></div>
<div className="flex justify-between border-t border-gray-200 pt-2 dark:border-gray-700"><span className="font-medium text-gray-700 dark:text-gray-300">Services Subtotal</span><span className="font-semibold text-gray-900 dark:text-gray-100">{formatCurrency(totals.subtotal)}</span></div>
{(totals.discountAmount > 0 || totals.shopCharge > 0 || totals.tax > 0) && (
<div className="space-y-2 border-t border-gray-200 pt-2 dark:border-gray-700">
{totals.discountAmount > 0 && <div className="flex justify-between"><span className="text-gray-500 dark:text-gray-400">Discount</span><span className="font-medium text-green-600 dark:text-green-400">{formatCurrency(totals.discountAmount)}</span></div>}
{totals.shopCharge > 0 && <div className="flex justify-between"><span className="text-gray-500 dark:text-gray-400">Shop Charge</span><span className="font-medium text-gray-900 dark:text-gray-100">{formatCurrency(totals.shopCharge)}</span></div>}
{totals.tax > 0 && <div className="flex justify-between"><span className="text-gray-500 dark:text-gray-400">{settings.taxLabel || 'Tax'}</span><span className="font-medium text-gray-900 dark:text-gray-100">{formatCurrency(totals.tax)}</span></div>}
</div>
)}
</div>
<div className="flex items-center justify-between bg-blue-50 px-4 py-3 dark:bg-blue-950/30">
<span className="text-sm font-bold text-gray-900 dark:text-white">Total Due</span>
<span className="text-xl font-bold text-blue-600 dark:text-blue-400">{formatCurrency(totals.total)}</span>
</div>
</div>
</div>
)}
</div>
);
const financialContent = (
<div>
{/* Sticky save bar */}
<div className="sticky top-0 z-10 flex items-center justify-end gap-2 border-b border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-700 dark:bg-gray-950">
{finSaved && <span className="text-xs text-green-600 dark:text-green-400">Saved</span>}
<button onClick={handleSaveFinancial} disabled={finSaving}
className="inline-flex items-center gap-1.5 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50">
{finSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
{finSaving ? 'Saving...' : 'Save Financial'}
</button>
</div>
<div className="space-y-4 p-4">
{/* Helper text */}
<div className="rounded-lg border border-blue-200 bg-blue-50 p-3 text-xs text-blue-700 dark:border-blue-700 dark:bg-blue-900/20 dark:text-blue-300">
<DollarSign className="mr-1 inline h-3.5 w-3.5" />
Enter the actual billed amounts and costs. Warranty totals and costs are deducted from Customer Pay. Shop charge is deducted from Customer Pay gross profits.
</div>
{/* Customer Pay section */}
<div className="rounded-xl border border-green-200 bg-green-50 p-4 dark:border-green-700 dark:bg-green-900/10">
<h3 className="mb-3 text-sm font-bold text-green-800 dark:text-green-300">Customer Pay</h3>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div>
<label className="block text-xs font-semibold text-gray-500 uppercase">CP Total</label>
<input type="number" step="0.01" value={finCpTotal} onChange={(e) => setFinCpTotal(e.target.value)} placeholder="0.00"
className="mt-1 w-full rounded-lg border border-gray-300 bg-white p-2 text-sm focus:ring-2 focus:ring-green-500 focus:outline-none dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" />
</div>
<div>
<label className="block text-xs font-semibold text-gray-500 uppercase">CP Cost</label>
<input type="number" step="0.01" value={finCpCost} onChange={(e) => setFinCpCost(e.target.value)} placeholder="0.00"
className="mt-1 w-full rounded-lg border border-gray-300 bg-white p-2 text-sm focus:ring-2 focus:ring-green-500 focus:outline-none dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" />
</div>
<div>
<label className="block text-xs font-semibold text-gray-500 uppercase">CP Net Total</label>
<div className="mt-1 flex h-[38px] items-center rounded-lg border border-green-200 bg-green-100 px-2 text-sm font-medium text-green-800 dark:border-green-700 dark:bg-green-900/30 dark:text-green-300">
{formatCurrency(cpNetTotal)}
</div>
<p className="mt-1 text-[10px] text-gray-400">CP Total minus Warranty Total</p>
</div>
</div>
<div className="mt-2 grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="rounded-lg bg-white p-2 dark:bg-gray-800">
<div className="flex justify-between text-xs">
<span className="text-gray-500 dark:text-gray-400">CP Net Cost</span>
<span className="font-medium text-gray-900 dark:text-gray-100">{formatCurrency(cpNetCost)}</span>
</div>
<p className="mt-0.5 text-[10px] text-gray-400">CP Cost minus Warranty Cost</p>
</div>
<div className="rounded-lg bg-white p-2 dark:bg-gray-800">
<div className="flex justify-between text-xs">
<span className="text-gray-500 dark:text-gray-400">CP Gross Profit</span>
<span className={`font-bold ${cpGrossProfit >= 0 ? 'text-green-700 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}>{formatCurrency(cpGrossProfit)}</span>
</div>
<p className="mt-0.5 text-[10px] text-gray-400">Net Total minus Net Cost minus Shop Charge</p>
</div>
</div>
</div>
{/* Warranty section */}
<div className="rounded-xl border border-blue-200 bg-blue-50 p-4 dark:border-blue-700 dark:bg-blue-900/10">
<h3 className="mb-3 text-sm font-bold text-blue-800 dark:text-blue-300">Warranty</h3>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div>
<label className="block text-xs font-semibold text-gray-500 uppercase">Warranty Total</label>
<input type="number" step="0.01" value={finWarrTotal} onChange={(e) => setFinWarrTotal(e.target.value)} placeholder="0.00"
className="mt-1 w-full rounded-lg border border-gray-300 bg-white p-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" />
</div>
<div>
<label className="block text-xs font-semibold text-gray-500 uppercase">Warranty Cost</label>
<input type="number" step="0.01" value={finWarrCost} onChange={(e) => setFinWarrCost(e.target.value)} placeholder="0.00"
className="mt-1 w-full rounded-lg border border-gray-300 bg-white p-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" />
</div>
<div>
<label className="block text-xs font-semibold text-gray-500 uppercase">Warranty GP</label>
<div className="mt-1 flex h-[38px] items-center rounded-lg border border-blue-200 bg-blue-100 px-2 text-sm font-medium text-blue-800 dark:border-blue-700 dark:bg-blue-900/30 dark:text-blue-300">
{formatCurrency(warrGrossProfit)}
</div>
<p className="mt-1 text-[10px] text-gray-400">Warranty Total minus Warranty Cost</p>
</div>
</div>
</div>
{/* Shop Charge section */}
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4 dark:border-amber-700 dark:bg-amber-900/10">
<h3 className="mb-3 text-sm font-bold text-amber-800 dark:text-amber-300">Shop Charge</h3>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className="block text-xs font-semibold text-gray-500 uppercase">Shop Charge</label>
<input type="number" step="0.01" value={finShopCharge} onChange={(e) => setFinShopCharge(e.target.value)} placeholder="0.00"
className="mt-1 w-full rounded-lg border border-gray-300 bg-white p-2 text-sm focus:ring-2 focus:ring-amber-500 focus:outline-none dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" />
<p className="mt-1 text-[10px] text-gray-400">Deducted from CP Gross Profit</p>
</div>
<div className="flex items-end">
<div className="w-full rounded-lg bg-white p-2 dark:bg-gray-800">
<div className="flex justify-between text-xs">
<span className="text-gray-500 dark:text-gray-400">CP GP after Shop Charge</span>
<span className={`font-bold ${cpGrossProfit >= 0 ? 'text-green-700 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}>{formatCurrency(cpGrossProfit)}</span>
</div>
</div>
</div>
</div>
</div>
{/* Gross Profit summary */}
<div className="rounded-xl bg-gray-100 p-4 dark:bg-gray-700/50">
<div className="flex items-center justify-between">
<div className="text-sm font-medium text-gray-600 dark:text-gray-300">Overall Gross Profit</div>
<div className={`text-2xl font-bold ${overallGrossProfit >= 0 ? 'text-gray-900 dark:text-white' : 'text-red-600 dark:text-red-400'}`}>{formatCurrency(overallGrossProfit)}</div>
</div>
<div className="mt-2 flex gap-4 text-xs text-gray-500 dark:text-gray-400">
<span>CP GP: <strong className={cpGrossProfit >= 0 ? 'text-green-700 dark:text-green-400' : 'text-red-600 dark:text-red-400'}>{formatCurrency(cpGrossProfit)}</strong></span>
<span>Warranty GP: <strong className={warrGrossProfit >= 0 ? 'text-blue-700 dark:text-blue-400' : 'text-red-600 dark:text-red-400'}>{formatCurrency(warrGrossProfit)}</strong></span>
</div>
</div>
</div>
</div>
);
const historyContent = (
<div>
{!ro.vin || !ro.vin.trim() ? (
<div className="rounded-xl bg-white p-8 text-center ring-1 ring-gray-200 dark:bg-gray-800 dark:ring-gray-700">
<History className="mx-auto h-10 w-10 text-gray-300 dark:text-gray-600" />
<p className="mt-3 text-sm text-gray-500 dark:text-gray-400">No VIN on this repair order vehicle history unavailable.</p>
</div>
) : historyLoading ? (
<div className="flex items-center justify-center py-12"><LoadingSpinner text="Loading history..." /></div>
) : historyData.length === 0 ? (
<div className="rounded-xl bg-white p-8 text-center ring-1 ring-gray-200 dark:bg-gray-800 dark:ring-gray-700">
<History className="mx-auto h-10 w-10 text-gray-300 dark:text-gray-600" />
<p className="mt-3 text-sm text-gray-500 dark:text-gray-400">No previous records found for VIN {ro.vin}.</p>
</div>
) : (
<div className="space-y-3">
{historyData.map((item) => (
<div key={`${item.type}-${item.id}`} className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-700 dark:bg-gray-900">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${item.type === 'ro' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300' : 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300'}`}>{item.type === 'ro' ? 'RO' : 'Quote'}</span>
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">{item.title}</span>
<StatusBadge status={item.status} config={statusConfig} />
</div>
{item.vehicleInfo && <p className="mt-1 text-xs text-gray-500 dark:text-gray-400">{item.vehicleInfo}</p>}
<p className="mt-1 text-xs text-gray-400">{formatDateTime(item.date)}</p>
</div>
<div className="text-right">
<p className="text-sm font-semibold text-green-600 dark:text-green-400">{formatCurrency(item.total)}</p>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
function formatFieldName(field: string): string {
return field
.replace(/([A-Z])/g, ' $1')
.replace(/^./, (s) => s.toUpperCase())
.trim();
}
const timelineContent = (
<div className="rounded-xl bg-white p-4 ring-1 ring-gray-200 dark:bg-gray-800 dark:ring-gray-700">
<h3 className="mb-2 text-sm font-semibold text-gray-700 dark:text-gray-300">Audit Timeline</h3>
{events.length === 0 ? (
<p className="text-sm text-gray-400">No recorded events.</p>
) : (
<ol className="divide-y divide-gray-100 dark:divide-gray-700">
{events.map((ev) => <TimelineEntry key={ev.id} ev={ev} />)}
</ol>
)}
{financialAuditLoading ? (
<div className="mt-6 flex items-center justify-center py-4">
<Loader2 className="h-4 w-4 animate-spin text-gray-400" />
</div>
) : financialAuditEntries.length > 0 && (
<div className="mt-6">
<h3 className="mb-3 text-sm font-semibold text-gray-700 dark:text-gray-300">Financial Audit Trail</h3>
<div className="space-y-2">
{financialAuditEntries.map((entry) => (
<div
key={entry.id}
className="rounded-lg border border-gray-200 bg-gray-50 p-3 text-sm dark:border-gray-700 dark:bg-gray-800/50"
>
<div className="flex flex-wrap items-center gap-1.5">
<span className="rounded bg-blue-100 px-1.5 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">
{formatFieldName(entry.field)}
</span>
<span className="text-xs text-gray-400">
{formatDateTime(entry.at)}
</span>
</div>
<div className="mt-1.5 flex flex-col gap-1">
<div className="flex flex-wrap items-baseline gap-2">
<span className="font-mono text-[11px] text-gray-500 bg-gray-100 rounded px-1.5 py-0.5 dark:bg-gray-700 dark:text-gray-400">
{JSON.stringify(entry.from)}
</span>
<span className="text-gray-400"></span>
<span className="font-mono text-[11px] text-gray-700 bg-gray-100 rounded px-1.5 py-0.5 dark:bg-gray-700 dark:text-gray-300">
{JSON.stringify(entry.to)}
</span>
</div>
<div className="flex flex-wrap items-center gap-3 text-xs text-gray-400">
{entry.by && <span>by {entry.by}</span>}
<span
className="font-mono text-[10px] text-gray-400 cursor-help"
title={entry.hash}
>
#{entry.hash?.slice(0, 8)}...
</span>
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
);
const activeTabContent = (() => {
switch (activeTab) {
case 'details': return detailsContent;
case 'services': return servicesContent;
case 'financial': return financialContent;
case 'history': return historyContent;
case 'timeline': return timelineContent;
}
})();
const content = (
<div className="flex h-full flex-col">
{/* Tab bar */}
<div className="flex items-center gap-1 border-b border-gray-200 bg-gray-50 p-2 dark:border-gray-700 dark:bg-gray-900">
<button onClick={closeModal}
className="mr-2 rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-800 dark:hover:text-gray-300">
<X className="h-4 w-4" />
</button>
<div className="flex gap-1">
{TABS.map((tab) => (
<button key={tab.id} onClick={() => setActiveTab(tab.id)}
className={`inline-flex items-center gap-1.5 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
activeTab === tab.id
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: 'text-gray-500 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200'
}`}>
<tab.icon className="h-4 w-4" />
{tab.label}
</button>
))}
</div>
{/* Status badge for reference */}
<div className="ml-auto flex items-center gap-2">
<span className="text-xs text-gray-400">{ro.roNumber || `#${ro.id.slice(0, 8)}`}</span>
<StatusBadge status={voided ? 'voided' : (ro.status || '')} config={statusConfig} />
</div>
</div>
{/* Tab content */}
<div className="flex-1 overflow-y-auto p-4">
{activeTabContent}
</div>
</div>
);
if (!isModal) {
return (
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Repair Order Details</h1>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
<button onClick={closeModal} className="inline-flex items-center gap-1 text-blue-600 hover:underline"><ArrowLeft className="h-4 w-4" /> Back to Repair Orders</button>
</p>
</div>
{content}
</div>
);
}
return (
<div className="fixed inset-0 z-50">
<button type="button" aria-label="Close" className="absolute inset-0 bg-gray-950/45 backdrop-blur-sm" onClick={closeModal} />
<div className="absolute inset-0 flex items-center justify-center p-3 sm:p-6">
<div className="relative flex h-[min(92vh,56rem)] w-full max-w-7xl overflow-hidden rounded-3xl border border-gray-200 bg-gray-50 shadow-2xl dark:border-gray-700 dark:bg-gray-950">
<div className="flex-1 overflow-hidden">
{content}
</div>
</div>
</div>
<AddTimeModal
open={showAddTimeModal}
initialMinutes={15}
onCancel={() => setShowAddTimeModal(false)}
onConfirm={(totalMinutes) => { setShowAddTimeModal(false); if (totalMinutes > 0) handleAddTime(totalMinutes); }}
/>
</div>
);
}