From bb02b77be2121d125d7efb4cf1727116ba18a5e8 Mon Sep 17 00:00:00 2001 From: Mohammad Khyata Date: Wed, 8 Apr 2026 14:38:16 +0300 Subject: [PATCH] filters , order inspections , inventory items , credit notes --- .github/skills/resource-filters/SKILL.md | 258 ++++++++++++++++ .../items/expense-item/page.tsx | 63 ++++ .../credit-notes/[id]/documents/page.tsx | 152 ++++++++++ .../sales/credit-notes/[id]/layout.tsx | 43 +++ .../sales/credit-notes/[id]/notes/page.tsx | 144 +++++++++ .../sales/credit-notes/[id]/page.tsx | 15 + .../sales/credit-notes/page.tsx | 79 +++++ .../sales/job-cards/[id]/attachments/page.tsx | 8 +- .../sales/job-cards/[id]/inspections/page.tsx | 77 +++++ .../sales/job-cards/[id]/layout.tsx | 4 + .../(authenticated)/sales/job-cards/page.tsx | 224 +++++++------- apps/dashboard/config/navGroups.tsx | 70 ++--- .../credit-notes/credit-note-actions.tsx | 50 ++++ .../credit-notes/credit-note-context.tsx | 28 ++ .../credit-note-document-form.tsx | 70 +++++ .../modules/credit-notes/credit-note-form.tsx | 182 +++++++++++ .../credit-notes/credit-note-general-info.tsx | 146 +++++++++ .../credit-notes/credit-note-note-form.tsx | 60 ++++ .../credit-notes/credit-note.schema.ts | 24 ++ .../expense-items/expense-item-form.tsx | 206 +++++++++++++ .../expense-items/expense-item.schema.ts | 17 ++ .../inventory-category-crud-dialog.tsx | 33 ++ .../expense-items/inventory-category-form.tsx | 80 +++++ .../modules/inspections/inspection-form.tsx | 130 +++++++- .../modules/inspections/inspection.schema.ts | 12 + .../modules/job-cards/job-card-filters.tsx | 282 ++++++++++++++++++ .../shared/components/filter-drawer.tsx | 93 ++++++ .../shared/hooks/use-filter-params.ts | 102 +++++++ packages/api/src/api.ts | 4 + packages/api/src/clients/expense-items.ts | 17 ++ packages/api/src/clients/index.ts | 2 + .../api/src/clients/inventory-categories.ts | 17 ++ packages/api/src/contracts/enums.ts | 5 + 33 files changed, 2552 insertions(+), 145 deletions(-) create mode 100644 .github/skills/resource-filters/SKILL.md create mode 100644 apps/dashboard/app/(authenticated)/items/expense-item/page.tsx create mode 100644 apps/dashboard/app/(authenticated)/sales/credit-notes/[id]/documents/page.tsx create mode 100644 apps/dashboard/app/(authenticated)/sales/credit-notes/[id]/layout.tsx create mode 100644 apps/dashboard/app/(authenticated)/sales/credit-notes/[id]/notes/page.tsx create mode 100644 apps/dashboard/app/(authenticated)/sales/credit-notes/[id]/page.tsx create mode 100644 apps/dashboard/app/(authenticated)/sales/credit-notes/page.tsx create mode 100644 apps/dashboard/app/(authenticated)/sales/job-cards/[id]/inspections/page.tsx create mode 100644 apps/dashboard/modules/credit-notes/credit-note-actions.tsx create mode 100644 apps/dashboard/modules/credit-notes/credit-note-context.tsx create mode 100644 apps/dashboard/modules/credit-notes/credit-note-document-form.tsx create mode 100644 apps/dashboard/modules/credit-notes/credit-note-form.tsx create mode 100644 apps/dashboard/modules/credit-notes/credit-note-general-info.tsx create mode 100644 apps/dashboard/modules/credit-notes/credit-note-note-form.tsx create mode 100644 apps/dashboard/modules/credit-notes/credit-note.schema.ts create mode 100644 apps/dashboard/modules/expense-items/expense-item-form.tsx create mode 100644 apps/dashboard/modules/expense-items/expense-item.schema.ts create mode 100644 apps/dashboard/modules/expense-items/inventory-category-crud-dialog.tsx create mode 100644 apps/dashboard/modules/expense-items/inventory-category-form.tsx create mode 100644 apps/dashboard/modules/job-cards/job-card-filters.tsx create mode 100644 apps/dashboard/shared/components/filter-drawer.tsx create mode 100644 apps/dashboard/shared/hooks/use-filter-params.ts create mode 100644 packages/api/src/clients/expense-items.ts create mode 100644 packages/api/src/clients/inventory-categories.ts diff --git a/.github/skills/resource-filters/SKILL.md b/.github/skills/resource-filters/SKILL.md new file mode 100644 index 0000000..61f5f43 --- /dev/null +++ b/.github/skills/resource-filters/SKILL.md @@ -0,0 +1,258 @@ +--- +name: resource-filters +description: "Add advanced filtering to resource list pages using a drawer/sheet with RHF forms and nuqs query params. Use when: adding filters to a ResourcePage, creating a filter drawer for any list page, implementing advanced search with URL-persisted filter state, adding query-param-based filtering to a CRUD page." +--- + +# Resource Filters + +Add URL-persisted advanced filtering to any ResourcePage or data table list. Filters live in a right-side Sheet drawer, powered by React Hook Form (RHF) and synced to URL query params via nuqs. + +## When to Use + +- User asks to add filters or advanced filters to a list/index page +- User wants URL-shareable filter state on a resource page +- User asks to filter by relations, dates, or boolean flags on any data table +- User wants a filter drawer/dialog for any CRUD listing + +## Architecture + +``` +┌─────────────────────────────────┐ +│ useFilterParams (generic hook) │ ← Manages RHF form + nuqs URL sync +│ • schema + defaults │ +│ • paramsParsers (nuqs) │ +│ • mapParamsToFormValues │ +│ • mapFormValuesToParams │ +│ Returns: form, appliedParams, │ +│ open/close/submit/reset, │ +│ activeFilterCount │ +└──────────────┬──────────────────┘ + │ + ┌──────────┴──────────┐ + │ │ +┌───▼───────┐ ┌────────▼──────────┐ +│FilterDrawer│ │ ResourcePage │ +│(Sheet UI) │ │ extraParams={ │ +│ │ │ ...appliedParams│ +│ │ │ ...quickFilters │ +│ Apply/Reset│ │ } │ +└────────────┘ └───────────────────┘ +``` + +## File Locations + +| File | Purpose | +|---|---| +| `shared/hooks/use-filter-params.ts` | Generic hook — form + nuqs state sync | +| `shared/components/filter-drawer.tsx` | `FilterDrawer` sheet + `FilterTrigger` button | +| `modules//-filters.tsx` | Feature-specific schema, parsers, mappers, fields | + +## Procedure + +### Step 1: Create the filter config file + +Create `modules//-filters.tsx` with: + +1. **Zod schema** — all filter fields. Use `relationField` for foreign-key selects, `z.string().optional()` for dates, `z.boolean().optional()` for flags. +2. **Default values** — matching the schema (null for relations, `""` for strings, `false` for booleans). +3. **nuqs parsers** — `parseAsInteger` for relation IDs, `parseAsString` for dates/text, `parseAsBoolean` for flags. +4. **`mapParamsToFormValues`** — URL params → form values. Use `toRelation(id)` for relation fields. +5. **`mapFormValuesToParams`** — form values → URL params. Use `toId(relation)` for relation fields. +6. **Config export** — `UseFilterParamsOptions` object with schema, defaults, parsers, mappers. +7. **Fields component** — renders RHF fields inside the drawer. + +#### Template + +```tsx +"use client" + +import { z } from "zod" +import { parseAsInteger, parseAsString, parseAsBoolean } from "nuqs" +import { toRelation, toId, type RelationFieldValue } from "@/shared/lib/utils" +import { + RhfAsyncSelectField, + RhfDateField, + RhfCheckboxField, + RhfSelectField, +} from "@/shared/components/form" +import { useAuthApi } from "@/shared/useApi" +import { SOME_ROUTES } from "@garage/api" +import { Separator } from "@/shared/components/ui/separator" +import type { UseFilterParamsOptions } from "@/shared/hooks/use-filter-params" + +// ── Schema ── + +const relationField = z.object({ value: z.string(), label: z.string() }).nullable().optional() + +const filterSchema = z.object({ + some_relation_id: relationField, + some_date: z.string().optional(), + some_flag: z.boolean().optional(), +}) + +type FilterValues = z.infer + +// ── Defaults ── + +const defaultValues: FilterValues = { + some_relation_id: null, + some_date: "", + some_flag: false, +} + +// ── nuqs Parsers ── + +const paramsParsers = { + some_relation_id: parseAsInteger, + some_date: parseAsString, + some_flag: parseAsBoolean, +} + +// ── Mappers ── + +function mapParamsToFormValues(params: Record): Partial { + return { + some_relation_id: params.some_relation_id ? toRelation(params.some_relation_id) : null, + some_date: params.some_date ?? "", + some_flag: params.some_flag ?? false, + } +} + +function mapFormValuesToParams(values: FilterValues): Record { + return { + some_relation_id: toId(values.some_relation_id as RelationFieldValue) ?? null, + some_date: values.some_date || null, + some_flag: values.some_flag || null, + } +} + +// ── Filter Config ── + +export const featureFilterConfig: UseFilterParamsOptions = { + schema: filterSchema, + defaultValues, + paramsParsers, + mapParamsToFormValues, + mapFormValuesToParams, +} + +// ── Filter Fields Component ── + +export function FeatureFilterFields() { + const api = useAuthApi() + + return ( + <> + api.someResource.list({ per_page: 100 })} + mapOption={(item: any) => ({ value: String(item.id), label: item.name })} + placeholder="All" + /> + + + + + + ) +} +``` + +### Step 2: Integrate into the page + +In the page component: + +```tsx +import { useFilterParams } from '@/shared/hooks/use-filter-params' +import { FilterDrawer, FilterTrigger } from '@/shared/components/filter-drawer' +import { featureFilterConfig, FeatureFilterFields } from '@/modules//-filters' + +export default function FeaturePage() { + const filter = useFilterParams(featureFilterConfig) + + // Combine drawer filters with any quick filters (tabs, search, etc.) + const extraParams = useMemo(() => { + const params: Record = { ...filter.appliedParams } + // Add quick filters if present + if (search) params.search = search + if (statusFilter !== "all") params.status = statusFilter + return params + }, [filter.appliedParams, search, statusFilter]) + + return ( + <> + ({ + title: "Feature", + actions: ( +
+ + {/* other actions like FormDialog */} +
+ ), + })} + {/* columns, tableHeader, etc. */} + /> + + { if (!open) filter.close() }} + onSubmit={filter.onSubmit} + onReset={filter.reset} + activeFilterCount={filter.activeFilterCount} + title="Filter Features" + > + + + + ) +} +``` + +## Key Conventions + +### Field Types + +| Filter Type | Schema | nuqs Parser | Form Component | Default | +|---|---|---|---|---| +| Foreign key (select) | `relationField` (nullable object) | `parseAsInteger` | `RhfAsyncSelectField` | `null` | +| Date | `z.string().optional()` | `parseAsString` | `RhfDateField` | `""` | +| Boolean flag | `z.boolean().optional()` | `parseAsBoolean` | `RhfCheckboxField` | `false` | +| Enum select | `z.string().optional()` | `parseAsString` | `RhfSelectField` | `""` | +| Text/search | `z.string().optional()` | `parseAsString` | `RhfTextField` | `""` | +| Comma-separated IDs | `z.string().optional()` | `parseAsString` | custom / `RhfTextField` | `""` | + +### Relation Field Mapping + +- **URL → Form**: `toRelation(params.field_id)` produces `{ value: "5", label: "5" }`. The async select resolves the display label from loaded options. +- **Form → URL**: `toId(values.field_id)` extracts the numeric ID. +- Import `toRelation`, `toId`, and `RelationFieldValue` from `@/shared/lib/utils`. + +### Section Grouping + +Group related filters with `` and section labels: +```tsx + +

