initial commit

This commit is contained in:
ray
2026-07-12 10:01:39 -04:00
commit fc8290d668
185 changed files with 38831 additions and 0 deletions
@@ -0,0 +1,103 @@
import { memo } from 'react';
import { CheckSquare, Square, ChevronRight } from 'lucide-react';
import { getStatusConfig } from '../../lib/status';
import { useSettings } from '../../store/settings';
import { formatDateTime, formatCurrency } from '../../lib/format';
import { isVoided } from '../../lib/roEvents';
import {
getCompletedMetricDate,
displayCustomerName,
ratingLabel,
} from '../../lib/repairOrders';
import type { RepairOrder, CustomerType } from '../../types';
import { CustomerTypeBadge } from './CustomerTypeBadge';
import { StatusBadge } from './StatusBadge';
import { TechNames } from './TechNames';
export const CompletedRow = memo(function CompletedRow({
ro,
selected,
onToggleSelect,
onOpen,
onToggleCustomerType,
techNames,
}: {
ro: RepairOrder;
selected: boolean;
onToggleSelect: () => void;
onOpen: () => void;
onToggleCustomerType: (id: string, currentType?: CustomerType) => void;
techNames: string[];
}) {
const voided = isVoided(ro);
const completedDate = getCompletedMetricDate(ro);
const rowClass = `
cursor-pointer transition-colors hover:bg-gray-50 dark:hover:bg-gray-700/50
${voided ? 'opacity-50 hover:opacity-70' : ''}
${selected ? 'ring-2 ring-inset ring-blue-400' : ''}
`;
return (
<tr
onClick={(e) => {
const target = e.target as HTMLElement;
if (target.closest('[data-no-open]') || target.closest('button')) return;
onOpen();
}}
className={rowClass}
>
<td className="px-2 py-2.5" data-no-open>
<button
onClick={(e) => { e.stopPropagation(); onToggleSelect(); }}
className="text-gray-400 hover:text-blue-600 dark:hover:text-blue-400"
title={selected ? 'Deselect' : 'Select for bulk action'}
>
{selected
? <CheckSquare className="h-4 w-4 text-blue-600 dark:text-blue-400" />
: <Square className="h-4 w-4" />}
</button>
</td>
<td className="px-3 py-2.5 font-mono text-xs text-gray-500 dark:text-gray-400">
{ro.roNumber || `#${ro.id.slice(0, 8)}`}
</td>
<td className="px-3 py-2.5">
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900 dark:text-white">{displayCustomerName(ro.customerName)}</span>
<CustomerTypeBadge
customerType={ro.customerType}
onClick={() => onToggleCustomerType(ro.id, ro.customerType)}
/>
</div>
</td>
<td className="max-w-[220px] px-3 py-2.5 text-gray-600 dark:text-gray-400">
<div className="space-y-0.5">
<p className="truncate">{ro.vehicleInfo || '—'}</p>
{ro.serviceLocation && (
<p className="truncate text-xs text-sky-600 dark:text-sky-400">{ro.serviceLocation}</p>
)}
{typeof ro.tripMiles === 'number' && ro.tripMiles > 0 && (
<p className="text-xs text-purple-600 dark:text-purple-400">{ro.tripMiles} mi trip</p>
)}
</div>
</td>
<td className="whitespace-nowrap px-3 py-2.5 text-sm text-gray-600 dark:text-gray-400">
{completedDate ? formatDateTime(completedDate) : '—'}
</td>
<td className="px-3 py-2.5">
<StatusBadge status={voided ? 'voided' : (ro.status || '')} config={getStatusConfig(useSettings())} />
</td>
<td className="whitespace-nowrap px-3 py-2.5 text-sm font-medium text-gray-900 dark:text-white">
{formatCurrency(ro.total || 0)}
</td>
<td className="whitespace-nowrap px-3 py-2.5 text-sm text-gray-600 dark:text-gray-400">
{ratingLabel(ro.satisfaction)}
</td>
<td className="whitespace-nowrap px-3 py-2.5 text-center">
<TechNames names={techNames} />
</td>
<td className="px-3 py-2.5 text-right">
<ChevronRight className="h-4 w-4 text-gray-400" />
</td>
</tr>
);
});
@@ -0,0 +1,36 @@
import { UserCheck, UserX } from 'lucide-react';
import { getCustomerTypeLabels } from '../../lib/settings';
import { useSettings } from '../../store/settings';
import type { CustomerType } from '../../types';
export function CustomerTypeBadge({
customerType,
onClick,
}: {
customerType?: CustomerType;
onClick: () => void;
}) {
const isWaiter = customerType === 'waiter';
const customerTypeLabels = getCustomerTypeLabels(useSettings());
return (
<button
onClick={(e) => {
e.stopPropagation();
onClick();
}}
className={`inline-flex cursor-pointer items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium transition-colors ${
isWaiter
? 'bg-purple-100 text-purple-800 hover:bg-purple-200 dark:bg-purple-900/40 dark:text-purple-300 dark:hover:bg-purple-900/60'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600'
}`}
title={`Click to toggle to ${isWaiter ? customerTypeLabels.primary : customerTypeLabels.secondary}`}
>
{isWaiter ? (
<UserCheck className="h-3 w-3" />
) : (
<UserX className="h-3 w-3" />
)}
{isWaiter ? customerTypeLabels.secondary : customerTypeLabels.primary}
</button>
);
}
@@ -0,0 +1,49 @@
import { Wrench, Plus } from 'lucide-react';
import type { TabId } from './types';
export function EmptyState({
tab,
onCreate,
}: {
tab: TabId;
onCreate: () => void;
}) {
const messages: Record<TabId, { title: string; desc: string }> = {
active: {
title: 'No active repair orders',
desc: 'There are no open repair orders in this view. Create a new order when a vehicle is checked in for service.',
},
all: {
title: 'No repair orders on file',
desc: 'Create your first repair order to track work performed, labor, parts, and customer authorization status.',
},
completed: {
title: 'No completed repair orders',
desc: 'Closed repair orders will appear here once work has been completed and finalized.',
},
};
const msg = messages[tab];
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<div className="mb-6 flex h-24 w-24 items-center justify-center rounded-full bg-blue-50 dark:bg-blue-900/20">
<Wrench className="h-12 w-12 text-blue-500 dark:text-blue-400" />
</div>
<h3 className="mb-2 text-lg font-semibold text-gray-900 dark:text-white">
{msg.title}
</h3>
<p className="mb-8 max-w-sm text-sm text-gray-500 dark:text-gray-400">
{msg.desc}
</p>
{tab !== 'completed' && (
<button
onClick={onCreate}
className="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-colors hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
>
<Plus className="h-4 w-4" />
Create Repair Order
</button>
)}
</div>
);
}
@@ -0,0 +1,11 @@
import { ListError } from '../ui/ListError';
export function ErrorState({
message,
onRetry,
}: {
message: string;
onRetry: () => void;
}) {
return <ListError title="Unable to load repair orders" message={message} onRetry={onRetry} />;
}
+118
View File
@@ -0,0 +1,118 @@
import { memo } from 'react';
import { CheckSquare, Square, ChevronRight } from 'lucide-react';
import { getStatusConfig } from '../../lib/status';
import { useSettings } from '../../store/settings';
import { formatDateTime } from '../../lib/format';
import { isVoided } from '../../lib/roEvents';
import {
getUrgencyClass,
displayCustomerName,
formatDueDateTime,
} from '../../lib/repairOrders';
import type { RepairOrder, CustomerType } from '../../types';
import { CustomerTypeBadge } from './CustomerTypeBadge';
import { StatusBadge } from './StatusBadge';
import { TimeRemainingCell } from './TimeRemainingCell';
import { RowProgressBar } from './RowProgressBar';
import { TechNames } from './TechNames';
export const FragmentRow = memo(function FragmentRow({
ro,
now,
selected,
onToggleSelect,
onOpen,
onToggleCustomerType,
techNames,
}: {
ro: RepairOrder;
now: number;
selected: boolean;
onToggleSelect: () => void;
onOpen: () => void;
onToggleCustomerType: (id: string, currentType?: CustomerType) => void;
techNames: string[];
}) {
const urgency = getUrgencyClass(ro, now);
const voided = isVoided(ro);
const rowClass = `
cursor-pointer transition-colors
${voided
? 'opacity-50 hover:opacity-70'
: urgency === 'urgent'
? 'bg-red-50 hover:bg-red-100 dark:bg-red-900/15 dark:hover:bg-red-900/25'
: 'hover:bg-gray-50 dark:hover:bg-gray-700/50'
}
${selected ? 'ring-2 ring-inset ring-blue-400' : ''}
`;
return (
<tr
onClick={(e) => {
const target = e.target as HTMLElement;
if (target.closest('[data-no-open]') || target.closest('button')) return;
onOpen();
}}
className={rowClass}
>
<td className="px-2 py-2.5" data-no-open>
<button
onClick={(e) => { e.stopPropagation(); onToggleSelect(); }}
className="text-gray-400 hover:text-blue-600 dark:hover:text-blue-400"
title={selected ? 'Deselect' : 'Select for bulk action'}
>
{selected
? <CheckSquare className="h-4 w-4 text-blue-600 dark:text-blue-400" />
: <Square className="h-4 w-4" />}
</button>
</td>
<td className="px-3 py-2.5 font-mono text-xs text-gray-500 dark:text-gray-400">
{ro.roNumber || `#${ro.id.slice(0, 8)}`}
</td>
<td className="px-3 py-2.5">
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900 dark:text-white">
{displayCustomerName(ro.customerName)}
</span>
<CustomerTypeBadge
customerType={ro.customerType}
onClick={() => onToggleCustomerType(ro.id, ro.customerType)}
/>
</div>
</td>
<td className="max-w-[200px] px-3 py-2.5 text-gray-600 dark:text-gray-400">
<div className="space-y-0.5">
<p className="truncate">{ro.vehicleInfo || '—'}</p>
{ro.serviceLocation && (
<p className="truncate text-xs text-sky-600 dark:text-sky-400">{ro.serviceLocation}</p>
)}
{typeof ro.tripMiles === 'number' && ro.tripMiles > 0 && (
<p className="text-xs text-purple-600 dark:text-purple-400">{ro.tripMiles} mi trip</p>
)}
</div>
</td>
<td className="px-3 py-2.5">
<StatusBadge status={voided ? 'voided' : (ro.status || '')} config={getStatusConfig(useSettings())} />
</td>
<td className="whitespace-nowrap px-3 py-2.5 text-sm text-gray-600 dark:text-gray-400">
{formatDateTime(ro.writeupTime || ro.created)}
</td>
<td className="whitespace-nowrap px-3 py-2.5 text-sm text-gray-600 dark:text-gray-400">
{formatDueDateTime(ro)}
</td>
<td className="whitespace-nowrap px-3 py-2.5">
<TimeRemainingCell ro={ro} now={now} />
</td>
<td className="whitespace-nowrap px-3 py-2.5">
<RowProgressBar ro={ro} />
</td>
<td className="whitespace-nowrap px-3 py-2.5 text-center">
<TechNames names={techNames} />
</td>
<td className="px-3 py-2.5 text-right">
<ChevronRight className="h-4 w-4 text-gray-400" />
</td>
</tr>
);
});
@@ -0,0 +1,29 @@
export function LoadingSkeleton() {
return (
<div className="animate-pulse space-y-4">
<div className="flex gap-2">
{Array.from({ length: 3 }).map((_, i) => (
<div
key={i}
className="h-9 w-24 rounded-lg bg-gray-200 dark:bg-gray-700"
/>
))}
</div>
<div className="rounded-xl bg-white ring-1 ring-gray-200 dark:bg-gray-800 dark:ring-gray-700">
<div className="space-y-3 p-6">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex gap-4">
<div className="h-4 w-16 rounded bg-gray-200 dark:bg-gray-700" />
<div className="h-4 flex-1 rounded bg-gray-200 dark:bg-gray-700" />
<div className="h-4 w-28 rounded bg-gray-200 dark:bg-gray-700" />
<div className="h-4 w-20 rounded bg-gray-200 dark:bg-gray-700" />
<div className="h-4 w-24 rounded bg-gray-200 dark:bg-gray-700" />
<div className="h-4 w-16 rounded bg-gray-200 dark:bg-gray-700" />
<div className="h-4 w-24 rounded bg-gray-200 dark:bg-gray-700" />
</div>
))}
</div>
</div>
</div>
);
}
+64
View File
@@ -0,0 +1,64 @@
import { X } from 'lucide-react';
import ROForm from '../ROForm';
import type { RepairOrder } from '../../types';
import type { CustomerWithVehicles } from '../../pages/Customers';
export function ROModal({
isOpen,
onClose,
onSave,
editRO,
existingRoNumbers,
settings,
customers,
}: {
isOpen: boolean;
onClose: () => void;
onSave: (data: Partial<RepairOrder>) => void;
editRO: RepairOrder | null;
existingRoNumbers: Set<string>;
settings: { taxRate: number; shopChargeRate: number; shopChargeCap: number; defaultLaborRate: number };
customers?: CustomerWithVehicles[];
}) {
if (!isOpen) return null;
const isEditing = !!editRO;
return (
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 p-4 pt-10">
<div className="w-full max-w-3xl rounded-2xl bg-white shadow-2xl dark:bg-gray-900">
{/* Header */}
<div className="flex items-center justify-between border-b border-gray-200 px-6 py-4 dark:border-gray-700">
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
{isEditing ? 'Edit Repair Order' : 'Create Repair Order'}
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">
{isEditing
? `Editing RO #${editRO?.roNumber || ''}`
: 'Enter customer and vehicle information'}
</p>
</div>
<button
onClick={onClose}
className="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-5 w-5" />
</button>
</div>
{/* Body */}
<div className="px-6 py-5">
<ROForm
editRO={editRO}
existingRoNumbers={existingRoNumbers}
settings={settings}
customers={customers}
onSave={(data) => { onSave(data); onClose(); }}
onCancel={onClose}
/>
</div>
</div>
</div>
);
}
@@ -0,0 +1,19 @@
import { roProgress } from '../../lib/status';
import type { RepairOrder } from '../../types';
export function RowProgressBar({ ro }: { ro: RepairOrder }) {
const { done, total, fraction } = roProgress(ro);
if (total === 0) {
return <span className="text-xs text-gray-300 dark:text-gray-600"></span>;
}
const pct = Math.round(fraction * 100);
const barColor = pct === 100 ? 'bg-green-500' : pct >= 50 ? 'bg-blue-500' : 'bg-gray-300 dark:bg-gray-600';
return (
<div className="flex items-center gap-2" title={`${done}/${total} services done`}>
<div className="h-1.5 w-16 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div className={`h-full transition-all ${barColor}`} style={{ width: `${pct}%` }} />
</div>
<span className="text-xs tabular-nums text-gray-500 dark:text-gray-400">{done}/{total}</span>
</div>
);
}
@@ -0,0 +1,28 @@
import { getStatusConfig } from '../../lib/status';
import { useSettings } from '../../store/settings';
export function StatusBadge({
status,
config,
}: {
status: string;
config: Record<string, { label: string; colors: string }>;
}) {
const cfg = config[status] || {
label: status,
colors:
'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400',
};
return (
<span
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${cfg.colors}`}
>
{cfg.label}
</span>
);
}
// Alternate version that derives config from settings (convenience)
export function StatusBadgeAuto({ status }: { status: string }) {
return <StatusBadge status={status} config={getStatusConfig(useSettings())} />;
}
+39
View File
@@ -0,0 +1,39 @@
import { TABS } from './types';
import type { TabId } from './types';
export function TabButton({
tab,
active,
count,
onClick,
}: {
tab: TabId;
active: boolean;
count?: number;
onClick: () => void;
}) {
const label = TABS.find((t) => t.id === tab)?.label || tab;
return (
<button
onClick={onClick}
className={`relative inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-all ${
active
? 'bg-blue-600 text-white shadow-sm'
: 'bg-white text-gray-600 ring-1 ring-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-400 dark:ring-gray-700 dark:hover:bg-gray-700'
}`}
>
{label}
{count !== undefined && (
<span
className={`inline-flex items-center justify-center rounded-full px-2 py-0.5 text-xs ${
active
? 'bg-white/20 text-white'
: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'
}`}
>
{count}
</span>
)}
</button>
);
}
+20
View File
@@ -0,0 +1,20 @@
export function TechNames({ names }: { names: string[] }) {
if (names.length === 0) return <span className="text-xs text-gray-300 dark:text-gray-600"></span>;
const label = names.join(', ');
const visible = names.slice(0, 2);
const extra = names.length - visible.length;
return (
<div className="flex max-w-[10rem] flex-wrap items-center justify-center gap-1" title={label}>
{visible.map((name, index) => (
<span key={`${name}-${index}`} className="truncate rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700 dark:bg-green-900/30 dark:text-green-400">
{name}
</span>
))}
{extra > 0 && (
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600 dark:bg-gray-700 dark:text-gray-300">
+{extra}
</span>
)}
</div>
);
}
@@ -0,0 +1,30 @@
import { AlertTriangle } from 'lucide-react';
import { formatTimeRemaining, getUrgencyClass } from '../../lib/repairOrders';
import type { RepairOrder } from '../../types';
export function TimeRemainingCell({
ro,
now,
}: {
ro: RepairOrder;
now: number;
}) {
const text = formatTimeRemaining(ro, now);
const urgency = getUrgencyClass(ro, now);
const colorClass =
urgency === 'urgent'
? 'text-red-600 dark:text-red-400 font-semibold'
: urgency === 'warning'
? 'text-amber-600 dark:text-amber-400 font-medium'
: 'text-gray-600 dark:text-gray-400';
return (
<span className={`inline-flex items-center gap-1 text-sm ${colorClass}`}>
{urgency === 'urgent' && (
<AlertTriangle className="h-3.5 w-3.5 text-red-500" />
)}
{text}
</span>
);
}
+21
View File
@@ -0,0 +1,21 @@
export type {
TabId,
CompletedRangeFilter,
CompletedStatusFilter,
CompletedRatingFilter,
SavedView,
} from './types';
export { TABS, SAVED_VIEWS_KEY, loadSavedViews, persistSavedViews } from './types';
export { StatusBadge } from './StatusBadge';
export { RowProgressBar } from './RowProgressBar';
export { TechNames } from './TechNames';
export { CustomerTypeBadge } from './CustomerTypeBadge';
export { TimeRemainingCell } from './TimeRemainingCell';
export { LoadingSkeleton } from './LoadingSkeleton';
export { EmptyState } from './EmptyState';
export { ErrorState } from './ErrorState';
export { TabButton } from './TabButton';
export { ROModal } from './ROModal';
export { FragmentRow } from './FragmentRow';
export { CompletedRow } from './CompletedRow';
+37
View File
@@ -0,0 +1,37 @@
/* ── Types & Constants ──────────────────────────────── */
export type TabId = 'active' | 'all' | 'completed';
export type CompletedRangeFilter = 'all' | 'today' | 'this_week' | 'this_month';
export type CompletedStatusFilter = 'all' | 'completed' | 'final_close';
export type CompletedRatingFilter = 'all' | 'rated' | 'unrated';
export const TABS: { id: TabId; label: string }[] = [
{ id: 'active', label: 'Active' },
{ id: 'all', label: 'All' },
{ id: 'completed', label: 'Completed' },
];
/* ── Saved-view preset shape ─────────────────────────── */
export interface SavedView {
id: string;
label: string;
statusChips: string[];
waiterOnly: boolean;
showVoided: boolean;
search: string;
}
export const SAVED_VIEWS_KEY = 'spq-ro-saved-views';
export function loadSavedViews(): SavedView[] {
try {
const raw = localStorage.getItem(SAVED_VIEWS_KEY);
if (raw) return JSON.parse(raw) as SavedView[];
} catch { /* corrupt JSON — ignore */ }
return [];
}
export function persistSavedViews(views: SavedView[]) {
try { localStorage.setItem(SAVED_VIEWS_KEY, JSON.stringify(views)); } catch { /* ignore */ }
}