This commit is contained in:
2026-04-07 06:32:40 +03:00
parent 24a44481a0
commit 11db1e6941
101 changed files with 54585 additions and 39160 deletions
@@ -70,6 +70,8 @@ export type RhfCustomerSelectFieldProps<
required?: boolean
disabled?: boolean
placeholder?: string
/** Filter customers by customer_type.name (case-insensitive). */
customerType?: string
}
// ── Component ──
@@ -84,6 +86,7 @@ export function RhfCustomerSelectField<
required,
disabled,
placeholder = "Search by name, company, or phone...",
customerType,
}: RhfCustomerSelectFieldProps<TValues, TName>) {
const api = useAuthApi()
const anchorRef = useRef<HTMLDivElement>(null)
@@ -96,10 +99,16 @@ export function RhfCustomerSelectField<
} = useController({ name, control, disabled })
const { data: options = [], isLoading } = useQuery<CustomerOption[]>({
queryKey: [CUSTOMER_ROUTES.INDEX, "customer-select"],
queryKey: [CUSTOMER_ROUTES.INDEX, "customer-select", customerType ?? "all"],
queryFn: async () => {
const res = await api.customers.list()
return extractItems(res).map(buildCustomerOption)
const items = extractItems(res)
const filtered = customerType
? items.filter((item: any) =>
item.customer_type?.name?.toLowerCase() === customerType.toLowerCase(),
)
: items
return filtered.map(buildCustomerOption)
},
staleTime: 5 * 60 * 1000,
})
@@ -0,0 +1,566 @@
"use client"
import { useState } from "react"
import {
useFormContext,
useController,
type FieldValues,
type FieldPath,
} from "react-hook-form"
import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query"
import {
Check,
ChevronFirst,
ChevronLast,
ChevronLeft,
ChevronRight,
Edit2,
MoreHorizontal,
Plus,
RefreshCcw,
Search,
Trash2,
X,
} from "lucide-react"
import { useAuthApi } from "@/shared/useApi"
import { QUICK_REMARK_ROUTES } from "@garage/api"
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/shared/components/ui/sheet"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import {
Field,
FieldLabel,
FieldError,
} from "@/shared/components/ui/field"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/shared/components/ui/tooltip"
import { cn } from "@/shared/lib/utils"
// ── Types ──
type QuickRemark = {
id: number
description: string
}
type QuickRemarksPage = {
data: QuickRemark[]
meta: {
current_page: number
last_page: number
per_page: number
total: number
}
}
type RhfCustomerRemarksFieldProps<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
> = {
name: TName
label?: string
description?: string
required?: boolean
disabled?: boolean
placeholder?: string
}
// ── Helpers ──
function extractPage(response: unknown): QuickRemarksPage {
const r = response as any
return {
data: Array.isArray(r?.data?.data)
? r.data.data
: Array.isArray(r?.data)
? r.data
: [],
meta: r?.data?.meta ?? r?.meta ?? {
current_page: 1,
last_page: 1,
per_page: 15,
total: 0,
},
}
}
// ── QuickRemarksSheet ──
function QuickRemarksSheet({
open,
onOpenChange,
selected,
onToggle,
}: {
open: boolean
onOpenChange: (v: boolean) => void
selected: string[]
onToggle: (description: string) => void
}) {
const api = useAuthApi()
const queryClient = useQueryClient()
const [page, setPage] = useState(1)
const [search, setSearch] = useState("")
const [creating, setCreating] = useState(false)
const [newDescription, setNewDescription] = useState("")
const [editingId, setEditingId] = useState<number | null>(null)
const [editingText, setEditingText] = useState("")
const queryKey = [QUICK_REMARK_ROUTES.INDEX, { page, search }]
const { data, isLoading } = useQuery<QuickRemarksPage>({
queryKey,
queryFn: async () => {
const res = await api.quickRemarks.list({
page,
...(search ? { search } : {}),
})
return extractPage(res)
},
enabled: open,
staleTime: 30_000,
})
const remarks = data?.data ?? []
const meta = data?.meta
const invalidate = () =>
queryClient.invalidateQueries({ queryKey: [QUICK_REMARK_ROUTES.INDEX] })
const createMutation = useMutation({
mutationFn: (description: string) =>
api.quickRemarks.create({ description }),
onSuccess: () => {
invalidate()
setCreating(false)
setNewDescription("")
},
})
const updateMutation = useMutation({
mutationFn: ({ id, description }: { id: number; description: string }) =>
api.quickRemarks.update(String(id), { description }),
onSuccess: () => {
invalidate()
setEditingId(null)
setEditingText("")
},
})
const deleteMutation = useMutation({
mutationFn: (id: number) =>
api.quickRemarks.destroy(String(id)),
onSuccess: () => invalidate(),
})
function handleCreate() {
const text = newDescription.trim()
if (!text) return
createMutation.mutate(text)
}
function handleUpdate(id: number) {
const text = editingText.trim()
if (!text) return
updateMutation.mutate({ id, description: text })
}
function startEdit(remark: QuickRemark) {
setEditingId(remark.id)
setEditingText(remark.description)
}
const totalPages = meta?.last_page ?? 1
const from = meta ? (meta.current_page - 1) * meta.per_page + 1 : 0
const to = meta ? Math.min(meta.current_page * meta.per_page, meta.total) : 0
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="flex w-105 flex-col gap-0 p-0 sm:w-120">
{/* Header */}
<SheetHeader className="flex flex-row items-center justify-between border-b px-4 py-3">
<SheetTitle className="text-base font-semibold">Quick Remarks</SheetTitle>
</SheetHeader>
{/* Search */}
<div className="border-b px-3 py-2 flex gap-1 items-center">
<div className="relative grow">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search..."
value={search}
onChange={(e) => {
setSearch(e.target.value)
setPage(1)
}}
className="pl-9 h-9"
/>
</div>
<Button
type="button"
size="sm"
onClick={() => {
setCreating(true)
setEditingId(null)
}}
>
<Plus className="h-3.5 w-3.5" />
Add
</Button>
</div>
{/* Inline create form */}
{creating && (
<div className="border-b px-3 py-2">
<div className="flex items-center gap-2">
<Input
autoFocus
placeholder="New quick remark..."
value={newDescription}
onChange={(e) => setNewDescription(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") { e.preventDefault(); handleCreate() }
if (e.key === "Escape") { setCreating(false); setNewDescription("") }
}}
className="h-8 text-sm"
/>
<Button
type="button"
size="sm"
disabled={createMutation.isPending || !newDescription.trim()}
onClick={handleCreate}
>
Save
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => { setCreating(false); setNewDescription("") }}
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
</div>
)}
{/* List */}
<div className="flex-1 overflow-y-auto">
{isLoading ? (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
Loading...
</div>
) : remarks.length === 0 ? (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
No quick remarks found.
</div>
) : (
remarks.map((remark) => {
const isSelected = selected.includes(remark.description)
const isEditing = editingId === remark.id
if (isEditing) {
return (
<div key={remark.id} className="flex items-center gap-2 border-b px-4 py-2">
<Input
autoFocus
value={editingText}
onChange={(e) => setEditingText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") { e.preventDefault(); handleUpdate(remark.id) }
if (e.key === "Escape") { setEditingId(null); setEditingText("") }
}}
className="h-8 flex-1 text-sm"
/>
<Button
type="button"
size="sm"
disabled={updateMutation.isPending || !editingText.trim()}
onClick={() => handleUpdate(remark.id)}
>
Save
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => { setEditingId(null); setEditingText("") }}
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
)
}
return (
<div
key={remark.id}
className={cn(
"flex cursor-pointer items-center justify-between border-b px-4 py-3 transition-colors hover:bg-muted/50",
isSelected && "bg-primary/5",
)}
onClick={() => onToggle(remark.description)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
onToggle(remark.description)
}
}}
>
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className={cn(
"flex h-4 w-4 shrink-0 items-center justify-center",
)}>
{isSelected && (
<Check className="h-4 w-4 text-primary" />
)}
</div>
<span className="truncate text-sm">{remark.description}</span>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">Actions</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation()
startEdit(remark)
}}
>
<Edit2 className="mr-2 h-3.5 w-3.5" />
Edit
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={(e) => {
e.stopPropagation()
deleteMutation.mutate(remark.id)
}}
>
<Trash2 className="mr-2 h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
})
)}
</div>
{/* Pagination */}
{meta && meta.total > 0 && (
<div className="flex items-center justify-between border-t px-4 py-2 text-sm text-muted-foreground">
<span>
{from}–{to} of {meta.total}
</span>
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={page <= 1}
onClick={() => setPage(1)}
>
<ChevronFirst className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
<ChevronRight className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={page >= totalPages}
onClick={() => setPage(totalPages)}
>
<ChevronLast className="h-4 w-4" />
</Button>
</div>
</div>
)}
</SheetContent>
</Sheet>
)
}
// ── Main Component ──
export function RhfCustomerRemarksField<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
>({
name,
label = "Customer Remark",
description,
required,
disabled,
placeholder = "Enter remark...",
}: RhfCustomerRemarksFieldProps<TValues, TName>) {
const { control } = useFormContext<TValues>()
const { field, fieldState: { error } } = useController({ name, control, disabled })
const [sheetOpen, setSheetOpen] = useState(false)
const remarks: string[] = Array.isArray(field.value) ? field.value : []
function updateAt(index: number, value: string) {
const next = [...remarks]
next[index] = value
field.onChange(next)
}
function addLine() {
field.onChange([...remarks, ""])
}
function removeLine(index: number) {
field.onChange(remarks.filter((_, i) => i !== index))
}
function toggleQuickRemark(description: string) {
const exists = remarks.includes(description)
if (exists) {
field.onChange(remarks.filter((r) => r !== description))
} else {
field.onChange([...remarks, description])
}
}
return (
<Field data-invalid={!!error || undefined}>
{label && (
<FieldLabel>
{label}
{required && (
<span className="text-destructive ms-0.5">*</span>
)}
</FieldLabel>
)}
{/* Repeater rows */}
<div className="flex flex-col rounded-md border divide-y overflow-hidden">
{remarks.map((remark, index) => (
<div key={index} className="flex items-center gap-2 px-3 py-2 bg-background">
<input
type="text"
value={remark}
onChange={(e) => updateAt(index, e.target.value)}
placeholder={placeholder}
disabled={disabled}
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
/>
<button
type="button"
disabled={disabled}
onClick={() => removeLine(index)}
className="shrink-0 rounded-full p-0.5 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-40"
aria-label="Remove remark"
>
<X className="h-4 w-4" />
</button>
</div>
))}
{/* Empty state */}
{remarks.length === 0 && (
<div className="px-3 py-2 text-sm text-muted-foreground italic">
No remarks added yet.
</div>
)}
{/* Footer row */}
<div className="flex items-center justify-between px-3 py-2 bg-muted/30">
<Button
type="button"
variant="ghost"
size="sm"
disabled={disabled}
onClick={addLine}
className="h-7 gap-1.5 text-xs text-primary hover:text-primary"
>
<Plus className="h-3.5 w-3.5" />
New Line
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="default"
size="icon"
disabled={disabled}
onClick={() => setSheetOpen(true)}
className="h-8 w-8 rounded-full"
aria-label="Pick from Quick Remarks"
>
<RefreshCcw className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">Quick Remarks</TooltipContent>
</Tooltip>
</div>
</div>
{error && <FieldError>{error.message}</FieldError>}
{description && !error && (
<p className="text-xs text-muted-foreground">{description}</p>
)}
<QuickRemarksSheet
open={sheetOpen}
onOpenChange={setSheetOpen}
selected={remarks}
onToggle={toggleQuickRemark}
/>
</Field>
)
}
@@ -0,0 +1,548 @@
"use client"
import { useState } from "react"
import {
useFormContext,
useController,
type FieldValues,
type FieldPath,
} from "react-hook-form"
import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query"
import {
Check,
ChevronFirst,
ChevronLast,
ChevronLeft,
ChevronRight,
Edit2,
MoreHorizontal,
Plus,
RefreshCcw,
Search,
Trash2,
X,
} from "lucide-react"
import { useAuthApi } from "@/shared/useApi"
import { QUICK_NOTE_ROUTES } from "@garage/api"
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/shared/components/ui/sheet"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import {
Field,
FieldLabel,
FieldError,
} from "@/shared/components/ui/field"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/shared/components/ui/tooltip"
import { cn } from "@/shared/lib/utils"
type QuickNote = {
id: number
description: string
}
type QuickNotesPage = {
data: QuickNote[]
meta: {
current_page: number
last_page: number
per_page: number
total: number
}
}
type RhfQuickNotesFieldProps<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
> = {
name: TName
label?: string
description?: string
required?: boolean
disabled?: boolean
placeholder?: string
}
function extractPage(response: unknown): QuickNotesPage {
const r = response as any
return {
data: Array.isArray(r?.data?.data)
? r.data.data
: Array.isArray(r?.data)
? r.data
: [],
meta: r?.data?.meta ?? r?.meta ?? {
current_page: 1,
last_page: 1,
per_page: 15,
total: 0,
},
}
}
function QuickNotesSheet({
open,
onOpenChange,
selected,
onToggle,
}: {
open: boolean
onOpenChange: (v: boolean) => void
selected: string[]
onToggle: (description: string) => void
}) {
const api = useAuthApi()
const queryClient = useQueryClient()
const [page, setPage] = useState(1)
const [search, setSearch] = useState("")
const [creating, setCreating] = useState(false)
const [newDescription, setNewDescription] = useState("")
const [editingId, setEditingId] = useState<number | null>(null)
const [editingText, setEditingText] = useState("")
const queryKey = [QUICK_NOTE_ROUTES.INDEX, { page, search }]
const { data, isLoading } = useQuery<QuickNotesPage>({
queryKey,
queryFn: async () => {
const res = await api.quickNotes.list({
page,
...(search ? { search } : {}),
})
return extractPage(res)
},
enabled: open,
staleTime: 30_000,
})
const notes = data?.data ?? []
const meta = data?.meta
const invalidate = () =>
queryClient.invalidateQueries({ queryKey: [QUICK_NOTE_ROUTES.INDEX] })
const createMutation = useMutation({
mutationFn: (description: string) =>
api.quickNotes.create({ description }),
onSuccess: () => {
invalidate()
setCreating(false)
setNewDescription("")
},
})
const updateMutation = useMutation({
mutationFn: ({ id, description }: { id: number; description: string }) =>
api.quickNotes.update(String(id), { description }),
onSuccess: () => {
invalidate()
setEditingId(null)
setEditingText("")
},
})
const deleteMutation = useMutation({
mutationFn: (id: number) =>
api.quickNotes.destroy(String(id)),
onSuccess: () => invalidate(),
})
function handleCreate() {
const text = newDescription.trim()
if (!text) return
createMutation.mutate(text)
}
function handleUpdate(id: number) {
const text = editingText.trim()
if (!text) return
updateMutation.mutate({ id, description: text })
}
function startEdit(note: QuickNote) {
setEditingId(note.id)
setEditingText(note.description)
}
const totalPages = meta?.last_page ?? 1
const from = meta ? (meta.current_page - 1) * meta.per_page + 1 : 0
const to = meta ? Math.min(meta.current_page * meta.per_page, meta.total) : 0
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="flex w-105 flex-col gap-0 p-0 sm:w-120">
<SheetHeader className="flex flex-row items-center justify-between border-b px-4 py-3">
<SheetTitle className="text-base font-semibold">Quick Notes</SheetTitle>
</SheetHeader>
<div className="border-b px-3 py-2 flex gap-1 items-center">
<div className="relative grow">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search..."
value={search}
onChange={(e) => {
setSearch(e.target.value)
setPage(1)
}}
className="pl-9 h-9"
/>
</div>
<Button
type="button"
size="sm"
onClick={() => {
setCreating(true)
setEditingId(null)
}}
>
<Plus className="h-3.5 w-3.5" />
Add
</Button>
</div>
{creating && (
<div className="border-b px-3 py-2">
<div className="flex items-center gap-2">
<Input
autoFocus
placeholder="New quick note..."
value={newDescription}
onChange={(e) => setNewDescription(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") { e.preventDefault(); handleCreate() }
if (e.key === "Escape") { setCreating(false); setNewDescription("") }
}}
className="h-8 text-sm"
/>
<Button
type="button"
size="sm"
disabled={createMutation.isPending || !newDescription.trim()}
onClick={handleCreate}
>
Save
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => { setCreating(false); setNewDescription("") }}
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
</div>
)}
<div className="flex-1 overflow-y-auto">
{isLoading ? (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
Loading...
</div>
) : notes.length === 0 ? (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
No quick notes found.
</div>
) : (
notes.map((note) => {
const isSelected = selected.includes(note.description)
const isEditing = editingId === note.id
if (isEditing) {
return (
<div key={note.id} className="flex items-center gap-2 border-b px-4 py-2">
<Input
autoFocus
value={editingText}
onChange={(e) => setEditingText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") { e.preventDefault(); handleUpdate(note.id) }
if (e.key === "Escape") { setEditingId(null); setEditingText("") }
}}
className="h-8 flex-1 text-sm"
/>
<Button
type="button"
size="sm"
disabled={updateMutation.isPending || !editingText.trim()}
onClick={() => handleUpdate(note.id)}
>
Save
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => { setEditingId(null); setEditingText("") }}
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
)
}
return (
<div
key={note.id}
className={cn(
"flex cursor-pointer items-center justify-between border-b px-4 py-3 transition-colors hover:bg-muted/50",
isSelected && "bg-primary/5",
)}
onClick={() => onToggle(note.description)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
onToggle(note.description)
}
}}
>
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className={cn(
"flex h-4 w-4 shrink-0 items-center justify-center",
)}>
{isSelected && (
<Check className="h-4 w-4 text-primary" />
)}
</div>
<span className="truncate text-sm">{note.description}</span>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">Actions</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation()
startEdit(note)
}}
>
<Edit2 className="mr-2 h-3.5 w-3.5" />
Edit
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={(e) => {
e.stopPropagation()
deleteMutation.mutate(note.id)
}}
>
<Trash2 className="mr-2 h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
})
)}
</div>
{meta && meta.total > 0 && (
<div className="flex items-center justify-between border-t px-4 py-2 text-sm text-muted-foreground">
<span>
{from}-{to} of {meta.total}
</span>
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={page <= 1}
onClick={() => setPage(1)}
>
<ChevronFirst className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
<ChevronRight className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={page >= totalPages}
onClick={() => setPage(totalPages)}
>
<ChevronLast className="h-4 w-4" />
</Button>
</div>
</div>
)}
</SheetContent>
</Sheet>
)
}
export function RhfQuickNotesField<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
>({
name,
label = "Quick Notes",
description,
required,
disabled,
placeholder = "Enter note...",
}: RhfQuickNotesFieldProps<TValues, TName>) {
const { control } = useFormContext<TValues>()
const { field, fieldState: { error } } = useController({ name, control, disabled })
const [sheetOpen, setSheetOpen] = useState(false)
const notes: string[] = Array.isArray(field.value) ? field.value : []
function updateAt(index: number, value: string) {
const next = [...notes]
next[index] = value
field.onChange(next)
}
function addLine() {
field.onChange([...notes, ""])
}
function removeLine(index: number) {
field.onChange(notes.filter((_, i) => i !== index))
}
function toggleQuickNote(description: string) {
const exists = notes.includes(description)
if (exists) {
field.onChange(notes.filter((r) => r !== description))
} else {
field.onChange([...notes, description])
}
}
return (
<Field data-invalid={!!error || undefined}>
{label && (
<FieldLabel>
{label}
{required && (
<span className="text-destructive ms-0.5">*</span>
)}
</FieldLabel>
)}
<div className="flex flex-col rounded-md border divide-y overflow-hidden">
{notes.map((note, index) => (
<div key={index} className="flex items-center gap-2 px-3 py-2 bg-background">
<input
type="text"
value={note}
onChange={(e) => updateAt(index, e.target.value)}
placeholder={placeholder}
disabled={disabled}
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
/>
<button
type="button"
disabled={disabled}
onClick={() => removeLine(index)}
className="shrink-0 rounded-full p-0.5 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-40"
aria-label="Remove note"
>
<X className="h-4 w-4" />
</button>
</div>
))}
{notes.length === 0 && (
<div className="px-3 py-2 text-sm text-muted-foreground italic">
No notes added yet.
</div>
)}
<div className="flex items-center justify-between px-3 py-2 bg-muted/30">
<Button
type="button"
variant="ghost"
size="sm"
disabled={disabled}
onClick={addLine}
className="h-7 gap-1.5 text-xs text-primary hover:text-primary"
>
<Plus className="h-3.5 w-3.5" />
New Line
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="default"
size="icon"
disabled={disabled}
onClick={() => setSheetOpen(true)}
className="h-8 w-8 rounded-full"
aria-label="Pick from Quick Notes"
>
<RefreshCcw className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">Quick Notes</TooltipContent>
</Tooltip>
</div>
</div>
{error && <FieldError>{error.message}</FieldError>}
{description && !error && (
<p className="text-xs text-muted-foreground">{description}</p>
)}
<QuickNotesSheet
open={sheetOpen}
onOpenChange={setSheetOpen}
selected={notes}
onToggle={toggleQuickNote}
/>
</Field>
)
}
@@ -0,0 +1,548 @@
"use client"
import { useState } from "react"
import {
useFormContext,
useController,
type FieldValues,
type FieldPath,
} from "react-hook-form"
import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query"
import {
Check,
ChevronFirst,
ChevronLast,
ChevronLeft,
ChevronRight,
Edit2,
MoreHorizontal,
Plus,
RefreshCcw,
Search,
Trash2,
X,
} from "lucide-react"
import { useAuthApi } from "@/shared/useApi"
import { SHOP_RECOMMENDATION_ROUTES } from "@garage/api"
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/shared/components/ui/sheet"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import {
Field,
FieldLabel,
FieldError,
} from "@/shared/components/ui/field"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/shared/components/ui/tooltip"
import { cn } from "@/shared/lib/utils"
type ShopRecommendation = {
id: number
description: string
}
type ShopRecommendationsPage = {
data: ShopRecommendation[]
meta: {
current_page: number
last_page: number
per_page: number
total: number
}
}
type RhfShopRecommendationsFieldProps<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
> = {
name: TName
label?: string
description?: string
required?: boolean
disabled?: boolean
placeholder?: string
}
function extractPage(response: unknown): ShopRecommendationsPage {
const r = response as any
return {
data: Array.isArray(r?.data?.data)
? r.data.data
: Array.isArray(r?.data)
? r.data
: [],
meta: r?.data?.meta ?? r?.meta ?? {
current_page: 1,
last_page: 1,
per_page: 15,
total: 0,
},
}
}
function ShopRecommendationsSheet({
open,
onOpenChange,
selected,
onToggle,
}: {
open: boolean
onOpenChange: (v: boolean) => void
selected: string[]
onToggle: (description: string) => void
}) {
const api = useAuthApi()
const queryClient = useQueryClient()
const [page, setPage] = useState(1)
const [search, setSearch] = useState("")
const [creating, setCreating] = useState(false)
const [newDescription, setNewDescription] = useState("")
const [editingId, setEditingId] = useState<number | null>(null)
const [editingText, setEditingText] = useState("")
const queryKey = [SHOP_RECOMMENDATION_ROUTES.INDEX, { page, search }]
const { data, isLoading } = useQuery<ShopRecommendationsPage>({
queryKey,
queryFn: async () => {
const res = await api.shopRecommendations.list({
page,
...(search ? { search } : {}),
})
return extractPage(res)
},
enabled: open,
staleTime: 30_000,
})
const recommendations = data?.data ?? []
const meta = data?.meta
const invalidate = () =>
queryClient.invalidateQueries({ queryKey: [SHOP_RECOMMENDATION_ROUTES.INDEX] })
const createMutation = useMutation({
mutationFn: (description: string) =>
api.shopRecommendations.create({ description }),
onSuccess: () => {
invalidate()
setCreating(false)
setNewDescription("")
},
})
const updateMutation = useMutation({
mutationFn: ({ id, description }: { id: number; description: string }) =>
api.shopRecommendations.update(String(id), { description }),
onSuccess: () => {
invalidate()
setEditingId(null)
setEditingText("")
},
})
const deleteMutation = useMutation({
mutationFn: (id: number) =>
api.shopRecommendations.destroy(String(id)),
onSuccess: () => invalidate(),
})
function handleCreate() {
const text = newDescription.trim()
if (!text) return
createMutation.mutate(text)
}
function handleUpdate(id: number) {
const text = editingText.trim()
if (!text) return
updateMutation.mutate({ id, description: text })
}
function startEdit(recommendation: ShopRecommendation) {
setEditingId(recommendation.id)
setEditingText(recommendation.description)
}
const totalPages = meta?.last_page ?? 1
const from = meta ? (meta.current_page - 1) * meta.per_page + 1 : 0
const to = meta ? Math.min(meta.current_page * meta.per_page, meta.total) : 0
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="flex w-105 flex-col gap-0 p-0 sm:w-120">
<SheetHeader className="flex flex-row items-center justify-between border-b px-4 py-3">
<SheetTitle className="text-base font-semibold">Shop Recommendations</SheetTitle>
</SheetHeader>
<div className="border-b px-3 py-2 flex gap-1 items-center">
<div className="relative grow">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search..."
value={search}
onChange={(e) => {
setSearch(e.target.value)
setPage(1)
}}
className="pl-9 h-9"
/>
</div>
<Button
type="button"
size="sm"
onClick={() => {
setCreating(true)
setEditingId(null)
}}
>
<Plus className="h-3.5 w-3.5" />
Add
</Button>
</div>
{creating && (
<div className="border-b px-3 py-2">
<div className="flex items-center gap-2">
<Input
autoFocus
placeholder="New shop recommendation..."
value={newDescription}
onChange={(e) => setNewDescription(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") { e.preventDefault(); handleCreate() }
if (e.key === "Escape") { setCreating(false); setNewDescription("") }
}}
className="h-8 text-sm"
/>
<Button
type="button"
size="sm"
disabled={createMutation.isPending || !newDescription.trim()}
onClick={handleCreate}
>
Save
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => { setCreating(false); setNewDescription("") }}
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
</div>
)}
<div className="flex-1 overflow-y-auto">
{isLoading ? (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
Loading...
</div>
) : recommendations.length === 0 ? (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
No shop recommendations found.
</div>
) : (
recommendations.map((recommendation) => {
const isSelected = selected.includes(recommendation.description)
const isEditing = editingId === recommendation.id
if (isEditing) {
return (
<div key={recommendation.id} className="flex items-center gap-2 border-b px-4 py-2">
<Input
autoFocus
value={editingText}
onChange={(e) => setEditingText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") { e.preventDefault(); handleUpdate(recommendation.id) }
if (e.key === "Escape") { setEditingId(null); setEditingText("") }
}}
className="h-8 flex-1 text-sm"
/>
<Button
type="button"
size="sm"
disabled={updateMutation.isPending || !editingText.trim()}
onClick={() => handleUpdate(recommendation.id)}
>
Save
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => { setEditingId(null); setEditingText("") }}
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
)
}
return (
<div
key={recommendation.id}
className={cn(
"flex cursor-pointer items-center justify-between border-b px-4 py-3 transition-colors hover:bg-muted/50",
isSelected && "bg-primary/5",
)}
onClick={() => onToggle(recommendation.description)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
onToggle(recommendation.description)
}
}}
>
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className={cn(
"flex h-4 w-4 shrink-0 items-center justify-center",
)}>
{isSelected && (
<Check className="h-4 w-4 text-primary" />
)}
</div>
<span className="truncate text-sm">{recommendation.description}</span>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">Actions</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation()
startEdit(recommendation)
}}
>
<Edit2 className="mr-2 h-3.5 w-3.5" />
Edit
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={(e) => {
e.stopPropagation()
deleteMutation.mutate(recommendation.id)
}}
>
<Trash2 className="mr-2 h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
})
)}
</div>
{meta && meta.total > 0 && (
<div className="flex items-center justify-between border-t px-4 py-2 text-sm text-muted-foreground">
<span>
{from}-{to} of {meta.total}
</span>
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={page <= 1}
onClick={() => setPage(1)}
>
<ChevronFirst className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
<ChevronRight className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={page >= totalPages}
onClick={() => setPage(totalPages)}
>
<ChevronLast className="h-4 w-4" />
</Button>
</div>
</div>
)}
</SheetContent>
</Sheet>
)
}
export function RhfShopRecommendationsField<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
>({
name,
label = "Shop Recommendations",
description,
required,
disabled,
placeholder = "Enter recommendation...",
}: RhfShopRecommendationsFieldProps<TValues, TName>) {
const { control } = useFormContext<TValues>()
const { field, fieldState: { error } } = useController({ name, control, disabled })
const [sheetOpen, setSheetOpen] = useState(false)
const recommendations: string[] = Array.isArray(field.value) ? field.value : []
function updateAt(index: number, value: string) {
const next = [...recommendations]
next[index] = value
field.onChange(next)
}
function addLine() {
field.onChange([...recommendations, ""])
}
function removeLine(index: number) {
field.onChange(recommendations.filter((_, i) => i !== index))
}
function toggleRecommendation(description: string) {
const exists = recommendations.includes(description)
if (exists) {
field.onChange(recommendations.filter((r) => r !== description))
} else {
field.onChange([...recommendations, description])
}
}
return (
<Field data-invalid={!!error || undefined}>
{label && (
<FieldLabel>
{label}
{required && (
<span className="text-destructive ms-0.5">*</span>
)}
</FieldLabel>
)}
<div className="flex flex-col rounded-md border divide-y overflow-hidden">
{recommendations.map((recommendation, index) => (
<div key={index} className="flex items-center gap-2 px-3 py-2 bg-background">
<input
type="text"
value={recommendation}
onChange={(e) => updateAt(index, e.target.value)}
placeholder={placeholder}
disabled={disabled}
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
/>
<button
type="button"
disabled={disabled}
onClick={() => removeLine(index)}
className="shrink-0 rounded-full p-0.5 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-40"
aria-label="Remove recommendation"
>
<X className="h-4 w-4" />
</button>
</div>
))}
{recommendations.length === 0 && (
<div className="px-3 py-2 text-sm text-muted-foreground italic">
No recommendations added yet.
</div>
)}
<div className="flex items-center justify-between px-3 py-2 bg-muted/30">
<Button
type="button"
variant="ghost"
size="sm"
disabled={disabled}
onClick={addLine}
className="h-7 gap-1.5 text-xs text-primary hover:text-primary"
>
<Plus className="h-3.5 w-3.5" />
New Line
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="default"
size="icon"
disabled={disabled}
onClick={() => setSheetOpen(true)}
className="h-8 w-8 rounded-full"
aria-label="Pick from Shop Recommendations"
>
<RefreshCcw className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">Shop Recommendations</TooltipContent>
</Tooltip>
</div>
</div>
{error && <FieldError>{error.message}</FieldError>}
{description && !error && (
<p className="text-xs text-muted-foreground">{description}</p>
)}
<ShopRecommendationsSheet
open={sheetOpen}
onOpenChange={setSheetOpen}
selected={recommendations}
onToggle={toggleRecommendation}
/>
</Field>
)
}
@@ -23,6 +23,7 @@ import {
type ExpenseFormValues,
} from "./expense.schema"
import { EXPENSE_ROUTES, JOB_CARD_ROUTES, VENDOR_ROUTES, DEPARTMENT_ROUTES } from "@garage/api"
import { getFullName } from "@/shared/utils/getFullName"
// ── Constants ──
@@ -158,7 +159,7 @@ export function ExpenseForm({ resourceId, initialData, onSuccess }: ExpenseFormP
placeholder="Select vendor"
queryKey={[VENDOR_ROUTES.INDEX]}
listFn={() => api.vendors.list()}
mapOption={mapLookupOption}
mapOption={(op: any) => ({ value: String(op.id), label: getFullName(op)})}
{...STORE_OBJECT}
/>
<RhfAsyncSelectField
@@ -0,0 +1,33 @@
"use client"
import { CrudDialog } from "@/shared/components/crud-dialog"
import { ColumnHeader } from "@/shared/data-view/table-view"
import { useAuthApi } from "@/shared/useApi"
import { INSURANCE_TYPE_ROUTES } from "@garage/api"
import { InsuranceTypeForm } from "./insurance-type-form"
export function InsuranceTypeCrudDialog() {
const api = useAuthApi()
return (
<CrudDialog
title="Insurance Type"
queryKey={[INSURANCE_TYPE_ROUTES.INDEX]}
getClient={() => api.insuranceTypes}
resourceLabel="insurance type"
columns={() => [
{
accessorKey: "name",
header: ({ column }) => <ColumnHeader column={column} title="Name" />,
},
]}
renderForm={({ resourceId, initialData, onSuccess }) => (
<InsuranceTypeForm
resourceId={resourceId}
initialData={initialData}
onSuccess={onSuccess}
/>
)}
/>
)
}
@@ -0,0 +1,87 @@
"use client"
import { z } from "zod"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { Plus, Save } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { FieldGroup } from "@/shared/components/ui/field"
import { Rhform, RhfTextField } from "@/shared/components/form"
import { toast } from "sonner"
import { useAuthApi } from "@/shared/useApi"
import { useEffect } from "react"
// ── Schema ──
const insuranceTypeSchema = z.object({
name: z.string().min(1, "Name is required"),
})
type InsuranceTypeFormValues = z.infer<typeof insuranceTypeSchema>
// ── Props ──
type InsuranceTypeFormProps = {
resourceId?: string | null
initialData?: any
onSuccess?: () => void
}
// ── Component ──
export function InsuranceTypeForm({ resourceId, initialData, onSuccess }: InsuranceTypeFormProps) {
const api = useAuthApi()
const isEditing = !!resourceId
const form = useForm<InsuranceTypeFormValues>({
resolver: zodResolver(insuranceTypeSchema),
defaultValues: { name: "" },
})
// Pre-fill when editing
useEffect(() => {
if (initialData) {
const d = initialData?.data ?? initialData
form.reset({ name: d.name ?? "" })
}
}, [initialData, form])
const handleSubmit = async (values: InsuranceTypeFormValues) => {
try {
const promise = isEditing
? api.insuranceTypes.update(resourceId!, { title: values.name } as any)
: api.insuranceTypes.create({ title: values.name } as any)
toast.promise(promise, {
loading: isEditing ? "Updating..." : "Creating...",
success: isEditing ? "Updated successfully" : "Created successfully",
error: isEditing ? "Failed to update" : "Failed to create",
})
await promise
form.reset()
onSuccess?.()
} catch {
// toast already shown
}
}
return (
<Rhform form={form} onSubmit={handleSubmit}>
<FieldGroup>
<RhfTextField
name="name"
label="Name"
placeholder="e.g. Comprehensive"
required
/>
<Button type="submit" disabled={form.formState.isSubmitting}>
{isEditing ? <Save className="h-4 w-4" /> : <Plus className="h-4 w-4" />}
{form.formState.isSubmitting
? (isEditing ? "Updating..." : "Creating...")
: (isEditing ? "Update" : "Create")}
</Button>
</FieldGroup>
</Rhform>
)
}
@@ -27,6 +27,7 @@ type JobCardActionsProps = {
orderDate?: string | null
serviceWriterName?: string | null
salesPersonName?: string | null
primaryTechnicianName?: string | null
}
// ── Informative Action Card ──
@@ -104,13 +105,13 @@ function EmployeePickerDialog({
// ── Main Component ──
export function JobCardActions({ jobCardId, orderDate, serviceWriterName, salesPersonName }: JobCardActionsProps) {
export function JobCardActions({ jobCardId, orderDate, serviceWriterName, salesPersonName, primaryTechnicianName }: JobCardActionsProps) {
const api = useAuthApi()
const router = useRouter()
const [datePickerOpen, setDatePickerOpen] = useState(false)
const [serviceWriterDialogOpen, setServiceWriterDialogOpen] = useState(false)
const [salesPersonDialogOpen, setSalesPersonDialogOpen] = useState(false)
const [primaryTechnicianDialogOpen, setPrimaryTechnicianDialogOpen] = useState(false)
const changeDateMutation = useMutation({
mutationFn: (date: Date) => {
@@ -161,6 +162,8 @@ export function JobCardActions({ jobCardId, orderDate, serviceWriterName, salesP
},
})
return (
<div className="flex flex-wrap items-stretch gap-2">
{/* Check-in Date Action Card */}
@@ -213,6 +216,19 @@ export function JobCardActions({ jobCardId, orderDate, serviceWriterName, salesP
isPending={changeSalesPersonMutation.isPending}
/>
</button>
{/* Primary Technician Action Card */}
<button
type="button"
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-lg"
onClick={() => setPrimaryTechnicianDialogOpen(true)}
>
<ActionCard
icon={UserCheck}
label="Primary Technician"
value={primaryTechnicianName ?? null}
isPending={false}
/>
</button>
{/* Edit / Delete Dropdown */}
@@ -234,6 +250,24 @@ export function JobCardActions({ jobCardId, orderDate, serviceWriterName, salesP
isPending={changeSalesPersonMutation.isPending}
onSelect={(id) => changeSalesPersonMutation.mutate(id)}
/>
<EmployeePickerDialog
open={salesPersonDialogOpen}
onOpenChange={setSalesPersonDialogOpen}
title="Change Sales Person"
description="Search and select an employee to assign as sales person."
isPending={changeSalesPersonMutation.isPending}
onSelect={(id) => changeSalesPersonMutation.mutate(id)}
/>
<EmployeePickerDialog
open={primaryTechnicianDialogOpen}
onOpenChange={setPrimaryTechnicianDialogOpen}
title="Change Primary Technician"
description="Search and select an employee to assign as primary technician."
isPending={false}
onSelect={()=>{}}
// onSelect={(id) => changePrimaryTechnicianMutation.mutate(id)}
/>
</div>
)
}
@@ -2,16 +2,16 @@
import { createContext, useContext, useState, useCallback } from "react"
import type { JobCardStatus } from "./job-card.schema"
import { JobCardResponseData } from "@garage/api"
import { JobCardShowData } from "../../../../packages/api/src/clients/job-cards"
const JobCardContext = createContext<JobCardResponseData | null>(null)
const JobCardContext = createContext<JobCardShowData | null>(null)
export function JobCardProvider({
jobCard,
children,
}: {
jobCard: JobCardResponseData
jobCard: JobCardShowData
children: React.ReactNode
}) {
const [status, setStatusState] = useState<JobCardStatus>(jobCard.status as JobCardStatus)
@@ -21,7 +21,7 @@ export function JobCardProvider({
}, [])
return (
<JobCardContext.Provider value={{ ...jobCard, status, setStatus } as JobCardResponseData}>
<JobCardContext.Provider value={{ ...jobCard, status, setStatus } as JobCardShowData}>
{children}
</JobCardContext.Provider>
)
@@ -4,14 +4,16 @@ import { AlertTriangle, Plus, Save } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { Alert, AlertTitle } from "@/shared/components/ui/alert"
import { FieldGroup } from "@/shared/components/ui/field"
import {
Rhform,
RhfTextField,
RhfTextareaField,
RhfCheckboxField,
RhfSelectField,
RhfAsyncSelectField,
RhfDateField,
RhfTimeField,
RhfAutoGenerateField,
} from "@/shared/components/form"
import { toast } from "sonner"
import { useAuthApi } from "@/shared/useApi"
@@ -22,16 +24,15 @@ import { toRelation, toId } from "@/shared/lib/utils"
import {
jobCardFormSchema,
type JobCardFormValues,
TAX_INCLUSIVE_OPTIONS,
DISCOUNT_TYPE_OPTIONS,
DISCOUNT_AT_OPTIONS,
ESTIMATE_TO_OPTIONS,
FUEL_LEVEL_OPTIONS,
JOB_CARD_STATUS_OPTIONS,
} from "./job-card.schema"
import { JOB_CARD_ROUTES, EMPLOYEE_ROUTES, DEPARTMENT_ROUTES } from "@garage/api"
import { JOB_CARD_ROUTES, EMPLOYEE_ROUTES, DEPARTMENT_ROUTES, INSURANCE_TYPE_ROUTES } from "@garage/api"
import { RhfCustomerSelectField } from "@/modules/customers/rhf-customer-select-field"
import { RhfVehicleSelectField } from "@/modules/vehicles/rhf-vehicle-select-field"
import { RhfLabelPickerField } from "@/modules/labels/rhf-label-picker-field"
import { RhfCustomerRemarksField } from "@/modules/estimates/rhf-customer-remarks-field"
import { InsuranceTypeCrudDialog } from "./insurance-type-crud-dialog"
// ── Props ──
@@ -49,15 +50,32 @@ const DEFAULT_VALUES: JobCardFormValues = {
vehicle: null,
department: null,
service_writer: null,
primary_technician: null,
sales_person: null,
insurance_type: null,
insurer: null,
order_number: "",
estimate_number: "",
status: "check_in",
estimate_to: "Customer",
tax_inclusive: "Tax Inclusive",
discount_type: "no",
discount_at: "inclusive_of_tax",
// check_in_date: "",
// check_in_time: (() => { const n = new Date(); return `${String(n.getHours()).padStart(2,"0")}:${String(n.getMinutes()).padStart(2,"0")}:${String(n.getSeconds()).padStart(2,"0")}` })(),
order_date: new Date().toISOString().split("T")[0],
check_in_date: "",
check_in_time: "",
start_date: "",
start_time: "",
delivery_date: "",
delivery_time: "",
km_in: "",
fuel_level: "",
has_insurance: false,
enable_parts_issuing: false,
enable_digital_authorisation: false,
footer: "",
customer_remarks: [],
labels: [],
}
// ── Mapping helpers ──
@@ -65,21 +83,56 @@ const DEFAULT_VALUES: JobCardFormValues = {
function mapToFormValues(data: unknown): JobCardFormValues {
const d = (data as any)?.data ?? data ?? {}
const mapTime = (v: unknown): string => {
if (!v || typeof v !== "string") return ""
if (v.includes("T")) return v.split("T")[1]?.slice(0, 8) ?? ""
return v
}
const mapDate = (v: unknown): string => {
if (!v || typeof v !== "string") return ""
return v.split("T")[0]
}
return {
title: d.title || "",
customer: toRelation(d.customer_id, d.customer ? `${d.customer.first_name} ${d.customer.last_name}` : undefined),
vehicle: toRelation(d.vehicle_id, d.vehicle ? `${d.vehicle.make} ${d.vehicle.model}` : undefined),
vehicle: toRelation(d.vehicle_id, d.vehicle ? (d.vehicle.plate_number ?? `${d.vehicle.make ?? ""} ${d.vehicle.model ?? ""}`.trim()) : undefined),
department: toRelation(d.department_id, d.department?.name),
service_writer: toRelation(d.service_writer_id, d.service_writer ? `${d.service_writer.first_name} ${d.service_writer.last_name}` : undefined),
primary_technician: toRelation(d.primary_technician_id, d.primary_technician ? `${d.primary_technician.first_name} ${d.primary_technician.last_name}` : undefined),
sales_person: toRelation(d.sales_person_id, d.sales_person ? `${d.sales_person.first_name} ${d.sales_person.last_name}` : undefined),
insurance_type: toRelation(d.insurance_type_id, d.insurance_type?.name),
insurer: toRelation(d.insurer_id, d.insurer ? `${d.insurer.first_name} ${d.insurer.last_name}`.trim() || d.insurer.company_name : undefined),
order_number: d.order_number || "",
estimate_number: d.estimate_number || "",
status: d.status || "draft",
estimate_to: d.estimate_to || "Customer",
tax_inclusive: d.tax_inclusive || "Tax Inclusive",
discount_type: d.discount_type || "no",
discount_at: d.discount_at || "inclusive_of_tax",
check_in_date: d.check_in_date ? d.check_in_date.split("T")[0] : "",
check_in_time: d.check_in_time ? d.check_in_time.split("T")[0] : "",
order_date: mapDate(d.order_date),
check_in_date: mapDate(d.check_in_date),
check_in_time: mapTime(d.check_in_time),
start_date: mapDate(d.start_date),
start_time: mapTime(d.start_time),
delivery_date: mapDate(d.delivery_date),
delivery_time: mapTime(d.delivery_time),
km_in: d.km_in != null ? String(d.km_in) : "",
fuel_level: d.fuel_level || "",
has_insurance: d.has_insurance ?? false,
enable_parts_issuing: d.enable_parts_issuing ?? false,
enable_digital_authorisation: d.enable_digital_authorisation ?? false,
footer: d.footer || "",
customer_remarks: Array.isArray(d.customer_remarks)
? d.customer_remarks.map((r: any) =>
typeof r === "string" ? r : (r?.remark ?? "")
).filter(Boolean)
: [],
labels: (d.labels ?? []).map((l: any) => ({
id: l.id,
title: l.title,
color_code: l.color_code,
})),
}
}
@@ -90,15 +143,32 @@ function mapFormToPayload(values: JobCardFormValues) {
vehicle_id: toId(values.vehicle),
department_id: toId(values.department),
service_writer_id: toId(values.service_writer),
primary_technician_id: toId(values.primary_technician),
sales_person_id: toId(values.sales_person),
insurance_type_id: values.insurance_type ? String(toId(values.insurance_type)) : null,
insurer_id: values.insurer ? String(toId(values.insurer)) : null,
estimate_number: values.estimate_number || undefined,
order_number: values.order_number || undefined,
status: values.status || undefined,
estimate_to: values.estimate_to || undefined,
tax_inclusive: values.tax_inclusive || undefined,
discount_type: values.discount_type || undefined,
discount_at: values.discount_at || undefined,
order_date: values.order_date || undefined,
check_in_date: values.check_in_date || undefined,
check_in_time: values.check_in_time || undefined,
start_date: values.start_date || undefined,
start_time: values.start_time || undefined,
delivery_date: values.delivery_date || undefined,
delivery_time: values.delivery_time || undefined,
km_in: values.km_in ? Number(values.km_in) : undefined,
fuel_level: values.fuel_level || undefined,
has_insurance: values.has_insurance,
enable_parts_issuing: values.enable_parts_issuing,
enable_digital_authorisation: values.enable_digital_authorisation,
footer: values.footer || undefined,
customer_remarks: values.customer_remarks?.filter(Boolean) ?? [],
label_ids: values.labels?.map((l) => l.id),
}
}
@@ -130,6 +200,10 @@ export function JobCardForm({ resourceId, initialData, onSuccess }: JobCardFormP
mapToFormValues,
})
const hasInsurance = form.watch("has_insurance")
const status = form.watch("status")
const isCheckIn = status === "check_in"
const { mutate, error, isPending } = useFormMutation(form, {
mutationFn: (values: JobCardFormValues) => {
const payload = mapFormToPayload(values)
@@ -161,93 +235,144 @@ export function JobCardForm({ resourceId, initialData, onSuccess }: JobCardFormP
</Alert>
)}
<FieldGroup>
<RhfTextField name="title" label="Title" placeholder="Job Card 001" required />
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_360px]">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfCustomerSelectField name="customer" />
<RhfVehicleSelectField name="vehicle" />
{/* ── Left column ── */}
<div className="space-y-4">
<RhfLabelPickerField name="labels" label="Labels" />
<RhfTextField name="title" label="Title" placeholder="Job Card 001" required />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfCustomerSelectField name="customer" />
<RhfVehicleSelectField name="vehicle" />
</div>
<RhfCheckboxField name="has_insurance" label="Has Insurance Work?" />
{hasInsurance && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfCustomerSelectField
name="insurer"
label="Insurer"
placeholder="Search insurer..."
customerType="Insurer"
/>
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium">Insurance Type</span>
<InsuranceTypeCrudDialog />
</div>
<RhfAsyncSelectField
name="insurance_type"
label=""
placeholder="Select insurance type"
queryKey={[INSURANCE_TYPE_ROUTES.INDEX]}
listFn={() => api.insuranceTypes.list()}
mapOption={mapLookupOption}
{...STORE_OBJECT}
/>
</div>
</div>
)}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
<RhfDateField name="order_date" label="Order Date" />
<RhfAutoGenerateField autoFetch name="order_number" label="Order#" placeholder="ORD-001" table="job_cards" />
<RhfAutoGenerateField autoFetch name="estimate_number" label="Estimate#" placeholder="EST-001" table="estimates" />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfAsyncSelectField
name="service_writer"
label="Service Writer"
placeholder="Select service writer"
queryKey={[EMPLOYEE_ROUTES.INDEX]}
listFn={() => api.employees.list()}
mapOption={mapEmployeeOption}
{...STORE_OBJECT}
/>
<RhfAsyncSelectField
name="primary_technician"
label="Primary Technician"
placeholder="Select technician"
queryKey={[EMPLOYEE_ROUTES.INDEX]}
listFn={() => api.employees.list()}
mapOption={mapEmployeeOption}
{...STORE_OBJECT}
/>
</div>
{/* ── Check-in Details (shown when status is check_in) ── */}
{isCheckIn && (
<div className="space-y-4 rounded-lg border p-4">
<p className="text-sm font-semibold">Check In Details</p>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfDateField name="check_in_date" label="Check In Date" />
<RhfTimeField name="check_in_time" label="Check In Time" />
</div>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<RhfTextField name="km_in" label="KMs IN" placeholder="0" type="number" />
<RhfSelectField
name="fuel_level"
label="Fuel Level"
placeholder="Select"
options={FUEL_LEVEL_OPTIONS}
/>
<RhfDateField name="start_date" label="Start Date" />
<RhfTimeField name="start_time" label="Start Time" />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-[1fr_1fr_1fr]">
<RhfAsyncSelectField
name="department"
label="Department"
placeholder="Select department"
queryKey={[DEPARTMENT_ROUTES.INDEX]}
listFn={() => api.departments.list()}
mapOption={mapLookupOption}
{...STORE_OBJECT}
/>
<RhfDateField name="delivery_date" label="Delivery Date" />
<RhfTimeField name="delivery_time" label="Delivery Time" />
</div>
</div>
)}
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{/* <RhfSelectField
{/* ── Right column ── */}
<div className="space-y-4">
<RhfSelectField
name="status"
label="Status"
placeholder="Select status"
options={JOB_CARD_STATUS_OPTIONS}
/> */}
<RhfSelectField
name="estimate_to"
label="Estimate To"
placeholder="Select estimate to"
options={ESTIMATE_TO_OPTIONS}
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfAsyncSelectField
name="department"
label="Department"
placeholder="Select department"
queryKey={[DEPARTMENT_ROUTES.INDEX]}
listFn={() => api.departments.list()}
mapOption={mapLookupOption}
{...STORE_OBJECT}
/>
<RhfAsyncSelectField
name="service_writer"
label="Service Writer"
placeholder="Select service writer"
queryKey={[EMPLOYEE_ROUTES.INDEX]}
listFn={() => api.employees.list()}
mapOption={mapEmployeeOption}
{...STORE_OBJECT}
/>
</div>
{/* <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfDateField name="check_in_date" label="Check-in Date" />
<RhfTimeField name="check_in_time" label="Check-in Time" withSeconds />
</div> */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField name="km_in" label="KM In" placeholder="50000" type="number" />
<RhfSelectField
name="fuel_level"
label="Fuel Level"
placeholder="Select fuel level"
options={FUEL_LEVEL_OPTIONS}
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<RhfSelectField
name="tax_inclusive"
label="Tax"
placeholder="Select tax type"
options={TAX_INCLUSIVE_OPTIONS}
/>
<RhfSelectField
name="discount_type"
label="Discount Type"
placeholder="Select discount type"
options={DISCOUNT_TYPE_OPTIONS}
/>
<RhfSelectField
name="discount_at"
label="Discount At"
placeholder="Select discount at"
options={DISCOUNT_AT_OPTIONS}
/>
<RhfCustomerRemarksField name="customer_remarks" />
<RhfTextareaField name="footer" label="Estimate Footer" placeholder="Thank you for your business." rows={6} />
<RhfCheckboxField
name="enable_parts_issuing"
label="Enable Parts Issuing"
description="When off, parts will be auto issued upon authorization. When on, manually request, reserve, and issue parts."
/>
<RhfCheckboxField
name="enable_digital_authorisation"
label="Enable Digital Authorisation"
description="When off, items will be auto authorised. When on, customer approval is required before proceeding."
/>
</div>
</div>
<div className="mt-6">
<Button type="submit" variant="default" disabled={isPending}>
{isEditing ? <Save /> : <Plus />}
{isPending
? (isEditing ? "Updating..." : "Creating...")
: (isEditing ? "Update Job Card" : "Create Job Card")}
</Button>
</FieldGroup>
</div>
</Rhform>
)
}
@@ -4,7 +4,6 @@ import {
Hash,
Users,
Car,
Building2,
Gauge,
Clock,
UserCheck,
@@ -21,15 +20,19 @@ import {
import { Badge } from "@/shared/components/ui/badge"
import { Separator } from "@/shared/components/ui/separator"
import { JobCardActions } from "./job-card-actions"
import { JobCardRemarksList } from "./job-card-remarks-list"
import { JobCardRecommendationsList } from "./job-card-recommendations-list"
import { getVehicleLabel } from "../vehicles/utils/getVehicleLabel"
import { JobCardResponseData } from "@garage/api"
import { getFullName } from "@/shared/utils/getFullName"
import { CrudShowResponse, JobCardsClient, PAYMENT_RECEIVED_ROUTES, PaymentReceivedClient } from "@garage/api"
import { ResourcePage } from "@/shared/data-view/resource-page"
import PaymentReceivedPage from "@/app/(authenticated)/sales/payment-received/page"
import JobCardPaymentsReceived from "./job-card-payments-received"
type JobCardGeneralInfoProps = {
jobCard: JobCardResponseData
}
type JobCard = NonNullable<CrudShowResponse<JobCardsClient>['data']>
function InfoItem({
icon: Icon,
@@ -64,7 +67,7 @@ const statusColorMap: Record<string, string> = {
cancelled: "destructive",
}
export function JobCardGeneralInfo({ jobCard }: { jobCard: JobCardResponseData }) {
export function JobCardGeneralInfo({ jobCard }: { jobCard: JobCard }) {
const formatStatus = (status?: string) => {
if (!status) return null
return status
@@ -73,163 +76,183 @@ export function JobCardGeneralInfo({ jobCard }: { jobCard: JobCardResponseData }
.join(" ")
}
console.log(jobCard)
return (
<div className="flex flex-col gap-6">
{jobCard.service_writer?.first_name}
<JobCardActions
jobCardId={String(jobCard.id)}
jobCardId={String(jobCard)}
orderDate={jobCard.order_date ?? null}
serviceWriterName={jobCard.service_writer?.first_name}
salesPersonName={jobCard.sales_person?.first_name}
primaryTechnicianName={jobCard.primary_technician?.first_name}
/>
<div className="grid gap-6 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ClipboardList className="size-4" />
Job Card Details
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4">
<div className="flex flex-wrap items-center gap-2">
{jobCard.title && (
<Badge variant="secondary">{jobCard.title}</Badge>
)}
{jobCard.status && (
<Badge variant={statusColorMap[jobCard.status] as any ?? "outline"}>
{formatStatus(jobCard.status)}
</Badge>
)}
</div>
<Separator />
<div className="grid gap-4 sm:grid-cols-2">
<InfoItem
icon={Calendar}
label="Check-in Date"
value={jobCard.check_in_date}
/>
<InfoItem
icon={Gauge}
label="KM In"
value={jobCard.km_in ? Number(jobCard.km_in).toLocaleString() : null}
/>
<InfoItem
icon={Clock}
label="Created"
value={jobCard.created_at ? new Date(jobCard.created_at).toLocaleDateString() : null}
/>
<InfoItem
icon={Clock}
label="Updated"
value={jobCard.updated_at ? new Date(jobCard.updated_at).toLocaleDateString() : null}
/>
</div>
</CardContent>
</Card>
{/* Related Information */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="size-4" />
Related Information
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4">
<div className="grid gap-4 sm:grid-cols-2">
<InfoItem
icon={Users}
label="Customer"
value={getFullName(jobCard.customer)}
/>
<InfoItem
icon={Car}
label="Vehicle"
value={getVehicleLabel(jobCard.vehicle as any)}
/>
<InfoItem
<div className="grid gap-6 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ClipboardList className="size-4" />
Job Card Details
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4">
<div className="flex flex-wrap items-center gap-2">
{jobCard.title && (
<Badge variant="secondary">{jobCard.title}</Badge>
)}
{jobCard.status && (
<Badge variant={statusColorMap[jobCard.status] as any ?? "outline"}>
{formatStatus(jobCard.status)}
</Badge>
)}
</div>
<Separator />
<div className="grid gap-4 sm:grid-cols-2">
<InfoItem
icon={Calendar}
label="Check-in Date"
value={jobCard.check_in_date}
/>
<InfoItem
icon={Gauge}
label="KM In"
value={jobCard.km_in ? Number(jobCard.km_in).toLocaleString() : null}
/>
<InfoItem
icon={Clock}
label="Created"
value={jobCard.created_at ? new Date(jobCard.created_at).toLocaleDateString() : null}
/>
<InfoItem
icon={Clock}
label="Updated"
value={jobCard.updated_at ? new Date(jobCard.updated_at).toLocaleDateString() : null}
/>
</div>
</CardContent>
</Card>
{/* Related Information */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="size-4" />
Related Information
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4">
<div className="grid gap-4 sm:grid-cols-2">
<InfoItem
icon={Users}
label="Customer"
value={getFullName(jobCard?.customer)}
/>
<InfoItem
icon={Car}
label="Vehicle"
value={getVehicleLabel(jobCard?.vehicle as any)}
/>
{/* <InfoItem
icon={Building2}
label="Department"
value={jobCard.department as any}
value={jobCard?.department?.name as any}
/> */}
<InfoItem
icon={Briefcase}
label="Sales Person"
value={getFullName(jobCard.sales_person as any)}
/>
<InfoItem
icon={UserCheck}
label="Service Writer"
value={getFullName(jobCard?.service_writer as any)}
/>
</div>
</CardContent>
</Card>
{/* Tax & Discount Settings */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<DollarSign className="size-4" />
Tax & Discount Settings
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
<InfoItem
icon={Receipt}
label="Tax Inclusive"
value={jobCard.tax_inclusive}
/>
<InfoItem
icon={Briefcase}
label="Sales Person"
value={getFullName(jobCard.sales_person as any)}
icon={DollarSign}
label="Discount Type"
value={formatStatus(jobCard.discount_type)}
/>
<InfoItem
icon={UserCheck}
label="Service Writer"
value={getFullName(jobCard.service_writer as any)}
icon={DollarSign}
label="Discount At"
value={formatStatus(jobCard.discount_at)}
/>
</div>
</CardContent>
</Card>
</CardContent>
</Card>
{/* Tax & Discount Settings */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<DollarSign className="size-4" />
Tax & Discount Settings
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
<InfoItem
icon={Receipt}
label="Tax Inclusive"
value={jobCard.tax_inclusive}
/>
<InfoItem
icon={DollarSign}
label="Discount Type"
value={formatStatus(jobCard.discount_type)}
/>
<InfoItem
icon={DollarSign}
label="Discount At"
value={formatStatus(jobCard.discount_at)}
/>
</CardContent>
</Card>
{/* Counts */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Hash className="size-4" />
Related Counts
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
<InfoItem
icon={Receipt}
label="Purchase Orders"
value={String(jobCard.purchase_orders_count ?? 0)}
/>
<InfoItem
icon={Receipt}
label="Bills"
value={String(jobCard.bills_count ?? 0)}
/>
<InfoItem
icon={DollarSign}
label="Expenses"
value={String(jobCard.expenses_count ?? 0)}
/>
<InfoItem
icon={ClipboardList}
label="Tasks"
value={String(jobCard.tasks_count ?? 0)}
/>
<InfoItem
icon={Calendar}
label="Appointments"
value={String(jobCard.appointments_count ?? 0)}
/>
</CardContent>
</Card>
</div>
{/* Counts */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Hash className="size-4" />
Related Counts
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
<InfoItem
icon={Receipt}
label="Purchase Orders"
value={String(jobCard.purchase_orders_count ?? 0)}
/>
<InfoItem
icon={Receipt}
label="Bills"
value={String(jobCard.bills_count ?? 0)}
/>
<InfoItem
icon={DollarSign}
label="Expenses"
value={String(jobCard.expenses_count ?? 0)}
/>
<InfoItem
icon={ClipboardList}
label="Tasks"
value={String(jobCard.tasks_count ?? 0)}
/>
<InfoItem
icon={Calendar}
label="Appointments"
value={String(jobCard.appointments_count ?? 0)}
/>
</CardContent>
</Card>
<JobCardPaymentsReceived />
<div className="grid gap-6 md:grid-cols-2">
<JobCardRemarksList
jobCardId={String(jobCard.id)}
remarks={(jobCard as any).customer_remarks ?? []}
/>
<JobCardRecommendationsList
jobCardId={String(jobCard.id)}
recommendations={(jobCard as any).shop_recommendations ?? []}
/>
</div>
</div>
)
@@ -0,0 +1,244 @@
"use client"
import React from "react"
import { AlertTriangle, Plus, Save } from "lucide-react"
import { z } from "zod"
import { Button } from "@/shared/components/ui/button"
import { Alert, AlertTitle } from "@/shared/components/ui/alert"
import { FieldGroup } from "@/shared/components/ui/field"
import {
Rhform,
RhfTextField,
RhfTextareaField,
RhfAsyncSelectField,
} from "@/shared/components/form"
import { DepartmentInlineForm } from "@/modules/services/inline-forms/department-inline-form"
import { toast } from "sonner"
import { useAuthApi } from "@/shared/useApi"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
// ── Schema ──
const jobCardPartFormSchema = z.object({
part: z
.object({ value: z.string(), label: z.string() })
.nullable(),
department: z
.object({ value: z.string(), label: z.string() })
.nullable()
.optional(),
quantity: z.coerce.number().min(1, "Quantity is required"),
rate: z.coerce.number().min(0, "Rate is required"),
tax: z.string().optional(),
description: z.string().optional(),
})
type JobCardPartFormValues = z.infer<typeof jobCardPartFormSchema>
// ── Props ──
export type JobCardPartFormProps = {
jobCardId: string
jobCardPartId?: number | null
initialData?: unknown
onSuccess?: () => void
onCancel?: () => void
}
const DEFAULT_VALUES: JobCardPartFormValues = {
part: null,
department: null,
quantity: 1,
rate: 0,
tax: "",
description: "",
}
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
function mapToFormValues(data: unknown): JobCardPartFormValues {
const d = (data as any) ?? {}
return {
part: d.part
? { value: String(d.part.id), label: d.part.title ?? String(d.part.id) }
: null,
department: d.department
? { value: String(d.department.id), label: d.department.name ?? String(d.department.id) }
: null,
quantity: d.quantity ?? 1,
rate: d.rate != null ? Number(d.rate) : 0,
tax: d.tax ?? "",
description: d.description ?? "",
}
}
// ── Component ──
export function JobCardPartForm({
jobCardId,
jobCardPartId,
initialData,
onSuccess,
onCancel,
}: JobCardPartFormProps) {
const api = useAuthApi()
const isEditing = !!jobCardPartId
const form = useForm<JobCardPartFormValues>({
resolver: zodResolver(jobCardPartFormSchema) as any,
defaultValues: initialData
? mapToFormValues(initialData)
: DEFAULT_VALUES,
})
const [error, setError] = React.useState<string | null>(null)
const [isPending, setIsPending] = React.useState(false)
async function handleSubmit(values: JobCardPartFormValues) {
setError(null)
setIsPending(true)
try {
if (isEditing && jobCardPartId) {
await toast.promise(
api.jobCards.updatePart(jobCardId, {
job_card_part_id: jobCardPartId,
quantity: values.quantity,
rate: values.rate,
description: values.description || undefined,
}),
{
loading: "Updating part...",
success: "Part updated successfully",
error: "Failed to update part",
}
)
} else {
await toast.promise(
api.jobCards.addPart(jobCardId, {
part_id: values.part ? Number(values.part.value) : undefined,
department_id: values.department ? Number(values.department.value) : undefined,
quantity: values.quantity,
rate: values.rate,
tax: values.tax || undefined,
description: values.description || undefined,
}),
{
loading: "Adding part...",
success: "Part added successfully",
error: "Failed to add part",
}
)
}
form.reset()
onSuccess?.()
} catch (err: any) {
setError(err?.message ?? "An unexpected error occurred")
} finally {
setIsPending(false)
}
}
return (
<Rhform form={form} onSubmit={handleSubmit}>
{error && (
<Alert variant="destructive" className="mb-4">
<AlertTriangle className="me-2 h-4 w-4" />
<AlertTitle>
{isEditing ? "Failed to update part" : "Failed to add part"}
</AlertTitle>
{error}
</Alert>
)}
<FieldGroup>
{!isEditing && (
<RhfAsyncSelectField
name="part"
label="Part"
placeholder="Select part"
required
queryKey={["parts"]}
listFn={() => api.parts.list()}
mapOption={(item: any) => ({
value: String(item.id),
label: item.title ?? String(item.id),
})}
{...STORE_OBJECT}
/>
)}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField
name="quantity"
label="Quantity"
type="number"
placeholder="1"
required
/>
<RhfTextField
name="rate"
label="Rate"
type="number"
placeholder="0.00"
required
/>
</div>
{!isEditing && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfAsyncSelectField
name="department"
label="Department"
placeholder="Select department"
queryKey={["departments"]}
listFn={() => api.departments.list()}
mapOption={(item: any) => ({
value: String(item.id),
label: item.name ?? String(item.id),
})}
createForm={(props) => <DepartmentInlineForm {...props} />}
createLabel="Department"
{...STORE_OBJECT}
/>
<RhfTextField
name="tax"
label="Tax"
placeholder="e.g. 5%"
/>
</div>
)}
<RhfTextareaField
name="description"
label="Description"
placeholder="Optional notes"
/>
</FieldGroup>
<div className="flex justify-end gap-2 pt-2">
{onCancel && (
<Button type="button" variant="outline" onClick={onCancel}>
Cancel
</Button>
)}
<Button type="submit" disabled={isPending}>
{isPending ? (
isEditing ? "Saving..." : "Adding..."
) : isEditing ? (
<>
<Save className="me-2 h-4 w-4" />
Save Changes
</>
) : (
<>
<Plus className="me-2 h-4 w-4" />
Add Part
</>
)}
</Button>
</div>
</Rhform>
)
}
@@ -0,0 +1,134 @@
"use client"
import { CrudResource } from "@/shared/data-view/resource-page"
import { ColumnHeader } from "@/shared/data-view/table-view"
import FormDialog from "@/shared/components/form-dialog"
import { PaymentReceivedForm } from "@/modules/payment-received/payment-received-form"
import { PAYMENT_RECEIVED_ROUTES, PaymentReceivedClient } from "@garage/api"
import {
BadgeDollarSignIcon,
CalendarIcon,
ChevronDown,
CreditCardIcon,
HashIcon,
} from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/shared/components/ui/collapsible"
import { Button } from "@/shared/components/ui/button"
import { useJobCard } from "./job-card-context"
import { formatDate, formatCurrency } from "@/shared/utils/formatters"
export default function JobCardPaymentsReceived() {
const jobCard = useJobCard()
return (
<Collapsible defaultOpen={false} className="group/collapsible">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-semibold">Payments Received</CardTitle>
<CollapsibleTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7">
<ChevronDown className="h-4 w-4 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-180" />
</Button>
</CollapsibleTrigger>
</div>
</CardHeader>
<CollapsibleContent>
<CardContent className="pt-0">
<CrudResource<PaymentReceivedClient>
extraParams={{ job_card_id: jobCard?.id }}
routeKey={PAYMENT_RECEIVED_ROUTES.INDEX}
getClient={(api) => api.paymentReceived}
tableHeader={({ invalidateQuery }) =>
<div className="p-2">
<FormDialog title="Record Payment">
{(resourceId) => (
<PaymentReceivedForm
resourceId={resourceId}
defaultJobCard={{ id: jobCard?.id, title: jobCard?.title }}
onSuccess={invalidateQuery}
/>
)}
</FormDialog>
</div>
}
columns={({ actionsColumn }) => [
{
accessorKey: "payment_number",
header: ({ column }) => <ColumnHeader column={column} title="Payment #" />,
cell: ({ row }) => {
const item = row.original
return (
<div className="flex items-center gap-2">
<HashIcon className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{item.payment_number || "—"}</span>
</div>
)
},
},
{
accessorKey: "amount_received",
header: ({ column }) => <ColumnHeader column={column} title="Amount" />,
cell: ({ row }) => {
const item = row.original
return (
<div className="flex items-center gap-2">
<BadgeDollarSignIcon className="h-4 w-4 text-emerald-600" />
<span className="font-semibold text-emerald-700 dark:text-emerald-400">
{formatCurrency(item.amount_received)}
</span>
</div>
)
},
},
{
accessorKey: "payment_mode_name",
header: ({ column }) => <ColumnHeader column={column} title="Payment Mode" />,
cell: ({ row }) => {
const item = row.original as any
return (
<div className="flex items-center gap-2">
<CreditCardIcon className="h-4 w-4 text-muted-foreground" />
<span className="capitalize">{item.payment_mode_name || "—"}</span>
</div>
)
},
},
{
accessorKey: "payment_date",
header: ({ column }) => <ColumnHeader column={column} title="Date" />,
cell: ({ row }) => {
const item = row.original
return (
<div className="flex items-center gap-2">
<CalendarIcon className="h-4 w-4 text-muted-foreground" />
<span>{formatDate(item.payment_date)}</span>
</div>
)
},
},
{
accessorKey: "note",
header: () => <span>Note</span>,
enableSorting: false,
cell: ({ row }) => {
const item = row.original
const note = item.note
if (!note) return <span className="text-muted-foreground">—</span>
return (
<span className="max-w-50 truncate block" title={note}>
{note}
</span>
)
},
},
actionsColumn(),
]}
/>
</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
)
}
@@ -0,0 +1,128 @@
"use client"
import { useState } from "react"
import { useMutation } from "@tanstack/react-query"
import { useRouter } from "next/navigation"
import { Plus, Trash2, Lightbulb } from "lucide-react"
import { toast } from "sonner"
import { useAuthApi } from "@/shared/useApi"
import { confirm } from "@/shared/components/confirm-dialog"
import { Button } from "@/shared/components/ui/button"
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card"
import { Textarea } from "@/shared/components/ui/textarea"
type ShopRecommendation = {
id: number
recommendation: string
created_at?: string
}
type Props = {
jobCardId: string
recommendations: ShopRecommendation[]
}
export function JobCardRecommendationsList({ jobCardId, recommendations }: Props) {
const api = useAuthApi()
const router = useRouter()
const [newRecommendation, setNewRecommendation] = useState("")
const addMutation = useMutation({
mutationFn: (recommendation: string) =>
api.jobCards.addShopRecommendation(jobCardId, { recommendation }),
onSuccess: () => {
toast.success("Shop recommendation added")
setNewRecommendation("")
router.refresh()
},
onError: () => {
toast.error("Failed to add shop recommendation")
},
})
const deleteMutation = useMutation({
mutationFn: (recommendationId: number) =>
api.jobCards.deleteShopRecommendation(jobCardId, recommendationId),
onSuccess: () => {
toast.success("Shop recommendation deleted")
router.refresh()
},
onError: () => {
toast.error("Failed to delete shop recommendation")
},
})
const handleAdd = () => {
const trimmed = newRecommendation.trim()
if (!trimmed) return
addMutation.mutate(trimmed)
}
const handleDelete = async (recommendation: ShopRecommendation) => {
const confirmed = await confirm({
title: "Delete Shop Recommendation",
description: "Are you sure you want to delete this recommendation?",
confirmLabel: "Delete",
variant: "destructive",
})
if (confirmed) {
deleteMutation.mutate(recommendation.id)
}
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Lightbulb className="size-4" />
Shop Recommendations
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{recommendations.length === 0 && (
<p className="text-sm text-muted-foreground">No shop recommendations yet.</p>
)}
{recommendations.map((rec) => (
<div
key={rec.id}
className="flex items-start justify-between gap-3 rounded-lg border px-4 py-3"
>
<p className="text-sm whitespace-pre-wrap">{rec.recommendation}</p>
<Button
variant="ghost"
size="icon"
className="shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => handleDelete(rec)}
disabled={deleteMutation.isPending}
>
<Trash2 className="size-4" />
</Button>
</div>
))}
<div className="flex flex-col gap-2">
<Textarea
placeholder="Add a shop recommendation..."
value={newRecommendation}
onChange={(e) => setNewRecommendation(e.target.value)}
rows={2}
/>
<Button
size="sm"
className="self-end"
onClick={handleAdd}
disabled={addMutation.isPending || !newRecommendation.trim()}
>
<Plus className="size-4" />
{addMutation.isPending ? "Adding..." : "Add Recommendation"}
</Button>
</div>
</CardContent>
</Card>
)
}
@@ -0,0 +1,128 @@
"use client"
import { useState } from "react"
import { useMutation } from "@tanstack/react-query"
import { useRouter } from "next/navigation"
import { Plus, Trash2, MessageSquare } from "lucide-react"
import { toast } from "sonner"
import { useAuthApi } from "@/shared/useApi"
import { confirm } from "@/shared/components/confirm-dialog"
import { Button } from "@/shared/components/ui/button"
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card"
import { Textarea } from "@/shared/components/ui/textarea"
type CustomerRemark = {
id: number
remark: string
created_at?: string
}
type Props = {
jobCardId: string
remarks: CustomerRemark[]
}
export function JobCardRemarksList({ jobCardId, remarks }: Props) {
const api = useAuthApi()
const router = useRouter()
const [newRemark, setNewRemark] = useState("")
const addMutation = useMutation({
mutationFn: (remark: string) =>
api.jobCards.addCustomerRemark(jobCardId, { remark }),
onSuccess: () => {
toast.success("Customer remark added")
setNewRemark("")
router.refresh()
},
onError: () => {
toast.error("Failed to add customer remark")
},
})
const deleteMutation = useMutation({
mutationFn: (remarkId: number) =>
api.jobCards.deleteCustomerRemark(jobCardId, remarkId),
onSuccess: () => {
toast.success("Customer remark deleted")
router.refresh()
},
onError: () => {
toast.error("Failed to delete customer remark")
},
})
const handleAdd = () => {
const trimmed = newRemark.trim()
if (!trimmed) return
addMutation.mutate(trimmed)
}
const handleDelete = async (remark: CustomerRemark) => {
const confirmed = await confirm({
title: "Delete Customer Remark",
description: "Are you sure you want to delete this remark?",
confirmLabel: "Delete",
variant: "destructive",
})
if (confirmed) {
deleteMutation.mutate(remark.id)
}
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MessageSquare className="size-4" />
Customer Remarks
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{remarks.length === 0 && (
<p className="text-sm text-muted-foreground">No customer remarks yet.</p>
)}
{remarks.map((remark) => (
<div
key={remark.id}
className="flex items-start justify-between gap-3 rounded-lg border px-4 py-3"
>
<p className="text-sm whitespace-pre-wrap">{remark.remark}</p>
<Button
variant="ghost"
size="icon"
className="shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => handleDelete(remark)}
disabled={deleteMutation.isPending}
>
<Trash2 className="size-4" />
</Button>
</div>
))}
<div className="flex flex-col gap-2">
<Textarea
placeholder="Add a customer remark..."
value={newRemark}
onChange={(e) => setNewRemark(e.target.value)}
rows={2}
/>
<Button
size="sm"
className="self-end"
onClick={handleAdd}
disabled={addMutation.isPending || !newRemark.trim()}
>
<Plus className="size-4" />
{addMutation.isPending ? "Adding..." : "Add Remark"}
</Button>
</div>
</CardContent>
</Card>
)
}
@@ -55,19 +55,56 @@ const jobCardFormSchema = z.object({
vehicle: relationFieldSchema,
department: relationFieldSchema,
service_writer: relationFieldSchema,
primary_technician: relationFieldSchema,
sales_person: relationFieldSchema,
insurance_type: relationFieldSchema,
insurer: relationFieldSchema,
// ── Settings ──
// ── Numbers & identifiers ──
order_number: z.string().optional(),
estimate_number: z.string().optional(),
// ── Status & settings ──
status: z.string().optional(),
estimate_to: z.string().optional(),
tax_inclusive: z.string().optional(),
discount_type: z.string().optional(),
discount_at: z.string().optional(),
// ── Check-in details ──
// ── Dates & times ──
order_date: z.string().optional(),
check_in_date: z.string().optional(),
check_in_time: z.string().optional(),
start_date: z.string().optional(),
start_time: z.string().optional(),
delivery_date: z.string().optional(),
delivery_time: z.string().optional(),
// ── Vehicle state ──
km_in: z.string().optional(),
fuel_level: z.string().optional(),
// ── Boolean options ──
has_insurance: z.boolean().optional(),
enable_parts_issuing: z.boolean().optional(),
enable_digital_authorisation: z.boolean().optional(),
// ── Notes ──
footer: z.string().optional(),
// ── Customer Remarks ──
customer_remarks: z.array(z.string()).optional(),
// ── Labels ──
labels: z
.array(
z.object({
id: z.number(),
title: z.string(),
color_code: z.string(),
}),
)
.optional(),
})
type JobCardFormValues = z.infer<typeof jobCardFormSchema>
@@ -80,7 +117,6 @@ export {
DISCOUNT_AT_OPTIONS,
ESTIMATE_TO_OPTIONS,
FUEL_LEVEL_OPTIONS,
JOB_CARD_STATUS_OPTIONS,
}
// Backward-compat alias used by job-card-status-stepper
export const JOB_CARD_STATUSES = JOB_CARD_STATUS_OPTIONS
@@ -0,0 +1,337 @@
"use client"
import { useState } from "react"
import {
useFormContext,
useController,
type FieldValues,
type FieldPath,
} from "react-hook-form"
import { useQuery, useQueryClient } from "@tanstack/react-query"
import { Check, PlusIcon, X } from "lucide-react"
import { useAuthApi } from "@/shared/useApi"
import { LABEL_ROUTES } from "@garage/api"
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/components/ui/popover"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import {
Field,
FieldLabel,
FieldError,
FieldDescription,
} from "@/shared/components/ui/field"
import { cn } from "@/shared/lib/utils"
// ── Types ──
export type LabelItem = {
id: number
title: string
color_code: string
}
type RhfLabelPickerFieldProps<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
> = {
name: TName
label?: string
description?: string
required?: boolean
disabled?: boolean
placeholder?: string
}
// ── Helpers ──
function extractLabels(response: unknown): LabelItem[] {
if (Array.isArray(response)) return response
const obj = response as any
if (Array.isArray(obj?.data?.data)) return obj.data.data
if (Array.isArray(obj?.data)) return obj.data
return []
}
// ── Component ──
export function RhfLabelPickerField<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
>({
name,
label,
description,
required,
disabled,
placeholder = "Select labels...",
}: RhfLabelPickerFieldProps<TValues, TName>) {
const api = useAuthApi()
const queryClient = useQueryClient()
const { control } = useFormContext<TValues>()
const { field, fieldState: { error } } = useController({ name, control, disabled })
const [open, setOpen] = useState(false)
const [search, setSearch] = useState("")
const [creating, setCreating] = useState(false)
const [newTitle, setNewTitle] = useState("")
const [newColor, setNewColor] = useState("#6366f1")
const [isSubmitting, setIsSubmitting] = useState(false)
const { data: allLabels = [] } = useQuery<LabelItem[]>({
queryKey: [LABEL_ROUTES.INDEX],
queryFn: async () => {
const res = await api.labels.list()
return extractLabels(res)
},
staleTime: 5 * 60 * 1000,
})
const selected: LabelItem[] = field.value ?? []
const filtered = search
? allLabels.filter((l) =>
l.title.toLowerCase().includes(search.toLowerCase()),
)
: allLabels
function toggle(lbl: LabelItem) {
const isSelected = selected.some((s) => s.id === lbl.id)
if (isSelected) {
field.onChange(selected.filter((s) => s.id !== lbl.id))
} else {
field.onChange([...selected, lbl])
}
}
function remove(id: number, e: React.MouseEvent) {
e.stopPropagation()
field.onChange(selected.filter((s) => s.id !== id))
}
async function handleCreate() {
if (!newTitle.trim()) return
setIsSubmitting(true)
try {
const res = await api.labels.create({
title: newTitle.trim(),
color_code: newColor,
}) as any
const created = res?.data ?? res
queryClient.invalidateQueries({ queryKey: [LABEL_ROUTES.INDEX] })
if (created?.id) {
field.onChange([
...selected,
{
id: created.id,
title: created.title ?? newTitle.trim(),
color_code: created.color_code ?? newColor,
},
])
}
setNewTitle("")
setNewColor("#6366f1")
setCreating(false)
} catch {
// silent – toast handled upstream if desired
} finally {
setIsSubmitting(false)
}
}
return (
<Field data-invalid={!!error || undefined}>
{label && (
<FieldLabel>
{label}
{required && (
<span className="text-destructive ms-0.5">*</span>
)}
</FieldLabel>
)}
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<div
role="combobox"
aria-expanded={open}
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
setOpen((v) => !v)
}
}}
className={cn(
"flex min-h-9 w-full cursor-pointer flex-wrap items-center gap-1.5 rounded-md border border-input bg-background px-3 py-1.5 text-sm transition-colors hover:border-ring focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
disabled && "cursor-not-allowed opacity-50",
error && "border-destructive",
)}
>
{selected.length === 0 && (
<span className="text-muted-foreground">
{placeholder}
</span>
)}
{selected.map((s) => (
<span
key={s.id}
className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium"
style={{
backgroundColor: s.color_code + "28",
borderColor: s.color_code + "80",
color: s.color_code,
}}
>
<span
className="h-2 w-2 shrink-0 rounded-full"
style={{ backgroundColor: s.color_code }}
/>
{s.title}
<button
type="button"
className="ml-0.5 rounded-full opacity-70 hover:opacity-100"
onClick={(e) => remove(s.id, e)}
tabIndex={-1}
aria-label={`Remove ${s.title}`}
>
<X className="h-2.5 w-2.5" />
</button>
</span>
))}
</div>
</PopoverTrigger>
<PopoverContent
className="flex w-64 flex-col overflow-hidden p-0"
align="start"
style={{
maxHeight:
"var(--radix-popover-content-available-height, 320px)",
}}
>
{/* Search */}
<div className="shrink-0 p-2">
<Input
placeholder="Search labels..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="h-8"
/>
</div>
{/* List */}
<ScrollArea className="min-h-0 flex-1">
<div className="px-1 pb-1">
{filtered.length === 0 && (
<p className="py-4 text-center text-xs text-muted-foreground">
No labels found
</p>
)}
{filtered.map((lbl) => {
const isSelected = selected.some(
(s) => s.id === lbl.id,
)
return (
<button
key={lbl.id}
type="button"
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-accent"
onClick={() => toggle(lbl)}
>
<span
className="h-3 w-3 shrink-0 rounded-full"
style={{
backgroundColor: lbl.color_code,
}}
/>
<span className="flex-1 text-start">
{lbl.title}
</span>
{isSelected && (
<Check className="h-3.5 w-3.5 text-primary" />
)}
</button>
)
})}
</div>
</ScrollArea>
{/* Footer: create */}
<div className="shrink-0 border-t p-2">
{creating ? (
<div className="flex flex-col gap-2">
<Input
placeholder="Label name"
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
className="h-8"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
handleCreate()
}
if (e.key === "Escape") setCreating(false)
}}
autoFocus
/>
<div className="flex items-center gap-2">
<input
type="color"
value={newColor}
onChange={(e) =>
setNewColor(e.target.value)
}
className="h-8 w-8 cursor-pointer rounded border p-0.5"
title="Pick a color"
/>
<span className="flex-1 text-xs text-muted-foreground">
Color
</span>
<Button
type="button"
size="sm"
className="h-7 text-xs"
onClick={handleCreate}
disabled={
isSubmitting || !newTitle.trim()
}
>
{isSubmitting ? "..." : "Create"}
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="h-7 text-xs"
onClick={() => setCreating(false)}
>
Cancel
</Button>
</div>
</div>
) : (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 w-full justify-start gap-1.5 text-xs"
onClick={() => setCreating(true)}
>
<PlusIcon className="h-3.5 w-3.5" />
Create label
</Button>
)}
</div>
</PopoverContent>
</Popover>
{description && (
<FieldDescription>{description}</FieldDescription>
)}
{error && <FieldError>{error.message}</FieldError>}
</Field>
)
}
@@ -24,7 +24,7 @@ import {
} from "./payment-made.schema"
import {
PAYMENT_MADE_ROUTES,
PAYMENT_ROUTES,
PAYMENT_MODE_ROUTES,
VENDOR_ROUTES,
EMPLOYEE_ROUTES,
PaymentFor,
@@ -222,8 +222,8 @@ export function PaymentMadeForm({ resourceId, initialData, onSuccess }: PaymentM
name="payment_mode"
label="Payment Mode"
placeholder="Select payment mode"
queryKey={[PAYMENT_ROUTES.MODES]}
listFn={() => api.payments.listModes()}
queryKey={[PAYMENT_MODE_ROUTES.INDEX]}
listFn={() => api.paymentModes.list()}
mapOption={mapLookupOption}
{...STORE_OBJECT}
/>
@@ -1,5 +1,6 @@
"use client"
import { useMemo } from "react"
import { AlertTriangle, Plus, Save } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
@@ -21,7 +22,7 @@ import {
paymentReceivedFormSchema,
type PaymentReceivedFormValues,
} from "./payment-received.schema"
import { PAYMENT_ROUTES, CUSTOMER_ROUTES, JOB_CARD_ROUTES } from "@garage/api"
import { PAYMENT_MODE_ROUTES, CUSTOMER_ROUTES, JOB_CARD_ROUTES } from "@garage/api"
// ── Props ──
@@ -29,6 +30,7 @@ export type PaymentReceivedFormProps = {
resourceId?: string | null
initialData?: unknown
onSuccess?: () => void
defaultJobCard?: { id?: number | null; title?: string | null } | null
}
// ── Default values ──
@@ -82,14 +84,24 @@ const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) =
// ── Component ──
export function PaymentReceivedForm({ resourceId, initialData, onSuccess }: PaymentReceivedFormProps) {
export function PaymentReceivedForm({ resourceId, initialData, onSuccess, defaultJobCard }: PaymentReceivedFormProps) {
const api = useAuthApi()
const resolvedInitialData = useMemo(() => {
if (!resourceId && defaultJobCard?.id != null) {
return {
...(initialData as any),
job_card: toRelation(defaultJobCard.id, defaultJobCard.title ?? undefined),
}
}
return initialData
}, [resourceId, defaultJobCard, initialData])
const { form, isEditing } = useResourceForm<PaymentReceivedFormValues, any>({
schema: paymentReceivedFormSchema,
defaultValues: DEFAULT_VALUES,
resourceId,
initialData,
initialData: resolvedInitialData,
mapToFormValues,
})
@@ -97,8 +109,8 @@ export function PaymentReceivedForm({ resourceId, initialData, onSuccess }: Paym
mutationFn: (values: PaymentReceivedFormValues) => {
const payload = mapFormToPayload(values)
const promise = isEditing && resourceId
? api.payments.updateReceived(resourceId, payload as any)
: api.payments.createReceived(payload as any)
? api.paymentReceived.update(resourceId, payload as any)
: api.paymentReceived.create(payload as any)
toast.promise(promise, {
loading: isEditing ? "Updating payment..." : "Recording payment...",
success: isEditing ? "Payment updated successfully" : "Payment recorded successfully",
@@ -148,7 +160,7 @@ export function PaymentReceivedForm({ resourceId, initialData, onSuccess }: Paym
listFn={() => api.jobCards.list()}
mapOption={(item: any) => ({
value: String(item.id),
label: item.job_card_number || item.name || `#${item.id}`,
label: item.title,
})}
{...STORE_OBJECT}
/>
@@ -166,8 +178,8 @@ export function PaymentReceivedForm({ resourceId, initialData, onSuccess }: Paym
name="payment_mode"
label="Payment Mode"
placeholder="Select payment mode"
queryKey={[PAYMENT_ROUTES.MODES]}
listFn={() => api.payments.listModes()}
queryKey={[PAYMENT_MODE_ROUTES.INDEX]}
listFn={() => api.paymentModes.list()}
mapOption={mapLookupOption}
{...STORE_OBJECT}
/>
@@ -10,6 +10,7 @@ import {
RhfTextField,
RhfTextareaField,
RhfAsyncSelectField,
RhfDateField,
} from "@/shared/components/form"
import { toast } from "sonner"
import { useAuthApi } from "@/shared/useApi"
@@ -21,7 +22,8 @@ import {
purchaseOrderFormSchema,
type PurchaseOrderFormValues,
} from "./purchase-order.schema"
import { PURCHASE_ORDER_ROUTES, VENDOR_ROUTES, JOB_CARD_ROUTES, DEPARTMENT_ROUTES } from "@garage/api"
import { VENDOR_ROUTES, JOB_CARD_ROUTES, DEPARTMENT_ROUTES } from "@garage/api"
import { getFullName } from "@/shared/utils/getFullName"
// ── Props ──
@@ -38,7 +40,7 @@ const DEFAULT_VALUES: PurchaseOrderFormValues = {
job_card: null,
department: null,
title: "",
order_date: "",
order_date: new Date().toISOString().split("T")[0],
delivery_date: "",
notes: "",
}
@@ -128,8 +130,8 @@ export function PurchaseOrderForm({ resourceId, initialData, onSuccess }: Purcha
<RhfTextField name="title" label="Title" placeholder="Enter purchase order title" required />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField name="order_date" label="Order Date" placeholder="YYYY-MM-DD" type="date" />
<RhfTextField name="delivery_date" label="Delivery Date" placeholder="YYYY-MM-DD" type="date" />
<RhfDateField name="order_date" label="Order Date" />
<RhfDateField name="delivery_date" label="Delivery Date" />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
@@ -139,7 +141,7 @@ export function PurchaseOrderForm({ resourceId, initialData, onSuccess }: Purcha
placeholder="Select vendor"
queryKey={[VENDOR_ROUTES.INDEX]}
listFn={() => api.vendors.list()}
mapOption={mapLookupOption}
mapOption={(op: any) => ({ label: getFullName(op as any), value: String(op.id) })}
{...STORE_OBJECT}
/>
<RhfAsyncSelectField
@@ -161,7 +163,7 @@ export function PurchaseOrderForm({ resourceId, initialData, onSuccess }: Purcha
listFn={() => api.jobCards.list()}
mapOption={(item: any) => ({
value: String(item.id),
label: item.job_card_number || item.name || `#${item.id}`,
label: item.title || `#${item.id}`,
})}
{...STORE_OBJECT}
/>
+266
View File
@@ -0,0 +1,266 @@
"use client"
import { AlertTriangle, Plus, Save } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { Alert, AlertTitle } from "@/shared/components/ui/alert"
import { FieldGroup } from "@/shared/components/ui/field"
import {
Rhform,
RhfTextField,
RhfTextareaField,
RhfSelectField,
RhfAsyncSelectField,
RhfDateField,
} from "@/shared/components/form"
import { toast } from "sonner"
import { useAuthApi } from "@/shared/useApi"
import { useResourceForm } from "@/shared/hooks/use-resource-form"
import { useFormMutation } from "@/shared/hooks/use-form-mutation"
import { toRelation, toId } from "@/shared/lib/utils"
import { taskFormSchema, type TaskFormValues } from "./task.schema"
import { TASK_ROUTES, TASK_TYPE_ROUTES, TASK_SECTION_ROUTES, EMPLOYEE_ROUTES, DEPARTMENT_ROUTES, JOB_CARD_ROUTES } from "@garage/api"
import { TaskStatus, TaskPriority } from "@garage/api"
import { TaskTypeCrudDialog } from "./task-type-crud-dialog"
import { TaskSectionCrudDialog } from "./task-section-crud-dialog"
// ── Constants ──
const STATUS_OPTIONS = TaskStatus.map((s) => ({
value: s,
label: s.charAt(0).toUpperCase() + s.slice(1),
}))
const PRIORITY_OPTIONS = TaskPriority.map((p,i) => ({
value: i,
label: p.charAt(0).toUpperCase() + p.slice(1),
}))
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
// ── Props ──
export type TaskFormProps = {
resourceId?: string | null
initialData?: unknown
onSuccess?: () => void
}
// ── Default values ──
const DEFAULT_VALUES: TaskFormValues = {
subject: "",
description: "",
task_type: null,
task_section: null,
owner: null,
department: null,
priority: "medium",
due_date: "",
status: "pending",
job_card: null,
}
// ── Mapping helpers ──
function mapToFormValues(data: unknown): TaskFormValues {
const d = (data as any)?.data ?? data ?? {}
return {
subject: d.subject || "",
description: d.description || "",
task_type: toRelation(d.task_type_id, d.task_type?.title),
task_section: toRelation(d.task_section_id, d.task_section?.title),
owner: toRelation(
d.owner_id,
d.owner ? `${d.owner.first_name ?? ""} ${d.owner.last_name ?? ""}`.trim() : undefined,
),
department: toRelation(d.department_id, d.department?.name),
priority: d.priority || "medium",
due_date: d.due_date ? d.due_date.split("T")[0] : "",
status: d.status || "pending",
job_card: toRelation(d.job_card_id, d.job_card?.title),
}
}
function mapFormToPayload(values: TaskFormValues) {
return {
subject: values.subject,
description: values.description || undefined,
task_type_id: toId(values.task_type),
task_section_id: toId(values.task_section),
owner_id: toId(values.owner),
department_id: toId(values.department),
priority: values.priority || undefined,
due_date: values.due_date || undefined,
status: values.status || undefined,
job_card_id: toId(values.job_card),
}
}
// ── Component ──
export function TaskForm({ resourceId, initialData, onSuccess }: TaskFormProps) {
const api = useAuthApi()
const { form, isEditing } = useResourceForm<TaskFormValues, any>({
schema: taskFormSchema,
defaultValues: DEFAULT_VALUES,
resourceId,
initialData,
mapToFormValues,
queryKey: [TASK_ROUTES.BY_ID, resourceId],
})
const { mutate, error, isPending } = useFormMutation(form, {
mutationFn: (values: TaskFormValues) => {
const payload = mapFormToPayload(values)
const promise = isEditing && resourceId
? api.tasks.update(resourceId, payload as any)
: api.tasks.create(payload as any)
toast.promise(promise, {
loading: isEditing ? "Updating task..." : "Creating task...",
success: isEditing ? "Task updated successfully" : "Task created successfully",
error: isEditing ? "Failed to update task" : "Failed to create task",
})
return promise
},
onSuccess: () => {
form.reset()
onSuccess?.()
},
})
return (
<Rhform form={form} onSubmit={(values) => mutate(values)} className="space-y-6">
{error && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>
{isEditing ? "Failed to update task" : "Failed to create task"}
</AlertTitle>
</Alert>
)}
<FieldGroup>
<RhfTextField
name="subject"
label="Subject"
placeholder="e.g. Inspect brake pads"
required
/>
<RhfTextareaField
name="description"
label="Description"
placeholder="Optional description..."
/>
</FieldGroup>
<FieldGroup>
<div className="grid grid-cols-2 gap-4">
{/* Task Type with inline CRUD */}
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium">Task Type</span>
<TaskTypeCrudDialog />
</div>
<RhfAsyncSelectField
name="task_type"
label=""
placeholder="Select task type..."
queryKey={[TASK_TYPE_ROUTES.INDEX]}
listFn={() => api.taskTypes.list()}
mapOption={(item: any) => ({ value: String(item.id), label: item.title })}
{...STORE_OBJECT}
/>
</div>
{/* Task Section with inline CRUD */}
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium">Task Section</span>
<TaskSectionCrudDialog />
</div>
<RhfAsyncSelectField
name="task_section"
label=""
placeholder="Select task section..."
queryKey={[TASK_SECTION_ROUTES.INDEX]}
listFn={() => api.taskSections.list()}
mapOption={(item: any) => ({ value: String(item.id), label: item.title })}
{...STORE_OBJECT}
/>
</div>
</div>
</FieldGroup>
<FieldGroup>
<div className="grid grid-cols-2 gap-4">
<RhfAsyncSelectField
name="owner"
label="Assigned To"
placeholder="Select employee..."
queryKey={[EMPLOYEE_ROUTES.INDEX]}
listFn={() => api.employees.list()}
mapOption={(item: any) => ({
value: String(item.id),
label: `${item.first_name ?? ""} ${item.last_name ?? ""}`.trim(),
})}
{...STORE_OBJECT}
/>
<RhfAsyncSelectField
name="department"
label="Department"
placeholder="Select department..."
queryKey={[DEPARTMENT_ROUTES.INDEX]}
listFn={() => api.departments.list()}
mapOption={(item: any) => ({ value: String(item.id), label: item.name })}
{...STORE_OBJECT}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<RhfSelectField
name="priority"
label="Priority"
placeholder="Select priority..."
options={PRIORITY_OPTIONS as any}
/>
<RhfSelectField
name="status"
label="Status"
placeholder="Select status..."
options={STATUS_OPTIONS}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<RhfDateField name="due_date" label="Due Date" />
<RhfAsyncSelectField
name="job_card"
label="Job Card"
placeholder="Select job card..."
queryKey={[JOB_CARD_ROUTES.INDEX]}
listFn={() => api.jobCards.list()}
mapOption={(item: any) => ({
value: String(item.id),
label: item.title || `#${item.id}`,
})}
{...STORE_OBJECT}
/>
</div>
</FieldGroup>
<Button type="submit" disabled={isPending} className="w-full">
{isEditing ? <Save className="h-4 w-4" /> : <Plus className="h-4 w-4" />}
{isPending
? isEditing ? "Updating..." : "Creating..."
: isEditing ? "Update Task" : "Add Task"}
</Button>
</Rhform>
)
}
@@ -0,0 +1,43 @@
"use client"
import { CrudDialog } from "@/shared/components/crud-dialog"
import { ColumnHeader } from "@/shared/data-view/table-view"
import { useAuthApi } from "@/shared/useApi"
import { TASK_SECTION_ROUTES } from "@garage/api"
import { TaskSectionForm } from "./task-section-form"
export function TaskSectionCrudDialog() {
const api = useAuthApi()
return (
<CrudDialog
title="Task Section"
queryKey={[TASK_SECTION_ROUTES.INDEX]}
getClient={() => api.taskSections}
resourceLabel="task section"
columns={() => [
{
accessorKey: "title",
header: ({ column }) => <ColumnHeader column={column} title="Title" />,
},
{
accessorKey: "arrangement",
header: ({ column }) => <ColumnHeader column={column} title="Order" />,
},
{
accessorKey: "is_default",
header: () => <span>Default</span>,
cell: ({ row }) => ((row.original as any).is_default ? "Yes" : "No"),
enableSorting: false,
},
]}
renderForm={({ resourceId, initialData, onSuccess }) => (
<TaskSectionForm
resourceId={resourceId}
initialData={initialData}
onSuccess={onSuccess}
/>
)}
/>
)
}
@@ -0,0 +1,107 @@
"use client"
import { z } from "zod"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { Plus, Save } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { FieldGroup } from "@/shared/components/ui/field"
import { Rhform, RhfTextField, RhfCheckboxField } from "@/shared/components/form"
import { toast } from "sonner"
import { useAuthApi } from "@/shared/useApi"
import { useEffect } from "react"
// ── Schema ──
const taskSectionSchema = z.object({
title: z.string().min(1, "Title is required"),
arrangement: z.string().optional(),
is_default: z.boolean().optional(),
})
type TaskSectionFormValues = z.infer<typeof taskSectionSchema>
// ── Props ──
type TaskSectionFormProps = {
resourceId?: string | null
initialData?: any
onSuccess?: () => void
}
// ── Component ──
export function TaskSectionForm({ resourceId, initialData, onSuccess }: TaskSectionFormProps) {
const api = useAuthApi()
const isEditing = !!resourceId
const form = useForm<TaskSectionFormValues>({
resolver: zodResolver(taskSectionSchema),
defaultValues: { title: "", arrangement: "", is_default: false },
})
useEffect(() => {
if (initialData) {
const d = initialData?.data ?? initialData
form.reset({
title: d.title ?? "",
arrangement: d.arrangement != null ? String(d.arrangement) : "",
is_default: d.is_default ?? false,
})
}
}, [initialData, form])
const handleSubmit = async (values: TaskSectionFormValues) => {
try {
const promise = isEditing
? api.taskSections.update(resourceId!, {
title: values.title,
arrangement: values.arrangement ? Number(values.arrangement) : undefined,
is_default: values.is_default,
})
: api.taskSections.create({
title: values.title,
arrangement: values.arrangement ? Number(values.arrangement) : undefined,
is_default: values.is_default,
})
toast.promise(promise, {
loading: isEditing ? "Updating..." : "Creating...",
success: isEditing ? "Updated successfully" : "Created successfully",
error: isEditing ? "Failed to update" : "Failed to create",
})
await promise
form.reset()
onSuccess?.()
} catch {
// toast already shown
}
}
return (
<Rhform form={form} onSubmit={handleSubmit}>
<FieldGroup>
<RhfTextField
name="title"
label="Title"
placeholder="e.g. Pre-Work"
required
/>
<RhfTextField
name="arrangement"
label="Arrangement Order"
placeholder="e.g. 1"
type="number"
/>
<RhfCheckboxField name="is_default" label="Set as default" />
<Button type="submit" disabled={form.formState.isSubmitting}>
{isEditing ? <Save className="h-4 w-4" /> : <Plus className="h-4 w-4" />}
{form.formState.isSubmitting
? isEditing ? "Updating..." : "Creating..."
: isEditing ? "Update" : "Create"}
</Button>
</FieldGroup>
</Rhform>
)
}
@@ -0,0 +1,39 @@
"use client"
import { CrudDialog } from "@/shared/components/crud-dialog"
import { ColumnHeader } from "@/shared/data-view/table-view"
import { useAuthApi } from "@/shared/useApi"
import { TASK_TYPE_ROUTES } from "@garage/api"
import { TaskTypeForm } from "./task-type-form"
export function TaskTypeCrudDialog() {
const api = useAuthApi()
return (
<CrudDialog
title="Task Type"
queryKey={[TASK_TYPE_ROUTES.INDEX]}
getClient={() => api.taskTypes}
resourceLabel="task type"
columns={() => [
{
accessorKey: "title",
header: ({ column }) => <ColumnHeader column={column} title="Title" />,
},
{
accessorKey: "is_default",
header: () => <span>Default</span>,
cell: ({ row }) => ((row.original as any).is_default ? "Yes" : "No"),
enableSorting: false,
},
]}
renderForm={({ resourceId, initialData, onSuccess }) => (
<TaskTypeForm
resourceId={resourceId}
initialData={initialData}
onSuccess={onSuccess}
/>
)}
/>
)
}
@@ -0,0 +1,91 @@
"use client"
import { z } from "zod"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { Plus, Save } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { FieldGroup } from "@/shared/components/ui/field"
import { Rhform, RhfTextField, RhfCheckboxField } from "@/shared/components/form"
import { toast } from "sonner"
import { useAuthApi } from "@/shared/useApi"
import { useEffect } from "react"
// ── Schema ──
const taskTypeSchema = z.object({
title: z.string().min(1, "Title is required"),
is_default: z.boolean().optional(),
})
type TaskTypeFormValues = z.infer<typeof taskTypeSchema>
// ── Props ──
type TaskTypeFormProps = {
resourceId?: string | null
initialData?: any
onSuccess?: () => void
}
// ── Component ──
export function TaskTypeForm({ resourceId, initialData, onSuccess }: TaskTypeFormProps) {
const api = useAuthApi()
const isEditing = !!resourceId
const form = useForm<TaskTypeFormValues>({
resolver: zodResolver(taskTypeSchema),
defaultValues: { title: "", is_default: false },
})
useEffect(() => {
if (initialData) {
const d = initialData?.data ?? initialData
form.reset({
title: d.title ?? "",
is_default: d.is_default ?? false,
})
}
}, [initialData, form])
const handleSubmit = async (values: TaskTypeFormValues) => {
try {
const promise = isEditing
? api.taskTypes.update(resourceId!, values)
: api.taskTypes.create(values)
toast.promise(promise, {
loading: isEditing ? "Updating..." : "Creating...",
success: isEditing ? "Updated successfully" : "Created successfully",
error: isEditing ? "Failed to update" : "Failed to create",
})
await promise
form.reset()
onSuccess?.()
} catch {
// toast already shown
}
}
return (
<Rhform form={form} onSubmit={handleSubmit}>
<FieldGroup>
<RhfTextField
name="title"
label="Title"
placeholder="e.g. Maintenance"
required
/>
<RhfCheckboxField name="is_default" label="Set as default" />
<Button type="submit" disabled={form.formState.isSubmitting}>
{isEditing ? <Save className="h-4 w-4" /> : <Plus className="h-4 w-4" />}
{form.formState.isSubmitting
? isEditing ? "Updating..." : "Creating..."
: isEditing ? "Update" : "Create"}
</Button>
</FieldGroup>
</Rhform>
)
}
@@ -0,0 +1,20 @@
import { z } from "zod"
export const relationFieldSchema = z
.object({ value: z.string(), label: z.string() })
.nullable()
export const taskFormSchema = z.object({
subject: z.string().min(1, "Subject is required"),
description: z.string().optional(),
task_type: relationFieldSchema,
task_section: relationFieldSchema,
owner: relationFieldSchema,
department: relationFieldSchema,
priority: z.string().optional(),
due_date: z.string().optional(),
status: z.string().optional(),
job_card: relationFieldSchema,
})
export type TaskFormValues = z.infer<typeof taskFormSchema>