diff --git a/.github/skills/crud-dialog/SKILL.md b/.github/skills/crud-dialog/SKILL.md new file mode 100644 index 0000000..8d02bdf --- /dev/null +++ b/.github/skills/crud-dialog/SKILL.md @@ -0,0 +1,304 @@ +--- +name: crud-dialog +description: "Create CRUD dialogs for managing lookup/reference resources inline (inside a dialog) rather than a full page. Use when: adding a config/manage button next to a select field, building an inline CRUD for a simple lookup entity (e.g. insurance types, payment terms, categories), embedding list+create+edit+delete inside a modal. Uses the shared CrudDialog component and useCrudDialog hook." +--- + +# CRUD Dialog Generator + +Create fully functional CRUD dialogs that embed list + create + edit + delete inside a modal dialog. This is the in-dialog counterpart of the page-level `ResourcePage` pattern. Ideal for managing simple lookup/reference entities without navigating away from the current form. + +## When to Use + +- User wants a config/settings button next to a select field to manage its options +- User wants to manage a simple lookup entity (e.g. insurance types, categories, tags) inline +- The resource is simple enough that a full page is overkill +- User says "CRUD in a dialog", "manage inside a modal", "config button", "inline CRUD" + +## When NOT to Use + +- The resource is complex with many fields, relations, or tabs → use **crud-page** skill instead +- The resource already has a dedicated page → link to it instead +- Only creation is needed (no listing/editing) → use `RhfAsyncSelectField` with `createForm` prop instead + +## Architecture + +The CRUD Dialog system has two layers: + +| Layer | File | Purpose | +|-------|------|---------| +| **Hook** | `shared/components/crud-dialog/use-crud-dialog.ts` | Local state for pagination, sorting, form open/close, delete confirmation. No URL pollution. | +| **Component** | `shared/components/crud-dialog/crud-dialog.tsx` | Renders trigger button → Dialog with DataTable (list view) ↔ Form (create/edit view). Uses `useCrudDialog` internally. | + +### Key Differences from ResourcePage + +| Aspect | ResourcePage | CrudDialog | +|--------|-------------|------------| +| Renders in | Full page | Dialog modal | +| Pagination state | URL query params (`nuqs`) | Local `useState` (no URL pollution) | +| Form rendering | `FormDialog` component | Inline view swap (list ↔ form) | +| Trigger | Page navigation | Button click (settings icon by default) | +| Use case | Primary resource management | Lookup/reference entity management | + +## Procedure + +### Step 1: Ensure API Client Exists + +The resource needs a client with `list`, `create`, `update`, `destroy` methods. Check `packages/api/src/clients/`. If missing, create one following the **crud-page** skill's Step 2. + +### Step 2: Create the Resource Form + +Create a simple form component for the resource. This is a lightweight form (no `useResourceForm` needed for simple entities). + +**File**: `apps/dashboard/modules//-form.tsx` + +**Template**: + +```tsx +"use client" + +import { z } from "zod" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { Plus, Save } from "lucide-react" +import { Button } from "@/shared/components/ui/button" +import { FieldGroup } from "@/shared/components/ui/field" +import { Rhform, RhfTextField } from "@/shared/components/form" +import { toast } from "sonner" +import { useAuthApi } from "@/shared/useApi" +import { useEffect } from "react" + +const schema = z.object({ + name: z.string().min(1, "Name is required"), + // Add more fields as needed +}) + +type FormValues = z.infer + +type Props = { + resourceId?: string | null + initialData?: any + onSuccess?: () => void +} + +export function Form({ resourceId, initialData, onSuccess }: Props) { + const api = useAuthApi() + const isEditing = !!resourceId + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { name: "" }, + }) + + // Pre-fill when editing + useEffect(() => { + if (initialData) { + const d = initialData?.data ?? initialData + form.reset({ name: d.name ?? "" }) + } + }, [initialData, form]) + + const handleSubmit = async (values: FormValues) => { + try { + const promise = isEditing + ? api..update(resourceId!, { title: values.name } as any) + : api..create({ title: values.name } as any) + + toast.promise(promise, { + loading: isEditing ? "Updating..." : "Creating...", + success: isEditing ? "Updated successfully" : "Created successfully", + error: isEditing ? "Failed to update" : "Failed to create", + }) + + await promise + form.reset() + onSuccess?.() + } catch { + // toast already shown + } + } + + return ( + + + + + + + ) +} +``` + +### Step 3: Create the CrudDialog Instance + +Wire the form into a `CrudDialog` component. + +**File**: `apps/dashboard/modules//-crud-dialog.tsx` + +**Template**: + +```tsx +"use client" + +import { CrudDialog } from "@/shared/components/crud-dialog" +import { ColumnHeader } from "@/shared/data-view/table-view" +import { useAuthApi } from "@/shared/useApi" +import { _ROUTES } from "@garage/api" +import { Form } from "./-form" + +export function CrudDialog() { + const api = useAuthApi() + + return ( + _ROUTES.INDEX]} + getClient={() => api.} + resourceLabel="" + columns={() => [ + { + accessorKey: "name", + header: ({ column }) => , + }, + // Add more columns as needed + ]} + renderForm={({ resourceId, initialData, onSuccess }) => ( + <Form + resourceId={resourceId} + initialData={initialData} + onSuccess={onSuccess} + /> + )} + /> + ) +} +``` + +### Step 4: Wire into the Parent Form + +Place the CrudDialog trigger next to the corresponding select field. The pattern is to add a custom label row with the config button: + +```tsx +import { CrudDialog } from "./-crud-dialog" + +// Inside the form JSX: +
+
+ + <CrudDialog /> +
+ _ROUTES.INDEX]} + listFn={() => api..list()} + mapOption={mapLookupOption} + {...STORE_OBJECT} + /> +
+``` + +**Key**: Set `label=""` on the `RhfAsyncSelectField` since the label is rendered manually above with the config button. + +## CrudDialog Props Reference + +```typescript +type CrudDialogProps = { + /** Dialog title shown in the header */ + title: string + /** React Query cache key */ + queryKey: string[] + /** Function returning the API client instance */ + getClient: () => TClient + /** Human-readable name for toast messages (e.g. "insurance type") */ + resourceLabel?: string + /** Table columns definition */ + columns: (helpers: { + openEdit: (row: any) => void + handleDelete: (row: any) => Promise + }) => ColumnDef[] + /** Render create/edit form */ + renderForm: (props: { + resourceId: string | null + initialData: any + onSuccess: () => void + }) => React.ReactNode + /** Optional custom trigger element (defaults to Settings2 icon) */ + trigger?: React.ReactNode + /** CSS class for the default trigger button */ + triggerClassName?: string +} +``` + +## useCrudDialog Hook API + +For advanced use cases where you need more control, use the hook directly: + +```typescript +const crud = useCrudDialog({ + queryKey: [ROUTES.INDEX], + getClient: () => api.resource, + resourceLabel: "item", +}) + +// Returns: +crud.items // Current page data +crud.isLoading // Query loading state +crud.pagination // { page, pageSize, pageCount, total } +crud.sorting // SortingState +crud.handleChange // DataViewChangeEvent handler +crud.isFormOpen // Whether form view is active +crud.editingId // ID being edited (null for create) +crud.editingItem // Full item data being edited +crud.openCreate() // Switch to create form +crud.openEdit(row) // Switch to edit form +crud.closeForm() // Back to list view +crud.handleDelete(row) // Confirm + delete +crud.handleFormSuccess() // Invalidate + close form +crud.invalidateQuery() // Refresh list data +``` + +## Real Example: Insurance Type + +See the implementation in `apps/dashboard/modules/job-cards/`: + +- [insurance-type-form.tsx](../../apps/dashboard/modules/job-cards/insurance-type-form.tsx) — Simple form with one "name" field +- [insurance-type-crud-dialog.tsx](../../apps/dashboard/modules/job-cards/insurance-type-crud-dialog.tsx) — CrudDialog wiring +- [job-card-form.tsx](../../apps/dashboard/modules/job-cards/job-card-form.tsx) — Usage next to the insurance type select field + +## Naming Conventions + +| Item | Pattern | Example | +|------|---------|---------| +| Form file | `modules//-form.tsx` | `job-cards/insurance-type-form.tsx` | +| CrudDialog file | `modules//-crud-dialog.tsx` | `job-cards/insurance-type-crud-dialog.tsx` | +| Form component | `Form` | `InsuranceTypeForm` | +| CrudDialog component | `CrudDialog` | `InsuranceTypeCrudDialog` | + +## Imports Cheat Sheet + +```tsx +// CrudDialog component +import { CrudDialog } from "@/shared/components/crud-dialog" + +// Table column header +import { ColumnHeader } from "@/shared/data-view/table-view" + +// API +import { useAuthApi } from "@/shared/useApi" +import { _ROUTES } from "@garage/api" + +// Form components (for the resource form) +import { Rhform, RhfTextField } from "@/shared/components/form" +import { Button } from "@/shared/components/ui/button" +import { FieldGroup } from "@/shared/components/ui/field" +import { z } from "zod" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { toast } from "sonner" +``` diff --git a/.github/skills/shared-formatters/SKILL.md b/.github/skills/shared-formatters/SKILL.md new file mode 100644 index 0000000..ee54798 --- /dev/null +++ b/.github/skills/shared-formatters/SKILL.md @@ -0,0 +1,73 @@ +--- +name: shared-formatters +description: "Use the central shared/utils/formatters.ts file for all display formatting in the dashboard. Use when: formatting dates, times, numbers, currencies, or enum strings in table cells, detail pages, or any UI display. Avoid inline formatting logic like `toLocaleDateString()`, `.toLocaleString()`, or manual string splits. Import from @/shared/utils/formatters." +--- + +# Shared Formatters + +Use this skill whenever work involves displaying dates, times, numbers, currencies, or enum strings in any UI component. + +## Source of Truth + +All shared display formatters live in: + +``` +apps/dashboard/shared/utils/formatters.ts +``` + +Import path: `@/shared/utils/formatters` + +## Available Formatters + +| Function | Input | Output example | +|---|---|---| +| `formatDate(value)` | string \| Date \| null | `"Jan 6, 2026"` | +| `formatDateTime(value)` | string \| Date \| null | `"Jan 6, 2026, 2:30 PM"` | +| `formatDateShort(value)` | string \| Date \| null | `"04/06/2026"` | +| `formatTime(value)` | string \| Date \| null | `"2:30 PM"` | +| `formatEnum(value)` | string \| null | `"In Progress"` | +| `formatNumber(value)` | number \| string \| null | `"150,000"` | +| `formatCurrency(value, currency?, locale?)` | number \| string \| null | `"$1,500.00"` | + +All functions return `"—"` for null/undefined/invalid input — never return an empty string or throw. + +## Rules + +1. **Never inline formatting.** Do not use `new Date(x).toLocaleDateString()`, `Number(x).toLocaleString()`, or manual `split("_")` chains in components or pages. Use the shared formatters instead. + +2. **Add before duplicating.** Check `formatters.ts` for an existing formatter before writing a new one. If a new formatter is needed, add it to `formatters.ts` — not inline. + +3. **Keep all formatters in one file.** Do not create separate formatter files per module. All display formatting stays in `shared/utils/formatters.ts`. + +4. **Consistent null handling.** Every formatter accepts `null | undefined` and returns `"—"`. Never require callers to guard against null before calling. + +5. **Use `formatEnum` for status/type fields.** Any snake_case or underscore-separated enum value displayed as text must go through `formatEnum`. + +## Workflow + +1. Identify a display value needing formatting in a component. +2. Check `formatters.ts` for an existing matching formatter. +3. If found, import and use it. +4. If missing, add to `formatters.ts` following the null-safety pattern, then import. +5. Remove any inline formatting that the shared formatter now replaces. + +## Examples + +```tsx +import { formatDate, formatEnum, formatNumber, formatCurrency } from "@/shared/utils/formatters" + +// Table cell — date +cell: ({ row }) => formatDate(row.original.created_at) + +// Table cell — enum status +cell: ({ row }) => {formatEnum(row.original.status)} + +// Table cell — number +cell: ({ row }) => formatNumber(row.original.km_in) + +// Table cell — currency +cell: ({ row }) => formatCurrency(row.original.total_amount) + +// Detail page field +

