diff --git a/packages/admin/src/components/pos/pos-register.tsx b/packages/admin/src/components/pos/pos-register.tsx index 8237549..134f48a 100644 --- a/packages/admin/src/components/pos/pos-register.tsx +++ b/packages/admin/src/components/pos/pos-register.tsx @@ -40,20 +40,25 @@ function configOptions(key: string) { }) } -export function POSRegister() { +interface POSRegisterProps { + embedded?: boolean +} + +export function POSRegister({ embedded }: POSRegisterProps = {}) { const { locationId, setLocation, currentTransactionId, setDrawerSession, locked, lock, touchActivity, token } = usePOSStore() - // Fetch lock timeout from config + // Fetch lock timeout from config (standalone only) const { data: lockTimeoutStr } = useQuery({ ...configOptions('pos_lock_timeout'), - enabled: !!token, + enabled: !!token && !embedded, }) const lockTimeoutMinutes = parseInt(lockTimeoutStr ?? '15') || 15 - // Auto-lock timer + // Auto-lock timer (standalone only — station shell handles this when embedded) const timerRef = useRef | null>(null) useEffect(() => { + if (embedded) return if (locked || lockTimeoutMinutes === 0) { if (timerRef.current) clearInterval(timerRef.current) return @@ -69,26 +74,27 @@ export function POSRegister() { return () => { if (timerRef.current) clearInterval(timerRef.current) } - }, [locked, lockTimeoutMinutes, lock]) + }, [embedded, locked, lockTimeoutMinutes, lock]) - // Track activity on any interaction + // Track activity (standalone only) const handleActivity = useCallback(() => { - if (!locked) touchActivity() - }, [locked, touchActivity]) + if (!embedded && !locked) touchActivity() + }, [embedded, locked, touchActivity]) - // Fetch locations + // Fetch locations (standalone only — station shell handles this when embedded) const { data: locationsData } = useQuery({ ...locationsOptions(), - enabled: !!token, + enabled: !!token && !embedded, }) const locations = locationsData?.data ?? [] - // Auto-select first location + // Auto-select first location (standalone only) useEffect(() => { + if (embedded) return if (!locationId && locations.length > 0) { setLocation(locations[0].id) } - }, [locationId, locations, setLocation]) + }, [embedded, locationId, locations, setLocation]) // Fetch current drawer for selected location const { data: drawer } = useQuery({ @@ -112,6 +118,20 @@ export function POSRegister() { enabled: !!currentTransactionId && !!token, }) + // Embedded mode: just the content panels, no wrapper/lock/topbar + if (embedded) { + return ( +
+
+ +
+
+ +
+
+ ) + } + return (
+
+ +

Lessons Station

+

Coming soon

+
+
+ ) +} diff --git a/packages/admin/src/components/station-repairs/repair-desk-view.tsx b/packages/admin/src/components/station-repairs/repair-desk-view.tsx new file mode 100644 index 0000000..6e95f09 --- /dev/null +++ b/packages/admin/src/components/station-repairs/repair-desk-view.tsx @@ -0,0 +1,66 @@ +import { useState } from 'react' +import { RepairStatusBar } from './repair-status-bar' +import { RepairQueuePanel } from './repair-queue-panel' +import { RepairDetailPanel } from './repair-detail-panel' +import { RepairIntakeForm } from './repair-intake-form' + +interface RepairDeskViewProps { + canEdit: boolean +} + +export function RepairDeskView({ canEdit }: RepairDeskViewProps) { + const [selectedTicketId, setSelectedTicketId] = useState(null) + const [intakeMode, setIntakeMode] = useState(false) + const [statusFilter, setStatusFilter] = useState(null) + const [activeFilterLabel, setActiveFilterLabel] = useState(null) + + function handleFilterChange(statuses: string[] | null) { + setStatusFilter(statuses) + // Track which group label is active for the status bar highlight + if (!statuses) { + setActiveFilterLabel(null) + } else { + // Map statuses back to group label + const groups: Record = { + new: 'New', in_transit: 'New', intake: 'New', + diagnosing: 'Diagnosing', pending_approval: 'Diagnosing', + approved: 'In Progress', in_progress: 'In Progress', pending_parts: 'In Progress', + ready: 'Ready', + } + setActiveFilterLabel(groups[statuses[0]] ?? null) + } + } + + if (intakeMode) { + return ( +
+ { + setIntakeMode(false) + setSelectedTicketId(ticketId) + }} + onCancel={() => setIntakeMode(false)} + /> +
+ ) + } + + return ( +
+ +
+
+ setIntakeMode(true)} + statusFilter={statusFilter} + /> +
+
+ +
+
+
+ ) +} diff --git a/packages/admin/src/components/station-repairs/repair-detail-panel.tsx b/packages/admin/src/components/station-repairs/repair-detail-panel.tsx new file mode 100644 index 0000000..c1ef926 --- /dev/null +++ b/packages/admin/src/components/station-repairs/repair-detail-panel.tsx @@ -0,0 +1,216 @@ +import { useState } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { + repairTicketDetailOptions, repairTicketKeys, repairTicketMutations, + repairLineItemListOptions, +} from '@/api/repairs' +import { api } from '@/lib/api-client' +import { StatusProgress } from '@/components/repairs/status-progress' +import { TicketNotes } from '@/components/repairs/ticket-notes' +import { TicketPhotos } from '@/components/repairs/ticket-photos' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { Separator } from '@/components/ui/separator' +import { Mail, ChevronRight, Wrench } from 'lucide-react' +import { toast } from 'sonner' + +const STATUS_FLOW = ['new', 'intake', 'diagnosing', 'pending_approval', 'approved', 'in_progress', 'ready', 'picked_up'] + +interface RepairDetailPanelProps { + ticketId: string | null + canEdit: boolean +} + +export function RepairDetailPanel({ ticketId, canEdit }: RepairDetailPanelProps) { + const queryClient = useQueryClient() + const [activeSection, setActiveSection] = useState<'details' | 'notes' | 'photos'>('details') + + const { data: ticket, isLoading } = useQuery({ + ...repairTicketDetailOptions(ticketId ?? ''), + enabled: !!ticketId, + }) + + const { data: lineItemsData } = useQuery({ + ...repairLineItemListOptions(ticketId ?? '', { page: 1, limit: 100, q: undefined, sort: undefined, order: 'asc' }), + enabled: !!ticketId, + }) + + const statusMutation = useMutation({ + mutationFn: (status: string) => repairTicketMutations.updateStatus(ticketId!, status), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: repairTicketKeys.detail(ticketId!) }) + queryClient.invalidateQueries({ queryKey: ['repair-tickets', 'station-all'] }) + toast.success('Status updated') + }, + onError: (err) => toast.error(err.message), + }) + + const emailEstimateMutation = useMutation({ + mutationFn: (email: string) => api.post(`/v1/repair-tickets/${ticketId}/email-estimate`, { email }), + onSuccess: () => toast.success('Estimate emailed'), + onError: (err) => toast.error(err.message), + }) + + if (!ticketId) { + return ( +
+
+ +

Select a ticket from the queue

+
+
+ ) + } + + if (isLoading || !ticket) { + return ( +
+
+
+
+
+ ) + } + + const lineItems = lineItemsData?.data ?? [] + const currentIdx = STATUS_FLOW.indexOf(ticket.status) + const nextStatus = currentIdx >= 0 && currentIdx < STATUS_FLOW.length - 1 ? STATUS_FLOW[currentIdx + 1] : null + const isTerminal = ['picked_up', 'delivered', 'cancelled'].includes(ticket.status) + + function handleStatusClick(status: string) { + if (canEdit && !isTerminal) statusMutation.mutate(status) + } + + function handleEmailEstimate() { + const email = (ticket as any).customerEmail + if (email) { + emailEstimateMutation.mutate(email) + } else { + toast.error('No customer email on file') + } + } + + return ( +
+ {/* Header */} +
+
+
+

#{ticket.ticketNumber}

+

{ticket.customerName}

+
+
+ + {canEdit && nextStatus && !isTerminal && ( + + )} +
+
+ + +
+ + {/* Section toggle */} +
+ {(['details', 'notes', 'photos'] as const).map((s) => ( + + ))} +
+ + {/* Content */} +
+ {activeSection === 'details' && ( +
+ {/* Item info */} +
+

Item: {ticket.itemDescription ?? 'N/A'}

+ {ticket.serialNumber &&

S/N: {ticket.serialNumber}

} + {ticket.conditionIn &&

Condition: {ticket.conditionIn}

} + {ticket.conditionInNotes &&

{ticket.conditionInNotes}

} +
+ + + + {/* Problem */} +
+

Problem

+

{ticket.problemDescription}

+
+ + {/* Dates */} +
+ {ticket.promisedDate && ( +
+

Promised

+

{new Date(ticket.promisedDate).toLocaleDateString()}

+
+ )} + {ticket.estimatedCost && ( +
+

Estimate

+

${ticket.estimatedCost}

+
+ )} + {ticket.actualCost && ( +
+

Actual

+

${ticket.actualCost}

+
+ )} +
+ + {/* Line items */} + {lineItems.length > 0 && ( + <> + +
+

Line Items

+
+ {lineItems.map((item) => ( +
+
+ {item.itemType} + {item.description} +
+ ${item.totalPrice} +
+ ))} + +
+ Total + ${lineItems.reduce((sum, i) => sum + parseFloat(i.totalPrice), 0).toFixed(2)} +
+
+
+ + )} +
+ )} + + {activeSection === 'notes' && ticketId && ( + + )} + + {activeSection === 'photos' && ticketId && ( + + )} +
+
+ ) +} diff --git a/packages/admin/src/components/station-repairs/repair-intake-form.tsx b/packages/admin/src/components/station-repairs/repair-intake-form.tsx new file mode 100644 index 0000000..bb83938 --- /dev/null +++ b/packages/admin/src/components/station-repairs/repair-intake-form.tsx @@ -0,0 +1,392 @@ +import { useState } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { api } from '@/lib/api-client' +import { repairTicketMutations, repairLineItemMutations, repairServiceTemplateListOptions } from '@/api/repairs' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Textarea } from '@/components/ui/textarea' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Separator } from '@/components/ui/separator' +import { Badge } from '@/components/ui/badge' +import { ArrowLeft, ArrowRight, Plus, Trash2, Check, Search } from 'lucide-react' +import { toast } from 'sonner' +import type { RepairServiceTemplate } from '@/types/repair' +import type { PaginatedResponse } from '@lunarfront/shared/schemas' + +interface RepairIntakeFormProps { + onComplete: (ticketId: string) => void + onCancel: () => void +} + +interface LineItemDraft { + id: string + itemType: string + description: string + qty: number + unitPrice: number +} + +const STEPS = ['Customer', 'Item', 'Problem & Estimate', 'Review'] +const CONDITIONS = [ + { value: 'excellent', label: 'Excellent' }, + { value: 'good', label: 'Good' }, + { value: 'fair', label: 'Fair' }, + { value: 'poor', label: 'Poor' }, +] + +export function RepairIntakeForm({ onComplete, onCancel }: RepairIntakeFormProps) { + const queryClient = useQueryClient() + const [step, setStep] = useState(0) + + // Customer + const [customerName, setCustomerName] = useState('') + const [customerPhone, setCustomerPhone] = useState('') + const [customerEmail, setCustomerEmail] = useState('') + const [accountId, setAccountId] = useState(null) + + // Account search + const [accountSearch, setAccountSearch] = useState('') + const { data: accountResults } = useQuery({ + queryKey: ['accounts', 'search', accountSearch], + queryFn: () => api.get>('/v1/accounts', { page: 1, limit: 10, q: accountSearch }), + enabled: accountSearch.length >= 2, + staleTime: 10_000, + }) + + // Item + const [itemDescription, setItemDescription] = useState('') + const [serialNumber, setSerialNumber] = useState('') + const [conditionIn, setConditionIn] = useState('') + const [conditionInNotes, setConditionInNotes] = useState('') + + // Problem & Estimate + const [problemDescription, setProblemDescription] = useState('') + const [estimatedCost, setEstimatedCost] = useState('') + const [promisedDate, setPromisedDate] = useState('') + const [lineItems, setLineItems] = useState([]) + + // Templates for quick-add + const { data: templatesData } = useQuery({ + ...repairServiceTemplateListOptions({ page: 1, limit: 100, q: undefined, sort: 'sort_order', order: 'asc' }), + }) + const templates = templatesData?.data ?? [] + + function addLineItem(template?: RepairServiceTemplate) { + setLineItems([...lineItems, { + id: crypto.randomUUID(), + itemType: template?.itemType ?? 'labor', + description: template?.description ?? template?.name ?? '', + qty: 1, + unitPrice: parseFloat(template?.defaultPrice ?? '0'), + }]) + } + + function removeLineItem(id: string) { + setLineItems(lineItems.filter(i => i.id !== id)) + } + + function updateLineItem(id: string, field: string, value: string | number) { + setLineItems(lineItems.map(i => i.id === id ? { ...i, [field]: value } : i)) + } + + const lineItemTotal = lineItems.reduce((sum, i) => sum + i.qty * i.unitPrice, 0) + + const createMutation = useMutation({ + mutationFn: async () => { + const ticket = await repairTicketMutations.create({ + customerName, + customerPhone: customerPhone || undefined, + accountId: accountId || undefined, + itemDescription: itemDescription || undefined, + serialNumber: serialNumber || undefined, + conditionIn: conditionIn || undefined, + conditionInNotes: conditionInNotes || undefined, + problemDescription, + estimatedCost: estimatedCost ? parseFloat(estimatedCost) : undefined, + promisedDate: promisedDate || undefined, + }) + + // Create line items + for (const item of lineItems) { + await repairLineItemMutations.create(ticket.id, { + itemType: item.itemType, + description: item.description, + qty: item.qty, + unitPrice: item.unitPrice, + }) + } + + return ticket + }, + onSuccess: (ticket) => { + queryClient.invalidateQueries({ queryKey: ['repair-tickets'] }) + toast.success(`Ticket #${ticket.ticketNumber} created`) + onComplete(ticket.id) + }, + onError: (err) => toast.error(err.message), + }) + + function selectAccount(acct: { id: string; name: string; email: string | null; phone: string | null }) { + setAccountId(acct.id) + setCustomerName(acct.name) + if (acct.phone) setCustomerPhone(acct.phone) + if (acct.email) setCustomerEmail(acct.email) + setAccountSearch('') + } + + function canProceed() { + if (step === 0) return customerName.trim().length > 0 + if (step === 1) return true + if (step === 2) return problemDescription.trim().length > 0 + return true + } + + return ( +
+ {/* Step indicator */} +
+ +
+
+ {STEPS.map((s, i) => ( +
+
+ {i < step ? : i + 1} + {s} +
+ {i < STEPS.length - 1 &&
} +
+ ))} +
+
+
+ + {/* Step content */} +
+ {step === 0 && ( +
+

Customer

+
+ +
+ + setAccountSearch(e.target.value)} + className="pl-9" + /> +
+ {accountSearch.length >= 2 && accountResults?.data && accountResults.data.length > 0 && ( +
+ {accountResults.data.map((acct) => ( + + ))} +
+ )} +
+ +

Or enter walk-in customer info:

+
+ + setCustomerName(e.target.value)} placeholder="Customer name" /> +
+
+
+ + setCustomerPhone(e.target.value)} placeholder="555-1234" /> +
+
+ + setCustomerEmail(e.target.value)} placeholder="email@example.com" /> +
+
+
+ )} + + {step === 1 && ( +
+

Item Details

+
+ + setItemDescription(e.target.value)} placeholder="e.g. Fender Stratocaster, iPhone 15, etc." /> +
+
+
+ + setSerialNumber(e.target.value)} placeholder="Optional" /> +
+
+ + +
+
+
+ +