initial commit
This commit is contained in:
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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())} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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 */ }
|
||||
}
|
||||
Reference in New Issue
Block a user