{formatDateTime(jobCard.updated_at)}

+``` diff --git a/apps/dashboard/app/(authenticated)/layout.tsx b/apps/dashboard/app/(authenticated)/layout.tsx index 3d92a2a..a2aa041 100644 --- a/apps/dashboard/app/(authenticated)/layout.tsx +++ b/apps/dashboard/app/(authenticated)/layout.tsx @@ -12,7 +12,7 @@ import { redirect } from "next/navigation" function Logo() { return (
- Logo + Logo
) } diff --git a/apps/dashboard/app/(authenticated)/page.tsx b/apps/dashboard/app/(authenticated)/page.tsx index 25bb63b..d6b26d1 100644 --- a/apps/dashboard/app/(authenticated)/page.tsx +++ b/apps/dashboard/app/(authenticated)/page.tsx @@ -4,7 +4,7 @@ import { DashboardContent } from "@/modules/home/dashboard-content"; export default function page() { return ( - } title="Dashboard"> + ) diff --git a/apps/dashboard/app/(authenticated)/productivity/tasks/page.tsx b/apps/dashboard/app/(authenticated)/productivity/tasks/page.tsx new file mode 100644 index 0000000..829d4a1 --- /dev/null +++ b/apps/dashboard/app/(authenticated)/productivity/tasks/page.tsx @@ -0,0 +1,57 @@ +"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 { TaskForm } from "@/modules/tasks/task-form" +import { TASK_ROUTES } from "@garage/api" +import type { TasksClient } from "@garage/api" + +export default function TasksPage() { + return ( + + routeKey={TASK_ROUTES.INDEX} + getClient={(api) => api.tasks} + headerProps={({ selectedItem, invalidateQuery }) => ({ + title: "Tasks", + actions: ( + + {(resourceId) => ( + + )} + + ), + })} + columns={({ actionsColumn }) => [ + { + accessorKey: "task_number", + header: ({ column }) => , + }, + { + accessorKey: "subject", + header: ({ column }) => , + }, + { + accessorKey: "priority", + header: ({ column }) => , + + }, + { + accessorKey: "status", + header: ({ column }) => , + + }, + { + accessorKey: "due_date", + header: ({ column }) => , + + }, + actionsColumn(), + ]} + /> + ) +} diff --git a/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/appointments/page.tsx b/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/appointments/page.tsx index 83475d6..b08eb1d 100644 --- a/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/appointments/page.tsx +++ b/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/appointments/page.tsx @@ -35,22 +35,23 @@ export default function JobCardAppointmentsPage({ return ( - 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 }) => ( - - {(resourceId) => ( - { closeDialog(); invalidateQuery() }} - /> - )} - + tableHeader={({ invalidateQuery, selectedItem, closeDialog }) => ( +
+ + {(resourceId) => ( + { closeDialog(); invalidateQuery() }} + /> + )} + +
)} columns={({ actionsColumn }) => [ { diff --git a/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/attachments/page.tsx b/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/attachments/page.tsx index f280205..633dd03 100644 --- a/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/attachments/page.tsx +++ b/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/attachments/page.tsx @@ -85,27 +85,24 @@ export default function JobCardAttachmentsPage() { return ( - - - } - title="Attachments" > - +
+ + +
{attachments?.length === 0 ? ( @@ -114,6 +111,7 @@ export default function JobCardAttachmentsPage() { ) : (
+ {attachments?.map((attachment) => { const Icon = getFileIcon(attachment.attachment_path) return ( diff --git a/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/bills/page.tsx b/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/bills/page.tsx index 1e141b3..8fc5868 100644 --- a/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/bills/page.tsx +++ b/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/bills/page.tsx @@ -24,21 +24,23 @@ export default function JobCardBillsPage({ return ( - pageTitle="Bills" routeKey={BILL_ROUTES.INDEX} getClient={(api) => api.bills} extraParams={{ job_card_id: jobCardId }} header={null} - toolbar={({ invalidateQuery, selectedItem, closeDialog }) => ( - - {(resourceId) => ( - { closeDialog(); invalidateQuery() }} - /> - )} - + tableHeader={({ invalidateQuery, selectedItem, closeDialog }) => ( +
+ + + {(resourceId) => ( + { closeDialog(); invalidateQuery() }} + /> + )} + +
)} columns={({ actionsColumn }) => [ { diff --git a/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/customer-remarks/page.tsx b/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/customer-remarks/page.tsx deleted file mode 100644 index e71ca34..0000000 --- a/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/customer-remarks/page.tsx +++ /dev/null @@ -1,153 +0,0 @@ -"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 } from "lucide-react" -import { toast } from "sonner" - -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 { Card, CardContent } from "@/shared/components/ui/card" -import { - Dialog, - DialogContent, - DialogHeader, - 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 - job_card_id?: number - remark?: string - created_at: string - updated_at: string -} - -export default function CustomerRemarksPage() { - const { id: jobCardId } = useParams<{ id: string }>() - const api = useAuthApi() - const queryClient = useQueryClient() - const [dialogOpen, setDialogOpen] = useState(false) - - const queryKey = ["job-card-remarks", jobCardId] - - const { data, isLoading } = useQuery({ - queryKey, - queryFn: async () => { - // const result = await api.jobCards. - }, - }) - - const deleteMutation = useMutation({ - mutationFn: () => api.jobCards.deleteCustomerRemark(jobCardId), - onSuccess: () => { - toast.success("Customer remark deleted successfully.") - queryClient.invalidateQueries({ queryKey }) - }, - onError: () => { - toast.error("Failed to delete customer remark.") - }, - }) - - const handleDelete = async (remark: CustomerRemark) => { - const confirmed = await confirm({ - title: "Delete Customer Remark", - description: "Are you sure you want to delete this remark?", - confirmLabel: "Delete", - variant: "destructive", - }) - if (confirmed) { - deleteMutation.mutate() - } - } - - const columns: ColumnDef[] = [ - { - accessorKey: "remark", - header: ({ column }) => , - cell: ({ getValue }) => { - const val = getValue() - return val || "—" - }, - }, - { - accessorKey: "created_at", - header: ({ column }) => , - cell: ({ getValue }) => { - const val = getValue() - return val ? new Date(val).toLocaleDateString() : "—" - }, - }, - { - id: "actions", - header: () => Actions, - cell: ({ row }) => ( - - ), - enableSorting: false, - }, - ] - - const remarks = Array.isArray(data) ? data : [] - - const pagination = { - page: 1, - pageSize: 100, - pageCount: 1, - total: remarks.length, - } - - return ( - - -
}> - -
- - - - - - - - - - - - Add Customer Remark - - { - setDialogOpen(false) - queryClient.invalidateQueries({ queryKey }) - }} - /> - - -
-
- - ) -} diff --git a/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/expenses/page.tsx b/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/expenses/page.tsx index bf5f947..db5647f 100644 --- a/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/expenses/page.tsx +++ b/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/expenses/page.tsx @@ -24,12 +24,13 @@ export default function JobCardExpensesPage({ return ( - pageTitle="Expenses" - routeKey={EXPENSE_ROUTES.INDEX} + routeKey={EXPENSE_ROUTES.INDEX} getClient={(api) => api.expenses} extraParams={{ job_card_id: jobCardId }} header={null} - toolbar={({ invalidateQuery, selectedItem, closeDialog }) => ( + tableHeader={({ invalidateQuery, selectedItem, closeDialog }) => ( +
+ {(resourceId) => ( )} +
)} columns={({ actionsColumn }) => [ { diff --git a/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/layout.tsx b/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/layout.tsx index 0e33427..a2b4fc1 100644 --- a/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/layout.tsx +++ b/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/layout.tsx @@ -19,7 +19,7 @@ export default async function JobCardDetailLayout(props: { params: Promise<{ id: const docs = jobCard?.documents return ( - + {props.children} diff --git a/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/page.tsx b/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/page.tsx index 9f4ce3b..9f13a6e 100644 --- a/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/page.tsx +++ b/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/page.tsx @@ -13,7 +13,7 @@ export default async function JobCardDetailPage(props: { params: Promise<{ id: s } return ( - + ) diff --git a/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/parts/page.tsx b/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/parts/page.tsx new file mode 100644 index 0000000..d336255 --- /dev/null +++ b/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/parts/page.tsx @@ -0,0 +1,196 @@ +"use client" + +import { use, useState } from "react" +import { useQuery, useQueryClient } from "@tanstack/react-query" +import { useAuthApi } from "@/shared/useApi" +import { ColumnHeader, DataTable } from "@/shared/data-view/table-view" +import { Button } from "@/shared/components/ui/button" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/shared/components/ui/dialog" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/shared/components/ui/dropdown-menu" +import { confirm } from "@/shared/components/confirm-dialog" +import { toast } from "sonner" +import { Ellipsis, Plus } from "lucide-react" +import type { ColumnDef } from "@tanstack/react-table" +import { JobCardPartForm } from "@/modules/job-cards/job-card-part-form" +import { formatDate } from "@/shared/utils/formatters" + +export default function JobCardPartsPage({ + params, +}: { + params: Promise<{ id: string }> +}) { + const { id: jobCardId } = use(params) + const api = useAuthApi() + const queryClient = useQueryClient() + const queryKey = ["job-card-parts", jobCardId] + + const [dialogOpen, setDialogOpen] = useState(false) + const [editItem, setEditItem] = useState(null) + + const { data, isLoading } = useQuery({ + queryKey, + queryFn: () => api.jobCards.getParts(jobCardId), + }) + + const rows = (data as any)?.data ?? [] + + const invalidate = () => queryClient.invalidateQueries({ queryKey }) + + async function handleDelete(row: any) { + const confirmed = await confirm({ + title: "Delete this part?", + description: `Remove part "${row.part?.title ?? "this part"}" from the job card?`, + }) + if (!confirmed) return + const promise = api.jobCards.deletePart(jobCardId, row.id) + toast.promise(promise, { + loading: "Deleting...", + success: "Part deleted", + error: "Failed to delete part", + }) + await promise + invalidate() + } + + const columns: ColumnDef[] = [ + { + accessorKey: "part.title", + header: ({ column }) => , + cell: ({ row }) => { + const part = row.original.part + return part ? ( +
+ {part.title} + {part.sku && ( + {part.sku} + )} +
+ ) : "—" + }, + }, + { + accessorKey: "quantity", + header: ({ column }) => , + cell: ({ row }) => row.original.quantity ?? "—", + }, + { + accessorKey: "rate", + header: ({ column }) => , + cell: ({ row }) => { + const val = row.original.rate + return val != null ? `$${Number(val).toFixed(2)}` : "—" + }, + }, + { + accessorKey: "tax", + header: ({ column }) => , + cell: ({ row }) => row.original.tax || "—", + }, + { + accessorKey: "department.name", + header: ({ column }) => , + cell: ({ row }) => row.original.department?.name || "—", + }, + { + accessorKey: "description", + header: ({ column }) => , + cell: ({ row }) => row.original.description || "—", + }, + { + accessorKey: "created_at", + header: ({ column }) => , + cell: ({ row }) => formatDate(row.original.created_at), + }, + { + id: "actions", + cell: ({ row }) => ( + + + + + + { + setEditItem(row.original) + setDialogOpen(true) + }} + > + Edit + + handleDelete(row.original)} + > + Delete + + + + ), + }, + ] + + return ( +
+
+ { + setDialogOpen(open) + if (!open) setEditItem(null) + }} + > + + + + + + {editItem ? "Edit Part" : "Add Part"} + + { + setDialogOpen(false) + setEditItem(null) + invalidate() + }} + onCancel={() => { + setDialogOpen(false) + setEditItem(null) + }} + /> + + +
+ + +
+ ) +} diff --git a/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/purchase-orders/page.tsx b/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/purchase-orders/page.tsx index 21cbc22..c335bb2 100644 --- a/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/purchase-orders/page.tsx +++ b/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/purchase-orders/page.tsx @@ -23,12 +23,13 @@ export default function JobCardPurchaseOrdersPage({ return ( - pageTitle="Purchase Orders" routeKey={PURCHASE_ORDER_ROUTES.INDEX} getClient={(api) => api.purchaseOrders} extraParams={{ job_card_id: jobCardId }} header={null} - toolbar={({ invalidateQuery, selectedItem, closeDialog }) => ( + tableHeader={({ invalidateQuery, selectedItem, closeDialog }) => ( +
+ {(resourceId) => ( )} +
+ )} columns={({ actionsColumn }) => [ { diff --git a/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/shop-recommendations/page.tsx b/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/shop-recommendations/page.tsx deleted file mode 100644 index e21e383..0000000 --- a/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/shop-recommendations/page.tsx +++ /dev/null @@ -1,157 +0,0 @@ -"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 } from "lucide-react" -import { toast } from "sonner" - -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 { Card, CardContent } from "@/shared/components/ui/card" -import { - Dialog, - DialogContent, - DialogHeader, - 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 - job_card_id?: number - recommendation?: string - created_at: string - updated_at: string -} - -export default function ShopRecommendationsPage() { - const { id: jobCardId } = useParams<{ id: string }>() - const api = useAuthApi() - const queryClient = useQueryClient() - const [dialogOpen, setDialogOpen] = useState(false) - - const queryKey = ["job-card-recommendations", jobCardId] - - const { data, isLoading } = useQuery({ - queryKey, - queryFn: async () => { - const result = await api.jobCards.show(jobCardId) - const d = (result as any)?.data ?? result - return d?.shop_recommendations ?? [] - }, - }) - - const deleteMutation = useMutation({ - mutationFn: () => api.jobCards.deleteShopRecommendation(jobCardId), - onSuccess: () => { - toast.success("Shop recommendation deleted successfully.") - queryClient.invalidateQueries({ queryKey }) - }, - onError: () => { - toast.error("Failed to delete shop recommendation.") - }, - }) - - const handleDelete = async (rec: ShopRecommendation) => { - const confirmed = await confirm({ - title: "Delete Shop Recommendation", - description: "Are you sure you want to delete this recommendation?", - confirmLabel: "Delete", - variant: "destructive", - }) - if (confirmed) { - deleteMutation.mutate() - } - } - - const columns: ColumnDef[] = [ - { - accessorKey: "recommendation", - header: ({ column }) => , - cell: ({ getValue }) => { - const val = getValue() - return val || "—" - }, - }, - { - accessorKey: "created_at", - header: ({ column }) => , - cell: ({ getValue }) => { - const val = getValue() - return val ? new Date(val).toLocaleDateString() : "—" - }, - }, - { - id: "actions", - header: () => Actions, - cell: ({ row }) => ( - - ), - enableSorting: false, - }, - ] - - const recommendations = Array.isArray(data) ? data : [] - - const pagination = { - page: 1, - pageSize: 100, - pageCount: 1, - total: recommendations.length, - } - - return ( - setDialogOpen(true)}> - - Add Recommendation - - } - > - - - - - {}} - isLoading={isLoading} - /> - - - - - - - Add Shop Recommendation - - { - setDialogOpen(false) - queryClient.invalidateQueries({ queryKey }) - }} - /> - - - - ) -} diff --git a/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/tasks/page.tsx b/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/tasks/page.tsx index 1611622..42d50ff 100644 --- a/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/tasks/page.tsx +++ b/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/tasks/page.tsx @@ -3,9 +3,13 @@ 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 { TaskForm } from "@/modules/tasks/task-form" import { TASK_ROUTES } from "@garage/api" import type { TasksClient } from "@garage/api" import { Badge } from "@/shared/components/ui/badge" +import { useJobCard } from "@/modules/job-cards/job-card-context" +import { formatDate, formatEnum } from "@/shared/utils/formatters" export default function JobCardTasksPage({ params, @@ -13,54 +17,58 @@ export default function JobCardTasksPage({ 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).title || `Job Card` } + : null return ( - pageTitle="Tasks" - routeKey={TASK_ROUTES.TASKS} + routeKey={TASK_ROUTES.INDEX} getClient={(api) => api.tasks} extraParams={{ job_card_id: jobCardId }} header={null} - columns={() => [ + tableHeader={({ invalidateQuery, selectedItem, closeDialog }) => ( +
+ + {(resourceId) => ( + { closeDialog(); invalidateQuery() }} + /> + )} + +
+ )} + columns={({ actionsColumn }) => [ { - accessorKey: "title", - header: ({ column }) => , - cell: ({ row }) => (row.original as any).title || "—", + accessorKey: "task_number", + header: ({ column }) => , + cell: ({ row }) => (row.original as any).task_number || "—", }, { - accessorKey: "task_type_name", - header: ({ column }) => , - cell: ({ row }) => (row.original as any).task_type_name || "—", - }, - { - accessorKey: "section_name", - header: ({ column }) => , - cell: ({ row }) => (row.original as any).section_name || "—", + accessorKey: "subject", + header: ({ column }) => , + cell: ({ row }) => (row.original as any).subject || "—", }, { accessorKey: "due_date", header: ({ column }) => , - cell: ({ row }) => { - const val = (row.original as any).due_date - return val ? new Date(val).toLocaleDateString() : "—" - }, + cell: ({ row }) => formatDate((row.original as any).due_date), }, { accessorKey: "priority", header: ({ column }) => , - cell: ({ row }) => { - const value = (row.original as any).priority - return value ? {value} : "—" - }, + }, { accessorKey: "status", header: ({ column }) => , - cell: ({ row }) => { - const value = (row.original as any).status - return value ? {value} : "—" - }, + }, + actionsColumn(), ]} /> ) diff --git a/apps/dashboard/app/(authenticated)/sales/job-cards/page.tsx b/apps/dashboard/app/(authenticated)/sales/job-cards/page.tsx index 11feac0..f2cd890 100644 --- a/apps/dashboard/app/(authenticated)/sales/job-cards/page.tsx +++ b/apps/dashboard/app/(authenticated)/sales/job-cards/page.tsx @@ -12,6 +12,7 @@ import { Badge } from '@/shared/components/ui/badge' import { Input } from '@/shared/components/ui/input' import { useRouter } from 'next/navigation' import { useState, useEffect } from 'react' +import { formatDate, formatEnum, formatNumber } from '@/shared/utils/formatters' type JobCardItem = { id: number @@ -31,19 +32,11 @@ const statusColorMap: Record = { cancelled: "destructive", } -const formatStatus = (status?: string) => { - if (!status) return "—" - return status - .split("_") - .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) - .join(" ") -} - export default function JobCardsPage() { const router = useRouter() const [searchInput, setSearchInput] = useState("") const [search, setSearch] = useState("") - const [statusFilter, setStatusFilter] = useState("all") + const [statusFilter, setStatusFilter] = useState("check_in") useEffect(() => { const timer = setTimeout(() => setSearch(searchInput), 400) @@ -56,14 +49,15 @@ export default function JobCardsPage() { return ( - 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 }) => ({ + title: "Job Cards", actions: ( - + {(resourceId) => ( , - cell: ({ row }) => { - const item = row.original as unknown as JobCardItem - return ( - - {formatStatus(item.status)} - - ) - }, + accessorKey: "order_number", + header: ({ column }) => , + }, { accessorKey: "check_in_date", header: ({ column }) => , + cell: ({ getValue }) => { + const val = getValue() + return formatDate(val) + } }, { - accessorKey: "km_in", + accessorKey: "vehicle_id", header: ({ column }) => , cell: ({ row }) => { const item = row.original as unknown as JobCardItem - return item.km_in ? Number(item.km_in).toLocaleString() : "—" + return item.km_in ? formatNumber(item.km_in) : "—" }, }, { @@ -117,14 +109,36 @@ export default function JobCardsPage() { header: ({ column }) => , cell: ({ row }) => { const item = row.original as unknown as JobCardItem - return item.created_at ? new Date(item.created_at).toLocaleDateString() : "—" + return formatDate(item.created_at) + }, + }, + { + accessorKey: "status", + header: ({ column }) => , + cell: ({ row }) => { + const item = row.original as unknown as JobCardItem + return ( + + {formatEnum(item.status)} + + ) }, }, actionsColumn(), ]} - toolbar={ -
- + + tableHeader={ +
+ + + All + {JobCardStatus.map((status) => ( + + {formatEnum(status)} + + ))} + +
} - tableHeader={ - - - All - {JobCardStatus.map((status) => ( - - {formatStatus(status)} - - ))} - - - } /> ) } diff --git a/apps/dashboard/app/(authenticated)/sales/payment-received/page.tsx b/apps/dashboard/app/(authenticated)/sales/payment-received/page.tsx index 05c48b0..6a1182d 100644 --- a/apps/dashboard/app/(authenticated)/sales/payment-received/page.tsx +++ b/apps/dashboard/app/(authenticated)/sales/payment-received/page.tsx @@ -4,7 +4,7 @@ import { ResourcePage } from "@/shared/data-view/resource-page" import { ColumnHeader } from "@/shared/data-view/table-view" import FormDialog from "@/shared/components/form-dialog" import { PaymentReceivedForm } from "@/modules/payment-received/payment-received-form" -import { PAYMENT_ROUTES } from "@garage/api" +import { PAYMENT_RECEIVED_ROUTES } from "@garage/api" import { BadgeDollarSignIcon, CalendarIcon, @@ -31,11 +31,10 @@ type PaymentReceivedItem = { export default function PaymentReceivedPage() { return ( ; destroy(id: string): Promise }> - pageTitle="Payments Received" - routeKey={PAYMENT_ROUTES.RECEIVED} + routeKey={PAYMENT_RECEIVED_ROUTES.INDEX} getClient={(api) => ({ - list: (query?: any) => api.payments.listReceived(query), - destroy: (id: string) => api.payments.destroyReceived(id), + list: (query?: any) => api.paymentReceived.list(query), + destroy: (id: string) => api.paymentReceived.destroy(id), })} headerProps={({ invalidateQuery }) => ({ actions: ( diff --git a/apps/dashboard/app/(authenticated)/settings/departments/page.tsx b/apps/dashboard/app/(authenticated)/settings/departments/page.tsx index b801bd3..8372c16 100644 --- a/apps/dashboard/app/(authenticated)/settings/departments/page.tsx +++ b/apps/dashboard/app/(authenticated)/settings/departments/page.tsx @@ -1,12 +1,55 @@ "use client" +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { toast } from "sonner" +import { StarIcon, StarOffIcon } from "lucide-react" + 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 { Button } from "@/shared/components/ui/button" +import { useAuthApi } from "@/shared/useApi" import { DEPARTMENT_ROUTES } from "@garage/api" import type { DepartmentsClient } from "@garage/api" +function FavoriteCell({ row }: { row: any }) { + const api = useAuthApi() + const queryClient = useQueryClient() + const isFavorite: boolean = row.is_favorite ?? false + + const { mutate, isPending } = useMutation({ + mutationFn: () => { + const promise = isFavorite + ? api.departments.removeFavorite({ id: row.id }) + : api.departments.setFavorite({ id: row.id }) + toast.promise(promise, { + loading: isFavorite ? "Removing from favourites..." : "Setting as favourite...", + success: isFavorite ? "Removed from favourites" : "Set as favourite", + error: isFavorite ? "Failed to remove favourite" : "Failed to set favourite", + }) + return promise + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [DEPARTMENT_ROUTES.INDEX] }) + }, + }) + + return ( + + ) +} + export default function DepartmentsPage() { return ( @@ -36,6 +79,12 @@ export default function DepartmentsPage() { header: ({ column }) => , cell: ({ row }) => (row.original as any).assignment_type ?? "none", }, + { + id: "favourite", + header: () => Favourite, + enableSorting: false, + cell: ({ row }) => , + }, actionsColumn(), ]} /> diff --git a/apps/dashboard/base/components/layout/dashboard/app-sidebar.tsx b/apps/dashboard/base/components/layout/dashboard/app-sidebar.tsx index 7e89e1e..d9f9e6c 100644 --- a/apps/dashboard/base/components/layout/dashboard/app-sidebar.tsx +++ b/apps/dashboard/base/components/layout/dashboard/app-sidebar.tsx @@ -1,11 +1,15 @@ "use client" +import { useCallback } from "react" import Link from "next/link" -import { usePathname } from "next/navigation" -import { ChevronRight, Circle } from "lucide-react" +import { usePathname, useRouter } from "next/navigation" +import { ChevronRight, Circle, LogOutIcon, UserIcon } from "lucide-react" import type { NavGroup, NavItem } from "@/base/types/navigation" +import type { UserInfo } from "@/base/types/navigation" +import { useAuthStore } from "@/shared/stores/auth-store" import { cn } from "@/shared/lib/utils" +import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar" import { Collapsible, CollapsibleContent, @@ -22,6 +26,7 @@ import { import { Sidebar, SidebarContent, + SidebarFooter, SidebarGroup, SidebarGroupLabel, SidebarHeader, @@ -38,11 +43,19 @@ import { type AppSidebarProps = React.ComponentProps & { navGroups: NavGroup[] logo?: React.ReactNode + user?: UserInfo } -export function AppSidebar({ navGroups, logo, ...props }: AppSidebarProps) { +export function AppSidebar({ navGroups, logo, user, ...props }: AppSidebarProps) { const { state, isMobile } = useSidebar() const isCollapsed = state === "collapsed" && !isMobile + const { logout } = useAuthStore((s) => s) + const router = useRouter() + + const handleLogout = useCallback(async () => { + await logout() + router.push("/login") + }, [logout, router]) return ( @@ -71,6 +84,58 @@ export function AppSidebar({ navGroups, logo, ...props }: AppSidebarProps) { ))} + + + + + + {user?.avatar && } + + {user?.initials ?? user?.name?.charAt(0).toUpperCase()} + + + {!isCollapsed && ( +
+ {user?.name} + {user?.email && ( + {user.email} + )} +
+ )} +
+
+ + +
+ {user?.name} + {user?.email && ( + {user.email} + )} +
+
+ + + + + Profile + + + + + + Logout + +
+
+
) diff --git a/apps/dashboard/base/components/layout/dashboard/dashboard-header.tsx b/apps/dashboard/base/components/layout/dashboard/dashboard-header.tsx index b93a333..33af6c7 100644 --- a/apps/dashboard/base/components/layout/dashboard/dashboard-header.tsx +++ b/apps/dashboard/base/components/layout/dashboard/dashboard-header.tsx @@ -1,22 +1,15 @@ "use client" import { useCallback, useEffect, useState } from "react" -import Link from "next/link" -import { useRouter } from "next/navigation" import { useTheme } from "next-themes" import { BellIcon, - LogOutIcon, MoonIcon, SearchIcon, SunIcon, - UserIcon, } from "lucide-react" -import type { UserInfo } from "@/base/types/navigation" -import { useAuthStore } from "@/shared/stores/auth-store" import { cn } from "@/shared/lib/utils" -import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar" import { Button } from "@/shared/components/ui/button" import { SidebarTrigger } from "@/shared/components/ui/sidebar" import { @@ -28,33 +21,17 @@ import { CommandGroup, CommandItem, } from "@/shared/components/ui/command" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuGroup, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/shared/components/ui/dropdown-menu" import { Separator } from "@/shared/components/ui/separator" export type DashboardHeaderProps = { - user?: UserInfo actions?: React.ReactNode className?: string + title?:string } -export function DashboardHeader({ actions, className }: DashboardHeaderProps) { +export function DashboardHeader({ actions, className, title }: DashboardHeaderProps) { const { resolvedTheme, setTheme } = useTheme() const [searchOpen, setSearchOpen] = useState(false) - const { logout, user } = useAuthStore((s) => s) - const router = useRouter() - - const handleLogout = useCallback(async () => { - await logout() - router.push("/login") - }, [logout, router]) useEffect(() => { function onKeyDown(e: KeyboardEvent) { @@ -78,75 +55,16 @@ export function DashboardHeader({ actions, className }: DashboardHeaderProps) { className, )} > + {title &&

{title}

} + {/* Sidebar toggle — mobile: hamburger, desktop: collapse */} - - + + {/* Left side — default actions */}
- {/* User dropdown */} - {/* {user && ( */} - - - - - - - {/* User info header */} - -
- - {user?.avatar && } - - {user?.initials ?? user?.name.charAt(0).toUpperCase()} - - -
- {user?.name} - {user?.email && ( - {user?.email} - )} - {user?.role && ( - {user?.role} - )} -
-
-
- - - - - - - - Profile - - - - - - - - - Logout - -
-
- {/* )} */} - - {/* Search trigger */} - + */} {/* Mobile search icon */} +
+ + {/* Inline create form */} + {creating && ( +
+
+ setNewDescription(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { e.preventDefault(); handleCreate() } + if (e.key === "Escape") { setCreating(false); setNewDescription("") } + }} + className="h-8 text-sm" + /> + + +
+
+ )} + + {/* List */} +
+ {isLoading ? ( +
+ Loading... +
+ ) : remarks.length === 0 ? ( +
+ No quick remarks found. +
+ ) : ( + remarks.map((remark) => { + const isSelected = selected.includes(remark.description) + const isEditing = editingId === remark.id + + if (isEditing) { + return ( +
+ setEditingText(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { e.preventDefault(); handleUpdate(remark.id) } + if (e.key === "Escape") { setEditingId(null); setEditingText("") } + }} + className="h-8 flex-1 text-sm" + /> + + +
+ ) + } + + return ( +
onToggle(remark.description)} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault() + onToggle(remark.description) + } + }} + > +
+
+ {isSelected && ( + + )} +
+ {remark.description} +
+ + e.stopPropagation()}> + + + + { + e.stopPropagation() + startEdit(remark) + }} + > + + Edit + + { + e.stopPropagation() + deleteMutation.mutate(remark.id) + }} + > + + Delete + + + +
+ ) + }) + )} +
+ + {/* Pagination */} + {meta && meta.total > 0 && ( +
+ + {from}–{to} of {meta.total} + +
+ + + + +
+
+ )} + + + ) +} + +// ── Main Component ── + +export function RhfCustomerRemarksField< + TValues extends FieldValues, + TName extends FieldPath, +>({ + name, + label = "Customer Remark", + description, + required, + disabled, + placeholder = "Enter remark...", +}: RhfCustomerRemarksFieldProps) { + const { control } = useFormContext() + const { field, fieldState: { error } } = useController({ name, control, disabled }) + + const [sheetOpen, setSheetOpen] = useState(false) + + const remarks: string[] = Array.isArray(field.value) ? field.value : [] + + function updateAt(index: number, value: string) { + const next = [...remarks] + next[index] = value + field.onChange(next) + } + + function addLine() { + field.onChange([...remarks, ""]) + } + + function removeLine(index: number) { + field.onChange(remarks.filter((_, i) => i !== index)) + } + + function toggleQuickRemark(description: string) { + const exists = remarks.includes(description) + if (exists) { + field.onChange(remarks.filter((r) => r !== description)) + } else { + field.onChange([...remarks, description]) + } + } + + return ( + + {label && ( + + {label} + {required && ( + * + )} + + )} + + {/* Repeater rows */} +
+ {remarks.map((remark, index) => ( +
+ updateAt(index, e.target.value)} + placeholder={placeholder} + disabled={disabled} + className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground" + /> + +
+ ))} + + {/* Empty state */} + {remarks.length === 0 && ( +
+ No remarks added yet. +
+ )} + + {/* Footer row */} +
+ + + + + + + Quick Remarks + +
+
+ + {error && {error.message}} + {description && !error && ( +

{description}

+ )} + + +
+ ) +} diff --git a/apps/dashboard/modules/estimates/rhf-quick-notes-field.tsx b/apps/dashboard/modules/estimates/rhf-quick-notes-field.tsx new file mode 100644 index 0000000..87ef5e7 --- /dev/null +++ b/apps/dashboard/modules/estimates/rhf-quick-notes-field.tsx @@ -0,0 +1,548 @@ +"use client" + +import { useState } from "react" +import { + useFormContext, + useController, + type FieldValues, + type FieldPath, +} from "react-hook-form" +import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query" +import { + Check, + ChevronFirst, + ChevronLast, + ChevronLeft, + ChevronRight, + Edit2, + MoreHorizontal, + Plus, + RefreshCcw, + Search, + Trash2, + X, +} from "lucide-react" + +import { useAuthApi } from "@/shared/useApi" +import { QUICK_NOTE_ROUTES } from "@garage/api" +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, +} from "@/shared/components/ui/sheet" +import { Button } from "@/shared/components/ui/button" +import { Input } from "@/shared/components/ui/input" +import { + Field, + FieldLabel, + FieldError, +} from "@/shared/components/ui/field" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/shared/components/ui/dropdown-menu" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/shared/components/ui/tooltip" +import { cn } from "@/shared/lib/utils" + +type QuickNote = { + id: number + description: string +} + +type QuickNotesPage = { + data: QuickNote[] + meta: { + current_page: number + last_page: number + per_page: number + total: number + } +} + +type RhfQuickNotesFieldProps< + TValues extends FieldValues, + TName extends FieldPath, +> = { + name: TName + label?: string + description?: string + required?: boolean + disabled?: boolean + placeholder?: string +} + +function extractPage(response: unknown): QuickNotesPage { + const r = response as any + return { + data: Array.isArray(r?.data?.data) + ? r.data.data + : Array.isArray(r?.data) + ? r.data + : [], + meta: r?.data?.meta ?? r?.meta ?? { + current_page: 1, + last_page: 1, + per_page: 15, + total: 0, + }, + } +} + +function QuickNotesSheet({ + open, + onOpenChange, + selected, + onToggle, +}: { + open: boolean + onOpenChange: (v: boolean) => void + selected: string[] + onToggle: (description: string) => void +}) { + const api = useAuthApi() + const queryClient = useQueryClient() + + const [page, setPage] = useState(1) + const [search, setSearch] = useState("") + const [creating, setCreating] = useState(false) + const [newDescription, setNewDescription] = useState("") + const [editingId, setEditingId] = useState(null) + const [editingText, setEditingText] = useState("") + + const queryKey = [QUICK_NOTE_ROUTES.INDEX, { page, search }] + + const { data, isLoading } = useQuery({ + queryKey, + queryFn: async () => { + const res = await api.quickNotes.list({ + page, + ...(search ? { search } : {}), + }) + return extractPage(res) + }, + enabled: open, + staleTime: 30_000, + }) + + const notes = data?.data ?? [] + const meta = data?.meta + + const invalidate = () => + queryClient.invalidateQueries({ queryKey: [QUICK_NOTE_ROUTES.INDEX] }) + + const createMutation = useMutation({ + mutationFn: (description: string) => + api.quickNotes.create({ description }), + onSuccess: () => { + invalidate() + setCreating(false) + setNewDescription("") + }, + }) + + const updateMutation = useMutation({ + mutationFn: ({ id, description }: { id: number; description: string }) => + api.quickNotes.update(String(id), { description }), + onSuccess: () => { + invalidate() + setEditingId(null) + setEditingText("") + }, + }) + + const deleteMutation = useMutation({ + mutationFn: (id: number) => + api.quickNotes.destroy(String(id)), + onSuccess: () => invalidate(), + }) + + function handleCreate() { + const text = newDescription.trim() + if (!text) return + createMutation.mutate(text) + } + + function handleUpdate(id: number) { + const text = editingText.trim() + if (!text) return + updateMutation.mutate({ id, description: text }) + } + + function startEdit(note: QuickNote) { + setEditingId(note.id) + setEditingText(note.description) + } + + const totalPages = meta?.last_page ?? 1 + const from = meta ? (meta.current_page - 1) * meta.per_page + 1 : 0 + const to = meta ? Math.min(meta.current_page * meta.per_page, meta.total) : 0 + + return ( + + + + Quick Notes + + +
+
+ + { + setSearch(e.target.value) + setPage(1) + }} + className="pl-9 h-9" + /> +
+ +
+ + {creating && ( +
+
+ setNewDescription(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { e.preventDefault(); handleCreate() } + if (e.key === "Escape") { setCreating(false); setNewDescription("") } + }} + className="h-8 text-sm" + /> + + +
+
+ )} + +
+ {isLoading ? ( +
+ Loading... +
+ ) : notes.length === 0 ? ( +
+ No quick notes found. +
+ ) : ( + notes.map((note) => { + const isSelected = selected.includes(note.description) + const isEditing = editingId === note.id + + if (isEditing) { + return ( +
+ setEditingText(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { e.preventDefault(); handleUpdate(note.id) } + if (e.key === "Escape") { setEditingId(null); setEditingText("") } + }} + className="h-8 flex-1 text-sm" + /> + + +
+ ) + } + + return ( +
onToggle(note.description)} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault() + onToggle(note.description) + } + }} + > +
+
+ {isSelected && ( + + )} +
+ {note.description} +
+ + e.stopPropagation()}> + + + + { + e.stopPropagation() + startEdit(note) + }} + > + + Edit + + { + e.stopPropagation() + deleteMutation.mutate(note.id) + }} + > + + Delete + + + +
+ ) + }) + )} +
+ + {meta && meta.total > 0 && ( +
+ + {from}-{to} of {meta.total} + +
+ + + + +
+
+ )} +
+
+ ) +} + +export function RhfQuickNotesField< + TValues extends FieldValues, + TName extends FieldPath, +>({ + name, + label = "Quick Notes", + description, + required, + disabled, + placeholder = "Enter note...", +}: RhfQuickNotesFieldProps) { + const { control } = useFormContext() + const { field, fieldState: { error } } = useController({ name, control, disabled }) + + const [sheetOpen, setSheetOpen] = useState(false) + + const notes: string[] = Array.isArray(field.value) ? field.value : [] + + function updateAt(index: number, value: string) { + const next = [...notes] + next[index] = value + field.onChange(next) + } + + function addLine() { + field.onChange([...notes, ""]) + } + + function removeLine(index: number) { + field.onChange(notes.filter((_, i) => i !== index)) + } + + function toggleQuickNote(description: string) { + const exists = notes.includes(description) + if (exists) { + field.onChange(notes.filter((r) => r !== description)) + } else { + field.onChange([...notes, description]) + } + } + + return ( + + {label && ( + + {label} + {required && ( + * + )} + + )} + +
+ {notes.map((note, index) => ( +
+ updateAt(index, e.target.value)} + placeholder={placeholder} + disabled={disabled} + className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground" + /> + +
+ ))} + + {notes.length === 0 && ( +
+ No notes added yet. +
+ )} + +
+ + + + + + + Quick Notes + +
+
+ + {error && {error.message}} + {description && !error && ( +

{description}

+ )} + + +
+ ) +} diff --git a/apps/dashboard/modules/estimates/rhf-shop-recommendations-field.tsx b/apps/dashboard/modules/estimates/rhf-shop-recommendations-field.tsx new file mode 100644 index 0000000..7f76cfe --- /dev/null +++ b/apps/dashboard/modules/estimates/rhf-shop-recommendations-field.tsx @@ -0,0 +1,548 @@ +"use client" + +import { useState } from "react" +import { + useFormContext, + useController, + type FieldValues, + type FieldPath, +} from "react-hook-form" +import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query" +import { + Check, + ChevronFirst, + ChevronLast, + ChevronLeft, + ChevronRight, + Edit2, + MoreHorizontal, + Plus, + RefreshCcw, + Search, + Trash2, + X, +} from "lucide-react" + +import { useAuthApi } from "@/shared/useApi" +import { SHOP_RECOMMENDATION_ROUTES } from "@garage/api" +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, +} from "@/shared/components/ui/sheet" +import { Button } from "@/shared/components/ui/button" +import { Input } from "@/shared/components/ui/input" +import { + Field, + FieldLabel, + FieldError, +} from "@/shared/components/ui/field" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/shared/components/ui/dropdown-menu" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/shared/components/ui/tooltip" +import { cn } from "@/shared/lib/utils" + +type ShopRecommendation = { + id: number + description: string +} + +type ShopRecommendationsPage = { + data: ShopRecommendation[] + meta: { + current_page: number + last_page: number + per_page: number + total: number + } +} + +type RhfShopRecommendationsFieldProps< + TValues extends FieldValues, + TName extends FieldPath, +> = { + name: TName + label?: string + description?: string + required?: boolean + disabled?: boolean + placeholder?: string +} + +function extractPage(response: unknown): ShopRecommendationsPage { + const r = response as any + return { + data: Array.isArray(r?.data?.data) + ? r.data.data + : Array.isArray(r?.data) + ? r.data + : [], + meta: r?.data?.meta ?? r?.meta ?? { + current_page: 1, + last_page: 1, + per_page: 15, + total: 0, + }, + } +} + +function ShopRecommendationsSheet({ + open, + onOpenChange, + selected, + onToggle, +}: { + open: boolean + onOpenChange: (v: boolean) => void + selected: string[] + onToggle: (description: string) => void +}) { + const api = useAuthApi() + const queryClient = useQueryClient() + + const [page, setPage] = useState(1) + const [search, setSearch] = useState("") + const [creating, setCreating] = useState(false) + const [newDescription, setNewDescription] = useState("") + const [editingId, setEditingId] = useState(null) + const [editingText, setEditingText] = useState("") + + const queryKey = [SHOP_RECOMMENDATION_ROUTES.INDEX, { page, search }] + + const { data, isLoading } = useQuery({ + queryKey, + queryFn: async () => { + const res = await api.shopRecommendations.list({ + page, + ...(search ? { search } : {}), + }) + return extractPage(res) + }, + enabled: open, + staleTime: 30_000, + }) + + const recommendations = data?.data ?? [] + const meta = data?.meta + + const invalidate = () => + queryClient.invalidateQueries({ queryKey: [SHOP_RECOMMENDATION_ROUTES.INDEX] }) + + const createMutation = useMutation({ + mutationFn: (description: string) => + api.shopRecommendations.create({ description }), + onSuccess: () => { + invalidate() + setCreating(false) + setNewDescription("") + }, + }) + + const updateMutation = useMutation({ + mutationFn: ({ id, description }: { id: number; description: string }) => + api.shopRecommendations.update(String(id), { description }), + onSuccess: () => { + invalidate() + setEditingId(null) + setEditingText("") + }, + }) + + const deleteMutation = useMutation({ + mutationFn: (id: number) => + api.shopRecommendations.destroy(String(id)), + onSuccess: () => invalidate(), + }) + + function handleCreate() { + const text = newDescription.trim() + if (!text) return + createMutation.mutate(text) + } + + function handleUpdate(id: number) { + const text = editingText.trim() + if (!text) return + updateMutation.mutate({ id, description: text }) + } + + function startEdit(recommendation: ShopRecommendation) { + setEditingId(recommendation.id) + setEditingText(recommendation.description) + } + + const totalPages = meta?.last_page ?? 1 + const from = meta ? (meta.current_page - 1) * meta.per_page + 1 : 0 + const to = meta ? Math.min(meta.current_page * meta.per_page, meta.total) : 0 + + return ( + + + + Shop Recommendations + + +
+
+ + { + setSearch(e.target.value) + setPage(1) + }} + className="pl-9 h-9" + /> +
+ +
+ + {creating && ( +
+
+ setNewDescription(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { e.preventDefault(); handleCreate() } + if (e.key === "Escape") { setCreating(false); setNewDescription("") } + }} + className="h-8 text-sm" + /> + + +
+
+ )} + +
+ {isLoading ? ( +
+ Loading... +
+ ) : recommendations.length === 0 ? ( +
+ No shop recommendations found. +
+ ) : ( + recommendations.map((recommendation) => { + const isSelected = selected.includes(recommendation.description) + const isEditing = editingId === recommendation.id + + if (isEditing) { + return ( +
+ setEditingText(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { e.preventDefault(); handleUpdate(recommendation.id) } + if (e.key === "Escape") { setEditingId(null); setEditingText("") } + }} + className="h-8 flex-1 text-sm" + /> + + +
+ ) + } + + return ( +
onToggle(recommendation.description)} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault() + onToggle(recommendation.description) + } + }} + > +
+
+ {isSelected && ( + + )} +
+ {recommendation.description} +
+ + e.stopPropagation()}> + + + + { + e.stopPropagation() + startEdit(recommendation) + }} + > + + Edit + + { + e.stopPropagation() + deleteMutation.mutate(recommendation.id) + }} + > + + Delete + + + +
+ ) + }) + )} +
+ + {meta && meta.total > 0 && ( +
+ + {from}-{to} of {meta.total} + +
+ + + + +
+
+ )} +
+
+ ) +} + +export function RhfShopRecommendationsField< + TValues extends FieldValues, + TName extends FieldPath, +>({ + name, + label = "Shop Recommendations", + description, + required, + disabled, + placeholder = "Enter recommendation...", +}: RhfShopRecommendationsFieldProps) { + const { control } = useFormContext() + const { field, fieldState: { error } } = useController({ name, control, disabled }) + + const [sheetOpen, setSheetOpen] = useState(false) + + const recommendations: string[] = Array.isArray(field.value) ? field.value : [] + + function updateAt(index: number, value: string) { + const next = [...recommendations] + next[index] = value + field.onChange(next) + } + + function addLine() { + field.onChange([...recommendations, ""]) + } + + function removeLine(index: number) { + field.onChange(recommendations.filter((_, i) => i !== index)) + } + + function toggleRecommendation(description: string) { + const exists = recommendations.includes(description) + if (exists) { + field.onChange(recommendations.filter((r) => r !== description)) + } else { + field.onChange([...recommendations, description]) + } + } + + return ( + + {label && ( + + {label} + {required && ( + * + )} + + )} + +
+ {recommendations.map((recommendation, index) => ( +
+ updateAt(index, e.target.value)} + placeholder={placeholder} + disabled={disabled} + className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground" + /> + +
+ ))} + + {recommendations.length === 0 && ( +
+ No recommendations added yet. +
+ )} + +
+ + + + + + + Shop Recommendations + +
+
+ + {error && {error.message}} + {description && !error && ( +

{description}

+ )} + + +
+ ) +} diff --git a/apps/dashboard/modules/expenses/expense-form.tsx b/apps/dashboard/modules/expenses/expense-form.tsx index c8f62b5..ab4b190 100644 --- a/apps/dashboard/modules/expenses/expense-form.tsx +++ b/apps/dashboard/modules/expenses/expense-form.tsx @@ -23,6 +23,7 @@ import { type ExpenseFormValues, } from "./expense.schema" import { EXPENSE_ROUTES, JOB_CARD_ROUTES, VENDOR_ROUTES, DEPARTMENT_ROUTES } from "@garage/api" +import { getFullName } from "@/shared/utils/getFullName" // ── Constants ── @@ -158,7 +159,7 @@ export function ExpenseForm({ resourceId, initialData, onSuccess }: ExpenseFormP placeholder="Select vendor" queryKey={[VENDOR_ROUTES.INDEX]} listFn={() => api.vendors.list()} - mapOption={mapLookupOption} + mapOption={(op: any) => ({ value: String(op.id), label: getFullName(op)})} {...STORE_OBJECT} /> api.insuranceTypes} + resourceLabel="insurance type" + columns={() => [ + { + accessorKey: "name", + header: ({ column }) => , + }, + ]} + renderForm={({ resourceId, initialData, onSuccess }) => ( + + )} + /> + ) +} diff --git a/apps/dashboard/modules/job-cards/insurance-type-form.tsx b/apps/dashboard/modules/job-cards/insurance-type-form.tsx new file mode 100644 index 0000000..5151bdd --- /dev/null +++ b/apps/dashboard/modules/job-cards/insurance-type-form.tsx @@ -0,0 +1,87 @@ +"use client" + +import { z } from "zod" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { Plus, Save } from "lucide-react" +import { Button } from "@/shared/components/ui/button" +import { FieldGroup } from "@/shared/components/ui/field" +import { Rhform, RhfTextField } from "@/shared/components/form" +import { toast } from "sonner" +import { useAuthApi } from "@/shared/useApi" +import { useEffect } from "react" + +// ── Schema ── + +const insuranceTypeSchema = z.object({ + name: z.string().min(1, "Name is required"), +}) + +type InsuranceTypeFormValues = z.infer + +// ── Props ── + +type InsuranceTypeFormProps = { + resourceId?: string | null + initialData?: any + onSuccess?: () => void +} + +// ── Component ── + +export function InsuranceTypeForm({ resourceId, initialData, onSuccess }: InsuranceTypeFormProps) { + const api = useAuthApi() + const isEditing = !!resourceId + + const form = useForm({ + resolver: zodResolver(insuranceTypeSchema), + defaultValues: { name: "" }, + }) + + // Pre-fill when editing + useEffect(() => { + if (initialData) { + const d = initialData?.data ?? initialData + form.reset({ name: d.name ?? "" }) + } + }, [initialData, form]) + + const handleSubmit = async (values: InsuranceTypeFormValues) => { + try { + const promise = isEditing + ? api.insuranceTypes.update(resourceId!, { title: values.name } as any) + : api.insuranceTypes.create({ title: values.name } as any) + + toast.promise(promise, { + loading: isEditing ? "Updating..." : "Creating...", + success: isEditing ? "Updated successfully" : "Created successfully", + error: isEditing ? "Failed to update" : "Failed to create", + }) + + await promise + form.reset() + onSuccess?.() + } catch { + // toast already shown + } + } + + return ( + + + + + + + ) +} diff --git a/apps/dashboard/modules/job-cards/job-card-actions.tsx b/apps/dashboard/modules/job-cards/job-card-actions.tsx index 7de976f..4710a00 100644 --- a/apps/dashboard/modules/job-cards/job-card-actions.tsx +++ b/apps/dashboard/modules/job-cards/job-card-actions.tsx @@ -27,6 +27,7 @@ type JobCardActionsProps = { orderDate?: string | null serviceWriterName?: string | null salesPersonName?: string | null + primaryTechnicianName?: string | null } // ── Informative Action Card ── @@ -104,13 +105,13 @@ function EmployeePickerDialog({ // ── Main Component ── -export function JobCardActions({ jobCardId, orderDate, serviceWriterName, salesPersonName }: JobCardActionsProps) { +export function JobCardActions({ jobCardId, orderDate, serviceWriterName, salesPersonName, primaryTechnicianName }: JobCardActionsProps) { const api = useAuthApi() const router = useRouter() const [datePickerOpen, setDatePickerOpen] = useState(false) const [serviceWriterDialogOpen, setServiceWriterDialogOpen] = useState(false) const [salesPersonDialogOpen, setSalesPersonDialogOpen] = useState(false) - + const [primaryTechnicianDialogOpen, setPrimaryTechnicianDialogOpen] = useState(false) const changeDateMutation = useMutation({ mutationFn: (date: Date) => { @@ -161,6 +162,8 @@ export function JobCardActions({ jobCardId, orderDate, serviceWriterName, salesP }, }) + + return (
{/* Check-in Date Action Card */} @@ -213,6 +216,19 @@ export function JobCardActions({ jobCardId, orderDate, serviceWriterName, salesP isPending={changeSalesPersonMutation.isPending} /> + {/* Primary Technician Action Card */} + {/* Edit / Delete Dropdown */} @@ -234,6 +250,24 @@ export function JobCardActions({ jobCardId, orderDate, serviceWriterName, salesP isPending={changeSalesPersonMutation.isPending} onSelect={(id) => changeSalesPersonMutation.mutate(id)} /> + changeSalesPersonMutation.mutate(id)} + /> + {}} + // onSelect={(id) => changePrimaryTechnicianMutation.mutate(id)} + /> +
) } diff --git a/apps/dashboard/modules/job-cards/job-card-context.tsx b/apps/dashboard/modules/job-cards/job-card-context.tsx index 02565d7..4531ddb 100644 --- a/apps/dashboard/modules/job-cards/job-card-context.tsx +++ b/apps/dashboard/modules/job-cards/job-card-context.tsx @@ -2,16 +2,16 @@ import { createContext, useContext, useState, useCallback } from "react" import type { JobCardStatus } from "./job-card.schema" -import { JobCardResponseData } from "@garage/api" +import { JobCardShowData } from "../../../../packages/api/src/clients/job-cards" + - -const JobCardContext = createContext(null) +const JobCardContext = createContext(null) export function JobCardProvider({ jobCard, children, }: { - jobCard: JobCardResponseData + jobCard: JobCardShowData children: React.ReactNode }) { const [status, setStatusState] = useState(jobCard.status as JobCardStatus) @@ -21,7 +21,7 @@ export function JobCardProvider({ }, []) return ( - + {children} ) diff --git a/apps/dashboard/modules/job-cards/job-card-form.tsx b/apps/dashboard/modules/job-cards/job-card-form.tsx index e1b245d..1fc35fe 100644 --- a/apps/dashboard/modules/job-cards/job-card-form.tsx +++ b/apps/dashboard/modules/job-cards/job-card-form.tsx @@ -4,14 +4,16 @@ import { AlertTriangle, Plus, Save } from "lucide-react" import { Button } from "@/shared/components/ui/button" import { Alert, AlertTitle } from "@/shared/components/ui/alert" -import { FieldGroup } from "@/shared/components/ui/field" import { Rhform, RhfTextField, + RhfTextareaField, + RhfCheckboxField, RhfSelectField, RhfAsyncSelectField, RhfDateField, RhfTimeField, + RhfAutoGenerateField, } from "@/shared/components/form" import { toast } from "sonner" import { useAuthApi } from "@/shared/useApi" @@ -22,16 +24,15 @@ import { toRelation, toId } from "@/shared/lib/utils" import { jobCardFormSchema, type JobCardFormValues, - TAX_INCLUSIVE_OPTIONS, - DISCOUNT_TYPE_OPTIONS, - DISCOUNT_AT_OPTIONS, - ESTIMATE_TO_OPTIONS, FUEL_LEVEL_OPTIONS, JOB_CARD_STATUS_OPTIONS, } from "./job-card.schema" -import { JOB_CARD_ROUTES, EMPLOYEE_ROUTES, DEPARTMENT_ROUTES } from "@garage/api" +import { JOB_CARD_ROUTES, EMPLOYEE_ROUTES, DEPARTMENT_ROUTES, INSURANCE_TYPE_ROUTES } from "@garage/api" import { RhfCustomerSelectField } from "@/modules/customers/rhf-customer-select-field" import { RhfVehicleSelectField } from "@/modules/vehicles/rhf-vehicle-select-field" +import { RhfLabelPickerField } from "@/modules/labels/rhf-label-picker-field" +import { RhfCustomerRemarksField } from "@/modules/estimates/rhf-customer-remarks-field" +import { InsuranceTypeCrudDialog } from "./insurance-type-crud-dialog" // ── Props ── @@ -49,15 +50,32 @@ const DEFAULT_VALUES: JobCardFormValues = { vehicle: null, department: null, service_writer: null, + primary_technician: null, + sales_person: null, + insurance_type: null, + insurer: null, + order_number: "", + estimate_number: "", status: "check_in", estimate_to: "Customer", tax_inclusive: "Tax Inclusive", discount_type: "no", discount_at: "inclusive_of_tax", - // check_in_date: "", - // check_in_time: (() => { const n = new Date(); return `${String(n.getHours()).padStart(2,"0")}:${String(n.getMinutes()).padStart(2,"0")}:${String(n.getSeconds()).padStart(2,"0")}` })(), + order_date: new Date().toISOString().split("T")[0], + check_in_date: "", + check_in_time: "", + start_date: "", + start_time: "", + delivery_date: "", + delivery_time: "", km_in: "", fuel_level: "", + has_insurance: false, + enable_parts_issuing: false, + enable_digital_authorisation: false, + footer: "", + customer_remarks: [], + labels: [], } // ── Mapping helpers ── @@ -65,21 +83,56 @@ const DEFAULT_VALUES: JobCardFormValues = { function mapToFormValues(data: unknown): JobCardFormValues { const d = (data as any)?.data ?? data ?? {} + const mapTime = (v: unknown): string => { + if (!v || typeof v !== "string") return "" + if (v.includes("T")) return v.split("T")[1]?.slice(0, 8) ?? "" + return v + } + const mapDate = (v: unknown): string => { + if (!v || typeof v !== "string") return "" + return v.split("T")[0] + } + return { title: d.title || "", customer: toRelation(d.customer_id, d.customer ? `${d.customer.first_name} ${d.customer.last_name}` : undefined), - vehicle: toRelation(d.vehicle_id, d.vehicle ? `${d.vehicle.make} ${d.vehicle.model}` : undefined), + vehicle: toRelation(d.vehicle_id, d.vehicle ? (d.vehicle.plate_number ?? `${d.vehicle.make ?? ""} ${d.vehicle.model ?? ""}`.trim()) : undefined), department: toRelation(d.department_id, d.department?.name), service_writer: toRelation(d.service_writer_id, d.service_writer ? `${d.service_writer.first_name} ${d.service_writer.last_name}` : undefined), + primary_technician: toRelation(d.primary_technician_id, d.primary_technician ? `${d.primary_technician.first_name} ${d.primary_technician.last_name}` : undefined), + sales_person: toRelation(d.sales_person_id, d.sales_person ? `${d.sales_person.first_name} ${d.sales_person.last_name}` : undefined), + insurance_type: toRelation(d.insurance_type_id, d.insurance_type?.name), + insurer: toRelation(d.insurer_id, d.insurer ? `${d.insurer.first_name} ${d.insurer.last_name}`.trim() || d.insurer.company_name : undefined), + order_number: d.order_number || "", + estimate_number: d.estimate_number || "", status: d.status || "draft", estimate_to: d.estimate_to || "Customer", tax_inclusive: d.tax_inclusive || "Tax Inclusive", discount_type: d.discount_type || "no", discount_at: d.discount_at || "inclusive_of_tax", - check_in_date: d.check_in_date ? d.check_in_date.split("T")[0] : "", - check_in_time: d.check_in_time ? d.check_in_time.split("T")[0] : "", + order_date: mapDate(d.order_date), + check_in_date: mapDate(d.check_in_date), + check_in_time: mapTime(d.check_in_time), + start_date: mapDate(d.start_date), + start_time: mapTime(d.start_time), + delivery_date: mapDate(d.delivery_date), + delivery_time: mapTime(d.delivery_time), km_in: d.km_in != null ? String(d.km_in) : "", fuel_level: d.fuel_level || "", + has_insurance: d.has_insurance ?? false, + enable_parts_issuing: d.enable_parts_issuing ?? false, + enable_digital_authorisation: d.enable_digital_authorisation ?? false, + footer: d.footer || "", + customer_remarks: Array.isArray(d.customer_remarks) + ? d.customer_remarks.map((r: any) => + typeof r === "string" ? r : (r?.remark ?? "") + ).filter(Boolean) + : [], + labels: (d.labels ?? []).map((l: any) => ({ + id: l.id, + title: l.title, + color_code: l.color_code, + })), } } @@ -90,15 +143,32 @@ function mapFormToPayload(values: JobCardFormValues) { vehicle_id: toId(values.vehicle), department_id: toId(values.department), service_writer_id: toId(values.service_writer), + primary_technician_id: toId(values.primary_technician), + sales_person_id: toId(values.sales_person), + insurance_type_id: values.insurance_type ? String(toId(values.insurance_type)) : null, + insurer_id: values.insurer ? String(toId(values.insurer)) : null, + estimate_number: values.estimate_number || undefined, + order_number: values.order_number || undefined, status: values.status || undefined, estimate_to: values.estimate_to || undefined, tax_inclusive: values.tax_inclusive || undefined, discount_type: values.discount_type || undefined, discount_at: values.discount_at || undefined, + order_date: values.order_date || undefined, check_in_date: values.check_in_date || undefined, check_in_time: values.check_in_time || undefined, + start_date: values.start_date || undefined, + start_time: values.start_time || undefined, + delivery_date: values.delivery_date || undefined, + delivery_time: values.delivery_time || undefined, km_in: values.km_in ? Number(values.km_in) : undefined, fuel_level: values.fuel_level || undefined, + has_insurance: values.has_insurance, + enable_parts_issuing: values.enable_parts_issuing, + enable_digital_authorisation: values.enable_digital_authorisation, + footer: values.footer || undefined, + customer_remarks: values.customer_remarks?.filter(Boolean) ?? [], + label_ids: values.labels?.map((l) => l.id), } } @@ -130,6 +200,10 @@ export function JobCardForm({ resourceId, initialData, onSuccess }: JobCardFormP mapToFormValues, }) + const hasInsurance = form.watch("has_insurance") + const status = form.watch("status") + const isCheckIn = status === "check_in" + const { mutate, error, isPending } = useFormMutation(form, { mutationFn: (values: JobCardFormValues) => { const payload = mapFormToPayload(values) @@ -161,93 +235,144 @@ export function JobCardForm({ resourceId, initialData, onSuccess }: JobCardFormP )} - - +
-
- - + {/* ── Left column ── */} +
+ + + +
+ + +
+ + + {hasInsurance && ( +
+ +
+
+ Insurance Type + +
+ api.insuranceTypes.list()} + mapOption={mapLookupOption} + {...STORE_OBJECT} + /> +
+
+ )} + +
+ + + + +
+ +
+ api.employees.list()} + mapOption={mapEmployeeOption} + {...STORE_OBJECT} + /> + api.employees.list()} + mapOption={mapEmployeeOption} + {...STORE_OBJECT} + /> +
+ + + {/* ── Check-in Details (shown when status is check_in) ── */} + {isCheckIn && ( +
+

Check In Details

+ +
+ + +
+ +
+ + + + +
+ +
+ api.departments.list()} + mapOption={mapLookupOption} + {...STORE_OBJECT} + /> + + +
+
+ )}
-
- {/* + */} - -
- -
- api.departments.list()} - mapOption={mapLookupOption} - {...STORE_OBJECT} - /> - api.employees.list()} - mapOption={mapEmployeeOption} - {...STORE_OBJECT} - /> -
- - {/*
- - -
*/} - -
- - -
- -
- - - + + + +
+
+
- +
) } diff --git a/apps/dashboard/modules/job-cards/job-card-general-info.tsx b/apps/dashboard/modules/job-cards/job-card-general-info.tsx index 723808c..dbaa260 100644 --- a/apps/dashboard/modules/job-cards/job-card-general-info.tsx +++ b/apps/dashboard/modules/job-cards/job-card-general-info.tsx @@ -4,7 +4,6 @@ import { Hash, Users, Car, - Building2, Gauge, Clock, UserCheck, @@ -21,15 +20,19 @@ import { import { Badge } from "@/shared/components/ui/badge" import { Separator } from "@/shared/components/ui/separator" import { JobCardActions } from "./job-card-actions" +import { JobCardRemarksList } from "./job-card-remarks-list" +import { JobCardRecommendationsList } from "./job-card-recommendations-list" import { getVehicleLabel } from "../vehicles/utils/getVehicleLabel" -import { JobCardResponseData } from "@garage/api" import { getFullName } from "@/shared/utils/getFullName" +import { CrudShowResponse, JobCardsClient, PAYMENT_RECEIVED_ROUTES, PaymentReceivedClient } from "@garage/api" +import { ResourcePage } from "@/shared/data-view/resource-page" +import PaymentReceivedPage from "@/app/(authenticated)/sales/payment-received/page" +import JobCardPaymentsReceived from "./job-card-payments-received" - -type JobCardGeneralInfoProps = { - jobCard: JobCardResponseData -} + +type JobCard = NonNullable['data']> + function InfoItem({ icon: Icon, @@ -64,7 +67,7 @@ const statusColorMap: Record = { cancelled: "destructive", } -export function JobCardGeneralInfo({ jobCard }: { jobCard: JobCardResponseData }) { +export function JobCardGeneralInfo({ jobCard }: { jobCard: JobCard }) { const formatStatus = (status?: string) => { if (!status) return null return status @@ -73,163 +76,183 @@ export function JobCardGeneralInfo({ jobCard }: { jobCard: JobCardResponseData } .join(" ") } - console.log(jobCard) return (
- {jobCard.service_writer?.first_name} -
- - - - - Job Card Details - - - -
- {jobCard.title && ( - {jobCard.title} - )} - {jobCard.status && ( - - {formatStatus(jobCard.status)} - - )} -
- -
- - - - -
-
-
- {/* Related Information */} - - - - - Related Information - - - -
- - - + + + + + Job Card Details + + + +
+ {jobCard.title && ( + {jobCard.title} + )} + {jobCard.status && ( + + {formatStatus(jobCard.status)} + + )} +
+ +
+ + + + +
+
+
+ + {/* Related Information */} + + + + + Related Information + + + +
+ + + {/* */} + + +
+
+
+ + {/* Tax & Discount Settings */} + + + + + Tax & Discount Settings + + + + -
-
-
+ + - {/* Tax & Discount Settings */} - - - - - Tax & Discount Settings - - - - - - - - + {/* Counts */} + + + + + Related Counts + + + + + + + + + + +
- {/* Counts */} - - - - - Related Counts - - - - - - - - - - + + + +
+ +
) diff --git a/apps/dashboard/modules/job-cards/job-card-part-form.tsx b/apps/dashboard/modules/job-cards/job-card-part-form.tsx new file mode 100644 index 0000000..6ced717 --- /dev/null +++ b/apps/dashboard/modules/job-cards/job-card-part-form.tsx @@ -0,0 +1,244 @@ +"use client" + +import React from "react" +import { AlertTriangle, Plus, Save } from "lucide-react" +import { z } from "zod" + +import { Button } from "@/shared/components/ui/button" +import { Alert, AlertTitle } from "@/shared/components/ui/alert" +import { FieldGroup } from "@/shared/components/ui/field" +import { + Rhform, + RhfTextField, + RhfTextareaField, + RhfAsyncSelectField, +} from "@/shared/components/form" +import { DepartmentInlineForm } from "@/modules/services/inline-forms/department-inline-form" +import { toast } from "sonner" +import { useAuthApi } from "@/shared/useApi" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" + +// ── Schema ── + +const jobCardPartFormSchema = z.object({ + part: z + .object({ value: z.string(), label: z.string() }) + .nullable(), + department: z + .object({ value: z.string(), label: z.string() }) + .nullable() + .optional(), + quantity: z.coerce.number().min(1, "Quantity is required"), + rate: z.coerce.number().min(0, "Rate is required"), + tax: z.string().optional(), + description: z.string().optional(), +}) + +type JobCardPartFormValues = z.infer + +// ── Props ── + +export type JobCardPartFormProps = { + jobCardId: string + jobCardPartId?: number | null + initialData?: unknown + onSuccess?: () => void + onCancel?: () => void +} + +const DEFAULT_VALUES: JobCardPartFormValues = { + part: null, + department: null, + quantity: 1, + rate: 0, + tax: "", + description: "", +} + +const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label } + +function mapToFormValues(data: unknown): JobCardPartFormValues { + const d = (data as any) ?? {} + return { + part: d.part + ? { value: String(d.part.id), label: d.part.title ?? String(d.part.id) } + : null, + department: d.department + ? { value: String(d.department.id), label: d.department.name ?? String(d.department.id) } + : null, + quantity: d.quantity ?? 1, + rate: d.rate != null ? Number(d.rate) : 0, + tax: d.tax ?? "", + description: d.description ?? "", + } +} + +// ── Component ── + +export function JobCardPartForm({ + jobCardId, + jobCardPartId, + initialData, + onSuccess, + onCancel, +}: JobCardPartFormProps) { + const api = useAuthApi() + const isEditing = !!jobCardPartId + + const form = useForm({ + resolver: zodResolver(jobCardPartFormSchema) as any, + defaultValues: initialData + ? mapToFormValues(initialData) + : DEFAULT_VALUES, + }) + + const [error, setError] = React.useState(null) + const [isPending, setIsPending] = React.useState(false) + + async function handleSubmit(values: JobCardPartFormValues) { + setError(null) + setIsPending(true) + try { + if (isEditing && jobCardPartId) { + await toast.promise( + api.jobCards.updatePart(jobCardId, { + job_card_part_id: jobCardPartId, + quantity: values.quantity, + rate: values.rate, + description: values.description || undefined, + }), + { + loading: "Updating part...", + success: "Part updated successfully", + error: "Failed to update part", + } + ) + } else { + await toast.promise( + api.jobCards.addPart(jobCardId, { + part_id: values.part ? Number(values.part.value) : undefined, + department_id: values.department ? Number(values.department.value) : undefined, + quantity: values.quantity, + rate: values.rate, + tax: values.tax || undefined, + description: values.description || undefined, + }), + { + loading: "Adding part...", + success: "Part added successfully", + error: "Failed to add part", + } + ) + } + form.reset() + onSuccess?.() + } catch (err: any) { + setError(err?.message ?? "An unexpected error occurred") + } finally { + setIsPending(false) + } + } + + return ( + + {error && ( + + + + {isEditing ? "Failed to update part" : "Failed to add part"} + + {error} + + )} + + + {!isEditing && ( + api.parts.list()} + mapOption={(item: any) => ({ + value: String(item.id), + label: item.title ?? String(item.id), + })} + {...STORE_OBJECT} + /> + )} + +
+ + +
+ + {!isEditing && ( +
+ api.departments.list()} + mapOption={(item: any) => ({ + value: String(item.id), + label: item.name ?? String(item.id), + })} + createForm={(props) => } + createLabel="Department" + {...STORE_OBJECT} + /> + +
+ )} + + +
+ +
+ {onCancel && ( + + )} + +
+
+ ) +} diff --git a/apps/dashboard/modules/job-cards/job-card-payments-received.tsx b/apps/dashboard/modules/job-cards/job-card-payments-received.tsx new file mode 100644 index 0000000..7958871 --- /dev/null +++ b/apps/dashboard/modules/job-cards/job-card-payments-received.tsx @@ -0,0 +1,134 @@ +"use client" + +import { CrudResource } from "@/shared/data-view/resource-page" +import { ColumnHeader } from "@/shared/data-view/table-view" +import FormDialog from "@/shared/components/form-dialog" +import { PaymentReceivedForm } from "@/modules/payment-received/payment-received-form" +import { PAYMENT_RECEIVED_ROUTES, PaymentReceivedClient } from "@garage/api" +import { + BadgeDollarSignIcon, + CalendarIcon, + ChevronDown, + CreditCardIcon, + HashIcon, +} from "lucide-react" +import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/shared/components/ui/collapsible" +import { Button } from "@/shared/components/ui/button" +import { useJobCard } from "./job-card-context" +import { formatDate, formatCurrency } from "@/shared/utils/formatters" + +export default function JobCardPaymentsReceived() { + const jobCard = useJobCard() + + return ( + + + +
+ Payments Received + + + +
+
+ + + + extraParams={{ job_card_id: jobCard?.id }} + routeKey={PAYMENT_RECEIVED_ROUTES.INDEX} + getClient={(api) => api.paymentReceived} + tableHeader={({ invalidateQuery }) => +
+ + {(resourceId) => ( + + )} + +
+ } + columns={({ actionsColumn }) => [ + { + accessorKey: "payment_number", + header: ({ column }) => , + cell: ({ row }) => { + const item = row.original + return ( +
+ + {item.payment_number || "—"} +
+ ) + }, + }, + { + accessorKey: "amount_received", + header: ({ column }) => , + cell: ({ row }) => { + const item = row.original + return ( +
+ + + {formatCurrency(item.amount_received)} + +
+ ) + }, + }, + { + accessorKey: "payment_mode_name", + header: ({ column }) => , + cell: ({ row }) => { + const item = row.original as any + return ( +
+ + {item.payment_mode_name || "—"} +
+ ) + }, + }, + { + accessorKey: "payment_date", + header: ({ column }) => , + cell: ({ row }) => { + const item = row.original + return ( +
+ + {formatDate(item.payment_date)} +
+ ) + }, + }, + { + accessorKey: "note", + header: () => Note, + enableSorting: false, + cell: ({ row }) => { + const item = row.original + const note = item.note + if (!note) return + return ( + + {note} + + ) + }, + }, + actionsColumn(), + ]} + /> +
+
+
+
+ ) +} diff --git a/apps/dashboard/modules/job-cards/job-card-recommendations-list.tsx b/apps/dashboard/modules/job-cards/job-card-recommendations-list.tsx new file mode 100644 index 0000000..2a98c30 --- /dev/null +++ b/apps/dashboard/modules/job-cards/job-card-recommendations-list.tsx @@ -0,0 +1,128 @@ +"use client" + +import { useState } from "react" +import { useMutation } from "@tanstack/react-query" +import { useRouter } from "next/navigation" +import { Plus, Trash2, Lightbulb } from "lucide-react" +import { toast } from "sonner" + +import { useAuthApi } from "@/shared/useApi" +import { confirm } from "@/shared/components/confirm-dialog" +import { Button } from "@/shared/components/ui/button" +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/shared/components/ui/card" +import { Textarea } from "@/shared/components/ui/textarea" + +type ShopRecommendation = { + id: number + recommendation: string + created_at?: string +} + +type Props = { + jobCardId: string + recommendations: ShopRecommendation[] +} + +export function JobCardRecommendationsList({ jobCardId, recommendations }: Props) { + const api = useAuthApi() + const router = useRouter() + const [newRecommendation, setNewRecommendation] = useState("") + + const addMutation = useMutation({ + mutationFn: (recommendation: string) => + api.jobCards.addShopRecommendation(jobCardId, { recommendation }), + onSuccess: () => { + toast.success("Shop recommendation added") + setNewRecommendation("") + router.refresh() + }, + onError: () => { + toast.error("Failed to add shop recommendation") + }, + }) + + const deleteMutation = useMutation({ + mutationFn: (recommendationId: number) => + api.jobCards.deleteShopRecommendation(jobCardId, recommendationId), + onSuccess: () => { + toast.success("Shop recommendation deleted") + router.refresh() + }, + onError: () => { + toast.error("Failed to delete shop recommendation") + }, + }) + + const handleAdd = () => { + const trimmed = newRecommendation.trim() + if (!trimmed) return + addMutation.mutate(trimmed) + } + + const handleDelete = async (recommendation: ShopRecommendation) => { + const confirmed = await confirm({ + title: "Delete Shop Recommendation", + description: "Are you sure you want to delete this recommendation?", + confirmLabel: "Delete", + variant: "destructive", + }) + if (confirmed) { + deleteMutation.mutate(recommendation.id) + } + } + + return ( + + + + + Shop Recommendations + + + + {recommendations.length === 0 && ( +

No shop recommendations yet.

+ )} + {recommendations.map((rec) => ( +
+

{rec.recommendation}

+ +
+ ))} +
+