Section Name

+``` + +### Pagination Reset + +The `useFilterParams` hook automatically resets the `page` query param to `1` when filters are applied or reset, preventing empty-page issues. + +### Quick Filters vs Drawer Filters + +- **Quick filters** (status tabs, search input) live directly in `tableHeader` and are managed via `useState` or separate nuqs params on the page. +- **Drawer filters** (advanced) are managed by `useFilterParams` and rendered inside `FilterDrawer`. +- Both are merged into `extraParams` with `useMemo`. + +## Reference Implementation + +See `modules/job-cards/job-card-filters.tsx` and `app/(authenticated)/sales/job-cards/page.tsx` for the complete working example. diff --git a/apps/dashboard/app/(authenticated)/items/expense-item/page.tsx b/apps/dashboard/app/(authenticated)/items/expense-item/page.tsx new file mode 100644 index 0000000..9ee37bc --- /dev/null +++ b/apps/dashboard/app/(authenticated)/items/expense-item/page.tsx @@ -0,0 +1,63 @@ +"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 { ExpenseItemForm } from "@/modules/expense-items/expense-item-form" +import { EXPENSE_ITEM_ROUTES } from "@garage/api" +import type { ExpenseItemsClient } from "@garage/api" + +export default function ExpenseItemPage() { + return ( + + pageTitle="Expense Items" + routeKey={EXPENSE_ITEM_ROUTES.INDEX} + getClient={(api) => api.expenseItems} + headerProps={({ selectedItem, invalidateQuery }) => ({ + actions: ( + + {(resourceId) => ( + + )} + + ), + })} + columns={({ actionsColumn }) => [ + { + accessorKey: "item_name", + header: ({ column }) => , + }, + { + accessorKey: "purchase_price", + header: ({ column }) => , + cell: ({ row }) => { + const val = (row.original as any).purchase_price + return val != null ? `$${Number(val).toFixed(2)}` : "—" + }, + }, + { + accessorKey: "purchase_chart_of_account", + header: ({ column }) => , + cell: ({ row }) => (row.original as any).purchase_chart_of_account || "—", + }, + { + accessorKey: "is_active", + header: ({ column }) => , + cell: ({ row }) => { + const active = (row.original as any).is_active + return ( + + {active ? "Active" : "Inactive"} + + ) + }, + }, + actionsColumn(), + ]} + /> + ) +} diff --git a/apps/dashboard/app/(authenticated)/sales/credit-notes/[id]/documents/page.tsx b/apps/dashboard/app/(authenticated)/sales/credit-notes/[id]/documents/page.tsx new file mode 100644 index 0000000..4b4a11f --- /dev/null +++ b/apps/dashboard/app/(authenticated)/sales/credit-notes/[id]/documents/page.tsx @@ -0,0 +1,152 @@ +"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 { CreditNoteDocumentForm } from "@/modules/credit-notes/credit-note-document-form" + +type CreditNoteAttachment = { + id: number + name?: string + url?: string + created_at: string +} + +export default function CreditNoteDocumentsPage() { + const { id: creditNoteId } = useParams<{ id: string }>() + const api = useAuthApi() + const queryClient = useQueryClient() + const [dialogOpen, setDialogOpen] = useState(false) + + const queryKey = ["credit-note-attachments", creditNoteId] + + const { data, isLoading } = useQuery({ + queryKey, + queryFn: async () => { + const response = await api.creditNotes.show(creditNoteId) + return (response as any)?.data ?? response + }, + }) + + const deleteMutation = useMutation({ + mutationFn: (attachmentId: number) => + api.creditNotes.deleteAttachment(creditNoteId, { attachment_id: attachmentId }), + onSuccess: () => { + toast.success("Attachment deleted successfully.") + queryClient.invalidateQueries({ queryKey }) + }, + onError: () => { + toast.error("Failed to delete attachment.") + }, + }) + + const handleDelete = async (attachment: CreditNoteAttachment) => { + const confirmed = await confirm({ + title: "Delete Attachment", + description: `Are you sure you want to delete "${attachment.name || "this attachment"}"?`, + confirmLabel: "Delete", + variant: "destructive", + }) + if (confirmed) { + deleteMutation.mutate(attachment.id) + } + } + + const columns: ColumnDef[] = [ + { + accessorKey: "name", + header: ({ column }) => , + cell: ({ getValue, row }) => { + const name = getValue() + const url = row.original.url + if (url) { + return ( + + {name || "Attachment"} + + ) + } + return name || "—" + }, + }, + { + 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 attachments: CreditNoteAttachment[] = (data as any)?.attachments ?? [] + + return ( +
+
+ +
+ + + + {}} + isLoading={isLoading} + /> + + + + + + + Upload Attachment + + { + setDialogOpen(false) + queryClient.invalidateQueries({ queryKey }) + }} + /> + + +
+ ) +} diff --git a/apps/dashboard/app/(authenticated)/sales/credit-notes/[id]/layout.tsx b/apps/dashboard/app/(authenticated)/sales/credit-notes/[id]/layout.tsx new file mode 100644 index 0000000..ae0e1dc --- /dev/null +++ b/apps/dashboard/app/(authenticated)/sales/credit-notes/[id]/layout.tsx @@ -0,0 +1,43 @@ +import { DashboardDetailsPage } from '@/base/components/layout/dashboard' +import { getServerApi } from '@garage/api/server' +import { CreditNoteActions } from '@/modules/credit-notes/credit-note-actions' +import { CreditNoteProvider } from '@/modules/credit-notes/credit-note-context' +import { ReceiptTextIcon } from 'lucide-react' +import React from 'react' + +export default async function CreditNoteDetailLayout(props: { params: Promise<{ id: string }>, children: React.ReactNode }) { + const { id } = await props.params + const api = await getServerApi() + const creditNote = await api.creditNotes.show(id) + const data = (creditNote as any)?.data ?? creditNote + const title = data?.subject || data?.credit_invoice || 'Credit Note Details' + + return ( + + } + backHref="/sales/credit-notes" + actions={} + tabs={[ + { + href: `/sales/credit-notes/${id}`, + label: 'Details' + }, + { + href: `/sales/credit-notes/${id}/documents`, + label: 'Documents' + }, + { + href: `/sales/credit-notes/${id}/notes`, + label: 'Notes' + }, + ]} + > + {props.children} + + + ) +} diff --git a/apps/dashboard/app/(authenticated)/sales/credit-notes/[id]/notes/page.tsx b/apps/dashboard/app/(authenticated)/sales/credit-notes/[id]/notes/page.tsx new file mode 100644 index 0000000..96a567c --- /dev/null +++ b/apps/dashboard/app/(authenticated)/sales/credit-notes/[id]/notes/page.tsx @@ -0,0 +1,144 @@ +"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 { CreditNoteNoteForm } from "@/modules/credit-notes/credit-note-note-form" + +type CreditNoteNote = { + id: number + note?: string + created_at: string + updated_at: string +} + +export default function CreditNoteNotesPage() { + const { id: creditNoteId } = useParams<{ id: string }>() + const api = useAuthApi() + const queryClient = useQueryClient() + const [dialogOpen, setDialogOpen] = useState(false) + + const queryKey = ["credit-note-notes", creditNoteId] + + const { data, isLoading } = useQuery({ + queryKey, + queryFn: async () => { + const response = await api.creditNotes.show(creditNoteId) + return (response as any)?.data ?? response + }, + }) + + const deleteMutation = useMutation({ + mutationFn: (noteId: number) => + api.creditNotes.deleteInternalNote(creditNoteId, { note_id: noteId } as never), + onSuccess: () => { + toast.success("Note deleted successfully.") + queryClient.invalidateQueries({ queryKey }) + }, + onError: () => { + toast.error("Failed to delete note.") + }, + }) + + const handleDelete = async (note: CreditNoteNote) => { + const confirmed = await confirm({ + title: "Delete Note", + description: "Are you sure you want to delete this note?", + confirmLabel: "Delete", + variant: "destructive", + }) + if (confirmed) { + deleteMutation.mutate(note.id) + } + } + + const columns: ColumnDef[] = [ + { + accessorKey: "note", + 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 notes: CreditNoteNote[] = (data as any)?.internal_notes ?? [] + + return ( +
+
+ +
+ + + + {}} + isLoading={isLoading} + /> + + + + + + + Add Note + + { + setDialogOpen(false) + queryClient.invalidateQueries({ queryKey }) + }} + /> + + +
+ ) +} diff --git a/apps/dashboard/app/(authenticated)/sales/credit-notes/[id]/page.tsx b/apps/dashboard/app/(authenticated)/sales/credit-notes/[id]/page.tsx new file mode 100644 index 0000000..2bc8e6e --- /dev/null +++ b/apps/dashboard/app/(authenticated)/sales/credit-notes/[id]/page.tsx @@ -0,0 +1,15 @@ +import { getServerApi } from '@garage/api/server' +import { CreditNoteGeneralInfo } from '@/modules/credit-notes/credit-note-general-info' + +export default async function CreditNoteDetailPage(props: { params: Promise<{ id: string }> }) { + const { id } = await props.params + const api = await getServerApi() + const response = await api.creditNotes.show(id) + const creditNote = (response as any)?.data ?? response + + return ( +
+ +
+ ) +} diff --git a/apps/dashboard/app/(authenticated)/sales/credit-notes/page.tsx b/apps/dashboard/app/(authenticated)/sales/credit-notes/page.tsx new file mode 100644 index 0000000..e874c36 --- /dev/null +++ b/apps/dashboard/app/(authenticated)/sales/credit-notes/page.tsx @@ -0,0 +1,79 @@ +"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 { CreditNoteForm } from "@/modules/credit-notes/credit-note-form" +import { CREDIT_NOTE_ROUTES } from "@garage/api" +import type { CreditNotesClient } from "@garage/api" + +type CreditNoteItem = { + id: number + subject?: string + credit_invoice?: string + customer_id?: number + status?: string + date?: string + created_at?: string +} + +export default function CreditNotesPage() { + const router = useRouter() + + return ( + + pageTitle="Credit Notes" + routeKey={CREDIT_NOTE_ROUTES.INDEX} + getClient={(api) => api.creditNotes} + onRowClick={(row) => router.push(`/sales/credit-notes/${(row as any).id}`)} + headerProps={({ selectedItem, invalidateQuery }) => ({ + actions: ( + + {(resourceId) => ( + + )} + + ), + })} + columns={({ actionsColumn }) => [ + { + accessorKey: "subject", + header: ({ column }) => , + }, + { + accessorKey: "credit_invoice", + header: ({ column }) => , + }, + { + accessorKey: "status", + header: ({ column }) => , + cell: ({ row }) => { + const item = row.original as unknown as CreditNoteItem + const status = item.status + const colorMap: Record = { + draft: "text-muted-foreground", + open: "text-blue-600", + applied: "text-green-600", + void: "text-gray-400", + } + return ( + + {status ? status.charAt(0).toUpperCase() + status.slice(1) : "—"} + + ) + }, + }, + { + accessorKey: "date", + header: ({ column }) => , + }, + 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 b895f90..a1dc17f 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 @@ -1,8 +1,8 @@ "use client" -import { useParams } from "next/navigation" +import { useParams, useRouter } from "next/navigation" import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" -import { useState, useRef } from "react" +import { useState, useRef, useTransition } from "react" import { Plus, Trash2, FileIcon, ImageIcon, FileTextIcon } from "lucide-react" import { toast } from "sonner" @@ -26,6 +26,8 @@ export default function JobCardAttachmentsPage() { const { id: jobCardId } = useParams<{ id: string }>() const api = useAuthApi() const queryClient = useQueryClient() + const router = useRouter() + const [isRefreshing, startRefreshTransition] = useTransition() const fileInputRef = useRef(null) const [isUploading, setIsUploading] = useState(false) @@ -41,6 +43,7 @@ export default function JobCardAttachmentsPage() { onSuccess: () => { toast.success("Attachment deleted successfully.") queryClient.invalidateQueries({ queryKey }) + startRefreshTransition(() => router.refresh()) }, onError: () => { toast.error("Failed to delete attachment.") @@ -74,6 +77,7 @@ export default function JobCardAttachmentsPage() { try { await promise queryClient.invalidateQueries({ queryKey }) + startRefreshTransition(() => router.refresh()) } finally { setIsUploading(false) if (fileInputRef.current) { diff --git a/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/inspections/page.tsx b/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/inspections/page.tsx new file mode 100644 index 0000000..59d2388 --- /dev/null +++ b/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/inspections/page.tsx @@ -0,0 +1,77 @@ +"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 { InspectionForm } from "@/modules/inspections/inspection-form" +import { INSPECTION_ROUTES } from "@garage/api" +import type { InspectionsClient } from "@garage/api" +import { useRouter } from "next/navigation" +import { useJobCard } from "@/modules/job-cards/job-card-context" + +export default function InspectionsPage() { + const router = useRouter() + const jobCard = useJobCard() + return ( + + pageTitle="Inspections" + extraParams={{job_card_id: jobCard?.id}} + routeKey={INSPECTION_ROUTES.INDEX} + getClient={(api) => api.inspections} + onRowClick={(row) => router.push(`/sales/inspections/${(row as any).id}`)} + headerProps={({ selectedItem, invalidateQuery }) => ({ + actions: ( + + {(resourceId) => ( + + )} + + ), + })} + columns={({ actionsColumn }) => [ + { + accessorKey: "title", + header: ({ column }) => , + }, + { + accessorKey: "customer", + header: ({ column }) => , + cell: ({ row }) => { + const c = (row.original as any).customer + return c ? `${c.first_name ?? ""} ${c.last_name ?? ""}`.trim() : "—" + }, + }, + { + accessorKey: "vehicle", + header: ({ column }) => , + cell: ({ row }) => { + const v = (row.original as any).vehicle + return v ? `${v.make ?? ""} ${v.model ?? ""}`.trim() : "—" + }, + }, + { + accessorKey: "inspection_category", + header: ({ column }) => , + cell: ({ row }) => (row.original as any).inspection_category?.name ?? "—", + }, + { + accessorKey: "status", + header: ({ column }) => , + cell: ({ row }) => { + const status = (row.original as any).status + return ( + + {status ?? "—"} + + ) + }, + }, + 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 3aa1a35..5e65617 100644 --- a/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/layout.tsx +++ b/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/layout.tsx @@ -39,6 +39,10 @@ export default async function JobCardDetailLayout(props: { params: Promise<{ id: href: `/sales/job-cards/${id}/attachments`, label: `Attachments (${docs?.length || 0})` }, + { + href: `/sales/job-cards/${id}/inspections`, + label: `Inspections (${(jobCard as any)?.inspections_count || 0})` + }, { href: `/sales/job-cards/${id}/purchase-orders`, label: `Purchase Orders (${jobCard?.purchase_orders_count || 0})` diff --git a/apps/dashboard/app/(authenticated)/sales/job-cards/page.tsx b/apps/dashboard/app/(authenticated)/sales/job-cards/page.tsx index ce3b4d8..e83069e 100644 --- a/apps/dashboard/app/(authenticated)/sales/job-cards/page.tsx +++ b/apps/dashboard/app/(authenticated)/sales/job-cards/page.tsx @@ -11,8 +11,11 @@ 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' +import { useState, useEffect, useMemo } from 'react' import { formatDate, formatEnum, formatNumber } from '@/shared/utils/formatters' +import { useFilterParams } from '@/shared/hooks/use-filter-params' +import { FilterDrawer, FilterTrigger } from '@/shared/components/filter-drawer' +import { jobCardFilterConfig, JobCardFilterFields } from '@/modules/job-cards/job-card-filters' type JobCardItem = { id: number @@ -38,118 +41,137 @@ export default function JobCardsPage() { const [search, setSearch] = useState("") const [statusFilter, setStatusFilter] = useState("check_in") + const filter = useFilterParams(jobCardFilterConfig) + useEffect(() => { const timer = setTimeout(() => setSearch(searchInput), 400) return () => clearTimeout(timer) }, [searchInput]) - const extraParams: Record = {} - if (search) extraParams.search = search - if (statusFilter !== "all") extraParams.status = statusFilter + const extraParams = useMemo(() => { + const params: Record = { ...filter.appliedParams } + if (search) params.search = search + if (statusFilter !== "all") params.status = statusFilter + return params + }, [filter.appliedParams, search, statusFilter]) return ( - - - 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, {close}) => ( - { invalidateQuery(); close();}} - /> - )} - - ), - })} - columns={({ actionsColumn }) => [ - { - accessorKey: "title", - header: ({ column }) => , - cell: ({ row }) => { - const item = row.original as unknown as JobCardItem - return ( -
- - {item.title} -
- ) + <> + + 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, {close}) => ( + { invalidateQuery(); close();}} + /> + )} + +
+ ), + })} + columns={({ actionsColumn }) => [ + { + accessorKey: "title", + header: ({ column }) => , + cell: ({ row }) => { + const item = row.original as unknown as JobCardItem + return ( +
+ + {item.title} +
+ ) + }, }, - }, - - { - accessorKey: "order_number", - header: ({ column }) => , - - }, - { - accessorKey: "check_in_date", - header: ({ column }) => , - cell: ({ getValue }) => { - const val = getValue() - return formatDate(val) - } - }, - { - accessorKey: "vehicle_id", - header: ({ column }) => , - cell: ({ row }) => { - const item = row.original as unknown as JobCardItem - return item.km_in ? formatNumber(item.km_in) : "—" + { + accessorKey: "order_number", + header: ({ column }) => , }, - }, - { - accessorKey: "created_at", - header: ({ column }) => , - cell: ({ row }) => { - const item = row.original as unknown as JobCardItem - return formatDate(item.created_at) + { + accessorKey: "check_in_date", + header: ({ column }) => , + cell: ({ getValue }) => { + const val = getValue() + return formatDate(val) + } }, - }, - { - accessorKey: "status", - header: ({ column }) => , - cell: ({ row }) => { - const item = row.original as unknown as JobCardItem - return ( - - {formatEnum(item.status)} - - ) + { + accessorKey: "vehicle_id", + header: ({ column }) => , + cell: ({ row }) => { + const item = row.original as unknown as JobCardItem + return item.km_in ? formatNumber(item.km_in) : "—" + }, }, - }, - actionsColumn(), - ]} + { + accessorKey: "created_at", + header: ({ column }) => , + cell: ({ row }) => { + const item = row.original as unknown as JobCardItem + return formatDate(item.created_at) + }, + }, + { + accessorKey: "status", + header: ({ column }) => , + cell: ({ row }) => { + const item = row.original as unknown as JobCardItem + return ( + + {formatEnum(item.status)} + + ) + }, + }, + actionsColumn(), + ]} - tableHeader={ -
- - - All - {JobCardStatus.map((status) => ( - - {formatEnum(status)} - - ))} - - -
- - setSearchInput(e.target.value)} - className="pl-8" - /> + tableHeader={ +
+ + + All + {JobCardStatus.map((status) => ( + + {formatEnum(status)} + + ))} + + +
+ + setSearchInput(e.target.value)} + className="pl-8" + /> +
-
- } - /> + } + /> + + { if (!open) filter.close() }} + onSubmit={filter.onSubmit} + onReset={filter.reset} + activeFilterCount={filter.activeFilterCount} + title="Filter Job Cards" + > + + + ) } diff --git a/apps/dashboard/config/navGroups.tsx b/apps/dashboard/config/navGroups.tsx index 8052ec0..7a5a0ab 100644 --- a/apps/dashboard/config/navGroups.tsx +++ b/apps/dashboard/config/navGroups.tsx @@ -57,14 +57,14 @@ export const navGroups: NavGroup[] = [ }, { title: "Customer & Vehicles", - href: "/customer-vehicles", + href: "/sales/vehicles", icon: , }, - { - title: "Reports", - href: "/reports", - icon: , - }, + // { + // title: "Reports", + // href: "/reports", + // icon: , + // }, ], }, { @@ -107,35 +107,35 @@ export const navGroups: NavGroup[] = [ { title: "Vendor Credits", href: "/purchase/vendor-credit", icon: }, ], }, - { - title: "CRM", - href: "/crm", - icon: , - items: [ - { title: "Leads", href: "/crm/leads/list", icon: }, - { title: "Calls", href: "/crm/calls-follow-up/list", icon: }, - { title: "Tasks", href: "/crm/tasks/list", icon: }, - ], - }, - { - title: "Marketing", - href: "/marketing", - icon: , - items: [ - { title: "Service Reminders", href: "/marketing/service-reminder/list", icon: }, - { title: "Rating & Reviews", href: "/marketing/rating-review", icon: }, - { title: "Google Business Reviews", href: "/marketing/google-rating-review", icon: }, - ], - }, - { - title: "Accountants", - href: "/accountants", - icon: , - items: [ - { title: "Manual Journals", href: "/accountants/manual-journal", icon: }, - { title: "Chart of Accounts", href: "/accountants/chart-of-account", icon: }, - ], - }, + // { + // title: "CRM", + // href: "/crm", + // icon: , + // items: [ + // { title: "Leads", href: "/crm/leads/list", icon: }, + // { title: "Calls", href: "/crm/calls-follow-up/list", icon: }, + // { title: "Tasks", href: "/crm/tasks/list", icon: }, + // ], + // }, + // { + // title: "Marketing", + // href: "/marketing", + // icon: , + // items: [ + // { title: "Service Reminders", href: "/marketing/service-reminder/list", icon: }, + // { title: "Rating & Reviews", href: "/marketing/rating-review", icon: }, + // { title: "Google Business Reviews", href: "/marketing/google-rating-review", icon: }, + // ], + // }, + // { + // title: "Accountants", + // href: "/accountants", + // icon: , + // items: [ + // { title: "Manual Journals", href: "/accountants/manual-journal", icon: }, + // { title: "Chart of Accounts", href: "/accountants/chart-of-account", icon: }, + // ], + // }, { title: "Employees", href: "/productivity", diff --git a/apps/dashboard/modules/credit-notes/credit-note-actions.tsx b/apps/dashboard/modules/credit-notes/credit-note-actions.tsx new file mode 100644 index 0000000..52574c3 --- /dev/null +++ b/apps/dashboard/modules/credit-notes/credit-note-actions.tsx @@ -0,0 +1,50 @@ +"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" + +type CreditNoteActionsProps = { + creditNoteId: string +} + +export function CreditNoteActions({ creditNoteId }: CreditNoteActionsProps) { + const api = useAuthApi() + const router = useRouter() + + const handleEdit = () => { + router.push(`/sales/credit-notes/${creditNoteId}/edit`) + } + + const handleDelete = async () => { + await api.creditNotes.destroy(creditNoteId) + router.push("/sales/credit-notes") + } + + return ( + + + + + + + + Edit + + + + Delete + + + + ) +} diff --git a/apps/dashboard/modules/credit-notes/credit-note-context.tsx b/apps/dashboard/modules/credit-notes/credit-note-context.tsx new file mode 100644 index 0000000..b9b77ba --- /dev/null +++ b/apps/dashboard/modules/credit-notes/credit-note-context.tsx @@ -0,0 +1,28 @@ +"use client" + +import { createContext, useContext } from "react" + +type CreditNoteContextValue = { + id: string + label: string +} + +const CreditNoteContext = createContext(null) + +export function CreditNoteProvider({ + creditNote, + children, +}: { + creditNote: CreditNoteContextValue + children: React.ReactNode +}) { + return ( + + {children} + + ) +} + +export function useCreditNote() { + return useContext(CreditNoteContext) +} diff --git a/apps/dashboard/modules/credit-notes/credit-note-document-form.tsx b/apps/dashboard/modules/credit-notes/credit-note-document-form.tsx new file mode 100644 index 0000000..3adc6bd --- /dev/null +++ b/apps/dashboard/modules/credit-notes/credit-note-document-form.tsx @@ -0,0 +1,70 @@ +"use client" + +import { z } from "zod" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { Plus } from "lucide-react" +import { Button } from "@/shared/components/ui/button" +import { FieldGroup } from "@/shared/components/ui/field" +import { Rhform } from "@/shared/components/form" +import { toast } from "sonner" +import { useAuthApi } from "@/shared/useApi" + +const schema = z.object({ + attachments: z.instanceof(FileList).refine((files) => files.length > 0, "At least one file is required"), +}) + +type FormValues = z.infer + +type CreditNoteDocumentFormProps = { + creditNoteId: string + onSuccess?: () => void +} + +export function CreditNoteDocumentForm({ creditNoteId, onSuccess }: CreditNoteDocumentFormProps) { + const api = useAuthApi() + + const form = useForm({ + resolver: zodResolver(schema), + }) + + const handleSubmit = async (values: FormValues) => { + try { + const formData = new FormData() + Array.from(values.attachments).forEach((file) => { + formData.append("attachments[]", file) + }) + await api.creditNotes.addAttachment(creditNoteId, formData) + toast.success("Attachment uploaded") + form.reset() + onSuccess?.() + } catch { + toast.error("Failed to upload attachment") + } + } + + return ( + + +
+ + + {form.formState.errors.attachments && ( +

+ {form.formState.errors.attachments.message as string} +

+ )} +
+ +
+
+ ) +} diff --git a/apps/dashboard/modules/credit-notes/credit-note-form.tsx b/apps/dashboard/modules/credit-notes/credit-note-form.tsx new file mode 100644 index 0000000..a724fe5 --- /dev/null +++ b/apps/dashboard/modules/credit-notes/credit-note-form.tsx @@ -0,0 +1,182 @@ +"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 { + creditNoteFormSchema, + type CreditNoteFormValues, +} from "./credit-note.schema" +import { CREDIT_NOTE_ROUTES, DEPARTMENT_ROUTES } from "@garage/api" +import { RhfCustomerSelectField } from "@/modules/customers/rhf-customer-select-field" + +// ── 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 } + +// ── Constants ── + +const STATUS_OPTIONS = [ + { value: "draft", label: "Draft" }, + { value: "open", label: "Open" }, + { value: "applied", label: "Applied" }, + { value: "void", label: "Void" }, +] + +// ── Props ── + +export type CreditNoteFormProps = { + resourceId?: string | null + initialData?: unknown + onSuccess?: () => void +} + +// ── Default values ── + +const DEFAULT_VALUES: CreditNoteFormValues = { + subject: "", + customer: null, + department: null, + date: "", + status: "draft", + notes: "", +} + +// ── Mapping helpers ── + +function mapToFormValues(data: unknown): CreditNoteFormValues { + const d = (data as any)?.data ?? data ?? {} + + return { + subject: d.subject || "", + customer: toRelation(d.customer_id, d.customer_name), + department: toRelation(d.department_id, d.department_name), + date: d.date || "", + status: d.status || "draft", + notes: d.notes || "", + } +} + +function mapFormToPayload(values: CreditNoteFormValues) { + return { + subject: values.subject, + customer_id: toId(values.customer), + department_id: toId(values.department), + date: values.date || undefined, + status: values.status, + notes: values.notes || undefined, + } +} + +// ── Component ── + +export function CreditNoteForm({ resourceId, initialData, onSuccess }: CreditNoteFormProps) { + const api = useAuthApi() + + const { form, isEditing } = useResourceForm({ + schema: creditNoteFormSchema, + defaultValues: DEFAULT_VALUES, + resourceId: resourceId ?? null, + initialData, + queryKey: [CREDIT_NOTE_ROUTES.BY_ID, resourceId], + initialize: (id) => api.creditNotes.show(id), + mapToFormValues, + }) + + const { mutate, error, isPending } = useFormMutation(form, { + mutationFn: (values: CreditNoteFormValues) => { + const payload = mapFormToPayload(values) + const promise = (isEditing && resourceId + ? api.creditNotes.update(resourceId, payload) + : api.creditNotes.create(payload)) as Promise + toast.promise(promise, { + loading: isEditing ? "Updating credit note..." : "Creating credit note...", + success: isEditing ? "Credit note updated successfully" : "Credit note created successfully", + error: isEditing ? "Failed to update credit note" : "Failed to create credit note", + }) + return promise + }, + onSuccess: () => { + form.reset() + onSuccess?.() + }, + }) + + return ( + mutate(values)}> + {error && ( + + + + {isEditing ? "Failed to update credit note" : "Failed to create credit note"} + + {error.message} + + )} + + + + +
+ + + api.departments.list()} + mapOption={mapLookupOption} + {...STORE_OBJECT} + /> +
+ +
+ + + +
+ + + + +
+
+ ) +} diff --git a/apps/dashboard/modules/credit-notes/credit-note-general-info.tsx b/apps/dashboard/modules/credit-notes/credit-note-general-info.tsx new file mode 100644 index 0000000..b6b0a99 --- /dev/null +++ b/apps/dashboard/modules/credit-notes/credit-note-general-info.tsx @@ -0,0 +1,146 @@ +import { + FileText, + Calendar, + Hash, + Users, + Building2, + Clock, +} 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 CreditNoteData = { + id?: number + subject?: string + credit_invoice?: string + date?: string + status?: string + notes?: string + customer_id?: number + customer_name?: string + department_name?: string + department_id?: number + invoice_id?: number + created_at?: string + updated_at?: string +} + +type CreditNoteGeneralInfoProps = { + creditNote: CreditNoteData +} + +function InfoItem({ + icon: Icon, + label, + value, +}: { + icon: React.ComponentType<{ className?: string }> + label: string + value?: string | null +}) { + return ( +
+
+ +
+
+ {label} + + {value || } + +
+
+ ) +} + +const statusColorMap: Record = { + draft: "secondary", + open: "default", + applied: "default", + void: "outline", +} + +export function CreditNoteGeneralInfo({ creditNote }: CreditNoteGeneralInfoProps) { + return ( +
+ {/* Credit Note Details */} + + + + + Credit Note Details + + + +
+ {creditNote.subject && ( + {creditNote.subject} + )} + {creditNote.status && ( + + {creditNote.status.charAt(0).toUpperCase() + creditNote.status.slice(1)} + + )} +
+ +
+ + + +
+
+
+ + {/* Relations */} + + + + + Related Info + + + +
+ + +
+ {creditNote.notes && ( + <> + +
+ Notes +

{creditNote.notes}

+
+ + )} +
+
+
+ ) +} diff --git a/apps/dashboard/modules/credit-notes/credit-note-note-form.tsx b/apps/dashboard/modules/credit-notes/credit-note-note-form.tsx new file mode 100644 index 0000000..99e23c8 --- /dev/null +++ b/apps/dashboard/modules/credit-notes/credit-note-note-form.tsx @@ -0,0 +1,60 @@ +"use client" + +import { z } from "zod" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { Plus } from "lucide-react" +import { Button } from "@/shared/components/ui/button" +import { FieldGroup } from "@/shared/components/ui/field" +import { Rhform, RhfTextareaField } from "@/shared/components/form" +import { toast } from "sonner" +import { useAuthApi } from "@/shared/useApi" + +const schema = z.object({ + note: z.string().min(1, "Note is required"), +}) + +type FormValues = z.infer + +type CreditNoteNoteFormProps = { + creditNoteId: string + onSuccess?: () => void +} + +export function CreditNoteNoteForm({ creditNoteId, onSuccess }: CreditNoteNoteFormProps) { + const api = useAuthApi() + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { note: "" }, + }) + + const handleSubmit = async (values: FormValues) => { + try { + await api.creditNotes.addInternalNote(creditNoteId, { note: values.note }) + toast.success("Note added") + form.reset() + onSuccess?.() + } catch { + toast.error("Failed to add note") + } + } + + return ( + + + + + + + ) +} diff --git a/apps/dashboard/modules/credit-notes/credit-note.schema.ts b/apps/dashboard/modules/credit-notes/credit-note.schema.ts new file mode 100644 index 0000000..4b1c280 --- /dev/null +++ b/apps/dashboard/modules/credit-notes/credit-note.schema.ts @@ -0,0 +1,24 @@ +import { z } from "zod" + +const relationFieldSchema = z + .object({ value: z.string(), label: z.string() }) + .nullable() + +const creditNoteFormSchema = z.object({ + // ── Required fields ── + subject: z.string().min(1, "Subject is required"), + + // ── Relations ── + customer: relationFieldSchema, + department: relationFieldSchema, + + // ── Optional fields ── + date: z.string().optional(), + status: z.string().optional(), + notes: z.string().optional(), +}) + +type CreditNoteFormValues = z.infer + +export { creditNoteFormSchema, relationFieldSchema } +export type { CreditNoteFormValues } diff --git a/apps/dashboard/modules/expense-items/expense-item-form.tsx b/apps/dashboard/modules/expense-items/expense-item-form.tsx new file mode 100644 index 0000000..0a5b0c9 --- /dev/null +++ b/apps/dashboard/modules/expense-items/expense-item-form.tsx @@ -0,0 +1,206 @@ +"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, + RhfAsyncSelectField, + RhfCheckboxField, +} 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 { expenseItemFormSchema, type ExpenseItemFormValues } from "./expense-item.schema" +import { EXPENSE_ITEM_ROUTES, INVENTORY_CATEGORY_ROUTES } from "@garage/api" +import { InventoryCategoryCrudDialog } from "./inventory-category-crud-dialog" + +// ── Constants ── + +const ITEM_TYPE_OPTIONS = [ + { value: "Expense", label: "Expense" }, +] + +const mapLookupOption = (item: any) => ({ + value: String(item.id), + label: item.title ?? item.name ?? String(item.id), +}) + +const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label } + +// ── Props ── + +export type ExpenseItemFormProps = { + resourceId?: string | null + initialData?: unknown + onSuccess?: () => void +} + +// ── Default values ── + +const DEFAULT_VALUES: ExpenseItemFormValues = { + item_type: "Expense", + item_name: "", + category: null, + purchase_price: undefined, + purchase_chart_of_account: "", + purchase_information: true, + is_active: true, +} + +// ── Mapping helpers ── + +function mapToFormValues(data: unknown): ExpenseItemFormValues { + const d = (data as any)?.data ?? data ?? {} + + return { + item_type: d.item_type || "Expense", + item_name: d.item_name || "", + category: toRelation(d.category_id, d.category_title ?? d.category_name), + purchase_price: d.purchase_price ?? undefined, + purchase_chart_of_account: d.purchase_chart_of_account || "", + purchase_information: d.purchase_information ?? true, + is_active: d.is_active ?? true, + } +} + +function mapFormToPayload(values: ExpenseItemFormValues) { + return { + item_type: values.item_type, + item_name: values.item_name, + category_id: toId(values.category), + purchase_price: values.purchase_price, + purchase_chart_of_account: values.purchase_chart_of_account || undefined, + purchase_information: values.purchase_information, + is_active: values.is_active, + } +} + +// ── Component ── + +export function ExpenseItemForm({ resourceId, initialData, onSuccess }: ExpenseItemFormProps) { + const api = useAuthApi() + + const { form, isEditing } = useResourceForm({ + schema: expenseItemFormSchema, + defaultValues: DEFAULT_VALUES, + resourceId, + initialData, + initialize: (id) => api.expenseItems.show(id), + queryKey: [EXPENSE_ITEM_ROUTES.BY_ID, resourceId], + mapToFormValues, + }) + + const { mutate, error, isPending } = useFormMutation(form, { + mutationFn: (values: ExpenseItemFormValues) => { + const promise = isEditing && resourceId + ? api.expenseItems.update(resourceId, mapFormToPayload(values)) + : api.expenseItems.create(mapFormToPayload(values)) + toast.promise(promise, { + loading: isEditing ? "Updating expense item..." : "Creating expense item...", + success: isEditing ? "Expense item updated successfully" : "Expense item created successfully", + error: isEditing ? "Failed to update expense item" : "Failed to create expense item", + }) + return promise + }, + onSuccess: () => { + form.reset() + onSuccess?.() + }, + }) + + return ( + mutate(values)}> + {error && ( + + + + {isEditing ? "Failed to update expense item" : "Failed to create expense item"} + + {error.message} + + )} + + +
+ + +
+ +
+
+ Category + +
+ api.inventoryCategories.list()} + mapOption={mapLookupOption} + {...STORE_OBJECT} + /> +
+ +
+ + +
+ +
+ + +
+
+ +
+ +
+
+ ) +} diff --git a/apps/dashboard/modules/expense-items/expense-item.schema.ts b/apps/dashboard/modules/expense-items/expense-item.schema.ts new file mode 100644 index 0000000..3156be0 --- /dev/null +++ b/apps/dashboard/modules/expense-items/expense-item.schema.ts @@ -0,0 +1,17 @@ +import { z } from "zod" + +export const relationFieldSchema = z + .object({ value: z.string(), label: z.string() }) + .nullable() + +export const expenseItemFormSchema = z.object({ + item_type: z.string().min(1, "Item type is required"), + item_name: z.string().min(1, "Item name is required"), + category: relationFieldSchema, + purchase_price: z.coerce.number().min(0).optional(), + purchase_chart_of_account: z.string().optional(), + purchase_information: z.boolean().default(true), + is_active: z.boolean().default(true), +}) + +export type ExpenseItemFormValues = z.infer diff --git a/apps/dashboard/modules/expense-items/inventory-category-crud-dialog.tsx b/apps/dashboard/modules/expense-items/inventory-category-crud-dialog.tsx new file mode 100644 index 0000000..64a0a6a --- /dev/null +++ b/apps/dashboard/modules/expense-items/inventory-category-crud-dialog.tsx @@ -0,0 +1,33 @@ +"use client" + +import { CrudDialog } from "@/shared/components/crud-dialog" +import { ColumnHeader } from "@/shared/data-view/table-view" +import { useAuthApi } from "@/shared/useApi" +import { INVENTORY_CATEGORY_ROUTES } from "@garage/api" +import { InventoryCategoryForm } from "./inventory-category-form" + +export function InventoryCategoryCrudDialog() { + const api = useAuthApi() + + return ( + api.inventoryCategories} + resourceLabel="inventory category" + columns={() => [ + { + accessorKey: "title", + header: ({ column }) => , + }, + ]} + renderForm={({ resourceId, initialData, onSuccess }) => ( + + )} + /> + ) +} diff --git a/apps/dashboard/modules/expense-items/inventory-category-form.tsx b/apps/dashboard/modules/expense-items/inventory-category-form.tsx new file mode 100644 index 0000000..3eab15d --- /dev/null +++ b/apps/dashboard/modules/expense-items/inventory-category-form.tsx @@ -0,0 +1,80 @@ +"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 inventoryCategorySchema = z.object({ + title: z.string().min(1, "Title is required"), +}) + +type InventoryCategoryFormValues = z.infer + +type InventoryCategoryFormProps = { + resourceId?: string | null + initialData?: any + onSuccess?: () => void +} + +export function InventoryCategoryForm({ resourceId, initialData, onSuccess }: InventoryCategoryFormProps) { + const api = useAuthApi() + const isEditing = !!resourceId + + const form = useForm({ + resolver: zodResolver(inventoryCategorySchema), + defaultValues: { title: "" }, + }) + + useEffect(() => { + if (initialData) { + const d = initialData?.data ?? initialData + form.reset({ title: d.title ?? "" }) + } + }, [initialData, form]) + + const handleSubmit = async (values: InventoryCategoryFormValues) => { + try { + const promise = isEditing + ? api.inventoryCategories.update(resourceId!, { title: values.title }) + : api.inventoryCategories.create({ title: values.title }) + + 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/inspections/inspection-form.tsx b/apps/dashboard/modules/inspections/inspection-form.tsx index 598c6ef..6ad79b4 100644 --- a/apps/dashboard/modules/inspections/inspection-form.tsx +++ b/apps/dashboard/modules/inspections/inspection-form.tsx @@ -8,7 +8,11 @@ import { FieldGroup } from "@/shared/components/ui/field" import { Rhform, RhfTextField, + RhfTextareaField, + RhfSelectField, RhfAsyncSelectField, + RhfDateField, + RhfTimeField, } from "@/shared/components/form" import { toast } from "sonner" import { useAuthApi } from "@/shared/useApi" @@ -25,6 +29,9 @@ import { import { INSPECTION_ROUTES, DEPARTMENT_ROUTES, + JOB_CARD_ROUTES, + InspectionStatus, + RateType, } from "@garage/api" import { RhfVehicleSelectField } from "@/modules/vehicles/rhf-vehicle-select-field" import { RhfCustomerSelectField } from "@/modules/customers/rhf-customer-select-field" @@ -46,10 +53,22 @@ const DEFAULT_VALUES: InspectionFormValues = { department: null, inspection_category: null, employee: null, + job_card: null, + labor_rate: null, title: "", order_number: "", date: "", time: "", + status: "in_progress", + note: "", + description: "", + rate_type: "flat_rate", + quantity: 1, + rate: 0, + working_hours: 0, + labor_hours: 0, + tax: "", + chart_of_account: "", } // ── Mapping helpers ── @@ -63,10 +82,26 @@ function mapToFormValues(data: unknown): InspectionFormValues { department: toRelation(d.department_id, d.department?.name), inspection_category: toRelation(d.inspection_category_id, d.inspection_category?.name), employee: toRelation(d.employee_id, d.employee?.first_name ? `${d.employee.first_name} ${d.employee.last_name ?? ""}`.trim() : undefined), + job_card: d.job_card_id + ? { value: String(d.job_card_id), label: d.job_card?.title ?? d.job_card?.order_number ?? String(d.job_card_id) } + : null, + labor_rate: d.labor_rate_id + ? { value: String(d.labor_rate_id), label: d.labor_rate?.title ?? String(d.labor_rate_id) } + : null, title: d.title ?? "", order_number: d.order_number ?? "", - date: d.date ?? "", + date: d.date ? d.date.split("T")[0] : "", time: d.time ?? "", + status: d.status ?? "in_progress", + note: d.note ?? "", + description: d.description ?? "", + rate_type: d.rate_type ?? "flat_rate", + quantity: d.quantity != null ? Number(d.quantity) : 1, + rate: d.rate != null ? Number(d.rate) : 0, + working_hours: d.working_hours != null ? Number(d.working_hours) : 0, + labor_hours: d.labor_hours != null ? Number(d.labor_hours) : 0, + tax: d.tax ?? "", + chart_of_account: d.chart_of_account ?? "", } } @@ -77,14 +112,36 @@ function mapFormToPayload(values: InspectionFormValues) { department_id: toId(values.department), inspection_category_id: toId(values.inspection_category), employee_id: toId(values.employee), + job_card_id: toId(values.job_card) ?? null, + labor_rate_id: values.labor_rate ? Number(values.labor_rate.value) : undefined, title: values.title, order_number: values.order_number || undefined, date: values.date || undefined, time: values.time || undefined, + status: values.status || undefined, + note: values.note || undefined, + description: values.description || undefined, + rate_type: values.rate_type || undefined, + quantity: values.quantity ?? undefined, + rate: values.rate ?? undefined, + working_hours: values.working_hours ?? undefined, + labor_hours: values.labor_hours ?? undefined, + tax: values.tax || undefined, + chart_of_account: values.chart_of_account || undefined, } } -// ── Shared mapOption for async selects ── +// ── Select options ── + +const STATUS_OPTIONS = InspectionStatus.map((v) => ({ + value: v, + label: v.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()), +})) + +const RATE_TYPE_OPTIONS = RateType.map((v) => ({ + value: v, + label: v === "flat_rate" ? "Flat Rate" : "Hourly", +})) const mapLookupOption = (item: any) => ({ value: String(item.id), @@ -171,15 +228,74 @@ export function InspectionForm({ resourceId, initialData, onSuccess }: Inspectio />
- - - +
+ + api.jobCards.list()} + mapOption={(item: any) => ({ + value: String(item.id), + label: item.title ?? item.order_number ?? String(item.id), + })} + {...STORE_OBJECT} + /> +
- - + +
+
+ + +
+ +
+ + api.inventory.listLaborRates()} + mapOption={(item: any) => ({ + value: String(item.id), + label: item.title ?? String(item.id), + })} + {...STORE_OBJECT} + /> +
+ +
+ + + +
+ +
+ + +
+ + + + + + + + + + + + + ) +} + +export function FilterTrigger({ + onClick, + activeFilterCount, +}: { + onClick: () => void + activeFilterCount: number +}) { + return ( + + ) +} diff --git a/apps/dashboard/shared/hooks/use-filter-params.ts b/apps/dashboard/shared/hooks/use-filter-params.ts new file mode 100644 index 0000000..3d8482b --- /dev/null +++ b/apps/dashboard/shared/hooks/use-filter-params.ts @@ -0,0 +1,102 @@ +"use client" + +import { useState, useMemo, useCallback } from "react" +import { useForm, type DefaultValues, type FieldValues, type UseFormReturn } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { useQueryStates, useQueryState, parseAsInteger } from "nuqs" +import type { ZodType } from "zod" + +export type UseFilterParamsOptions = { + schema: ZodType + defaultValues: DefaultValues + paramsParsers: Record + mapParamsToFormValues: (params: Record) => Partial + mapFormValuesToParams: (values: TFormValues) => Record +} + +export type UseFilterParamsReturn = { + form: UseFormReturn + isOpen: boolean + open: () => void + close: () => void + onSubmit: (values: TFormValues) => void + reset: () => void + appliedParams: Record + activeFilterCount: number +} + +export function useFilterParams({ + schema, + defaultValues, + paramsParsers, + mapParamsToFormValues, + mapFormValuesToParams, +}: UseFilterParamsOptions): UseFilterParamsReturn { + const [params, setParams] = useQueryStates(paramsParsers) + const [, setPage] = useQueryState("page", parseAsInteger) + const [isOpen, setIsOpen] = useState(false) + + const form = useForm({ + resolver: zodResolver(schema) as any, + defaultValues, + }) + + const open = useCallback(() => { + const formValues = mapParamsToFormValues(params) + form.reset({ ...defaultValues, ...formValues } as any) + setIsOpen(true) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [params]) + + const close = useCallback(() => { + setIsOpen(false) + }, []) + + const onSubmit = useCallback((values: TFormValues) => { + const mapped = mapFormValuesToParams(values) + const fullParams: Record = {} + for (const key of Object.keys(paramsParsers)) { + fullParams[key] = mapped[key] ?? null + } + setParams(fullParams) + setPage(1) + setIsOpen(false) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mapFormValuesToParams, paramsParsers, setParams, setPage]) + + const reset = useCallback(() => { + const nullParams: Record = {} + for (const key of Object.keys(paramsParsers)) { + nullParams[key] = null + } + setParams(nullParams) + setPage(1) + form.reset(defaultValues) + setIsOpen(false) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [paramsParsers, setParams, setPage]) + + const appliedParams = useMemo(() => { + const result: Record = {} + for (const [key, value] of Object.entries(params)) { + if (value === null || value === undefined || value === "") continue + if (Array.isArray(value) && value.length === 0) continue + if (typeof value === "boolean" && value === false) continue + result[key] = value + } + return result + }, [params]) + + const activeFilterCount = Object.keys(appliedParams).length + + return { + form, + isOpen, + open, + close, + onSubmit, + reset, + appliedParams, + activeFilterCount, + } +} diff --git a/packages/api/src/api.ts b/packages/api/src/api.ts index dda9564..1544d11 100644 --- a/packages/api/src/api.ts +++ b/packages/api/src/api.ts @@ -54,6 +54,8 @@ import { ServiceGroupPartsClient } from "./clients/service-group-parts" import { SettingsClient } from "./clients/settings" import { ConfigurationsClient } from "./clients/configurations" import { AutoGenerateClient } from "./clients/auto-generate" +import { ExpenseItemsClient } from "./clients/expense-items" +import { InventoryCategoriesClient } from "./clients/inventory-categories" export function createApi(options?: ApiClientOptions) { return { @@ -112,6 +114,8 @@ export function createApi(options?: ApiClientOptions) { settings: new SettingsClient(undefined, options), configurations: new ConfigurationsClient(undefined, options), autoGenerate: new AutoGenerateClient(undefined, options), + expenseItems: new ExpenseItemsClient(undefined, options), + inventoryCategories: new InventoryCategoriesClient(undefined, options), } } diff --git a/packages/api/src/clients/expense-items.ts b/packages/api/src/clients/expense-items.ts new file mode 100644 index 0000000..5d7606c --- /dev/null +++ b/packages/api/src/clients/expense-items.ts @@ -0,0 +1,17 @@ +import { CrudClient } from "../infra/crud-client" +import type { ApiClientOptions } from "../infra/client" +import type { ApiPath } from "../infra/types" + +export const EXPENSE_ITEM_ROUTES = { + INDEX: "/api/expense-items", + BY_ID: "/api/expense-items/{id}", +} as const satisfies Record + +export class ExpenseItemsClient extends CrudClient< + typeof EXPENSE_ITEM_ROUTES.INDEX, + typeof EXPENSE_ITEM_ROUTES.BY_ID +> { + constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) { + super(baseUrl, defaultOptions, EXPENSE_ITEM_ROUTES.INDEX, EXPENSE_ITEM_ROUTES.BY_ID) + } +} diff --git a/packages/api/src/clients/index.ts b/packages/api/src/clients/index.ts index 4cc6f55..23d0183 100644 --- a/packages/api/src/clients/index.ts +++ b/packages/api/src/clients/index.ts @@ -53,3 +53,5 @@ export { ServiceGroupPartsClient, SERVICE_GROUP_PART_ROUTES } from "./service-gr export { SettingsClient, SETTINGS_ROUTES } from "./settings" export { ConfigurationsClient, CONFIGURATION_ROUTES } from "./configurations" export { AutoGenerateClient, AUTO_GENERATE_ROUTES } from "./auto-generate" +export { ExpenseItemsClient, EXPENSE_ITEM_ROUTES } from "./expense-items" +export { InventoryCategoriesClient, INVENTORY_CATEGORY_ROUTES } from "./inventory-categories" diff --git a/packages/api/src/clients/inventory-categories.ts b/packages/api/src/clients/inventory-categories.ts new file mode 100644 index 0000000..3f22b49 --- /dev/null +++ b/packages/api/src/clients/inventory-categories.ts @@ -0,0 +1,17 @@ +import { CrudClient } from "../infra/crud-client" +import type { ApiClientOptions } from "../infra/client" +import type { ApiPath } from "../infra/types" + +export const INVENTORY_CATEGORY_ROUTES = { + INDEX: "/api/inventory-categories", + BY_ID: "/api/inventory-categories/{id}", +} as const satisfies Record + +export class InventoryCategoriesClient extends CrudClient< + typeof INVENTORY_CATEGORY_ROUTES.INDEX, + typeof INVENTORY_CATEGORY_ROUTES.BY_ID +> { + constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) { + super(baseUrl, defaultOptions, INVENTORY_CATEGORY_ROUTES.INDEX, INVENTORY_CATEGORY_ROUTES.BY_ID) + } +} diff --git a/packages/api/src/contracts/enums.ts b/packages/api/src/contracts/enums.ts index b193e6d..9efad80 100644 --- a/packages/api/src/contracts/enums.ts +++ b/packages/api/src/contracts/enums.ts @@ -107,6 +107,10 @@ export type ServiceGroupRateType = (typeof ServiceGroupRateType)[number]; // ⚙️ Configurations export const SellRatesTaxInclusive = ['Tax Inclusive', 'Tax Exclusive'] as const; export type SellRatesTaxInclusive = (typeof SellRatesTaxInclusive)[number]; +// Tables + +export const Tables= ['bills', 'expenses', 'invoices', 'job_cards', 'credit_notes', 'vendor_credits'] as const; +export type Tables = (typeof Tables)[number]; export const GiveDiscounts = ['no', 'line_item_level', 'transaction_level'] as const; export type GiveDiscounts = (typeof GiveDiscounts)[number]; @@ -116,3 +120,4 @@ export type PurchaseRatesTaxInclusive = (typeof PurchaseRatesTaxInclusive)[numbe export const ReceiveDiscounts = ['no', 'line_item_level', 'transaction_level'] as const; export type ReceiveDiscounts = (typeof ReceiveDiscounts)[number]; +