1730 lines
94 KiB
TypeScript
1730 lines
94 KiB
TypeScript
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>
|
||
);
|
||
}
|