updates
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user