Add repair ticket detail improvements and intake estimate builder
Status progress bar component with visual step indicator, in_transit status for instruments being transported to shop. Ticket detail page reworked with inline edit form, reopen for cancelled tickets, photos grouped by repair phase (intake/in_progress/completed). Intake form now supports building estimates with template picker and manual line items that carry over to the ticket. Service template API client and types added for template search in line item dialogs.
This commit is contained in:
@@ -1,18 +1,25 @@
|
||||
import { useState } from 'react'
|
||||
import { createFileRoute, useParams, useNavigate } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { repairTicketDetailOptions, repairTicketMutations, repairTicketKeys, repairLineItemListOptions, repairLineItemMutations, repairLineItemKeys } from '@/api/repairs'
|
||||
import {
|
||||
repairTicketDetailOptions, repairTicketMutations, repairTicketKeys,
|
||||
repairLineItemListOptions, repairLineItemMutations, repairLineItemKeys,
|
||||
repairServiceTemplateListOptions,
|
||||
} from '@/api/repairs'
|
||||
import { usePagination } from '@/hooks/use-pagination'
|
||||
import { StatusProgress } from '@/components/repairs/status-progress'
|
||||
import { TicketPhotos } from '@/components/repairs/ticket-photos'
|
||||
import { DataTable, type Column } from '@/components/shared/data-table'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||
import { ArrowLeft, Plus, Trash2 } from 'lucide-react'
|
||||
import { ArrowLeft, Plus, Trash2, RotateCcw, Save } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import type { RepairLineItem } from '@/types/repair'
|
||||
@@ -29,6 +36,7 @@ export const Route = createFileRoute('/_authenticated/repairs/$ticketId')({
|
||||
})
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
in_transit: 'In Transit',
|
||||
intake: 'Intake',
|
||||
diagnosing: 'Diagnosing',
|
||||
pending_approval: 'Pending Approval',
|
||||
@@ -41,7 +49,7 @@ const STATUS_LABELS: Record<string, string> = {
|
||||
cancelled: 'Cancelled',
|
||||
}
|
||||
|
||||
const STATUS_FLOW = ['intake', 'diagnosing', 'pending_approval', 'approved', 'in_progress', 'ready', 'picked_up']
|
||||
const STATUS_FLOW = ['in_transit', 'intake', 'diagnosing', 'pending_approval', 'approved', 'in_progress', 'ready', 'picked_up']
|
||||
|
||||
function RepairTicketDetailPage() {
|
||||
const { ticketId } = useParams({ from: '/_authenticated/repairs/$ticketId' })
|
||||
@@ -49,11 +57,15 @@ function RepairTicketDetailPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const hasPermission = useAuthStore((s) => s.hasPermission)
|
||||
const [addItemOpen, setAddItemOpen] = useState(false)
|
||||
const [editing, setEditing] = useState(false)
|
||||
const { params, setPage, setSort } = usePagination()
|
||||
|
||||
const { data: ticket, isLoading } = useQuery(repairTicketDetailOptions(ticketId))
|
||||
const { data: lineItemsData, isLoading: itemsLoading } = useQuery(repairLineItemListOptions(ticketId, params))
|
||||
|
||||
// Edit form state
|
||||
const [editFields, setEditFields] = useState<Record<string, string>>({})
|
||||
|
||||
const statusMutation = useMutation({
|
||||
mutationFn: (status: string) => repairTicketMutations.updateStatus(ticketId, status),
|
||||
onSuccess: () => {
|
||||
@@ -63,6 +75,16 @@ function RepairTicketDetailPage() {
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) => repairTicketMutations.update(ticketId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: repairTicketKeys.detail(ticketId) })
|
||||
toast.success('Ticket updated')
|
||||
setEditing(false)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const deleteItemMutation = useMutation({
|
||||
mutationFn: repairLineItemMutations.delete,
|
||||
onSuccess: () => {
|
||||
@@ -73,36 +95,67 @@ function RepairTicketDetailPage() {
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
)
|
||||
return <div className="space-y-4"><Skeleton className="h-8 w-48" /><Skeleton className="h-64 w-full" /></div>
|
||||
}
|
||||
|
||||
if (!ticket) {
|
||||
return <p className="text-muted-foreground">Repair ticket not found</p>
|
||||
}
|
||||
|
||||
function startEdit() {
|
||||
setEditFields({
|
||||
customerName: ticket!.customerName,
|
||||
customerPhone: ticket!.customerPhone ?? '',
|
||||
instrumentDescription: ticket!.instrumentDescription ?? '',
|
||||
serialNumber: ticket!.serialNumber ?? '',
|
||||
conditionIn: ticket!.conditionIn ?? '',
|
||||
conditionInNotes: ticket!.conditionInNotes ?? '',
|
||||
problemDescription: ticket!.problemDescription,
|
||||
technicianNotes: ticket!.technicianNotes ?? '',
|
||||
estimatedCost: ticket!.estimatedCost ?? '',
|
||||
actualCost: ticket!.actualCost ?? '',
|
||||
})
|
||||
setEditing(true)
|
||||
}
|
||||
|
||||
function saveEdit() {
|
||||
const data: Record<string, unknown> = {}
|
||||
if (editFields.customerName !== ticket!.customerName) data.customerName = editFields.customerName
|
||||
if (editFields.customerPhone !== (ticket!.customerPhone ?? '')) data.customerPhone = editFields.customerPhone || undefined
|
||||
if (editFields.instrumentDescription !== (ticket!.instrumentDescription ?? '')) data.instrumentDescription = editFields.instrumentDescription || undefined
|
||||
if (editFields.serialNumber !== (ticket!.serialNumber ?? '')) data.serialNumber = editFields.serialNumber || undefined
|
||||
if (editFields.conditionIn !== (ticket!.conditionIn ?? '')) data.conditionIn = editFields.conditionIn || undefined
|
||||
if (editFields.conditionInNotes !== (ticket!.conditionInNotes ?? '')) data.conditionInNotes = editFields.conditionInNotes || undefined
|
||||
if (editFields.problemDescription !== ticket!.problemDescription) data.problemDescription = editFields.problemDescription
|
||||
if (editFields.technicianNotes !== (ticket!.technicianNotes ?? '')) data.technicianNotes = editFields.technicianNotes || undefined
|
||||
if (editFields.estimatedCost !== (ticket!.estimatedCost ?? '')) data.estimatedCost = editFields.estimatedCost ? parseFloat(editFields.estimatedCost) : undefined
|
||||
if (editFields.actualCost !== (ticket!.actualCost ?? '')) data.actualCost = editFields.actualCost ? parseFloat(editFields.actualCost) : undefined
|
||||
|
||||
if (Object.keys(data).length === 0) {
|
||||
setEditing(false)
|
||||
return
|
||||
}
|
||||
updateMutation.mutate(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 (hasPermission('repairs.edit') && ticket && status !== ticket.status) {
|
||||
statusMutation.mutate(status)
|
||||
}
|
||||
}
|
||||
|
||||
const lineItemColumns: Column<RepairLineItem>[] = [
|
||||
{
|
||||
key: 'item_type',
|
||||
header: 'Type',
|
||||
sortable: true,
|
||||
render: (i) => <Badge variant="outline">{i.itemType.replace('_', ' ')}</Badge>,
|
||||
},
|
||||
{ key: 'item_type', header: 'Type', sortable: true, render: (i) => <Badge variant="outline">{i.itemType.replace('_', ' ')}</Badge> },
|
||||
{ key: 'description', header: 'Description', render: (i) => <>{i.description}</> },
|
||||
{ key: 'qty', header: 'Qty', render: (i) => <>{i.qty}</> },
|
||||
{ key: 'unit_price', header: 'Unit Price', render: (i) => <>${i.unitPrice}</> },
|
||||
{ key: 'total_price', header: 'Total', render: (i) => <span className="font-medium">${i.totalPrice}</span> },
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
render: (i) => hasPermission('repairs.admin') ? (
|
||||
key: 'actions', header: '', render: (i) => hasPermission('repairs.admin') ? (
|
||||
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); deleteItemMutation.mutate(i.id) }}>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
@@ -112,6 +165,7 @@ function RepairTicketDetailPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-4xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/repairs', search: {} as any })}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
@@ -120,13 +174,20 @@ function RepairTicketDetailPage() {
|
||||
<h1 className="text-2xl font-bold">Ticket #{ticket.ticketNumber}</h1>
|
||||
<p className="text-sm text-muted-foreground">{ticket.customerName}</p>
|
||||
</div>
|
||||
<Badge variant={ticket.status === 'cancelled' ? 'destructive' : 'default'} className="text-sm px-3 py-1">
|
||||
{STATUS_LABELS[ticket.status]}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Status Progress Bar */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<StatusProgress
|
||||
currentStatus={ticket.status}
|
||||
onStatusClick={hasPermission('repairs.edit') && !isTerminal ? handleStatusClick : undefined}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Status Actions */}
|
||||
{hasPermission('repairs.edit') && ticket.status !== 'cancelled' && ticket.status !== 'picked_up' && ticket.status !== 'delivered' && (
|
||||
{hasPermission('repairs.edit') && !isTerminal && (
|
||||
<div className="flex gap-2">
|
||||
{nextStatus && (
|
||||
<Button onClick={() => statusMutation.mutate(nextStatus)} disabled={statusMutation.isPending}>
|
||||
@@ -138,6 +199,11 @@ function RepairTicketDetailPage() {
|
||||
Pending Parts
|
||||
</Button>
|
||||
)}
|
||||
{ticket.status === 'pending_parts' && (
|
||||
<Button variant="secondary" onClick={() => statusMutation.mutate('in_progress')} disabled={statusMutation.isPending}>
|
||||
Parts Received
|
||||
</Button>
|
||||
)}
|
||||
{hasPermission('repairs.admin') && (
|
||||
<Button variant="destructive" onClick={() => statusMutation.mutate('cancelled')} disabled={statusMutation.isPending}>
|
||||
Cancel
|
||||
@@ -146,37 +212,125 @@ function RepairTicketDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ticket Details */}
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-lg">Customer</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
<div><span className="text-muted-foreground">Name:</span> {ticket.customerName}</div>
|
||||
<div><span className="text-muted-foreground">Phone:</span> {ticket.customerPhone ?? '-'}</div>
|
||||
<div><span className="text-muted-foreground">Account:</span> {ticket.accountId ?? 'Walk-in'}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-lg">Instrument</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
<div><span className="text-muted-foreground">Description:</span> {ticket.instrumentDescription ?? '-'}</div>
|
||||
<div><span className="text-muted-foreground">Serial:</span> {ticket.serialNumber ?? '-'}</div>
|
||||
<div><span className="text-muted-foreground">Condition:</span> {ticket.conditionIn ?? '-'}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
{/* Reopen for cancelled tickets */}
|
||||
{ticket.status === 'cancelled' && hasPermission('repairs.admin') && (
|
||||
<Button variant="outline" onClick={() => statusMutation.mutate('intake')} disabled={statusMutation.isPending}>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
Reopen Ticket
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Ticket Details — View or Edit */}
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-lg">Problem & Notes</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
<div><span className="text-muted-foreground">Problem:</span> {ticket.problemDescription}</div>
|
||||
{ticket.conditionInNotes && <div><span className="text-muted-foreground">Condition Notes:</span> {ticket.conditionInNotes}</div>}
|
||||
{ticket.technicianNotes && <div><span className="text-muted-foreground">Tech Notes:</span> {ticket.technicianNotes}</div>}
|
||||
<div className="flex gap-6 pt-2">
|
||||
<div><span className="text-muted-foreground">Estimate:</span> {ticket.estimatedCost ? `$${ticket.estimatedCost}` : '-'}</div>
|
||||
<div><span className="text-muted-foreground">Actual:</span> {ticket.actualCost ? `$${ticket.actualCost}` : '-'}</div>
|
||||
<div><span className="text-muted-foreground">Promised:</span> {ticket.promisedDate ? new Date(ticket.promisedDate).toLocaleDateString() : '-'}</div>
|
||||
</div>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-lg">Details</CardTitle>
|
||||
{hasPermission('repairs.edit') && !editing && (
|
||||
<Button variant="outline" size="sm" onClick={startEdit}>Edit</Button>
|
||||
)}
|
||||
{editing && (
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={saveEdit} disabled={updateMutation.isPending}>
|
||||
<Save className="mr-1 h-3 w-3" />{updateMutation.isPending ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setEditing(false)}>Cancel</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{editing ? (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Customer Name</Label>
|
||||
<Input value={editFields.customerName} onChange={(e) => setEditFields((p) => ({ ...p, customerName: e.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Phone</Label>
|
||||
<Input value={editFields.customerPhone} onChange={(e) => setEditFields((p) => ({ ...p, customerPhone: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Instrument</Label>
|
||||
<Input value={editFields.instrumentDescription} onChange={(e) => setEditFields((p) => ({ ...p, instrumentDescription: e.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Serial Number</Label>
|
||||
<Input value={editFields.serialNumber} onChange={(e) => setEditFields((p) => ({ ...p, serialNumber: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Condition</Label>
|
||||
<Select value={editFields.conditionIn} onValueChange={(v) => setEditFields((p) => ({ ...p, conditionIn: v }))}>
|
||||
<SelectTrigger><SelectValue placeholder="Select" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="excellent">Excellent</SelectItem>
|
||||
<SelectItem value="good">Good</SelectItem>
|
||||
<SelectItem value="fair">Fair</SelectItem>
|
||||
<SelectItem value="poor">Poor</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Estimated Cost</Label>
|
||||
<Input type="number" step="0.01" value={editFields.estimatedCost} onChange={(e) => setEditFields((p) => ({ ...p, estimatedCost: e.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Actual Cost</Label>
|
||||
<Input type="number" step="0.01" value={editFields.actualCost} onChange={(e) => setEditFields((p) => ({ ...p, actualCost: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Condition Notes</Label>
|
||||
<Textarea value={editFields.conditionInNotes} onChange={(e) => setEditFields((p) => ({ ...p, conditionInNotes: e.target.value }))} rows={2} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Problem Description</Label>
|
||||
<Textarea value={editFields.problemDescription} onChange={(e) => setEditFields((p) => ({ ...p, problemDescription: e.target.value }))} rows={3} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Technician Notes</Label>
|
||||
<Textarea value={editFields.technicianNotes} onChange={(e) => setEditFields((p) => ({ ...p, technicianNotes: e.target.value }))} rows={3} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2 text-sm">
|
||||
<div><span className="text-muted-foreground">Customer:</span> {ticket.customerName}</div>
|
||||
<div><span className="text-muted-foreground">Phone:</span> {ticket.customerPhone ?? '-'}</div>
|
||||
<div><span className="text-muted-foreground">Account:</span> {ticket.accountId ?? 'Walk-in'}</div>
|
||||
</div>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div><span className="text-muted-foreground">Instrument:</span> {ticket.instrumentDescription ?? '-'}</div>
|
||||
<div><span className="text-muted-foreground">Serial:</span> {ticket.serialNumber ?? '-'}</div>
|
||||
<div><span className="text-muted-foreground">Condition:</span> {ticket.conditionIn ?? '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div><span className="text-muted-foreground">Problem:</span> {ticket.problemDescription}</div>
|
||||
{ticket.conditionInNotes && <div><span className="text-muted-foreground">Condition Notes:</span> {ticket.conditionInNotes}</div>}
|
||||
{ticket.technicianNotes && <div><span className="text-muted-foreground">Tech Notes:</span> {ticket.technicianNotes}</div>}
|
||||
</div>
|
||||
<div className="flex gap-6 text-sm">
|
||||
<div><span className="text-muted-foreground">Estimate:</span> {ticket.estimatedCost ? `$${ticket.estimatedCost}` : '-'}</div>
|
||||
<div><span className="text-muted-foreground">Actual:</span> {ticket.actualCost ? `$${ticket.actualCost}` : '-'}</div>
|
||||
<div><span className="text-muted-foreground">Promised:</span> {ticket.promisedDate ? new Date(ticket.promisedDate).toLocaleDateString() : '-'}</div>
|
||||
<div><span className="text-muted-foreground">Intake:</span> {new Date(ticket.intakeDate).toLocaleDateString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Photos by Phase */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Photos</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TicketPhotos ticketId={ticketId} currentStatus={ticket.status} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -209,10 +363,17 @@ function RepairTicketDetailPage() {
|
||||
|
||||
function AddLineItemDialog({ ticketId, open, onOpenChange }: { ticketId: string; open: boolean; onOpenChange: (open: boolean) => void }) {
|
||||
const queryClient = useQueryClient()
|
||||
const [templateSearch, setTemplateSearch] = useState('')
|
||||
const [showTemplates, setShowTemplates] = useState(false)
|
||||
const [itemType, setItemType] = useState('labor')
|
||||
const [description, setDescription] = useState('')
|
||||
const [qty, setQty] = useState('1')
|
||||
const [unitPrice, setUnitPrice] = useState('0')
|
||||
const [cost, setCost] = useState('')
|
||||
|
||||
const { data: templatesData } = useQuery(
|
||||
repairServiceTemplateListOptions({ page: 1, limit: 20, q: templateSearch || undefined, order: 'asc', sort: 'name' }),
|
||||
)
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) => repairLineItemMutations.create(ticketId, data),
|
||||
@@ -220,33 +381,86 @@ function AddLineItemDialog({ ticketId, open, onOpenChange }: { ticketId: string;
|
||||
queryClient.invalidateQueries({ queryKey: repairLineItemKeys.all(ticketId) })
|
||||
toast.success('Line item added')
|
||||
onOpenChange(false)
|
||||
setDescription('')
|
||||
setQty('1')
|
||||
setUnitPrice('0')
|
||||
resetForm()
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
function resetForm() {
|
||||
setDescription('')
|
||||
setQty('1')
|
||||
setUnitPrice('0')
|
||||
setCost('')
|
||||
setItemType('labor')
|
||||
setTemplateSearch('')
|
||||
setShowTemplates(false)
|
||||
}
|
||||
|
||||
function selectTemplate(template: { name: string; instrumentType: string | null; size: string | null; itemType: string; defaultPrice: string; defaultCost: string | null }) {
|
||||
const desc = [template.name, template.instrumentType, template.size].filter(Boolean).join(' — ')
|
||||
setDescription(desc)
|
||||
setItemType(template.itemType)
|
||||
setUnitPrice(template.defaultPrice)
|
||||
setCost(template.defaultCost ?? '')
|
||||
setShowTemplates(false)
|
||||
setTemplateSearch('')
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
const q = parseFloat(qty) || 1
|
||||
const up = parseFloat(unitPrice) || 0
|
||||
mutation.mutate({
|
||||
itemType,
|
||||
description,
|
||||
qty: q,
|
||||
unitPrice: up,
|
||||
totalPrice: q * up,
|
||||
})
|
||||
const c = cost ? parseFloat(cost) : undefined
|
||||
mutation.mutate({ itemType, description, qty: q, unitPrice: up, totalPrice: q * up, cost: c })
|
||||
}
|
||||
|
||||
const templates = templatesData?.data ?? []
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog open={open} onOpenChange={(o) => { onOpenChange(o); if (!o) resetForm() }}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm"><Plus className="mr-2 h-4 w-4" />Add Item</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>Add Line Item</DialogTitle></DialogHeader>
|
||||
|
||||
{/* Template picker */}
|
||||
<div className="relative">
|
||||
<Label>Quick Add from Template</Label>
|
||||
<Input
|
||||
placeholder="Search templates (e.g. rehair, valve)..."
|
||||
value={templateSearch}
|
||||
onChange={(e) => { setTemplateSearch(e.target.value); setShowTemplates(true) }}
|
||||
onFocus={() => templateSearch && setShowTemplates(true)}
|
||||
className="mt-1"
|
||||
/>
|
||||
{showTemplates && templateSearch.length > 0 && (
|
||||
<div className="absolute z-50 mt-1 w-full rounded-md border bg-popover shadow-lg max-h-48 overflow-auto">
|
||||
{templates.length === 0 ? (
|
||||
<div className="p-3 text-sm text-muted-foreground">No templates found</div>
|
||||
) : (
|
||||
templates.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
className="w-full text-left px-3 py-2 text-sm hover:bg-accent flex justify-between"
|
||||
onClick={() => selectTemplate(t)}
|
||||
>
|
||||
<span>{t.name}{t.instrumentType ? ` — ${t.instrumentType}` : ''}{t.size ? ` ${t.size}` : ''}</span>
|
||||
<span className="text-muted-foreground">${t.defaultPrice}</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative flex items-center gap-2 py-1">
|
||||
<div className="flex-1 border-t" />
|
||||
<span className="text-xs text-muted-foreground">or fill manually</span>
|
||||
<div className="flex-1 border-t" />
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Type</Label>
|
||||
@@ -264,7 +478,7 @@ function AddLineItemDialog({ ticketId, open, onOpenChange }: { ticketId: string;
|
||||
<Label>Description *</Label>
|
||||
<Input value={description} onChange={(e) => setDescription(e.target.value)} required />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Qty</Label>
|
||||
<Input type="number" step="0.001" value={qty} onChange={(e) => setQty(e.target.value)} />
|
||||
@@ -273,6 +487,10 @@ function AddLineItemDialog({ ticketId, open, onOpenChange }: { ticketId: string;
|
||||
<Label>Unit Price</Label>
|
||||
<Input type="number" step="0.01" value={unitPrice} onChange={(e) => setUnitPrice(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Cost (internal)</Label>
|
||||
<Input type="number" step="0.01" value={cost} onChange={(e) => setCost(e.target.value)} placeholder="Optional" />
|
||||
</div>
|
||||
</div>
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? 'Adding...' : 'Add'}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useQuery, useMutation } from '@tanstack/react-query'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { RepairTicketCreateSchema } from '@forte/shared/schemas'
|
||||
import { repairTicketMutations } from '@/api/repairs'
|
||||
import { repairTicketMutations, repairLineItemMutations, repairServiceTemplateListOptions } from '@/api/repairs'
|
||||
import { accountListOptions } from '@/api/accounts'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -14,10 +14,20 @@ import { Textarea } from '@/components/ui/textarea'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { ArrowLeft, Search, Plus, Upload, X, ImageIcon } from 'lucide-react'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { ArrowLeft, Search, Plus, X, ImageIcon, Trash2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import type { Account } from '@/types/account'
|
||||
|
||||
interface PendingLineItem {
|
||||
itemType: string
|
||||
description: string
|
||||
qty: number
|
||||
unitPrice: number
|
||||
totalPrice: number
|
||||
cost?: number
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/repairs/new')({
|
||||
component: NewRepairPage,
|
||||
})
|
||||
@@ -31,6 +41,20 @@ function NewRepairPage() {
|
||||
const [selectedAccount, setSelectedAccount] = useState<Account | null>(null)
|
||||
const [showAccountDropdown, setShowAccountDropdown] = useState(false)
|
||||
|
||||
// Line items built during intake
|
||||
const [lineItems, setLineItems] = useState<PendingLineItem[]>([])
|
||||
|
||||
// Template search for adding line items
|
||||
const [templateSearch, setTemplateSearch] = useState('')
|
||||
const [showTemplates, setShowTemplates] = useState(false)
|
||||
|
||||
// Manual line item fields
|
||||
const [manualType, setManualType] = useState('labor')
|
||||
const [manualDesc, setManualDesc] = useState('')
|
||||
const [manualQty, setManualQty] = useState('1')
|
||||
const [manualPrice, setManualPrice] = useState('0')
|
||||
const [manualCost, setManualCost] = useState('')
|
||||
|
||||
// Photos
|
||||
const [photos, setPhotos] = useState<File[]>([])
|
||||
const photoInputRef = useRef<HTMLInputElement>(null)
|
||||
@@ -39,11 +63,14 @@ function NewRepairPage() {
|
||||
accountListOptions({ page: 1, limit: 20, q: accountSearch || undefined, order: 'asc', sort: 'name' }),
|
||||
)
|
||||
|
||||
const { data: templatesData } = useQuery(
|
||||
repairServiceTemplateListOptions({ page: 1, limit: 20, q: templateSearch || undefined, order: 'asc', sort: 'name' }),
|
||||
)
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm({
|
||||
resolver: zodResolver(RepairTicketCreateSchema),
|
||||
@@ -59,11 +86,24 @@ function NewRepairPage() {
|
||||
},
|
||||
})
|
||||
|
||||
// Auto-calculate estimated cost from line items
|
||||
const estimatedTotal = lineItems.reduce((sum, item) => sum + item.totalPrice, 0)
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (data: Record<string, unknown>) => {
|
||||
// Set estimated cost from line items if any
|
||||
if (lineItems.length > 0) {
|
||||
data.estimatedCost = estimatedTotal
|
||||
}
|
||||
|
||||
const ticket = await repairTicketMutations.create(data)
|
||||
|
||||
// Upload photos after ticket creation
|
||||
// Create line items
|
||||
for (const item of lineItems) {
|
||||
await repairLineItemMutations.create(ticket.id, item as unknown as Record<string, unknown>)
|
||||
}
|
||||
|
||||
// Upload photos
|
||||
for (const photo of photos) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', photo)
|
||||
@@ -102,6 +142,43 @@ function NewRepairPage() {
|
||||
setValue('customerPhone', '')
|
||||
}
|
||||
|
||||
function addFromTemplate(template: { name: string; instrumentType: string | null; size: string | null; itemType: string; defaultPrice: string; defaultCost: string | null }) {
|
||||
const desc = [template.name, template.instrumentType, template.size].filter(Boolean).join(' — ')
|
||||
setLineItems((prev) => [...prev, {
|
||||
itemType: template.itemType,
|
||||
description: desc,
|
||||
qty: 1,
|
||||
unitPrice: parseFloat(template.defaultPrice),
|
||||
totalPrice: parseFloat(template.defaultPrice),
|
||||
cost: template.defaultCost ? parseFloat(template.defaultCost) : undefined,
|
||||
}])
|
||||
setTemplateSearch('')
|
||||
setShowTemplates(false)
|
||||
}
|
||||
|
||||
function addManualItem() {
|
||||
if (!manualDesc.trim()) return
|
||||
const q = parseFloat(manualQty) || 1
|
||||
const p = parseFloat(manualPrice) || 0
|
||||
const c = manualCost ? parseFloat(manualCost) : undefined
|
||||
setLineItems((prev) => [...prev, {
|
||||
itemType: manualType,
|
||||
description: manualDesc,
|
||||
qty: q,
|
||||
unitPrice: p,
|
||||
totalPrice: q * p,
|
||||
cost: c,
|
||||
}])
|
||||
setManualDesc('')
|
||||
setManualQty('1')
|
||||
setManualPrice('0')
|
||||
setManualCost('')
|
||||
}
|
||||
|
||||
function removeLineItem(index: number) {
|
||||
setLineItems((prev) => prev.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
function addPhotos(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const files = Array.from(e.target.files ?? [])
|
||||
setPhotos((prev) => [...prev, ...files])
|
||||
@@ -113,6 +190,7 @@ function NewRepairPage() {
|
||||
}
|
||||
|
||||
const accounts = accountsData?.data ?? []
|
||||
const templates = templatesData?.data ?? []
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-4xl">
|
||||
@@ -136,19 +214,15 @@ function NewRepairPage() {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Account search */}
|
||||
{!selectedAccount ? (
|
||||
<div className="relative">
|
||||
<Label>Search Account</Label>
|
||||
<div className="relative mt-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Type to search accounts..."
|
||||
placeholder="Type to search by name, email, or phone..."
|
||||
value={accountSearch}
|
||||
onChange={(e) => {
|
||||
setAccountSearch(e.target.value)
|
||||
setShowAccountDropdown(true)
|
||||
}}
|
||||
onChange={(e) => { setAccountSearch(e.target.value); setShowAccountDropdown(true) }}
|
||||
onFocus={() => setShowAccountDropdown(true)}
|
||||
className="pl-9"
|
||||
/>
|
||||
@@ -159,14 +233,10 @@ function NewRepairPage() {
|
||||
<div className="p-3 text-sm text-muted-foreground">No accounts found</div>
|
||||
) : (
|
||||
accounts.map((a) => (
|
||||
<button
|
||||
key={a.id}
|
||||
type="button"
|
||||
className="w-full text-left px-3 py-2 text-sm hover:bg-accent flex items-center justify-between"
|
||||
onClick={() => selectAccount(a)}
|
||||
>
|
||||
<button key={a.id} type="button" className="w-full text-left px-3 py-2 text-sm hover:bg-accent flex items-center justify-between" onClick={() => selectAccount(a)}>
|
||||
<div>
|
||||
<span className="font-medium">{a.name}</span>
|
||||
{a.email && <span className="text-muted-foreground ml-2">{a.email}</span>}
|
||||
{a.phone && <span className="text-muted-foreground ml-2">{a.phone}</span>}
|
||||
</div>
|
||||
{a.accountNumber && <span className="text-xs text-muted-foreground font-mono">#{a.accountNumber}</span>}
|
||||
@@ -187,9 +257,7 @@ function NewRepairPage() {
|
||||
{selectedAccount.accountNumber && <span className="font-mono">#{selectedAccount.accountNumber}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={clearAccount}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={clearAccount}><X className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -209,9 +277,7 @@ function NewRepairPage() {
|
||||
|
||||
{/* Instrument Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Instrument</CardTitle>
|
||||
</CardHeader>
|
||||
<CardHeader><CardTitle className="text-lg">Instrument</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
@@ -223,14 +289,11 @@ function NewRepairPage() {
|
||||
<Input {...register('serialNumber')} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Condition at Intake</Label>
|
||||
<Select onValueChange={(v) => setValue('conditionIn', v as any)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select condition" />
|
||||
</SelectTrigger>
|
||||
<SelectTrigger><SelectValue placeholder="Select condition" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="excellent">Excellent</SelectItem>
|
||||
<SelectItem value="good">Good</SelectItem>
|
||||
@@ -240,62 +303,159 @@ function NewRepairPage() {
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Estimated Cost</Label>
|
||||
<Input type="number" step="0.01" {...register('estimatedCost', { valueAsNumber: true })} />
|
||||
<Label>Problem Description *</Label>
|
||||
<Textarea {...register('problemDescription')} rows={2} placeholder="Describe the issue..." />
|
||||
{errors.problemDescription && <p className="text-sm text-destructive">{errors.problemDescription.message}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Estimate / Line Items Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg">Estimate</CardTitle>
|
||||
{lineItems.length > 0 && (
|
||||
<span className="text-lg font-semibold text-primary">${estimatedTotal.toFixed(2)}</span>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Template search */}
|
||||
<div className="relative">
|
||||
<Label>Quick Add from Template</Label>
|
||||
<div className="relative mt-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search templates (e.g. rehair, strings, valve)..."
|
||||
value={templateSearch}
|
||||
onChange={(e) => { setTemplateSearch(e.target.value); setShowTemplates(true) }}
|
||||
onFocus={() => templateSearch && setShowTemplates(true)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
{showTemplates && templateSearch.length > 0 && (
|
||||
<div className="absolute z-50 mt-1 w-full rounded-md border bg-popover shadow-lg max-h-48 overflow-auto">
|
||||
{templates.length === 0 ? (
|
||||
<div className="p-3 text-sm text-muted-foreground">No templates found</div>
|
||||
) : (
|
||||
templates.map((t) => (
|
||||
<button key={t.id} type="button" className="w-full text-left px-3 py-2 text-sm hover:bg-accent flex justify-between" onClick={() => addFromTemplate(t)}>
|
||||
<span>{t.name}{t.instrumentType ? ` — ${t.instrumentType}` : ''}{t.size ? ` ${t.size}` : ''}</span>
|
||||
<span className="text-muted-foreground">${t.defaultPrice}</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Manual line item add */}
|
||||
<div className="space-y-2">
|
||||
<div className="relative flex items-center gap-2">
|
||||
<div className="flex-1 border-t" />
|
||||
<span className="text-xs text-muted-foreground">or add manually</span>
|
||||
<div className="flex-1 border-t" />
|
||||
</div>
|
||||
<div className="grid grid-cols-12 gap-2 items-end">
|
||||
<div className="col-span-2">
|
||||
<Label className="text-xs">Type</Label>
|
||||
<Select value={manualType} onValueChange={setManualType}>
|
||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="labor">Labor</SelectItem>
|
||||
<SelectItem value="part">Part</SelectItem>
|
||||
<SelectItem value="flat_rate">Flat Rate</SelectItem>
|
||||
<SelectItem value="misc">Misc</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="col-span-3">
|
||||
<Label className="text-xs">Description</Label>
|
||||
<Input className="h-9" value={manualDesc} onChange={(e) => setManualDesc(e.target.value)} placeholder="e.g. Ernie Ball strings" />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<Label className="text-xs">Qty</Label>
|
||||
<Input className="h-9" type="number" step="0.001" value={manualQty} onChange={(e) => setManualQty(e.target.value)} />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<Label className="text-xs">Price</Label>
|
||||
<Input className="h-9" type="number" step="0.01" value={manualPrice} onChange={(e) => setManualPrice(e.target.value)} />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<Label className="text-xs">Cost</Label>
|
||||
<Input className="h-9" type="number" step="0.01" value={manualCost} onChange={(e) => setManualCost(e.target.value)} placeholder="Internal" />
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<Button type="button" size="sm" className="h-9 w-full" onClick={addManualItem} disabled={!manualDesc.trim()}>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Problem Description *</Label>
|
||||
<Textarea {...register('problemDescription')} rows={3} placeholder="Describe the issue..." />
|
||||
{errors.problemDescription && <p className="text-sm text-destructive">{errors.problemDescription.message}</p>}
|
||||
</div>
|
||||
{/* Line items table */}
|
||||
{lineItems.length > 0 && (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Description</TableHead>
|
||||
<TableHead className="text-right">Qty</TableHead>
|
||||
<TableHead className="text-right">Price</TableHead>
|
||||
<TableHead className="text-right">Total</TableHead>
|
||||
<TableHead className="w-10"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{lineItems.map((item, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell><Badge variant="outline">{item.itemType.replace('_', ' ')}</Badge></TableCell>
|
||||
<TableCell>{item.description}</TableCell>
|
||||
<TableCell className="text-right">{item.qty}</TableCell>
|
||||
<TableCell className="text-right">${item.unitPrice.toFixed(2)}</TableCell>
|
||||
<TableCell className="text-right font-medium">${item.totalPrice.toFixed(2)}</TableCell>
|
||||
<TableCell>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => removeLineItem(i)}>
|
||||
<Trash2 className="h-3 w-3 text-destructive" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-right font-semibold">Estimated Total</TableCell>
|
||||
<TableCell className="text-right font-bold text-primary">${estimatedTotal.toFixed(2)}</TableCell>
|
||||
<TableCell></TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Photos Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Intake Photos</CardTitle>
|
||||
</CardHeader>
|
||||
<CardHeader><CardTitle className="text-lg">Intake Photos</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">Optional — document instrument condition at intake</p>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{photos.map((photo, i) => (
|
||||
<div key={i} className="relative group">
|
||||
<img
|
||||
src={URL.createObjectURL(photo)}
|
||||
alt={`Intake photo ${i + 1}`}
|
||||
className="h-24 w-24 object-cover rounded-md border"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute -top-2 -right-2 h-5 w-5 rounded-full bg-destructive text-destructive-foreground flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={() => removePhoto(i)}
|
||||
>
|
||||
<img src={URL.createObjectURL(photo)} alt={`Intake photo ${i + 1}`} className="h-24 w-24 object-cover rounded-md border" />
|
||||
<button type="button" className="absolute -top-2 -right-2 h-5 w-5 rounded-full bg-destructive text-destructive-foreground flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity" onClick={() => removePhoto(i)}>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="h-24 w-24 rounded-md border-2 border-dashed border-muted-foreground/30 flex flex-col items-center justify-center text-muted-foreground hover:border-primary hover:text-primary transition-colors"
|
||||
onClick={() => photoInputRef.current?.click()}
|
||||
>
|
||||
<button type="button" className="h-24 w-24 rounded-md border-2 border-dashed border-muted-foreground/30 flex flex-col items-center justify-center text-muted-foreground hover:border-primary hover:text-primary transition-colors" onClick={() => photoInputRef.current?.click()}>
|
||||
<ImageIcon className="h-6 w-6 mb-1" />
|
||||
<span className="text-xs">Add Photo</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={photoInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={addPhotos}
|
||||
/>
|
||||
<input ref={photoInputRef} type="file" accept="image/jpeg,image/png,image/webp" multiple className="hidden" onChange={addPhotos} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user