updates
This commit is contained in:
parent
5f3d208158
commit
24a44481a0
76
.github/skills/api-enums-reference/SKILL.md
vendored
Normal file
76
.github/skills/api-enums-reference/SKILL.md
vendored
Normal file
@ -0,0 +1,76 @@
|
||||
---
|
||||
name: api-enums-reference
|
||||
description: "Use the central API enums file as the source of truth for enum values in this project. Use when: adding enum fields, updating enum options, creating form selects, typing status/discount/rate fields, syncing backend enum changes, or avoiding duplicated hardcoded enum literals."
|
||||
---
|
||||
|
||||
# API Enums Reference
|
||||
|
||||
Use this skill whenever work touches enum-like fields in API clients, schemas, forms, table filters, or page logic.
|
||||
|
||||
## Source of Truth
|
||||
|
||||
All shared enum values and enum union types must come from:
|
||||
|
||||
- packages/api/src/contracts/enums.ts
|
||||
|
||||
Do not recreate enum arrays inline when an equivalent enum already exists in this file.
|
||||
|
||||
## Rules
|
||||
|
||||
1. Reuse before creating.
|
||||
Search and import from `@garage/api` exports (or local contracts path inside packages/api) before adding new literals.
|
||||
|
||||
2. Keep runtime and type together.
|
||||
For every enum, keep this pattern in `enums.ts`:
|
||||
|
||||
```ts
|
||||
export const ExampleStatus = ['a', 'b'] as const;
|
||||
export type ExampleStatus = (typeof ExampleStatus)[number];
|
||||
```
|
||||
|
||||
3. Preserve backend values exactly.
|
||||
Enum string values are case- and space-sensitive; keep exact spelling from backend migrations/spec.
|
||||
|
||||
4. Avoid duplicate synonyms.
|
||||
If two domains share the same canonical values, prefer reusing an existing enum unless domain separation is intentional.
|
||||
|
||||
5. Update centrally first.
|
||||
When backend enum options change, update `packages/api/src/contracts/enums.ts` first, then update consuming UI/API code.
|
||||
|
||||
6. Prefer imports in forms and schemas.
|
||||
Use central enums for select options and for typed payload/status fields instead of hardcoded string unions.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Identify the enum field and backend values.
|
||||
2. Check `packages/api/src/contracts/enums.ts` for an existing enum.
|
||||
3. If found, import and use it.
|
||||
4. If missing, add a new const+type pair in `enums.ts`.
|
||||
5. Update consumers to reference the central enum.
|
||||
6. Verify there are no duplicated literal arrays for the same field.
|
||||
|
||||
## Examples
|
||||
|
||||
```ts
|
||||
import { InvoiceStatus, type InvoiceStatus as InvoiceStatusType } from '@garage/api'
|
||||
|
||||
const statusOptions = InvoiceStatus
|
||||
|
||||
type Payload = {
|
||||
status: InvoiceStatusType
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
import { DiscountType } from '@garage/api'
|
||||
|
||||
const discountOptions = DiscountType.map((value) => ({
|
||||
label: value,
|
||||
value,
|
||||
}))
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- If a module needs a presentation-specific label, map from the central enum value instead of changing raw enum literals.
|
||||
- If backend adds/removes values, keep API and dashboard aligned in the same change set.
|
||||
86
.github/skills/date-time-pickers/SKILL.md
vendored
Normal file
86
.github/skills/date-time-pickers/SKILL.md
vendored
Normal file
@ -0,0 +1,86 @@
|
||||
---
|
||||
name: date-time-pickers
|
||||
description: "Use RhfDateField and RhfTimeField (shadcn Calendar/Popover-based) for all date and time inputs in forms. Use when: adding date fields, adding time fields, replacing `type=\"date\"` or `type=\"time\"` RhfTextField inputs, building any form that captures a date or time value."
|
||||
---
|
||||
|
||||
# Date & Time Pickers
|
||||
|
||||
Always use the shadcn-based picker components for date and time fields. Never use `<RhfTextField type="date">` or `<RhfTextField type="time">`.
|
||||
|
||||
## Components
|
||||
|
||||
| Use For | Component | Import |
|
||||
|---|---|---|
|
||||
| Date fields (YYYY-MM-DD) | `RhfDateField` | `@/shared/components/form` |
|
||||
| Time fields (HH:MM:SS) | `RhfTimeField` | `@/shared/components/form` |
|
||||
|
||||
## RhfDateField
|
||||
|
||||
Renders a shadcn Calendar inside a Popover. Value is a `string` in `"YYYY-MM-DD"` format.
|
||||
|
||||
```tsx
|
||||
import { RhfDateField } from "@/shared/components/form"
|
||||
|
||||
<RhfDateField name="check_in_date" label="Check-in Date" />
|
||||
```
|
||||
|
||||
**Schema type**: `z.string().optional()` (stores `"YYYY-MM-DD"`)
|
||||
|
||||
**Default value**: `""` for empty, or `new Date().toISOString().split("T")[0]` for today.
|
||||
|
||||
**`mapToFormValues`**: `d.check_in_date ? d.check_in_date.split("T")[0] : ""`
|
||||
|
||||
**`mapFormToPayload`**: `values.check_in_date || undefined`
|
||||
|
||||
## RhfTimeField
|
||||
|
||||
Renders an HH / MM / SS spinner inside a Popover. Value is a `string` in `"HH:MM:SS"` format.
|
||||
|
||||
```tsx
|
||||
import { RhfTimeField } from "@/shared/components/form"
|
||||
|
||||
// With seconds (default)
|
||||
<RhfTimeField name="check_in_time" label="Check-in Time" withSeconds />
|
||||
|
||||
// Without seconds
|
||||
<RhfTimeField name="check_in_time" label="Check-in Time" />
|
||||
```
|
||||
|
||||
**Props**:
|
||||
- `withSeconds?: boolean` — show the SS spinner (default `true`)
|
||||
- `placeholder?: string` — trigger button placeholder text
|
||||
|
||||
**Schema type**: `z.string().optional()` (stores `"HH:MM:SS"` or `"HH:MM"`)
|
||||
|
||||
**Default value for current time**:
|
||||
```ts
|
||||
(() => {
|
||||
const n = new Date()
|
||||
return `${String(n.getHours()).padStart(2,"0")}:${String(n.getMinutes()).padStart(2,"0")}:${String(n.getSeconds()).padStart(2,"0")}`
|
||||
})()
|
||||
```
|
||||
|
||||
**`mapToFormValues`** (API returns `"HH:MM"` or ISO datetime):
|
||||
- If ISO: `d.check_in_time ? d.check_in_time.split("T")[1]?.slice(0, 8) ?? "" : ""`
|
||||
- If plain time string: `d.check_in_time ?? ""`
|
||||
|
||||
**`mapFormToPayload`**: `values.check_in_time || undefined`
|
||||
|
||||
## Underlying Controls (non-RHF use)
|
||||
|
||||
If you need to use the pickers outside of RHF:
|
||||
|
||||
```tsx
|
||||
import { DatePickerField, TimePickerField } from "@/shared/components/form"
|
||||
|
||||
<DatePickerField value={date} onChange={setDate} placeholder="Select date" />
|
||||
<TimePickerField value={time} onChange={setTime} withSeconds />
|
||||
```
|
||||
|
||||
## File Locations
|
||||
|
||||
- Control: `apps/dashboard/shared/components/form/controls/date-picker-field.tsx`
|
||||
- Control: `apps/dashboard/shared/components/form/controls/time-picker-field.tsx`
|
||||
- RHF wrapper: `apps/dashboard/shared/components/form/fields/rhf-date-field.tsx`
|
||||
- RHF wrapper: `apps/dashboard/shared/components/form/fields/rhf-time-field.tsx`
|
||||
- Exports: `apps/dashboard/shared/components/form/index.ts`
|
||||
@ -0,0 +1,29 @@
|
||||
import { DashboardDetailsPage } from "@/base/components/layout/dashboard"
|
||||
import { AppointmentActions } from "@/modules/appointments/appointment-actions"
|
||||
import { AppointmentProvider } from "@/modules/appointments/appointment-context"
|
||||
import { CalendarCheck2 } from "lucide-react"
|
||||
import React from "react"
|
||||
|
||||
export default async function layout(props: {
|
||||
params: Promise<{ id: string }>
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const { id } = await props.params
|
||||
|
||||
return (
|
||||
<AppointmentProvider appointment={{ id, label: `Appointment #${id}` }}>
|
||||
<DashboardDetailsPage
|
||||
className="p-0 lg:p-0"
|
||||
icon={<CalendarCheck2 className="size-5" />}
|
||||
title={`Appointment #${id}`}
|
||||
backHref="/calendar/appointment/list"
|
||||
actions={<AppointmentActions appointmentId={id} />}
|
||||
tabs={[
|
||||
{ href: `/calendar/appointment/${id}`, label: "Details" },
|
||||
]}
|
||||
>
|
||||
{props.children}
|
||||
</DashboardDetailsPage>
|
||||
</AppointmentProvider>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,49 @@
|
||||
"use client"
|
||||
|
||||
import { use } from "react"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { APPOINTMENT_ROUTES } from "@garage/api"
|
||||
import { AppointmentGeneralInfo } from "@/modules/appointments/appointment-general-info"
|
||||
import DashboardPage from "@/base/components/layout/dashboard/dashboard-page"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function AppointmentDetailsPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = use(params)
|
||||
const api = useAuthApi()
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: [APPOINTMENT_ROUTES.INDEX, "detail", id],
|
||||
queryFn: async () => {
|
||||
const response = await api.appointments.list()
|
||||
const items = (response as any)?.data ?? []
|
||||
return items.find((item: any) => String(item.id) === id) ?? null
|
||||
},
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<DashboardPage header={null}>
|
||||
<div className="grid gap-6 md:grid-cols-2 p-6">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-48 rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
</DashboardPage>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<DashboardPage header={null}>
|
||||
<p className="text-muted-foreground p-6">Appointment not found.</p>
|
||||
</DashboardPage>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardPage header={null}>
|
||||
<AppointmentGeneralInfo appointment={data} />
|
||||
</DashboardPage>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,112 @@
|
||||
"use client"
|
||||
|
||||
import { useRouter } from "next/navigation"
|
||||
import { ResourcePage } from "@/shared/data-view/resource-page"
|
||||
import { ColumnHeader } from "@/shared/data-view/table-view"
|
||||
import FormDialog from "@/shared/components/form-dialog"
|
||||
import { AppointmentForm } from "@/modules/appointments/appointment-form"
|
||||
import { APPOINTMENT_ROUTES } from "@garage/api"
|
||||
import type { AppointmentsClient } from "@garage/api"
|
||||
import { CalendarCheck2Icon, ClipboardListIcon, ClockIcon, ExternalLinkIcon } from "lucide-react"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
requested: "bg-yellow-100 text-yellow-800",
|
||||
confirmed: "bg-blue-100 text-blue-800",
|
||||
in_progress: "bg-purple-100 text-purple-800",
|
||||
completed: "bg-green-100 text-green-800",
|
||||
cancelled: "bg-red-100 text-red-800",
|
||||
}
|
||||
|
||||
export default function AppointmentsPage() {
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
<ResourcePage<AppointmentsClient>
|
||||
pageTitle="Appointments"
|
||||
routeKey={APPOINTMENT_ROUTES.INDEX}
|
||||
getClient={(api) => api.appointments}
|
||||
onRowClick={(row) => router.push(`/calendar/appointment/${(row as any).id}`)}
|
||||
headerProps={({ selectedItem, invalidateQuery }) => ({
|
||||
actions: (
|
||||
<FormDialog title="Appointment">
|
||||
{(resourceId) => (
|
||||
<AppointmentForm
|
||||
resourceId={resourceId}
|
||||
initialData={selectedItem}
|
||||
onSuccess={invalidateQuery}
|
||||
/>
|
||||
)}
|
||||
</FormDialog>
|
||||
),
|
||||
})}
|
||||
columns={({ actionsColumn }) => [
|
||||
{
|
||||
accessorKey: "title",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Title" />,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarCheck2Icon className="size-4 text-muted-foreground" />
|
||||
<span>{(row.original as any).title}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "date",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Date" />,
|
||||
},
|
||||
{
|
||||
accessorKey: "from_time",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Time" />,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original as any
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<ClockIcon className="size-3 text-muted-foreground" />
|
||||
<span>{r.from_time} – {r.to_time}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Status" />,
|
||||
cell: ({ row }) => {
|
||||
const status = (row.original as any).status
|
||||
const colorClass = STATUS_COLORS[status] ?? "bg-gray-100 text-gray-800"
|
||||
return (
|
||||
<Badge className={colorClass}>
|
||||
{status?.replace("_", " ") ?? "—"}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "job_card",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Job Card" />,
|
||||
cell: ({ row }) => {
|
||||
const jobCardId = (row.original as any).job_card_id
|
||||
if (!jobCardId) return <span className="text-muted-foreground">—</span>
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 gap-1.5"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
router.push(`/sales/job-cards/${jobCardId}`)
|
||||
}}
|
||||
>
|
||||
<ClipboardListIcon className="size-3" />
|
||||
#{jobCardId}
|
||||
<ExternalLinkIcon className="size-3" />
|
||||
</Button>
|
||||
)
|
||||
},
|
||||
},
|
||||
actionsColumn(),
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
292
apps/dashboard/app/(authenticated)/items/adjustment/page.tsx
Normal file
292
apps/dashboard/app/(authenticated)/items/adjustment/page.tsx
Normal file
@ -0,0 +1,292 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useRef } from "react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { Paperclip, Plus, Trash2, FileIcon, ImageIcon, FileTextIcon } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { ResourcePage } from "@/shared/data-view/resource-page"
|
||||
import { ColumnHeader } from "@/shared/data-view/table-view"
|
||||
import FormDialog from "@/shared/components/form-dialog"
|
||||
import { InventoryAdjustmentForm } from "@/modules/inventory-adjustments/inventory-adjustment-form"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent } from "@/shared/components/ui/card"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/components/ui/dialog"
|
||||
import { confirm } from "@/shared/components/confirm-dialog"
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { INVENTORY_ADJUSTMENT_ROUTES } from "@garage/api"
|
||||
import type { InventoryAdjustmentsClient } from "@garage/api"
|
||||
|
||||
// ── Attachment helpers ──
|
||||
|
||||
type AttachmentFile = {
|
||||
id: number
|
||||
original_name?: string
|
||||
attachment_path?: string
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
function getFileIcon(path?: string) {
|
||||
if (!path) return FileIcon
|
||||
const lower = path.toLowerCase()
|
||||
if (/\.(jpg|jpeg|png|gif|webp|svg)$/.test(lower)) return ImageIcon
|
||||
if (/\.pdf$/.test(lower)) return FileTextIcon
|
||||
return FileIcon
|
||||
}
|
||||
|
||||
// ── Attachments Dialog ──
|
||||
|
||||
function AttachmentsDialog({
|
||||
open,
|
||||
adjustmentId,
|
||||
adjustmentRef,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean
|
||||
adjustmentId: string
|
||||
adjustmentRef: string
|
||||
onClose: () => void
|
||||
}) {
|
||||
const api = useAuthApi()
|
||||
const queryClient = useQueryClient()
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [sessionFiles, setSessionFiles] = useState<AttachmentFile[]>([])
|
||||
|
||||
const queryKey = [INVENTORY_ADJUSTMENT_ROUTES.INDEX, adjustmentId, "attachments"]
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (attachmentId: number) =>
|
||||
api.inventoryAdjustments.deleteAttachment(adjustmentId, attachmentId),
|
||||
onSuccess: (_, attachmentId) => {
|
||||
toast.success("Attachment deleted.")
|
||||
setSessionFiles((prev) => prev.filter((f) => f.id !== attachmentId))
|
||||
queryClient.invalidateQueries({ queryKey })
|
||||
},
|
||||
onError: () => toast.error("Failed to delete attachment."),
|
||||
})
|
||||
|
||||
const handleDelete = async (attachment: AttachmentFile) => {
|
||||
const confirmed = await confirm({
|
||||
title: "Delete Attachment",
|
||||
description: `Are you sure you want to delete "${attachment.original_name ?? "this file"}"?`,
|
||||
confirmLabel: "Delete",
|
||||
variant: "destructive",
|
||||
})
|
||||
if (confirmed) deleteMutation.mutate(attachment.id)
|
||||
}
|
||||
|
||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files
|
||||
if (!files || files.length === 0) return
|
||||
|
||||
setIsUploading(true)
|
||||
const fileArray = Array.from(files)
|
||||
|
||||
try {
|
||||
const result = await toast.promise(
|
||||
api.inventoryAdjustments.addAttachment(adjustmentId, fileArray),
|
||||
{
|
||||
loading: "Uploading attachment(s)...",
|
||||
success: "Attachment(s) uploaded successfully",
|
||||
error: "Failed to upload attachment(s)",
|
||||
},
|
||||
)
|
||||
// Track uploaded files locally for display within this session
|
||||
const now = new Date().toISOString()
|
||||
const uploaded: AttachmentFile[] = fileArray.map((file, i) => ({
|
||||
id: Date.now() + i,
|
||||
original_name: file.name,
|
||||
attachment_path: file.name,
|
||||
created_at: now,
|
||||
}))
|
||||
setSessionFiles((prev) => [...prev, ...uploaded])
|
||||
queryClient.invalidateQueries({ queryKey })
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
if (fileInputRef.current) fileInputRef.current.value = ""
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setSessionFiles([])
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && handleClose()}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Attachments — {adjustmentRef}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleUpload}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isUploading}
|
||||
size="sm"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
{isUploading ? "Uploading..." : "Upload Attachment"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{sessionFiles.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
||||
No attachments uploaded in this session. Click "Upload Attachment" to add files.
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{sessionFiles.map((attachment) => {
|
||||
const Icon = getFileIcon(attachment.attachment_path)
|
||||
return (
|
||||
<Card key={attachment.id}>
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
|
||||
<Icon className="size-5" />
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className="truncate text-sm font-medium" title={attachment.original_name}>
|
||||
{attachment.original_name}
|
||||
</span>
|
||||
{attachment.created_at && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(attachment.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => handleDelete(attachment)}
|
||||
title="Delete attachment"
|
||||
>
|
||||
<Trash2 className="size-4 text-destructive" />
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Page ──
|
||||
|
||||
export default function InventoryAdjustmentsPage() {
|
||||
const [attachmentTarget, setAttachmentTarget] = useState<{
|
||||
id: string
|
||||
ref: string
|
||||
} | null>(null)
|
||||
|
||||
return (
|
||||
<>
|
||||
<ResourcePage<InventoryAdjustmentsClient>
|
||||
pageTitle="Inventory Adjustments"
|
||||
routeKey={INVENTORY_ADJUSTMENT_ROUTES.INDEX}
|
||||
getClient={(api) => api.inventoryAdjustments}
|
||||
headerProps={({ selectedItem, invalidateQuery }) => ({
|
||||
actions: (
|
||||
<FormDialog title="Inventory Adjustment">
|
||||
{(resourceId) => (
|
||||
<InventoryAdjustmentForm
|
||||
resourceId={resourceId}
|
||||
initialData={selectedItem}
|
||||
onSuccess={invalidateQuery}
|
||||
/>
|
||||
)}
|
||||
</FormDialog>
|
||||
),
|
||||
})}
|
||||
columns={({ actionsColumn }) => [
|
||||
{
|
||||
accessorKey: "reference_number",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Reference #" />,
|
||||
cell: ({ row }) => (row.original as any).reference_number || "—",
|
||||
},
|
||||
{
|
||||
accessorKey: "date",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Date" />,
|
||||
cell: ({ row }) => {
|
||||
const val = (row.original as any).date
|
||||
return val ? new Date(val).toLocaleDateString() : "—"
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "chart_of_account",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Chart of Account" />,
|
||||
cell: ({ row }) => (row.original as any).chart_of_account || "—",
|
||||
},
|
||||
{
|
||||
accessorKey: "notes",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Notes" />,
|
||||
cell: ({ row }) => {
|
||||
const notes = (row.original as any).notes
|
||||
return notes ? (
|
||||
<span className="max-w-50 truncate block" title={notes}>{notes}</span>
|
||||
) : "—"
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "created_at",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Created" />,
|
||||
cell: ({ row }) => {
|
||||
const val = (row.original as any).created_at
|
||||
return val ? new Date(val).toLocaleDateString() : "—"
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "attachments",
|
||||
header: () => null,
|
||||
cell: ({ row }) => {
|
||||
const item = row.original as any
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
title="Manage Attachments"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setAttachmentTarget({
|
||||
id: String(item.id),
|
||||
ref: item.reference_number || `ADJ-${item.id}`,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Paperclip className="size-4" />
|
||||
</Button>
|
||||
)
|
||||
},
|
||||
},
|
||||
actionsColumn(),
|
||||
]}
|
||||
/>
|
||||
|
||||
{attachmentTarget && (
|
||||
<AttachmentsDialog
|
||||
open={!!attachmentTarget}
|
||||
adjustmentId={attachmentTarget.id}
|
||||
adjustmentRef={attachmentTarget.ref}
|
||||
onClose={() => setAttachmentTarget(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,39 @@
|
||||
import { DashboardDetailsPage } from '@/base/components/layout/dashboard'
|
||||
import { getServerApi } from '@garage/api/server'
|
||||
import { EmployeeActions } from '@/modules/employees/employee-actions'
|
||||
import { EmployeeProvider } from '@/modules/employees/employee-context'
|
||||
import React from 'react'
|
||||
|
||||
export default async function layout(props: {
|
||||
params: Promise<{ id: string }>
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const { id } = await props.params
|
||||
const api = await getServerApi()
|
||||
const employee = await api.employees.getById(id)
|
||||
|
||||
const firstName = employee.data?.first_name || ''
|
||||
const lastName = employee.data?.last_name || ''
|
||||
const title = [firstName, lastName].filter(Boolean).join(' ') || 'Employee Details'
|
||||
const employeeLabel = title
|
||||
|
||||
return (
|
||||
<>
|
||||
<EmployeeProvider employee={{ id, label: employeeLabel }}>
|
||||
<DashboardDetailsPage
|
||||
className="p-0 lg:p-0"
|
||||
title={title}
|
||||
description={employee.data?.position || employee.data?.designation || undefined}
|
||||
backHref="/productivity/employees"
|
||||
actions={<EmployeeActions employeeId={id} />}
|
||||
tabs={[
|
||||
{ href: `/productivity/employees/${id}`, label: 'Details' },
|
||||
{ href: `/productivity/employees/${id}/permissions`, label: 'Permissions' },
|
||||
]}
|
||||
>
|
||||
{props.children}
|
||||
</DashboardDetailsPage>
|
||||
</EmployeeProvider>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
import { getServerApi } from '@garage/api/server'
|
||||
import { EmployeeGeneralInfo } from '@/modules/employees/employee-general-info'
|
||||
import DashboardPage from '@/base/components/layout/dashboard/dashboard-page'
|
||||
|
||||
export default async function page(props: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await props.params
|
||||
const api = await getServerApi()
|
||||
const employee = await api.employees.getById(id)
|
||||
|
||||
if (!employee.data) {
|
||||
return <div className="text-muted-foreground">Employee not found.</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardPage header={null}>
|
||||
<EmployeeGeneralInfo employee={employee.data as any} />
|
||||
</DashboardPage>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
"use client"
|
||||
|
||||
import { use } from "react"
|
||||
import { EmployeePermissionsForm } from "@/modules/employees/employee-permissions-form"
|
||||
|
||||
export default function EmployeePermissionsPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = use(params)
|
||||
|
||||
return <EmployeePermissionsForm employeeId={id} />
|
||||
}
|
||||
@ -6,13 +6,16 @@ import FormDialog from "@/shared/components/form-dialog"
|
||||
import { EmployeeForm } from "@/modules/employees/employee-form"
|
||||
import { EMPLOYEE_ROUTES } from "@garage/api"
|
||||
import type { EmployeesClient } from "@garage/api"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
export default function EmployeesPage() {
|
||||
const router = useRouter()
|
||||
return (
|
||||
<ResourcePage<EmployeesClient>
|
||||
pageTitle="Employees"
|
||||
routeKey={EMPLOYEE_ROUTES.INDEX}
|
||||
getClient={(api) => api.employees}
|
||||
onRowClick={(row) => router.push(`/productivity/employees/${(row as any).id}`)}
|
||||
headerProps={({ selectedItem, invalidateQuery }) => ({
|
||||
actions: (
|
||||
<FormDialog title="Employee">
|
||||
|
||||
73
apps/dashboard/app/(authenticated)/purchase/bill/page.tsx
Normal file
73
apps/dashboard/app/(authenticated)/purchase/bill/page.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
"use client"
|
||||
|
||||
import FormDialog from "@/shared/components/form-dialog"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { ResourcePage } from "@/shared/data-view/resource-page"
|
||||
import { ColumnHeader } from "@/shared/data-view/table-view"
|
||||
import { BillForm } from "@/modules/bills/bill-form"
|
||||
import { BILL_ROUTES } from "@garage/api"
|
||||
import type { BillsClient } from "@garage/api"
|
||||
|
||||
export default function BillsPage() {
|
||||
return (
|
||||
<ResourcePage<BillsClient>
|
||||
pageTitle="Bills"
|
||||
routeKey={BILL_ROUTES.INDEX}
|
||||
getClient={(api) => api.bills}
|
||||
headerProps={({ selectedItem, invalidateQuery }) => ({
|
||||
actions: (
|
||||
<FormDialog title="Bill">
|
||||
{(resourceId) => (
|
||||
<BillForm
|
||||
resourceId={resourceId}
|
||||
initialData={selectedItem}
|
||||
onSuccess={invalidateQuery}
|
||||
/>
|
||||
)}
|
||||
</FormDialog>
|
||||
),
|
||||
})}
|
||||
columns={({ actionsColumn }) => [
|
||||
{
|
||||
accessorKey: "bill_number",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Bill #" />,
|
||||
cell: ({ row }) => (row.original as any).bill_number || "—",
|
||||
},
|
||||
{
|
||||
accessorKey: "title",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Title" />,
|
||||
},
|
||||
{
|
||||
accessorKey: "vendor_name",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Vendor" />,
|
||||
cell: ({ row }) => (row.original as any).vendor_name || "—",
|
||||
},
|
||||
{
|
||||
accessorKey: "bill_date",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Bill Date" />,
|
||||
cell: ({ row }) => {
|
||||
const value = (row.original as any).bill_date
|
||||
return value ? new Date(value).toLocaleDateString() : "—"
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "bill_due_date",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Due Date" />,
|
||||
cell: ({ row }) => {
|
||||
const value = (row.original as any).bill_due_date
|
||||
return value ? new Date(value).toLocaleDateString() : "—"
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Status" />,
|
||||
cell: ({ row }) => {
|
||||
const status = (row.original as any).status
|
||||
return <Badge variant={status === "paid" ? "default" : "secondary"}>{status || "—"}</Badge>
|
||||
},
|
||||
},
|
||||
actionsColumn(),
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,377 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useRef } from "react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import {
|
||||
Paperclip,
|
||||
Plus,
|
||||
Trash2,
|
||||
FileIcon,
|
||||
ImageIcon,
|
||||
FileTextIcon,
|
||||
BadgeDollarSignIcon,
|
||||
CalendarIcon,
|
||||
CreditCardIcon,
|
||||
HashIcon,
|
||||
UserIcon,
|
||||
BriefcaseIcon,
|
||||
} from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { ResourcePage } from "@/shared/data-view/resource-page"
|
||||
import { ColumnHeader } from "@/shared/data-view/table-view"
|
||||
import FormDialog from "@/shared/components/form-dialog"
|
||||
import { PaymentMadeForm } from "@/modules/payment-mades/payment-made-form"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent } from "@/shared/components/ui/card"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/components/ui/dialog"
|
||||
import { confirm } from "@/shared/components/confirm-dialog"
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { PAYMENT_MADE_ROUTES } from "@garage/api"
|
||||
import type { PaymentMadesClient } from "@garage/api"
|
||||
|
||||
// ── Attachment helpers ──
|
||||
|
||||
type AttachmentFile = {
|
||||
id: number
|
||||
original_name?: string
|
||||
attachment_path?: string
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
function getFileIcon(path?: string) {
|
||||
if (!path) return FileIcon
|
||||
const lower = path.toLowerCase()
|
||||
if (/\.(jpg|jpeg|png|gif|webp|svg)$/.test(lower)) return ImageIcon
|
||||
if (/\.pdf$/.test(lower)) return FileTextIcon
|
||||
return FileIcon
|
||||
}
|
||||
|
||||
// ── Attachments Dialog ──
|
||||
|
||||
function AttachmentsDialog({
|
||||
open,
|
||||
paymentId,
|
||||
paymentRef,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean
|
||||
paymentId: string
|
||||
paymentRef: string
|
||||
onClose: () => void
|
||||
}) {
|
||||
const api = useAuthApi()
|
||||
const queryClient = useQueryClient()
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [sessionFiles, setSessionFiles] = useState<AttachmentFile[]>([])
|
||||
|
||||
const queryKey = [PAYMENT_MADE_ROUTES.INDEX, paymentId, "attachments"]
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (attachmentId: number) =>
|
||||
api.paymentMades.deleteAttachment(paymentId, { attachment_id: attachmentId } as any),
|
||||
onSuccess: (_, attachmentId) => {
|
||||
toast.success("Attachment deleted.")
|
||||
setSessionFiles((prev) => prev.filter((f) => f.id !== attachmentId))
|
||||
queryClient.invalidateQueries({ queryKey })
|
||||
},
|
||||
onError: () => toast.error("Failed to delete attachment."),
|
||||
})
|
||||
|
||||
const handleDelete = async (attachment: AttachmentFile) => {
|
||||
const confirmed = await confirm({
|
||||
title: "Delete Attachment",
|
||||
description: `Are you sure you want to delete "${attachment.original_name ?? "this file"}"?`,
|
||||
confirmLabel: "Delete",
|
||||
variant: "destructive",
|
||||
})
|
||||
if (confirmed) deleteMutation.mutate(attachment.id)
|
||||
}
|
||||
|
||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files
|
||||
if (!files || files.length === 0) return
|
||||
|
||||
setIsUploading(true)
|
||||
const fileArray = Array.from(files)
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
fileArray.forEach((file) => formData.append("attachments[]", file))
|
||||
|
||||
await toast.promise(
|
||||
api.paymentMades.addAttachment(paymentId, formData),
|
||||
{
|
||||
loading: "Uploading attachment(s)...",
|
||||
success: "Attachment(s) uploaded successfully",
|
||||
error: "Failed to upload attachment(s)",
|
||||
},
|
||||
)
|
||||
const now = new Date().toISOString()
|
||||
const uploaded: AttachmentFile[] = fileArray.map((file, i) => ({
|
||||
id: Date.now() + i,
|
||||
original_name: file.name,
|
||||
attachment_path: file.name,
|
||||
created_at: now,
|
||||
}))
|
||||
setSessionFiles((prev) => [...prev, ...uploaded])
|
||||
queryClient.invalidateQueries({ queryKey })
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
if (fileInputRef.current) fileInputRef.current.value = ""
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setSessionFiles([])
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && handleClose()}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Attachments — {paymentRef}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleUpload}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isUploading}
|
||||
size="sm"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
{isUploading ? "Uploading..." : "Upload Attachment"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{sessionFiles.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
||||
No attachments uploaded in this session. Click "Upload Attachment" to add files.
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{sessionFiles.map((attachment) => {
|
||||
const Icon = getFileIcon(attachment.attachment_path)
|
||||
return (
|
||||
<Card key={attachment.id}>
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
|
||||
<Icon className="size-5" />
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className="truncate text-sm font-medium" title={attachment.original_name}>
|
||||
{attachment.original_name}
|
||||
</span>
|
||||
{attachment.created_at && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(attachment.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => handleDelete(attachment)}
|
||||
title="Delete attachment"
|
||||
>
|
||||
<Trash2 className="size-4 text-destructive" />
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Page ──
|
||||
|
||||
type PaymentMadeItem = {
|
||||
id: number
|
||||
payment_number?: string
|
||||
vendor_name?: string
|
||||
employee_name?: string
|
||||
payment_for?: string
|
||||
payment_made?: string | number
|
||||
payment_mode_name?: string
|
||||
payment_date?: string
|
||||
paid_through?: string
|
||||
notes?: string
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
export default function PaymentsMadePage() {
|
||||
const [attachmentTarget, setAttachmentTarget] = useState<{
|
||||
id: string
|
||||
ref: string
|
||||
} | null>(null)
|
||||
|
||||
return (
|
||||
<>
|
||||
<ResourcePage<PaymentMadesClient>
|
||||
pageTitle="Payments Made"
|
||||
routeKey={PAYMENT_MADE_ROUTES.INDEX}
|
||||
getClient={(api) => api.paymentMades}
|
||||
headerProps={({ invalidateQuery }) => ({
|
||||
actions: (
|
||||
<FormDialog title="Record Payment">
|
||||
{(resourceId) => (
|
||||
<PaymentMadeForm
|
||||
resourceId={resourceId}
|
||||
onSuccess={invalidateQuery}
|
||||
/>
|
||||
)}
|
||||
</FormDialog>
|
||||
),
|
||||
})}
|
||||
columns={({ actionsColumn }) => [
|
||||
{
|
||||
accessorKey: "payment_number",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Payment #" />,
|
||||
cell: ({ row }) => {
|
||||
const item = row.original as unknown as PaymentMadeItem
|
||||
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: "vendor_name",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Vendor" />,
|
||||
cell: ({ row }) => {
|
||||
const item = row.original as unknown as PaymentMadeItem
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<UserIcon className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{item.vendor_name || "—"}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "payment_for",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Payment For" />,
|
||||
cell: ({ row }) => {
|
||||
const item = row.original as unknown as PaymentMadeItem
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<BriefcaseIcon className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="capitalize">{item.payment_for || "—"}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "payment_made",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Amount" />,
|
||||
cell: ({ row }) => {
|
||||
const item = row.original as unknown as PaymentMadeItem
|
||||
const amount = item.payment_made
|
||||
? Number(item.payment_made).toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})
|
||||
: "—"
|
||||
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">
|
||||
{amount}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "payment_mode_name",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Payment Mode" />,
|
||||
cell: ({ row }) => {
|
||||
const item = row.original as unknown as PaymentMadeItem
|
||||
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 as unknown as PaymentMadeItem
|
||||
const formatted = item.payment_date
|
||||
? new Date(item.payment_date).toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
: "—"
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarIcon className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{formatted}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "attachments",
|
||||
header: () => null,
|
||||
cell: ({ row }) => {
|
||||
const item = row.original as any
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
title="Manage Attachments"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setAttachmentTarget({
|
||||
id: String(item.id),
|
||||
ref: item.payment_number || `PAY-${item.id}`,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Paperclip className="size-4" />
|
||||
</Button>
|
||||
)
|
||||
},
|
||||
},
|
||||
actionsColumn(),
|
||||
]}
|
||||
/>
|
||||
|
||||
{attachmentTarget && (
|
||||
<AttachmentsDialog
|
||||
open={!!attachmentTarget}
|
||||
paymentId={attachmentTarget.id}
|
||||
paymentRef={attachmentTarget.ref}
|
||||
onClose={() => setAttachmentTarget(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,72 @@
|
||||
"use client"
|
||||
|
||||
import { ResourcePage } from "@/shared/data-view/resource-page"
|
||||
import { ColumnHeader } from "@/shared/data-view/table-view"
|
||||
import FormDialog from "@/shared/components/form-dialog"
|
||||
import { PurchaseOrderForm } from "@/modules/purchase-orders/purchase-order-form"
|
||||
import { PURCHASE_ORDER_ROUTES } from "@garage/api"
|
||||
import type { PurchaseOrdersClient } from "@garage/api"
|
||||
|
||||
export default function PurchaseOrdersPage() {
|
||||
return (
|
||||
<ResourcePage<PurchaseOrdersClient>
|
||||
pageTitle="Purchase Orders"
|
||||
routeKey={PURCHASE_ORDER_ROUTES.INDEX}
|
||||
getClient={(api) => api.purchaseOrders}
|
||||
headerProps={({ selectedItem, invalidateQuery }) => ({
|
||||
actions: (
|
||||
<FormDialog title="Purchase Order">
|
||||
{(resourceId) => (
|
||||
<PurchaseOrderForm
|
||||
resourceId={resourceId}
|
||||
initialData={selectedItem}
|
||||
onSuccess={invalidateQuery}
|
||||
/>
|
||||
)}
|
||||
</FormDialog>
|
||||
),
|
||||
})}
|
||||
columns={({ actionsColumn }) => [
|
||||
{
|
||||
accessorKey: "order_number",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Order #" />,
|
||||
cell: ({ row }) => (row.original as any).order_number || "—",
|
||||
},
|
||||
{
|
||||
accessorKey: "title",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Title" />,
|
||||
},
|
||||
{
|
||||
accessorKey: "vendor_name",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Vendor" />,
|
||||
cell: ({ row }) => (row.original as any).vendor_name || "—",
|
||||
},
|
||||
{
|
||||
accessorKey: "order_date",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Order Date" />,
|
||||
cell: ({ row }) => {
|
||||
const val = (row.original as any).order_date
|
||||
return val ? new Date(val).toLocaleDateString() : "—"
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "delivery_date",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Delivery Date" />,
|
||||
cell: ({ row }) => {
|
||||
const val = (row.original as any).delivery_date
|
||||
return val ? new Date(val).toLocaleDateString() : "—"
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "created_at",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Created" />,
|
||||
cell: ({ row }) => {
|
||||
const val = (row.original as any).created_at
|
||||
return val ? new Date(val).toLocaleDateString() : "—"
|
||||
},
|
||||
},
|
||||
actionsColumn(),
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,65 @@
|
||||
"use client"
|
||||
|
||||
import FormDialog from "@/shared/components/form-dialog"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { ResourcePage } from "@/shared/data-view/resource-page"
|
||||
import { ColumnHeader } from "@/shared/data-view/table-view"
|
||||
import { VendorCreditForm } from "@/modules/vendor-credits/vendor-credit-form"
|
||||
import { VENDOR_CREDIT_ROUTES } from "@garage/api"
|
||||
import type { VendorCreditsClient } from "@garage/api"
|
||||
|
||||
export default function VendorCreditsPage() {
|
||||
return (
|
||||
<ResourcePage<VendorCreditsClient>
|
||||
pageTitle="Vendor Credits"
|
||||
routeKey={VENDOR_CREDIT_ROUTES.INDEX}
|
||||
getClient={(api) => api.vendorCredits}
|
||||
headerProps={({ selectedItem, invalidateQuery }) => ({
|
||||
actions: (
|
||||
<FormDialog title="Vendor Credit">
|
||||
{(resourceId) => (
|
||||
<VendorCreditForm
|
||||
resourceId={resourceId}
|
||||
initialData={selectedItem}
|
||||
onSuccess={invalidateQuery}
|
||||
/>
|
||||
)}
|
||||
</FormDialog>
|
||||
),
|
||||
})}
|
||||
columns={({ actionsColumn }) => [
|
||||
{
|
||||
accessorKey: "subject",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Subject" />,
|
||||
},
|
||||
{
|
||||
accessorKey: "vendor_name",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Vendor" />,
|
||||
cell: ({ row }) => (row.original as any).vendor_name || "—",
|
||||
},
|
||||
{
|
||||
accessorKey: "bill_number",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Bill #" />,
|
||||
cell: ({ row }) => (row.original as any).bill_number || "—",
|
||||
},
|
||||
{
|
||||
accessorKey: "vendor_credit_date",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Date" />,
|
||||
cell: ({ row }) => {
|
||||
const value = (row.original as any).vendor_credit_date
|
||||
return value ? new Date(value).toLocaleDateString() : "—"
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Status" />,
|
||||
cell: ({ row }) => {
|
||||
const status = (row.original as any).status
|
||||
return <Badge variant={status === "closed" ? "secondary" : "default"}>{status || "—"}</Badge>
|
||||
},
|
||||
},
|
||||
actionsColumn(),
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
import { DashboardDetailsPage } from '@/base/components/layout/dashboard'
|
||||
import { getServerApi } from '@garage/api/server'
|
||||
import { CustomerActions } from '@/modules/customers/customer-actions'
|
||||
import { CustomerProvider } from '@/modules/customers/customer-context'
|
||||
import React from 'react'
|
||||
|
||||
export default async function layout(props: {
|
||||
params: Promise<{ id: string }>
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const { id } = await props.params
|
||||
const api = await getServerApi()
|
||||
const customer = await api.customers.getById(id)
|
||||
|
||||
const firstName = customer.data?.first_name ?? ''
|
||||
const lastName = customer.data?.last_name ?? ''
|
||||
const fullName = [firstName, lastName].filter(Boolean).join(' ') || 'Customer Details'
|
||||
const customerLabel = fullName
|
||||
|
||||
return (
|
||||
<>
|
||||
<CustomerProvider customer={{ id, label: customerLabel }}>
|
||||
<DashboardDetailsPage
|
||||
className="p-0 lg:p-0"
|
||||
title={fullName}
|
||||
description={customer.data?.email ?? customer.data?.phone ?? undefined}
|
||||
backHref="/sales/customers"
|
||||
actions={<CustomerActions customerId={id} />}
|
||||
tabs={[
|
||||
{ href: `/sales/customers/${id}`, label: 'Details' },
|
||||
{ href: `/sales/customers/${id}/notes`, label: 'Notes' },
|
||||
{ href: `/sales/customers/${id}/vehicles`, label: 'Vehicles' },
|
||||
]}
|
||||
>
|
||||
{props.children}
|
||||
</DashboardDetailsPage>
|
||||
</CustomerProvider>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,206 @@
|
||||
"use client"
|
||||
|
||||
import { useParams } from "next/navigation"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { type ColumnDef } from "@tanstack/react-table"
|
||||
import { useState } from "react"
|
||||
import { Plus, Trash2, StickyNote } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { z } from "zod"
|
||||
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { DataTable, ColumnHeader } from "@/shared/data-view/table-view"
|
||||
import { confirm } from "@/shared/components/confirm-dialog"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/components/ui/dialog"
|
||||
import {
|
||||
Field,
|
||||
FieldLabel,
|
||||
FieldError,
|
||||
} from "@/shared/components/ui/field"
|
||||
import { Textarea } from "@/shared/components/ui/textarea"
|
||||
import DashboardPage from "@/base/components/layout/dashboard/dashboard-page"
|
||||
|
||||
type CustomerNote = {
|
||||
id: number
|
||||
note: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
const addNoteSchema = z.object({
|
||||
note: z.string().min(1, "Note content is required"),
|
||||
})
|
||||
type AddNoteValues = z.infer<typeof addNoteSchema>
|
||||
|
||||
export default function CustomerNotesPage() {
|
||||
const { id: customerId } = useParams<{ id: string }>()
|
||||
const api = useAuthApi()
|
||||
const queryClient = useQueryClient()
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
|
||||
const queryKey = ["customer-notes", customerId]
|
||||
|
||||
const { data: customerData, isLoading } = useQuery({
|
||||
queryKey,
|
||||
queryFn: () => api.customers.getById(customerId),
|
||||
})
|
||||
|
||||
const notes: CustomerNote[] = (customerData?.data as any)?.notes ?? []
|
||||
|
||||
const meta = (customerData as any)?.meta
|
||||
const pagination = {
|
||||
page: meta?.current_page ?? 1,
|
||||
pageSize: meta?.per_page ?? 15,
|
||||
pageCount: meta?.last_page ?? 1,
|
||||
total: meta?.total ?? notes.length,
|
||||
}
|
||||
|
||||
const addNoteMutation = useMutation({
|
||||
mutationFn: (values: AddNoteValues) =>
|
||||
api.customers.addNote(customerId, { note: values.note }),
|
||||
onSuccess: () => {
|
||||
toast.success("Note added successfully.")
|
||||
queryClient.invalidateQueries({ queryKey })
|
||||
setDialogOpen(false)
|
||||
reset()
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Failed to add note.")
|
||||
},
|
||||
})
|
||||
|
||||
const deleteNoteMutation = useMutation({
|
||||
mutationFn: (noteId: number) => api.customers.deleteNote(customerId, noteId),
|
||||
onSuccess: () => {
|
||||
toast.success("Note deleted successfully.")
|
||||
queryClient.invalidateQueries({ queryKey })
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Failed to delete note.")
|
||||
},
|
||||
})
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = useForm<AddNoteValues>({
|
||||
resolver: zodResolver(addNoteSchema),
|
||||
defaultValues: { note: "" },
|
||||
})
|
||||
|
||||
const handleDelete = async (note: CustomerNote) => {
|
||||
const confirmed = await confirm({
|
||||
title: "Delete Note",
|
||||
description: "Are you sure you want to delete this note?",
|
||||
confirmLabel: "Delete",
|
||||
variant: "destructive",
|
||||
})
|
||||
if (confirmed) {
|
||||
deleteNoteMutation.mutate(note.id)
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnDef<CustomerNote>[] = [
|
||||
{
|
||||
accessorKey: "note",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Note" />,
|
||||
cell: ({ getValue }) => (
|
||||
<span className="whitespace-pre-wrap text-sm">{getValue<string>()}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "created_at",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Created At" />,
|
||||
cell: ({ getValue }) => {
|
||||
const val = getValue<string>()
|
||||
return val ? new Date(val).toLocaleDateString() : "—"
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <span className="sr-only">Actions</span>,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDelete(row.original)}
|
||||
disabled={deleteNoteMutation.isPending}
|
||||
>
|
||||
<Trash2 className="size-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<DashboardPage
|
||||
header={null}
|
||||
title="Notes"
|
||||
toolbar={
|
||||
<Button size="sm" onClick={() => setDialogOpen(true)}>
|
||||
<Plus className="size-4" />
|
||||
Add Note
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={notes}
|
||||
pagination={pagination}
|
||||
sorting={[]}
|
||||
onChange={() => {}}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Note</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form
|
||||
onSubmit={handleSubmit((values) => addNoteMutation.mutate(values))}
|
||||
className="grid gap-4"
|
||||
>
|
||||
<Field>
|
||||
<FieldLabel>Note</FieldLabel>
|
||||
<Textarea
|
||||
{...register("note")}
|
||||
placeholder="Enter note..."
|
||||
rows={4}
|
||||
/>
|
||||
{errors.note && <FieldError>{errors.note.message}</FieldError>}
|
||||
</Field>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setDialogOpen(false)
|
||||
reset()
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={addNoteMutation.isPending}>
|
||||
{addNoteMutation.isPending ? "Saving..." : "Save Note"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</DashboardPage>
|
||||
)
|
||||
}
|
||||
|
||||
@ -0,0 +1,19 @@
|
||||
import { getServerApi } from '@garage/api/server'
|
||||
import { CustomerGeneralInfo } from '@/modules/customers/customer-general-info'
|
||||
import DashboardPage from '@/base/components/layout/dashboard/dashboard-page'
|
||||
|
||||
export default async function page(props: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await props.params
|
||||
const api = await getServerApi()
|
||||
const customer = await api.customers.getById(id)
|
||||
|
||||
if (!customer.data) {
|
||||
return <div className="text-muted-foreground p-6">Customer not found.</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardPage header={null}>
|
||||
<CustomerGeneralInfo customer={customer.data} />
|
||||
</DashboardPage>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,72 @@
|
||||
"use client"
|
||||
|
||||
import { use } from "react"
|
||||
import { ResourcePage } from "@/shared/data-view/resource-page"
|
||||
import { ColumnHeader } from "@/shared/data-view/table-view"
|
||||
import FormDialog from "@/shared/components/form-dialog"
|
||||
import { VehicleForm } from "@/modules/vehicles/vehicle-form"
|
||||
import { VEHICLE_ROUTES } from "@garage/api"
|
||||
import type { VehiclesClient } from "@garage/api"
|
||||
import { CarIcon } from "lucide-react"
|
||||
import { useCustomer } from "@/modules/customers/customer-context"
|
||||
|
||||
export default function CustomerVehiclesPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id: customerId } = use(params)
|
||||
const customer = useCustomer()
|
||||
|
||||
return (
|
||||
<ResourcePage<VehiclesClient>
|
||||
toolbar={({ invalidateQuery, selectedItem, closeDialog }) => (
|
||||
<FormDialog title="Vehicle">
|
||||
{(resourceId) => (
|
||||
<VehicleForm
|
||||
resourceId={resourceId}
|
||||
initialData={selectedItem}
|
||||
onSuccess={() => {
|
||||
closeDialog()
|
||||
invalidateQuery()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</FormDialog>
|
||||
)}
|
||||
pageTitle="Customer Vehicles"
|
||||
routeKey={VEHICLE_ROUTES.INDEX}
|
||||
getClient={(api) => api.vehicles}
|
||||
extraParams={{ customer_id: customerId }}
|
||||
header={null}
|
||||
columns={({ actionsColumn }) => [
|
||||
{
|
||||
accessorKey: "make",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Vehicle" />,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original as any
|
||||
const make = r.make ?? ""
|
||||
const model = r.model ?? ""
|
||||
const display = `${make} ${model}`.trim() || "—"
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<CarIcon className="size-4 text-muted-foreground" />
|
||||
<span className="font-medium">{display}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "year",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Year" />,
|
||||
cell: ({ row }) => (row.original as any).year ?? "—",
|
||||
},
|
||||
{
|
||||
accessorKey: "license_plate",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="License Plate" />,
|
||||
cell: ({ row }) => {
|
||||
const val = (row.original as any).license_plate
|
||||
return val ? <span className="font-mono text-xs">{val}</span> : "—"
|
||||
},
|
||||
},
|
||||
actionsColumn(),
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { ResourcePage } from '@/shared/data-view/resource-page'
|
||||
import { ColumnHeader } from '@/shared/data-view/table-view'
|
||||
import FormDialog from '@/shared/components/form-dialog'
|
||||
@ -9,11 +10,13 @@ import type { CustomersClient } from '@garage/api'
|
||||
import { Building2Icon, UserIcon } from 'lucide-react'
|
||||
|
||||
export default function CustomersPage() {
|
||||
const router = useRouter()
|
||||
return (
|
||||
<ResourcePage<CustomersClient>
|
||||
pageTitle='Customers'
|
||||
routeKey={CUSTOMER_ROUTES.INDEX}
|
||||
getClient={(api) => api.customers}
|
||||
onRowClick={(row) => router.push(`/sales/customers/${(row as any).id}`)}
|
||||
headerProps={({ selectedItem, invalidateQuery }) => ({
|
||||
actions: (
|
||||
<FormDialog title="Customer">
|
||||
|
||||
@ -0,0 +1,41 @@
|
||||
import { DashboardDetailsPage } from '@/base/components/layout/dashboard'
|
||||
import { getServerApi } from '@garage/api/server'
|
||||
import { EstimateActions } from '@/modules/estimates/estimate-actions'
|
||||
import { EstimateProvider } from '@/modules/estimates/estimate-context'
|
||||
import { FileTextIcon } from 'lucide-react'
|
||||
import React from 'react'
|
||||
|
||||
export default async function layout(props: {
|
||||
params: Promise<{ id: string }>
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const { id } = await props.params
|
||||
const api = await getServerApi()
|
||||
const estimate = await api.estimates.getById(id)
|
||||
|
||||
const estimateData = (estimate as any)?.data
|
||||
const title = estimateData?.title || estimateData?.estimate_number || `Estimate #${id}`
|
||||
const estimateLabel = estimateData?.estimate_number
|
||||
? `${estimateData.estimate_number}${estimateData.title ? ` — ${estimateData.title}` : ''}`
|
||||
: title
|
||||
|
||||
return (
|
||||
<EstimateProvider estimate={{ id, label: estimateLabel }}>
|
||||
<DashboardDetailsPage
|
||||
className="p-0 lg:p-0"
|
||||
icon={<FileTextIcon className="size-5" />}
|
||||
title={title}
|
||||
description={
|
||||
estimateData?.date ? `Date: ${estimateData.date}` : undefined
|
||||
}
|
||||
backHref="/sales/estimates"
|
||||
actions={<EstimateActions estimateId={id} />}
|
||||
tabs={[
|
||||
{ href: `/sales/estimates/${id}`, label: 'Details' },
|
||||
]}
|
||||
>
|
||||
{props.children}
|
||||
</DashboardDetailsPage>
|
||||
</EstimateProvider>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
import { getServerApi } from '@garage/api/server'
|
||||
import { EstimateGeneralInfo } from '@/modules/estimates/estimate-general-info'
|
||||
import DashboardPage from '@/base/components/layout/dashboard/dashboard-page'
|
||||
|
||||
export default async function page(props: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await props.params
|
||||
const api = await getServerApi()
|
||||
const estimate = await api.estimates.getById(id)
|
||||
|
||||
const estimateData = (estimate as any)?.data
|
||||
|
||||
if (!estimateData) {
|
||||
return <div className="text-muted-foreground p-4">Estimate not found.</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardPage header={null}>
|
||||
<EstimateGeneralInfo estimate={estimateData} />
|
||||
</DashboardPage>
|
||||
)
|
||||
}
|
||||
@ -6,20 +6,11 @@ import FormDialog from '@/shared/components/form-dialog'
|
||||
import { EstimateForm } from '@/modules/estimates/estimate-form'
|
||||
import { ESTIMATE_ROUTES } from '@garage/api'
|
||||
import type { EstimatesClient } from '@garage/api'
|
||||
import { FileTextIcon } from 'lucide-react'
|
||||
import { Car, FileTextIcon } from 'lucide-react'
|
||||
import { Button } from '@/shared/components/ui/button'
|
||||
import Link from 'next/link'
|
||||
|
||||
type EstimateItem = {
|
||||
id: number
|
||||
title?: string
|
||||
estimate_number?: string
|
||||
date?: string
|
||||
customer_name?: string
|
||||
vehicle_name?: string
|
||||
has_insurance?: boolean
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
export default function EstimatesPage({ vehicleId }: { vehicleId: string }) {
|
||||
export default function EstimatesPage() {
|
||||
return (
|
||||
<ResourcePage<EstimatesClient>
|
||||
pageTitle="Estimates"
|
||||
@ -31,7 +22,6 @@ export default function EstimatesPage({ vehicleId }: { vehicleId: string }) {
|
||||
{(resourceId) => (
|
||||
<EstimateForm
|
||||
resourceId={resourceId}
|
||||
initialData={{ vehicle:{label: vehicleId, value: vehicleId}}}
|
||||
onSuccess={invalidateQuery}
|
||||
/>
|
||||
)}
|
||||
@ -43,11 +33,13 @@ export default function EstimatesPage({ vehicleId }: { vehicleId: string }) {
|
||||
accessorKey: "title",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Title" />,
|
||||
cell: ({ row }) => {
|
||||
const item = row.original as unknown as EstimateItem
|
||||
const item = row.original
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href={`/sales/estimates/${item.id}`} className="flex items-center gap-2 hover:underline">
|
||||
<FileTextIcon className="text-muted-foreground h-4 w-4" />
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
@ -61,8 +53,16 @@ export default function EstimatesPage({ vehicleId }: { vehicleId: string }) {
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Customer" />,
|
||||
},
|
||||
{
|
||||
accessorKey: "vehicle_name",
|
||||
accessorKey: "vehicle_id",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Vehicle" />,
|
||||
cell: ({ row }) => {
|
||||
const item = row.original
|
||||
return <Button variant="outline" asChild size="sm">
|
||||
<Link href={`/sales/vehicles/${item.vehicle_id}`}>
|
||||
<Car/> Go to vehicle
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "date",
|
||||
@ -72,7 +72,7 @@ export default function EstimatesPage({ vehicleId }: { vehicleId: string }) {
|
||||
accessorKey: "has_insurance",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Insurance" />,
|
||||
cell: ({ row }) => {
|
||||
const item = row.original as unknown as EstimateItem
|
||||
const item = row.original
|
||||
return item.has_insurance ? "Yes" : "No"
|
||||
},
|
||||
},
|
||||
@ -80,7 +80,7 @@ export default function EstimatesPage({ vehicleId }: { vehicleId: string }) {
|
||||
accessorKey: "created_at",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Created" />,
|
||||
cell: ({ row }) => {
|
||||
const item = row.original as unknown as EstimateItem
|
||||
const item = row.original
|
||||
return item.created_at ? new Date(item.created_at).toLocaleDateString() : "—"
|
||||
},
|
||||
},
|
||||
|
||||
@ -0,0 +1,589 @@
|
||||
"use client"
|
||||
|
||||
import { use, useState, useRef, useCallback } from "react"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { DataTable, ColumnHeader } from "@/shared/data-view/table-view"
|
||||
import DashboardPage from "@/base/components/layout/dashboard/dashboard-page"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/components/ui/dropdown-menu"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/components/ui/dialog"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { Label } from "@/shared/components/ui/label"
|
||||
import { Textarea } from "@/shared/components/ui/textarea"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
Plus,
|
||||
Ellipsis,
|
||||
Pencil,
|
||||
Trash2,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
XCircle,
|
||||
MinusCircle,
|
||||
CircleDot,
|
||||
Paperclip,
|
||||
FileUp,
|
||||
FileText,
|
||||
FileImage,
|
||||
File,
|
||||
X,
|
||||
} from "lucide-react"
|
||||
import { INSPECTION_ROUTES } from "@garage/api"
|
||||
|
||||
// ── Types ──
|
||||
|
||||
type CheckpointItem = {
|
||||
id: number
|
||||
inspection_id?: number
|
||||
name?: string
|
||||
description?: string
|
||||
record_type?: string
|
||||
condition_rate?: number
|
||||
file?: string
|
||||
status?: string
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
// ── Constants ──
|
||||
|
||||
const CHECKPOINT_STATUSES = [
|
||||
{ value: "passed", label: "Passed", icon: CheckCircle2, color: "bg-green-100 text-green-800" },
|
||||
{ value: "need_attention", label: "Need Attention", icon: AlertTriangle, color: "bg-yellow-100 text-yellow-800" },
|
||||
{ value: "failed", label: "Failed", icon: XCircle, color: "bg-red-100 text-red-800" },
|
||||
{ value: "not_applicable", label: "Not Applicable", icon: MinusCircle, color: "bg-gray-100 text-gray-800" },
|
||||
{ value: "not_inspected", label: "Not Inspected", icon: CircleDot, color: "bg-blue-100 text-blue-800" },
|
||||
] as const
|
||||
|
||||
const RECORD_TYPES = [
|
||||
{ value: "record_conditions", label: "Record Conditions" },
|
||||
{ value: "record_audio", label: "Record Audio" },
|
||||
{ value: "record_video", label: "Record Video" },
|
||||
{ value: "capture_photo", label: "Capture Photo" },
|
||||
] as const
|
||||
|
||||
function formatStatus(status?: string) {
|
||||
if (!status) return "Not Inspected"
|
||||
return status.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
}
|
||||
|
||||
function getStatusConfig(status?: string) {
|
||||
return CHECKPOINT_STATUSES.find((s) => s.value === status) || CHECKPOINT_STATUSES[4]
|
||||
}
|
||||
|
||||
// ── Checkpoint Form Dialog ──
|
||||
|
||||
function CheckpointFormDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
inspectionId,
|
||||
checkpoint,
|
||||
onSuccess,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
inspectionId: string
|
||||
checkpoint?: CheckpointItem | null
|
||||
onSuccess: () => void
|
||||
}) {
|
||||
const api = useAuthApi()
|
||||
const [name, setName] = useState(checkpoint?.name ?? "")
|
||||
const [description, setDescription] = useState(checkpoint?.description ?? "")
|
||||
const [recordType, setRecordType] = useState(checkpoint?.record_type ?? "record_conditions")
|
||||
const [isPending, setIsPending] = useState(false)
|
||||
|
||||
const isEditing = !!checkpoint
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!name.trim()) {
|
||||
toast.error("Name is required")
|
||||
return
|
||||
}
|
||||
setIsPending(true)
|
||||
try {
|
||||
const payload = {
|
||||
inspection_id: Number(inspectionId),
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
record_type: recordType,
|
||||
}
|
||||
|
||||
if (isEditing) {
|
||||
const promise = api.inspections.updateCheckpoint(String(checkpoint.id), payload)
|
||||
toast.promise(promise, {
|
||||
loading: "Updating checkpoint...",
|
||||
success: "Checkpoint updated",
|
||||
error: "Failed to update checkpoint",
|
||||
})
|
||||
await promise
|
||||
} else {
|
||||
const promise = api.inspections.createCheckpoint(payload)
|
||||
toast.promise(promise, {
|
||||
loading: "Creating checkpoint...",
|
||||
success: "Checkpoint created",
|
||||
error: "Failed to create checkpoint",
|
||||
})
|
||||
await promise
|
||||
}
|
||||
onSuccess()
|
||||
onOpenChange(false)
|
||||
} finally {
|
||||
setIsPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEditing ? "Edit Checkpoint" : "Add Checkpoint"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cp-name">Name *</Label>
|
||||
<Input
|
||||
id="cp-name"
|
||||
placeholder="e.g. Engine Oil Level"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cp-description">Description</Label>
|
||||
<Textarea
|
||||
id="cp-description"
|
||||
placeholder="Check oil level and condition"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cp-record-type">Record Type</Label>
|
||||
<Select value={recordType} onValueChange={setRecordType}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{RECORD_TYPES.map((rt) => (
|
||||
<SelectItem key={rt.value} value={rt.value}>
|
||||
{rt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isEditing ? <Pencil className="size-4" /> : <Plus className="size-4" />}
|
||||
{isPending
|
||||
? (isEditing ? "Updating..." : "Creating...")
|
||||
: (isEditing ? "Update Checkpoint" : "Create Checkpoint")}
|
||||
</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Attachments Dialog ──
|
||||
|
||||
function getFileIcon(url: string) {
|
||||
const ext = url.split(".").pop()?.toLowerCase()
|
||||
if (["png", "jpg", "jpeg", "gif", "webp", "svg"].includes(ext ?? "")) return FileImage
|
||||
if (ext === "pdf") return FileText
|
||||
return File
|
||||
}
|
||||
|
||||
function getFileName(url: string) {
|
||||
try {
|
||||
return decodeURIComponent(url.split("/").pop() ?? "Attachment")
|
||||
} catch {
|
||||
return url.split("/").pop() ?? "Attachment"
|
||||
}
|
||||
}
|
||||
|
||||
function isImageUrl(url: string) {
|
||||
const ext = url.split(".").pop()?.toLowerCase()
|
||||
return ["png", "jpg", "jpeg", "gif", "webp", "svg"].includes(ext ?? "")
|
||||
}
|
||||
|
||||
function CheckpointAttachmentsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
checkpoint,
|
||||
onSuccess,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
checkpoint: CheckpointItem | null
|
||||
onSuccess: () => void
|
||||
}) {
|
||||
const api = useAuthApi()
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
|
||||
const handleUpload = useCallback(async (file: globalThis.File) => {
|
||||
if (!checkpoint) return
|
||||
setIsUploading(true)
|
||||
try {
|
||||
const promise = api.inspections.uploadCheckpointMedia(
|
||||
String(checkpoint.id),
|
||||
{ file },
|
||||
)
|
||||
toast.promise(promise, {
|
||||
loading: "Uploading attachment...",
|
||||
success: "Attachment uploaded",
|
||||
error: "Failed to upload attachment",
|
||||
})
|
||||
await promise
|
||||
onSuccess()
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
if (fileInputRef.current) fileInputRef.current.value = ""
|
||||
}
|
||||
}, [api, checkpoint, onSuccess])
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!checkpoint) return
|
||||
const promise = api.inspections.deleteCheckpointMedia(String(checkpoint.id))
|
||||
toast.promise(promise, {
|
||||
loading: "Removing attachment...",
|
||||
success: "Attachment removed",
|
||||
error: "Failed to remove attachment",
|
||||
})
|
||||
await promise
|
||||
onSuccess()
|
||||
}, [api, checkpoint, onSuccess])
|
||||
|
||||
const hasFile = !!checkpoint?.file
|
||||
const FileIcon = checkpoint?.file ? getFileIcon(checkpoint.file) : File
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Attachments — {checkpoint?.name}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Current attachment */}
|
||||
{hasFile ? (
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3 min-w-0 ">
|
||||
{isImageUrl(checkpoint.file!) ? (
|
||||
<img
|
||||
src={checkpoint.file!}
|
||||
alt="Checkpoint attachment"
|
||||
className="size-12 rounded-md object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex size-12 shrink-0 items-center justify-center rounded-md bg-muted">
|
||||
<FileIcon className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex flex-col gap-0.5 max-w-48">
|
||||
<a
|
||||
href={checkpoint.file!}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="truncate text-sm font-medium text-primary hover:underline"
|
||||
>
|
||||
{getFileName(checkpoint.file!)}
|
||||
</a>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Current attachment
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0 text-destructive hover:text-destructive"
|
||||
onClick={handleDelete}
|
||||
title="Remove attachment"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-2 rounded-lg border border-dashed p-6 text-muted-foreground">
|
||||
<Paperclip className="size-8" />
|
||||
<span className="text-sm">No attachments yet</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload area */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.csv,.txt,audio/*,video/*"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) handleUpload(file)
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
disabled={isUploading}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<FileUp className="size-4" />
|
||||
{isUploading
|
||||
? "Uploading..."
|
||||
: hasFile
|
||||
? "Replace Attachment"
|
||||
: "Add Attachment"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main Page ──
|
||||
|
||||
export default function InspectionCheckpointsPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id: inspectionId } = use(params)
|
||||
const api = useAuthApi()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const [formOpen, setFormOpen] = useState(false)
|
||||
const [editingCheckpoint, setEditingCheckpoint] = useState<CheckpointItem | null>(null)
|
||||
const [attachmentsCheckpoint, setAttachmentsCheckpoint] = useState<CheckpointItem | null>(null)
|
||||
|
||||
const queryKey = [INSPECTION_ROUTES.CHECKPOINTS, inspectionId]
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey,
|
||||
queryFn: () => api.inspections.listCheckpoints({ inspection_id: inspectionId } as never),
|
||||
})
|
||||
|
||||
const invalidate = () => {
|
||||
queryClient.invalidateQueries({ queryKey })
|
||||
}
|
||||
|
||||
// ── Status change mutation ──
|
||||
const changeStatusMutation = useMutation({
|
||||
mutationFn: ({ checkpointId, status }: { checkpointId: number; status: string }) =>
|
||||
api.inspections.changeCheckpointStatus({
|
||||
inspection_id: Number(inspectionId),
|
||||
inspection_check_point_id: String(checkpointId),
|
||||
name: "", // required by API type but server uses checkpoint id from context
|
||||
record_type: "record_conditions",
|
||||
status,
|
||||
} as never),
|
||||
onSuccess: () => invalidate(),
|
||||
})
|
||||
|
||||
// ── Delete checkpoint mutation ──
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (checkpointId: string) => api.inspections.destroyCheckpoint(checkpointId),
|
||||
onSuccess: () => {
|
||||
toast.success("Checkpoint deleted")
|
||||
invalidate()
|
||||
},
|
||||
onError: () => toast.error("Failed to delete checkpoint"),
|
||||
})
|
||||
|
||||
const handleEdit = (checkpoint: CheckpointItem) => {
|
||||
setEditingCheckpoint(checkpoint)
|
||||
setFormOpen(true)
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditingCheckpoint(null)
|
||||
setFormOpen(true)
|
||||
}
|
||||
|
||||
const handleStatusChange = (checkpointId: number, status: string) => {
|
||||
const promise = changeStatusMutation.mutateAsync({ checkpointId, status, })
|
||||
toast.promise(promise, {
|
||||
loading: "Updating status...",
|
||||
success: `Status changed to ${formatStatus(status)}`,
|
||||
error: "Failed to update status",
|
||||
})
|
||||
}
|
||||
|
||||
const checkpoints = (data as any)?.data ?? []
|
||||
const meta = (data as any)?.meta
|
||||
|
||||
const pagination = {
|
||||
page: meta?.current_page ?? 1,
|
||||
pageSize: meta?.per_page ?? 15,
|
||||
pageCount: meta?.last_page ?? 1,
|
||||
total: meta?.total ?? 0,
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: ({ column }: any) => <ColumnHeader column={column} title="Name" />,
|
||||
cell: ({ row }: any) => {
|
||||
const item = row.original as CheckpointItem
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-medium">{item.name}</span>
|
||||
{item.description && (
|
||||
<span className="text-xs text-muted-foreground">{item.description}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "record_type",
|
||||
header: ({ column }: any) => <ColumnHeader column={column} title="Record Type" />,
|
||||
cell: ({ row }: any) => {
|
||||
const item = row.original as CheckpointItem
|
||||
const rt = RECORD_TYPES.find((r) => r.value === item.record_type)
|
||||
return rt?.label ?? item.record_type ?? "—"
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: ({ column }: any) => <ColumnHeader column={column} title="Status" />,
|
||||
cell: ({ row }: any) => {
|
||||
const item = row.original as CheckpointItem
|
||||
const config = getStatusConfig(item.status)
|
||||
return (
|
||||
<Badge className={config.color}>
|
||||
<config.icon className="mr-1 size-3" />
|
||||
{formatStatus(item.status)}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "condition_rate",
|
||||
header: ({ column }: any) => <ColumnHeader column={column} title="Condition" />,
|
||||
cell: ({ row }: any) => {
|
||||
const item = row.original as CheckpointItem
|
||||
return item.condition_rate != null ? `${item.condition_rate}/10` : "—"
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "attachments",
|
||||
header: () => <span className="text-xs">Attachments</span>,
|
||||
cell: ({ row }: any) => {
|
||||
const item = row.original as CheckpointItem
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
onClick={() => setAttachmentsCheckpoint(item)}
|
||||
>
|
||||
<Paperclip className="size-3.5" />
|
||||
{item.file ? (
|
||||
<Badge variant="secondary" className="px-1.5 py-0 text-xs">1</Badge>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">0</span>
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
},
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }: any) => {
|
||||
const item = row.original as CheckpointItem
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="size-8">
|
||||
<Ellipsis className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{CHECKPOINT_STATUSES.map((s) => (
|
||||
<DropdownMenuItem
|
||||
key={s.value}
|
||||
onClick={() => handleStatusChange(item.id, s.value)}
|
||||
disabled={item.status === s.value}
|
||||
>
|
||||
<s.icon className="size-4" />
|
||||
{s.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => handleEdit(item)}>
|
||||
<Pencil className="size-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => deleteMutation.mutate(String(item.id))}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<DashboardPage header={null}>
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<h3 className="text-lg font-semibold">Checkpoints</h3>
|
||||
<Button onClick={handleAdd} size="sm">
|
||||
<Plus className="size-4" />
|
||||
Add Checkpoint
|
||||
</Button>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={checkpoints}
|
||||
pagination={pagination}
|
||||
sorting={[]}
|
||||
onChange={() => {}}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<CheckpointFormDialog
|
||||
open={formOpen}
|
||||
onOpenChange={(open) => {
|
||||
setFormOpen(open)
|
||||
if (!open) setEditingCheckpoint(null)
|
||||
}}
|
||||
inspectionId={inspectionId}
|
||||
checkpoint={editingCheckpoint}
|
||||
onSuccess={invalidate}
|
||||
/>
|
||||
<CheckpointAttachmentsDialog
|
||||
open={!!attachmentsCheckpoint}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setAttachmentsCheckpoint(null)
|
||||
}}
|
||||
checkpoint={attachmentsCheckpoint}
|
||||
onSuccess={invalidate}
|
||||
/>
|
||||
</DashboardPage>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,42 @@
|
||||
"use client"
|
||||
|
||||
import { use } from "react"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { InspectionForm } from "@/modules/inspections/inspection-form"
|
||||
import DashboardPage from "@/base/components/layout/dashboard/dashboard-page"
|
||||
import { INSPECTION_ROUTES } from "@garage/api"
|
||||
|
||||
export default function InspectionEditPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = use(params)
|
||||
const api = useAuthApi()
|
||||
const router = useRouter()
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: [INSPECTION_ROUTES.BY_ID, id],
|
||||
queryFn: () => api.inspections.getById(id),
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<DashboardPage header={null}>
|
||||
<div className="flex items-center justify-center p-8 text-muted-foreground">
|
||||
Loading...
|
||||
</div>
|
||||
</DashboardPage>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardPage header={null}>
|
||||
<div className="mx-auto max-w-2xl p-6">
|
||||
<InspectionForm
|
||||
resourceId={id}
|
||||
initialData={data}
|
||||
onSuccess={() => router.push(`/sales/inspections/${id}`)}
|
||||
/>
|
||||
</div>
|
||||
</DashboardPage>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,42 @@
|
||||
import { DashboardDetailsPage } from '@/base/components/layout/dashboard'
|
||||
import { getServerApi } from '@garage/api/server'
|
||||
import { InspectionActions } from '@/modules/inspections/inspection-actions'
|
||||
import { InspectionProvider } from '@/modules/inspections/inspection-context'
|
||||
import React from 'react'
|
||||
|
||||
export default async function layout(props: {
|
||||
params: Promise<{ id: string }>
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const { id } = await props.params
|
||||
const api = await getServerApi()
|
||||
const inspection = await api.inspections.getById(id)
|
||||
|
||||
const title = inspection.data?.title || 'Inspection Details'
|
||||
const orderNumber = inspection.data?.order_number
|
||||
const status = inspection.data?.status
|
||||
|
||||
return (
|
||||
<InspectionProvider inspection={{ id, label: title }}>
|
||||
<DashboardDetailsPage
|
||||
className="p-0 lg:p-0"
|
||||
title={title}
|
||||
description={orderNumber ? `Order: ${orderNumber}` : undefined}
|
||||
backHref="/sales/inspections"
|
||||
actions={<InspectionActions inspectionId={id} status={status} />}
|
||||
tabs={[
|
||||
{
|
||||
href: `/sales/inspections/${id}`,
|
||||
label: 'Details',
|
||||
},
|
||||
{
|
||||
href: `/sales/inspections/${id}/checkpoints`,
|
||||
label: 'Checkpoints',
|
||||
},
|
||||
]}
|
||||
>
|
||||
{props.children}
|
||||
</DashboardDetailsPage>
|
||||
</InspectionProvider>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
import { getServerApi } from '@garage/api/server'
|
||||
import { InspectionGeneralInfo } from '@/modules/inspections/inspection-general-info'
|
||||
import DashboardPage from '@/base/components/layout/dashboard/dashboard-page'
|
||||
|
||||
export default async function page(props: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await props.params
|
||||
const api = await getServerApi()
|
||||
const inspection = await api.inspections.getById(id)
|
||||
|
||||
if (!inspection.data) {
|
||||
return <div className="text-muted-foreground">Inspection not found.</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardPage header={null}>
|
||||
<InspectionGeneralInfo inspection={inspection.data} />
|
||||
</DashboardPage>
|
||||
)
|
||||
}
|
||||
@ -6,13 +6,17 @@ import FormDialog from "@/shared/components/form-dialog"
|
||||
import { InspectionForm } from "@/modules/inspections/inspection-form"
|
||||
import { INSPECTION_ROUTES } from "@garage/api"
|
||||
import type { InspectionsClient } from "@garage/api"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
export default function InspectionsPage() {
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
<ResourcePage<InspectionsClient>
|
||||
pageTitle="Inspections"
|
||||
routeKey={INSPECTION_ROUTES.INDEX}
|
||||
getClient={(api) => api.inspections}
|
||||
onRowClick={(row) => router.push(`/sales/inspections/${(row as any).id}`)}
|
||||
headerProps={({ selectedItem, invalidateQuery }) => ({
|
||||
actions: (
|
||||
<FormDialog title="Inspection">
|
||||
|
||||
@ -0,0 +1,100 @@
|
||||
"use client"
|
||||
|
||||
import { use } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { ResourcePage } from "@/shared/data-view/resource-page"
|
||||
import { ColumnHeader } from "@/shared/data-view/table-view"
|
||||
import FormDialog from "@/shared/components/form-dialog"
|
||||
import { AppointmentForm } from "@/modules/appointments/appointment-form"
|
||||
import { APPOINTMENT_ROUTES } from "@garage/api"
|
||||
import type { AppointmentsClient } from "@garage/api"
|
||||
import { CalendarCheck2Icon, ClockIcon } from "lucide-react"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { useJobCard } from "@/modules/job-cards/job-card-context"
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
requested: "bg-yellow-100 text-yellow-800",
|
||||
confirmed: "bg-blue-100 text-blue-800",
|
||||
in_progress: "bg-purple-100 text-purple-800",
|
||||
completed: "bg-green-100 text-green-800",
|
||||
cancelled: "bg-red-100 text-red-800",
|
||||
}
|
||||
|
||||
export default function JobCardAppointmentsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}) {
|
||||
const { id: jobCardId } = use(params)
|
||||
const router = useRouter()
|
||||
const jobCard = useJobCard()
|
||||
|
||||
const defaultJobCard = jobCard
|
||||
? { value: String((jobCard as any).id), label: (jobCard as any).label || (jobCard as any).title || `Job Card` }
|
||||
: null
|
||||
|
||||
return (
|
||||
<ResourcePage<AppointmentsClient>
|
||||
pageTitle="Appointments"
|
||||
routeKey={APPOINTMENT_ROUTES.INDEX}
|
||||
getClient={(api) => api.appointments}
|
||||
extraParams={{ job_card_id: jobCardId }}
|
||||
header={null}
|
||||
onRowClick={(row) => router.push(`/calendar/appointment/${(row as any).id}`)}
|
||||
toolbar={({ invalidateQuery, selectedItem, closeDialog }) => (
|
||||
<FormDialog title="Appointment">
|
||||
{(resourceId) => (
|
||||
<AppointmentForm
|
||||
resourceId={resourceId}
|
||||
initialData={selectedItem ?? { job_card: defaultJobCard }}
|
||||
onSuccess={() => { closeDialog(); invalidateQuery() }}
|
||||
/>
|
||||
)}
|
||||
</FormDialog>
|
||||
)}
|
||||
columns={({ actionsColumn }) => [
|
||||
{
|
||||
accessorKey: "title",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Title" />,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarCheck2Icon className="size-4 text-muted-foreground" />
|
||||
<span>{(row.original as any).title}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "date",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Date" />,
|
||||
},
|
||||
{
|
||||
accessorKey: "from_time",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Time" />,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original as any
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<ClockIcon className="size-3 text-muted-foreground" />
|
||||
<span>{r.from_time} - {r.to_time}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Status" />,
|
||||
cell: ({ row }) => {
|
||||
const status = (row.original as any).status
|
||||
const colorClass = STATUS_COLORS[status] ?? "bg-gray-100 text-gray-800"
|
||||
return (
|
||||
<Badge className={colorClass}>
|
||||
{status?.replace("_", " ") ?? "—"}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
},
|
||||
actionsColumn(),
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -10,15 +10,11 @@ import { useAuthApi } from "@/shared/useApi"
|
||||
import { confirm } from "@/shared/components/confirm-dialog"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent } from "@/shared/components/ui/card"
|
||||
import { JOB_CARD_ROUTES } from "@garage/api"
|
||||
import { JOB_CARD_ROUTES, JobCardResponseData } from "@garage/api"
|
||||
import DashboardPage from "@/base/components/layout/dashboard/dashboard-page"
|
||||
import { useJobCard } from "@/modules/job-cards/job-card-context"
|
||||
import { CONSTANTS } from "@/config/constants"
|
||||
|
||||
type Attachment = {
|
||||
id: number
|
||||
file_name: string
|
||||
url: string
|
||||
mime_type?: string
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
function getFileIcon(mimeType?: string) {
|
||||
if (mimeType?.startsWith("image/")) return ImageIcon
|
||||
@ -35,13 +31,9 @@ export default function JobCardAttachmentsPage() {
|
||||
|
||||
const queryKey = [JOB_CARD_ROUTES.INDEX, jobCardId, "attachments"]
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey,
|
||||
queryFn: () => api.jobCards.show(jobCardId),
|
||||
})
|
||||
const jobcard = useJobCard()
|
||||
|
||||
const jobCard = (data as any)?.data ?? data
|
||||
const attachments: Attachment[] = jobCard?.documents ?? jobCard?.attachments ?? []
|
||||
const attachments = jobcard?.attachment_files
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (attachmentId: number) =>
|
||||
@ -55,10 +47,10 @@ export default function JobCardAttachmentsPage() {
|
||||
},
|
||||
})
|
||||
|
||||
const handleDelete = async (attachment: Attachment) => {
|
||||
const handleDelete = async (attachment: JobCardResponseData['attachment_files'][number]) => {
|
||||
const confirmed = await confirm({
|
||||
title: "Delete Attachment",
|
||||
description: `Are you sure you want to delete "${attachment.file_name}"?`,
|
||||
description: `Are you sure you want to delete "${attachment.original_name}"?`,
|
||||
confirmLabel: "Delete",
|
||||
variant: "destructive",
|
||||
})
|
||||
@ -91,7 +83,9 @@ export default function JobCardAttachmentsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<DashboardPage
|
||||
header={null}
|
||||
toolbar={
|
||||
<div className="flex items-center justify-end">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
@ -107,15 +101,12 @@ export default function JobCardAttachmentsPage() {
|
||||
<Plus className="size-4" />
|
||||
{isUploading ? "Uploading..." : "Upload Attachment"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>}
|
||||
title="Attachments"
|
||||
>
|
||||
|
||||
{isLoading ? (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-muted-foreground">
|
||||
Loading attachments...
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : attachments.length === 0 ? (
|
||||
|
||||
{attachments?.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-muted-foreground">
|
||||
No attachments yet. Click "Upload Attachment" to add files.
|
||||
@ -123,8 +114,8 @@ export default function JobCardAttachmentsPage() {
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{attachments.map((attachment) => {
|
||||
const Icon = getFileIcon(attachment.mime_type)
|
||||
{attachments?.map((attachment) => {
|
||||
const Icon = getFileIcon(attachment.attachment_path)
|
||||
return (
|
||||
<Card key={attachment.id}>
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
@ -133,13 +124,13 @@ export default function JobCardAttachmentsPage() {
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<a
|
||||
href={attachment.url}
|
||||
href={CONSTANTS.getAssetUrl(attachment.attachment_path)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="truncate text-sm font-medium hover:underline"
|
||||
title={attachment.file_name}
|
||||
title={attachment.original_name}
|
||||
>
|
||||
{attachment.file_name}
|
||||
{attachment.original_name}
|
||||
</a>
|
||||
{attachment.created_at && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
@ -161,6 +152,6 @@ export default function JobCardAttachmentsPage() {
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DashboardPage>
|
||||
)
|
||||
}
|
||||
|
||||
@ -0,0 +1,86 @@
|
||||
"use client"
|
||||
|
||||
import { use } from "react"
|
||||
import FormDialog from "@/shared/components/form-dialog"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { ResourcePage } from "@/shared/data-view/resource-page"
|
||||
import { ColumnHeader } from "@/shared/data-view/table-view"
|
||||
import { BillForm } from "@/modules/bills/bill-form"
|
||||
import { BILL_ROUTES } from "@garage/api"
|
||||
import type { BillsClient } from "@garage/api"
|
||||
import { useJobCard } from "@/modules/job-cards/job-card-context"
|
||||
|
||||
export default function JobCardBillsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}) {
|
||||
const { id: jobCardId } = use(params)
|
||||
const jobCard = useJobCard()
|
||||
|
||||
const defaultJobCard = jobCard
|
||||
? { value: String((jobCard as any).id), label: (jobCard as any).label || (jobCard as any).title || `Job Card` }
|
||||
: null
|
||||
|
||||
return (
|
||||
<ResourcePage<BillsClient>
|
||||
pageTitle="Bills"
|
||||
routeKey={BILL_ROUTES.INDEX}
|
||||
getClient={(api) => api.bills}
|
||||
extraParams={{ job_card_id: jobCardId }}
|
||||
header={null}
|
||||
toolbar={({ invalidateQuery, selectedItem, closeDialog }) => (
|
||||
<FormDialog title="Bill">
|
||||
{(resourceId) => (
|
||||
<BillForm
|
||||
resourceId={resourceId}
|
||||
initialData={selectedItem ?? { job_card: defaultJobCard }}
|
||||
onSuccess={() => { closeDialog(); invalidateQuery() }}
|
||||
/>
|
||||
)}
|
||||
</FormDialog>
|
||||
)}
|
||||
columns={({ actionsColumn }) => [
|
||||
{
|
||||
accessorKey: "bill_number",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Bill #" />,
|
||||
cell: ({ row }) => (row.original as any).bill_number || "—",
|
||||
},
|
||||
{
|
||||
accessorKey: "title",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Title" />,
|
||||
},
|
||||
{
|
||||
accessorKey: "vendor_name",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Vendor" />,
|
||||
cell: ({ row }) => (row.original as any).vendor_name || "—",
|
||||
},
|
||||
{
|
||||
accessorKey: "bill_date",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Bill Date" />,
|
||||
cell: ({ row }) => {
|
||||
const value = (row.original as any).bill_date
|
||||
return value ? new Date(value).toLocaleDateString() : "—"
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "bill_due_date",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Due Date" />,
|
||||
cell: ({ row }) => {
|
||||
const value = (row.original as any).bill_due_date
|
||||
return value ? new Date(value).toLocaleDateString() : "—"
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Status" />,
|
||||
cell: ({ row }) => {
|
||||
const status = (row.original as any).status
|
||||
return <Badge variant={status === "paid" ? "default" : "secondary"}>{status || "—"}</Badge>
|
||||
},
|
||||
},
|
||||
actionsColumn(),
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -19,6 +19,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/shared/components/ui/dialog"
|
||||
import { JobCardRemarkForm } from "@/modules/job-cards/job-card-remark-form"
|
||||
import DashboardPage from "@/base/components/layout/dashboard/dashboard-page"
|
||||
|
||||
type CustomerRemark = {
|
||||
id: number
|
||||
@ -39,9 +40,7 @@ export default function CustomerRemarksPage() {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey,
|
||||
queryFn: async () => {
|
||||
const result = await api.jobCards.show(jobCardId)
|
||||
const d = (result as any)?.data ?? result
|
||||
return d?.customer_remarks ?? []
|
||||
// const result = await api.jobCards.
|
||||
},
|
||||
})
|
||||
|
||||
@ -112,13 +111,15 @@ export default function CustomerRemarksPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-end">
|
||||
<DashboardPage header={null} title="Customer Remarks" toolbar={ <div className="flex items-center justify-end">
|
||||
<Button onClick={() => setDialogOpen(true)}>
|
||||
<Plus className="size-4" />
|
||||
Add Customer Remark
|
||||
</Button>
|
||||
</div>
|
||||
</div>}>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
@ -126,8 +127,6 @@ export default function CustomerRemarksPage() {
|
||||
columns={columns}
|
||||
data={remarks}
|
||||
pagination={pagination}
|
||||
sorting={[]}
|
||||
onChange={() => {}}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</CardContent>
|
||||
@ -148,5 +147,7 @@ export default function CustomerRemarksPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</DashboardPage>
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
@ -0,0 +1,90 @@
|
||||
"use client"
|
||||
|
||||
import { use } from "react"
|
||||
import { ResourcePage } from "@/shared/data-view/resource-page"
|
||||
import { ColumnHeader } from "@/shared/data-view/table-view"
|
||||
import FormDialog from "@/shared/components/form-dialog"
|
||||
import { ExpenseForm } from "@/modules/expenses/expense-form"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { EXPENSE_ROUTES } from "@garage/api"
|
||||
import type { ExpensesClient } from "@garage/api"
|
||||
import { useJobCard } from "@/modules/job-cards/job-card-context"
|
||||
|
||||
export default function JobCardExpensesPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}) {
|
||||
const { id: jobCardId } = use(params)
|
||||
const jobCard = useJobCard()
|
||||
|
||||
const defaultJobCard = jobCard
|
||||
? { value: String((jobCard as any).id), label: (jobCard as any).label || (jobCard as any).title || `Job Card` }
|
||||
: null
|
||||
|
||||
return (
|
||||
<ResourcePage<ExpensesClient>
|
||||
pageTitle="Expenses"
|
||||
routeKey={EXPENSE_ROUTES.INDEX}
|
||||
getClient={(api) => api.expenses}
|
||||
extraParams={{ job_card_id: jobCardId }}
|
||||
header={null}
|
||||
toolbar={({ invalidateQuery, selectedItem, closeDialog }) => (
|
||||
<FormDialog title="Expense">
|
||||
{(resourceId) => (
|
||||
<ExpenseForm
|
||||
resourceId={resourceId}
|
||||
initialData={selectedItem ?? { job_card: defaultJobCard }}
|
||||
onSuccess={() => { closeDialog(); invalidateQuery() }}
|
||||
/>
|
||||
)}
|
||||
</FormDialog>
|
||||
)}
|
||||
columns={({ actionsColumn }) => [
|
||||
{
|
||||
accessorKey: "title",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Title" />,
|
||||
},
|
||||
{
|
||||
accessorKey: "invoice_number",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Invoice #" />,
|
||||
cell: ({ row }) => (row.original as any).invoice_number || "—",
|
||||
},
|
||||
{
|
||||
accessorKey: "vendor_name",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Vendor" />,
|
||||
cell: ({ row }) => (row.original as any).vendor_name || "—",
|
||||
},
|
||||
{
|
||||
accessorKey: "expense_date",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Date" />,
|
||||
cell: ({ row }) => {
|
||||
const val = (row.original as any).expense_date
|
||||
return val ? new Date(val).toLocaleDateString() : "—"
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Status" />,
|
||||
cell: ({ row }) => {
|
||||
const status = (row.original as any).status
|
||||
return (
|
||||
<Badge variant={status === "paid" ? "default" : "secondary"}>
|
||||
{status || "—"}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "created_at",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Created" />,
|
||||
cell: ({ row }) => {
|
||||
const val = (row.original as any).created_at
|
||||
return val ? new Date(val).toLocaleDateString() : "—"
|
||||
},
|
||||
},
|
||||
actionsColumn(),
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -3,26 +3,32 @@ import { getServerApi } from '@garage/api/server'
|
||||
import { JobCardActions } from '@/modules/job-cards/job-card-actions'
|
||||
import { JobCardProvider } from '@/modules/job-cards/job-card-context'
|
||||
import { JobCardStatusStepper } from '@/modules/job-cards/job-card-status-stepper'
|
||||
import { ClipboardListIcon } from 'lucide-react'
|
||||
import { ClipboardListIcon, Ellipsis, Pencil, Trash2 } from 'lucide-react'
|
||||
import React from 'react'
|
||||
import JobCardDropdown from '@/modules/job-cards/job-card-dropdown'
|
||||
|
||||
|
||||
export default async function JobCardDetailLayout(props: { params: Promise<{ id: string }>, children: React.ReactNode }) {
|
||||
const { id } = await props.params
|
||||
const api = await getServerApi()
|
||||
const jobCard = await api.jobCards.show(id)
|
||||
const data = (jobCard as any)?.data ?? jobCard
|
||||
const title = data?.title || 'Job Card Details'
|
||||
const status = data?.status || 'draft'
|
||||
|
||||
const jobCard = await api.jobCards.show(id).then(res => res.data)
|
||||
|
||||
const title = jobCard?.title || 'Job Card Details'
|
||||
const status = jobCard?.status || 'draft'
|
||||
const docs = jobCard?.documents
|
||||
|
||||
return (
|
||||
<JobCardProvider jobCard={{ id, label: title, status }}>
|
||||
<JobCardProvider jobCard={{ ...jobCard, label: title }}>
|
||||
<DashboardDetailsPage
|
||||
className='p-0 lg:p-0'
|
||||
title={title}
|
||||
description={data?.status ? `Status: ${data.status.split("_").map((w: string) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ")}` : undefined}
|
||||
description={status ? `Status: ${status.split("_").map((w: string) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ")}` : undefined}
|
||||
icon={<ClipboardListIcon className="size-5" />}
|
||||
backHref="/sales/job-cards"
|
||||
actions={<JobCardActions jobCardId={id} />}
|
||||
actions={
|
||||
<JobCardDropdown id={id} />
|
||||
}
|
||||
subHeader={<JobCardStatusStepper jobCardId={id} />}
|
||||
tabs={[
|
||||
{
|
||||
@ -39,7 +45,27 @@ export default async function JobCardDetailLayout(props: { params: Promise<{ id:
|
||||
},
|
||||
{
|
||||
href: `/sales/job-cards/${id}/attachments`,
|
||||
label: 'Attachments'
|
||||
label: `Attachments (${docs?.length || 0})`
|
||||
},
|
||||
{
|
||||
href: `/sales/job-cards/${id}/purchase-orders`,
|
||||
label: `Purchase Orders (${jobCard?.purchase_orders_count || 0})`
|
||||
},
|
||||
{
|
||||
href: `/sales/job-cards/${id}/bills`,
|
||||
label: `Bills (${jobCard?.bills_count || 0})`
|
||||
},
|
||||
{
|
||||
href: `/sales/job-cards/${id}/appointments`,
|
||||
label: `Appointments (${jobCard?.appointments_count || 0})`
|
||||
},
|
||||
{
|
||||
href: `/sales/job-cards/${id}/expenses`,
|
||||
label: `Expenses (${jobCard?.expenses_count || 0})`
|
||||
},
|
||||
{
|
||||
href: `/sales/job-cards/${id}/tasks`,
|
||||
label: `Tasks (${jobCard?.tasks_count || 0})`
|
||||
},
|
||||
]}
|
||||
>
|
||||
|
||||
@ -0,0 +1,85 @@
|
||||
"use client"
|
||||
|
||||
import { use } from "react"
|
||||
import { ResourcePage } from "@/shared/data-view/resource-page"
|
||||
import { ColumnHeader } from "@/shared/data-view/table-view"
|
||||
import FormDialog from "@/shared/components/form-dialog"
|
||||
import { PurchaseOrderForm } from "@/modules/purchase-orders/purchase-order-form"
|
||||
import { PURCHASE_ORDER_ROUTES } from "@garage/api"
|
||||
import type { PurchaseOrdersClient } from "@garage/api"
|
||||
import { useJobCard } from "@/modules/job-cards/job-card-context"
|
||||
|
||||
export default function JobCardPurchaseOrdersPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}) {
|
||||
const { id: jobCardId } = use(params)
|
||||
const jobCard = useJobCard()
|
||||
|
||||
const defaultJobCard = jobCard
|
||||
? { value: String((jobCard as any).id), label: (jobCard as any).label || (jobCard as any).title || `Job Card` }
|
||||
: null
|
||||
|
||||
return (
|
||||
<ResourcePage<PurchaseOrdersClient>
|
||||
pageTitle="Purchase Orders"
|
||||
routeKey={PURCHASE_ORDER_ROUTES.INDEX}
|
||||
getClient={(api) => api.purchaseOrders}
|
||||
extraParams={{ job_card_id: jobCardId }}
|
||||
header={null}
|
||||
toolbar={({ invalidateQuery, selectedItem, closeDialog }) => (
|
||||
<FormDialog title="Purchase Order">
|
||||
{(resourceId) => (
|
||||
<PurchaseOrderForm
|
||||
resourceId={resourceId}
|
||||
initialData={selectedItem ?? { job_card: defaultJobCard }}
|
||||
onSuccess={() => { closeDialog(); invalidateQuery() }}
|
||||
/>
|
||||
)}
|
||||
</FormDialog>
|
||||
)}
|
||||
columns={({ actionsColumn }) => [
|
||||
{
|
||||
accessorKey: "order_number",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Order #" />,
|
||||
cell: ({ row }) => (row.original as any).order_number || "—",
|
||||
},
|
||||
{
|
||||
accessorKey: "title",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Title" />,
|
||||
},
|
||||
{
|
||||
accessorKey: "vendor_name",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Vendor" />,
|
||||
cell: ({ row }) => (row.original as any).vendor_name || "—",
|
||||
},
|
||||
{
|
||||
accessorKey: "order_date",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Order Date" />,
|
||||
cell: ({ row }) => {
|
||||
const val = (row.original as any).order_date
|
||||
return val ? new Date(val).toLocaleDateString() : "—"
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "delivery_date",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Delivery Date" />,
|
||||
cell: ({ row }) => {
|
||||
const val = (row.original as any).delivery_date
|
||||
return val ? new Date(val).toLocaleDateString() : "—"
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "created_at",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Created" />,
|
||||
cell: ({ row }) => {
|
||||
const val = (row.original as any).created_at
|
||||
return val ? new Date(val).toLocaleDateString() : "—"
|
||||
},
|
||||
},
|
||||
actionsColumn(),
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -19,6 +19,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/shared/components/ui/dialog"
|
||||
import { JobCardRecommendationForm } from "@/modules/job-cards/job-card-recommendation-form"
|
||||
import DashboardPage from "@/base/components/layout/dashboard/dashboard-page"
|
||||
|
||||
type ShopRecommendation = {
|
||||
id: number
|
||||
@ -112,13 +113,17 @@ export default function ShopRecommendationsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-end">
|
||||
<DashboardPage
|
||||
header={null}
|
||||
title="Shop Recommendations"
|
||||
toolbar={
|
||||
<Button onClick={() => setDialogOpen(true)}>
|
||||
<Plus className="size-4" />
|
||||
Add Shop Recommendation
|
||||
<Plus className="size-4 me-2" />
|
||||
Add Recommendation
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
@ -147,6 +152,6 @@ export default function ShopRecommendationsPage() {
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</DashboardPage>
|
||||
)
|
||||
}
|
||||
|
||||
@ -0,0 +1,67 @@
|
||||
"use client"
|
||||
|
||||
import { use } from "react"
|
||||
import { ResourcePage } from "@/shared/data-view/resource-page"
|
||||
import { ColumnHeader } from "@/shared/data-view/table-view"
|
||||
import { TASK_ROUTES } from "@garage/api"
|
||||
import type { TasksClient } from "@garage/api"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
|
||||
export default function JobCardTasksPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}) {
|
||||
const { id: jobCardId } = use(params)
|
||||
|
||||
return (
|
||||
<ResourcePage<TasksClient>
|
||||
pageTitle="Tasks"
|
||||
routeKey={TASK_ROUTES.TASKS}
|
||||
getClient={(api) => api.tasks}
|
||||
extraParams={{ job_card_id: jobCardId }}
|
||||
header={null}
|
||||
columns={() => [
|
||||
{
|
||||
accessorKey: "title",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Task" />,
|
||||
cell: ({ row }) => (row.original as any).title || "—",
|
||||
},
|
||||
{
|
||||
accessorKey: "task_type_name",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Type" />,
|
||||
cell: ({ row }) => (row.original as any).task_type_name || "—",
|
||||
},
|
||||
{
|
||||
accessorKey: "section_name",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Section" />,
|
||||
cell: ({ row }) => (row.original as any).section_name || "—",
|
||||
},
|
||||
{
|
||||
accessorKey: "due_date",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Due Date" />,
|
||||
cell: ({ row }) => {
|
||||
const val = (row.original as any).due_date
|
||||
return val ? new Date(val).toLocaleDateString() : "—"
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "priority",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Priority" />,
|
||||
cell: ({ row }) => {
|
||||
const value = (row.original as any).priority
|
||||
return value ? <Badge variant="outline">{value}</Badge> : "—"
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Status" />,
|
||||
cell: ({ row }) => {
|
||||
const value = (row.original as any).status
|
||||
return value ? <Badge>{value}</Badge> : "—"
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -4,11 +4,14 @@ import { ResourcePage } from '@/shared/data-view/resource-page'
|
||||
import { ColumnHeader } from '@/shared/data-view/table-view'
|
||||
import FormDialog from '@/shared/components/form-dialog'
|
||||
import { JobCardForm } from '@/modules/job-cards/job-card-form'
|
||||
import { JOB_CARD_ROUTES } from '@garage/api'
|
||||
import { JOB_CARD_ROUTES, JobCardStatus } from '@garage/api'
|
||||
import type { JobCardsClient } from '@garage/api'
|
||||
import { ClipboardListIcon } from 'lucide-react'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/shared/components/ui/tabs'
|
||||
import { ClipboardListIcon, SearchIcon } from 'lucide-react'
|
||||
import { Badge } from '@/shared/components/ui/badge'
|
||||
import { Input } from '@/shared/components/ui/input'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
type JobCardItem = {
|
||||
id: number
|
||||
@ -38,12 +41,25 @@ const formatStatus = (status?: string) => {
|
||||
|
||||
export default function JobCardsPage() {
|
||||
const router = useRouter()
|
||||
const [searchInput, setSearchInput] = useState("")
|
||||
const [search, setSearch] = useState("")
|
||||
const [statusFilter, setStatusFilter] = useState<string>("all")
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setSearch(searchInput), 400)
|
||||
return () => clearTimeout(timer)
|
||||
}, [searchInput])
|
||||
|
||||
const extraParams: Record<string, unknown> = {}
|
||||
if (search) extraParams.search = search
|
||||
if (statusFilter !== "all") extraParams.status = statusFilter
|
||||
|
||||
return (
|
||||
<ResourcePage<JobCardsClient>
|
||||
pageTitle="Job Cards"
|
||||
routeKey={JOB_CARD_ROUTES.INDEX}
|
||||
getClient={(api) => api.jobCards}
|
||||
extraParams={extraParams}
|
||||
onRowClick={(row) => router.push(`/sales/job-cards/${row.id}`)}
|
||||
headerProps={({ selectedItem, invalidateQuery }) => ({
|
||||
actions: (
|
||||
@ -106,6 +122,32 @@ export default function JobCardsPage() {
|
||||
},
|
||||
actionsColumn(),
|
||||
]}
|
||||
toolbar={
|
||||
<div className="flex gap-3 w-full">
|
||||
|
||||
<div className="relative w-64">
|
||||
<SearchIcon className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search job cards..."
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
tableHeader={
|
||||
<Tabs value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<TabsList variant="default">
|
||||
<TabsTrigger value="all" className='data-[state=active]:bg-primary/10 data-[state=active]:text-primary'>All</TabsTrigger>
|
||||
{JobCardStatus.map((status) => (
|
||||
<TabsTrigger className='data-[state=active]:bg-primary/10 data-[state=active]:text-primary ' key={status} value={status}>
|
||||
{formatStatus(status)}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -11,16 +11,6 @@ import type { EstimatesClient } from '@garage/api'
|
||||
import { FileTextIcon } from 'lucide-react'
|
||||
import { useVehicle } from '@/modules/vehicles/vehicle-context'
|
||||
|
||||
type EstimateItem = {
|
||||
id: number
|
||||
title?: string
|
||||
estimate_number?: string
|
||||
date?: string
|
||||
customer_name?: string
|
||||
has_insurance?: boolean
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
export default function VehicleEstimatesPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id: vehicleId } = use(params)
|
||||
const vehicle = useVehicle()
|
||||
@ -57,7 +47,7 @@ export default function VehicleEstimatesPage({ params }: { params: Promise<{ id:
|
||||
accessorKey: "title",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Title" />,
|
||||
cell: ({ row }) => {
|
||||
const item = row.original as unknown as EstimateItem
|
||||
const item = row.original
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<FileTextIcon className="text-muted-foreground h-4 w-4" />
|
||||
@ -82,7 +72,7 @@ export default function VehicleEstimatesPage({ params }: { params: Promise<{ id:
|
||||
accessorKey: "has_insurance",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Insurance" />,
|
||||
cell: ({ row }) => {
|
||||
const item = row.original as unknown as EstimateItem
|
||||
const item = row.original
|
||||
return item.has_insurance ? "Yes" : "No"
|
||||
},
|
||||
},
|
||||
@ -90,7 +80,7 @@ export default function VehicleEstimatesPage({ params }: { params: Promise<{ id:
|
||||
accessorKey: "created_at",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Created" />,
|
||||
cell: ({ row }) => {
|
||||
const item = row.original as unknown as EstimateItem
|
||||
const item = row.original
|
||||
return item.created_at ? new Date(item.created_at).toLocaleDateString() : "—"
|
||||
},
|
||||
},
|
||||
|
||||
17
apps/dashboard/app/(authenticated)/settings/company/page.tsx
Normal file
17
apps/dashboard/app/(authenticated)/settings/company/page.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
"use client"
|
||||
|
||||
import { SettingsForm } from "@/modules/settings/company/settings-form"
|
||||
|
||||
export default function CompanySettingsPage() {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-6 p-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Company Settings</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Manage your workshop profile, contact details, and preferences.
|
||||
</p>
|
||||
</div>
|
||||
<SettingsForm />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { GeneralPreferencesForm } from "@/modules/settings/configurations/general-preferences-form"
|
||||
|
||||
export default function GeneralPreferencesPage() {
|
||||
return <GeneralPreferencesForm />
|
||||
}
|
||||
@ -0,0 +1,42 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import DashboardPage from "@/base/components/layout/dashboard/dashboard-page"
|
||||
|
||||
const TABS = [
|
||||
{ label: "Sales", href: "/settings/configurations/preferences/sales" },
|
||||
{ label: "Purchases", href: "/settings/configurations/preferences/purchases" },
|
||||
{ label: "General", href: "/settings/configurations/preferences/general" },
|
||||
]
|
||||
|
||||
export default function ConfigurationsLayout({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname()
|
||||
|
||||
return (
|
||||
<DashboardPage headerProps={{ title: "Configurations" }}>
|
||||
<div className="space-y-6">
|
||||
<nav className="flex gap-1 border-b">
|
||||
{TABS.map((tab) => (
|
||||
<Link
|
||||
key={tab.href}
|
||||
href={tab.href}
|
||||
className={cn(
|
||||
"px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors",
|
||||
pathname === tab.href
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
<div className="max-w-xl">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</DashboardPage>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { PurchaseConfigForm } from "@/modules/settings/configurations/purchase-config-form"
|
||||
|
||||
export default function PurchasesConfigPage() {
|
||||
return <PurchaseConfigForm />
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { SalesConfigForm } from "@/modules/settings/configurations/sales-config-form"
|
||||
|
||||
export default function SalesConfigPage() {
|
||||
return <SalesConfigForm />
|
||||
}
|
||||
@ -0,0 +1,43 @@
|
||||
"use client"
|
||||
|
||||
import { DepartmentForm } from "@/modules/settings/departments/department-form"
|
||||
import { ResourcePage } from "@/shared/data-view/resource-page"
|
||||
import { ColumnHeader } from "@/shared/data-view/table-view"
|
||||
import FormDialog from "@/shared/components/form-dialog"
|
||||
import { DEPARTMENT_ROUTES } from "@garage/api"
|
||||
import type { DepartmentsClient } from "@garage/api"
|
||||
|
||||
export default function DepartmentsPage() {
|
||||
return (
|
||||
<ResourcePage<DepartmentsClient>
|
||||
pageTitle="Departments"
|
||||
routeKey={DEPARTMENT_ROUTES.INDEX}
|
||||
getClient={(api) => api.departments}
|
||||
headerProps={({ selectedItem, invalidateQuery }) => ({
|
||||
actions: (
|
||||
<FormDialog title="Department">
|
||||
{(resourceId) => (
|
||||
<DepartmentForm
|
||||
resourceId={resourceId}
|
||||
initialData={selectedItem}
|
||||
onSuccess={invalidateQuery}
|
||||
/>
|
||||
)}
|
||||
</FormDialog>
|
||||
),
|
||||
})}
|
||||
columns={({ actionsColumn }) => [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Name" />,
|
||||
},
|
||||
{
|
||||
accessorKey: "assignment_type",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Assignment Type" />,
|
||||
cell: ({ row }) => (row.original as any).assignment_type ?? "none",
|
||||
},
|
||||
actionsColumn(),
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,39 @@
|
||||
"use client"
|
||||
|
||||
import { InsuranceTypeForm } from "@/modules/settings/insurance-types/insurance-type-form"
|
||||
import { ResourcePage } from "@/shared/data-view/resource-page"
|
||||
import { ColumnHeader } from "@/shared/data-view/table-view"
|
||||
import FormDialog from "@/shared/components/form-dialog"
|
||||
import { INSURANCE_TYPE_ROUTES } from "@garage/api"
|
||||
import type { InsuranceTypesClient } from "@garage/api"
|
||||
|
||||
export default function InsuranceTypesPage() {
|
||||
return (
|
||||
<ResourcePage<InsuranceTypesClient>
|
||||
pageTitle="Insurance Types"
|
||||
routeKey={INSURANCE_TYPE_ROUTES.INDEX}
|
||||
getClient={(api) => api.insuranceTypes}
|
||||
headerProps={({ selectedItem, invalidateQuery }) => ({
|
||||
actions: (
|
||||
<FormDialog title="Insurance Type">
|
||||
{(resourceId) => (
|
||||
<InsuranceTypeForm
|
||||
resourceId={resourceId}
|
||||
initialData={selectedItem}
|
||||
onSuccess={invalidateQuery}
|
||||
/>
|
||||
)}
|
||||
</FormDialog>
|
||||
),
|
||||
})}
|
||||
columns={({ actionsColumn }) => [
|
||||
{
|
||||
accessorKey: "title",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Title" />,
|
||||
cell: ({ row }) => (row.original as any).title ?? (row.original as any).name ?? "—",
|
||||
},
|
||||
actionsColumn(),
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -1,12 +1,54 @@
|
||||
"use client"
|
||||
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { toast } from "sonner"
|
||||
import { CheckIcon, StarIcon, StarOffIcon, XIcon } from "lucide-react"
|
||||
|
||||
import { TaxForm } from "@/modules/settings/tax-rates/tax-form"
|
||||
import { ResourcePage } from "@/shared/data-view/resource-page"
|
||||
import { ColumnHeader } from "@/shared/data-view/table-view"
|
||||
import FormDialog from "@/shared/components/form-dialog"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { TAX_ROUTES } from "@garage/api"
|
||||
import type { TaxesClient } from "@garage/api"
|
||||
import { CheckIcon, XIcon } from "lucide-react"
|
||||
|
||||
function TaxDefaultCell({ row }: { row: any }) {
|
||||
const api = useAuthApi()
|
||||
const queryClient = useQueryClient()
|
||||
const isDefault: boolean = row.is_default ?? false
|
||||
|
||||
const { mutate, isPending } = useMutation({
|
||||
mutationFn: () => {
|
||||
const promise = isDefault
|
||||
? api.taxes.removeDefault({ id: row.id })
|
||||
: api.taxes.setDefault({ id: row.id })
|
||||
toast.promise(promise, {
|
||||
loading: isDefault ? "Removing default..." : "Setting as default...",
|
||||
success: isDefault ? "Default removed" : "Set as default",
|
||||
error: isDefault ? "Failed to remove default" : "Failed to set as default",
|
||||
})
|
||||
return promise
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [TAX_ROUTES.INDEX] })
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
disabled={isPending}
|
||||
onClick={() => mutate()}
|
||||
title={isDefault ? "Remove default" : "Set as default"}
|
||||
>
|
||||
{isDefault
|
||||
? <StarIcon className="h-4 w-4 text-yellow-500 fill-yellow-400" />
|
||||
: <StarOffIcon className="h-4 w-4 text-muted-foreground" />}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export default function TaxesPage() {
|
||||
return (
|
||||
@ -49,11 +91,16 @@ export default function TaxesPage() {
|
||||
{
|
||||
accessorKey: "is_default",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Default" />,
|
||||
cell: ({ row }) =>
|
||||
(row.original as any).is_default
|
||||
cell: ({ row }) => (row.original as any).is_default
|
||||
? <CheckIcon className="h-4 w-4 text-green-600" />
|
||||
: <XIcon className="h-4 w-4 text-muted-foreground" />,
|
||||
},
|
||||
{
|
||||
id: "set_default",
|
||||
header: () => <span className="sr-only">Set Default</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <TaxDefaultCell row={row.original} />,
|
||||
},
|
||||
actionsColumn(),
|
||||
]}
|
||||
/>
|
||||
|
||||
@ -68,12 +68,14 @@ export default function DashboardDetailsPageLayout({
|
||||
</Button>
|
||||
)}
|
||||
{(avatarSrc || avatarFallback) && (
|
||||
<Avatar size="lg">
|
||||
<a rel="preload" target="_blank" href={avatarSrc} >
|
||||
<Avatar className="size-14">
|
||||
{avatarSrc && <AvatarImage src={avatarSrc} alt={title} />}
|
||||
<AvatarFallback>
|
||||
{avatarFallback ?? title.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</a>
|
||||
)}
|
||||
{!avatarSrc && !avatarFallback && icon && (
|
||||
<div className="flex items-center justify-center size-10 rounded-full bg-muted text-muted-foreground">
|
||||
|
||||
@ -31,6 +31,7 @@ import {
|
||||
ReceiptIcon,
|
||||
ReceiptTextIcon,
|
||||
SettingsIcon,
|
||||
ShieldIcon,
|
||||
ShoppingBasketIcon,
|
||||
CircleDollarSign,
|
||||
StarIcon,
|
||||
@ -170,6 +171,8 @@ export const navGroups: NavGroup[] = [
|
||||
items: [
|
||||
{ title: "Company", href: "/settings/company", icon: <Building2Icon /> },
|
||||
{ title: "Shop Types", href: "/settings/shop-type", icon: <CarIcon /> },
|
||||
{ title: "Departments", href: "/settings/departments", icon: <Building2Icon /> },
|
||||
{ title: "Insurance Types", href: "/settings/insurance-types", icon: <ShieldIcon /> },
|
||||
{ title: "Tax & Rates", href: "/settings/tax-rates", icon: <ReceiptTextIcon /> },
|
||||
{ title: "Configurations", href: "/settings/configurations/preferences/sales", icon: <SettingsIcon /> },
|
||||
{ title: "Templates", href: "/settings/templates", icon: <ClipboardListIcon /> },
|
||||
|
||||
124
apps/dashboard/modules/appointments/appointment-actions.tsx
Normal file
124
apps/dashboard/modules/appointments/appointment-actions.tsx
Normal file
@ -0,0 +1,124 @@
|
||||
"use client"
|
||||
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useState } from "react"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/components/ui/dropdown-menu"
|
||||
import { Ellipsis, Pencil, Trash2, CheckCircle, Unlink } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { APPOINTMENT_ROUTES } from "@garage/api"
|
||||
|
||||
type AppointmentActionsProps = {
|
||||
appointmentId: string
|
||||
currentStatus?: string
|
||||
jobCardId?: number | null
|
||||
}
|
||||
|
||||
export function AppointmentActions({ appointmentId, currentStatus, jobCardId }: AppointmentActionsProps) {
|
||||
const api = useAuthApi()
|
||||
const router = useRouter()
|
||||
const queryClient = useQueryClient()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const handleEdit = () => {
|
||||
router.push(`/calendar/appointment/${appointmentId}/edit`)
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
await api.appointments.destroy(appointmentId)
|
||||
toast.success("Appointment deleted.")
|
||||
router.push("/calendar/appointment/list")
|
||||
} catch {
|
||||
toast.error("Failed to delete appointment.")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleChangeStatus = async (status: string) => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
await api.appointments.changeStatus(appointmentId, { status } as any)
|
||||
toast.success(`Status updated to "${status}".`)
|
||||
queryClient.invalidateQueries({ queryKey: [APPOINTMENT_ROUTES.INDEX] })
|
||||
router.refresh()
|
||||
} catch {
|
||||
toast.error("Failed to update status.")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUnlinkJobCard = async () => {
|
||||
if (!jobCardId) return
|
||||
setIsLoading(true)
|
||||
try {
|
||||
await api.appointments.unlinkJobCard(appointmentId, {} as any)
|
||||
toast.success("Job card unlinked.")
|
||||
queryClient.invalidateQueries({ queryKey: [APPOINTMENT_ROUTES.INDEX] })
|
||||
router.refresh()
|
||||
} catch {
|
||||
toast.error("Failed to unlink job card.")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" disabled={isLoading}>
|
||||
<Ellipsis className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={handleEdit}>
|
||||
<Pencil className="size-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
{currentStatus !== "confirmed" && (
|
||||
<DropdownMenuItem onClick={() => handleChangeStatus("confirmed")}>
|
||||
<CheckCircle className="size-4" />
|
||||
Confirm
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{currentStatus !== "completed" && (
|
||||
<DropdownMenuItem onClick={() => handleChangeStatus("completed")}>
|
||||
<CheckCircle className="size-4" />
|
||||
Mark Completed
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{currentStatus !== "cancelled" && (
|
||||
<DropdownMenuItem onClick={() => handleChangeStatus("cancelled")}>
|
||||
<CheckCircle className="size-4" />
|
||||
Cancel
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{jobCardId && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleUnlinkJobCard}>
|
||||
<Unlink className="size-4" />
|
||||
Unlink Job Card
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
|
||||
<Trash2 className="size-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
28
apps/dashboard/modules/appointments/appointment-context.tsx
Normal file
28
apps/dashboard/modules/appointments/appointment-context.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import { createContext, useContext } from "react"
|
||||
|
||||
type AppointmentContextValue = {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const AppointmentContext = createContext<AppointmentContextValue | null>(null)
|
||||
|
||||
export function AppointmentProvider({
|
||||
appointment,
|
||||
children,
|
||||
}: {
|
||||
appointment: AppointmentContextValue
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<AppointmentContext.Provider value={appointment}>
|
||||
{children}
|
||||
</AppointmentContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useAppointment() {
|
||||
return useContext(AppointmentContext)
|
||||
}
|
||||
208
apps/dashboard/modules/appointments/appointment-form.tsx
Normal file
208
apps/dashboard/modules/appointments/appointment-form.tsx
Normal file
@ -0,0 +1,208 @@
|
||||
"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,
|
||||
} 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 {
|
||||
appointmentFormSchema,
|
||||
type AppointmentFormValues,
|
||||
APPOINTMENT_STATUS_OPTIONS,
|
||||
} from "./appointment.schema"
|
||||
import { RhfVehicleSelectField } from "@/modules/vehicles/rhf-vehicle-select-field"
|
||||
import { RhfCustomerSelectField } from "@/modules/customers/rhf-customer-select-field"
|
||||
import { RhfEmployeeSelectField } from "@/modules/employees/rhf-employee-select-field"
|
||||
|
||||
// ── Props ──
|
||||
|
||||
export type AppointmentFormProps = {
|
||||
resourceId?: string | null
|
||||
initialData?: unknown
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
// ── Default values ──
|
||||
|
||||
const DEFAULT_VALUES: AppointmentFormValues = {
|
||||
title: "",
|
||||
date: "",
|
||||
from_time: "",
|
||||
to_time: "",
|
||||
customer: null,
|
||||
vehicle: null,
|
||||
service_writer: null,
|
||||
technician: null,
|
||||
department: null,
|
||||
job_card: null,
|
||||
notes: "",
|
||||
status: "requested",
|
||||
}
|
||||
|
||||
// ── Mapping helpers ──
|
||||
|
||||
function mapToFormValues(data: unknown): AppointmentFormValues {
|
||||
const d = (data as any)?.data ?? data ?? {}
|
||||
|
||||
return {
|
||||
title: d.title || "",
|
||||
date: d.date || "",
|
||||
from_time: d.from_time || "",
|
||||
to_time: d.to_time || "",
|
||||
customer: toRelation(d.customer_id, d.customer_name),
|
||||
vehicle: toRelation(d.vehicle_id, d.vehicle_name),
|
||||
service_writer: toRelation(d.service_writer_id, d.service_writer_name),
|
||||
technician: toRelation(d.technician_id, d.technician_name),
|
||||
department: toRelation(d.department_id, d.department_name),
|
||||
job_card: toRelation(d.job_card_id, d.job_card_title),
|
||||
notes: d.notes || "",
|
||||
status: d.status || "requested",
|
||||
}
|
||||
}
|
||||
|
||||
function mapFormToPayload(values: AppointmentFormValues) {
|
||||
return {
|
||||
title: values.title,
|
||||
date: values.date,
|
||||
from_time: values.from_time,
|
||||
to_time: values.to_time,
|
||||
customer_id: toId(values.customer),
|
||||
vehicle_id: toId(values.vehicle),
|
||||
service_writer_id: toId(values.service_writer),
|
||||
technician_id: toId(values.technician),
|
||||
department_id: toId(values.department),
|
||||
job_card_id: toId(values.job_card),
|
||||
notes: values.notes || undefined,
|
||||
status: values.status || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Shared helpers ──
|
||||
|
||||
const mapLookupOption = (item: any) => ({
|
||||
value: String(item.id),
|
||||
label: item.name ?? item.title ?? String(item.id),
|
||||
})
|
||||
|
||||
const mapJobCardOption = (item: any) => ({
|
||||
value: String(item.id),
|
||||
label: item.title || `Job Card #${item.id}`,
|
||||
})
|
||||
|
||||
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
|
||||
|
||||
// ── Component ──
|
||||
|
||||
export function AppointmentForm({ resourceId, initialData, onSuccess }: AppointmentFormProps) {
|
||||
const api = useAuthApi()
|
||||
|
||||
const { form, isEditing } = useResourceForm<AppointmentFormValues>({
|
||||
schema: appointmentFormSchema,
|
||||
defaultValues: DEFAULT_VALUES,
|
||||
resourceId,
|
||||
initialData,
|
||||
mapToFormValues,
|
||||
})
|
||||
|
||||
const { mutate, isPending, error } = useFormMutation(form, {
|
||||
mutationFn: (values: AppointmentFormValues) => {
|
||||
const payload = mapFormToPayload(values)
|
||||
const promise = (isEditing && resourceId
|
||||
? api.appointments.update(resourceId, payload as any)
|
||||
: api.appointments.create(payload as any)) as Promise<any>
|
||||
toast.promise(promise, {
|
||||
loading: isEditing ? "Updating appointment..." : "Creating appointment...",
|
||||
success: isEditing ? "Appointment updated." : "Appointment created.",
|
||||
error: isEditing ? "Failed to update appointment." : "Failed to create appointment.",
|
||||
})
|
||||
return promise
|
||||
},
|
||||
onSuccess: () => {
|
||||
form.reset()
|
||||
onSuccess?.()
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<Rhform form={form} onSubmit={(values) => mutate(values)} className="space-y-6">
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="size-4" />
|
||||
<AlertTitle>{error.message}</AlertTitle>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FieldGroup>
|
||||
<RhfTextField name="title" label="Title" placeholder="e.g. Oil Change Appointment" required />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<RhfTextField name="date" label="Date" placeholder="YYYY-MM-DD" type="date" />
|
||||
<RhfSelectField
|
||||
name="status"
|
||||
label="Status"
|
||||
options={APPOINTMENT_STATUS_OPTIONS}
|
||||
placeholder="Select status"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<RhfTextField name="from_time" label="From Time" placeholder="HH:MM" type="time" />
|
||||
<RhfTextField name="to_time" label="To Time" placeholder="HH:MM" type="time" />
|
||||
</div>
|
||||
</FieldGroup>
|
||||
|
||||
<FieldGroup>
|
||||
<RhfCustomerSelectField name="customer" />
|
||||
<RhfVehicleSelectField name="vehicle" />
|
||||
</FieldGroup>
|
||||
|
||||
<FieldGroup>
|
||||
<RhfEmployeeSelectField name="service_writer" label="Service Writer" />
|
||||
<RhfEmployeeSelectField name="technician" label="Technician" />
|
||||
<RhfAsyncSelectField
|
||||
name="department"
|
||||
label="Department"
|
||||
placeholder="Select department..."
|
||||
queryKey={["departments-lookup"]}
|
||||
listFn={() => api.departments.list()}
|
||||
mapOption={mapLookupOption}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
</FieldGroup>
|
||||
|
||||
<FieldGroup>
|
||||
<RhfAsyncSelectField
|
||||
name="job_card"
|
||||
label="Job Card (optional)"
|
||||
placeholder="Select job card..."
|
||||
queryKey={["job-cards-lookup"]}
|
||||
listFn={() => api.jobCards.list()}
|
||||
mapOption={mapJobCardOption}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
</FieldGroup>
|
||||
|
||||
<FieldGroup>
|
||||
<RhfTextareaField name="notes" label="Notes" placeholder="Additional notes..." />
|
||||
</FieldGroup>
|
||||
|
||||
<Button type="submit" disabled={isPending} className="w-full">
|
||||
{isEditing ? <Save className="size-4" /> : <Plus className="size-4" />}
|
||||
{isPending
|
||||
? (isEditing ? "Saving..." : "Creating...")
|
||||
: (isEditing ? "Save Changes" : "Create Appointment")}
|
||||
</Button>
|
||||
</Rhform>
|
||||
)
|
||||
}
|
||||
166
apps/dashboard/modules/appointments/appointment-general-info.tsx
Normal file
166
apps/dashboard/modules/appointments/appointment-general-info.tsx
Normal file
@ -0,0 +1,166 @@
|
||||
import {
|
||||
CalendarCheck2,
|
||||
Clock,
|
||||
User,
|
||||
Car,
|
||||
Users,
|
||||
Wrench,
|
||||
Building2,
|
||||
FileText,
|
||||
ClipboardList,
|
||||
Info,
|
||||
ExternalLink,
|
||||
} from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
|
||||
type AppointmentData = {
|
||||
id?: number
|
||||
job_card_id?: number
|
||||
title?: string
|
||||
date?: string
|
||||
from_time?: string
|
||||
to_time?: string
|
||||
customer_id?: number
|
||||
vehicle_id?: number
|
||||
service_writer_id?: number
|
||||
technician_id?: number
|
||||
department_id?: number
|
||||
notes?: string
|
||||
status?: string
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
type AppointmentGeneralInfoProps = {
|
||||
appointment: AppointmentData
|
||||
}
|
||||
|
||||
function InfoItem({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
label: string
|
||||
value?: string | number | null
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
|
||||
<Icon className="size-4" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<span className="text-sm font-medium">
|
||||
{value != null && value !== "" ? String(value) : <span className="text-muted-foreground">—</span>}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
requested: "bg-yellow-100 text-yellow-800",
|
||||
confirmed: "bg-blue-100 text-blue-800",
|
||||
in_progress: "bg-purple-100 text-purple-800",
|
||||
completed: "bg-green-100 text-green-800",
|
||||
cancelled: "bg-red-100 text-red-800",
|
||||
}
|
||||
|
||||
export function AppointmentGeneralInfo({ appointment }: AppointmentGeneralInfoProps) {
|
||||
const statusClass = appointment.status
|
||||
? (STATUS_COLORS[appointment.status] ?? "bg-gray-100 text-gray-800")
|
||||
: "bg-gray-100 text-gray-800"
|
||||
|
||||
return (
|
||||
<div className="grid gap-6 md:grid-cols-2 p-6">
|
||||
{/* Appointment Details */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<CalendarCheck2 className="size-4" />
|
||||
Appointment Details
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{appointment.status && (
|
||||
<Badge className={statusClass}>
|
||||
{appointment.status.replace("_", " ")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<InfoItem icon={Info} label="Title" value={appointment.title} />
|
||||
<InfoItem icon={CalendarCheck2} label="Date" value={appointment.date} />
|
||||
<InfoItem icon={Clock} label="From Time" value={appointment.from_time} />
|
||||
<InfoItem icon={Clock} label="To Time" value={appointment.to_time} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Customer & Vehicle */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<User className="size-4" />
|
||||
Customer & Vehicle
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<InfoItem icon={User} label="Customer ID" value={appointment.customer_id} />
|
||||
<InfoItem icon={Car} label="Vehicle ID" value={appointment.vehicle_id} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Assignment */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Users className="size-4" />
|
||||
Assignment
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<InfoItem icon={Users} label="Service Writer ID" value={appointment.service_writer_id} />
|
||||
<InfoItem icon={Wrench} label="Technician ID" value={appointment.technician_id} />
|
||||
<InfoItem icon={Building2} label="Department ID" value={appointment.department_id} />
|
||||
</div>
|
||||
{appointment.job_card_id && (
|
||||
<div className="pt-2">
|
||||
<span className="text-xs text-muted-foreground mb-1 block">Job Card</span>
|
||||
<Button asChild variant="outline" size="sm" className="gap-1.5">
|
||||
<Link href={`/sales/job-cards/${appointment.job_card_id}`}>
|
||||
<ClipboardList className="size-3.5" />
|
||||
Job Card #{appointment.job_card_id}
|
||||
<ExternalLink className="size-3.5" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Notes */}
|
||||
{appointment.notes && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="size-4" />
|
||||
Notes
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground whitespace-pre-line">{appointment.notes}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
33
apps/dashboard/modules/appointments/appointment.schema.ts
Normal file
33
apps/dashboard/modules/appointments/appointment.schema.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { z } from "zod"
|
||||
|
||||
const relationFieldSchema = z
|
||||
.object({ value: z.string(), label: z.string() })
|
||||
.nullable()
|
||||
|
||||
const APPOINTMENT_STATUS_OPTIONS = [
|
||||
{ value: "requested", label: "Requested" },
|
||||
{ value: "confirmed", label: "Confirmed" },
|
||||
{ value: "in_progress", label: "In Progress" },
|
||||
{ value: "completed", label: "Completed" },
|
||||
{ value: "cancelled", label: "Cancelled" },
|
||||
]
|
||||
|
||||
const appointmentFormSchema = z.object({
|
||||
title: z.string().min(1, "Title is required"),
|
||||
date: z.string().min(1, "Date is required"),
|
||||
from_time: z.string().min(1, "Start time is required"),
|
||||
to_time: z.string().min(1, "End time is required"),
|
||||
customer: relationFieldSchema,
|
||||
vehicle: relationFieldSchema,
|
||||
service_writer: relationFieldSchema,
|
||||
technician: relationFieldSchema,
|
||||
department: relationFieldSchema,
|
||||
job_card: relationFieldSchema,
|
||||
notes: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
})
|
||||
|
||||
type AppointmentFormValues = z.infer<typeof appointmentFormSchema>
|
||||
|
||||
export { appointmentFormSchema, relationFieldSchema, APPOINTMENT_STATUS_OPTIONS }
|
||||
export type { AppointmentFormValues }
|
||||
197
apps/dashboard/modules/bills/bill-form.tsx
Normal file
197
apps/dashboard/modules/bills/bill-form.tsx
Normal file
@ -0,0 +1,197 @@
|
||||
"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,
|
||||
RhfAsyncSelectField,
|
||||
RhfSelectField,
|
||||
RhfTextField,
|
||||
RhfTextareaField,
|
||||
} from "@/shared/components/form"
|
||||
import { useResourceForm } from "@/shared/hooks/use-resource-form"
|
||||
import { useFormMutation } from "@/shared/hooks/use-form-mutation"
|
||||
import { toId, toRelation } from "@/shared/lib/utils"
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { BillStatus, BILL_ROUTES, DEPARTMENT_ROUTES, JOB_CARD_ROUTES, PAYMENT_TERM_ROUTES, VENDOR_ROUTES } from "@garage/api"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { billFormSchema, type BillFormValues } from "./bill.schema"
|
||||
|
||||
export type BillFormProps = {
|
||||
resourceId?: string | null
|
||||
initialData?: unknown
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
const DEFAULT_VALUES: BillFormValues = {
|
||||
vendor: null,
|
||||
job_card: null,
|
||||
payment_term: null,
|
||||
department: null,
|
||||
title: "",
|
||||
bill_date: "",
|
||||
bill_due_date: "",
|
||||
status: "draft",
|
||||
notes: "",
|
||||
}
|
||||
|
||||
const STATUS_OPTIONS = BillStatus.map((value) => ({
|
||||
value,
|
||||
label: value.replaceAll("_", " "),
|
||||
}))
|
||||
|
||||
const mapLookupOption = (item: any) => ({
|
||||
value: String(item.id),
|
||||
label: item.name ?? item.title ?? item.bill_number ?? `#${item.id}`,
|
||||
})
|
||||
|
||||
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
|
||||
|
||||
function mapToFormValues(data: unknown): BillFormValues {
|
||||
const d = (data as any)?.data ?? data ?? {}
|
||||
|
||||
return {
|
||||
vendor: toRelation(d.vendor_id, d.vendor_name),
|
||||
job_card: toRelation(d.job_card_id, d.job_card_number ?? d.job_card_name),
|
||||
payment_term: toRelation(d.payment_terms_id, d.payment_terms_name),
|
||||
department: toRelation(d.department_id, d.department_name),
|
||||
title: d.title || "",
|
||||
bill_date: d.bill_date || "",
|
||||
bill_due_date: d.bill_due_date || "",
|
||||
status: d.status || "draft",
|
||||
notes: d.notes || "",
|
||||
}
|
||||
}
|
||||
|
||||
function mapFormToPayload(values: BillFormValues) {
|
||||
return {
|
||||
title: values.title,
|
||||
vendor_id: toId(values.vendor),
|
||||
job_card_id: toId(values.job_card),
|
||||
payment_terms_id: toId(values.payment_term),
|
||||
department_id: toId(values.department),
|
||||
bill_date: values.bill_date || undefined,
|
||||
bill_due_date: values.bill_due_date || undefined,
|
||||
status: values.status || undefined,
|
||||
notes: values.notes || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function BillForm({ resourceId, initialData, onSuccess }: BillFormProps) {
|
||||
const api = useAuthApi()
|
||||
|
||||
const { form, isEditing } = useResourceForm<BillFormValues, any>({
|
||||
schema: billFormSchema,
|
||||
defaultValues: DEFAULT_VALUES,
|
||||
resourceId,
|
||||
initialData,
|
||||
mapToFormValues,
|
||||
})
|
||||
|
||||
const { mutate, error, isPending } = useFormMutation(form, {
|
||||
mutationFn: (values: BillFormValues) => {
|
||||
const payload = mapFormToPayload(values)
|
||||
const promise = isEditing && resourceId
|
||||
? api.bills.update(resourceId, payload)
|
||||
: api.bills.create(payload)
|
||||
|
||||
toast.promise(promise, {
|
||||
loading: isEditing ? "Updating bill..." : "Creating bill...",
|
||||
success: isEditing ? "Bill updated successfully" : "Bill created successfully",
|
||||
error: isEditing ? "Failed to update bill" : "Failed to create bill",
|
||||
})
|
||||
|
||||
return promise
|
||||
},
|
||||
onSuccess: () => {
|
||||
form.reset()
|
||||
onSuccess?.()
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<Rhform form={form} onSubmit={(values) => mutate(values)}>
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertTriangle className="me-2 h-4 w-4" />
|
||||
<AlertTitle>{isEditing ? "Failed to update bill" : "Failed to create bill"}</AlertTitle>
|
||||
{error.message}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FieldGroup>
|
||||
<RhfTextField name="title" label="Title" placeholder="Enter bill title" required />
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfTextField name="bill_date" label="Bill Date" type="date" />
|
||||
<RhfTextField name="bill_due_date" label="Due Date" type="date" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfAsyncSelectField
|
||||
name="vendor"
|
||||
label="Vendor"
|
||||
placeholder="Select vendor"
|
||||
queryKey={[VENDOR_ROUTES.INDEX]}
|
||||
listFn={() => api.vendors.list()}
|
||||
mapOption={mapLookupOption}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
<RhfAsyncSelectField
|
||||
name="department"
|
||||
label="Department"
|
||||
placeholder="Select department"
|
||||
queryKey={[DEPARTMENT_ROUTES.INDEX]}
|
||||
listFn={() => api.departments.list()}
|
||||
mapOption={mapLookupOption}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<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.job_card_number || item.name || `#${item.id}` })}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
<RhfAsyncSelectField
|
||||
name="payment_term"
|
||||
label="Payment Term"
|
||||
placeholder="Select payment term"
|
||||
queryKey={[PAYMENT_TERM_ROUTES.INDEX]}
|
||||
listFn={() => api.paymentTerms.list()}
|
||||
mapOption={mapLookupOption}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<RhfSelectField name="status" label="Status" options={STATUS_OPTIONS} />
|
||||
<RhfTextareaField name="notes" label="Notes" rows={3} />
|
||||
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? (
|
||||
"Saving..."
|
||||
) : isEditing ? (
|
||||
<>
|
||||
<Save className="me-2 h-4 w-4" />
|
||||
Update Bill
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="me-2 h-4 w-4" />
|
||||
Create Bill
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</FieldGroup>
|
||||
</Rhform>
|
||||
)
|
||||
}
|
||||
22
apps/dashboard/modules/bills/bill.schema.ts
Normal file
22
apps/dashboard/modules/bills/bill.schema.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { z } from "zod"
|
||||
|
||||
const relationFieldSchema = z
|
||||
.object({ value: z.string(), label: z.string() })
|
||||
.nullable()
|
||||
|
||||
const billFormSchema = z.object({
|
||||
vendor: relationFieldSchema,
|
||||
job_card: relationFieldSchema,
|
||||
payment_term: relationFieldSchema,
|
||||
department: relationFieldSchema,
|
||||
title: z.string().min(1, "Title is required"),
|
||||
bill_date: z.string().optional(),
|
||||
bill_due_date: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
})
|
||||
|
||||
type BillFormValues = z.infer<typeof billFormSchema>
|
||||
|
||||
export { billFormSchema, relationFieldSchema }
|
||||
export type { BillFormValues }
|
||||
65
apps/dashboard/modules/customers/customer-actions.tsx
Normal file
65
apps/dashboard/modules/customers/customer-actions.tsx
Normal file
@ -0,0 +1,65 @@
|
||||
"use client"
|
||||
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/components/ui/dropdown-menu"
|
||||
import { Ellipsis, Pencil, Trash2 } from "lucide-react"
|
||||
import { confirm } from "@/shared/components/confirm-dialog"
|
||||
import { toast } from "sonner"
|
||||
|
||||
type CustomerActionsProps = {
|
||||
customerId: string
|
||||
}
|
||||
|
||||
export function CustomerActions({ customerId }: CustomerActionsProps) {
|
||||
const api = useAuthApi()
|
||||
const router = useRouter()
|
||||
|
||||
const handleEdit = () => {
|
||||
router.push(`/sales/customers/${customerId}/edit`)
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
const confirmed = await confirm({
|
||||
title: "Delete Customer",
|
||||
description: "Are you sure you want to delete this customer? This action cannot be undone.",
|
||||
confirmLabel: "Delete",
|
||||
variant: "destructive",
|
||||
})
|
||||
if (!confirmed) return
|
||||
|
||||
try {
|
||||
await api.customers.destroy(customerId)
|
||||
toast.success("Customer deleted successfully.")
|
||||
router.push("/sales/customers")
|
||||
} catch {
|
||||
toast.error("Failed to delete customer.")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Ellipsis className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={handleEdit}>
|
||||
<Pencil className="size-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
|
||||
<Trash2 className="size-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
28
apps/dashboard/modules/customers/customer-context.tsx
Normal file
28
apps/dashboard/modules/customers/customer-context.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import { createContext, useContext } from "react"
|
||||
|
||||
type CustomerContextValue = {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const CustomerContext = createContext<CustomerContextValue | null>(null)
|
||||
|
||||
export function CustomerProvider({
|
||||
customer,
|
||||
children,
|
||||
}: {
|
||||
customer: CustomerContextValue
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<CustomerContext.Provider value={customer}>
|
||||
{children}
|
||||
</CustomerContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useCustomer() {
|
||||
return useContext(CustomerContext)
|
||||
}
|
||||
167
apps/dashboard/modules/customers/customer-general-info.tsx
Normal file
167
apps/dashboard/modules/customers/customer-general-info.tsx
Normal file
@ -0,0 +1,167 @@
|
||||
import {
|
||||
User,
|
||||
Mail,
|
||||
Phone,
|
||||
MapPin,
|
||||
Building2,
|
||||
Globe,
|
||||
DollarSign,
|
||||
CreditCard,
|
||||
Tag,
|
||||
Users,
|
||||
FileText,
|
||||
} from "lucide-react"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/components/ui/card"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Separator } from "@/shared/components/ui/separator"
|
||||
|
||||
type CustomerData = {
|
||||
id?: number
|
||||
customer_type_id?: number
|
||||
salutation?: string
|
||||
first_name?: string
|
||||
last_name?: string
|
||||
company_name?: string
|
||||
email?: string
|
||||
phone?: string
|
||||
alternate_phone?: string
|
||||
opening_balance?: number
|
||||
credit_limit?: number
|
||||
website?: string
|
||||
referral_source_id?: number
|
||||
payment_terms_id?: number
|
||||
address_line_1?: string
|
||||
address_line_2?: string
|
||||
country_id?: number
|
||||
state_id?: number
|
||||
city?: string
|
||||
zip_code?: string
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
customer_type?: { id?: number; name?: string }
|
||||
referral_source?: { id?: number; title?: string }
|
||||
payment_terms?: { id?: number; title?: string }
|
||||
country?: { id?: number; name?: string }
|
||||
state?: { id?: number; name?: string }
|
||||
}
|
||||
|
||||
type CustomerGeneralInfoProps = {
|
||||
customer: CustomerData
|
||||
}
|
||||
|
||||
function InfoItem({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
label: string
|
||||
value?: string | number | null
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
|
||||
<Icon className="size-4" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<span className="text-sm font-medium">
|
||||
{value != null && value !== "" ? (
|
||||
String(value)
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CustomerGeneralInfo({ customer }: CustomerGeneralInfoProps) {
|
||||
const fullName = [customer.salutation, customer.first_name, customer.last_name]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
|
||||
return (
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* Personal Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<User className="size-4" />
|
||||
Personal Information
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary">{fullName || "Unknown"}</Badge>
|
||||
{customer.customer_type?.name && (
|
||||
<Badge variant="outline">{customer.customer_type.name}</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<InfoItem icon={User} label="Full Name" value={fullName} />
|
||||
<InfoItem icon={Building2} label="Company" value={customer.company_name} />
|
||||
<InfoItem icon={Tag} label="Customer Type" value={customer.customer_type?.name} />
|
||||
<InfoItem icon={Users} label="Referral Source" value={customer.referral_source?.title} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Contact Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Phone className="size-4" />
|
||||
Contact Details
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 sm:grid-cols-2">
|
||||
<InfoItem icon={Mail} label="Email" value={customer.email} />
|
||||
<InfoItem icon={Phone} label="Phone" value={customer.phone} />
|
||||
<InfoItem icon={Phone} label="Alternate Phone" value={customer.alternate_phone} />
|
||||
<InfoItem icon={Globe} label="Website" value={customer.website} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Address */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<MapPin className="size-4" />
|
||||
Address
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 sm:grid-cols-2">
|
||||
<InfoItem icon={MapPin} label="Address Line 1" value={customer.address_line_1} />
|
||||
<InfoItem icon={MapPin} label="Address Line 2" value={customer.address_line_2} />
|
||||
<InfoItem icon={MapPin} label="City" value={customer.city} />
|
||||
<InfoItem icon={MapPin} label="ZIP Code" value={customer.zip_code} />
|
||||
<InfoItem icon={MapPin} label="State" value={customer.state?.name} />
|
||||
<InfoItem icon={MapPin} label="Country" value={customer.country?.name} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Financial Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<DollarSign className="size-4" />
|
||||
Financial Details
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 sm:grid-cols-2">
|
||||
<InfoItem icon={DollarSign} label="Opening Balance" value={customer.opening_balance} />
|
||||
<InfoItem icon={CreditCard} label="Credit Limit" value={customer.credit_limit} />
|
||||
<InfoItem icon={FileText} label="Payment Terms" value={customer.payment_terms?.title} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
196
apps/dashboard/modules/customers/rhf-customer-select-field.tsx
Normal file
196
apps/dashboard/modules/customers/rhf-customer-select-field.tsx
Normal file
@ -0,0 +1,196 @@
|
||||
"use client"
|
||||
|
||||
import { useRef, useState } from "react"
|
||||
import { useFormContext, useController, type FieldValues, type FieldPath } from "react-hook-form"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { Building2, Loader2 } from "lucide-react"
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { CUSTOMER_ROUTES } from "@garage/api"
|
||||
import { FieldShell } from "@/shared/components/form/field-shell"
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxInput,
|
||||
ComboboxContent,
|
||||
ComboboxList,
|
||||
ComboboxItem,
|
||||
ComboboxEmpty,
|
||||
} from "@/shared/components/ui/combobox"
|
||||
|
||||
// ── Customer option type (enriched for display) ──
|
||||
|
||||
type CustomerOption = {
|
||||
value: string
|
||||
label: string
|
||||
first_name?: string
|
||||
last_name?: string
|
||||
company_name?: string
|
||||
email?: string
|
||||
phone?: string
|
||||
}
|
||||
|
||||
function buildCustomerOption(item: any): CustomerOption {
|
||||
const name = [item.first_name, item.last_name].filter(Boolean).join(" ")
|
||||
const label = name || item.company_name || `Customer #${item.id}`
|
||||
return {
|
||||
value: String(item.id),
|
||||
label,
|
||||
first_name: item.first_name,
|
||||
last_name: item.last_name,
|
||||
company_name: item.company_name,
|
||||
email: item.email,
|
||||
phone: item.phone,
|
||||
}
|
||||
}
|
||||
|
||||
function extractItems(response: unknown): any[] {
|
||||
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 []
|
||||
}
|
||||
|
||||
function getInitials(opt: CustomerOption): string {
|
||||
if (opt.first_name || opt.last_name) {
|
||||
return [opt.first_name?.[0], opt.last_name?.[0]].filter(Boolean).join("").toUpperCase()
|
||||
}
|
||||
if (opt.company_name) return opt.company_name[0].toUpperCase()
|
||||
return "?"
|
||||
}
|
||||
|
||||
// ── Props ──
|
||||
|
||||
export type RhfCustomerSelectFieldProps<
|
||||
TValues extends FieldValues,
|
||||
TName extends FieldPath<TValues>,
|
||||
> = {
|
||||
name: TName
|
||||
label?: string
|
||||
description?: string
|
||||
required?: boolean
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
// ── Component ──
|
||||
|
||||
export function RhfCustomerSelectField<
|
||||
TValues extends FieldValues,
|
||||
TName extends FieldPath<TValues>,
|
||||
>({
|
||||
name,
|
||||
label = "Customer",
|
||||
description,
|
||||
required,
|
||||
disabled,
|
||||
placeholder = "Search by name, company, or phone...",
|
||||
}: RhfCustomerSelectFieldProps<TValues, TName>) {
|
||||
const api = useAuthApi()
|
||||
const anchorRef = useRef<HTMLDivElement>(null)
|
||||
const [inputValue, setInputValue] = useState("")
|
||||
|
||||
const { control } = useFormContext<TValues>()
|
||||
const {
|
||||
field,
|
||||
fieldState: { error },
|
||||
} = useController({ name, control, disabled })
|
||||
|
||||
const { data: options = [], isLoading } = useQuery<CustomerOption[]>({
|
||||
queryKey: [CUSTOMER_ROUTES.INDEX, "customer-select"],
|
||||
queryFn: async () => {
|
||||
const res = await api.customers.list()
|
||||
return extractItems(res).map(buildCustomerOption)
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
const filtered = inputValue
|
||||
? options.filter((c) =>
|
||||
[c.first_name, c.last_name, c.company_name, c.email, c.phone]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(inputValue.toLowerCase()),
|
||||
)
|
||||
: options
|
||||
|
||||
return (
|
||||
<FieldShell
|
||||
label={label}
|
||||
error={error?.message}
|
||||
description={description}
|
||||
required={required}
|
||||
>
|
||||
<div ref={anchorRef}>
|
||||
<Combobox
|
||||
value={field.value}
|
||||
onValueChange={(val: CustomerOption | CustomerOption[] | null) => {
|
||||
const single = Array.isArray(val) ? val[0] ?? null : val
|
||||
field.onChange(
|
||||
single ? { value: single.value, label: single.label } : null,
|
||||
)
|
||||
}}
|
||||
disabled={field.disabled}
|
||||
onInputValueChange={(val: string, { reason }: { reason: string }) => {
|
||||
if (reason === "input-change") setInputValue(val)
|
||||
}}
|
||||
isItemEqualToValue={(item: CustomerOption, val: any) =>
|
||||
item?.value === val?.value
|
||||
}
|
||||
>
|
||||
<ComboboxInput
|
||||
placeholder={placeholder}
|
||||
showClear={!!field.value}
|
||||
onBlur={field.onBlur}
|
||||
aria-invalid={!!error || undefined}
|
||||
/>
|
||||
<ComboboxContent anchor={anchorRef}>
|
||||
<ComboboxList className='overflow-auto'>
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading &&
|
||||
filtered.map((opt) => (
|
||||
<ComboboxItem key={opt.value} value={opt}>
|
||||
<div className="flex items-center gap-3 py-0.5 w-full min-w-0">
|
||||
{/* Avatar circle with initials */}
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary border border-border text-sm font-medium">
|
||||
{getInitials(opt)}
|
||||
</div>
|
||||
|
||||
{/* Identity */}
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className="truncate text-sm font-medium leading-none">
|
||||
{opt.label}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{opt.company_name && opt.label !== opt.company_name && (
|
||||
<span className="flex items-center gap-1 truncate text-xs text-muted-foreground">
|
||||
<Building2 className="size-3 shrink-0" />
|
||||
{opt.company_name}
|
||||
</span>
|
||||
)}
|
||||
{opt.phone && (
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{opt.phone}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ComboboxItem>
|
||||
))}
|
||||
|
||||
{!isLoading && filtered.length === 0 && (
|
||||
<ComboboxEmpty>No customers found</ComboboxEmpty>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</div>
|
||||
</FieldShell>
|
||||
)
|
||||
}
|
||||
70
apps/dashboard/modules/employees/employee-actions.tsx
Normal file
70
apps/dashboard/modules/employees/employee-actions.tsx
Normal file
@ -0,0 +1,70 @@
|
||||
"use client"
|
||||
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/components/ui/dropdown-menu"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/shared/components/ui/dialog"
|
||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||
import { Ellipsis, Pencil, Trash2 } from "lucide-react"
|
||||
import { useFormDialog } from "@/shared/components/form-dialog"
|
||||
import { EmployeeForm } from "./employee-form"
|
||||
|
||||
type EmployeeActionsProps = {
|
||||
employeeId: string
|
||||
}
|
||||
|
||||
export function EmployeeActions({ employeeId }: EmployeeActionsProps) {
|
||||
const api = useAuthApi()
|
||||
const router = useRouter()
|
||||
const editDialog = useFormDialog("employee_edit")
|
||||
|
||||
const handleDelete = async () => {
|
||||
await api.employees.destroy(employeeId)
|
||||
router.push("/productivity/employees")
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Ellipsis className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => editDialog.open(employeeId)}>
|
||||
<Pencil className="size-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
|
||||
<Trash2 className="size-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Dialog open={editDialog.isOpen} onOpenChange={(v) => { if (!v) editDialog.close() }}>
|
||||
<DialogContent className="min-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl font-bold">Edit Employee</DialogTitle>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="max-h-[80vh] px-4">
|
||||
<EmployeeForm
|
||||
resourceId={editDialog.resourceId}
|
||||
onSuccess={() => {
|
||||
editDialog.close()
|
||||
router.refresh()
|
||||
}}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
178
apps/dashboard/modules/employees/employee-combobox.tsx
Normal file
178
apps/dashboard/modules/employees/employee-combobox.tsx
Normal file
@ -0,0 +1,178 @@
|
||||
"use client"
|
||||
|
||||
import { useRef, useState } from "react"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { EMPLOYEE_ROUTES } from "@garage/api"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxInput,
|
||||
ComboboxContent,
|
||||
ComboboxList,
|
||||
ComboboxItem,
|
||||
ComboboxEmpty,
|
||||
} from "@/shared/components/ui/combobox"
|
||||
|
||||
// ── Types ──
|
||||
|
||||
export type EmployeeOption = {
|
||||
value: string
|
||||
label: string
|
||||
first_name?: string
|
||||
last_name?: string
|
||||
email?: string
|
||||
type?: string
|
||||
status?: string
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
function buildEmployeeOption(item: any): EmployeeOption {
|
||||
const label =
|
||||
[item.first_name, item.last_name].filter(Boolean).join(" ") ||
|
||||
`Employee #${item.id}`
|
||||
return {
|
||||
value: String(item.id),
|
||||
label,
|
||||
first_name: item.first_name,
|
||||
last_name: item.last_name,
|
||||
email: item.email,
|
||||
type: item.type,
|
||||
status: item.status,
|
||||
}
|
||||
}
|
||||
|
||||
function extractItems(response: unknown): any[] {
|
||||
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 []
|
||||
}
|
||||
|
||||
function getInitials(opt: EmployeeOption): string {
|
||||
return (
|
||||
[opt.first_name?.[0], opt.last_name?.[0]].filter(Boolean).join("").toUpperCase() || "E"
|
||||
)
|
||||
}
|
||||
|
||||
// ── Props ──
|
||||
|
||||
type EmployeeComboboxProps = {
|
||||
value?: EmployeeOption | null
|
||||
onValueChange: (employee: EmployeeOption | null) => void
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
showClear?: boolean
|
||||
onBlur?: () => void
|
||||
"aria-invalid"?: boolean
|
||||
}
|
||||
|
||||
// ── Component ──
|
||||
|
||||
export function EmployeeCombobox({
|
||||
value,
|
||||
onValueChange,
|
||||
disabled,
|
||||
placeholder = "Search by name or email...",
|
||||
showClear,
|
||||
onBlur,
|
||||
"aria-invalid": ariaInvalid,
|
||||
}: EmployeeComboboxProps) {
|
||||
const api = useAuthApi()
|
||||
const anchorRef = useRef<HTMLDivElement>(null)
|
||||
const [inputValue, setInputValue] = useState("")
|
||||
|
||||
const { data: options = [], isLoading } = useQuery<EmployeeOption[]>({
|
||||
queryKey: [EMPLOYEE_ROUTES.INDEX, "employee-select"],
|
||||
queryFn: async () => {
|
||||
const res = await api.employees.list()
|
||||
return extractItems(res).map(buildEmployeeOption)
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
const filtered = inputValue
|
||||
? options.filter((e) =>
|
||||
[e.first_name, e.last_name, e.email, e.type]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(inputValue.toLowerCase()),
|
||||
)
|
||||
: options
|
||||
|
||||
return (
|
||||
<div ref={anchorRef}>
|
||||
<Combobox
|
||||
value={value}
|
||||
onValueChange={(val: EmployeeOption | EmployeeOption[] | null) => {
|
||||
const single = Array.isArray(val) ? val[0] ?? null : val
|
||||
onValueChange(single)
|
||||
}}
|
||||
disabled={disabled}
|
||||
onInputValueChange={(val: string, { reason }: { reason: string }) => {
|
||||
if (reason === "input-change") setInputValue(val)
|
||||
}}
|
||||
isItemEqualToValue={(item: EmployeeOption, val: any) =>
|
||||
item?.value === val?.value
|
||||
}
|
||||
>
|
||||
<ComboboxInput
|
||||
placeholder={placeholder}
|
||||
showClear={showClear ?? !!value}
|
||||
onBlur={onBlur}
|
||||
aria-invalid={ariaInvalid || undefined}
|
||||
/>
|
||||
<ComboboxContent anchor={anchorRef}>
|
||||
<ComboboxList>
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading &&
|
||||
filtered.map((opt) => (
|
||||
<ComboboxItem key={opt.value} value={opt}>
|
||||
<div className="flex items-center gap-3 py-0.5 w-full min-w-0">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary border border-border text-sm font-medium">
|
||||
{getInitials(opt)}
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className="truncate text-sm font-medium leading-none">
|
||||
{opt.label}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{opt.type && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-4 px-1.5 text-xs font-normal capitalize"
|
||||
>
|
||||
{opt.type}
|
||||
</Badge>
|
||||
)}
|
||||
{opt.status && (
|
||||
<span
|
||||
className={`text-xs capitalize ${opt.status === "active" ? "text-green-600 dark:text-green-400" : "text-muted-foreground"}`}
|
||||
>
|
||||
{opt.status}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ComboboxItem>
|
||||
))}
|
||||
|
||||
{!isLoading && filtered.length === 0 && (
|
||||
<ComboboxEmpty>No employees found</ComboboxEmpty>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
28
apps/dashboard/modules/employees/employee-context.tsx
Normal file
28
apps/dashboard/modules/employees/employee-context.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import { createContext, useContext } from "react"
|
||||
|
||||
type EmployeeContextValue = {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const EmployeeContext = createContext<EmployeeContextValue | null>(null)
|
||||
|
||||
export function EmployeeProvider({
|
||||
employee,
|
||||
children,
|
||||
}: {
|
||||
employee: EmployeeContextValue
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<EmployeeContext.Provider value={employee}>
|
||||
{children}
|
||||
</EmployeeContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useEmployee() {
|
||||
return useContext(EmployeeContext)
|
||||
}
|
||||
159
apps/dashboard/modules/employees/employee-general-info.tsx
Normal file
159
apps/dashboard/modules/employees/employee-general-info.tsx
Normal file
@ -0,0 +1,159 @@
|
||||
import {
|
||||
User,
|
||||
Mail,
|
||||
Phone,
|
||||
Briefcase,
|
||||
Building2,
|
||||
Clock,
|
||||
MapPin,
|
||||
Calendar,
|
||||
BadgeCheck,
|
||||
CircleDot,
|
||||
} from "lucide-react"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/components/ui/card"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Separator } from "@/shared/components/ui/separator"
|
||||
|
||||
type EmployeeData = {
|
||||
id?: number
|
||||
type?: string
|
||||
first_name?: string
|
||||
last_name?: string
|
||||
email?: string
|
||||
phone?: string
|
||||
position?: string
|
||||
designation?: string
|
||||
salary?: string
|
||||
wage_type?: string
|
||||
status?: string
|
||||
track_attendance?: boolean
|
||||
notify_owner_when_punch_in_out?: boolean
|
||||
geo_fence_radius?: string | number | null
|
||||
department?: { id?: number; name?: string } | null
|
||||
shop_calender?: { id?: number; title?: string } | null
|
||||
shop_timing?: { id?: number; title?: string } | null
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
type EmployeeGeneralInfoProps = {
|
||||
employee: EmployeeData
|
||||
}
|
||||
|
||||
function InfoItem({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
label: string
|
||||
value?: string | null
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
|
||||
<Icon className="size-4" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<span className="text-sm font-medium">
|
||||
{value || <span className="text-muted-foreground">—</span>}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function EmployeeGeneralInfo({ employee }: EmployeeGeneralInfoProps) {
|
||||
const fullName = [employee.first_name, employee.last_name].filter(Boolean).join(" ")
|
||||
|
||||
return (
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* Personal Information */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<User className="size-4" />
|
||||
Personal Information
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary">{fullName || "Unknown"}</Badge>
|
||||
{employee.type && (
|
||||
<Badge variant="outline" className="capitalize">
|
||||
{employee.type}
|
||||
</Badge>
|
||||
)}
|
||||
{employee.status && (
|
||||
<Badge
|
||||
variant={employee.status === "active" ? "default" : "secondary"}
|
||||
className="capitalize"
|
||||
>
|
||||
{employee.status}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<InfoItem icon={Mail} label="Email" value={employee.email} />
|
||||
<InfoItem icon={Phone} label="Phone" value={employee.phone} />
|
||||
<InfoItem icon={Briefcase} label="Position" value={employee.position} />
|
||||
<InfoItem icon={BadgeCheck} label="Designation" value={employee.designation} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Work Details */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Building2 className="size-4" />
|
||||
Work Details
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<InfoItem
|
||||
icon={Building2}
|
||||
label="Department"
|
||||
value={employee.department?.name}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={Calendar}
|
||||
label="Shop Calendar"
|
||||
value={employee.shop_calender?.title}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={Clock}
|
||||
label="Shop Timing"
|
||||
value={employee.shop_timing?.title}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={MapPin}
|
||||
label="Geo Fence Radius"
|
||||
value={employee.geo_fence_radius != null ? String(employee.geo_fence_radius) : null}
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant={employee.track_attendance ? "default" : "secondary"}>
|
||||
<CircleDot className="mr-1 size-3" />
|
||||
{employee.track_attendance ? "Attendance Tracked" : "No Attendance Tracking"}
|
||||
</Badge>
|
||||
{employee.notify_owner_when_punch_in_out && (
|
||||
<Badge variant="outline">
|
||||
Punch In/Out Notifications
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
292
apps/dashboard/modules/employees/employee-permissions-form.tsx
Normal file
292
apps/dashboard/modules/employees/employee-permissions-form.tsx
Normal file
@ -0,0 +1,292 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { EMPLOYEE_ROUTES } from "@garage/api"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Checkbox } from "@/shared/components/ui/checkbox"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { toast } from "sonner"
|
||||
import { Save, ShieldCheck } from "lucide-react"
|
||||
import DashboardPage from "@/base/components/layout/dashboard/dashboard-page"
|
||||
|
||||
// ── Permission resource groups ──
|
||||
|
||||
const PERMISSION_GROUPS: { label: string; key: string }[] = [
|
||||
{ label: "Appointments", key: "appointments" },
|
||||
{ label: "Bills", key: "bills" },
|
||||
{ label: "Check Point Labels", key: "check_point_labels" },
|
||||
{ label: "Credit Notes", key: "credit_notes" },
|
||||
{ label: "Customers", key: "customers" },
|
||||
{ label: "Departments", key: "departments" },
|
||||
{ label: "Document Types", key: "document_types" },
|
||||
{ label: "Employees", key: "employees" },
|
||||
{ label: "Estimates", key: "estimates" },
|
||||
{ label: "Expense Items", key: "expense_items" },
|
||||
{ label: "Expenses", key: "expenses" },
|
||||
{ label: "Holidays", key: "holidays" },
|
||||
{ label: "Inspection Categories", key: "inspection_categories" },
|
||||
{ label: "Inspection Check Points", key: "inspection_check_points" },
|
||||
{ label: "Inspections", key: "inspections" },
|
||||
{ label: "Insurance Types", key: "insurance_types" },
|
||||
{ label: "Inventory Adjustments", key: "inventory_adjustments" },
|
||||
{ label: "Inventory Categories", key: "inventory_categories" },
|
||||
{ label: "Invoice Documents", key: "invoice_documents" },
|
||||
{ label: "Invoice Labels", key: "invoice_labels" },
|
||||
{ label: "Invoice Notes", key: "invoice_notes" },
|
||||
{ label: "Invoice Sequences", key: "invoice_sequences" },
|
||||
{ label: "Invoices", key: "invoices" },
|
||||
{ label: "Job Cards", key: "job_cards" },
|
||||
{ label: "Labels", key: "labels" },
|
||||
{ label: "Labor Rates", key: "labor_rates" },
|
||||
{ label: "Make & Models", key: "make_and_models" },
|
||||
{ label: "Parts", key: "parts" },
|
||||
{ label: "Payment Mades", key: "payment_mades" },
|
||||
{ label: "Payment Modes", key: "payment_modes" },
|
||||
{ label: "Payment Received", key: "payment_recieveds" },
|
||||
{ label: "Payment Terms", key: "payment_terms" },
|
||||
{ label: "Purchase Orders", key: "purchase_orders" },
|
||||
{ label: "Quick Notes", key: "quick_notes" },
|
||||
{ label: "Quick Remarks", key: "quick_remarks" },
|
||||
{ label: "Reasons", key: "reasons" },
|
||||
{ label: "Referral Sources", key: "referral_sources" },
|
||||
{ label: "Service Group Includes", key: "service_group_includes" },
|
||||
{ label: "Service Group Parts", key: "service_group_parts" },
|
||||
{ label: "Service Group Pricings", key: "service_group_pricings" },
|
||||
{ label: "Service Group Services", key: "service_group_services" },
|
||||
{ label: "Service Groups", key: "service_groups" },
|
||||
{ label: "Services", key: "services" },
|
||||
{ label: "Settings", key: "settings" },
|
||||
{ label: "Shop Calendars", key: "shop_calenders" },
|
||||
{ label: "Shop Timings", key: "shop_timings" },
|
||||
{ label: "Shop Types", key: "shop_types" },
|
||||
{ label: "Task Sections", key: "task_sections" },
|
||||
{ label: "Task Types", key: "task_types" },
|
||||
{ label: "Tasks", key: "tasks" },
|
||||
{ label: "Taxes", key: "taxes" },
|
||||
{ label: "Time Sheets", key: "time_sheets" },
|
||||
{ label: "Unit Types", key: "unit_types" },
|
||||
{ label: "Vehicle Body Types", key: "vehicle_body_types" },
|
||||
{ label: "Vehicle Colors", key: "vehicle_colors" },
|
||||
{ label: "Vehicle Documents", key: "vehicle_documents" },
|
||||
{ label: "Vehicle Fuel Types", key: "vehicle_fuel_types" },
|
||||
{ label: "Vehicle Mile & Kms", key: "vehicle_mile_and_kms" },
|
||||
{ label: "Vehicle Transmissions", key: "vehicle_transmissions" },
|
||||
{ label: "Vehicles", key: "vehicles" },
|
||||
{ label: "Vendor Credits", key: "vendor_credits" },
|
||||
{ label: "Vendors", key: "vendors" },
|
||||
]
|
||||
|
||||
const ACTIONS = ["view", "create", "update", "delete"] as const
|
||||
|
||||
type PermissionAction = typeof ACTIONS[number]
|
||||
|
||||
type PermissionsState = Record<string, boolean>
|
||||
|
||||
function buildPermissionsPayload(state: PermissionsState) {
|
||||
return state
|
||||
}
|
||||
|
||||
function extractPermissions(data: Record<string, unknown> | null | undefined): PermissionsState {
|
||||
if (!data) return {}
|
||||
const result: PermissionsState = {}
|
||||
for (const group of PERMISSION_GROUPS) {
|
||||
for (const action of ACTIONS) {
|
||||
const key = `can_${action}_${group.key}`
|
||||
result[key] = Boolean(data[key])
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
type EmployeePermissionsFormProps = {
|
||||
employeeId: string
|
||||
}
|
||||
|
||||
export function EmployeePermissionsForm({ employeeId }: EmployeePermissionsFormProps) {
|
||||
const api = useAuthApi()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const queryKey = [EMPLOYEE_ROUTES.BY_ID, employeeId]
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey,
|
||||
queryFn: () => api.employees.getById(employeeId),
|
||||
})
|
||||
|
||||
const employeeData = (data as any)?.data as Record<string, unknown> | undefined
|
||||
|
||||
const [permissions, setPermissions] = useState<PermissionsState | null>(null)
|
||||
|
||||
const currentPermissions = permissions ?? extractPermissions(employeeData)
|
||||
|
||||
const { mutate, isPending } = useMutation({
|
||||
mutationFn: (payload: PermissionsState) =>
|
||||
api.employees.updatePermissions(employeeId, payload as never),
|
||||
onSuccess: () => {
|
||||
toast.success("Permissions updated successfully")
|
||||
queryClient.invalidateQueries({ queryKey })
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Failed to update permissions")
|
||||
},
|
||||
})
|
||||
|
||||
const handleToggle = (key: string, value: boolean) => {
|
||||
setPermissions((prev) => ({
|
||||
...(prev ?? extractPermissions(employeeData)),
|
||||
[key]: value,
|
||||
}))
|
||||
}
|
||||
|
||||
const handleRowToggle = (groupKey: string, checked: boolean) => {
|
||||
const updates: PermissionsState = {}
|
||||
for (const action of ACTIONS) {
|
||||
updates[`can_${action}_${groupKey}`] = checked
|
||||
}
|
||||
setPermissions((prev) => ({
|
||||
...(prev ?? extractPermissions(employeeData)),
|
||||
...updates,
|
||||
}))
|
||||
}
|
||||
|
||||
const handleColumnToggle = (action: PermissionAction, checked: boolean) => {
|
||||
const updates: PermissionsState = {}
|
||||
for (const group of PERMISSION_GROUPS) {
|
||||
updates[`can_${action}_${group.key}`] = checked
|
||||
}
|
||||
setPermissions((prev) => ({
|
||||
...(prev ?? extractPermissions(employeeData)),
|
||||
...updates,
|
||||
}))
|
||||
}
|
||||
|
||||
const isRowChecked = (groupKey: string) =>
|
||||
ACTIONS.every((action) => currentPermissions[`can_${action}_${groupKey}`])
|
||||
|
||||
const isRowIndeterminate = (groupKey: string) => {
|
||||
const values = ACTIONS.map((action) => currentPermissions[`can_${action}_${groupKey}`])
|
||||
return values.some(Boolean) && !values.every(Boolean)
|
||||
}
|
||||
|
||||
const isColumnChecked = (action: PermissionAction) =>
|
||||
PERMISSION_GROUPS.every((g) => currentPermissions[`can_${action}_${g.key}`])
|
||||
|
||||
const isColumnIndeterminate = (action: PermissionAction) => {
|
||||
const values = PERMISSION_GROUPS.map((g) => currentPermissions[`can_${action}_${g.key}`])
|
||||
return values.some(Boolean) && !values.every(Boolean)
|
||||
}
|
||||
|
||||
const isAllChecked = PERMISSION_GROUPS.every((g) =>
|
||||
ACTIONS.every((action) => currentPermissions[`can_${action}_${g.key}`])
|
||||
)
|
||||
|
||||
const isAllIndeterminate = (() => {
|
||||
const values = PERMISSION_GROUPS.flatMap((g) =>
|
||||
ACTIONS.map((action) => currentPermissions[`can_${action}_${g.key}`])
|
||||
)
|
||||
return values.some(Boolean) && !values.every(Boolean)
|
||||
})()
|
||||
|
||||
const handleToggleAll = (checked: boolean) => {
|
||||
const updates: PermissionsState = {}
|
||||
for (const group of PERMISSION_GROUPS) {
|
||||
for (const action of ACTIONS) {
|
||||
updates[`can_${action}_${group.key}`] = checked
|
||||
}
|
||||
}
|
||||
setPermissions(updates)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<DashboardPage header={null}>
|
||||
<div className="text-muted-foreground text-sm">Loading permissions...</div>
|
||||
</DashboardPage>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardPage header={null}>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-4">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<ShieldCheck className="size-4" />
|
||||
Permissions
|
||||
</CardTitle>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => mutate(buildPermissionsPayload(currentPermissions))}
|
||||
disabled={isPending}
|
||||
>
|
||||
<Save className="size-4" />
|
||||
Save Permissions
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="overflow-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="py-2 pr-4 text-left font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
className="border-primary"
|
||||
checked={isAllChecked || (isAllIndeterminate ? "indeterminate" : false)}
|
||||
onCheckedChange={(v) => handleToggleAll(Boolean(v))}
|
||||
/>
|
||||
Resource
|
||||
</div>
|
||||
</th>
|
||||
{ACTIONS.map((action) => (
|
||||
<th key={action} className="py-2 px-4 text-center font-medium capitalize">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<Checkbox
|
||||
className="border border-primary"
|
||||
checked={isColumnChecked(action) || (isColumnIndeterminate(action) ? "indeterminate" : false)}
|
||||
onCheckedChange={(v) => handleColumnToggle(action, Boolean(v))}
|
||||
/>
|
||||
<Badge variant="outline" className="capitalize text-xs">
|
||||
{action}
|
||||
</Badge>
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{PERMISSION_GROUPS.map((group) => (
|
||||
<tr key={group.key} className="border-b last:border-0 hover:bg-muted/30 transition-colors">
|
||||
<td className="py-2 pr-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
className="border border-primary"
|
||||
checked={isRowChecked(group.key) || (isRowIndeterminate(group.key) ? "indeterminate" : false)}
|
||||
onCheckedChange={(v) => handleRowToggle(group.key, Boolean(v))}
|
||||
/>
|
||||
<span className="text-sm">{group.label}</span>
|
||||
</div>
|
||||
</td>
|
||||
{ACTIONS.map((action) => {
|
||||
const key = `can_${action}_${group.key}`
|
||||
return (
|
||||
<td key={action} className="py-2 px-4 text-center ">
|
||||
<Checkbox
|
||||
className="mx-auto border border-primary"
|
||||
|
||||
checked={Boolean(currentPermissions[key])}
|
||||
onCheckedChange={(v) => handleToggle(key, Boolean(v))}
|
||||
/>
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</DashboardPage>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,60 @@
|
||||
"use client"
|
||||
|
||||
import { useFormContext, useController, type FieldValues, type FieldPath } from "react-hook-form"
|
||||
import { FieldShell } from "@/shared/components/form/field-shell"
|
||||
import { EmployeeCombobox } from "./employee-combobox"
|
||||
|
||||
// ── Props ──
|
||||
|
||||
export type RhfEmployeeSelectFieldProps<
|
||||
TValues extends FieldValues,
|
||||
TName extends FieldPath<TValues>,
|
||||
> = {
|
||||
name: TName
|
||||
label?: string
|
||||
description?: string
|
||||
required?: boolean
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
// ── Component ──
|
||||
|
||||
export function RhfEmployeeSelectField<
|
||||
TValues extends FieldValues,
|
||||
TName extends FieldPath<TValues>,
|
||||
>({
|
||||
name,
|
||||
label = "Employee",
|
||||
description,
|
||||
required,
|
||||
disabled,
|
||||
placeholder = "Search by name or email...",
|
||||
}: RhfEmployeeSelectFieldProps<TValues, TName>) {
|
||||
const { control } = useFormContext<TValues>()
|
||||
const {
|
||||
field,
|
||||
fieldState: { error },
|
||||
} = useController({ name, control, disabled })
|
||||
|
||||
return (
|
||||
<FieldShell
|
||||
label={label}
|
||||
error={error?.message}
|
||||
description={description}
|
||||
required={required}
|
||||
>
|
||||
<EmployeeCombobox
|
||||
value={field.value}
|
||||
onValueChange={(emp) => {
|
||||
field.onChange(emp ? { value: emp.value, label: emp.label } : null)
|
||||
}}
|
||||
disabled={field.disabled}
|
||||
placeholder={placeholder}
|
||||
showClear={!!field.value}
|
||||
onBlur={field.onBlur}
|
||||
aria-invalid={!!error || undefined}
|
||||
/>
|
||||
</FieldShell>
|
||||
)
|
||||
}
|
||||
75
apps/dashboard/modules/estimates/estimate-actions.tsx
Normal file
75
apps/dashboard/modules/estimates/estimate-actions.tsx
Normal file
@ -0,0 +1,75 @@
|
||||
"use client"
|
||||
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/components/ui/dropdown-menu"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/components/ui/dialog"
|
||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||
import { Ellipsis, Pencil, Trash2 } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { EstimateForm } from "./estimate-form"
|
||||
|
||||
type EstimateActionsProps = {
|
||||
estimateId: string
|
||||
}
|
||||
|
||||
export function EstimateActions({ estimateId }: EstimateActionsProps) {
|
||||
const api = useAuthApi()
|
||||
const router = useRouter()
|
||||
const [editOpen, setEditOpen] = useState(false)
|
||||
|
||||
const handleDelete = async () => {
|
||||
await api.estimates.destroy(estimateId)
|
||||
router.push("/sales/estimates")
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Ellipsis className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setEditOpen(true)}>
|
||||
<Pencil className="size-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
|
||||
<Trash2 className="size-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Dialog open={editOpen} onOpenChange={setEditOpen}>
|
||||
<DialogContent className="min-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl font-bold">Edit Estimate</DialogTitle>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="max-h-[80vh] px-4">
|
||||
<EstimateForm
|
||||
resourceId={estimateId}
|
||||
onSuccess={() => {
|
||||
setEditOpen(false)
|
||||
router.refresh()
|
||||
}}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
28
apps/dashboard/modules/estimates/estimate-context.tsx
Normal file
28
apps/dashboard/modules/estimates/estimate-context.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import { createContext, useContext } from "react"
|
||||
|
||||
type EstimateContextValue = {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const EstimateContext = createContext<EstimateContextValue | null>(null)
|
||||
|
||||
export function EstimateProvider({
|
||||
estimate,
|
||||
children,
|
||||
}: {
|
||||
estimate: EstimateContextValue
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<EstimateContext.Provider value={estimate}>
|
||||
{children}
|
||||
</EstimateContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useEstimate() {
|
||||
return useContext(EstimateContext)
|
||||
}
|
||||
@ -23,7 +23,9 @@ import {
|
||||
estimateFormSchema,
|
||||
type EstimateFormValues,
|
||||
} from "./estimate.schema"
|
||||
import { ESTIMATE_ROUTES, CUSTOMER_ROUTES, VEHICLE_ROUTES, DEPARTMENT_ROUTES, LABEL_ROUTES } from "@garage/api"
|
||||
import { ESTIMATE_ROUTES, DEPARTMENT_ROUTES, LABEL_ROUTES } from "@garage/api"
|
||||
import { RhfCustomerSelectField } from "@/modules/customers/rhf-customer-select-field"
|
||||
import { RhfVehicleSelectField } from "@/modules/vehicles/rhf-vehicle-select-field"
|
||||
|
||||
// ── Props ──
|
||||
|
||||
@ -91,16 +93,6 @@ const mapLookupOption = (item: any) => ({
|
||||
label: item.name,
|
||||
})
|
||||
|
||||
const mapCustomerOption = (item: any) => ({
|
||||
value: String(item.id),
|
||||
label: [item.first_name, item.last_name].filter(Boolean).join(" "),
|
||||
})
|
||||
|
||||
const mapVehicleOption = (item: any) => ({
|
||||
value: String(item.id),
|
||||
label: [item.year, item.make_name, item.model_name].filter(Boolean).join(" "),
|
||||
})
|
||||
|
||||
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
|
||||
|
||||
// ── Component ──
|
||||
@ -157,24 +149,8 @@ export function EstimateForm({ resourceId, initialData, onSuccess }: EstimateFor
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfAsyncSelectField
|
||||
name="customer"
|
||||
label="Customer"
|
||||
placeholder="Select customer"
|
||||
queryKey={[CUSTOMER_ROUTES.INDEX]}
|
||||
listFn={() => api.customers.list()}
|
||||
mapOption={mapCustomerOption}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
<RhfAsyncSelectField
|
||||
name="vehicle"
|
||||
label="Vehicle"
|
||||
placeholder="Select vehicle"
|
||||
queryKey={[VEHICLE_ROUTES.INDEX]}
|
||||
listFn={() => api.vehicles.list()}
|
||||
mapOption={mapVehicleOption}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
<RhfCustomerSelectField name="customer" />
|
||||
<RhfVehicleSelectField name="vehicle" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
|
||||
197
apps/dashboard/modules/estimates/estimate-general-info.tsx
Normal file
197
apps/dashboard/modules/estimates/estimate-general-info.tsx
Normal file
@ -0,0 +1,197 @@
|
||||
import {
|
||||
FileText,
|
||||
Hash,
|
||||
Calendar,
|
||||
User,
|
||||
Car,
|
||||
Building2,
|
||||
Shield,
|
||||
Tag,
|
||||
MessageSquare,
|
||||
} from "lucide-react"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/components/ui/card"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
|
||||
type EstimateData = {
|
||||
id?: number
|
||||
title?: string
|
||||
estimate_number?: string
|
||||
date?: string
|
||||
customer_id?: number
|
||||
vehicle_id?: number
|
||||
department_id?: number
|
||||
has_insurance?: boolean
|
||||
enable_digital_authorisation?: boolean
|
||||
insurance_type_id?: string | number | null
|
||||
insurer_id?: string | number | null
|
||||
service_writer_id?: number
|
||||
footer?: string | null
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
labels?: {
|
||||
id?: number
|
||||
title?: string
|
||||
color_code?: string
|
||||
}[]
|
||||
customer_remarks?: {
|
||||
id?: number
|
||||
remark?: string
|
||||
created_at?: string
|
||||
}[]
|
||||
// Joined fields that may come from the API
|
||||
customer_name?: string
|
||||
vehicle_name?: string
|
||||
department_name?: string
|
||||
}
|
||||
|
||||
type EstimateGeneralInfoProps = {
|
||||
estimate: EstimateData
|
||||
}
|
||||
|
||||
function InfoItem({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
label: string
|
||||
value?: string | null
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
|
||||
<Icon className="size-4" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<span className="text-sm font-medium">
|
||||
{value || <span className="text-muted-foreground">—</span>}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function EstimateGeneralInfo({ estimate }: EstimateGeneralInfoProps) {
|
||||
return (
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="size-4" /> Estimate Details
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<InfoItem icon={FileText} label="Title" value={estimate.title} />
|
||||
<InfoItem icon={Hash} label="Estimate #" value={estimate.estimate_number} />
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<InfoItem icon={Calendar} label="Date" value={estimate.date} />
|
||||
<InfoItem
|
||||
icon={Shield}
|
||||
label="Insurance"
|
||||
value={estimate.has_insurance ? "Yes" : "No"}
|
||||
/>
|
||||
</div>
|
||||
<InfoItem
|
||||
icon={Calendar}
|
||||
label="Created"
|
||||
value={
|
||||
estimate.created_at
|
||||
? new Date(estimate.created_at).toLocaleDateString()
|
||||
: null
|
||||
}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<User className="size-4" /> Related Records
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4">
|
||||
<InfoItem
|
||||
icon={User}
|
||||
label="Customer"
|
||||
value={estimate.customer_name || (estimate.customer_id ? `#${estimate.customer_id}` : null)}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={Car}
|
||||
label="Vehicle"
|
||||
value={estimate.vehicle_name || (estimate.vehicle_id ? `#${estimate.vehicle_id}` : null)}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={Building2}
|
||||
label="Department"
|
||||
value={estimate.department_name || (estimate.department_id ? `#${estimate.department_id}` : null)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{estimate.labels && estimate.labels.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Tag className="size-4" /> Labels
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{estimate.labels.map((label) => (
|
||||
<Badge
|
||||
key={label.id}
|
||||
style={label.color_code ? { backgroundColor: label.color_code } : undefined}
|
||||
>
|
||||
{label.title}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{estimate.customer_remarks && estimate.customer_remarks.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<MessageSquare className="size-4" /> Customer Remarks
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3">
|
||||
{estimate.customer_remarks.map((remark) => (
|
||||
<div key={remark.id} className="rounded-md border p-3 text-sm">
|
||||
<p>{remark.remark}</p>
|
||||
{remark.created_at && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{new Date(remark.created_at).toLocaleDateString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{estimate.footer && (
|
||||
<Card className="md:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="size-4" /> Footer
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm">{estimate.footer}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
88
apps/dashboard/modules/inspections/inspection-actions.tsx
Normal file
88
apps/dashboard/modules/inspections/inspection-actions.tsx
Normal file
@ -0,0 +1,88 @@
|
||||
"use client"
|
||||
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/components/ui/dropdown-menu"
|
||||
import { Ellipsis, Pencil, Trash2, Play, CheckCircle2 } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
type InspectionActionsProps = {
|
||||
inspectionId: string
|
||||
status?: string
|
||||
onStatusChange?: () => void
|
||||
}
|
||||
|
||||
const STATUS_TRANSITIONS: Record<string, { next: string; label: string; icon: typeof Play }> = {
|
||||
open: { next: "in_progress", label: "Start Inspection", icon: Play },
|
||||
in_progress: { next: "completed", label: "Mark Completed", icon: CheckCircle2 },
|
||||
}
|
||||
|
||||
export function InspectionActions({ inspectionId, status, onStatusChange }: InspectionActionsProps) {
|
||||
const api = useAuthApi()
|
||||
const router = useRouter()
|
||||
|
||||
const handleEdit = () => {
|
||||
router.push(`/sales/inspections/${inspectionId}/edit`)
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
const promise = api.inspections.destroy(inspectionId)
|
||||
toast.promise(promise, {
|
||||
loading: "Deleting inspection...",
|
||||
success: "Inspection deleted successfully",
|
||||
error: "Failed to delete inspection",
|
||||
})
|
||||
await promise
|
||||
router.push("/sales/inspections")
|
||||
}
|
||||
|
||||
const handleStatusChange = async (newStatus: string) => {
|
||||
const promise = api.inspections.changeStatus({
|
||||
status: newStatus,
|
||||
} as never)
|
||||
toast.promise(promise, {
|
||||
loading: "Updating status...",
|
||||
success: "Status updated successfully",
|
||||
error: "Failed to update status",
|
||||
})
|
||||
await promise
|
||||
onStatusChange?.()
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
const transition = status ? STATUS_TRANSITIONS[status] : undefined
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Ellipsis className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={handleEdit}>
|
||||
<Pencil className="size-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
{transition && (
|
||||
<DropdownMenuItem onClick={() => handleStatusChange(transition.next)}>
|
||||
<transition.icon className="size-4" />
|
||||
{transition.label}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
|
||||
<Trash2 className="size-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
28
apps/dashboard/modules/inspections/inspection-context.tsx
Normal file
28
apps/dashboard/modules/inspections/inspection-context.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import { createContext, useContext } from "react"
|
||||
|
||||
type InspectionContextValue = {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const InspectionContext = createContext<InspectionContextValue | null>(null)
|
||||
|
||||
export function InspectionProvider({
|
||||
inspection,
|
||||
children,
|
||||
}: {
|
||||
inspection: InspectionContextValue
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<InspectionContext.Provider value={inspection}>
|
||||
{children}
|
||||
</InspectionContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useInspection() {
|
||||
return useContext(InspectionContext)
|
||||
}
|
||||
@ -24,11 +24,11 @@ import {
|
||||
} from "./inspection.schema"
|
||||
import {
|
||||
INSPECTION_ROUTES,
|
||||
CUSTOMER_ROUTES,
|
||||
VEHICLE_ROUTES,
|
||||
DEPARTMENT_ROUTES,
|
||||
EMPLOYEE_ROUTES,
|
||||
} from "@garage/api"
|
||||
import { RhfVehicleSelectField } from "@/modules/vehicles/rhf-vehicle-select-field"
|
||||
import { RhfCustomerSelectField } from "@/modules/customers/rhf-customer-select-field"
|
||||
import { RhfEmployeeSelectField } from "@/modules/employees/rhf-employee-select-field"
|
||||
|
||||
// ── Props ──
|
||||
|
||||
@ -91,21 +91,6 @@ const mapLookupOption = (item: any) => ({
|
||||
label: item.name ?? item.title ?? String(item.id),
|
||||
})
|
||||
|
||||
const mapCustomerOption = (item: any) => ({
|
||||
value: String(item.id),
|
||||
label: `${item.first_name ?? ""} ${item.last_name ?? ""}`.trim() || String(item.id),
|
||||
})
|
||||
|
||||
const mapVehicleOption = (item: any) => ({
|
||||
value: String(item.id),
|
||||
label: `${item.make ?? ""} ${item.model ?? ""} ${item.year ?? ""}`.trim() || String(item.id),
|
||||
})
|
||||
|
||||
const mapEmployeeOption = (item: any) => ({
|
||||
value: String(item.id),
|
||||
label: `${item.first_name ?? ""} ${item.last_name ?? ""}`.trim() || String(item.id),
|
||||
})
|
||||
|
||||
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
|
||||
|
||||
// ── Component ──
|
||||
@ -157,24 +142,8 @@ export function InspectionForm({ resourceId, initialData, onSuccess }: Inspectio
|
||||
<RhfTextField name="title" label="Title" placeholder="e.g. Pre-purchase" required />
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfAsyncSelectField
|
||||
name="customer"
|
||||
label="Customer"
|
||||
placeholder="Select customer"
|
||||
queryKey={[CUSTOMER_ROUTES.INDEX]}
|
||||
listFn={() => api.customers.list()}
|
||||
mapOption={mapCustomerOption}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
<RhfAsyncSelectField
|
||||
name="vehicle"
|
||||
label="Vehicle"
|
||||
placeholder="Select vehicle"
|
||||
queryKey={[VEHICLE_ROUTES.INDEX]}
|
||||
listFn={() => api.vehicles.list()}
|
||||
mapOption={mapVehicleOption}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
<RhfCustomerSelectField name="customer" />
|
||||
<RhfVehicleSelectField name="vehicle" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
@ -202,21 +171,13 @@ export function InspectionForm({ resourceId, initialData, onSuccess }: Inspectio
|
||||
/>
|
||||
</div>
|
||||
|
||||
<RhfAsyncSelectField
|
||||
name="employee"
|
||||
label="Employee"
|
||||
placeholder="Select employee"
|
||||
queryKey={[EMPLOYEE_ROUTES.INDEX]}
|
||||
listFn={() => api.employees.list()}
|
||||
mapOption={mapEmployeeOption}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
<RhfEmployeeSelectField name="employee" />
|
||||
|
||||
<RhfTextField name="order_number" label="Order Number" placeholder="e.g. ORD-001" />
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfTextField name="date" label="Date" placeholder="YYYY-MM-DD" type="date" />
|
||||
<RhfTextField name="time" label="Time" placeholder="HH:MM:SS" type="time" />
|
||||
<RhfTextField name="time" label="Time" placeholder="HH:MM:SS" type="time" step={1} />
|
||||
</div>
|
||||
|
||||
<Button type="submit" variant="default" disabled={isPending}>
|
||||
|
||||
215
apps/dashboard/modules/inspections/inspection-general-info.tsx
Normal file
215
apps/dashboard/modules/inspections/inspection-general-info.tsx
Normal file
@ -0,0 +1,215 @@
|
||||
import {
|
||||
ClipboardCheck,
|
||||
Calendar,
|
||||
Clock,
|
||||
Hash,
|
||||
FileText,
|
||||
User,
|
||||
Car,
|
||||
Building2,
|
||||
FolderOpen,
|
||||
} from "lucide-react"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/components/ui/card"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Separator } from "@/shared/components/ui/separator"
|
||||
|
||||
type InspectionData = {
|
||||
id?: number
|
||||
title?: string
|
||||
customer_id?: number
|
||||
vehicle_id?: number
|
||||
department_id?: number
|
||||
inspection_category_id?: number
|
||||
employee_id?: number
|
||||
order_number?: string
|
||||
date?: string
|
||||
time?: string
|
||||
note?: string
|
||||
status?: string
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
customer?: { id?: number; first_name?: string; last_name?: string }
|
||||
vehicle?: { id?: number; make?: string; model?: string; license_plate?: string }
|
||||
department?: { id?: number; name?: string }
|
||||
inspection_category?: { id?: number; name?: string }
|
||||
employee?: { id?: number; first_name?: string; last_name?: string }
|
||||
}
|
||||
|
||||
type InspectionGeneralInfoProps = {
|
||||
inspection: InspectionData
|
||||
}
|
||||
|
||||
function InfoItem({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
label: string
|
||||
value?: string | null
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
|
||||
<Icon className="size-4" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<span className="text-sm font-medium">
|
||||
{value || <span className="text-muted-foreground">—</span>}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
open: "bg-blue-100 text-blue-800",
|
||||
in_progress: "bg-yellow-100 text-yellow-800",
|
||||
completed: "bg-green-100 text-green-800",
|
||||
}
|
||||
|
||||
function formatStatus(status?: string) {
|
||||
if (!status) return "—"
|
||||
return status.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
}
|
||||
|
||||
export function InspectionGeneralInfo({ inspection }: InspectionGeneralInfoProps) {
|
||||
const customerName = inspection.customer
|
||||
? `${inspection.customer.first_name ?? ""} ${inspection.customer.last_name ?? ""}`.trim()
|
||||
: undefined
|
||||
|
||||
const vehicleName = inspection.vehicle
|
||||
? `${inspection.vehicle.make ?? ""} ${inspection.vehicle.model ?? ""}`.trim()
|
||||
: undefined
|
||||
|
||||
const employeeName = inspection.employee
|
||||
? `${inspection.employee.first_name ?? ""} ${inspection.employee.last_name ?? ""}`.trim()
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* Inspection Details */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<ClipboardCheck className="size-4" />
|
||||
Inspection Details
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{inspection.title && (
|
||||
<Badge variant="secondary">{inspection.title}</Badge>
|
||||
)}
|
||||
{inspection.status && (
|
||||
<Badge className={STATUS_COLORS[inspection.status] || ""}>
|
||||
{formatStatus(inspection.status)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<InfoItem
|
||||
icon={Hash}
|
||||
label="Order Number"
|
||||
value={inspection.order_number}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={FolderOpen}
|
||||
label="Category"
|
||||
value={inspection.inspection_category?.name}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={Calendar}
|
||||
label="Date"
|
||||
value={inspection.date ? new Date(inspection.date).toLocaleDateString() : null}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={Clock}
|
||||
label="Time"
|
||||
value={inspection.time}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Assignments */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<User className="size-4" />
|
||||
Assignments
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 sm:grid-cols-2">
|
||||
<InfoItem
|
||||
icon={User}
|
||||
label="Customer"
|
||||
value={customerName}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={Car}
|
||||
label="Vehicle"
|
||||
value={vehicleName}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={User}
|
||||
label="Employee"
|
||||
value={employeeName}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={Building2}
|
||||
label="Department"
|
||||
value={inspection.department?.name}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Notes & Timestamps */}
|
||||
{(inspection.note || inspection.created_at) && (
|
||||
<Card className="md:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="size-4" />
|
||||
Notes & Timestamps
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<InfoItem
|
||||
icon={Calendar}
|
||||
label="Created At"
|
||||
value={inspection.created_at ? new Date(inspection.created_at).toLocaleString() : null}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={Calendar}
|
||||
label="Updated At"
|
||||
value={inspection.updated_at ? new Date(inspection.updated_at).toLocaleString() : null}
|
||||
/>
|
||||
</div>
|
||||
{inspection.note && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
|
||||
<FileText className="size-4" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs text-muted-foreground">Note</span>
|
||||
<p className="text-sm whitespace-pre-wrap">{inspection.note}</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,227 @@
|
||||
"use client"
|
||||
|
||||
import { AlertTriangle, Plus, Save, Trash2 } from "lucide-react"
|
||||
import { useFieldArray } from "react-hook-form"
|
||||
|
||||
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 { 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 {
|
||||
inventoryAdjustmentFormSchema,
|
||||
type InventoryAdjustmentFormValues,
|
||||
} from "./inventory-adjustment.schema"
|
||||
import { INVENTORY_ADJUSTMENT_ROUTES, REASON_ROUTES, PARTS_ROUTES } from "@garage/api"
|
||||
|
||||
// ── Props ──
|
||||
|
||||
export type InventoryAdjustmentFormProps = {
|
||||
resourceId?: string | null
|
||||
initialData?: unknown
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
// ── Default values ──
|
||||
|
||||
const DEFAULT_VALUES: InventoryAdjustmentFormValues = {
|
||||
reference_number: "",
|
||||
date: "",
|
||||
chart_of_account: "",
|
||||
reason: null,
|
||||
notes: "",
|
||||
parts: [{ part: null, quantity: 1, rate: 0 }],
|
||||
}
|
||||
|
||||
// ── Mapping helpers ──
|
||||
|
||||
function mapToFormValues(data: unknown): InventoryAdjustmentFormValues {
|
||||
const d = (data as any)?.data ?? data ?? {}
|
||||
return {
|
||||
reference_number: d.reference_number || "",
|
||||
date: d.date || "",
|
||||
chart_of_account: d.chart_of_account || "",
|
||||
reason: toRelation(d.reason_id, d.reason_name),
|
||||
notes: d.notes || "",
|
||||
parts: Array.isArray(d.parts) && d.parts.length > 0
|
||||
? d.parts.map((p: any) => ({
|
||||
part: toRelation(p.part_id, p.part_name),
|
||||
quantity: p.quantity ?? 1,
|
||||
rate: p.rate ?? 0,
|
||||
}))
|
||||
: [{ part: null, quantity: 1, rate: 0 }],
|
||||
}
|
||||
}
|
||||
|
||||
function mapFormToPayload(values: InventoryAdjustmentFormValues) {
|
||||
return {
|
||||
reference_number: values.reference_number || undefined,
|
||||
date: values.date || undefined,
|
||||
chart_of_account: values.chart_of_account || undefined,
|
||||
reason_id: toId(values.reason) ? Number(toId(values.reason)) : undefined,
|
||||
notes: values.notes || undefined,
|
||||
parts: values.parts.map((p) => ({
|
||||
part_id: toId(p.part) ? Number(toId(p.part)) : undefined,
|
||||
quantity: p.quantity,
|
||||
rate: p.rate,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
const mapLookupOption = (item: any) => ({ value: String(item.id), label: item.name ?? item.title })
|
||||
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
|
||||
|
||||
// ── Component ──
|
||||
|
||||
export function InventoryAdjustmentForm({ resourceId, initialData, onSuccess }: InventoryAdjustmentFormProps) {
|
||||
const api = useAuthApi()
|
||||
|
||||
const { form, isEditing } = useResourceForm<InventoryAdjustmentFormValues, any>({
|
||||
schema: inventoryAdjustmentFormSchema,
|
||||
defaultValues: DEFAULT_VALUES,
|
||||
resourceId,
|
||||
initialData,
|
||||
mapToFormValues,
|
||||
})
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "parts",
|
||||
})
|
||||
|
||||
const { mutate, error, isPending } = useFormMutation(form, {
|
||||
mutationFn: (values: InventoryAdjustmentFormValues) => {
|
||||
const payload = mapFormToPayload(values)
|
||||
const promise = isEditing && resourceId
|
||||
? api.inventoryAdjustments.update(resourceId, payload as never)
|
||||
: api.inventoryAdjustments.create(payload as never)
|
||||
return promise
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(isEditing ? "Adjustment updated." : "Adjustment created.")
|
||||
onSuccess?.()
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<Rhform form={form} onSubmit={mutate} className="flex flex-col gap-4">
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="size-4" />
|
||||
<AlertTitle>
|
||||
{isEditing ? "Failed to update adjustment" : "Failed to create adjustment"}
|
||||
</AlertTitle>
|
||||
{error.message}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FieldGroup>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfTextField
|
||||
name="reference_number"
|
||||
label="Reference Number"
|
||||
placeholder="ADJ-001"
|
||||
/>
|
||||
<RhfTextField
|
||||
name="date"
|
||||
label="Date"
|
||||
type="date"
|
||||
/>
|
||||
<RhfTextField
|
||||
name="chart_of_account"
|
||||
label="Chart of Account"
|
||||
placeholder="Account name"
|
||||
/>
|
||||
<RhfAsyncSelectField
|
||||
name="reason"
|
||||
label="Reason"
|
||||
placeholder="Select reason..."
|
||||
queryKey={[REASON_ROUTES.INDEX]}
|
||||
listFn={() => api.reasons.list()}
|
||||
mapOption={mapLookupOption}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<RhfTextareaField
|
||||
name="notes"
|
||||
label="Notes"
|
||||
placeholder="Additional notes..."
|
||||
rows={3}
|
||||
/>
|
||||
</FieldGroup>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Parts</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => append({ part: null, quantity: 1, rate: 0 })}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
Add Part
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{fields.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">No parts added. Click "Add Part" to begin.</p>
|
||||
)}
|
||||
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className="grid grid-cols-[1fr_auto_auto_auto] items-end gap-2 rounded-lg border p-3">
|
||||
<RhfAsyncSelectField
|
||||
name={`parts.${index}.part`}
|
||||
label="Part"
|
||||
placeholder="Select part..."
|
||||
queryKey={[PARTS_ROUTES.INDEX]}
|
||||
listFn={() => api.parts.list()}
|
||||
mapOption={mapLookupOption}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
<RhfTextField
|
||||
name={`parts.${index}.quantity`}
|
||||
label="Qty"
|
||||
type="number"
|
||||
placeholder="1"
|
||||
/>
|
||||
<RhfTextField
|
||||
name={`parts.${index}.rate`}
|
||||
label="Rate"
|
||||
type="number"
|
||||
placeholder="0"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="mb-0.5"
|
||||
onClick={() => remove(index)}
|
||||
disabled={fields.length === 1}
|
||||
>
|
||||
<Trash2 className="size-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isPending}>
|
||||
<Save className="size-4" />
|
||||
{isPending ? "Saving..." : isEditing ? "Update Adjustment" : "Create Adjustment"}
|
||||
</Button>
|
||||
</div>
|
||||
</Rhform>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const relationFieldSchema = z
|
||||
.object({ value: z.string(), label: z.string() })
|
||||
.nullable()
|
||||
|
||||
const partLineSchema = z.object({
|
||||
part: relationFieldSchema,
|
||||
quantity: z.coerce.number().min(1, "Quantity must be at least 1"),
|
||||
rate: z.coerce.number().min(0, "Rate must be 0 or more"),
|
||||
})
|
||||
|
||||
export const inventoryAdjustmentFormSchema = z.object({
|
||||
reference_number: z.string().optional(),
|
||||
date: z.string().optional(),
|
||||
chart_of_account: z.string().optional(),
|
||||
reason: relationFieldSchema,
|
||||
notes: z.string().optional(),
|
||||
parts: z.array(partLineSchema).min(1, "At least one part is required"),
|
||||
})
|
||||
|
||||
export type InventoryAdjustmentFormValues = z.infer<typeof inventoryAdjustmentFormSchema>
|
||||
export type PartLineValues = z.infer<typeof partLineSchema>
|
||||
@ -22,7 +22,9 @@ import {
|
||||
invoiceFormSchema,
|
||||
type InvoiceFormValues,
|
||||
} from "./invoice.schema"
|
||||
import { INVOICE_ROUTES, CUSTOMER_ROUTES, VEHICLE_ROUTES, DEPARTMENT_ROUTES } from "@garage/api"
|
||||
import { INVOICE_ROUTES, DEPARTMENT_ROUTES } from "@garage/api"
|
||||
import { RhfCustomerSelectField } from "@/modules/customers/rhf-customer-select-field"
|
||||
import { RhfVehicleSelectField } from "@/modules/vehicles/rhf-vehicle-select-field"
|
||||
|
||||
// ── Constants ──
|
||||
|
||||
@ -95,16 +97,6 @@ const mapLookupOption = (item: any) => ({
|
||||
label: item.name,
|
||||
})
|
||||
|
||||
const mapCustomerOption = (item: any) => ({
|
||||
value: String(item.id),
|
||||
label: [item.first_name, item.last_name].filter(Boolean).join(" "),
|
||||
})
|
||||
|
||||
const mapVehicleOption = (item: any) => ({
|
||||
value: String(item.id),
|
||||
label: [item.year, item.make_name, item.model_name].filter(Boolean).join(" "),
|
||||
})
|
||||
|
||||
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
|
||||
|
||||
// ── Component ──
|
||||
@ -171,25 +163,8 @@ export function InvoiceForm({ resourceId, initialData, onSuccess }: InvoiceFormP
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfAsyncSelectField
|
||||
name="customer"
|
||||
label="Customer"
|
||||
placeholder="Select customer"
|
||||
queryKey={[CUSTOMER_ROUTES.INDEX]}
|
||||
listFn={() => api.customers.list()}
|
||||
mapOption={mapCustomerOption}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
<RhfAsyncSelectField
|
||||
name="vehicle"
|
||||
label="Vehicle"
|
||||
placeholder="Select vehicle"
|
||||
queryKey={[VEHICLE_ROUTES.INDEX]}
|
||||
listFn={() => api.vehicles.list()}
|
||||
mapOption={mapVehicleOption}
|
||||
{...STORE_OBJECT}
|
||||
|
||||
/>
|
||||
<RhfCustomerSelectField name="customer" />
|
||||
<RhfVehicleSelectField name="vehicle" />
|
||||
</div>
|
||||
|
||||
<RhfAsyncSelectField
|
||||
|
||||
@ -1,17 +1,9 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useRef } from "react"
|
||||
import { useMutation, useQuery } from "@tanstack/react-query"
|
||||
import { useState } from "react"
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/components/ui/dropdown-menu"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@ -19,39 +11,55 @@ import {
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/shared/components/ui/dialog"
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxInput,
|
||||
ComboboxContent,
|
||||
ComboboxList,
|
||||
ComboboxItem,
|
||||
ComboboxEmpty,
|
||||
} from "@/shared/components/ui/combobox"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/shared/components/ui/popover"
|
||||
import { Calendar } from "@/shared/components/ui/calendar"
|
||||
import { confirm } from "@/shared/components/confirm-dialog"
|
||||
import { toast } from "sonner"
|
||||
import { Ellipsis, Pencil, Trash2, CalendarIcon, UserCog, UserCheck, Loader2 } from "lucide-react"
|
||||
import { Pencil, CalendarIcon, UserCog, UserCheck, Loader2 } from "lucide-react"
|
||||
import { format } from "date-fns"
|
||||
import { EMPLOYEE_ROUTES } from "@garage/api"
|
||||
import { EmployeeCombobox } from "@/modules/employees/employee-combobox"
|
||||
|
||||
type JobCardActionsProps = {
|
||||
jobCardId: string
|
||||
orderDate?: string | null
|
||||
serviceWriterName?: string | null
|
||||
salesPersonName?: string | null
|
||||
}
|
||||
|
||||
type Employee = {
|
||||
id: number
|
||||
first_name?: string
|
||||
last_name?: string
|
||||
name?: string
|
||||
}
|
||||
// ── Informative Action Card ──
|
||||
|
||||
function getEmployeeName(emp: Employee) {
|
||||
return emp.name || [emp.first_name, emp.last_name].filter(Boolean).join(" ") || `Employee #${emp.id}`
|
||||
function ActionCard({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
isPending,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
label: string
|
||||
value?: string | null
|
||||
isPending?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="group relative flex items-center gap-3 rounded-lg border bg-card px-4 py-3 text-left transition-colors hover:bg-accent hover:text-accent-foreground cursor-pointer select-none">
|
||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground transition-colors group-hover:bg-background">
|
||||
<Icon className="size-4" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 overflow-hidden">
|
||||
<span className="text-[11px] font-medium text-muted-foreground leading-none">{label}</span>
|
||||
<span className="text-sm font-semibold leading-tight truncate">
|
||||
{value ?? <span className="font-normal text-muted-foreground italic">Not set</span>}
|
||||
</span>
|
||||
</div>
|
||||
{isPending ? (
|
||||
<Loader2 className="ml-2 size-3.5 shrink-0 text-muted-foreground animate-spin" />
|
||||
) : (
|
||||
<Pencil className="ml-2 size-3 shrink-0 text-muted-foreground opacity-0 group-hover:opacity-60 transition-opacity" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Employee Picker Dialog ──
|
||||
@ -61,8 +69,6 @@ type EmployeePickerDialogProps = {
|
||||
onOpenChange: (open: boolean) => void
|
||||
title: string
|
||||
description: string
|
||||
employees: Employee[]
|
||||
loading: boolean
|
||||
isPending: boolean
|
||||
onSelect: (employeeId: number) => void
|
||||
}
|
||||
@ -72,18 +78,9 @@ function EmployeePickerDialog({
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
employees,
|
||||
loading,
|
||||
isPending,
|
||||
onSelect,
|
||||
}: EmployeePickerDialogProps) {
|
||||
const anchorRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const handleSelect = (emp: Employee | null) => {
|
||||
if (!emp) return
|
||||
onSelect(emp.id)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
@ -91,36 +88,15 @@ function EmployeePickerDialog({
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div ref={anchorRef}>
|
||||
<Combobox
|
||||
<EmployeeCombobox
|
||||
value={null}
|
||||
onValueChange={handleSelect}
|
||||
onValueChange={(emp) => {
|
||||
if (emp) onSelect(Number(emp.value))
|
||||
}}
|
||||
disabled={isPending}
|
||||
>
|
||||
<ComboboxInput
|
||||
placeholder="Search employees..."
|
||||
showClear={false}
|
||||
/>
|
||||
<ComboboxContent anchor={anchorRef}>
|
||||
<ComboboxList>
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
{!loading &&
|
||||
employees.map((emp) => (
|
||||
<ComboboxItem key={emp.id} value={emp}>
|
||||
{getEmployeeName(emp)}
|
||||
</ComboboxItem>
|
||||
))}
|
||||
{!loading && employees.length === 0 && (
|
||||
<ComboboxEmpty>No employees found</ComboboxEmpty>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
@ -128,42 +104,13 @@ function EmployeePickerDialog({
|
||||
|
||||
// ── Main Component ──
|
||||
|
||||
export function JobCardActions({ jobCardId }: JobCardActionsProps) {
|
||||
export function JobCardActions({ jobCardId, orderDate, serviceWriterName, salesPersonName }: JobCardActionsProps) {
|
||||
const api = useAuthApi()
|
||||
const router = useRouter()
|
||||
const [datePickerOpen, setDatePickerOpen] = useState(false)
|
||||
const [serviceWriterDialogOpen, setServiceWriterDialogOpen] = useState(false)
|
||||
const [salesPersonDialogOpen, setSalesPersonDialogOpen] = useState(false)
|
||||
|
||||
const { data: employeesData, isLoading: employeesLoading } = useQuery({
|
||||
queryKey: [EMPLOYEE_ROUTES.INDEX],
|
||||
queryFn: () => api.employees.list(),
|
||||
})
|
||||
|
||||
const employees: Employee[] = (employeesData as any)?.data ?? []
|
||||
|
||||
const handleEdit = () => {
|
||||
router.push(`/sales/job-cards/${jobCardId}/edit`)
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
const confirmed = await confirm({
|
||||
title: "Delete Job Card",
|
||||
description: "Are you sure you want to delete this job card? This action cannot be undone.",
|
||||
confirmLabel: "Delete",
|
||||
variant: "destructive",
|
||||
})
|
||||
if (confirmed) {
|
||||
const promise = api.jobCards.destroy(jobCardId)
|
||||
toast.promise(promise, {
|
||||
loading: "Deleting job card...",
|
||||
success: "Job card deleted successfully",
|
||||
error: "Failed to delete job card",
|
||||
})
|
||||
await promise
|
||||
router.push("/sales/job-cards")
|
||||
}
|
||||
}
|
||||
|
||||
const changeDateMutation = useMutation({
|
||||
mutationFn: (date: Date) => {
|
||||
@ -215,15 +162,20 @@ export function JobCardActions({ jobCardId }: JobCardActionsProps) {
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex flex-wrap items-stretch gap-2">
|
||||
{/* Check-in Date Action Card */}
|
||||
<Popover open={datePickerOpen} onOpenChange={setDatePickerOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<CalendarIcon className="size-4" />
|
||||
<span className="hidden sm:inline">Change Date</span>
|
||||
</Button>
|
||||
<button type="button" className="focus:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-lg">
|
||||
<ActionCard
|
||||
icon={CalendarIcon}
|
||||
label="Order Date"
|
||||
value={orderDate ? new Date(orderDate).toLocaleDateString() : null}
|
||||
isPending={changeDateMutation.isPending}
|
||||
/>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="end">
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
onSelect={(date) => {
|
||||
@ -234,42 +186,42 @@ export function JobCardActions({ jobCardId }: JobCardActionsProps) {
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Button variant="outline" size="sm" onClick={() => setServiceWriterDialogOpen(true)}>
|
||||
<UserCog className="size-4" />
|
||||
<span className="hidden sm:inline">Service Writer</span>
|
||||
</Button>
|
||||
{/* Service Writer Action Card */}
|
||||
<button
|
||||
type="button"
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-lg"
|
||||
onClick={() => setServiceWriterDialogOpen(true)}
|
||||
>
|
||||
<ActionCard
|
||||
icon={UserCog}
|
||||
label="Service Writer"
|
||||
value={serviceWriterName ?? null}
|
||||
isPending={changeServiceWriterMutation.isPending}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<Button variant="outline" size="sm" onClick={() => setSalesPersonDialogOpen(true)}>
|
||||
<UserCheck className="size-4" />
|
||||
<span className="hidden sm:inline">Sales Person</span>
|
||||
</Button>
|
||||
{/* Sales Person Action Card */}
|
||||
<button
|
||||
type="button"
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-lg"
|
||||
onClick={() => setSalesPersonDialogOpen(true)}
|
||||
>
|
||||
<ActionCard
|
||||
icon={UserCheck}
|
||||
label="Sales Person"
|
||||
value={salesPersonName ?? null}
|
||||
isPending={changeSalesPersonMutation.isPending}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Edit / Delete Dropdown */}
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Ellipsis className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={handleEdit}>
|
||||
<Pencil className="size-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
|
||||
<Trash2 className="size-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<EmployeePickerDialog
|
||||
open={serviceWriterDialogOpen}
|
||||
onOpenChange={setServiceWriterDialogOpen}
|
||||
title="Change Service Writer"
|
||||
description="Search and select an employee to assign as service writer."
|
||||
employees={employees}
|
||||
loading={employeesLoading}
|
||||
isPending={changeServiceWriterMutation.isPending}
|
||||
onSelect={(id) => changeServiceWriterMutation.mutate(id)}
|
||||
/>
|
||||
@ -279,8 +231,6 @@ export function JobCardActions({ jobCardId }: JobCardActionsProps) {
|
||||
onOpenChange={setSalesPersonDialogOpen}
|
||||
title="Change Sales Person"
|
||||
description="Search and select an employee to assign as sales person."
|
||||
employees={employees}
|
||||
loading={employeesLoading}
|
||||
isPending={changeSalesPersonMutation.isPending}
|
||||
onSelect={(id) => changeSalesPersonMutation.mutate(id)}
|
||||
/>
|
||||
|
||||
@ -2,31 +2,26 @@
|
||||
|
||||
import { createContext, useContext, useState, useCallback } from "react"
|
||||
import type { JobCardStatus } from "./job-card.schema"
|
||||
import { JobCardResponseData } from "@garage/api"
|
||||
|
||||
type JobCardContextValue = {
|
||||
id: string
|
||||
label: string
|
||||
status: JobCardStatus
|
||||
setStatus: (status: JobCardStatus) => void
|
||||
}
|
||||
|
||||
const JobCardContext = createContext<JobCardContextValue | null>(null)
|
||||
const JobCardContext = createContext<JobCardResponseData | null>(null)
|
||||
|
||||
export function JobCardProvider({
|
||||
jobCard,
|
||||
children,
|
||||
}: {
|
||||
jobCard: { id: string; label: string; status: JobCardStatus }
|
||||
jobCard: JobCardResponseData
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const [status, setStatusState] = useState<JobCardStatus>(jobCard.status)
|
||||
const [status, setStatusState] = useState<JobCardStatus>(jobCard.status as JobCardStatus)
|
||||
|
||||
const setStatus = useCallback((newStatus: JobCardStatus) => {
|
||||
setStatusState(newStatus)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<JobCardContext.Provider value={{ id: jobCard.id, label: jobCard.label, status, setStatus }}>
|
||||
<JobCardContext.Provider value={{ ...jobCard, status, setStatus } as JobCardResponseData}>
|
||||
{children}
|
||||
</JobCardContext.Provider>
|
||||
)
|
||||
|
||||
56
apps/dashboard/modules/job-cards/job-card-dropdown.tsx
Normal file
56
apps/dashboard/modules/job-cards/job-card-dropdown.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
|
||||
"use client"
|
||||
import { confirm } from '@/shared/components/confirm-dialog';
|
||||
import { api } from '@garage/api';
|
||||
import { useRouter } from 'next/dist/client/components/navigation';
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/shared/components/ui/dropdown-menu'
|
||||
import { Button } from '@/shared/components/ui/button'
|
||||
import { toast } from 'sonner'
|
||||
import { Ellipsis, Pencil, Trash2 } from 'lucide-react';
|
||||
export default function JobCardDropdown({ id }: { id: string }) {
|
||||
const router = useRouter();
|
||||
const handleEdit = () => {
|
||||
router.push(`/sales/job-cards/${id}/edit`)
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
const confirmed = await confirm({
|
||||
title: "Delete Job Card",
|
||||
description: "Are you sure you want to delete this job card? This action cannot be undone.",
|
||||
confirmLabel: "Delete",
|
||||
variant: "destructive",
|
||||
})
|
||||
if (confirmed) {
|
||||
const promise = api.jobCards.destroy(id)
|
||||
toast.promise(promise, {
|
||||
loading: "Deleting job card...",
|
||||
success: "Job card deleted successfully",
|
||||
error: "Failed to delete job card",
|
||||
})
|
||||
await promise
|
||||
router.push("/sales/job-cards")
|
||||
}
|
||||
}
|
||||
return (
|
||||
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="self-stretch h-auto aspect-square">
|
||||
<Ellipsis className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={handleEdit}>
|
||||
<Pencil className="size-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
|
||||
<Trash2 className="size-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@ -10,6 +10,8 @@ import {
|
||||
RhfTextField,
|
||||
RhfSelectField,
|
||||
RhfAsyncSelectField,
|
||||
RhfDateField,
|
||||
RhfTimeField,
|
||||
} from "@/shared/components/form"
|
||||
import { toast } from "sonner"
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
@ -23,8 +25,13 @@ import {
|
||||
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, CUSTOMER_ROUTES, VEHICLE_ROUTES } from "@garage/api"
|
||||
import { JOB_CARD_ROUTES, EMPLOYEE_ROUTES, DEPARTMENT_ROUTES } from "@garage/api"
|
||||
import { RhfCustomerSelectField } from "@/modules/customers/rhf-customer-select-field"
|
||||
import { RhfVehicleSelectField } from "@/modules/vehicles/rhf-vehicle-select-field"
|
||||
|
||||
// ── Props ──
|
||||
|
||||
@ -40,10 +47,17 @@ const DEFAULT_VALUES: JobCardFormValues = {
|
||||
title: "",
|
||||
customer: null,
|
||||
vehicle: null,
|
||||
status: "draft",
|
||||
department: null,
|
||||
service_writer: null,
|
||||
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")}` })(),
|
||||
km_in: "",
|
||||
fuel_level: "",
|
||||
}
|
||||
|
||||
// ── Mapping helpers ──
|
||||
@ -53,12 +67,19 @@ function mapToFormValues(data: unknown): JobCardFormValues {
|
||||
|
||||
return {
|
||||
title: d.title || "",
|
||||
customer: toRelation(d.customer_id, d.customer_name),
|
||||
vehicle: toRelation(d.vehicle_id, d.vehicle_name),
|
||||
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),
|
||||
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),
|
||||
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] : "",
|
||||
km_in: d.km_in != null ? String(d.km_in) : "",
|
||||
fuel_level: d.fuel_level || "",
|
||||
}
|
||||
}
|
||||
|
||||
@ -67,10 +88,17 @@ function mapFormToPayload(values: JobCardFormValues) {
|
||||
title: values.title,
|
||||
customer_id: toId(values.customer),
|
||||
vehicle_id: toId(values.vehicle),
|
||||
department_id: toId(values.department),
|
||||
service_writer_id: toId(values.service_writer),
|
||||
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,
|
||||
check_in_date: values.check_in_date || undefined,
|
||||
check_in_time: values.check_in_time || undefined,
|
||||
km_in: values.km_in ? Number(values.km_in) : undefined,
|
||||
fuel_level: values.fuel_level || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@ -81,14 +109,9 @@ const mapLookupOption = (item: any) => ({
|
||||
label: item.name,
|
||||
})
|
||||
|
||||
const mapCustomerOption = (item: any) => ({
|
||||
const mapEmployeeOption = (item: any) => ({
|
||||
value: String(item.id),
|
||||
label: [item.first_name, item.last_name].filter(Boolean).join(" "),
|
||||
})
|
||||
|
||||
const mapVehicleOption = (item: any) => ({
|
||||
value: String(item.id),
|
||||
label: [item.year, item.make_name, item.model_name].filter(Boolean).join(" "),
|
||||
label: `${item.first_name} ${item.last_name}`,
|
||||
})
|
||||
|
||||
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
|
||||
@ -141,27 +164,62 @@ export function JobCardForm({ resourceId, initialData, onSuccess }: JobCardFormP
|
||||
<FieldGroup>
|
||||
<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>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{/* <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="customer"
|
||||
label="Customer"
|
||||
placeholder="Select customer"
|
||||
queryKey={[CUSTOMER_ROUTES.INDEX]}
|
||||
listFn={() => api.customers.list()}
|
||||
mapOption={mapCustomerOption}
|
||||
name="department"
|
||||
label="Department"
|
||||
placeholder="Select department"
|
||||
queryKey={[DEPARTMENT_ROUTES.INDEX]}
|
||||
listFn={() => api.departments.list()}
|
||||
mapOption={mapLookupOption}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
<RhfAsyncSelectField
|
||||
name="vehicle"
|
||||
label="Vehicle"
|
||||
placeholder="Select vehicle"
|
||||
queryKey={[VEHICLE_ROUTES.INDEX]}
|
||||
listFn={() => api.vehicles.list()}
|
||||
mapOption={mapVehicleOption}
|
||||
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"
|
||||
|
||||
@ -20,37 +20,15 @@ import {
|
||||
} from "@/shared/components/ui/card"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Separator } from "@/shared/components/ui/separator"
|
||||
import { JobCardActions } from "./job-card-actions"
|
||||
import { getVehicleLabel } from "../vehicles/utils/getVehicleLabel"
|
||||
import { JobCardResponseData } from "@garage/api"
|
||||
import { getFullName } from "@/shared/utils/getFullName"
|
||||
|
||||
|
||||
type JobCardData = {
|
||||
id?: number
|
||||
title?: string
|
||||
status?: string
|
||||
check_in_date?: string
|
||||
km_in?: number
|
||||
tax_inclusive?: string
|
||||
discount_type?: string
|
||||
discount_at?: string
|
||||
customer_id?: number
|
||||
customer_name?: string
|
||||
vehicle_id?: number
|
||||
vehicle_name?: string
|
||||
department_id?: number
|
||||
department_name?: string
|
||||
sales_person_id?: number
|
||||
sales_person_name?: string
|
||||
service_writer_id?: number
|
||||
service_writer_name?: string
|
||||
purchase_orders_count?: number
|
||||
bills_count?: number
|
||||
expenses_count?: number
|
||||
tasks_count?: number
|
||||
appointments_count?: number
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
type JobCardGeneralInfoProps = {
|
||||
jobCard: JobCardData
|
||||
jobCard: JobCardResponseData
|
||||
}
|
||||
|
||||
function InfoItem({
|
||||
@ -86,7 +64,7 @@ const statusColorMap: Record<string, string> = {
|
||||
cancelled: "destructive",
|
||||
}
|
||||
|
||||
export function JobCardGeneralInfo({ jobCard }: JobCardGeneralInfoProps) {
|
||||
export function JobCardGeneralInfo({ jobCard }: { jobCard: JobCardResponseData }) {
|
||||
const formatStatus = (status?: string) => {
|
||||
if (!status) return null
|
||||
return status
|
||||
@ -95,9 +73,17 @@ export function JobCardGeneralInfo({ jobCard }: JobCardGeneralInfoProps) {
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
console.log(jobCard)
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{jobCard.service_writer?.first_name}
|
||||
<JobCardActions
|
||||
jobCardId={String(jobCard.id)}
|
||||
orderDate={jobCard.order_date ?? null}
|
||||
serviceWriterName={jobCard.service_writer?.first_name}
|
||||
salesPersonName={jobCard.sales_person?.first_name}
|
||||
/>
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* Job Card Details */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
@ -155,27 +141,27 @@ export function JobCardGeneralInfo({ jobCard }: JobCardGeneralInfoProps) {
|
||||
<InfoItem
|
||||
icon={Users}
|
||||
label="Customer"
|
||||
value={jobCard.customer_name}
|
||||
value={getFullName(jobCard.customer)}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={Car}
|
||||
label="Vehicle"
|
||||
value={jobCard.vehicle_name}
|
||||
value={getVehicleLabel(jobCard.vehicle as any)}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={Building2}
|
||||
label="Department"
|
||||
value={jobCard.department_name}
|
||||
value={jobCard.department as any}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={Briefcase}
|
||||
label="Sales Person"
|
||||
value={jobCard.sales_person_name}
|
||||
value={getFullName(jobCard.sales_person as any)}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={UserCheck}
|
||||
label="Service Writer"
|
||||
value={jobCard.service_writer_name}
|
||||
value={getFullName(jobCard.service_writer as any)}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
@ -245,5 +231,6 @@ export function JobCardGeneralInfo({ jobCard }: JobCardGeneralInfoProps) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -70,7 +70,7 @@ export function JobCardStatusStepper({ jobCardId }: JobCardStatusStepperProps) {
|
||||
<TooltipProvider>
|
||||
<div className="flex items-center gap-0 overflow-x-auto">
|
||||
{JOB_CARD_STATUSES.map((step, index) => {
|
||||
const Icon = STATUS_ICONS[step.value]
|
||||
const Icon = STATUS_ICONS[step.value] ?? CircleDot
|
||||
const isActive = step.value === currentStatus
|
||||
const isCompleted = index < currentIndex
|
||||
const isTransitioning = isPending && variables === step.value
|
||||
|
||||
@ -1,4 +1,12 @@
|
||||
import { z } from "zod"
|
||||
import {
|
||||
JobCardStatus,
|
||||
EstimateTo,
|
||||
TaxInclusive,
|
||||
DiscountType,
|
||||
DiscountAt,
|
||||
FuelLevel,
|
||||
} from "@garage/api"
|
||||
|
||||
const relationFieldSchema = z
|
||||
.object({ value: z.string(), label: z.string() })
|
||||
@ -6,31 +14,37 @@ const relationFieldSchema = z
|
||||
|
||||
// ── Job Card Statuses ──
|
||||
|
||||
export const JOB_CARD_STATUSES = [
|
||||
{ value: "draft", label: "Draft" },
|
||||
{ value: "check_in", label: "Check In" },
|
||||
{ value: "in_progress", label: "In Progress" },
|
||||
{ value: "on_hold", label: "On Hold" },
|
||||
{ value: "ready_to_deliver", label: "Ready to Deliver" },
|
||||
{ value: "delivered", label: "Delivered" },
|
||||
] as const
|
||||
export const JOB_CARD_STATUS_OPTIONS = JobCardStatus.map((v) => ({
|
||||
value: v,
|
||||
label: v
|
||||
.replace(/_/g, " ")
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase()),
|
||||
}))
|
||||
|
||||
export type JobCardStatus = (typeof JOB_CARD_STATUSES)[number]["value"]
|
||||
const ESTIMATE_TO_OPTIONS = EstimateTo.map((v) => ({ value: v, label: v }))
|
||||
|
||||
const TAX_INCLUSIVE_OPTIONS = [
|
||||
{ value: "Tax Inclusive", label: "Tax Inclusive" },
|
||||
{ value: "Tax Exclusive", label: "Tax Exclusive" },
|
||||
]
|
||||
const TAX_INCLUSIVE_OPTIONS = TaxInclusive.map((v) => ({ value: v, label: v }))
|
||||
|
||||
const DISCOUNT_TYPE_OPTIONS = [
|
||||
{ value: "no", label: "No Discount" },
|
||||
{ value: "transaction_level", label: "Transaction Level" },
|
||||
]
|
||||
const DISCOUNT_TYPE_OPTIONS = DiscountType.map((v) => ({
|
||||
value: v,
|
||||
label: v
|
||||
.replace(/_/g, " ")
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase()),
|
||||
}))
|
||||
|
||||
const DISCOUNT_AT_OPTIONS = [
|
||||
{ value: "inclusive_of_tax", label: "Inclusive of Tax" },
|
||||
{ value: "exclusive_of_tax", label: "Exclusive of Tax" },
|
||||
]
|
||||
const DISCOUNT_AT_OPTIONS = DiscountAt.map((v) => ({
|
||||
value: v,
|
||||
label: v
|
||||
.replace(/_/g, " ")
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase()),
|
||||
}))
|
||||
|
||||
const FUEL_LEVEL_OPTIONS = FuelLevel.map((v) => ({
|
||||
value: v,
|
||||
label: v
|
||||
.replace(/_/g, " ")
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase()),
|
||||
}))
|
||||
|
||||
const jobCardFormSchema = z.object({
|
||||
// ── Required fields ──
|
||||
@ -39,15 +53,36 @@ const jobCardFormSchema = z.object({
|
||||
// ── Relations ──
|
||||
customer: relationFieldSchema,
|
||||
vehicle: relationFieldSchema,
|
||||
department: relationFieldSchema,
|
||||
service_writer: relationFieldSchema,
|
||||
|
||||
// ── 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 ──
|
||||
check_in_date: z.string().optional(),
|
||||
check_in_time: z.string().optional(),
|
||||
km_in: z.string().optional(),
|
||||
fuel_level: z.string().optional(),
|
||||
})
|
||||
|
||||
type JobCardFormValues = z.infer<typeof jobCardFormSchema>
|
||||
|
||||
export { jobCardFormSchema, relationFieldSchema, TAX_INCLUSIVE_OPTIONS, DISCOUNT_TYPE_OPTIONS, DISCOUNT_AT_OPTIONS }
|
||||
export {
|
||||
jobCardFormSchema,
|
||||
relationFieldSchema,
|
||||
TAX_INCLUSIVE_OPTIONS,
|
||||
DISCOUNT_TYPE_OPTIONS,
|
||||
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
|
||||
export type { JobCardFormValues }
|
||||
export type { JobCardStatus } from "@garage/api"
|
||||
|
||||
254
apps/dashboard/modules/payment-mades/payment-made-form.tsx
Normal file
254
apps/dashboard/modules/payment-mades/payment-made-form.tsx
Normal file
@ -0,0 +1,254 @@
|
||||
"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,
|
||||
RhfSelectField,
|
||||
RhfTextareaField,
|
||||
RhfAsyncSelectField,
|
||||
} 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 {
|
||||
paymentMadeFormSchema,
|
||||
type PaymentMadeFormValues,
|
||||
} from "./payment-made.schema"
|
||||
import {
|
||||
PAYMENT_MADE_ROUTES,
|
||||
PAYMENT_ROUTES,
|
||||
VENDOR_ROUTES,
|
||||
EMPLOYEE_ROUTES,
|
||||
PaymentFor,
|
||||
} from "@garage/api"
|
||||
|
||||
// ── Constants ──
|
||||
|
||||
const PAYMENT_FOR_OPTIONS = PaymentFor.map((value) => ({
|
||||
value,
|
||||
label: value.charAt(0).toUpperCase() + value.slice(1),
|
||||
}))
|
||||
|
||||
// ── Props ──
|
||||
|
||||
export type PaymentMadeFormProps = {
|
||||
resourceId?: string | null
|
||||
initialData?: unknown
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
// ── Default values ──
|
||||
|
||||
const DEFAULT_VALUES: PaymentMadeFormValues = {
|
||||
vendor: null,
|
||||
employee: null,
|
||||
payment_mode: null,
|
||||
payment_for: "",
|
||||
payment_made: "",
|
||||
payment_number: "",
|
||||
payment_reference: "",
|
||||
payment_date: "",
|
||||
paid_through: "",
|
||||
notes: "",
|
||||
}
|
||||
|
||||
// ── Mapping helpers ──
|
||||
|
||||
function mapToFormValues(data: unknown): PaymentMadeFormValues {
|
||||
const d = (data as any)?.data ?? data ?? {}
|
||||
|
||||
return {
|
||||
vendor: toRelation(d.vendor_id, d.vendor_name),
|
||||
employee: toRelation(d.employee_id, d.employee_name),
|
||||
payment_mode: toRelation(d.payment_mode_id, d.payment_mode_name),
|
||||
payment_for: d.payment_for || "",
|
||||
payment_made: d.payment_made ? String(d.payment_made) : "",
|
||||
payment_number: d.payment_number || "",
|
||||
payment_reference: d.payment_reference || "",
|
||||
payment_date: d.payment_date || "",
|
||||
paid_through: d.paid_through || "",
|
||||
notes: d.notes || "",
|
||||
}
|
||||
}
|
||||
|
||||
function mapFormToPayload(values: PaymentMadeFormValues) {
|
||||
return {
|
||||
vendor_id: toId(values.vendor),
|
||||
employee_id: toId(values.employee) || undefined,
|
||||
payment_mode_id: toId(values.payment_mode),
|
||||
payment_for: values.payment_for,
|
||||
payment_made: values.payment_made,
|
||||
payment_number: values.payment_number || undefined,
|
||||
payment_reference: values.payment_reference || undefined,
|
||||
payment_date: values.payment_date,
|
||||
paid_through: values.paid_through || undefined,
|
||||
notes: values.notes || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Shared mapOption for async selects ──
|
||||
|
||||
const mapLookupOption = (item: any) => ({
|
||||
value: String(item.id),
|
||||
label: item.name ?? item.title ?? `#${item.id}`,
|
||||
})
|
||||
|
||||
const mapVendorOption = (item: any) => ({
|
||||
value: String(item.id),
|
||||
label: item.name ?? item.company_name ?? `#${item.id}`,
|
||||
})
|
||||
|
||||
const mapEmployeeOption = (item: any) => ({
|
||||
value: String(item.id),
|
||||
label: item.first_name
|
||||
? `${item.first_name} ${item.last_name || ""}`.trim()
|
||||
: item.name ?? `#${item.id}`,
|
||||
})
|
||||
|
||||
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
|
||||
|
||||
// ── Component ──
|
||||
|
||||
export function PaymentMadeForm({ resourceId, initialData, onSuccess }: PaymentMadeFormProps) {
|
||||
const api = useAuthApi()
|
||||
|
||||
const { form, isEditing } = useResourceForm<PaymentMadeFormValues, any>({
|
||||
schema: paymentMadeFormSchema,
|
||||
defaultValues: DEFAULT_VALUES,
|
||||
resourceId,
|
||||
initialData,
|
||||
mapToFormValues,
|
||||
})
|
||||
|
||||
const { mutate, error, isPending } = useFormMutation(form, {
|
||||
mutationFn: (values: PaymentMadeFormValues) => {
|
||||
const payload = mapFormToPayload(values)
|
||||
const promise = (isEditing && resourceId
|
||||
? api.paymentMades.update(resourceId, payload as any)
|
||||
: api.paymentMades.create(payload as any)) as Promise<any>
|
||||
toast.promise(promise, {
|
||||
loading: isEditing ? "Updating payment..." : "Recording payment...",
|
||||
success: isEditing ? "Payment updated successfully" : "Payment recorded successfully",
|
||||
error: isEditing ? "Failed to update payment" : "Failed to record payment",
|
||||
})
|
||||
return promise
|
||||
},
|
||||
onSuccess: () => {
|
||||
form.reset()
|
||||
onSuccess?.()
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<Rhform form={form} onSubmit={(values) => mutate(values)}>
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertTriangle className="me-2 h-4 w-4" />
|
||||
<AlertTitle>
|
||||
{isEditing ? "Failed to update payment" : "Failed to record payment"}
|
||||
</AlertTitle>
|
||||
{error.message}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FieldGroup>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfAsyncSelectField
|
||||
name="vendor"
|
||||
label="Vendor"
|
||||
placeholder="Select vendor"
|
||||
queryKey={[VENDOR_ROUTES.INDEX]}
|
||||
listFn={() => api.vendors.list()}
|
||||
mapOption={mapVendorOption}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
<RhfAsyncSelectField
|
||||
name="employee"
|
||||
label="Employee"
|
||||
placeholder="Select employee"
|
||||
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">
|
||||
<RhfSelectField
|
||||
name="payment_for"
|
||||
label="Payment For"
|
||||
placeholder="Select type"
|
||||
options={PAYMENT_FOR_OPTIONS}
|
||||
required
|
||||
/>
|
||||
<RhfTextField
|
||||
name="payment_made"
|
||||
label="Amount"
|
||||
placeholder="0.00"
|
||||
type="number"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfTextField
|
||||
name="payment_number"
|
||||
label="Payment Number"
|
||||
placeholder="PAY-001"
|
||||
/>
|
||||
<RhfTextField
|
||||
name="payment_reference"
|
||||
label="Payment Reference"
|
||||
placeholder="Reference"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfTextField
|
||||
name="payment_date"
|
||||
label="Payment Date"
|
||||
type="date"
|
||||
required
|
||||
/>
|
||||
<RhfAsyncSelectField
|
||||
name="payment_mode"
|
||||
label="Payment Mode"
|
||||
placeholder="Select payment mode"
|
||||
queryKey={[PAYMENT_ROUTES.MODES]}
|
||||
listFn={() => api.payments.listModes()}
|
||||
mapOption={mapLookupOption}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<RhfTextField
|
||||
name="paid_through"
|
||||
label="Paid Through"
|
||||
placeholder="Account name"
|
||||
/>
|
||||
|
||||
<RhfTextareaField
|
||||
name="notes"
|
||||
label="Notes"
|
||||
rows={3}
|
||||
placeholder="Add any notes about this payment..."
|
||||
/>
|
||||
|
||||
<Button type="submit" variant="default" disabled={isPending}>
|
||||
{isEditing ? <Save /> : <Plus />}
|
||||
{isPending
|
||||
? (isEditing ? "Updating..." : "Recording...")
|
||||
: (isEditing ? "Update Payment" : "Record Payment")}
|
||||
</Button>
|
||||
</FieldGroup>
|
||||
</Rhform>
|
||||
)
|
||||
}
|
||||
26
apps/dashboard/modules/payment-mades/payment-made.schema.ts
Normal file
26
apps/dashboard/modules/payment-mades/payment-made.schema.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { z } from "zod"
|
||||
|
||||
const relationFieldSchema = z
|
||||
.object({ value: z.string(), label: z.string() })
|
||||
.nullable()
|
||||
|
||||
const paymentMadeFormSchema = z.object({
|
||||
// ── Relations ──
|
||||
vendor: relationFieldSchema,
|
||||
employee: relationFieldSchema,
|
||||
payment_mode: relationFieldSchema,
|
||||
|
||||
// ── Payment info ──
|
||||
payment_for: z.string().min(1, "Payment for is required"),
|
||||
payment_made: z.string().min(1, "Amount is required"),
|
||||
payment_number: z.string().optional(),
|
||||
payment_reference: z.string().optional(),
|
||||
payment_date: z.string().min(1, "Payment date is required"),
|
||||
paid_through: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
})
|
||||
|
||||
type PaymentMadeFormValues = z.infer<typeof paymentMadeFormSchema>
|
||||
|
||||
export { paymentMadeFormSchema, relationFieldSchema }
|
||||
export type { PaymentMadeFormValues }
|
||||
189
apps/dashboard/modules/purchase-orders/purchase-order-form.tsx
Normal file
189
apps/dashboard/modules/purchase-orders/purchase-order-form.tsx
Normal file
@ -0,0 +1,189 @@
|
||||
"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,
|
||||
RhfAsyncSelectField,
|
||||
} 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 {
|
||||
purchaseOrderFormSchema,
|
||||
type PurchaseOrderFormValues,
|
||||
} from "./purchase-order.schema"
|
||||
import { PURCHASE_ORDER_ROUTES, VENDOR_ROUTES, JOB_CARD_ROUTES, DEPARTMENT_ROUTES } from "@garage/api"
|
||||
|
||||
// ── Props ──
|
||||
|
||||
export type PurchaseOrderFormProps = {
|
||||
resourceId?: string | null
|
||||
initialData?: unknown
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
// ── Default values ──
|
||||
|
||||
const DEFAULT_VALUES: PurchaseOrderFormValues = {
|
||||
vendor: null,
|
||||
job_card: null,
|
||||
department: null,
|
||||
title: "",
|
||||
order_date: "",
|
||||
delivery_date: "",
|
||||
notes: "",
|
||||
}
|
||||
|
||||
// ── Mapping helpers ──
|
||||
|
||||
function mapToFormValues(data: unknown): PurchaseOrderFormValues {
|
||||
const d = (data as any)?.data ?? data ?? {}
|
||||
|
||||
return {
|
||||
vendor: toRelation(d.vendor_id, d.vendor_name),
|
||||
job_card: toRelation(d.job_card_id, d.job_card_name),
|
||||
department: toRelation(d.department_id, d.department_name),
|
||||
title: d.title || "",
|
||||
order_date: d.order_date || "",
|
||||
delivery_date: d.delivery_date || "",
|
||||
notes: d.notes || "",
|
||||
}
|
||||
}
|
||||
|
||||
function mapFormToPayload(values: PurchaseOrderFormValues) {
|
||||
return {
|
||||
vendor_id: toId(values.vendor),
|
||||
job_card_id: toId(values.job_card),
|
||||
department_id: toId(values.department),
|
||||
title: values.title,
|
||||
order_date: values.order_date || undefined,
|
||||
delivery_date: values.delivery_date || undefined,
|
||||
notes: values.notes || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Shared mapOption for async selects ──
|
||||
|
||||
const mapLookupOption = (item: any) => ({
|
||||
value: String(item.id),
|
||||
label: item.name,
|
||||
})
|
||||
|
||||
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
|
||||
|
||||
// ── Component ──
|
||||
|
||||
export function PurchaseOrderForm({ resourceId, initialData, onSuccess }: PurchaseOrderFormProps) {
|
||||
const api = useAuthApi()
|
||||
|
||||
const { form, isEditing } = useResourceForm<PurchaseOrderFormValues, any>({
|
||||
schema: purchaseOrderFormSchema,
|
||||
defaultValues: DEFAULT_VALUES,
|
||||
resourceId,
|
||||
initialData,
|
||||
mapToFormValues,
|
||||
})
|
||||
|
||||
const { mutate, error, isPending } = useFormMutation(form, {
|
||||
mutationFn: (values: PurchaseOrderFormValues) => {
|
||||
const payload = mapFormToPayload(values)
|
||||
const promise = isEditing && resourceId
|
||||
? api.purchaseOrders.update(resourceId, payload)
|
||||
: api.purchaseOrders.create(payload)
|
||||
toast.promise(promise, {
|
||||
loading: isEditing ? "Updating purchase order..." : "Creating purchase order...",
|
||||
success: isEditing ? "Purchase order updated successfully" : "Purchase order created successfully",
|
||||
error: isEditing ? "Failed to update purchase order" : "Failed to create purchase order",
|
||||
})
|
||||
return promise
|
||||
},
|
||||
onSuccess: () => {
|
||||
form.reset()
|
||||
onSuccess?.()
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<Rhform form={form} onSubmit={(values) => mutate(values)}>
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertTriangle className="me-2 h-4 w-4" />
|
||||
<AlertTitle>
|
||||
{isEditing ? "Failed to update purchase order" : "Failed to create purchase order"}
|
||||
</AlertTitle>
|
||||
{error.message}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FieldGroup>
|
||||
<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" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfAsyncSelectField
|
||||
name="vendor"
|
||||
label="Vendor"
|
||||
placeholder="Select vendor"
|
||||
queryKey={[VENDOR_ROUTES.INDEX]}
|
||||
listFn={() => api.vendors.list()}
|
||||
mapOption={mapLookupOption}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
<RhfAsyncSelectField
|
||||
name="department"
|
||||
label="Department"
|
||||
placeholder="Select department"
|
||||
queryKey={[DEPARTMENT_ROUTES.INDEX]}
|
||||
listFn={() => api.departments.list()}
|
||||
mapOption={mapLookupOption}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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.job_card_number || item.name || `#${item.id}`,
|
||||
})}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
|
||||
<RhfTextareaField name="notes" label="Notes" rows={3} />
|
||||
|
||||
<Button type="submit" variant="default" disabled={isPending}>
|
||||
{isPending ? (
|
||||
"Saving..."
|
||||
) : isEditing ? (
|
||||
<>
|
||||
<Save className="me-2 h-4 w-4" />
|
||||
Update Purchase Order
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="me-2 h-4 w-4" />
|
||||
Create Purchase Order
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</FieldGroup>
|
||||
</Rhform>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
import { z } from "zod"
|
||||
|
||||
const relationFieldSchema = z
|
||||
.object({ value: z.string(), label: z.string() })
|
||||
.nullable()
|
||||
|
||||
const purchaseOrderFormSchema = z.object({
|
||||
// ── Relations ──
|
||||
vendor: relationFieldSchema,
|
||||
job_card: relationFieldSchema,
|
||||
department: relationFieldSchema,
|
||||
|
||||
// ── Basic info ──
|
||||
title: z.string().min(1, "Title is required"),
|
||||
order_date: z.string().optional(),
|
||||
delivery_date: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
})
|
||||
|
||||
type PurchaseOrderFormValues = z.infer<typeof purchaseOrderFormSchema>
|
||||
|
||||
export { purchaseOrderFormSchema, relationFieldSchema }
|
||||
export type { PurchaseOrderFormValues }
|
||||
319
apps/dashboard/modules/settings/company/settings-form.tsx
Normal file
319
apps/dashboard/modules/settings/company/settings-form.tsx
Normal file
@ -0,0 +1,319 @@
|
||||
"use client"
|
||||
|
||||
import { AlertTriangle, Save } from "lucide-react"
|
||||
import { useEffect } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { toast } from "sonner"
|
||||
|
||||
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,
|
||||
RhfSelectField,
|
||||
RhfAsyncSelectField,
|
||||
RhfTextareaField,
|
||||
} from "@/shared/components/form"
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { useFormMutation } from "@/shared/hooks/use-form-mutation"
|
||||
import { toId } from "@/shared/lib/utils"
|
||||
|
||||
import { FirstDayOfWork } from "@garage/api"
|
||||
import { SETTINGS_ROUTES } from "@garage/api"
|
||||
import { settingsFormSchema, type SettingsFormValues } from "./settings.schema"
|
||||
|
||||
// ── Constants ──
|
||||
|
||||
const FIRST_DAY_OPTIONS = FirstDayOfWork.map((d) => ({
|
||||
value: d,
|
||||
label: d.charAt(0).toUpperCase() + d.slice(1),
|
||||
}))
|
||||
|
||||
const mapLookupOption = (item: any) => ({ value: String(item.id), label: item.name })
|
||||
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
|
||||
|
||||
// ── Default values ──
|
||||
|
||||
const DEFAULT_VALUES: SettingsFormValues = {
|
||||
name: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
alternative_phone: "",
|
||||
website: "",
|
||||
time_zone: "",
|
||||
upi_id: "",
|
||||
first_day_of_work: "",
|
||||
latitude: "",
|
||||
longitude: "",
|
||||
bank_details: "",
|
||||
first_address_line: "",
|
||||
second_address_line: "",
|
||||
country: null,
|
||||
state: null,
|
||||
city: "",
|
||||
zip_code: "",
|
||||
description: "",
|
||||
security: "",
|
||||
privacy_policy: "",
|
||||
}
|
||||
|
||||
// ── Mapping helpers ──
|
||||
|
||||
function mapToFormValues(data: unknown): SettingsFormValues {
|
||||
const d = (data as any)?.data ?? data ?? {}
|
||||
return {
|
||||
name: d.name ?? "",
|
||||
email: d.email ?? "",
|
||||
phone: d.phone ?? "",
|
||||
alternative_phone: d.alternative_phone ?? "",
|
||||
website: d.website ?? "",
|
||||
time_zone: d.time_zone ?? "",
|
||||
upi_id: d.upi_id ?? "",
|
||||
first_day_of_work: d.first_day_of_work ?? "",
|
||||
latitude: d.latitude ?? "",
|
||||
longitude: d.longitude ?? "",
|
||||
bank_details: d.bank_details ?? "",
|
||||
first_address_line: d.first_address_line ?? "",
|
||||
second_address_line: d.second_address_line ?? "",
|
||||
country: null,
|
||||
state: null,
|
||||
city: d.city ?? "",
|
||||
zip_code: d.zip_code ?? "",
|
||||
description: d.description ?? "",
|
||||
security: d.security ?? "",
|
||||
privacy_policy: d.privacy_policy ?? "",
|
||||
}
|
||||
}
|
||||
|
||||
function mapFormToPayload(values: SettingsFormValues) {
|
||||
return {
|
||||
name: values.name,
|
||||
email: values.email || undefined,
|
||||
phone: values.phone || undefined,
|
||||
time_zone: values.time_zone || undefined,
|
||||
first_day_of_work: values.first_day_of_work || undefined,
|
||||
first_address_line: values.first_address_line || undefined,
|
||||
country_id: toId(values.country),
|
||||
city: values.city || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Component ──
|
||||
|
||||
export function SettingsForm() {
|
||||
const api = useAuthApi()
|
||||
|
||||
const form = useForm<SettingsFormValues>({
|
||||
resolver: zodResolver(settingsFormSchema) as any,
|
||||
defaultValues: DEFAULT_VALUES,
|
||||
})
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: [SETTINGS_ROUTES.INDEX],
|
||||
queryFn: () => api.settings.fetch(),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return
|
||||
const raw = (data as any)?.data
|
||||
const record = Array.isArray(raw) ? raw[0] : raw
|
||||
if (record) {
|
||||
form.reset(mapToFormValues(record))
|
||||
}
|
||||
}, [data]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const { mutate, error, isPending } = useFormMutation(form, {
|
||||
mutationFn: (values: SettingsFormValues) => {
|
||||
const payload = mapFormToPayload(values)
|
||||
const promise = api.settings.update(payload)
|
||||
toast.promise(promise, {
|
||||
loading: "Saving settings...",
|
||||
success: "Settings saved successfully",
|
||||
error: "Failed to save settings",
|
||||
})
|
||||
return promise
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<Rhform form={form} onSubmit={(values) => mutate(values)}>
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertTriangle className="me-2 h-4 w-4" />
|
||||
<AlertTitle>Failed to save settings</AlertTitle>
|
||||
{error.message}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FieldGroup>
|
||||
{/* General Info */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfTextField
|
||||
name="name"
|
||||
label="Workshop Name"
|
||||
placeholder="My Workshop"
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<RhfTextField
|
||||
name="email"
|
||||
label="Email"
|
||||
placeholder="workshop@example.com"
|
||||
type="email"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfTextField
|
||||
name="phone"
|
||||
label="Phone"
|
||||
placeholder="+971501234567"
|
||||
type="tel"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<RhfTextField
|
||||
name="alternative_phone"
|
||||
label="Alternative Phone"
|
||||
placeholder="+971509876543"
|
||||
type="tel"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfTextField
|
||||
name="website"
|
||||
label="Website"
|
||||
placeholder="https://example.com"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<RhfTextField
|
||||
name="upi_id"
|
||||
label="UPI ID"
|
||||
placeholder="workshop@upi"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfTextField
|
||||
name="time_zone"
|
||||
label="Time Zone"
|
||||
placeholder="Asia/Dubai"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<RhfSelectField
|
||||
name="first_day_of_work"
|
||||
label="First Day of Work"
|
||||
placeholder="Select day"
|
||||
options={FIRST_DAY_OPTIONS}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Address */}
|
||||
<RhfTextField
|
||||
name="first_address_line"
|
||||
label="Address Line 1"
|
||||
placeholder="Street 10"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<RhfTextField
|
||||
name="second_address_line"
|
||||
label="Address Line 2"
|
||||
placeholder="Near Central Plaza"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfAsyncSelectField
|
||||
name="country"
|
||||
label="Country"
|
||||
placeholder="Select country"
|
||||
queryKey={["countries"]}
|
||||
listFn={() => api.geo.countries()}
|
||||
mapOption={mapLookupOption}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
<RhfAsyncSelectField
|
||||
name="state"
|
||||
label="State"
|
||||
placeholder="Select state"
|
||||
queryKey={["states"]}
|
||||
listFn={() => api.geo.states()}
|
||||
mapOption={mapLookupOption}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfTextField
|
||||
name="city"
|
||||
label="City"
|
||||
placeholder="Dubai"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<RhfTextField
|
||||
name="zip_code"
|
||||
label="Zip Code"
|
||||
placeholder="00000"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Location */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfTextField
|
||||
name="latitude"
|
||||
label="Latitude"
|
||||
placeholder="25.2048"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<RhfTextField
|
||||
name="longitude"
|
||||
label="Longitude"
|
||||
placeholder="55.2708"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Other */}
|
||||
<RhfTextareaField
|
||||
name="bank_details"
|
||||
label="Bank Details"
|
||||
placeholder="Bank name, account number, IBAN..."
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<RhfTextareaField
|
||||
name="description"
|
||||
label="Description"
|
||||
placeholder="About the workshop..."
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<RhfTextareaField
|
||||
name="security"
|
||||
label="Security Policy"
|
||||
placeholder="Security policy text..."
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<RhfTextareaField
|
||||
name="privacy_policy"
|
||||
label="Privacy Policy"
|
||||
placeholder="Privacy policy text..."
|
||||
disabled={isLoading}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isPending || isLoading}>
|
||||
<Save />
|
||||
{isPending ? "Saving..." : "Save Settings"}
|
||||
</Button>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</Rhform>
|
||||
)
|
||||
}
|
||||
30
apps/dashboard/modules/settings/company/settings.schema.ts
Normal file
30
apps/dashboard/modules/settings/company/settings.schema.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const relationFieldSchema = z
|
||||
.object({ value: z.string(), label: z.string() })
|
||||
.nullable()
|
||||
|
||||
export const settingsFormSchema = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
email: z.union([z.string().email("Invalid email address"), z.literal("")]).optional(),
|
||||
phone: z.string().optional(),
|
||||
alternative_phone: z.string().optional(),
|
||||
website: z.string().optional(),
|
||||
time_zone: z.string().optional(),
|
||||
upi_id: z.string().optional(),
|
||||
first_day_of_work: z.string().optional(),
|
||||
latitude: z.string().optional(),
|
||||
longitude: z.string().optional(),
|
||||
bank_details: z.string().optional(),
|
||||
first_address_line: z.string().optional(),
|
||||
second_address_line: z.string().optional(),
|
||||
country: relationFieldSchema,
|
||||
state: relationFieldSchema,
|
||||
city: z.string().optional(),
|
||||
zip_code: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
security: z.string().optional(),
|
||||
privacy_policy: z.string().optional(),
|
||||
})
|
||||
|
||||
export type SettingsFormValues = z.infer<typeof settingsFormSchema>
|
||||
@ -0,0 +1,24 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const salesConfigFormSchema = z.object({
|
||||
sell_rates_tax_inclusive: z.string().optional(),
|
||||
give_discounts: z.string().optional(),
|
||||
})
|
||||
|
||||
export type SalesConfigFormValues = z.infer<typeof salesConfigFormSchema>
|
||||
|
||||
export const purchaseConfigFormSchema = z.object({
|
||||
purchase_rates_tax_inclusive: z.string().optional(),
|
||||
receive_discounts: z.string().optional(),
|
||||
})
|
||||
|
||||
export type PurchaseConfigFormValues = z.infer<typeof purchaseConfigFormSchema>
|
||||
|
||||
export const generalPreferencesFormSchema = z.object({
|
||||
sell_rates_tax_inclusive: z.string().optional(),
|
||||
give_discounts: z.string().optional(),
|
||||
purchase_rates_tax_inclusive: z.string().optional(),
|
||||
receive_discounts: z.string().optional(),
|
||||
})
|
||||
|
||||
export type GeneralPreferencesFormValues = z.infer<typeof generalPreferencesFormSchema>
|
||||
@ -0,0 +1,102 @@
|
||||
"use client"
|
||||
|
||||
import { AlertTriangle, Save } from "lucide-react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Alert, AlertTitle } from "@/shared/components/ui/alert"
|
||||
import { FieldGroup } from "@/shared/components/ui/field"
|
||||
import { Rhform, RhfSelectField } from "@/shared/components/form"
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { useFormMutation } from "@/shared/hooks/use-form-mutation"
|
||||
|
||||
import { TaxInclusive, DiscountType } from "@garage/api"
|
||||
import { generalPreferencesFormSchema, type GeneralPreferencesFormValues } from "./configurations.schema"
|
||||
|
||||
const TAX_INCLUSIVE_OPTIONS = TaxInclusive.map((v) => ({ value: v, label: v }))
|
||||
|
||||
const DISCOUNT_OPTIONS = DiscountType.map((v) => ({
|
||||
value: v,
|
||||
label: v === "no" ? "No Discount" : v === "line_item_level" ? "Line Item Level" : "Transaction Level",
|
||||
}))
|
||||
|
||||
const DEFAULT_VALUES: GeneralPreferencesFormValues = {
|
||||
sell_rates_tax_inclusive: "",
|
||||
give_discounts: "",
|
||||
purchase_rates_tax_inclusive: "",
|
||||
receive_discounts: "",
|
||||
}
|
||||
|
||||
export function GeneralPreferencesForm() {
|
||||
const api = useAuthApi()
|
||||
|
||||
const form = useForm<GeneralPreferencesFormValues>({
|
||||
resolver: zodResolver(generalPreferencesFormSchema) as any,
|
||||
defaultValues: DEFAULT_VALUES,
|
||||
})
|
||||
|
||||
const { mutate, error, isPending } = useFormMutation(form, {
|
||||
mutationFn: (values: GeneralPreferencesFormValues) => {
|
||||
const promise = api.configurations.updateGeneralPreferences(values)
|
||||
toast.promise(promise, {
|
||||
loading: "Saving general preferences...",
|
||||
success: "General preferences saved",
|
||||
error: "Failed to save general preferences",
|
||||
})
|
||||
return promise
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<Rhform form={form} onSubmit={(values) => mutate(values)}>
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertTriangle className="me-2 h-4 w-4" />
|
||||
<AlertTitle>Failed to save general preferences</AlertTitle>
|
||||
{error.message}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FieldGroup>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfSelectField
|
||||
name="sell_rates_tax_inclusive"
|
||||
label="Sell Rates Tax"
|
||||
placeholder="Select tax type"
|
||||
options={TAX_INCLUSIVE_OPTIONS}
|
||||
/>
|
||||
<RhfSelectField
|
||||
name="give_discounts"
|
||||
label="Give Discounts"
|
||||
placeholder="Select discount type"
|
||||
options={DISCOUNT_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfSelectField
|
||||
name="purchase_rates_tax_inclusive"
|
||||
label="Purchase Rates Tax"
|
||||
placeholder="Select tax type"
|
||||
options={TAX_INCLUSIVE_OPTIONS}
|
||||
/>
|
||||
<RhfSelectField
|
||||
name="receive_discounts"
|
||||
label="Receive Discounts"
|
||||
placeholder="Select discount type"
|
||||
options={DISCOUNT_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isPending}>
|
||||
<Save />
|
||||
{isPending ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</Rhform>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,83 @@
|
||||
"use client"
|
||||
|
||||
import { AlertTriangle, Save } from "lucide-react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Alert, AlertTitle } from "@/shared/components/ui/alert"
|
||||
import { FieldGroup } from "@/shared/components/ui/field"
|
||||
import { Rhform, RhfSelectField } from "@/shared/components/form"
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { useFormMutation } from "@/shared/hooks/use-form-mutation"
|
||||
|
||||
import { TaxInclusive, DiscountType } from "@garage/api"
|
||||
import { purchaseConfigFormSchema, type PurchaseConfigFormValues } from "./configurations.schema"
|
||||
|
||||
const TAX_INCLUSIVE_OPTIONS = TaxInclusive.map((v) => ({ value: v, label: v }))
|
||||
|
||||
const DISCOUNT_OPTIONS = DiscountType.map((v) => ({
|
||||
value: v,
|
||||
label: v === "no" ? "No Discount" : v === "line_item_level" ? "Line Item Level" : "Transaction Level",
|
||||
}))
|
||||
|
||||
const DEFAULT_VALUES: PurchaseConfigFormValues = {
|
||||
purchase_rates_tax_inclusive: "",
|
||||
receive_discounts: "",
|
||||
}
|
||||
|
||||
export function PurchaseConfigForm() {
|
||||
const api = useAuthApi()
|
||||
|
||||
const form = useForm<PurchaseConfigFormValues>({
|
||||
resolver: zodResolver(purchaseConfigFormSchema) as any,
|
||||
defaultValues: DEFAULT_VALUES,
|
||||
})
|
||||
|
||||
const { mutate, error, isPending } = useFormMutation(form, {
|
||||
mutationFn: (values: PurchaseConfigFormValues) => {
|
||||
const promise = api.configurations.updatePurchaseTaxDiscount(values)
|
||||
toast.promise(promise, {
|
||||
loading: "Saving purchase configuration...",
|
||||
success: "Purchase configuration saved",
|
||||
error: "Failed to save purchase configuration",
|
||||
})
|
||||
return promise
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<Rhform form={form} onSubmit={(values) => mutate(values)}>
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertTriangle className="me-2 h-4 w-4" />
|
||||
<AlertTitle>Failed to save purchase configuration</AlertTitle>
|
||||
{error.message}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FieldGroup>
|
||||
<RhfSelectField
|
||||
name="purchase_rates_tax_inclusive"
|
||||
label="Purchase Rates Tax"
|
||||
placeholder="Select tax type"
|
||||
options={TAX_INCLUSIVE_OPTIONS}
|
||||
/>
|
||||
<RhfSelectField
|
||||
name="receive_discounts"
|
||||
label="Receive Discounts"
|
||||
placeholder="Select discount type"
|
||||
options={DISCOUNT_OPTIONS}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isPending}>
|
||||
<Save />
|
||||
{isPending ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</Rhform>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,83 @@
|
||||
"use client"
|
||||
|
||||
import { AlertTriangle, Save } from "lucide-react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Alert, AlertTitle } from "@/shared/components/ui/alert"
|
||||
import { FieldGroup } from "@/shared/components/ui/field"
|
||||
import { Rhform, RhfSelectField } from "@/shared/components/form"
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { useFormMutation } from "@/shared/hooks/use-form-mutation"
|
||||
|
||||
import { TaxInclusive, DiscountType } from "@garage/api"
|
||||
import { salesConfigFormSchema, type SalesConfigFormValues } from "./configurations.schema"
|
||||
|
||||
const TAX_INCLUSIVE_OPTIONS = TaxInclusive.map((v) => ({ value: v, label: v }))
|
||||
|
||||
const DISCOUNT_OPTIONS = DiscountType.map((v) => ({
|
||||
value: v,
|
||||
label: v === "no" ? "No Discount" : v === "line_item_level" ? "Line Item Level" : "Transaction Level",
|
||||
}))
|
||||
|
||||
const DEFAULT_VALUES: SalesConfigFormValues = {
|
||||
sell_rates_tax_inclusive: "",
|
||||
give_discounts: "",
|
||||
}
|
||||
|
||||
export function SalesConfigForm() {
|
||||
const api = useAuthApi()
|
||||
|
||||
const form = useForm<SalesConfigFormValues>({
|
||||
resolver: zodResolver(salesConfigFormSchema) as any,
|
||||
defaultValues: DEFAULT_VALUES,
|
||||
})
|
||||
|
||||
const { mutate, error, isPending } = useFormMutation(form, {
|
||||
mutationFn: (values: SalesConfigFormValues) => {
|
||||
const promise = api.configurations.updateSalesTaxDiscount(values)
|
||||
toast.promise(promise, {
|
||||
loading: "Saving sales configuration...",
|
||||
success: "Sales configuration saved",
|
||||
error: "Failed to save sales configuration",
|
||||
})
|
||||
return promise
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<Rhform form={form} onSubmit={(values) => mutate(values)}>
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertTriangle className="me-2 h-4 w-4" />
|
||||
<AlertTitle>Failed to save sales configuration</AlertTitle>
|
||||
{error.message}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FieldGroup>
|
||||
<RhfSelectField
|
||||
name="sell_rates_tax_inclusive"
|
||||
label="Sell Rates Tax"
|
||||
placeholder="Select tax type"
|
||||
options={TAX_INCLUSIVE_OPTIONS}
|
||||
/>
|
||||
<RhfSelectField
|
||||
name="give_discounts"
|
||||
label="Give Discounts"
|
||||
placeholder="Select discount type"
|
||||
options={DISCOUNT_OPTIONS}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isPending}>
|
||||
<Save />
|
||||
{isPending ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</Rhform>
|
||||
)
|
||||
}
|
||||
112
apps/dashboard/modules/settings/departments/department-form.tsx
Normal file
112
apps/dashboard/modules/settings/departments/department-form.tsx
Normal file
@ -0,0 +1,112 @@
|
||||
"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, RhfSelectField } 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 { DEPARTMENT_ROUTES } from "@garage/api"
|
||||
import { DEPARTMENT_ASSIGNMENT_TYPE_OPTIONS } from "@/modules/services/department-assignment-types"
|
||||
import { departmentFormSchema, type DepartmentFormValues } from "./department.schema"
|
||||
|
||||
export type DepartmentFormProps = {
|
||||
resourceId?: string | null
|
||||
initialData?: unknown
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
const DEFAULT_VALUES: DepartmentFormValues = {
|
||||
name: "",
|
||||
assignment_type: "none",
|
||||
}
|
||||
|
||||
function mapToFormValues(data: unknown): DepartmentFormValues {
|
||||
const d = (data as any)?.data ?? data ?? {}
|
||||
return {
|
||||
name: d.name ?? "",
|
||||
assignment_type: d.assignment_type ?? "none",
|
||||
}
|
||||
}
|
||||
|
||||
function mapFormToPayload(values: DepartmentFormValues) {
|
||||
return {
|
||||
name: values.name,
|
||||
assignment_type: values.assignment_type || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function DepartmentForm({ resourceId, initialData, onSuccess }: DepartmentFormProps) {
|
||||
const api = useAuthApi()
|
||||
|
||||
const { form, isEditing } = useResourceForm<DepartmentFormValues, any>({
|
||||
schema: departmentFormSchema,
|
||||
defaultValues: DEFAULT_VALUES,
|
||||
resourceId,
|
||||
initialData,
|
||||
queryKey: [DEPARTMENT_ROUTES.BY_ID, resourceId],
|
||||
mapToFormValues,
|
||||
})
|
||||
|
||||
const { mutate, error, isPending } = useFormMutation(form, {
|
||||
mutationFn: (values: DepartmentFormValues) => {
|
||||
const payload = mapFormToPayload(values)
|
||||
const promise = isEditing && resourceId
|
||||
? api.departments.update(resourceId, payload)
|
||||
: api.departments.create(payload)
|
||||
|
||||
toast.promise(promise, {
|
||||
loading: isEditing ? "Updating department..." : "Creating department...",
|
||||
success: isEditing ? "Department updated successfully" : "Department created successfully",
|
||||
error: isEditing ? "Failed to update department" : "Failed to create department",
|
||||
})
|
||||
|
||||
return promise
|
||||
},
|
||||
onSuccess: () => {
|
||||
form.reset()
|
||||
onSuccess?.()
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<Rhform form={form} onSubmit={(values) => mutate(values)}>
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertTriangle className="me-2 h-4 w-4" />
|
||||
<AlertTitle>
|
||||
{isEditing ? "Failed to update department" : "Failed to create department"}
|
||||
</AlertTitle>
|
||||
{error.message}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FieldGroup>
|
||||
<RhfTextField
|
||||
name="name"
|
||||
label="Name"
|
||||
placeholder="e.g. Mechanical"
|
||||
required
|
||||
/>
|
||||
<RhfSelectField
|
||||
name="assignment_type"
|
||||
label="Assignment Type"
|
||||
placeholder="Select assignment type"
|
||||
options={[...DEPARTMENT_ASSIGNMENT_TYPE_OPTIONS]}
|
||||
/>
|
||||
|
||||
<Button type="submit" variant="default" disabled={isPending}>
|
||||
{isEditing ? <Save /> : <Plus />}
|
||||
{isPending
|
||||
? (isEditing ? "Updating..." : "Creating...")
|
||||
: (isEditing ? "Update Department" : "Create Department")}
|
||||
</Button>
|
||||
</FieldGroup>
|
||||
</Rhform>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const departmentFormSchema = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
assignment_type: z.string().optional(),
|
||||
})
|
||||
|
||||
export type DepartmentFormValues = z.infer<typeof departmentFormSchema>
|
||||
@ -0,0 +1,102 @@
|
||||
"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 } 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 { INSURANCE_TYPE_ROUTES } from "@garage/api"
|
||||
import { insuranceTypeFormSchema, type InsuranceTypeFormValues } from "./insurance-type.schema"
|
||||
|
||||
export type InsuranceTypeFormProps = {
|
||||
resourceId?: string | null
|
||||
initialData?: unknown
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
const DEFAULT_VALUES: InsuranceTypeFormValues = {
|
||||
title: "",
|
||||
}
|
||||
|
||||
function mapToFormValues(data: unknown): InsuranceTypeFormValues {
|
||||
const d = (data as any)?.data ?? data ?? {}
|
||||
return {
|
||||
title: d.title ?? d.name ?? "",
|
||||
}
|
||||
}
|
||||
|
||||
function mapFormToPayload(values: InsuranceTypeFormValues) {
|
||||
return {
|
||||
title: values.title,
|
||||
}
|
||||
}
|
||||
|
||||
export function InsuranceTypeForm({ resourceId, initialData, onSuccess }: InsuranceTypeFormProps) {
|
||||
const api = useAuthApi()
|
||||
|
||||
const { form, isEditing } = useResourceForm<InsuranceTypeFormValues, any>({
|
||||
schema: insuranceTypeFormSchema,
|
||||
defaultValues: DEFAULT_VALUES,
|
||||
resourceId,
|
||||
initialData,
|
||||
queryKey: [INSURANCE_TYPE_ROUTES.BY_ID, resourceId],
|
||||
mapToFormValues,
|
||||
})
|
||||
|
||||
const { mutate, error, isPending } = useFormMutation(form, {
|
||||
mutationFn: (values: InsuranceTypeFormValues) => {
|
||||
const payload = mapFormToPayload(values)
|
||||
const promise = isEditing && resourceId
|
||||
? api.insuranceTypes.update(resourceId, payload)
|
||||
: api.insuranceTypes.create(payload)
|
||||
|
||||
toast.promise(promise, {
|
||||
loading: isEditing ? "Updating insurance type..." : "Creating insurance type...",
|
||||
success: isEditing ? "Insurance type updated successfully" : "Insurance type created successfully",
|
||||
error: isEditing ? "Failed to update insurance type" : "Failed to create insurance type",
|
||||
})
|
||||
|
||||
return promise
|
||||
},
|
||||
onSuccess: () => {
|
||||
form.reset()
|
||||
onSuccess?.()
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<Rhform form={form} onSubmit={(values) => mutate(values)}>
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertTriangle className="me-2 h-4 w-4" />
|
||||
<AlertTitle>
|
||||
{isEditing ? "Failed to update insurance type" : "Failed to create insurance type"}
|
||||
</AlertTitle>
|
||||
{error.message}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FieldGroup>
|
||||
<RhfTextField
|
||||
name="title"
|
||||
label="Title"
|
||||
placeholder="e.g. Comprehensive"
|
||||
required
|
||||
/>
|
||||
|
||||
<Button type="submit" variant="default" disabled={isPending}>
|
||||
{isEditing ? <Save /> : <Plus />}
|
||||
{isPending
|
||||
? (isEditing ? "Updating..." : "Creating...")
|
||||
: (isEditing ? "Update Insurance Type" : "Create Insurance Type")}
|
||||
</Button>
|
||||
</FieldGroup>
|
||||
</Rhform>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const insuranceTypeFormSchema = z.object({
|
||||
title: z.string().min(1, "Title is required"),
|
||||
})
|
||||
|
||||
export type InsuranceTypeFormValues = z.infer<typeof insuranceTypeFormSchema>
|
||||
209
apps/dashboard/modules/vehicles/rhf-vehicle-select-field.tsx
Normal file
209
apps/dashboard/modules/vehicles/rhf-vehicle-select-field.tsx
Normal file
@ -0,0 +1,209 @@
|
||||
"use client"
|
||||
|
||||
import { useRef, useState } from "react"
|
||||
import { useFormContext, useController, type FieldValues, type FieldPath } from "react-hook-form"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { Car, Loader2 } from "lucide-react"
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { VEHICLE_ROUTES } from "@garage/api"
|
||||
import { FieldShell } from "@/shared/components/form/field-shell"
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxInput,
|
||||
ComboboxContent,
|
||||
ComboboxList,
|
||||
ComboboxItem,
|
||||
ComboboxEmpty,
|
||||
} from "@/shared/components/ui/combobox"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import Image from "next/image"
|
||||
|
||||
// ── Vehicle option type (enriched for display) ──
|
||||
|
||||
type VehicleOption = {
|
||||
value: string
|
||||
label: string
|
||||
make?: string
|
||||
model?: string
|
||||
year?: string
|
||||
sub_model?: string
|
||||
license_plate?: string
|
||||
image_url?: string
|
||||
mileage?: string
|
||||
}
|
||||
|
||||
function buildVehicleOption(item: any): VehicleOption {
|
||||
const label =
|
||||
[item.year, item.make, item.model].filter(Boolean).join(" ") ||
|
||||
`Vehicle #${item.id}`
|
||||
return {
|
||||
value: String(item.id),
|
||||
label,
|
||||
make: item.make,
|
||||
model: item.model,
|
||||
year: item.year,
|
||||
sub_model: item.sub_model,
|
||||
license_plate: item.license_plate,
|
||||
image_url: item.image_url,
|
||||
mileage: item.mileage,
|
||||
}
|
||||
}
|
||||
|
||||
function extractItems(response: unknown): any[] {
|
||||
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 []
|
||||
}
|
||||
|
||||
// ── Props ──
|
||||
|
||||
export type RhfVehicleSelectFieldProps<
|
||||
TValues extends FieldValues,
|
||||
TName extends FieldPath<TValues>,
|
||||
> = {
|
||||
name: TName
|
||||
label?: string
|
||||
description?: string
|
||||
required?: boolean
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
// ── Component ──
|
||||
|
||||
export function RhfVehicleSelectField<
|
||||
TValues extends FieldValues,
|
||||
TName extends FieldPath<TValues>,
|
||||
>({
|
||||
name,
|
||||
label = "Vehicle",
|
||||
description,
|
||||
required,
|
||||
disabled,
|
||||
placeholder = "Search by make, model, year, or plate...",
|
||||
}: RhfVehicleSelectFieldProps<TValues, TName>) {
|
||||
const api = useAuthApi()
|
||||
const anchorRef = useRef<HTMLDivElement>(null)
|
||||
const [inputValue, setInputValue] = useState("")
|
||||
|
||||
const { control } = useFormContext<TValues>()
|
||||
const {
|
||||
field,
|
||||
fieldState: { error },
|
||||
} = useController({ name, control, disabled })
|
||||
|
||||
const { data: options = [], isLoading } = useQuery<VehicleOption[]>({
|
||||
queryKey: [VEHICLE_ROUTES.INDEX, "vehicle-select"],
|
||||
queryFn: async () => {
|
||||
const res = await api.vehicles.list()
|
||||
return extractItems(res).map(buildVehicleOption)
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
const filtered = inputValue
|
||||
? options.filter((v) =>
|
||||
[v.year, v.make, v.model, v.sub_model, v.license_plate]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(inputValue.toLowerCase()),
|
||||
)
|
||||
: options
|
||||
|
||||
return (
|
||||
<FieldShell
|
||||
label={label}
|
||||
error={error?.message}
|
||||
description={description}
|
||||
required={required}
|
||||
>
|
||||
<div ref={anchorRef}>
|
||||
<Combobox
|
||||
value={field.value}
|
||||
onValueChange={(val: VehicleOption | VehicleOption[] | null) => {
|
||||
const single = Array.isArray(val) ? val[0] ?? null : val
|
||||
// Store only { value, label } to stay compatible with relationFieldSchema
|
||||
field.onChange(
|
||||
single ? { value: single.value, label: single.label } : null,
|
||||
)
|
||||
}}
|
||||
disabled={field.disabled}
|
||||
onInputValueChange={(val: string, { reason }: { reason: string }) => {
|
||||
if (reason === "input-change") setInputValue(val)
|
||||
}}
|
||||
// Compare by id string so the selected item highlights correctly
|
||||
// even when the stored form value has fewer fields than VehicleOption
|
||||
isItemEqualToValue={(item: VehicleOption, val: any) =>
|
||||
item?.value === val?.value
|
||||
}
|
||||
>
|
||||
<ComboboxInput
|
||||
placeholder={placeholder}
|
||||
showClear={!!field.value}
|
||||
onBlur={field.onBlur}
|
||||
aria-invalid={!!error || undefined}
|
||||
/>
|
||||
<ComboboxContent anchor={anchorRef}>
|
||||
<ComboboxList>
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading &&
|
||||
filtered.map((opt) => (
|
||||
<ComboboxItem key={opt.value} value={opt}>
|
||||
<div className="flex items-center gap-3 py-0.5 w-full min-w-0">
|
||||
{/* Thumbnail */}
|
||||
{opt.image_url ? (
|
||||
<Image
|
||||
height={60}
|
||||
width={60}
|
||||
src={opt.image_url}
|
||||
alt=""
|
||||
className="size-9 shrink-0 rounded-md object-cover border border-border"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground border border-border">
|
||||
<Car className="size-4" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Identity */}
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className="truncate text-sm font-medium leading-none">
|
||||
{[opt.year, opt.make, opt.model]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
{opt.sub_model && (
|
||||
<span className="ml-1 font-normal text-muted-foreground">
|
||||
{opt.sub_model}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{opt.license_plate && (
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{opt.license_plate}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</ComboboxItem>
|
||||
))}
|
||||
|
||||
{!isLoading && filtered.length === 0 && (
|
||||
<ComboboxEmpty>No vehicles found</ComboboxEmpty>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</div>
|
||||
</FieldShell>
|
||||
)
|
||||
}
|
||||
5
apps/dashboard/modules/vehicles/utils/getVehicleLabel.ts
Normal file
5
apps/dashboard/modules/vehicles/utils/getVehicleLabel.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { VehicleListResponse } from "@garage/api"
|
||||
|
||||
export const getVehicleLabel = (vehicle: VehicleListResponse) => {
|
||||
return `${vehicle.make} ${vehicle.model} ${vehicle.sub_model} (${vehicle.year} - ${vehicle.license_plate})`
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user