38 Commits
Author SHA1 Message Date
humam fcbba6247d feat: add document sharing functionality across various modules
- Introduced ShareDocumentButton component for sharing documents.
- Added ShareDocumentDialog for email and WhatsApp sharing options.
- Integrated document sharing in estimates, invoices, inspections, job cards, bills, and purchase orders.
- Implemented useDocumentShare hook for handling share logic.
- Created DocumentShareClient for API interactions related to document sharing.
- Updated layouts and actions to include sharing options for relevant entities.
2026-05-14 12:21:01 +04:00
humam 05b55b5721 fix: add staleTime to job card expense, parts, and services queries; update job card general info and payments received components 2026-05-13 17:20:59 +04:00
humam 6b356d2855 fix estimate table and expense items 2026-05-13 15:55:40 +04:00
Najjar\NajjarV02andClaude Opus 4.7 cc7dc1bd17 fix: align frontend zod schemas with backend validation rules
Tightened frontend zod schemas where backend required fields the frontend marked optional, and matched enum/format constraints.

Schemas:
- vehicle-document: document_type required
- parts: shop_type, category, unit_type, department, sku required
- inspections: customer, vehicle, department, inspection_category, employee, order_number, date, time required
- appointments: service_writer required, cross-field to_time > from_time
- vendor-credits: vendor + vendor_credit_date required
- job-cards: documents[].document_type_id required
- expense-items: category, unit_type, department, sku required
- inventory-adjustments: reference_number, date required
- tasks: task_type, task_section required
- shop-timings: in_time, out_time enforce HH:MM:SS regex
- vendor-credit: subject + vendor + vendor_credit_date required

Settings:
- shop-type: shop_type + note required
- insurance-types: description field added, required
- make-and-models: shop_type required, year 1900..2100
- departments: assignment_type required enum (AssignmentType)
- company: website URL validation, latitude/longitude range, first_day_of_work enum, string max lengths
- configurations: 4 fields enum-typed (was raw strings)

Inline forms:
- job-card service/part/expense-item: relations required (part/service/expense_item/department)
- job-card recommendation: max 255
- vehicles/inline-forms/shop-type: shop_type + note required
- vehicles/inline-forms/body-type: shop_type_id pulled from parent form context, guard on submit
- vehicles/inline-forms/color: code field added, required
- services/inline-forms/department: assignment_type required enum
- tasks/task-section: arrangement required integer
- invoices/invoice-edit: status + discount enum-typed

Auth:
- login: password min 6 (was 8; backend allows 6)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:22:26 +04:00
mohammadkhayataandCopilot 349a458c3c feat: enhance invoice and job card forms with new fields, validation, and print functionality
Co-authored-by: Copilot <copilot@github.com>
2026-05-09 02:51:19 +03:00
mohammadkhayata 25c3125894 fix: update default values in service form and enhance validation for required fields 2026-05-08 16:02:51 +03:00
mohammadkhayataandCopilot 4b51ffe457 refactor: enhance employee and service forms with new fields and validation
Co-authored-by: Copilot <copilot@github.com>
2026-05-08 15:55:33 +03:00
mohammadkhayata b0361fcbfb fix build errors 2026-05-08 12:15:40 +03:00
mohammadkhayataandCopilot 38565298fc update forms
Co-authored-by: Copilot <copilot@github.com>
2026-05-07 21:02:15 +03:00
mohammadkhayataandCopilot dd32658500 fix bugs
Co-authored-by: Copilot <copilot@github.com>
2026-05-07 15:04:05 +03:00
mohammadkhayataandCopilot c7eb23dd3f fix bugs
Co-authored-by: Copilot <copilot@github.com>
2026-05-07 13:32:35 +03:00
mohammadkhayataandCopilot 4faafe6667 hide company for non company users
Co-authored-by: Copilot <copilot@github.com>
2026-05-07 11:34:07 +03:00
mohammadkhayataandCopilot 1fda8d8d7b fixes
Co-authored-by: Copilot <copilot@github.com>
2026-05-07 11:30:23 +03:00
mohammadkhayata cdd1cbc31a fix validation issues 2026-05-07 10:35:57 +03:00
mohammadkhayata c5d406792f add ignoreDeprecations option to tsconfig files 2026-05-01 11:23:23 +03:00
mohammadkhayataandCopilot 97364f4734 fix bugs phase 2 (excel ) , download sample feature
Co-authored-by: Copilot <copilot@github.com>
2026-05-01 11:04:38 +03:00
mohammadkhayataandCopilot e1ef6fa2ea fix many bugs
Co-authored-by: Copilot <copilot@github.com>
2026-04-30 19:03:31 +03:00
mohammadkhayataandCopilot 0c43c8edd2 add customer_id prop to RhfVehicleSelectField and update vehicle query
Co-authored-by: Copilot <copilot@github.com>
2026-04-27 11:32:35 +03:00
mohammadkhayata 615a0fda59 fix build 2026-04-24 12:28:30 +03:00
mohammadkhayataandCopilot 65964605e1 updates
Co-authored-by: Copilot <copilot@github.com>
2026-04-24 12:20:10 +03:00
mohammadkhayata 564e9e510f fix build 2026-04-23 15:15:21 +03:00
mohammadkhayata c0f78c6e18 updates 2026-04-23 14:38:41 +03:00
mohammadkhayata f17dd1486c fix invoice informations 2026-04-16 11:42:11 +03:00
mohammadkhayata 973149e974 fix build 2026-04-15 04:59:05 +03:00
mohammadkhayata 020ffccfd6 finish bills , po , jobcards relational fields creation 2026-04-13 18:48:15 +03:00
mohammadkhayata 3eb471a604 fix build errors 2026-04-09 15:23:36 +03:00
mohammadkhayata 29cc9c6f4e updates 2026-04-09 15:17:07 +03:00
mohammadkhayata c5f6d2f596 make and models 2026-04-09 11:06:56 +03:00
mohammadkhayata 17d8e1c90d fix build error 2026-04-08 15:04:01 +03:00
mohammadkhayata bb02b77be2 filters , order inspections , inventory items , credit notes 2026-04-08 14:38:16 +03:00
mohammadkhayata 90c84a0bda update 2026-04-07 15:05:03 +03:00
mohammadkhayata 38ef10da4d updates 2026-04-07 14:45:29 +03:00
mohammadkhayata c47f7c1b6a fix build error 2026-04-07 13:05:22 +03:00
mohammadkhayata 9fd7b61c5a fix build 2026-04-07 07:07:02 +03:00
mohammadkhayata 11db1e6941 updates 2026-04-07 06:32:40 +03:00
mohammadkhayata 24a44481a0 updates 2026-04-06 02:32:47 +03:00
mohammadkhayata 5f3d208158 updates 2026-04-01 15:34:50 +03:00
mohammadkhayata 13b56d4960 init 2026-03-27 16:20:46 +03:00
559 changed files with 143528 additions and 22784 deletions
BIN
View File
Binary file not shown.
+9
View File
@@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"Bash(pnpm --version)",
"Bash(grep -E \"\\\\.\\(tsx?|jsx?\\)$\")",
"Bash(grep -E \"\\\\.\\(tsx?\\)$\")"
]
}
}
Binary file not shown.
+2
View File
@@ -0,0 +1,2 @@
NIXPACKS_NODE_VERSION=22
NEXT_PUBLIC_API_URL=http://reparee.test
@@ -0,0 +1,76 @@
---
name: api-enums-reference
description: "Use the central API enums file as the source of truth for enum values in this project. Use when: adding enum fields, updating enum options, creating form selects, typing status/discount/rate fields, syncing backend enum changes, or avoiding duplicated hardcoded enum literals."
---
# API Enums Reference
Use this skill whenever work touches enum-like fields in API clients, schemas, forms, table filters, or page logic.
## Source of Truth
All shared enum values and enum union types must come from:
- packages/api/src/contracts/enums.ts
Do not recreate enum arrays inline when an equivalent enum already exists in this file.
## Rules
1. Reuse before creating.
Search and import from `@garage/api` exports (or local contracts path inside packages/api) before adding new literals.
2. Keep runtime and type together.
For every enum, keep this pattern in `enums.ts`:
```ts
export const ExampleStatus = ['a', 'b'] as const;
export type ExampleStatus = (typeof ExampleStatus)[number];
```
3. Preserve backend values exactly.
Enum string values are case- and space-sensitive; keep exact spelling from backend migrations/spec.
4. Avoid duplicate synonyms.
If two domains share the same canonical values, prefer reusing an existing enum unless domain separation is intentional.
5. Update centrally first.
When backend enum options change, update `packages/api/src/contracts/enums.ts` first, then update consuming UI/API code.
6. Prefer imports in forms and schemas.
Use central enums for select options and for typed payload/status fields instead of hardcoded string unions.
## Workflow
1. Identify the enum field and backend values.
2. Check `packages/api/src/contracts/enums.ts` for an existing enum.
3. If found, import and use it.
4. If missing, add a new const+type pair in `enums.ts`.
5. Update consumers to reference the central enum.
6. Verify there are no duplicated literal arrays for the same field.
## Examples
```ts
import { InvoiceStatus, type InvoiceStatus as InvoiceStatusType } from '@garage/api'
const statusOptions = InvoiceStatus
type Payload = {
status: InvoiceStatusType
}
```
```ts
import { DiscountType } from '@garage/api'
const discountOptions = DiscountType.map((value) => ({
label: value,
value,
}))
```
## Notes
- If a module needs a presentation-specific label, map from the central enum value instead of changing raw enum literals.
- If backend adds/removes values, keep API and dashboard aligned in the same change set.
+304
View File
@@ -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/<parent-module>/<resource>-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<typeof schema>
type Props = {
resourceId?: string | null
initialData?: any
onSuccess?: () => void
}
export function <Resource>Form({ resourceId, initialData, onSuccess }: Props) {
const api = useAuthApi()
const isEditing = !!resourceId
const form = useForm<FormValues>({
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.<resource>.update(resourceId!, { title: values.name } as any)
: api.<resource>.create({ title: values.name } as any)
toast.promise(promise, {
loading: isEditing ? "Updating..." : "Creating...",
success: isEditing ? "Updated successfully" : "Created successfully",
error: isEditing ? "Failed to update" : "Failed to create",
})
await promise
form.reset()
onSuccess?.()
} catch {
// toast already shown
}
}
return (
<Rhform form={form} onSubmit={handleSubmit}>
<FieldGroup>
<RhfTextField name="name" label="Name" placeholder="e.g. My Item" required />
<Button type="submit" disabled={form.formState.isSubmitting}>
{isEditing ? <Save className="h-4 w-4" /> : <Plus className="h-4 w-4" />}
{form.formState.isSubmitting
? (isEditing ? "Updating..." : "Creating...")
: (isEditing ? "Update" : "Create")}
</Button>
</FieldGroup>
</Rhform>
)
}
```
### Step 3: Create the CrudDialog Instance
Wire the form into a `CrudDialog` component.
**File**: `apps/dashboard/modules/<parent-module>/<resource>-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 { <RESOURCE>_ROUTES } from "@garage/api"
import { <Resource>Form } from "./<resource>-form"
export function <Resource>CrudDialog() {
const api = useAuthApi()
return (
<CrudDialog
title="<Resource Label>"
queryKey={[<RESOURCE>_ROUTES.INDEX]}
getClient={() => api.<resource>}
resourceLabel="<resource label>"
columns={() => [
{
accessorKey: "name",
header: ({ column }) => <ColumnHeader column={column} title="Name" />,
},
// Add more columns as needed
]}
renderForm={({ resourceId, initialData, onSuccess }) => (
<<Resource>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 { <Resource>CrudDialog } from "./<resource>-crud-dialog"
// Inside the form JSX:
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium"><Field Label></span>
<<Resource>CrudDialog />
</div>
<RhfAsyncSelectField
name="<field_name>"
label=""
placeholder="Select..."
queryKey={[<RESOURCE>_ROUTES.INDEX]}
listFn={() => api.<resource>.list()}
mapOption={mapLookupOption}
{...STORE_OBJECT}
/>
</div>
```
**Key**: Set `label=""` on the `RhfAsyncSelectField` since the label is rendered manually above with the config button.
## CrudDialog Props Reference
```typescript
type CrudDialogProps<TClient> = {
/** 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<void>
}) => ColumnDef<any>[]
/** 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/<parent>/<resource>-form.tsx` | `job-cards/insurance-type-form.tsx` |
| CrudDialog file | `modules/<parent>/<resource>-crud-dialog.tsx` | `job-cards/insurance-type-crud-dialog.tsx` |
| Form component | `<Resource>Form` | `InsuranceTypeForm` |
| CrudDialog component | `<Resource>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 { <RESOURCE>_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"
```
+53 -5
View File
@@ -74,6 +74,7 @@ Create `apps/dashboard/modules/<feature>/<feature>-form.tsx`:
5. Use `useFormMutation()` for submit with automatic validation error mapping 5. Use `useFormMutation()` for submit with automatic validation error mapping
6. Render with `Rhform` + `RhfTextField` / `RhfSelectField` / `RhfAsyncSelectField` etc. 6. Render with `Rhform` + `RhfTextField` / `RhfSelectField` / `RhfAsyncSelectField` etc.
7. Include error alert, submit button with loading/edit states 7. Include error alert, submit button with loading/edit states
8. For every required schema/backend field, pass `required` to the rendered form field component so required UI indicators and consistency are preserved
### Step 5: Create Page Component ### Step 5: Create Page Component
@@ -111,13 +112,44 @@ Create `apps/dashboard/app/(authenticated)/<section>/<feature>/page.tsx`:
| Form component | `<PascalFeature>Form` | `JobCardForm` | | Form component | `<PascalFeature>Form` | `JobCardForm` |
| Page component | `<PascalFeature>Page` (default export) | `JobCardsPage` | | Page component | `<PascalFeature>Page` (default export) | `JobCardsPage` |
### Relation Fields (Foreign Keys) ### Relation Fields — Choosing the Right Component
**Two patterns exist. Pick the right one before building any relational field.**
#### Simple FK → `RhfAsyncSelectField`
Use for single-record foreign keys within the same domain or to a simple lookup/reference entity.
Examples: invoice → customer, bill → vendor, part → category, service → unit type, job card → vehicle, PO → department, any `*_type` or `*_terms` relation.
- Stored in form as `{ value: string, label: string } | null` - Stored in form as `{ value: string, label: string } | null`
- Use `toRelation(id, name)` to convert API data → form value - Use `toRelation(id, name)` to convert API data → form value
- Use `toId(relation)` to convert form value → API payload - Use `toId(relation)` to convert form value → API payload
- Schema uses `relationFieldSchema = z.object({ value: z.string(), label: z.string() }).nullable()` - Schema uses `relationFieldSchema = z.object({ value: z.string(), label: z.string() }).nullable()`
- Rendered with `<RhfAsyncSelectField>` (fetches options via React Query)
#### Cross-Domain Line Items → `RhfResourceField` (resource-selector skill)
Use when the relationship involves a **join table with extra fields** (quantity, rate, description) or the related items are from a **fully separate domain with its own CRUD page** and multiple records can be linked.
Examples: adding parts to a bill, linking services to an invoice, attaching expense items to a PO.
**→ Read the `resource-selector` SKILL before implementing these fields.**
Ready-to-use selector fields (import directly — do not re-implement):
| Component | Import path | For |
|---|---|---|
| `PartsSelectorField` | `@/modules/parts/parts-selector-field` | Parts line items |
| `ServicesSelectorField` | `@/modules/services/services-selector-field` | Service line items |
| `ExpenseItemsSelectorField` | `@/modules/expense-items/expense-items-selector-field` | Expense line items |
```tsx
// Schema: use an array sub-schema with part_id / service_id / expense_id
part_items: z.array(z.object({ part_id: z.number(), title: z.string(), quantity: z.number(), rate: z.number(), description: z.string().optional() })).optional()
// Form: render inside <FieldGroup>
<PartsSelectorField<MyFormValues, "part_items"> name="part_items" />
```
### Async Select Pattern ### Async Select Pattern
@@ -144,8 +176,24 @@ const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) =
| `RhfTextareaField` | Multi-line text | | `RhfTextareaField` | Multi-line text |
| `RhfCheckboxField` | Boolean toggles | | `RhfCheckboxField` | Boolean toggles |
| `RhfSelectField` | Static option dropdowns | | `RhfSelectField` | Static option dropdowns |
| `RhfAsyncSelectField` | Server-fetched single-select combobox | | `RhfAsyncSelectField` | Server-fetched single FK combobox (same-domain relation) |
| `RhfAsyncMultiSelectField` | Server-fetched multi-select combobox | | `RhfAsyncMultiSelectField` | Server-fetched multi-select combobox |
| `RhfResourceField` | Cross-domain multi-select with join table extra fields (parts, services, expenses) — see resource-selector skill |
| `RhfDateField` | Date picker — see date-time-pickers skill |
| `RhfTimeField` | Time picker — see date-time-pickers skill |
### Required Prop Rule (Important)
When a field is required by schema or backend validation, always pass the `required` prop on the matching form component.
- Applies to all controls that support it, including custom selectors (for example `RhfCustomerSelectField`, `RhfVehicleSelectField`) and standard fields (`RhfTextField`, `RhfSelectField`, etc.)
- Do not rely on schema-only required validation; keep UI required indicator (`*`) in sync with validation requirements
```tsx
<RhfTextField name="title" label="Title" required />
<RhfCustomerSelectField name="customer" required />
<RhfVehicleSelectField name="vehicle" required customer_id={customer?.value} />
```
### Imports Cheat Sheet ### Imports Cheat Sheet
@@ -153,8 +201,8 @@ const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) =
// Page // Page
import { ResourcePage } from '@/shared/data-view/resource-page' import { ResourcePage } from '@/shared/data-view/resource-page'
import { ColumnHeader } from '@/shared/data-view/table-view' import { ColumnHeader } from '@/shared/data-view/table-view'
import type { <Resource>Client } from '@repo/api' import type { <Resource>Client } from '@garage/api'
import { <RESOURCE>_ROUTES } from '@repo/api' import { <RESOURCE>_ROUTES } from '@garage/api'
// Form // Form
import { Rhform, RhfTextField, RhfSelectField, RhfAsyncSelectField } from "@/shared/components/form" import { Rhform, RhfTextField, RhfSelectField, RhfAsyncSelectField } from "@/shared/components/form"
+13 -6
View File
@@ -33,15 +33,22 @@ import {
<feature>FormSchema, <feature>FormSchema,
type <Feature>FormValues, type <Feature>FormValues,
} from "./<feature>.schema" } from "./<feature>.schema"
import { <RESOURCE>_ROUTES } from "@repo/api" import { <RESOURCE>_ROUTES } from "@garage/api"
// ── Constants ── // ── Constants ──
// Static select options (if needed): // ALWAYS derive select options from API enums imported from "@garage/api".
// const STATUS_OPTIONS = [ // NEVER hardcode enum values inline — they must stay in sync with the backend.
// { value: "active", label: "Active" }, //
// { value: "inactive", label: "Inactive" }, // Pattern: import the enum array, then .map() to produce { value, label } pairs.
// ] //
// const STATUS_OPTIONS = SomeStatus.map((v) => ({
// value: v,
// label: v.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()),
// }))
//
// If no matching enum exists in packages/api/src/contracts/enums.ts, add it there first,
// then import and use it here.
// ── Props ── // ── Props ──
+5 -5
View File
@@ -14,8 +14,8 @@ Where `<section>` is the navigation section (e.g. `sales`, `inventory`, `hr`) an
import { ResourcePage } from '@/shared/data-view/resource-page' import { ResourcePage } from '@/shared/data-view/resource-page'
import { ColumnHeader } from '@/shared/data-view/table-view' import { ColumnHeader } from '@/shared/data-view/table-view'
import { <Feature>Form } from '@/modules/<feature>/<feature>-form' import { <Feature>Form } from '@/modules/<feature>/<feature>-form'
import { <RESOURCE>_ROUTES } from '@repo/api' import { <RESOURCE>_ROUTES } from '@garage/api'
import type { <Resource>Client } from '@repo/api' import type { <Resource>Client } from '@garage/api'
export default function <Features>Page() { export default function <Features>Page() {
return ( return (
@@ -119,8 +119,8 @@ actionsColumn(),
import { ResourcePage } from '@/shared/data-view/resource-page' import { ResourcePage } from '@/shared/data-view/resource-page'
import { ColumnHeader } from '@/shared/data-view/table-view' import { ColumnHeader } from '@/shared/data-view/table-view'
import { CustomerForm } from '@/modules/customers/customer-form' import { CustomerForm } from '@/modules/customers/customer-form'
import { CUSTOMER_ROUTES } from '@repo/api' import { CUSTOMER_ROUTES } from '@garage/api'
import type { CustomersClient } from '@repo/api' import type { CustomersClient } from '@garage/api'
import { Building2Icon, UserIcon } from 'lucide-react' import { Building2Icon, UserIcon } from 'lucide-react'
export default function CustomersPage() { export default function CustomersPage() {
@@ -184,7 +184,7 @@ import { DashboardHeader } from '@/base/components/layout/dashboard'
import DashboardPage from '@/base/components/layout/dashboard/dashboard-page' import DashboardPage from '@/base/components/layout/dashboard/dashboard-page'
import { ColumnHeader, DataTable, useDataTableQuery } from '@/shared/data-view/table-view' import { ColumnHeader, DataTable, useDataTableQuery } from '@/shared/data-view/table-view'
import { useAuthApi } from '@/shared/useApi' import { useAuthApi } from '@/shared/useApi'
import { <RESOURCE>_ROUTES } from '@repo/api' import { <RESOURCE>_ROUTES } from '@garage/api'
import type { ColumnDef } from '@tanstack/react-table' import type { ColumnDef } from '@tanstack/react-table'
const columns: ColumnDef<any>[] = [ const columns: ColumnDef<any>[] = [
+86
View File
@@ -0,0 +1,86 @@
---
name: date-time-pickers
description: "Use RhfDateField and RhfTimeField (shadcn Calendar/Popover-based) for all date and time inputs in forms. Use when: adding date fields, adding time fields, replacing `type=\"date\"` or `type=\"time\"` RhfTextField inputs, building any form that captures a date or time value."
---
# Date & Time Pickers
Always use the shadcn-based picker components for date and time fields. Never use `<RhfTextField type="date">` or `<RhfTextField type="time">`.
## Components
| Use For | Component | Import |
|---|---|---|
| Date fields (YYYY-MM-DD) | `RhfDateField` | `@/shared/components/form` |
| Time fields (HH:MM:SS) | `RhfTimeField` | `@/shared/components/form` |
## RhfDateField
Renders a shadcn Calendar inside a Popover. Value is a `string` in `"YYYY-MM-DD"` format.
```tsx
import { RhfDateField } from "@/shared/components/form"
<RhfDateField name="check_in_date" label="Check-in Date" />
```
**Schema type**: `z.string().optional()` (stores `"YYYY-MM-DD"`)
**Default value**: `""` for empty, or `new Date().toISOString().split("T")[0]` for today.
**`mapToFormValues`**: `d.check_in_date ? d.check_in_date.split("T")[0] : ""`
**`mapFormToPayload`**: `values.check_in_date || undefined`
## RhfTimeField
Renders an HH / MM / SS spinner inside a Popover. Value is a `string` in `"HH:MM:SS"` format.
```tsx
import { RhfTimeField } from "@/shared/components/form"
// With seconds (default)
<RhfTimeField name="check_in_time" label="Check-in Time" withSeconds />
// Without seconds
<RhfTimeField name="check_in_time" label="Check-in Time" />
```
**Props**:
- `withSeconds?: boolean` — show the SS spinner (default `true`)
- `placeholder?: string` — trigger button placeholder text
**Schema type**: `z.string().optional()` (stores `"HH:MM:SS"` or `"HH:MM"`)
**Default value for current time**:
```ts
(() => {
const n = new Date()
return `${String(n.getHours()).padStart(2,"0")}:${String(n.getMinutes()).padStart(2,"0")}:${String(n.getSeconds()).padStart(2,"0")}`
})()
```
**`mapToFormValues`** (API returns `"HH:MM"` or ISO datetime):
- If ISO: `d.check_in_time ? d.check_in_time.split("T")[1]?.slice(0, 8) ?? "" : ""`
- If plain time string: `d.check_in_time ?? ""`
**`mapFormToPayload`**: `values.check_in_time || undefined`
## Underlying Controls (non-RHF use)
If you need to use the pickers outside of RHF:
```tsx
import { DatePickerField, TimePickerField } from "@/shared/components/form"
<DatePickerField value={date} onChange={setDate} placeholder="Select date" />
<TimePickerField value={time} onChange={setTime} withSeconds />
```
## File Locations
- Control: `apps/dashboard/shared/components/form/controls/date-picker-field.tsx`
- Control: `apps/dashboard/shared/components/form/controls/time-picker-field.tsx`
- RHF wrapper: `apps/dashboard/shared/components/form/fields/rhf-date-field.tsx`
- RHF wrapper: `apps/dashboard/shared/components/form/fields/rhf-time-field.tsx`
- Exports: `apps/dashboard/shared/components/form/index.ts`
+95
View File
@@ -0,0 +1,95 @@
# Invoice Pattern Skill
This skill defines the standard for implementing invoice-like forms (Invoice, Estimate, Job Card, Purchase Order, Bill, etc.) in the carage-erp dashboard. All such forms must follow this pattern for layout, discount/tax handling, and summary calculation. The current Invoice form is the canonical reference.
---
## 1. Layout
- **Two-column grid:**
- **Main column (9/12):**
- Subject, invoice number/title
- Status select, Discount Type select
- Conditional Transaction Discount field
- Line item selectors: Parts, Services, Expense Items (with optional line-level discount)
- Notes, Terms & Conditions
- Submit button
- **Sidebar column (3/12):**
- Invoice date, Due date
- Customer, Vehicle selectors
- Tax select (see below)
- Department, Payment Terms, Invoice Sequence, Insurance fields
- Summary card (see below)
## 2. Discount Implementation
- **Discount Type:**
- Field: `discount` (enum: 'no', 'line_item_level', 'transaction_level')
- Select field in main column
- **Transaction-level Discount:**
- Field: `discount_amount` (number)
- Only shown when `discount === "transaction_level"`
- **Line-level Discount:**
- Each line item (parts, services, expenses) has `discount_amount` field
- Only shown when `discount === "line_item_level"`
- **Payload Mapping:**
- Only include `discount_amount` at transaction level if `discount === "transaction_level"`
- Only include per-line `discount_amount` if `discount === "line_item_level"`
## 3. Tax Type Implementation
- **Tax Field:**
- Field: `tax` (relationFieldSchema: `{ value: string, label: string } | null`)
- Uses `RhfAsyncSelectField` in sidebar
- `mapOption`: `{ value: String(item.id), label: `${item.title} (${item.rate}%)` }`
- The selected tax's rate is parsed from the label string in the summary (regex: `/\((\d+(?:\.\d+)?)%\)/`)
## 4. Summary Implementation
- **Summary Card:**
- Always rendered in the sidebar below the Details card
- Uses `InvoiceFormSummary` (form-aware adapter)
- `InvoiceFormSummary` flattens all line items, reads discount/tax fields, and passes them to `useDocumentTotals` hook
- `useDocumentTotals` (pure hook) computes subtotal, discounts, tax, and total
- `DocumentTotalsSummary` (pure display component) renders the summary
---
## Reference: Invoice Form
- See `apps/dashboard/modules/invoices/invoice-form.tsx` for the canonical implementation.
- Schema: `apps/dashboard/modules/invoices/invoice.schema.ts`
- Summary logic: `apps/dashboard/modules/invoices/invoice-form-summary.tsx`, `shared/hooks/use-document-totals.ts`, `shared/components/document-totals-summary.tsx`
---
## Required for All Invoice-like Forms
- Follow the above layout and field conventions
- Use the same discount/tax logic and summary calculation
- Use the same field and payload mapping patterns
- Use the same summary component structure
---
## Example: Tax Field (in sidebar)
```tsx
<RhfAsyncSelectField
name="tax"
label="Tax"
placeholder="Select tax rate"
queryKey={[TAX_ROUTES.INDEX]}
listFn={() => api.taxes.list()}
mapOption={(item: any) => ({
value: String(item.id),
label: item.title ? `${item.title} (${item.rate}%)` : `#${item.id}`,
})}
{...STORE_OBJECT}
/>
```
## Example: Discount Type Select (in main column)
```tsx
<RhfSelectField name="discount" label="Discount Type" options={DISCOUNT_OPTIONS} />
```
## Example: Summary Card
```tsx
<div className="mt-4">
<InvoiceFormSummary />
</div>
```
@@ -0,0 +1,583 @@
---
name: resource-details-page
description: "Create resource details pages with tabbed layouts, context providers, and sub-pages for the carage-erp dashboard. Use when: building a details/show page for a resource, adding tabs to a resource detail view, creating a nested [id] layout, scaffolding sub-pages (owners, documents, estimates), implementing resource context providers, or adding actions menus to detail headers."
---
# Resource Details Page
Create fully structured resource details pages with tabbed navigation, shared context, and reusable sub-page patterns. This skill covers: layout → context provider → general-info component → actions component → tab sub-pages.
## When to Use
- User asks to create a details/show page for a resource (e.g. "create a customer details page")
- User asks to add tabs to a resource (e.g. "add an invoices tab to the vehicle page")
- User asks to scaffold a `[id]` layout with nested pages
- User wants sub-pages that share parent resource data (e.g. vehicle → estimates)
- User wants an actions dropdown (edit/delete) on a resource header
## Reference Implementation
The canonical implementation is the **vehicle details page** at:
```
app/(authenticated)/sales/vehicles/[id]/
├── layout.tsx ← Server component: fetches resource, renders DashboardDetailsPage
├── page.tsx ← Details tab: server component, read-only info display
├── owners/page.tsx ← Sub-tab: client component, manual DataTable
├── documents/page.tsx ← Sub-tab: client component, manual DataTable + upload
├── estimates/page.tsx ← Sub-tab: client component, ResourcePage with extraParams
```
Module files at:
```
modules/vehicles/
├── vehicle.schema.ts
├── vehicle-form.tsx
├── vehicle-general-info.tsx ← Read-only info cards for Details tab
├── vehicle-actions.tsx ← Dropdown menu (Edit/Delete)
├── vehicle-context.tsx ← React context for sharing resource data to sub-pages
└── vehicle-document-form.tsx ← Document-specific form (optional)
```
## Procedure
### Step 1: Create the Resource Context
Create `modules/<resource>/<resource>-context.tsx`:
This context allows child tab pages to access the parent resource's identity (id + display label) without re-fetching. This is essential for sub-pages that create related records (e.g. creating an estimate pre-populated with the parent vehicle).
```tsx
"use client"
import { createContext, useContext } from "react"
type <Resource>ContextValue = {
id: string
label: string
}
const <Resource>Context = createContext<<Resource>ContextValue | null>(null)
export function <Resource>Provider({
<resource>,
children,
}: {
<resource>: <Resource>ContextValue
children: React.ReactNode
}) {
return (
<<Resource>Context.Provider value={<resource>}>
{children}
</<Resource>Context.Provider>
)
}
export function use<Resource>() {
return useContext(<Resource>Context)
}
```
**Key rules:**
- Always a `"use client"` component (context requires client React)
- Keep the context value minimal — only `id` and `label`
- `label` is a human-readable display string built from the resource's key fields
- The hook returns `null` when used outside the provider (no throw — graceful fallback)
- Do NOT store the full resource object — only identity data needed by sub-pages
### Step 2: Create the Actions Component
Create `modules/<resource>/<resource>-actions.tsx`:
This is a dropdown menu rendered in the `DashboardDetailsPage` header for resource-level actions.
```tsx
"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 <Resource>ActionsProps = {
<resource>Id: string
}
export function <Resource>Actions({ <resource>Id }: <Resource>ActionsProps) {
const api = useAuthApi()
const router = useRouter()
const handleEdit = () => {
router.push(`/<section>/<resources>/${<resource>Id}/edit`)
}
const handleDelete = async () => {
await api.<resources>.destroy(<resource>Id)
router.push("/<section>/<resources>")
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<Ellipsis className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleEdit}>
<Pencil className="size-4" />
Edit
</DropdownMenuItem>
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
<Trash2 className="size-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
```
**Key rules:**
- Always a `"use client"` component
- Accepts `<resource>Id: string` as the only required prop
- Uses `useAuthApi()` for API calls, `useRouter()` for navigation
- `handleDelete` navigates back to the list page after deletion
- Add confirmation dialog for destructive actions when appropriate
### Step 3: Create the General Info Component
Create `modules/<resource>/<resource>-general-info.tsx`:
A read-only display component for the **Details tab** (the default `page.tsx`). Uses Card-based layout with icon-labeled fields.
```tsx
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
import { Separator } from "@/shared/components/ui/separator"
// Import relevant icons from lucide-react
type <Resource>Data = {
// Type all fields from the API response
id?: number
name?: string
// ...
}
type <Resource>GeneralInfoProps = {
<resource>: <Resource>Data
}
function InfoItem({
icon: Icon,
label,
value,
}: {
icon: React.ComponentType<{ className?: string }>
label: string
value?: string | null
}) {
return (
<div className="flex items-start gap-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
<Icon className="size-4" />
</div>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">{label}</span>
<span className="text-sm font-medium">
{value || <span className="text-muted-foreground">—</span>}
</span>
</div>
</div>
)
}
export function <Resource>GeneralInfo({ <resource> }: <Resource>GeneralInfoProps) {
return (
<div className="grid gap-6 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
{/* Icon */} Section Title
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4">
<div className="grid gap-4 sm:grid-cols-2">
<InfoItem icon={...} label="Field Name" value={<resource>.field} />
{/* More InfoItems */}
</div>
</CardContent>
</Card>
{/* More Cards for other field groups */}
</div>
)
}
```
**Key rules:**
- This is a **server component** (no `"use client"`) — it receives data as props
- Group related fields into separate `Card` sections
- Use the `InfoItem` helper for consistent icon+label+value layout
- Show `"—"` dash for missing/null values
- Use `Badge` for status-like fields, `Separator` between groups
### Step 4: Create the Layout (Server Component)
Create `app/(authenticated)/<section>/<resources>/[id]/layout.tsx`:
This is the **central orchestrator** — it fetches the resource, renders the tabbed shell, and wraps children with the context provider.
```tsx
import { DashboardDetailsPage } from '@/base/components/layout/dashboard'
import { getServerApi } from '@garage/api/server'
import { <Resource>Actions } from '@/modules/<resources>/<resource>-actions'
import { <Resource>Provider } from '@/modules/<resources>/<resource>-context'
import React from 'react'
export default async function layout(props: {
params: Promise<{ id: string }>
children: React.ReactNode
}) {
const { id } = await props.params
const api = await getServerApi()
const <resource> = await api.<resources>.getById(id)
const title = /* Build display title from resource fields */ ''
const <resource>Label = /* Build label for context, e.g. combining key fields */ ''
return (
<>
<<Resource>Provider <resource>={{ id, label: <resource>Label || title }}>
<DashboardDetailsPage
className="p-0 lg:p-0"
avatarSrc={<resource>.data?.image_url || ""}
title={title}
description={/* subtitle text */}
backHref="/<section>/<resources>"
actions={<<Resource>Actions <resource>Id={id} />}
tabs={[
{ href: `/<section>/<resources>/${id}`, label: 'Details' },
{ href: `/<section>/<resources>/${id}/<sub-tab>`, label: '<Sub Tab>' },
// More tabs...
]}
>
{props.children}
</DashboardDetailsPage>
</<Resource>Provider>
</>
)
}
```
**Key rules:**
- **Server component** (no `"use client"`) — uses `await` for data fetching
- Params are `Promise<{ id: string }>` in Next.js 15+ (use `await props.params`)
- Uses `getServerApi()` from `@garage/api/server` for server-side API calls
- `<Resource>Provider` wraps the entire `DashboardDetailsPage` so all tab children can access context
- `backHref` points to the resource list page
- `tabs[0].href` is always the base `/<section>/<resources>/${id}` (Details tab)
- `className="p-0 lg:p-0"` removes default padding — sub-pages handle their own spacing
**`DashboardDetailsPage` props:**
| Prop | Type | Purpose |
|---|---|---|
| `title` | `string` | Primary heading in the header |
| `description` | `string?` | Subtitle text below the title |
| `avatarSrc` | `string?` | Avatar image URL |
| `avatarFallback` | `string?` | Fallback text for avatar (e.g. initials) |
| `icon` | `ReactNode?` | Icon instead of avatar |
| `actions` | `ReactNode?` | Action buttons in header (right side) |
| `backHref` | `string?` | Back navigation URL |
| `tabs` | `{ href, label }[]?` | Route-based tab navigation |
| `className` | `string?` | Additional classes for content area |
| `children` | `ReactNode` | Active tab content (Next.js route children) |
### Step 5: Create the Details Tab (Default Page)
Create `app/(authenticated)/<section>/<resources>/[id]/page.tsx`:
```tsx
import { getServerApi } from '@garage/api/server'
import { <Resource>GeneralInfo } from '@/modules/<resources>/<resource>-general-info'
import DashboardPage from '@/base/components/layout/dashboard/dashboard-page'
export default async function page(props: { params: Promise<{ id: string }> }) {
const { id } = await props.params
const api = await getServerApi()
const <resource> = await api.<resources>.getById(id)
if (!<resource>.data) {
return <div className="text-muted-foreground"><Resource> not found.</div>
}
return (
<DashboardPage header={null}>
<<Resource>GeneralInfo <resource>={<resource>.data} />
</DashboardPage>
)
}
```
**Key rules:**
- **Server component** — fetches resource data on the server
- Uses `DashboardPage` wrapper with `header={null}` (the layout already has the header)
- Renders the general-info component with the full resource data
- Shows a fallback message if the resource is not found
### Step 6: Create Sub-Tab Pages
Choose the appropriate pattern based on the sub-tab's requirements:
#### Pattern A: ResourcePage with `extraParams` (Preferred for CRUD sub-tabs)
Use when the sub-tab needs full CRUD for a **related** resource filtered by the parent ID.
```tsx
"use client"
import { use } from "react"
import { ResourcePage } from '@/shared/data-view/resource-page'
import { ColumnHeader } from '@/shared/data-view/table-view'
import FormDialog from '@/shared/components/form-dialog'
import { <Related>Form } from '@/modules/<related>/<related>-form'
import { <RELATED>_ROUTES } from '@garage/api'
import type { <Related>Client } from '@garage/api'
import { use<Resource> } from '@/modules/<resource>/<resource>-context'
export default function <Resource><Related>Page({ params }: { params: Promise<{ id: string }> }) {
const { id: <resource>Id } = use(params)
const <resource> = use<Resource>()
return (
<ResourcePage<<Related>Client>
toolbar={({ invalidateQuery, selectedItem, closeDialog }) => (
<FormDialog title="<Related>">
{(resourceId) => (
<<Related>Form
resourceId={resourceId}
initialData={{
<resource>: <resource>
? { value: <resource>.id, label: <resource>.label }
: null,
}}
onSuccess={() => {
closeDialog();
invalidateQuery();
}}
/>
)}
</FormDialog>
)}
pageTitle="<Resource> <Related>s"
routeKey={<RELATED>_ROUTES.INDEX}
getClient={(api) => api.<related>s}
extraParams={{ <resource>_id: <resource>Id }}
header={null}
columns={({ actionsColumn }) => [
// Column definitions...
actionsColumn(),
]}
/>
)
}
```
**Key rules:**
- `"use client"` — uses hooks (`use()`, `use<Resource>()`)
- Uses `use(params)` (React 19) to unwrap the params Promise
- Consumes the parent context via `use<Resource>()` to pre-populate the form's relation field
- `extraParams={{ <resource>_id: <resource>Id }}` filters the list to only show related records
- `header={null}` — the layout already provides the header
- Pass `initialData` with the parent resource as a relation field `{ value, label }`
#### Pattern B: Manual DataTable (For custom interactions)
Use when the sub-tab needs non-standard behavior (link/unlink, file uploads, custom mutations).
```tsx
"use client"
import { useParams } from "next/navigation"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { useAuthApi } from "@/shared/useApi"
import { DataTable, ColumnHeader } from "@/shared/data-view/table-view"
import DashboardPage from "@/base/components/layout/dashboard/dashboard-page"
export default function <Resource><Tab>Page() {
const { id: <resource>Id } = useParams<{ id: string }>()
const api = useAuthApi()
const queryClient = useQueryClient()
const queryKey = ["<resource>-<tab>", <resource>Id]
const { data, isLoading } = useQuery({
queryKey,
queryFn: () => api.<resources>.get<Tab>(<resource>Id),
})
// Custom mutations (link, unlink, upload, etc.)
return (
<DashboardPage header={null}>
<DataTable
columns={columns}
data={data?.data ?? []}
isLoading={isLoading}
/>
</DashboardPage>
)
}
```
**Key rules:**
- Use `useParams()` or `use(params)` to get the parent resource ID
- Custom query key includes the parent resource ID
- Use `DashboardPage header={null}` as wrapper
- Implement custom mutations with `useMutation` + `useQueryClient` for cache invalidation
## API Integration
### Server-Side (Layout + Details Tab)
```tsx
import { getServerApi } from '@garage/api/server'
// In an async server component:
const api = await getServerApi()
const resource = await api.<resources>.getById(id)
```
- `getServerApi()` reads auth cookies server-side — no token passing needed
- Returns typed responses based on OpenAPI schema
- Used in `layout.tsx` and the default `page.tsx` (Details tab)
### Client-Side (Sub-Tab Pages)
```tsx
import { useAuthApi } from "@/shared/useApi"
// In a client component:
const api = useAuthApi()
// Then use with React Query:
useQuery({ queryFn: () => api.<resources>.list() })
```
- `useAuthApi()` returns the API client with the auth token from the store
- Always use with React Query (`useQuery`, `useMutation`) for caching and state management
- `ResourcePage` handles this internally — only needed for manual DataTable pages
### Client Types
API clients live in `packages/api/src/clients/<resource>.ts`. Each exports:
- `<RESOURCE>_ROUTES` — route constants (`INDEX`, `BY_ID`, etc.)
- `<Resource>Client` — class with typed methods
These are re-exported from `@garage/api` for use in the dashboard.
## Component Reusability
### Reusable Across Resources
| Component | Location | Reuse Pattern |
|---|---|---|
| `DashboardDetailsPage` | `@/base/components/layout/dashboard` | Used by every `[id]/layout.tsx` |
| `DashboardPage` | `@/base/components/layout/dashboard` | Used by every tab `page.tsx` with `header={null}` |
| `ResourcePage` | `@/shared/data-view/resource-page` | Used by CRUD sub-tabs with `extraParams` |
| `FormDialog` | `@/shared/components/form-dialog` | Used for create/edit dialogs in sub-tabs |
| `DataTable` | `@/shared/data-view/table-view` | Used for custom sub-tabs (non-CRUD) |
| `ColumnHeader` | `@/shared/data-view/table-view` | Used in all column definitions |
| `InfoItem` pattern | Copy per resource | Icon+label+value display for general-info |
### Resource-Specific (Create Per Resource)
| Component | Location | Purpose |
|---|---|---|
| `<resource>-context.tsx` | `modules/<resource>/` | Context provider for parent resource identity |
| `<resource>-actions.tsx` | `modules/<resource>/` | Header dropdown menu (edit/delete) |
| `<resource>-general-info.tsx` | `modules/<resource>/` | Read-only details display for Details tab |
| `<resource>-form.tsx` | `modules/<resource>/` | CRUD form (shared with list page creation) |
| `<resource>.schema.ts` | `modules/<resource>/` | Zod schema (shared with form) |
## File Structure Convention
```
app/(authenticated)/<section>/<resources>/[id]/
├── layout.tsx ← Server: fetch + DashboardDetailsPage + Provider
├── page.tsx ← Server: Details tab (GeneralInfo)
├── <sub-tab-1>/page.tsx ← Client: ResourcePage or manual DataTable
├── <sub-tab-2>/page.tsx ← Client: ResourcePage or manual DataTable
└── ...
modules/<resources>/
├── <resource>.schema.ts ← Zod form schema
├── <resource>-form.tsx ← CRUD form component
├── <resource>-context.tsx ← Context provider (id + label)
├── <resource>-actions.tsx ← Actions dropdown
├── <resource>-general-info.tsx ← Read-only info cards
└── <optional-sub-forms>/ ← e.g. document upload forms
```
## Naming Conventions
| Item | Pattern | Example |
|---|---|---|
| Layout file | `app/.../<resources>/[id]/layout.tsx` | `vehicles/[id]/layout.tsx` |
| Details page | `app/.../<resources>/[id]/page.tsx` | `vehicles/[id]/page.tsx` |
| Sub-tab page | `app/.../<resources>/[id]/<tab>/page.tsx` | `vehicles/[id]/estimates/page.tsx` |
| Context file | `modules/<resources>/<resource>-context.tsx` | `vehicles/vehicle-context.tsx` |
| Context type | `<Resource>ContextValue` | `VehicleContextValue` |
| Provider | `<Resource>Provider` | `VehicleProvider` |
| Hook | `use<Resource>()` | `useVehicle()` |
| Actions file | `modules/<resources>/<resource>-actions.tsx` | `vehicles/vehicle-actions.tsx` |
| Actions component | `<Resource>Actions` | `VehicleActions` |
| General info file | `modules/<resources>/<resource>-general-info.tsx` | `vehicles/vehicle-general-info.tsx` |
| General info component | `<Resource>GeneralInfo` | `VehicleGeneralInfo` |
## Imports Cheat Sheet
```tsx
// Layout
import { DashboardDetailsPage } from '@/base/components/layout/dashboard'
import { getServerApi } from '@garage/api/server'
import { <Resource>Actions } from '@/modules/<resources>/<resource>-actions'
import { <Resource>Provider } from '@/modules/<resources>/<resource>-context'
// Details tab
import { getServerApi } from '@garage/api/server'
import { <Resource>GeneralInfo } from '@/modules/<resources>/<resource>-general-info'
import DashboardPage from '@/base/components/layout/dashboard/dashboard-page'
// Sub-tab (ResourcePage pattern)
import { ResourcePage } from '@/shared/data-view/resource-page'
import { ColumnHeader } from '@/shared/data-view/table-view'
import FormDialog from '@/shared/components/form-dialog'
import { use<Resource> } from '@/modules/<resources>/<resource>-context'
import type { <Related>Client } from '@garage/api'
import { <RELATED>_ROUTES } from '@garage/api'
// Sub-tab (Manual DataTable pattern)
import { useAuthApi } from "@/shared/useApi"
import { DataTable, ColumnHeader } from "@/shared/data-view/table-view"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import DashboardPage from '@/base/components/layout/dashboard/dashboard-page'
// Context provider
import { createContext, useContext } from "react"
// Actions
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"
// General Info
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
import { Separator } from "@/shared/components/ui/separator"
```
+258
View File
@@ -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│
│ <Fields /> │ │ ...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/<feature>/<feature>-filters.tsx` | Feature-specific schema, parsers, mappers, fields |
## Procedure
### Step 1: Create the filter config file
Create `modules/<feature>/<feature>-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<T>` 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<typeof filterSchema>
// ── 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<string, any>): Partial<FilterValues> {
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<string, any> {
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<FilterValues> = {
schema: filterSchema,
defaultValues,
paramsParsers,
mapParamsToFormValues,
mapFormValuesToParams,
}
// ── Filter Fields Component ──
export function FeatureFilterFields() {
const api = useAuthApi()
return (
<>
<RhfAsyncSelectField
name="some_relation_id"
label="Some Relation"
queryKey={[SOME_ROUTES.INDEX]}
listFn={() => api.someResource.list( )}
mapOption={(item: any) => ({ value: String(item.id), label: item.name })}
placeholder="All"
/>
<RhfDateField name="some_date" label="Some Date" />
<RhfCheckboxField name="some_flag" label="Some Flag" />
</>
)
}
```
### 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/<feature>/<feature>-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<string, unknown> = { ...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 (
<>
<ResourcePage
extraParams={extraParams}
headerProps={({ ... }) => ({
title: "Feature",
actions: (
<div className="flex items-center gap-2">
<FilterTrigger
onClick={filter.open}
activeFilterCount={filter.activeFilterCount}
/>
{/* other actions like FormDialog */}
</div>
),
})}
{/* columns, tableHeader, etc. */}
/>
<FilterDrawer
form={filter.form}
isOpen={filter.isOpen}
onOpenChange={(open) => { if (!open) filter.close() }}
onSubmit={filter.onSubmit}
onReset={filter.reset}
activeFilterCount={filter.activeFilterCount}
title="Filter Features"
>
<FeatureFilterFields />
</FilterDrawer>
</>
)
}
```
## 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 `<Separator />` and section labels:
```tsx
<Separator />
<p className="text-sm font-medium text-muted-foreground pt-2">Section Name</p>
```
### 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.
+315
View File
@@ -0,0 +1,315 @@
---
name: resource-selector
description: "Use RhfResourceField and ResourceSelectorDialog for cross-domain relational line-item fields in forms. Use when: linking parts/services/expense-items to invoices, bills, purchase orders, or estimates; adding line-item tables with qty/rate/description; picking multiple records from a separate domain entity. Do NOT use for simple FK relations like 'customer type' or 'payment terms' — use RhfAsyncSelectField for those."
---
# Resource Selector (Cross-Domain Relations)
Use `RhfResourceField` + `ResourceSelectorDialog` when a form needs to pick **one or more records from a different domain** and store them as an **editable line-item array with extra data** (quantity, rate, description, etc.).
## Decision: RhfResourceField vs RhfAsyncSelectField
| Situation | Use |
|---|---|
| Linking parts / services / expense items to a bill, invoice, PO, or estimate | **`RhfResourceField`** |
| Picking multiple cross-domain items that need per-row extra fields (qty, rate) | **`RhfResourceField`** |
| Simple FK: invoice → customer, PO → vendor, job card → vehicle | **`RhfAsyncSelectField`** |
| Simple FK: insurance type, payment terms, category, department, unit type | **`RhfAsyncSelectField`** |
| Single-record lookup within the same domain | **`RhfAsyncSelectField`** |
**Rule:** If the relationship involves a **join table with extra fields** (quantity, rate, description, chart_of_account) or the related items are from a **fully separate domain with its own CRUD page**, use `RhfResourceField`. Otherwise use `RhfAsyncSelectField`.
### Domain Boundary Examples
```
Cross-domain (use RhfResourceField): Simple FK (use RhfAsyncSelectField):
Invoice ←→ Parts Invoice → Customer
Invoice ←→ Services Invoice → Payment Terms
Bill ←→ Parts Bill → Vendor
Bill ←→ Services Bill → Department
Bill ←→ Expense Items Part → Category
PO ←→ Parts Service → Unit Type
Estimate ←→ Services Job Card → Vehicle
```
## Architecture
| File | Purpose |
|---|---|
| `shared/components/resource-selector/resource-selector-dialog.tsx` | Generic dialog wrapping `CrudResource` with multi-row selection + Confirm/Cancel |
| `shared/components/resource-selector/rhf-resource-field.tsx` | RHF field: Card with trigger → opens dialog → renders items via `renderItems` callback |
| `modules/<domain>/<domain>-columns.tsx` | Shared column definitions reused by both the domain page and selector dialogs |
| `modules/<domain>/<domain>-selector-field.tsx` | Self-contained wrapper for a specific domain (e.g. `PartsSelectorField`) |
## Procedure
### Step 1: Create / Reuse Domain Columns File
If the domain already has a page, extract shared columns to `modules/<domain>/<domain>-columns.tsx`. This avoids duplication between the list page and the selector dialog.
```tsx
// modules/parts/parts-columns.tsx
import { ColumnHeader } from "@/shared/data-view/table-view"
import type { ColumnDef } from "@tanstack/react-table"
export const partColumns = {
title: {
accessorKey: "title",
header: ({ column }) => <ColumnHeader column={column} title="Name" />,
cell: ({ row }) => <span className="font-medium">{(row.original as any).title || "—"}</span>,
},
purchasePrice: {
accessorKey: "purchase_price",
header: ({ column }) => <ColumnHeader column={column} title="Purchase Price" />,
cell: ({ row }) => {
const val = (row.original as any).purchase_price
return val != null ? `$${Number(val).toFixed(2)}` : "—"
},
},
// ... more columns
} satisfies Record<string, ColumnDef<any, any>>
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// CRITICAL: use `satisfies Record<string, ColumnDef<any, any>>`
// NOT `as ColumnDef<unknown>` — the latter causes type errors in ResourceSelectorDialog
```
### Step 2: Define the Item Shape in the Schema
Add a sub-schema for the line items inside the form schema:
```tsx
// In <resource>.schema.ts
const billPartItemSchema = z.object({
part_id: z.number(),
title: z.string(), // display only, not sent to API
quantity: z.number().min(1),
rate: z.number().min(0),
description: z.string().optional(),
})
const billFormSchema = z.object({
// ... other fields
part_items: z.array(billPartItemSchema).optional(),
service_items: z.array(billServiceItemSchema).optional(),
})
```
### Step 3: Create the Selector Field Component
Create `modules/<domain>/<domain>-selector-field.tsx`:
```tsx
"use client"
import type { FieldValues, FieldPath } from "react-hook-form"
import { Trash2 } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import {
Table, TableHeader, TableBody,
TableHead, TableRow, TableCell,
} from "@/shared/components/ui/table"
import { RhfResourceField } from "@/shared/components/resource-selector"
import { partColumns } from "./parts-columns"
import { PARTS_ROUTES } from "@garage/api"
import type { PartsClient } from "@garage/api"
type PartItem = {
part_id: number
title: string
quantity: number
rate: number
description?: string
}
type Constraint = PartItem[] | undefined
export type PartsSelectorFieldProps<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
> = {
name: TName & (TValues[TName] extends Constraint ? TName : never)
label?: string
triggerLabel?: string
}
export function PartsSelectorField<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
>({ name, label = "Parts", triggerLabel = "Add Parts" }: PartsSelectorFieldProps<TValues, TName>) {
return (
<RhfResourceField<TValues, TName, PartsClient>
name={name}
label={label}
triggerLabel={triggerLabel}
itemKey="part_id" // deduplicate by this key when re-selecting
dialogProps={{
title: "Select Parts",
crudProps: {
routeKey: PARTS_ROUTES.INDEX,
getClient: (api) => api.parts,
columns: [
partColumns.title,
partColumns.partNumber,
partColumns.purchasePrice,
partColumns.stock,
],
},
}}
mapSelected={(row) => {
const r = row as any
return {
part_id: r.id,
title: r.title || "",
quantity: 1,
rate: Number(r.purchase_price) || 0,
description: "",
} as any
}}
renderItems={(items, { remove, update }) => (
<Table>
<TableHeader>
<TableRow>
<TableHead>Part</TableHead>
<TableHead className="w-24">Qty</TableHead>
<TableHead className="w-28">Rate</TableHead>
<TableHead>Description</TableHead>
<TableHead className="w-12" />
</TableRow>
</TableHeader>
<TableBody>
{((items as PartItem[] | undefined) ?? []).map((item, index) => (
<TableRow key={item.part_id}>
<TableCell className="font-medium">{item.title}</TableCell>
<TableCell>
<Input type="number" min={1} value={item.quantity}
onChange={(e) => update(index, { ...item, quantity: Number(e.target.value) || 1 } as any)}
className="h-8 w-20" />
</TableCell>
<TableCell>
<Input type="number" min={0} step={0.01} value={item.rate}
onChange={(e) => update(index, { ...item, rate: Number(e.target.value) || 0 } as any)}
className="h-8 w-24" />
</TableCell>
<TableCell>
<Input value={item.description ?? ""}
onChange={(e) => update(index, { ...item, description: e.target.value } as any)}
placeholder="Optional description" className="h-8" />
</TableCell>
<TableCell>
<Button type="button" variant="ghost" size="icon-sm" onClick={() => remove(index)}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
/>
)
}
```
### Step 4: Wire into the Form
```tsx
// In <resource>-form.tsx
// 1. Import
import { PartsSelectorField } from "@/modules/parts/parts-selector-field"
import { ServicesSelectorField } from "@/modules/services/services-selector-field"
// 2. Add to DEFAULT_VALUES
const DEFAULT_VALUES = {
// ...
part_items: [],
service_items: [],
}
// 3. Map from API → form (in mapToFormValues)
part_items: (d.parts ?? []).map((p: any) => ({
part_id: p.part_id ?? p.id,
title: p.part?.title ?? p.title ?? "",
quantity: Number(p.quantity) || 1,
rate: Number(p.rate) || 0,
description: p.description ?? "",
})),
// 4. Map from form → API payload (in mapFormToPayload)
part_items: (values.part_items ?? []).map((item) => ({
part_id: item.part_id,
quantity: item.quantity,
rate: item.rate,
description: item.description || undefined,
})),
// 5. Render inside <FieldGroup>
<PartsSelectorField<MyFormValues, "part_items"> name="part_items" />
<ServicesSelectorField<MyFormValues, "service_items"> name="service_items" />
```
## Existing Selector Fields (Ready to Use)
These are already built. Import and use directly — do NOT re-implement from scratch:
| Component | File | `itemKey` | Notes |
|---|---|---|---|
| `PartsSelectorField` | `modules/parts/parts-selector-field.tsx` | `part_id` | qty + rate + description editable |
| `ServicesSelectorField` | `modules/services/services-selector-field.tsx` | `service_id` | qty + rate + description editable |
| `ExpenseItemsSelectorField` | `modules/expense-items/expense-items-selector-field.tsx` | `expense_id` | qty + rate + description editable |
**Usage:**
```tsx
import { PartsSelectorField } from "@/modules/parts/parts-selector-field"
import { ServicesSelectorField } from "@/modules/services/services-selector-field"
import { ExpenseItemsSelectorField } from "@/modules/expense-items/expense-items-selector-field"
// Inside <FieldGroup>:
<PartsSelectorField<MyFormValues, "part_items"> name="part_items" />
<ServicesSelectorField<MyFormValues, "service_items"> name="service_items" />
<ExpenseItemsSelectorField<MyFormValues, "expense_items"> name="expense_items" />
```
## API Props Reference
### `RhfResourceField`
| Prop | Type | Required | Description |
|---|---|---|---|
| `name` | `FieldPath<TValues>` | ✅ | RHF field name; value is an array |
| `label` | `string` | ✅ | Card heading |
| `triggerLabel` | `string` | | Button label (defaults to `label`) |
| `mapSelected` | `(row) => ItemShape` | ✅ | Maps a selected table row to the form item shape |
| `renderItems` | `(items, helpers) => ReactNode` | ✅ | Renders the editable item table |
| `dialogProps` | object | ✅ | Config for the selector dialog (see below) |
| `itemKey` | `string` | | Field used for deduplication. Defaults to `"id"` |
### `dialogProps`
```ts
{
title: string // Dialog heading
crudProps: {
routeKey: ApiPath // e.g. PARTS_ROUTES.INDEX
getClient: (api) => client // e.g. (api) => api.parts
columns: ColumnDef[] // Subset of domain columns to show
}
rowKey?: keyof Item // Selection identity key (defaults to "id")
}
```
### `renderItems` helpers
| Helper | Signature | Description |
|---|---|---|
| `remove` | `(index: number) => void` | Remove item at index |
| `update` | `(index: number, item) => void` | Replace item at index |
| `replace` | `(items) => void` | Replace entire array |
## Common Pitfalls
- **Column type error**: Always use `satisfies Record<string, ColumnDef<any, any>>` on the columns object. Using `as ColumnDef<unknown>` per-entry causes type errors in the selector dialog.
- **itemKey mismatch**: The `itemKey` on `RhfResourceField` must match the field name in your item shape (e.g. `"part_id"`, not `"id"`).
- **Pre-fill in create mode**: `useResourceForm` uses a shallow spread for initial data. Pre-fill the field with already-shaped items (not raw API shape) to avoid empty arrays. See the `mapToFormValues` pattern above.
- **Selector field generic parameters**: Always pass both type params when using a selector field in a form: `<PartsSelectorField<MyFormValues, "part_items"> name="part_items" />`.
+73
View File
@@ -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 }) => <Badge>{formatEnum(row.original.status)}</Badge>
// Table cell — number
cell: ({ row }) => formatNumber(row.original.km_in)
// Table cell — currency
cell: ({ row }) => formatCurrency(row.original.total_amount)
// Detail page field
<p>{formatDateTime(jobCard.updated_at)}</p>
```
+1 -1
View File
@@ -12,7 +12,7 @@ npx create-turbo@latest
## What's inside? ## What's inside?
This Turborepo includes the following packages/apps: et This Turborepo includes the following packages/apps:
### Apps and Packages ### Apps and Packages
Submodule apps/dashboard deleted from 078e5383ba
+39
View File
@@ -0,0 +1,39 @@
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
/cypress/videos
/cypress/screenshots
/cypress/downloads
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# env files
.env*.local
# typescript
*.tsbuildinfo
next-env.d.ts
+7
View File
@@ -0,0 +1,7 @@
dist/
node_modules/
.next/
.turbo/
coverage/
pnpm-lock.yaml
.pnpm-store/
+11
View File
@@ -0,0 +1,11 @@
{
"endOfLine": "lf",
"semi": false,
"singleQuote": false,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 80,
"plugins": ["prettier-plugin-tailwindcss"],
"tailwindStylesheet": "app/globals.css",
"tailwindFunctions": ["cn", "cva"]
}
+11
View File
@@ -0,0 +1,11 @@
{
"servers": {
"shadcn": {
"command": "npx",
"args": [
"shadcn@latest",
"mcp"
]
}
}
}
+21
View File
@@ -0,0 +1,21 @@
# Next.js template
This is a Next.js template with shadcn/ui.
## Adding components
To add components to your app, run the following command:
```bash
npx shadcn@latest add button
```
This will place the ui components in the `components` directory.
## Using components
To use the components in your app, import them as follows:
```tsx
import { Button } from "@/components/ui/button";
```
+12
View File
@@ -0,0 +1,12 @@
import { LoginForm } from "@/modules/auth/login-form";
export default function Page() {
return (
<div className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
<div className="w-full max-w-sm">
<LoginForm />
</div>
</div>
)
}
@@ -0,0 +1,29 @@
import { DashboardDetailsPage } from "@/base/components/layout/dashboard"
import { AppointmentActions } from "@/modules/appointments/appointment-actions"
import { AppointmentProvider } from "@/modules/appointments/appointment-context"
import { CalendarCheck2 } from "lucide-react"
import React from "react"
export default async function layout(props: {
params: Promise<{ id: string }>
children: React.ReactNode
}) {
const { id } = await props.params
return (
<AppointmentProvider appointment={{ id, label: `Appointment #${id}` }}>
<DashboardDetailsPage
className="p-0 lg:p-0"
icon={<CalendarCheck2 className="size-5" />}
title={`Appointment #${id}`}
backHref="/calendar/appointment/list"
actions={<AppointmentActions appointmentId={id} />}
tabs={[
{ href: `/calendar/appointment/${id}`, label: "Details" },
]}
>
{props.children}
</DashboardDetailsPage>
</AppointmentProvider>
)
}
@@ -0,0 +1,49 @@
"use client"
import { use } from "react"
import { useQuery } from "@tanstack/react-query"
import { useAuthApi } from "@/shared/useApi"
import { APPOINTMENT_ROUTES } from "@garage/api"
import { AppointmentGeneralInfo } from "@/modules/appointments/appointment-general-info"
import DashboardPage from "@/base/components/layout/dashboard/dashboard-page"
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function AppointmentDetailsPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params)
const api = useAuthApi()
const { data, isLoading } = useQuery({
queryKey: [APPOINTMENT_ROUTES.INDEX, "detail", id],
queryFn: async () => {
const response = await api.appointments.list()
const items = (response as any)?.data ?? []
return items.find((item: any) => String(item.id) === id) ?? null
},
})
if (isLoading) {
return (
<DashboardPage header={null}>
<div className="grid gap-6 md:grid-cols-2 p-6">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-48 rounded-xl" />
))}
</div>
</DashboardPage>
)
}
if (!data) {
return (
<DashboardPage header={null}>
<p className="text-muted-foreground p-6">Appointment not found.</p>
</DashboardPage>
)
}
return (
<DashboardPage header={null}>
<AppointmentGeneralInfo appointment={data} />
</DashboardPage>
)
}
@@ -0,0 +1,112 @@
"use client"
import { useRouter } from "next/navigation"
import { ResourcePage } from "@/shared/data-view/resource-page"
import { ColumnHeader } from "@/shared/data-view/table-view"
import FormDialog from "@/shared/components/form-dialog"
import { AppointmentForm } from "@/modules/appointments/appointment-form"
import { APPOINTMENT_ROUTES } from "@garage/api"
import type { AppointmentsClient } from "@garage/api"
import { CalendarCheck2Icon, ClipboardListIcon, ClockIcon, ExternalLinkIcon } from "lucide-react"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
const STATUS_COLORS: Record<string, string> = {
requested: "bg-yellow-100 text-yellow-800",
confirmed: "bg-blue-100 text-blue-800",
in_progress: "bg-purple-100 text-purple-800",
completed: "bg-green-100 text-green-800",
cancelled: "bg-red-100 text-red-800",
}
export default function AppointmentsPage() {
const router = useRouter()
return (
<ResourcePage<AppointmentsClient>
pageTitle="Appointments"
routeKey={APPOINTMENT_ROUTES.INDEX}
getClient={(api) => api.appointments}
onRowClick={(row) => router.push(`/calendar/appointment/${(row as any).id}`)}
headerProps={({ selectedItem, invalidateQuery }) => ({
actions: (
<FormDialog title="Appointment">
{(resourceId) => (
<AppointmentForm
resourceId={resourceId}
initialData={selectedItem}
onSuccess={invalidateQuery}
/>
)}
</FormDialog>
),
})}
columns={({ actionsColumn }) => [
{
accessorKey: "title",
header: ({ column }) => <ColumnHeader column={column} title="Title" />,
cell: ({ row }) => (
<div className="flex items-center gap-2">
<CalendarCheck2Icon className="size-4 text-muted-foreground" />
<span>{(row.original as any).title}</span>
</div>
),
},
{
accessorKey: "date",
header: ({ column }) => <ColumnHeader column={column} title="Date" />,
},
{
accessorKey: "from_time",
header: ({ column }) => <ColumnHeader column={column} title="Time" />,
cell: ({ row }) => {
const r = row.original as any
return (
<div className="flex items-center gap-1">
<ClockIcon className="size-3 text-muted-foreground" />
<span>{r.from_time} – {r.to_time}</span>
</div>
)
},
},
{
accessorKey: "status",
header: ({ column }) => <ColumnHeader column={column} title="Status" />,
cell: ({ row }) => {
const status = (row.original as any).status
const colorClass = STATUS_COLORS[status] ?? "bg-gray-100 text-gray-800"
return (
<Badge className={colorClass}>
{status?.replace("_", " ") ?? "—"}
</Badge>
)
},
},
{
id: "job_card",
header: ({ column }) => <ColumnHeader column={column} title="Job Card" />,
cell: ({ row }) => {
const jobCardId = (row.original as any).job_card_id
if (!jobCardId) return <span className="text-muted-foreground">—</span>
return (
<Button
variant="outline"
size="sm"
className="h-7 gap-1.5"
onClick={(e) => {
e.stopPropagation()
router.push(`/sales/job-cards/${jobCardId}`)
}}
>
<ClipboardListIcon className="size-3" />
#{jobCardId}
<ExternalLinkIcon className="size-3" />
</Button>
)
},
},
actionsColumn(),
]}
/>
)
}
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation"
export default function CalendarsPage() {
return redirect("/calendar/appointment/list")
}
@@ -0,0 +1,292 @@
"use client"
import { useState, useRef } from "react"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { Paperclip, Plus, Trash2, FileIcon, ImageIcon, FileTextIcon } from "lucide-react"
import { toast } from "sonner"
import { ResourcePage } from "@/shared/data-view/resource-page"
import { ColumnHeader } from "@/shared/data-view/table-view"
import FormDialog from "@/shared/components/form-dialog"
import { InventoryAdjustmentForm } from "@/modules/inventory-adjustments/inventory-adjustment-form"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent } from "@/shared/components/ui/card"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog"
import { confirm } from "@/shared/components/confirm-dialog"
import { useAuthApi } from "@/shared/useApi"
import { INVENTORY_ADJUSTMENT_ROUTES } from "@garage/api"
import type { InventoryAdjustmentsClient } from "@garage/api"
// ── Attachment helpers ──
type AttachmentFile = {
id: number
original_name?: string
attachment_path?: string
created_at?: string
}
function getFileIcon(path?: string) {
if (!path) return FileIcon
const lower = path.toLowerCase()
if (/\.(jpg|jpeg|png|gif|webp|svg)$/.test(lower)) return ImageIcon
if (/\.pdf$/.test(lower)) return FileTextIcon
return FileIcon
}
// ── Attachments Dialog ──
function AttachmentsDialog({
open,
adjustmentId,
adjustmentRef,
onClose,
}: {
open: boolean
adjustmentId: string
adjustmentRef: string
onClose: () => void
}) {
const api = useAuthApi()
const queryClient = useQueryClient()
const fileInputRef = useRef<HTMLInputElement>(null)
const [isUploading, setIsUploading] = useState(false)
const [sessionFiles, setSessionFiles] = useState<AttachmentFile[]>([])
const queryKey = [INVENTORY_ADJUSTMENT_ROUTES.INDEX, adjustmentId, "attachments"]
const deleteMutation = useMutation({
mutationFn: (attachmentId: number) =>
api.inventoryAdjustments.deleteAttachment(adjustmentId, attachmentId),
onSuccess: (_, attachmentId) => {
toast.success("Attachment deleted.")
setSessionFiles((prev) => prev.filter((f) => f.id !== attachmentId))
queryClient.invalidateQueries({ queryKey })
},
onError: () => toast.error("Failed to delete attachment."),
})
const handleDelete = async (attachment: AttachmentFile) => {
const confirmed = await confirm({
title: "Delete Attachment",
description: `Are you sure you want to delete "${attachment.original_name ?? "this file"}"?`,
confirmLabel: "Delete",
variant: "destructive",
})
if (confirmed) deleteMutation.mutate(attachment.id)
}
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (!files || files.length === 0) return
setIsUploading(true)
const fileArray = Array.from(files)
try {
const result = await toast.promise(
api.inventoryAdjustments.addAttachment(adjustmentId, fileArray),
{
loading: "Uploading attachment(s)...",
success: "Attachment(s) uploaded successfully",
error: "Failed to upload attachment(s)",
},
)
// Track uploaded files locally for display within this session
const now = new Date().toISOString()
const uploaded: AttachmentFile[] = fileArray.map((file, i) => ({
id: Date.now() + i,
original_name: file.name,
attachment_path: file.name,
created_at: now,
}))
setSessionFiles((prev) => [...prev, ...uploaded])
queryClient.invalidateQueries({ queryKey })
} finally {
setIsUploading(false)
if (fileInputRef.current) fileInputRef.current.value = ""
}
}
const handleClose = () => {
setSessionFiles([])
onClose()
}
return (
<Dialog open={open} onOpenChange={(v) => !v && handleClose()}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Attachments — {adjustmentRef}</DialogTitle>
</DialogHeader>
<div className="flex justify-end">
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={handleUpload}
/>
<Button
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
size="sm"
>
<Plus className="size-4" />
{isUploading ? "Uploading..." : "Upload Attachment"}
</Button>
</div>
{sessionFiles.length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground">
No attachments uploaded in this session. Click "Upload Attachment" to add files.
</CardContent>
</Card>
) : (
<div className="grid gap-3 sm:grid-cols-2">
{sessionFiles.map((attachment) => {
const Icon = getFileIcon(attachment.attachment_path)
return (
<Card key={attachment.id}>
<CardContent className="flex items-center gap-3 p-4">
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
<Icon className="size-5" />
</div>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate text-sm font-medium" title={attachment.original_name}>
{attachment.original_name}
</span>
{attachment.created_at && (
<span className="text-xs text-muted-foreground">
{new Date(attachment.created_at).toLocaleDateString()}
</span>
)}
</div>
<Button
variant="ghost"
size="icon-sm"
onClick={() => handleDelete(attachment)}
title="Delete attachment"
>
<Trash2 className="size-4 text-destructive" />
</Button>
</CardContent>
</Card>
)
})}
</div>
)}
</DialogContent>
</Dialog>
)
}
// ── Page ──
export default function InventoryAdjustmentsPage() {
const [attachmentTarget, setAttachmentTarget] = useState<{
id: string
ref: string
} | null>(null)
return (
<>
<ResourcePage<InventoryAdjustmentsClient>
pageTitle="Inventory Adjustments"
routeKey={INVENTORY_ADJUSTMENT_ROUTES.INDEX}
getClient={(api) => api.inventoryAdjustments}
headerProps={({ selectedItem, invalidateQuery }) => ({
actions: (
<FormDialog title="Inventory Adjustment">
{(resourceId) => (
<InventoryAdjustmentForm
resourceId={resourceId}
initialData={selectedItem}
onSuccess={invalidateQuery}
/>
)}
</FormDialog>
),
})}
columns={({ actionsColumn }) => [
{
accessorKey: "reference_number",
header: ({ column }) => <ColumnHeader column={column} title="Reference #" />,
cell: ({ row }) => (row.original as any).reference_number || "—",
},
{
accessorKey: "date",
header: ({ column }) => <ColumnHeader column={column} title="Date" />,
cell: ({ row }) => {
const val = (row.original as any).date
return val ? new Date(val).toLocaleDateString() : "—"
},
},
{
accessorKey: "chart_of_account",
header: ({ column }) => <ColumnHeader column={column} title="Chart of Account" />,
cell: ({ row }) => (row.original as any).chart_of_account || "—",
},
{
accessorKey: "notes",
header: ({ column }) => <ColumnHeader column={column} title="Notes" />,
cell: ({ row }) => {
const notes = (row.original as any).notes
return notes ? (
<span className="max-w-50 truncate block" title={notes}>{notes}</span>
) : "—"
},
},
{
accessorKey: "created_at",
header: ({ column }) => <ColumnHeader column={column} title="Created" />,
cell: ({ row }) => {
const val = (row.original as any).created_at
return val ? new Date(val).toLocaleDateString() : "—"
},
},
{
id: "attachments",
header: () => null,
cell: ({ row }) => {
const item = row.original as any
return (
<Button
variant="ghost"
size="icon-sm"
title="Manage Attachments"
onClick={(e) => {
e.stopPropagation()
setAttachmentTarget({
id: String(item.id),
ref: item.reference_number || `ADJ-${item.id}`,
})
}}
>
<Paperclip className="size-4" />
</Button>
)
},
},
actionsColumn(),
]}
/>
{attachmentTarget && (
<AttachmentsDialog
open={!!attachmentTarget}
adjustmentId={attachmentTarget.id}
adjustmentRef={attachmentTarget.ref}
onClose={() => setAttachmentTarget(null)}
/>
)}
</>
)
}
@@ -0,0 +1,66 @@
"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 (
<ResourcePage<ExpenseItemsClient>
pageTitle="Expense Items"
routeKey={EXPENSE_ITEM_ROUTES.INDEX}
getClient={(api) => api.expenseItems}
headerProps={({ selectedItem, invalidateQuery }) => ({
actions: (
<FormDialog title="Expense Item">
{(resourceId, { close }) => (
<ExpenseItemForm
resourceId={resourceId}
initialData={selectedItem}
onSuccess={() => {
invalidateQuery()
close()
}}
/>
)}
</FormDialog>
),
})}
columns={({ actionsColumn }) => [
{
accessorKey: "item_name",
header: ({ column }) => <ColumnHeader column={column} title="Item Name" />,
},
{
accessorKey: "purchase_price",
header: ({ column }) => <ColumnHeader column={column} title="Purchase Price" />,
cell: ({ row }) => {
const val = (row.original as any).purchase_price
return val != null ? `$${Number(val).toFixed(2)}` : "—"
},
},
{
accessorKey: "purchase_chart_of_account",
header: ({ column }) => <ColumnHeader column={column} title="Chart of Account" />,
cell: ({ row }) => (row.original as any).purchase_chart_of_account || "—",
},
{
accessorKey: "is_active",
header: ({ column }) => <ColumnHeader column={column} title="Status" />,
cell: ({ row }) => {
const active = (row.original as any).is_active
return (
<span className={active ? "text-green-600" : "text-muted-foreground"}>
{active ? "Active" : "Inactive"}
</span>
)
},
},
actionsColumn(),
]}
/>
)
}
@@ -0,0 +1,58 @@
"use client"
import { ResourcePage } from "@/shared/data-view/resource-page"
import FormDialog from "@/shared/components/form-dialog"
import { ImportDataButton } from "@/shared/components/import-data-button"
import { ExportDataButton } from "@/shared/components/export-data-button"
import { PartForm } from "@/modules/parts/part-form"
import { partColumns } from "@/modules/parts/parts-columns"
import { useAuthApi } from "@/shared/useApi"
import { PARTS_ROUTES } from "@garage/api"
import type { PartsClient } from "@garage/api"
export default function PartsPage() {
const api = useAuthApi()
return (
<ResourcePage<PartsClient>
pageTitle="Parts"
routeKey={PARTS_ROUTES.INDEX}
getClient={(api) => api.parts}
headerProps={({ selectedItem, invalidateQuery }) => ({
actions: (
<div className="flex items-center gap-2">
<ImportDataButton
onImport={(file) => api.parts.importData(file)}
onSuccess={invalidateQuery}
/>
<ExportDataButton
onExport={(filters) => api.parts.exportData(filters)}
fileName="parts"
/>
<FormDialog title="Part">
{(resourceId) => (
<PartForm
resourceId={resourceId}
initialData={selectedItem}
onSuccess={invalidateQuery}
/>
)}
</FormDialog>
</div>
),
})}
columns={({ actionsColumn }) => [
partColumns.title,
partColumns.partNumber,
partColumns.manufacturer,
partColumns.sellingPrice,
partColumns.purchasePrice,
partColumns.stock,
partColumns.status,
partColumns.createdAt,
actionsColumn(),
]}
/>
)
}
@@ -0,0 +1,78 @@
"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 { ServiceGroupForm } from "@/modules/service-groups/service-group-form"
import { Badge } from "@/shared/components/ui/badge"
import { SERVICE_GROUP_ROUTES } from "@garage/api"
import type { ServiceGroupsClient } from "@garage/api"
export default function ServiceGroupPage() {
return (
<ResourcePage<ServiceGroupsClient>
pageTitle="Service Groups"
routeKey={SERVICE_GROUP_ROUTES.INDEX}
getClient={(api) => api.serviceGroups}
headerProps={({ selectedItem, invalidateQuery }) => ({
actions: (
<FormDialog title="Service Group">
{(resourceId) => (
<ServiceGroupForm
resourceId={resourceId}
initialData={selectedItem}
onSuccess={invalidateQuery}
/>
)}
</FormDialog>
),
})}
columns={({ actionsColumn }) => [
{
accessorKey: "name",
header: ({ column }) => <ColumnHeader column={column} title="Name" />,
cell: ({ row }) => {
const r = row.original as any
return (
<div>
<span className="font-medium">{r.service_name || r.name || "—"}</span>
{r.code && (
<span className="ml-2 text-xs text-muted-foreground">{r.code}</span>
)}
</div>
)
},
},
{
accessorKey: "selling_price",
header: ({ column }) => <ColumnHeader column={column} title="Price" />,
cell: ({ row }) => {
const val = (row.original as any).selling_price
return val != null ? `$${Number(val).toFixed(2)}` : "—"
},
},
{
accessorKey: "is_active",
header: ({ column }) => <ColumnHeader column={column} title="Status" />,
cell: ({ row }) => {
const active = (row.original as any).is_active
return (
<Badge variant={active ? "default" : "secondary"}>
{active ? "Active" : "Inactive"}
</Badge>
)
},
},
{
accessorKey: "created_at",
header: ({ column }) => <ColumnHeader column={column} title="Created" />,
cell: ({ row }) => {
const val = (row.original as any).created_at
return val ? new Date(val).toLocaleDateString() : "—"
},
},
actionsColumn(),
]}
/>
)
}
@@ -0,0 +1,90 @@
"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 { ImportDataButton } from "@/shared/components/import-data-button"
import { ExportDataButton } from "@/shared/components/export-data-button"
import { useAuthApi } from "@/shared/useApi"
import { ServiceForm } from "@/modules/services/service-form"
import { SERVICE_ROUTES } from "@garage/api"
import type { ServicesClient } from "@garage/api"
export default function ServicesPage() {
const api = useAuthApi()
return (
<ResourcePage<ServicesClient>
pageTitle="Services"
routeKey={SERVICE_ROUTES.INDEX}
getClient={(api) => api.services}
headerProps={({ selectedItem, invalidateQuery }) => ({
actions: (
<div className="flex items-center gap-2">
<ImportDataButton
onImport={(file) => api.services.importData(file)}
onSuccess={invalidateQuery}
/>
<ExportDataButton
onExport={(filters) => api.services.exportData(filters)}
fileName="services"
/>
<FormDialog title="Service">
{(resourceId) => (
<ServiceForm
resourceId={resourceId}
initialData={selectedItem}
onSuccess={invalidateQuery}
/>
)}
</FormDialog>
</div>
),
})}
columns={({ actionsColumn }) => [
{
accessorKey: "labor_name",
header: ({ column }) => <ColumnHeader column={column} title="Name" />,
cell: ({ row }) => {
const r = row.original as any
return (
<div>
<span className="font-medium">{r.labor_name || r.name || "—"}</span>
{r.service_code && (
<span className="ml-2 text-xs text-muted-foreground">{r.service_code}</span>
)}
</div>
)
},
},
{
accessorKey: "description",
header: ({ column }) => <ColumnHeader column={column} title="Description" />,
cell: ({ row }) => {
const val = (row.original as any).description
return val
? <span className="max-w-50 truncate block">{val}</span>
: "—"
},
},
{
accessorKey: "selling_price",
header: ({ column }) => <ColumnHeader column={column} title="Price" />,
cell: ({ row }) => {
const val = (row.original as any).selling_price
return val != null ? `$${Number(val).toFixed(2)}` : "—"
},
},
{
accessorKey: "created_at",
header: ({ column }) => <ColumnHeader column={column} title="Created" />,
cell: ({ row }) => {
const val = (row.original as any).created_at
return val ? new Date(val).toLocaleDateString() : "—"
},
},
actionsColumn(),
]}
/>
)
}
@@ -0,0 +1,45 @@
import { Suspense } from "react"
import Image from "next/image"
import { DashboardLayout } from "@/base/components/layout/dashboard"
import { useAuth } from "@/shared/hooks/use-auth"
import { navGroups } from "@/config/navGroups"
import { getAuthCookies } from "@/modules/auth/auth.actions"
import { redirect } from "next/navigation"
function Logo() {
return (
<div className="flex items-center gap-2">
<Image alt="Logo" src={'/assets/logo.png'} height={100} width={100} />
</div>
)
}
export default async function AuthenticatedLayout({
children,
}: {
children: React.ReactNode
}) {
const { token, user } = await getAuthCookies()
if(!token || !user ) {
redirect('/login');
}
const userInfo = user
? {
name: user.name,
email: user.email,
initials: user.name.charAt(0).toUpperCase(),
}
: undefined
return (
<DashboardLayout navGroups={navGroups} logo={<Logo />} user={userInfo}>
{children}
</DashboardLayout>
)
}
@@ -0,0 +1,12 @@
import { DashboardHeader } from "@/base/components/layout/dashboard";
import DashboardPage from "@/base/components/layout/dashboard/dashboard-page";
import { DashboardContent } from "@/modules/home/dashboard-content";
import { DashboardFiltersToolbar } from "@/modules/home/dashboard-filters-toolbar";
export default function page() {
return (
<DashboardPage headerProps={{ title: "Dashboard", actions: <DashboardFiltersToolbar /> }} >
<DashboardContent />
</DashboardPage>
)
}
@@ -0,0 +1,39 @@
import { DashboardDetailsPage } from '@/base/components/layout/dashboard'
import { getServerApi } from '@garage/api/server'
import { EmployeeActions } from '@/modules/employees/employee-actions'
import { EmployeeProvider } from '@/modules/employees/employee-context'
import React from 'react'
export default async function layout(props: {
params: Promise<{ id: string }>
children: React.ReactNode
}) {
const { id } = await props.params
const api = await getServerApi()
const employee = await api.employees.getById(id)
const firstName = employee.data?.first_name || ''
const lastName = employee.data?.last_name || ''
const title = [firstName, lastName].filter(Boolean).join(' ') || 'Employee Details'
const employeeLabel = title
return (
<>
<EmployeeProvider employee={{ id, label: employeeLabel }}>
<DashboardDetailsPage
className="p-0 lg:p-0"
title={title}
description={(employee.data as any)?.position || employee.data?.designation || undefined}
backHref="/productivity/employees"
actions={<EmployeeActions employeeId={id} />}
tabs={[
{ href: `/productivity/employees/${id}`, label: 'Details' },
{ href: `/productivity/employees/${id}/permissions`, label: 'Permissions' },
]}
>
{props.children}
</DashboardDetailsPage>
</EmployeeProvider>
</>
)
}
@@ -0,0 +1,19 @@
import { getServerApi } from '@garage/api/server'
import { EmployeeGeneralInfo } from '@/modules/employees/employee-general-info'
import DashboardPage from '@/base/components/layout/dashboard/dashboard-page'
export default async function page(props: { params: Promise<{ id: string }> }) {
const { id } = await props.params
const api = await getServerApi()
const employee = await api.employees.getById(id)
if (!employee.data) {
return <div className="text-muted-foreground">Employee not found.</div>
}
return (
<DashboardPage header={null}>
<EmployeeGeneralInfo employee={employee.data as any} />
</DashboardPage>
)
}
@@ -0,0 +1,10 @@
"use client"
import { use } from "react"
import { EmployeePermissionsForm } from "@/modules/employees/employee-permissions-form"
export default function EmployeePermissionsPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params)
return <EmployeePermissionsForm employeeId={id} />
}
@@ -0,0 +1,74 @@
"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 { EmployeeForm } from "@/modules/employees/employee-form"
import { EMPLOYEE_ROUTES } from "@garage/api"
import type { EmployeesClient } from "@garage/api"
import { useRouter } from "next/navigation"
export default function EmployeesPage() {
const router = useRouter()
return (
<ResourcePage<EmployeesClient>
pageTitle="Employees"
routeKey={EMPLOYEE_ROUTES.INDEX}
getClient={(api) => api.employees}
onRowClick={(row) => router.push(`/productivity/employees/${(row as any).id}`)}
headerProps={({ selectedItem, invalidateQuery }) => ({
actions: (
<FormDialog title="Employee">
{(resourceId) => (
<EmployeeForm
resourceId={resourceId}
initialData={selectedItem}
onSuccess={invalidateQuery}
/>
)}
</FormDialog>
),
})}
columns={({ actionsColumn }) => [
{
accessorKey: "first_name",
header: ({ column }) => <ColumnHeader column={column} title="Name" />,
cell: ({ row }) => {
const { first_name, last_name } = row.original
return `${first_name ?? ""} ${last_name ?? ""}`.trim()
},
},
{
accessorKey: "email",
header: ({ column }) => <ColumnHeader column={column} title="Email" />,
},
{
accessorKey: "phone",
header: ({ column }) => <ColumnHeader column={column} title="Phone" />,
},
{
accessorKey: "position",
header: ({ column }) => <ColumnHeader column={column} title="Position" />,
},
{
accessorKey: "department",
header: ({ column }) => <ColumnHeader column={column} title="Department" />,
cell: ({ row }) => (row.original as any).department?.name ?? "—",
},
{
accessorKey: "status",
header: ({ column }) => <ColumnHeader column={column} title="Status" />,
cell: ({ row }) => {
const status = row.original.status
return (
<span className={status === "active" ? "text-green-600" : "text-red-600"}>
{status}
</span>
)
},
},
actionsColumn(),
]}
/>
)
}
@@ -0,0 +1,46 @@
"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 { HolidayYearForm } from "@/modules/settings/holiday-year/holiday-year-form"
import { HOLIDAY_YEAR_ROUTES } from "@garage/api"
import type { HolidayYearsClient } from "@garage/api"
export default function HolidayYearsPage() {
return (
<ResourcePage<HolidayYearsClient>
pageTitle="Holiday Years"
routeKey={HOLIDAY_YEAR_ROUTES.INDEX}
getClient={(api) => api.holidayYears}
headerProps={({ selectedItem, invalidateQuery }) => ({
actions: (
<FormDialog title="Holiday Year">
{(resourceId) => (
<HolidayYearForm
resourceId={resourceId}
initialData={selectedItem}
onSuccess={invalidateQuery}
/>
)}
</FormDialog>
),
})}
columns={({ actionsColumn }) => [
{
accessorKey: "year",
header: ({ column }) => <ColumnHeader column={column} title="Year" />,
},
{
accessorKey: "created_at",
header: ({ column }) => <ColumnHeader column={column} title="Created At" />,
cell: ({ row }) => {
const date = (row.original as any).created_at
return date ? new Date(date).toLocaleDateString() : "—"
},
},
actionsColumn({ onEdit: undefined }),
]}
/>
)
}
@@ -0,0 +1,56 @@
"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 { ShopCalendarForm } from "@/modules/shop-calendars/shop-calendar-form"
import { SHOP_CALENDAR_ROUTES } from "@garage/api"
import type { ShopCalendarsClient } from "@garage/api"
import { CheckCircle2Icon } from "lucide-react"
export default function ShopCalendarsPage() {
return (
<ResourcePage<ShopCalendarsClient>
pageTitle="Shop Calendars"
routeKey={SHOP_CALENDAR_ROUTES.INDEX}
getClient={(api) => api.shopCalendars}
headerProps={({ selectedItem, invalidateQuery }) => ({
actions: (
<FormDialog title="Shop Calendar">
{(resourceId) => (
<ShopCalendarForm
resourceId={resourceId}
initialData={selectedItem}
onSuccess={invalidateQuery}
/>
)}
</FormDialog>
),
})}
columns={({ actionsColumn }) => [
{
accessorKey: "title",
header: ({ column }) => <ColumnHeader column={column} title="Title" />,
},
{
accessorKey: "is_default",
header: ({ column }) => <ColumnHeader column={column} title="Default" />,
cell: ({ row }) =>
(row.original as any).is_default ? (
<CheckCircle2Icon className="text-green-600 h-5 w-5" />
) : null,
},
{
accessorKey: "shop_calender_days",
header: () => <span>Days</span>,
enableSorting: false,
cell: ({ row }) => {
const days = (row.original as any).shop_calender_days
return days?.length ?? 0
},
},
actionsColumn({ onEdit: undefined }),
]}
/>
)
}
@@ -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 { ShopTimingForm } from "@/modules/shop-timings/shop-timing-form"
import { SHOP_TIMING_ROUTES } from "@garage/api"
import type { ShopTimingsClient } from "@garage/api"
import { CheckCircle2Icon } from "lucide-react"
export default function ShopTimingsPage() {
return (
<ResourcePage<ShopTimingsClient>
pageTitle="Shop Timings"
routeKey={SHOP_TIMING_ROUTES.INDEX}
getClient={(api) => api.shopTimings}
headerProps={({ selectedItem, invalidateQuery }) => ({
actions: (
<FormDialog title="Shop Timing">
{(resourceId) => (
<ShopTimingForm
resourceId={resourceId}
initialData={selectedItem}
onSuccess={invalidateQuery}
/>
)}
</FormDialog>
),
})}
columns={({ actionsColumn }) => [
{
accessorKey: "title",
header: ({ column }) => <ColumnHeader column={column} title="Title" />,
},
{
accessorKey: "in_time",
header: ({ column }) => <ColumnHeader column={column} title="In Time" />,
},
{
accessorKey: "out_time",
header: ({ column }) => <ColumnHeader column={column} title="Out Time" />,
},
{
accessorKey: "full_day_hours",
header: ({ column }) => <ColumnHeader column={column} title="Full Day Hours" />,
},
{
accessorKey: "half_day_hours",
header: ({ column }) => <ColumnHeader column={column} title="Half Day Hours" />,
},
{
accessorKey: "is_default",
header: ({ column }) => <ColumnHeader column={column} title="Default" />,
cell: ({ row }) =>
row.original.is_default ? (
<CheckCircle2Icon className="text-green-600 h-5 w-5" />
) : null,
},
actionsColumn(),
]}
/>
)
}
@@ -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 (
<ResourcePage<TasksClient>
routeKey={TASK_ROUTES.INDEX}
getClient={(api) => api.tasks}
headerProps={({ selectedItem, invalidateQuery }) => ({
title: "Tasks",
actions: (
<FormDialog title="Task">
{(resourceId) => (
<TaskForm
resourceId={resourceId}
initialData={selectedItem}
onSuccess={invalidateQuery}
/>
)}
</FormDialog>
),
})}
columns={({ actionsColumn }) => [
{
accessorKey: "task_number",
header: ({ column }) => <ColumnHeader column={column} title="Task #" />,
},
{
accessorKey: "subject",
header: ({ column }) => <ColumnHeader column={column} title="Subject" />,
},
{
accessorKey: "priority",
header: ({ column }) => <ColumnHeader column={column} title="Priority" />,
},
{
accessorKey: "status",
header: ({ column }) => <ColumnHeader column={column} title="Status" />,
},
{
accessorKey: "due_date",
header: ({ column }) => <ColumnHeader column={column} title="Due Date" />,
},
actionsColumn(),
]}
/>
)
}
@@ -0,0 +1,42 @@
"use client"
import { use } from "react"
import { useQuery } from "@tanstack/react-query"
import { useAuthApi } from "@/shared/useApi"
import { useRouter } from "next/navigation"
import { BillForm } from "@/modules/bills/bill-form"
import DashboardPage from "@/base/components/layout/dashboard/dashboard-page"
import { BILL_ROUTES } from "@garage/api"
export default function BillEditPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params)
const api = useAuthApi()
const router = useRouter()
const { data, isLoading } = useQuery({
queryKey: [BILL_ROUTES.BY_ID, id],
queryFn: () => api.bills.show(id),
})
if (isLoading) {
return (
<DashboardPage header={null}>
<div className="flex items-center justify-center p-8 text-muted-foreground">
Loading...
</div>
</DashboardPage>
)
}
return (
<DashboardPage header={null}>
<div className="p-6">
<BillForm
resourceId={id}
initialData={data}
onSuccess={() => router.push(`/purchase/bill/${id}`)}
/>
</div>
</DashboardPage>
)
}
@@ -0,0 +1,45 @@
import { DashboardDetailsPage } from '@/base/components/layout/dashboard'
import { getServerApi } from '@garage/api/server'
import { BillActions } from '@/modules/bills/bill-actions'
import { BillProvider, type BillResponse } from '@/modules/bills/bill-context'
import BillStatusBadge from '@/modules/bills/bill-status-badge'
import { ShareDocumentButton } from '@/shared/components/share-document-button'
import { ReceiptIcon } from 'lucide-react'
import React from 'react'
export default async function BillDetailLayout(props: {
params: Promise<{ id: string }>
children: React.ReactNode
}) {
const { id } = await props.params
const api = await getServerApi()
const bill = await api.bills.show(id)
const data = bill.data as BillResponse
const title = data?.title || data?.bill_number || 'Bill Details'
return (
<BillProvider bill={data}>
<DashboardDetailsPage
className="p-0 lg:p-0"
title={title}
description={data?.bill_number ? `Bill #: ${data.bill_number}` : undefined}
icon={<ReceiptIcon className="size-5" />}
backHref="/purchase/bill"
actions={
<div className="flex space-x-2 items-center">
<BillStatusBadge bill={{id, status:data?.status}} />
<ShareDocumentButton type="bill" id={id} />
<BillActions billId={id} />
</div>
}
tabs={[
{
href: `/purchase/bill/${id}`,
label: 'Details',
},
]}
>
{props.children}
</DashboardDetailsPage>
</BillProvider>
)
}
@@ -0,0 +1,32 @@
import { getServerApi } from '@garage/api/server'
import { BillGeneralInfo } from '@/modules/bills/bill-general-info'
import { BillPartsSection } from '@/modules/bills/bill-parts-section'
import { BillServicesSection } from '@/modules/bills/bill-services-section'
import { BillExpensesSection } from '@/modules/bills/bill-expenses-section'
import { BillTotalsSummary } from '@/modules/bills/bill-totals-summary'
import { BillPaymentsSection } from '@/modules/bills/bill-payments-section'
import DashboardPage from '@/base/components/layout/dashboard/dashboard-page'
export default async function BillDetailPage(props: { params: Promise<{ id: string }> }) {
const { id } = await props.params
const api = await getServerApi()
const bill = await api.bills.show(id)
const data = (bill as any)?.data ?? bill
if (!data) {
return <div className="text-muted-foreground">Bill not found.</div>
}
return (
<DashboardPage header={null}>
<div className="grid gap-6">
<BillGeneralInfo />
<BillPartsSection parts={data.parts} />
<BillServicesSection services={data.services} />
<BillExpensesSection expenses={data.expenses} />
<BillPaymentsSection />
<BillTotalsSummary />
</div>
</DashboardPage>
)
}
@@ -0,0 +1,87 @@
"use client"
import { useRouter } from "next/navigation"
import FormDialog from "@/shared/components/form-dialog"
import { Badge } from "@/shared/components/ui/badge"
import { ResourcePage } from "@/shared/data-view/resource-page"
import { ColumnHeader } from "@/shared/data-view/table-view"
import { BillForm } from "@/modules/bills/bill-form"
import { BILL_ROUTES } from "@garage/api"
import type { BillsClient } from "@garage/api"
import { formatDate } from "@/shared/utils/formatters"
export default function BillsPage() {
const router = useRouter()
return (
<ResourcePage<BillsClient>
pageTitle="Bills"
routeKey={BILL_ROUTES.INDEX}
getClient={(api) => api.bills}
onRowClick={(row) => router.push(`/purchase/bill/${(row as any).id}`)}
headerProps={({ selectedItem, invalidateQuery }) => ({
actions: (
<FormDialog classNames={{ dialogContent: "lg:min-w-6xl" }} title="Bill">
{(resourceId) => (
<BillForm
resourceId={resourceId}
initialData={selectedItem}
onSuccess={invalidateQuery}
/>
)}
</FormDialog>
),
})}
columns={({ actionsColumn }) => [
{
accessorKey: "bill_number",
header: ({ column }) => <ColumnHeader column={column} title="Bill #" />,
cell: ({ row }) => (row.original as any).bill_number || "—",
},
{
accessorKey: "total",
header: ({ column }) => <ColumnHeader column={column} title="Total" />,
cell: ({ row }) => (row.original as any).total || "—",
},
{
accessorKey: "balance_due",
header: ({ column }) => <ColumnHeader column={column} title="Balance Due" />,
cell: ({ row }) => (row.original as any).balance_due || "—",
},
{
accessorKey: "title",
header: ({ column }) => <ColumnHeader column={column} title="Title" />,
},
{
accessorKey: "vendor",
header: ({ column }) => <ColumnHeader column={column} title="Vendor" />,
cell: ({ row }) => (row.original as any).vendor?.name || "—",
},
{
accessorKey: "bill_date",
header: ({ column }) => <ColumnHeader column={column} title="Bill Date" />,
cell: ({ row }) => formatDate((row.original as any).bill_date) || "—",
},
{
accessorKey: "bill_due_date",
header: ({ column }) => <ColumnHeader column={column} title="Due Date" />,
cell: ({ row }) => formatDate((row.original as any).bill_due_date) || "—",
},
{
accessorKey: "status",
header: ({ column }) => <ColumnHeader column={column} title="Status" />,
cell: ({ row }) => {
const status = (row.original as any).status
return (
<Badge variant={status === "paid" ? "default" : "secondary"}>
{status?.replace(/_/g, " ") || "—"}
</Badge>
)
},
},
actionsColumn(),
]}
/>
)
}
@@ -0,0 +1,38 @@
import { DashboardDetailsPage } from '@/base/components/layout/dashboard'
import { getServerApi } from '@garage/api/server'
import { ExpenseActions } from '@/modules/expenses/expense-actions'
import { ExpenseProvider, type ExpenseContextValue } from '@/modules/expenses/expense-context'
import { ReceiptIcon } from 'lucide-react'
import React from 'react'
export default async function ExpenseDetailLayout(props: {
params: Promise<{ id: string }>
children: React.ReactNode
}) {
const { id } = await props.params
const api = await getServerApi()
const expense = await api.expenses.getById(id)
const data = expense.data as ExpenseContextValue
const title = data?.title || data?.invoice_number || 'Expense Details'
return (
<ExpenseProvider expense={data}>
<DashboardDetailsPage
className="p-0 lg:p-0"
title={title}
description={data?.invoice_number ? `Invoice #: ${data.invoice_number}` : undefined}
icon={<ReceiptIcon className="size-5" />}
backHref="/purchase/expense"
actions={<ExpenseActions expenseId={id} />}
tabs={[
{
href: `/purchase/expense/${id}`,
label: 'Details',
},
]}
>
{props.children}
</DashboardDetailsPage>
</ExpenseProvider>
)
}
@@ -0,0 +1,37 @@
import DashboardPage from '@/base/components/layout/dashboard/dashboard-page'
import { ExpenseGeneralInfo } from '@/modules/expenses/expense-general-info'
import { ExpenseItemsSection } from '@/modules/expenses/expense-items-section'
import { ExpensePaymentsSection } from '@/modules/expenses/expense-payments-section'
import { formatTaxLabel } from '@/shared/utils/formatters'
import { getServerApi } from '@garage/api/server'
export default async function ExpenseDetailPage(props: { params: Promise<{ id: string }> }) {
const { id } = await props.params
const api = await getServerApi()
const expense = await api.expenses.show(id)
const data = (expense as any)?.data ?? expense
if (!data) {
return <div className="text-muted-foreground">Expense not found.</div>
}
const taxLabel = formatTaxLabel(data.tax, '') || undefined
return (
<DashboardPage header={null}>
<div className="grid gap-6">
<ExpenseGeneralInfo />
<ExpensePaymentsSection />
<ExpenseItemsSection
items={data.expense_items}
discountType={data.discount}
subTotal={data.sub_total}
discountAmount={data.discount_amount_major}
taxAmount={data.tax_amount}
total={data.total}
taxLabel={taxLabel}
/>
</div>
</DashboardPage>
)
}
@@ -0,0 +1,93 @@
"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 { ExpenseForm } from "@/modules/expenses/expense-form"
import { Badge } from "@/shared/components/ui/badge"
import { EXPENSE_ROUTES } from "@garage/api"
import type { ExpensesClient } from "@garage/api"
import { useRouter } from "next/navigation"
import { formatCurrency, formatDate, formatEnum } from "@/shared/utils/formatters"
export default function ExpensesPage() {
const router = useRouter()
return (
<ResourcePage<ExpensesClient>
pageTitle="Expenses"
routeKey={EXPENSE_ROUTES.INDEX}
getClient={(api) => api.expenses}
onRowClick={(row)=>router.push(`/purchase/expense/${row.id}`)}
headerProps={({ selectedItem, invalidateQuery }) => ({
actions: (
<FormDialog
title="Expense"
classNames={{ dialogContent: "lg:min-w-6xl" }}
>
{(resourceId) => (
<ExpenseForm
resourceId={resourceId}
initialData={selectedItem}
onSuccess={invalidateQuery}
/>
)}
</FormDialog>
),
})}
columns={({ actionsColumn }) => [
{
accessorKey: "title",
header: ({ column }) => <ColumnHeader column={column} title="Title" />,
},
{
accessorKey: "invoice_number",
header: ({ column }) => <ColumnHeader column={column} title="Invoice #" />,
cell: ({ row }) => (row.original as any).invoice_number || "—",
},
{
accessorKey: "vendor",
header: () => "Vendor",
cell: ({ row }) => {
const vendor = (row.original as any).vendor
return vendor?.company_name || vendor?.name || "—"
},
},
{
accessorKey: "expense_date",
header: ({ column }) => <ColumnHeader column={column} title="Date" />,
cell: ({ row }) => formatDate((row.original as any).expense_date),
},
{
accessorKey: "total",
header: () => "Total",
cell: ({ row }) => formatCurrency((row.original as any).total ?? 0),
},
{
accessorKey: "balance_due",
header: () => "Balance Due",
cell: ({ row }) => formatCurrency((row.original as any).balance_due ?? 0),
},
{
accessorKey: "status",
header: ({ column }) => <ColumnHeader column={column} title="Status" />,
cell: ({ row }) => {
const status = (row.original as any).status
const variantMap: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
draft: "secondary",
open: "default",
un_paid: "destructive",
partially_paid: "secondary",
paid: "default",
}
return (
<Badge variant={variantMap[status] ?? "outline"}>
{formatEnum(status) || "—"}
</Badge>
)
},
},
actionsColumn(),
]}
/>
)
}
@@ -0,0 +1,377 @@
"use client"
import { useState, useRef } from "react"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import {
Paperclip,
Plus,
Trash2,
FileIcon,
ImageIcon,
FileTextIcon,
BadgeDollarSignIcon,
CalendarIcon,
CreditCardIcon,
HashIcon,
UserIcon,
BriefcaseIcon,
} from "lucide-react"
import { toast } from "sonner"
import { ResourcePage } from "@/shared/data-view/resource-page"
import { ColumnHeader } from "@/shared/data-view/table-view"
import FormDialog from "@/shared/components/form-dialog"
import { PaymentMadeForm } from "@/modules/payment-mades/payment-made-form"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent } from "@/shared/components/ui/card"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog"
import { confirm } from "@/shared/components/confirm-dialog"
import { useAuthApi } from "@/shared/useApi"
import { PAYMENT_MADE_ROUTES } from "@garage/api"
import type { PaymentMadesClient } from "@garage/api"
// ── Attachment helpers ──
type AttachmentFile = {
id: number
original_name?: string
attachment_path?: string
created_at?: string
}
function getFileIcon(path?: string) {
if (!path) return FileIcon
const lower = path.toLowerCase()
if (/\.(jpg|jpeg|png|gif|webp|svg)$/.test(lower)) return ImageIcon
if (/\.pdf$/.test(lower)) return FileTextIcon
return FileIcon
}
// ── Attachments Dialog ──
function AttachmentsDialog({
open,
paymentId,
paymentRef,
onClose,
}: {
open: boolean
paymentId: string
paymentRef: string
onClose: () => void
}) {
const api = useAuthApi()
const queryClient = useQueryClient()
const fileInputRef = useRef<HTMLInputElement>(null)
const [isUploading, setIsUploading] = useState(false)
const [sessionFiles, setSessionFiles] = useState<AttachmentFile[]>([])
const queryKey = [PAYMENT_MADE_ROUTES.INDEX, paymentId, "attachments"]
const deleteMutation = useMutation({
mutationFn: (attachmentId: number) =>
api.paymentMades.deleteAttachment(paymentId, { attachment_id: attachmentId } as any),
onSuccess: (_, attachmentId) => {
toast.success("Attachment deleted.")
setSessionFiles((prev) => prev.filter((f) => f.id !== attachmentId))
queryClient.invalidateQueries({ queryKey })
},
onError: () => toast.error("Failed to delete attachment."),
})
const handleDelete = async (attachment: AttachmentFile) => {
const confirmed = await confirm({
title: "Delete Attachment",
description: `Are you sure you want to delete "${attachment.original_name ?? "this file"}"?`,
confirmLabel: "Delete",
variant: "destructive",
})
if (confirmed) deleteMutation.mutate(attachment.id)
}
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (!files || files.length === 0) return
setIsUploading(true)
const fileArray = Array.from(files)
try {
const formData = new FormData()
fileArray.forEach((file) => formData.append("attachments[]", file))
await toast.promise(
api.paymentMades.addAttachment(paymentId, formData),
{
loading: "Uploading attachment(s)...",
success: "Attachment(s) uploaded successfully",
error: "Failed to upload attachment(s)",
},
)
const now = new Date().toISOString()
const uploaded: AttachmentFile[] = fileArray.map((file, i) => ({
id: Date.now() + i,
original_name: file.name,
attachment_path: file.name,
created_at: now,
}))
setSessionFiles((prev) => [...prev, ...uploaded])
queryClient.invalidateQueries({ queryKey })
} finally {
setIsUploading(false)
if (fileInputRef.current) fileInputRef.current.value = ""
}
}
const handleClose = () => {
setSessionFiles([])
onClose()
}
return (
<Dialog open={open} onOpenChange={(v) => !v && handleClose()}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Attachments — {paymentRef}</DialogTitle>
</DialogHeader>
<div className="flex justify-end">
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={handleUpload}
/>
<Button
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
size="sm"
>
<Plus className="size-4" />
{isUploading ? "Uploading..." : "Upload Attachment"}
</Button>
</div>
{sessionFiles.length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground">
No attachments uploaded in this session. Click &quot;Upload Attachment&quot; to add files.
</CardContent>
</Card>
) : (
<div className="grid gap-3 sm:grid-cols-2">
{sessionFiles.map((attachment) => {
const Icon = getFileIcon(attachment.attachment_path)
return (
<Card key={attachment.id}>
<CardContent className="flex items-center gap-3 p-4">
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
<Icon className="size-5" />
</div>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate text-sm font-medium" title={attachment.original_name}>
{attachment.original_name}
</span>
{attachment.created_at && (
<span className="text-xs text-muted-foreground">
{new Date(attachment.created_at).toLocaleDateString()}
</span>
)}
</div>
<Button
variant="ghost"
size="icon-sm"
onClick={() => handleDelete(attachment)}
title="Delete attachment"
>
<Trash2 className="size-4 text-destructive" />
</Button>
</CardContent>
</Card>
)
})}
</div>
)}
</DialogContent>
</Dialog>
)
}
// ── Page ──
type PaymentMadeItem = {
id: number
payment_number?: string
vendor_name?: string
employee_name?: string
payment_for?: string
payment_made?: string | number
payment_mode_name?: string
payment_date?: string
paid_through?: string
notes?: string
created_at?: string
}
export default function PaymentsMadePage() {
const [attachmentTarget, setAttachmentTarget] = useState<{
id: string
ref: string
} | null>(null)
return (
<>
<ResourcePage<PaymentMadesClient>
pageTitle="Payments Made"
routeKey={PAYMENT_MADE_ROUTES.INDEX}
getClient={(api) => api.paymentMades}
headerProps={({ invalidateQuery }) => ({
actions: (
<FormDialog title="Record Payment">
{(resourceId) => (
<PaymentMadeForm
resourceId={resourceId}
onSuccess={invalidateQuery}
/>
)}
</FormDialog>
),
})}
columns={({ actionsColumn }) => [
{
accessorKey: "payment_number",
header: ({ column }) => <ColumnHeader column={column} title="Payment #" />,
cell: ({ row }) => {
const item = row.original as unknown as PaymentMadeItem
return (
<div className="flex items-center gap-2">
<HashIcon className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{item.payment_number || "—"}</span>
</div>
)
},
},
{
accessorKey: "vendor_name",
header: ({ column }) => <ColumnHeader column={column} title="Vendor" />,
cell: ({ row }) => {
const item = row.original as unknown as PaymentMadeItem
return (
<div className="flex items-center gap-2">
<UserIcon className="h-4 w-4 text-muted-foreground" />
<span>{item.vendor_name || "—"}</span>
</div>
)
},
},
{
accessorKey: "payment_for",
header: ({ column }) => <ColumnHeader column={column} title="Payment For" />,
cell: ({ row }) => {
const item = row.original as unknown as PaymentMadeItem
return (
<div className="flex items-center gap-2">
<BriefcaseIcon className="h-4 w-4 text-muted-foreground" />
<span className="capitalize">{item.payment_for || "—"}</span>
</div>
)
},
},
{
accessorKey: "payment_made",
header: ({ column }) => <ColumnHeader column={column} title="Amount" />,
cell: ({ row }) => {
const item = row.original as unknown as PaymentMadeItem
const amount = item.payment_made
? Number(item.payment_made).toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
: "—"
return (
<div className="flex items-center gap-2">
<BadgeDollarSignIcon className="h-4 w-4 text-emerald-600" />
<span className="font-semibold text-emerald-700 dark:text-emerald-400">
{amount}
</span>
</div>
)
},
},
{
accessorKey: "payment_mode_name",
header: ({ column }) => <ColumnHeader column={column} title="Payment Mode" />,
cell: ({ row }) => {
const item = row.original as unknown as PaymentMadeItem
return (
<div className="flex items-center gap-2">
<CreditCardIcon className="h-4 w-4 text-muted-foreground" />
<span className="capitalize">{item.payment_mode_name || "—"}</span>
</div>
)
},
},
{
accessorKey: "payment_date",
header: ({ column }) => <ColumnHeader column={column} title="Date" />,
cell: ({ row }) => {
const item = row.original as unknown as PaymentMadeItem
const formatted = item.payment_date
? new Date(item.payment_date).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
})
: "—"
return (
<div className="flex items-center gap-2">
<CalendarIcon className="h-4 w-4 text-muted-foreground" />
<span>{formatted}</span>
</div>
)
},
},
{
id: "attachments",
header: () => null,
cell: ({ row }) => {
const item = row.original as any
return (
<Button
variant="ghost"
size="icon-sm"
title="Manage Attachments"
onClick={(e) => {
e.stopPropagation()
setAttachmentTarget({
id: String(item.id),
ref: item.payment_number || `PAY-${item.id}`,
})
}}
>
<Paperclip className="size-4" />
</Button>
)
},
},
actionsColumn(),
]}
/>
{attachmentTarget && (
<AttachmentsDialog
open={!!attachmentTarget}
paymentId={attachmentTarget.id}
paymentRef={attachmentTarget.ref}
onClose={() => setAttachmentTarget(null)}
/>
)}
</>
)
}
@@ -0,0 +1,46 @@
import { DashboardDetailsPage } from '@/base/components/layout/dashboard'
import { getServerApi } from '@garage/api/server'
import { PurchaseOrderActions } from '@/modules/purchase-orders/purchase-order-actions'
import { PurchaseOrderProvider } from '@/modules/purchase-orders/purchase-order-context'
import { CreateBillFromPOButton } from '@/modules/purchase-orders/create-bill-from-po-button'
import { ShareDocumentButton } from '@/shared/components/share-document-button'
import { ClipboardList } from 'lucide-react'
import React from 'react'
export default async function layout(props: {
params: Promise<{ id: string }>
children: React.ReactNode
}) {
const { id } = await props.params
const api = await getServerApi()
const purchaseOrder = (await api.purchaseOrders.getById(id)) as any
const data = purchaseOrder?.data ?? purchaseOrder
const title = data?.title || data?.order_number || 'Purchase Order'
const orderNumber = data?.order_number
const description = orderNumber ? `Order #: ${orderNumber}` : undefined
return (
<PurchaseOrderProvider purchaseOrder={{ id, label: title, data }}>
<DashboardDetailsPage
className="p-0 lg:p-0"
icon={<ClipboardList className="size-5" />}
title={title}
description={description}
backHref="/purchase/purchase-order"
actions={
<div className="flex items-center gap-2">
<CreateBillFromPOButton />
<ShareDocumentButton type="purchase_order" id={id} />
<PurchaseOrderActions purchaseOrderId={id} />
</div>
}
tabs={[
{ href: `/purchase/purchase-order/${id}`, label: 'Details' },
]}
>
{props.children}
</DashboardDetailsPage>
</PurchaseOrderProvider>
)
}
@@ -0,0 +1,21 @@
import { getServerApi } from '@garage/api/server'
import { PurchaseOrderGeneralInfo } from '@/modules/purchase-orders/purchase-order-general-info'
import DashboardPage from '@/base/components/layout/dashboard/dashboard-page'
export default async function page(props: { params: Promise<{ id: string }> }) {
const { id } = await props.params
const api = await getServerApi()
const response = await api.purchaseOrders.getById(id)
const purchaseOrder = (response as any)?.data ?? response
if (!purchaseOrder) {
return <div className="text-muted-foreground">Purchase order not found.</div>
}
return (
<DashboardPage header={null}>
<PurchaseOrderGeneralInfo purchaseOrder={purchaseOrder} />
</DashboardPage>
)
}
@@ -0,0 +1,76 @@
"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 { PurchaseOrderForm } from "@/modules/purchase-orders/purchase-order-form"
import { PURCHASE_ORDER_ROUTES } from "@garage/api"
import type { PurchaseOrdersClient } from "@garage/api"
export default function PurchaseOrdersPage() {
const router = useRouter()
return (
<ResourcePage<PurchaseOrdersClient>
pageTitle="Purchase Orders"
routeKey={PURCHASE_ORDER_ROUTES.INDEX}
getClient={(api) => api.purchaseOrders}
onRowClick={(row) => router.push(`/purchase/purchase-order/${(row as any).id}`)}
headerProps={({ selectedItem, invalidateQuery }) => ({
actions: (
<FormDialog classNames={{ dialogContent: "min-w-6xl" }} title="Purchase Order">
{(resourceId, { close }) => (
<PurchaseOrderForm
resourceId={resourceId}
initialData={selectedItem}
onSuccess={() => { invalidateQuery(); close()}}
/>
)}
</FormDialog>
),
})}
columns={({ actionsColumn }) => [
{
accessorKey: "order_number",
header: ({ column }) => <ColumnHeader column={column} title="Order #" />,
cell: ({ row }) => (row.original as any).order_number || "—",
},
{
accessorKey: "title",
header: ({ column }) => <ColumnHeader column={column} title="Title" />,
},
{
accessorKey: "vendor_name",
header: ({ column }) => <ColumnHeader column={column} title="Vendor" />,
cell: ({ row }) => (row.original as any).vendor_name || "—",
},
{
accessorKey: "order_date",
header: ({ column }) => <ColumnHeader column={column} title="Order Date" />,
cell: ({ row }) => {
const val = (row.original as any).order_date
return val ? new Date(val).toLocaleDateString() : "—"
},
},
{
accessorKey: "delivery_date",
header: ({ column }) => <ColumnHeader column={column} title="Delivery Date" />,
cell: ({ row }) => {
const val = (row.original as any).delivery_date
return val ? new Date(val).toLocaleDateString() : "—"
},
},
{
accessorKey: "created_at",
header: ({ column }) => <ColumnHeader column={column} title="Created" />,
cell: ({ row }) => {
const val = (row.original as any).created_at
return val ? new Date(val).toLocaleDateString() : "—"
},
},
actionsColumn(),
]}
/>
)
}
@@ -0,0 +1,65 @@
"use client"
import FormDialog from "@/shared/components/form-dialog"
import { Badge } from "@/shared/components/ui/badge"
import { ResourcePage } from "@/shared/data-view/resource-page"
import { ColumnHeader } from "@/shared/data-view/table-view"
import { VendorCreditForm } from "@/modules/vendor-credits/vendor-credit-form"
import { VENDOR_CREDIT_ROUTES } from "@garage/api"
import type { VendorCreditsClient } from "@garage/api"
export default function VendorCreditsPage() {
return (
<ResourcePage<VendorCreditsClient>
pageTitle="Vendor Credits"
routeKey={VENDOR_CREDIT_ROUTES.INDEX}
getClient={(api) => api.vendorCredits}
headerProps={({ selectedItem, invalidateQuery }) => ({
actions: (
<FormDialog title="Vendor Credit">
{(resourceId) => (
<VendorCreditForm
resourceId={resourceId}
initialData={selectedItem}
onSuccess={invalidateQuery}
/>
)}
</FormDialog>
),
})}
columns={({ actionsColumn }) => [
{
accessorKey: "subject",
header: ({ column }) => <ColumnHeader column={column} title="Subject" />,
},
{
accessorKey: "vendor_name",
header: ({ column }) => <ColumnHeader column={column} title="Vendor" />,
cell: ({ row }) => (row.original as any).vendor_name || "—",
},
{
accessorKey: "bill_number",
header: ({ column }) => <ColumnHeader column={column} title="Bill #" />,
cell: ({ row }) => (row.original as any).bill_number || "—",
},
{
accessorKey: "vendor_credit_date",
header: ({ column }) => <ColumnHeader column={column} title="Date" />,
cell: ({ row }) => {
const value = (row.original as any).vendor_credit_date
return value ? new Date(value).toLocaleDateString() : "—"
},
},
{
accessorKey: "status",
header: ({ column }) => <ColumnHeader column={column} title="Status" />,
cell: ({ row }) => {
const status = (row.original as any).status
return <Badge variant={status === "closed" ? "secondary" : "default"}>{status || "—"}</Badge>
},
},
actionsColumn(),
]}
/>
)
}
@@ -0,0 +1,61 @@
"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 { VendorForm } from "@/modules/vendors/vendor-form"
import { VENDOR_ROUTES } from "@garage/api"
import type { VendorsClient } from "@garage/api"
export default function VendorsPage() {
return (
<ResourcePage<VendorsClient>
pageTitle="Vendors"
routeKey={VENDOR_ROUTES.INDEX}
getClient={(api) => api.vendors}
headerProps={({ selectedItem, invalidateQuery }) => ({
actions: (
<FormDialog title="Vendor">
{(resourceId) => (
<VendorForm
resourceId={resourceId}
initialData={selectedItem}
onSuccess={invalidateQuery}
/>
)}
</FormDialog>
),
})}
columns={({ actionsColumn }) => [
{
accessorKey: "first_name",
header: ({ column }) => <ColumnHeader column={column} title="Name" />,
cell: ({ row }) => {
const r = row.original as any
const name = [r.first_name, r.last_name].filter(Boolean).join(" ")
return name || "—"
},
},
{
accessorKey: "company_name",
header: ({ column }) => <ColumnHeader column={column} title="Company" />,
cell: ({ row }) => (row.original as any).company_name || "—",
},
{
accessorKey: "email",
header: ({ column }) => <ColumnHeader column={column} title="Email" />,
cell: ({ row }) => (row.original as any).email || "—",
},
{
accessorKey: "created_at",
header: ({ column }) => <ColumnHeader column={column} title="Created" />,
cell: ({ row }) => {
const val = (row.original as any).created_at
return val ? new Date(val).toLocaleDateString() : "—"
},
},
actionsColumn(),
]}
/>
)
}
@@ -0,0 +1,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<CreditNoteAttachment>[] = [
{
accessorKey: "name",
header: ({ column }) => <ColumnHeader column={column} title="Name" />,
cell: ({ getValue, row }) => {
const name = getValue<string>()
const url = row.original.url
if (url) {
return (
<a href={url} target="_blank" rel="noopener noreferrer" className="text-primary underline">
{name || "Attachment"}
</a>
)
}
return name || "—"
},
},
{
accessorKey: "created_at",
header: ({ column }) => <ColumnHeader column={column} title="Uploaded" />,
cell: ({ getValue }) => {
const val = getValue<string>()
return val ? new Date(val).toLocaleDateString() : "—"
},
},
{
id: "actions",
header: () => <span className="sr-only">Actions</span>,
cell: ({ row }) => (
<Button
variant="ghost"
size="icon-sm"
onClick={() => handleDelete(row.original)}
title="Delete attachment"
>
<Trash2 className="size-4 text-destructive" />
</Button>
),
enableSorting: false,
},
]
const attachments: CreditNoteAttachment[] = (data as any)?.attachments ?? []
return (
<div className="flex flex-col gap-4 p-4 lg:p-6">
<div className="flex items-center justify-end">
<Button onClick={() => setDialogOpen(true)}>
<Plus className="size-4" />
Add Attachment
</Button>
</div>
<Card>
<CardContent>
<DataTable
columns={columns}
data={attachments}
pagination={{ page: 1, pageSize: 15, pageCount: 1, total: attachments.length }}
sorting={[]}
onChange={() => {}}
isLoading={isLoading}
/>
</CardContent>
</Card>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Upload Attachment</DialogTitle>
</DialogHeader>
<CreditNoteDocumentForm
creditNoteId={creditNoteId}
onSuccess={() => {
setDialogOpen(false)
queryClient.invalidateQueries({ queryKey })
}}
/>
</DialogContent>
</Dialog>
</div>
)
}
@@ -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 (
<CreditNoteProvider creditNote={{ id, label: title }}>
<DashboardDetailsPage
className='p-0 lg:p-0'
title={title}
description={data?.credit_invoice ? `Credit Note #: ${data.credit_invoice}` : undefined}
icon={<ReceiptTextIcon className="size-5" />}
backHref="/sales/credit-notes"
actions={<CreditNoteActions creditNoteId={id} />}
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}
</DashboardDetailsPage>
</CreditNoteProvider>
)
}
@@ -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<CreditNoteNote>[] = [
{
accessorKey: "note",
header: ({ column }) => <ColumnHeader column={column} title="Note" />,
cell: ({ getValue }) => {
const val = getValue<string>()
return val || "—"
},
},
{
accessorKey: "created_at",
header: ({ column }) => <ColumnHeader column={column} title="Created" />,
cell: ({ getValue }) => {
const val = getValue<string>()
return val ? new Date(val).toLocaleDateString() : "—"
},
},
{
id: "actions",
header: () => <span className="sr-only">Actions</span>,
cell: ({ row }) => (
<Button
variant="ghost"
size="icon-sm"
onClick={() => handleDelete(row.original)}
title="Delete note"
>
<Trash2 className="size-4 text-destructive" />
</Button>
),
enableSorting: false,
},
]
const notes: CreditNoteNote[] = (data as any)?.internal_notes ?? []
return (
<div className="flex flex-col gap-4 p-4 lg:p-6">
<div className="flex items-center justify-end">
<Button onClick={() => setDialogOpen(true)}>
<Plus className="size-4" />
Add Note
</Button>
</div>
<Card>
<CardContent>
<DataTable
columns={columns}
data={notes}
pagination={{ page: 1, pageSize: 15, pageCount: 1, total: notes.length }}
sorting={[]}
onChange={() => {}}
isLoading={isLoading}
/>
</CardContent>
</Card>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Note</DialogTitle>
</DialogHeader>
<CreditNoteNoteForm
creditNoteId={creditNoteId}
onSuccess={() => {
setDialogOpen(false)
queryClient.invalidateQueries({ queryKey })
}}
/>
</DialogContent>
</Dialog>
</div>
)
}
@@ -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 (
<div className="p-4 lg:p-6">
<CreditNoteGeneralInfo creditNote={creditNote} />
</div>
)
}
@@ -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 (
<ResourcePage<CreditNotesClient>
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: (
<FormDialog title="Credit Note">
{(resourceId) => (
<CreditNoteForm
resourceId={resourceId}
initialData={selectedItem}
onSuccess={invalidateQuery}
/>
)}
</FormDialog>
),
})}
columns={({ actionsColumn }) => [
{
accessorKey: "subject",
header: ({ column }) => <ColumnHeader column={column} title="Subject" />,
},
{
accessorKey: "credit_invoice",
header: ({ column }) => <ColumnHeader column={column} title="Credit Note #" />,
},
{
accessorKey: "status",
header: ({ column }) => <ColumnHeader column={column} title="Status" />,
cell: ({ row }) => {
const item = row.original as unknown as CreditNoteItem
const status = item.status
const colorMap: Record<string, string> = {
draft: "text-muted-foreground",
open: "text-blue-600",
applied: "text-green-600",
void: "text-gray-400",
}
return (
<span className={colorMap[status ?? ""] ?? ""}>
{status ? status.charAt(0).toUpperCase() + status.slice(1) : "—"}
</span>
)
},
},
{
accessorKey: "date",
header: ({ column }) => <ColumnHeader column={column} title="Date" />,
},
actionsColumn(),
]}
/>
)
}
@@ -0,0 +1,40 @@
import { DashboardDetailsPage } from '@/base/components/layout/dashboard'
import { getServerApi } from '@garage/api/server'
import { CustomerActions } from '@/modules/customers/customer-actions'
import { CustomerProvider } from '@/modules/customers/customer-context'
import React from 'react'
export default async function layout(props: {
params: Promise<{ id: string }>
children: React.ReactNode
}) {
const { id } = await props.params
const api = await getServerApi()
const customer = await api.customers.getById(id)
const firstName = customer.data?.first_name ?? ''
const lastName = customer.data?.last_name ?? ''
const fullName = [firstName, lastName].filter(Boolean).join(' ') || 'Customer Details'
const customerLabel = fullName
return (
<>
<CustomerProvider customer={{ id, label: customerLabel }}>
<DashboardDetailsPage
className="p-0 lg:p-0"
title={fullName}
description={customer.data?.email ?? customer.data?.phone ?? undefined}
backHref="/sales/customers"
actions={<CustomerActions customerId={id} />}
tabs={[
{ href: `/sales/customers/${id}`, label: 'Details' },
{ href: `/sales/customers/${id}/notes`, label: 'Notes' },
{ href: `/sales/customers/${id}/vehicles`, label: 'Vehicles' },
]}
>
{props.children}
</DashboardDetailsPage>
</CustomerProvider>
</>
)
}
@@ -0,0 +1,207 @@
"use client"
import { useParams } from "next/navigation"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { type ColumnDef } from "@tanstack/react-table"
import { useState } from "react"
import { Plus, Trash2, StickyNote } from "lucide-react"
import { toast } from "sonner"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
import { useAuthApi } from "@/shared/useApi"
import { DataTable, ColumnHeader } from "@/shared/data-view/table-view"
import { confirm } from "@/shared/components/confirm-dialog"
import { Button } from "@/shared/components/ui/button"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog"
import {
Field,
FieldLabel,
FieldError,
} from "@/shared/components/ui/field"
import { Textarea } from "@/shared/components/ui/textarea"
import DashboardPage from "@/base/components/layout/dashboard/dashboard-page"
type CustomerNote = {
id: number
note: string
created_at: string
updated_at: string
}
const addNoteSchema = z.object({
note: z.string().min(1, "Note content is required"),
})
type AddNoteValues = z.infer<typeof addNoteSchema>
export default function CustomerNotesPage() {
const { id: customerId } = useParams<{ id: string }>()
const api = useAuthApi()
const queryClient = useQueryClient()
const [dialogOpen, setDialogOpen] = useState(false)
const queryKey = ["customer-notes", customerId]
const { data: customerData, isLoading } = useQuery({
queryKey,
queryFn: () => api.customers.getById(customerId),
})
const notes: CustomerNote[] = (customerData?.data as any)?.notes ?? []
const meta = (customerData as any)?.meta
const pagination = {
page: meta?.current_page ?? 1,
pageSize: meta?.per_page ?? 15,
pageCount: meta?.last_page ?? 1,
total: meta?.total ?? notes.length,
}
const addNoteMutation = useMutation({
mutationFn: (values: AddNoteValues) =>
api.customers.addNote(customerId, { note: values.note }),
onSuccess: () => {
toast.success("Note added successfully.")
queryClient.invalidateQueries({ queryKey })
setDialogOpen(false)
reset()
},
onError: () => {
toast.error("Failed to add note.")
},
})
const deleteNoteMutation = useMutation({
mutationFn: (noteId: number) => api.customers.deleteNote(customerId, noteId),
onSuccess: () => {
toast.success("Note deleted successfully.")
queryClient.invalidateQueries({ queryKey })
},
onError: () => {
toast.error("Failed to delete note.")
},
})
const {
handleSubmit,
register,
reset,
formState: { errors },
} = useForm<AddNoteValues>({
resolver: zodResolver(addNoteSchema),
defaultValues: { note: "" },
})
const handleDelete = async (note: CustomerNote) => {
const confirmed = await confirm({
title: "Delete Note",
description: "Are you sure you want to delete this note?",
confirmLabel: "Delete",
variant: "destructive",
})
if (confirmed) {
deleteNoteMutation.mutate(note.id)
}
}
const columns: ColumnDef<CustomerNote>[] = [
{
accessorKey: "note",
header: ({ column }) => <ColumnHeader column={column} title="Note" />,
cell: ({ getValue }) => (
<span className="whitespace-pre-wrap text-sm">{getValue<string>()}</span>
),
},
{
accessorKey: "created_at",
header: ({ column }) => <ColumnHeader column={column} title="Created At" />,
cell: ({ getValue }) => {
const val = getValue<string>()
return val ? new Date(val).toLocaleDateString() : "—"
},
},
{
id: "actions",
header: () => <span className="sr-only">Actions</span>,
cell: ({ row }) => (
<div className="flex justify-end">
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(row.original)}
disabled={deleteNoteMutation.isPending}
>
<Trash2 className="size-4 text-destructive" />
</Button>
</div>
),
},
]
return (
<DashboardPage
headerProps={{
title: "Notes",
actions: (
<Button size="sm" onClick={() => setDialogOpen(true)}>
<Plus className="size-4" />
Add Note
</Button>
),
}}
>
<DataTable
columns={columns}
data={notes}
pagination={pagination}
sorting={[]}
onChange={() => {}}
isLoading={isLoading}
/>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Note</DialogTitle>
</DialogHeader>
<form
onSubmit={handleSubmit((values) => addNoteMutation.mutate(values))}
className="grid gap-4"
>
<Field>
<FieldLabel>Note</FieldLabel>
<Textarea
{...register("note")}
placeholder="Enter note..."
rows={4}
/>
{errors.note && <FieldError>{errors.note.message}</FieldError>}
</Field>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => {
setDialogOpen(false)
reset()
}}
>
Cancel
</Button>
<Button type="submit" disabled={addNoteMutation.isPending}>
{addNoteMutation.isPending ? "Saving..." : "Save Note"}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</DashboardPage>
)
}
@@ -0,0 +1,19 @@
import { getServerApi } from '@garage/api/server'
import { CustomerGeneralInfo } from '@/modules/customers/customer-general-info'
import DashboardPage from '@/base/components/layout/dashboard/dashboard-page'
export default async function page(props: { params: Promise<{ id: string }> }) {
const { id } = await props.params
const api = await getServerApi()
const customer = await api.customers.getById(id)
if (!customer.data) {
return <div className="text-muted-foreground p-6">Customer not found.</div>
}
return (
<DashboardPage header={null}>
<CustomerGeneralInfo customer={customer.data} />
</DashboardPage>
)
}
@@ -0,0 +1,72 @@
"use client"
import { use } from "react"
import { ResourcePage } from "@/shared/data-view/resource-page"
import { ColumnHeader } from "@/shared/data-view/table-view"
import FormDialog from "@/shared/components/form-dialog"
import { VehicleForm } from "@/modules/vehicles/vehicle-form"
import { VEHICLE_ROUTES } from "@garage/api"
import type { VehiclesClient } from "@garage/api"
import { CarIcon } from "lucide-react"
import { useCustomer } from "@/modules/customers/customer-context"
export default function CustomerVehiclesPage({ params }: { params: Promise<{ id: string }> }) {
const { id: customerId } = use(params)
const customer = useCustomer()
return (
<ResourcePage<VehiclesClient>
tableHeader={({ invalidateQuery, selectedItem, closeDialog }) => (
<FormDialog title="Vehicle">
{(resourceId) => (
<VehicleForm
resourceId={resourceId}
initialData={selectedItem}
onSuccess={() => {
closeDialog()
invalidateQuery()
}}
/>
)}
</FormDialog>
)}
pageTitle="Customer Vehicles"
routeKey={VEHICLE_ROUTES.INDEX}
getClient={(api) => api.vehicles}
extraParams={{ customer_id: customerId }}
header={null}
columns={({ actionsColumn }) => [
{
accessorKey: "make",
header: ({ column }) => <ColumnHeader column={column} title="Vehicle" />,
cell: ({ row }) => {
const r = row.original as any
const make = r.make ?? ""
const model = r.model ?? ""
const display = `${make} ${model}`.trim() || "—"
return (
<div className="flex items-center gap-2">
<CarIcon className="size-4 text-muted-foreground" />
<span className="font-medium">{display}</span>
</div>
)
},
},
{
accessorKey: "year",
header: ({ column }) => <ColumnHeader column={column} title="Year" />,
cell: ({ row }) => (row.original as any).year ?? "—",
},
{
accessorKey: "license_plate",
header: ({ column }) => <ColumnHeader column={column} title="License Plate" />,
cell: ({ row }) => {
const val = (row.original as any).license_plate
return val ? <span className="font-mono text-xs">{val}</span> : "—"
},
},
actionsColumn(),
]}
/>
)
}
@@ -0,0 +1,82 @@
"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 { ImportDataButton } from '@/shared/components/import-data-button'
import { ExportDataButton } from '@/shared/components/export-data-button'
import { DownloadSampleButton } from '@/shared/components/download-sample-button'
import { useAuthApi } from '@/shared/useApi'
import { CustomerForm } from '@/modules/customers/customer-form'
import { CUSTOMER_ROUTES } from '@garage/api'
import type { CustomersClient } from '@garage/api'
import { Building2Icon, UserIcon } from 'lucide-react'
export default function CustomersPage() {
const router = useRouter()
const api = useAuthApi()
return (
<ResourcePage<CustomersClient>
pageTitle='Customers'
routeKey={CUSTOMER_ROUTES.INDEX}
getClient={(api) => api.customers}
onRowClick={(row) => router.push(`/sales/customers/${(row as any).id}`)}
headerProps={({ selectedItem, invalidateQuery }) => ({
actions: (
<div className='flex items-center gap-2'>
<DownloadSampleButton
onDownload={() => api.customers.downloadImportSample()}
fileName='customers-import-sample'
/>
<ImportDataButton
onImport={(file) => api.customers.importData(file)}
onSuccess={invalidateQuery}
/>
<ExportDataButton
onExport={(filters) => api.customers.exportData(filters)}
fileName='customers'
/>
<FormDialog title="Customer">
{(resourceId) => (
<CustomerForm
resourceId={resourceId}
initialData={selectedItem}
onSuccess={invalidateQuery}
/>
)}
</FormDialog>
</div>
),
})}
columns={({ actionsColumn }) => [
{
accessorKey: "first_name",
header: ({ column }) => <ColumnHeader column={column} title="Customer" />,
cell: ({ row }) => {
const customerName = row.original.first_name
const isCompany = (row.original as any).customer_type?.name?.toLocaleLowerCase() === "company";
const companyName = row.original.company_name
const name = isCompany && companyName ? `${customerName} (${row.original.last_name})` : customerName
return (<div className="flex items-center gap-2">
{isCompany ? <Building2Icon className="text-muted-foreground" /> : <UserIcon className="text-muted-foreground" />}
<span>{name}</span>
</div>
)
},
},
{
accessorKey: "email",
header: ({ column }) => <ColumnHeader column={column} title="Email" />,
},
{
accessorKey: "phone",
header: ({ column }) => <ColumnHeader column={column} title="Phone" />,
},
actionsColumn(),
]}
/>
)
}
@@ -0,0 +1,56 @@
import { DashboardDetailsPage } from '@/base/components/layout/dashboard'
import { getServerApi } from '@garage/api/server'
import { EstimateActions } from '@/modules/estimates/estimate-actions'
import { EstimateProvider } from '@/modules/estimates/estimate-context'
import { CreateInvoiceFromEstimateButton } from '@/modules/estimates/create-invoice-from-estimate-button'
import { CreateJobCardFromEstimateButton } from '@/modules/estimates/create-job-card-from-estimate-button'
import { ShareDocumentButton } from '@/shared/components/share-document-button'
import { FileTextIcon } from 'lucide-react'
import React from 'react'
import { formatDate } from '@/shared/utils/formatters'
export default async function layout(props: {
params: Promise<{ id: string }>
children: React.ReactNode
}) {
const { id } = await props.params
const api = await getServerApi()
const estimate = await api.estimates.show(id)
const estimateData = estimate?.data
const title = estimateData?.title || estimateData?.estimate_number || `Estimate #${id}`
const estimateLabel = estimateData?.estimate_number
? `${estimateData.estimate_number}${estimateData.title ? ` — ${estimateData.title}` : ''}`
: title
if (!estimateData) {
return <div className="text-muted-foreground p-4">Estimate not found.</div>
}
return (
<EstimateProvider estimate={estimateData}>
<DashboardDetailsPage
className="p-0 lg:p-0"
icon={<FileTextIcon className="size-5" />}
title={title}
description={
estimateData?.date ? `Date: ${formatDate(estimateData.date)}` : undefined
}
backHref="/sales/estimates"
actions={
<div className="flex items-center gap-2">
<CreateInvoiceFromEstimateButton />
<CreateJobCardFromEstimateButton />
<ShareDocumentButton type="estimate" id={id} />
<EstimateActions estimateId={id} />
</div>
}
tabs={[
{ href: `/sales/estimates/${id}`, label: 'Details' },
]}
>
{props.children}
</DashboardDetailsPage>
</EstimateProvider>
)
}
@@ -0,0 +1,32 @@
import { getServerApi } from '@garage/api/server'
import { EstimateGeneralInfo } from '@/modules/estimates/estimate-general-info'
import { EstimateServicesSection } from '@/modules/estimates/estimate-services-section'
import { EstimatePartsSection } from '@/modules/estimates/estimate-parts-section'
import { EstimateExpenseItemsSection } from '@/modules/estimates/estimate-expense-items-section'
import { EstimateTotalsSummary } from '@/modules/estimates/estimate-totals-summary'
import DashboardPage from '@/base/components/layout/dashboard/dashboard-page'
export default async function page(props: { params: Promise<{ id: string }> }) {
const { id } = await props.params
const api = await getServerApi()
const estimate = await api.estimates.show(id)
const estimateData = estimate?.data
if (!estimateData) {
return <div className="text-muted-foreground p-4">Estimate not found.</div>
}
return (
<DashboardPage header={null}>
<div className="grid gap-6">
<EstimateGeneralInfo estimate={estimateData} />
<EstimateServicesSection estimateId={id} />
<EstimatePartsSection estimateId={id} />
<EstimateExpenseItemsSection estimateId={id} />
<EstimateTotalsSummary />
</div>
</DashboardPage>
)
}
@@ -0,0 +1,112 @@
"use client"
import { useRouter } from 'next/navigation'
import { ResourcePage } from '@/shared/data-view/resource-page'
import { ColumnHeader } from '@/shared/data-view/table-view'
import FormDialog from '@/shared/components/form-dialog'
import { EstimateForm } from '@/modules/estimates/estimate-form'
import { ESTIMATE_ROUTES } from '@garage/api'
import type { EstimatesClient } from '@garage/api'
import { Car, FileTextIcon, UserIcon } from 'lucide-react'
import { Button } from '@/shared/components/ui/button'
import Link from 'next/link'
import { formatDate } from '@/shared/utils/formatters'
import { getVehicleLabel } from '@/modules/vehicles/utils/getVehicleLabel'
import { getFullName } from '@/shared/utils/getFullName'
export default function EstimatesPage() {
const router = useRouter()
return (
<ResourcePage<EstimatesClient>
pageTitle="Estimates"
routeKey={ESTIMATE_ROUTES.INDEX}
getClient={(api) => api.estimates}
onRowClick={(row) => router.push(`/sales/estimates/${(row as any).id}`)}
headerProps={({ selectedItem, invalidateQuery }) => ({
actions: (
<FormDialog title="Estimate">
{(resourceId) => (
<EstimateForm
resourceId={resourceId}
onSuccess={invalidateQuery}
/>
)}
</FormDialog>
),
})}
columns={({ actionsColumn }) => [
{
accessorKey: "title",
header: ({ column }) => <ColumnHeader column={column} title="Title" />,
cell: ({ row }) => {
const item = row.original
return (
<div className="flex items-center gap-2">
<Link href={`/sales/estimates/${item.id}`} className="flex items-center gap-2 hover:underline" onClick={(e) => e.stopPropagation()}>
<FileTextIcon className="text-muted-foreground h-4 w-4" />
<span>{item.title}</span>
</Link>
</div>
)
},
},
{
accessorKey: "estimate_number",
header: ({ column }) => <ColumnHeader column={column} title="Estimate #" />,
},
{
accessorKey: "customer_name",
header: ({ column }) => <ColumnHeader column={column} title="Customer" />,
cell: ({ row }) => {
const item:any = row.original
if (!item.customer?.id) return "—"
return (
<Button variant="outline" asChild size="sm" onClick={(e) => e.stopPropagation()}>
<Link href={`/sales/customers/${item.customer.id}`}>
<UserIcon /> {getFullName(item.customer) || "—"}
</Link>
</Button>
)
}
},
{
accessorKey: "vehicle",
header: ({ column }) => <ColumnHeader column={column} title="Vehicle" />,
cell: ({ row }) => {
const item :any= row.original
return <Button variant="outline" asChild size="sm" onClick={(e) => e.stopPropagation()}>
<Link href={`/sales/vehicles/${item.vehicle?.id}`}>
<Car/> {getVehicleLabel(item.vehicle as any) || "—"}
</Link>
</Button>
}
},
{
accessorKey: "date",
header: ({ column }) => <ColumnHeader column={column} title="Date" />,
cell: ({ row }) => {
const item = row.original
return formatDate(item.date)
}
},
{
accessorKey: "has_insurance",
header: ({ column }) => <ColumnHeader column={column} title="Insurance" />,
cell: ({ row }) => {
const item = row.original
return item.has_insurance ? "Yes" : "No"
},
},
{
accessorKey: "created_at",
header: ({ column }) => <ColumnHeader column={column} title="Created" />,
cell: ({ row }) => {
const item = row.original
return item.created_at ? new Date(item.created_at).toLocaleDateString() : "—"
},
},
actionsColumn(),
]}
/>
)
}
@@ -0,0 +1,589 @@
"use client"
import { use, useState, useRef, useCallback } from "react"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { useAuthApi } from "@/shared/useApi"
import { DataTable, ColumnHeader } from "@/shared/data-view/table-view"
import DashboardPage from "@/base/components/layout/dashboard/dashboard-page"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Textarea } from "@/shared/components/ui/textarea"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import { toast } from "sonner"
import {
Plus,
Ellipsis,
Pencil,
Trash2,
CheckCircle2,
AlertTriangle,
XCircle,
MinusCircle,
CircleDot,
Paperclip,
FileUp,
FileText,
FileImage,
File,
X,
} from "lucide-react"
import { INSPECTION_ROUTES } from "@garage/api"
// ── Types ──
type CheckpointItem = {
id: number
inspection_id?: number
name?: string
description?: string
record_type?: string
condition_rate?: number
file?: string
status?: string
created_at?: string
updated_at?: string
}
// ── Constants ──
const CHECKPOINT_STATUSES = [
{ value: "passed", label: "Passed", icon: CheckCircle2, color: "bg-green-100 text-green-800" },
{ value: "need_attention", label: "Need Attention", icon: AlertTriangle, color: "bg-yellow-100 text-yellow-800" },
{ value: "failed", label: "Failed", icon: XCircle, color: "bg-red-100 text-red-800" },
{ value: "not_applicable", label: "Not Applicable", icon: MinusCircle, color: "bg-gray-100 text-gray-800" },
{ value: "not_inspected", label: "Not Inspected", icon: CircleDot, color: "bg-blue-100 text-blue-800" },
] as const
const RECORD_TYPES = [
{ value: "record_conditions", label: "Record Conditions" },
{ value: "record_audio", label: "Record Audio" },
{ value: "record_video", label: "Record Video" },
{ value: "capture_photo", label: "Capture Photo" },
] as const
function formatStatus(status?: string) {
if (!status) return "Not Inspected"
return status.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())
}
function getStatusConfig(status?: string) {
return CHECKPOINT_STATUSES.find((s) => s.value === status) || CHECKPOINT_STATUSES[4]
}
// ── Checkpoint Form Dialog ──
function CheckpointFormDialog({
open,
onOpenChange,
inspectionId,
checkpoint,
onSuccess,
}: {
open: boolean
onOpenChange: (open: boolean) => void
inspectionId: string
checkpoint?: CheckpointItem | null
onSuccess: () => void
}) {
const api = useAuthApi()
const [name, setName] = useState(checkpoint?.name ?? "")
const [description, setDescription] = useState(checkpoint?.description ?? "")
const [recordType, setRecordType] = useState(checkpoint?.record_type ?? "record_conditions")
const [isPending, setIsPending] = useState(false)
const isEditing = !!checkpoint
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!name.trim()) {
toast.error("Name is required")
return
}
setIsPending(true)
try {
const payload = {
inspection_id: Number(inspectionId),
name: name.trim(),
description: description.trim() || undefined,
record_type: recordType,
}
if (isEditing) {
const promise = api.inspections.updateCheckpoint(String(checkpoint.id), payload)
toast.promise(promise, {
loading: "Updating checkpoint...",
success: "Checkpoint updated",
error: "Failed to update checkpoint",
})
await promise
} else {
const promise = api.inspections.createCheckpoint(payload)
toast.promise(promise, {
loading: "Creating checkpoint...",
success: "Checkpoint created",
error: "Failed to create checkpoint",
})
await promise
}
onSuccess()
onOpenChange(false)
} finally {
setIsPending(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{isEditing ? "Edit Checkpoint" : "Add Checkpoint"}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="cp-name">Name *</Label>
<Input
id="cp-name"
placeholder="e.g. Engine Oil Level"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="cp-description">Description</Label>
<Textarea
id="cp-description"
placeholder="Check oil level and condition"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="cp-record-type">Record Type</Label>
<Select value={recordType} onValueChange={setRecordType}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{RECORD_TYPES.map((rt) => (
<SelectItem key={rt.value} value={rt.value}>
{rt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button type="submit" disabled={isPending}>
{isEditing ? <Pencil className="size-4" /> : <Plus className="size-4" />}
{isPending
? (isEditing ? "Updating..." : "Creating...")
: (isEditing ? "Update Checkpoint" : "Create Checkpoint")}
</Button>
</form>
</DialogContent>
</Dialog>
)
}
// ── Attachments Dialog ──
function getFileIcon(url: string) {
const ext = url.split(".").pop()?.toLowerCase()
if (["png", "jpg", "jpeg", "gif", "webp", "svg"].includes(ext ?? "")) return FileImage
if (ext === "pdf") return FileText
return File
}
function getFileName(url: string) {
try {
return decodeURIComponent(url.split("/").pop() ?? "Attachment")
} catch {
return url.split("/").pop() ?? "Attachment"
}
}
function isImageUrl(url: string) {
const ext = url.split(".").pop()?.toLowerCase()
return ["png", "jpg", "jpeg", "gif", "webp", "svg"].includes(ext ?? "")
}
function CheckpointAttachmentsDialog({
open,
onOpenChange,
checkpoint,
onSuccess,
}: {
open: boolean
onOpenChange: (open: boolean) => void
checkpoint: CheckpointItem | null
onSuccess: () => void
}) {
const api = useAuthApi()
const fileInputRef = useRef<HTMLInputElement>(null)
const [isUploading, setIsUploading] = useState(false)
const handleUpload = useCallback(async (file: globalThis.File) => {
if (!checkpoint) return
setIsUploading(true)
try {
const promise = api.inspections.uploadCheckpointMedia(
String(checkpoint.id),
{ file },
)
toast.promise(promise, {
loading: "Uploading attachment...",
success: "Attachment uploaded",
error: "Failed to upload attachment",
})
await promise
onSuccess()
} finally {
setIsUploading(false)
if (fileInputRef.current) fileInputRef.current.value = ""
}
}, [api, checkpoint, onSuccess])
const handleDelete = useCallback(async () => {
if (!checkpoint) return
const promise = api.inspections.deleteCheckpointMedia(String(checkpoint.id))
toast.promise(promise, {
loading: "Removing attachment...",
success: "Attachment removed",
error: "Failed to remove attachment",
})
await promise
onSuccess()
}, [api, checkpoint, onSuccess])
const hasFile = !!checkpoint?.file
const FileIcon = checkpoint?.file ? getFileIcon(checkpoint.file) : File
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Attachments — {checkpoint?.name}</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4">
{/* Current attachment */}
{hasFile ? (
<div className="rounded-lg border p-3">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3 min-w-0 ">
{isImageUrl(checkpoint.file!) ? (
<img
src={checkpoint.file!}
alt="Checkpoint attachment"
className="size-12 rounded-md object-cover"
/>
) : (
<div className="flex size-12 shrink-0 items-center justify-center rounded-md bg-muted">
<FileIcon className="size-5 text-muted-foreground" />
</div>
)}
<div className="min-w-0 flex flex-col gap-0.5 max-w-48">
<a
href={checkpoint.file!}
target="_blank"
rel="noopener noreferrer"
className="truncate text-sm font-medium text-primary hover:underline"
>
{getFileName(checkpoint.file!)}
</a>
<span className="text-xs text-muted-foreground">
Current attachment
</span>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="shrink-0 text-destructive hover:text-destructive"
onClick={handleDelete}
title="Remove attachment"
>
<Trash2 className="size-4" />
</Button>
</div>
</div>
) : (
<div className="flex flex-col items-center gap-2 rounded-lg border border-dashed p-6 text-muted-foreground">
<Paperclip className="size-8" />
<span className="text-sm">No attachments yet</span>
</div>
)}
{/* Upload area */}
<input
ref={fileInputRef}
type="file"
className="hidden"
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.csv,.txt,audio/*,video/*"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) handleUpload(file)
}}
/>
<Button
variant="outline"
className="w-full"
disabled={isUploading}
onClick={() => fileInputRef.current?.click()}
>
<FileUp className="size-4" />
{isUploading
? "Uploading..."
: hasFile
? "Replace Attachment"
: "Add Attachment"}
</Button>
</div>
</DialogContent>
</Dialog>
)
}
// ── Main Page ──
export default function InspectionCheckpointsPage({ params }: { params: Promise<{ id: string }> }) {
const { id: inspectionId } = use(params)
const api = useAuthApi()
const queryClient = useQueryClient()
const [formOpen, setFormOpen] = useState(false)
const [editingCheckpoint, setEditingCheckpoint] = useState<CheckpointItem | null>(null)
const [attachmentsCheckpoint, setAttachmentsCheckpoint] = useState<CheckpointItem | null>(null)
const queryKey = [INSPECTION_ROUTES.CHECKPOINTS, inspectionId]
const { data, isLoading } = useQuery({
queryKey,
queryFn: () => api.inspections.listCheckpoints({ inspection_id: inspectionId } as never),
})
const invalidate = () => {
queryClient.invalidateQueries({ queryKey })
}
// ── Status change mutation ──
const changeStatusMutation = useMutation({
mutationFn: ({ checkpointId, status }: { checkpointId: number; status: string }) =>
api.inspections.changeCheckpointStatus({
inspection_id: Number(inspectionId),
inspection_check_point_id: String(checkpointId),
name: "", // required by API type but server uses checkpoint id from context
record_type: "record_conditions",
status,
} as never),
onSuccess: () => invalidate(),
})
// ── Delete checkpoint mutation ──
const deleteMutation = useMutation({
mutationFn: (checkpointId: string) => api.inspections.destroyCheckpoint(checkpointId),
onSuccess: () => {
toast.success("Checkpoint deleted")
invalidate()
},
onError: () => toast.error("Failed to delete checkpoint"),
})
const handleEdit = (checkpoint: CheckpointItem) => {
setEditingCheckpoint(checkpoint)
setFormOpen(true)
}
const handleAdd = () => {
setEditingCheckpoint(null)
setFormOpen(true)
}
const handleStatusChange = (checkpointId: number, status: string) => {
const promise = changeStatusMutation.mutateAsync({ checkpointId, status, })
toast.promise(promise, {
loading: "Updating status...",
success: `Status changed to ${formatStatus(status)}`,
error: "Failed to update status",
})
}
const checkpoints = (data as any)?.data ?? []
const meta = (data as any)?.meta
const pagination = {
page: meta?.current_page ?? 1,
pageSize: meta?.per_page ?? 15,
pageCount: meta?.last_page ?? 1,
total: meta?.total ?? 0,
}
const columns = [
{
accessorKey: "name",
header: ({ column }: any) => <ColumnHeader column={column} title="Name" />,
cell: ({ row }: any) => {
const item = row.original as CheckpointItem
return (
<div className="flex flex-col gap-0.5">
<span className="font-medium">{item.name}</span>
{item.description && (
<span className="text-xs text-muted-foreground">{item.description}</span>
)}
</div>
)
},
},
{
accessorKey: "record_type",
header: ({ column }: any) => <ColumnHeader column={column} title="Record Type" />,
cell: ({ row }: any) => {
const item = row.original as CheckpointItem
const rt = RECORD_TYPES.find((r) => r.value === item.record_type)
return rt?.label ?? item.record_type ?? "—"
},
},
{
accessorKey: "status",
header: ({ column }: any) => <ColumnHeader column={column} title="Status" />,
cell: ({ row }: any) => {
const item = row.original as CheckpointItem
const config = getStatusConfig(item.status)
return (
<Badge className={config.color}>
<config.icon className="mr-1 size-3" />
{formatStatus(item.status)}
</Badge>
)
},
},
{
accessorKey: "condition_rate",
header: ({ column }: any) => <ColumnHeader column={column} title="Condition" />,
cell: ({ row }: any) => {
const item = row.original as CheckpointItem
return item.condition_rate != null ? `${item.condition_rate}/10` : "—"
},
},
{
id: "attachments",
header: () => <span className="text-xs">Attachments</span>,
cell: ({ row }: any) => {
const item = row.original as CheckpointItem
return (
<Button
variant="ghost"
size="sm"
className="gap-1.5"
onClick={() => setAttachmentsCheckpoint(item)}
>
<Paperclip className="size-3.5" />
{item.file ? (
<Badge variant="secondary" className="px-1.5 py-0 text-xs">1</Badge>
) : (
<span className="text-xs text-muted-foreground">0</span>
)}
</Button>
)
},
enableSorting: false,
},
{
id: "actions",
cell: ({ row }: any) => {
const item = row.original as CheckpointItem
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-8">
<Ellipsis className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{CHECKPOINT_STATUSES.map((s) => (
<DropdownMenuItem
key={s.value}
onClick={() => handleStatusChange(item.id, s.value)}
disabled={item.status === s.value}
>
<s.icon className="size-4" />
{s.label}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => handleEdit(item)}>
<Pencil className="size-4" />
Edit
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onClick={() => deleteMutation.mutate(String(item.id))}
>
<Trash2 className="size-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
},
},
]
return (
<DashboardPage header={null}>
<div className="flex items-center justify-between p-4">
<h3 className="text-lg font-semibold">Checkpoints</h3>
<Button onClick={handleAdd} size="sm">
<Plus className="size-4" />
Add Checkpoint
</Button>
</div>
<DataTable
columns={columns}
data={checkpoints}
pagination={pagination}
sorting={[]}
onChange={() => {}}
isLoading={isLoading}
/>
<CheckpointFormDialog
open={formOpen}
onOpenChange={(open) => {
setFormOpen(open)
if (!open) setEditingCheckpoint(null)
}}
inspectionId={inspectionId}
checkpoint={editingCheckpoint}
onSuccess={invalidate}
/>
<CheckpointAttachmentsDialog
open={!!attachmentsCheckpoint}
onOpenChange={(open) => {
if (!open) setAttachmentsCheckpoint(null)
}}
checkpoint={attachmentsCheckpoint}
onSuccess={invalidate}
/>
</DashboardPage>
)
}
@@ -0,0 +1,42 @@
"use client"
import { use } from "react"
import { useQuery } from "@tanstack/react-query"
import { useAuthApi } from "@/shared/useApi"
import { useRouter } from "next/navigation"
import { InspectionForm } from "@/modules/inspections/inspection-form"
import DashboardPage from "@/base/components/layout/dashboard/dashboard-page"
import { INSPECTION_ROUTES } from "@garage/api"
export default function InspectionEditPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params)
const api = useAuthApi()
const router = useRouter()
const { data, isLoading } = useQuery({
queryKey: [INSPECTION_ROUTES.BY_ID, id],
queryFn: () => api.inspections.getById(id),
})
if (isLoading) {
return (
<DashboardPage header={null}>
<div className="flex items-center justify-center p-8 text-muted-foreground">
Loading...
</div>
</DashboardPage>
)
}
return (
<DashboardPage header={null}>
<div className="mx-auto max-w-2xl p-6">
<InspectionForm
resourceId={id}
initialData={data}
onSuccess={() => router.push(`/sales/inspections/${id}`)}
/>
</div>
</DashboardPage>
)
}
@@ -0,0 +1,48 @@
import { DashboardDetailsPage } from '@/base/components/layout/dashboard'
import { getServerApi } from '@garage/api/server'
import { InspectionActions } from '@/modules/inspections/inspection-actions'
import { InspectionProvider } from '@/modules/inspections/inspection-context'
import { ShareDocumentButton } from '@/shared/components/share-document-button'
import React from 'react'
export default async function layout(props: {
params: Promise<{ id: string }>
children: React.ReactNode
}) {
const { id } = await props.params
const api = await getServerApi()
const inspection = await api.inspections.getById(id)
const title = inspection.data?.title || 'Inspection Details'
const orderNumber = inspection.data?.order_number
const status = inspection.data?.status
return (
<InspectionProvider inspection={{ id, label: title }}>
<DashboardDetailsPage
className="p-0 lg:p-0"
title={title}
description={orderNumber ? `Order: ${orderNumber}` : undefined}
backHref="/sales/inspections"
actions={
<div className="flex items-center gap-2">
<ShareDocumentButton type="inspection" id={id} />
<InspectionActions inspectionId={id} status={status} />
</div>
}
tabs={[
{
href: `/sales/inspections/${id}`,
label: 'Details',
},
{
href: `/sales/inspections/${id}/checkpoints`,
label: 'Checkpoints',
},
]}
>
{props.children}
</DashboardDetailsPage>
</InspectionProvider>
)
}
@@ -0,0 +1,19 @@
import { getServerApi } from '@garage/api/server'
import { InspectionGeneralInfo } from '@/modules/inspections/inspection-general-info'
import DashboardPage from '@/base/components/layout/dashboard/dashboard-page'
export default async function page(props: { params: Promise<{ id: string }> }) {
const { id } = await props.params
const api = await getServerApi()
const inspection = await api.inspections.getById(id)
if (!inspection.data) {
return <div className="text-muted-foreground">Inspection not found.</div>
}
return (
<DashboardPage header={null}>
<InspectionGeneralInfo inspection={inspection.data} />
</DashboardPage>
)
}
@@ -0,0 +1,75 @@
"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"
export default function InspectionsPage() {
const router = useRouter()
return (
<ResourcePage<InspectionsClient>
pageTitle="Inspections"
routeKey={INSPECTION_ROUTES.INDEX}
getClient={(api) => api.inspections}
onRowClick={(row) => router.push(`/sales/inspections/${(row as any).id}`)}
headerProps={({ selectedItem, invalidateQuery }) => ({
actions: (
<FormDialog title="Inspection">
{(resourceId) => (
<InspectionForm
resourceId={resourceId}
initialData={selectedItem}
onSuccess={invalidateQuery}
/>
)}
</FormDialog>
),
})}
columns={({ actionsColumn }) => [
{
accessorKey: "title",
header: ({ column }) => <ColumnHeader column={column} title="Title" />,
},
{
accessorKey: "customer",
header: ({ column }) => <ColumnHeader column={column} title="Customer" />,
cell: ({ row }) => {
const c = (row.original as any).customer
return c ? `${c.first_name ?? ""} ${c.last_name ?? ""}`.trim() : "—"
},
},
{
accessorKey: "vehicle",
header: ({ column }) => <ColumnHeader column={column} title="Vehicle" />,
cell: ({ row }) => {
const v = (row.original as any).vehicle
return v ? `${v.make ?? ""} ${v.model ?? ""}`.trim() : "—"
},
},
{
accessorKey: "inspection_category",
header: ({ column }) => <ColumnHeader column={column} title="Category" />,
cell: ({ row }) => (row.original as any).inspection_category?.name ?? "—",
},
{
accessorKey: "status",
header: ({ column }) => <ColumnHeader column={column} title="Status" />,
cell: ({ row }) => {
const status = (row.original as any).status
return (
<span className={status === "completed" ? "text-green-600" : "text-yellow-600"}>
{status ?? "—"}
</span>
)
},
},
actionsColumn(),
]}
/>
)
}
@@ -0,0 +1,162 @@
"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 { InvoiceDocumentForm } from "@/modules/invoices/invoice-document-form"
type InvoiceDocument = {
id: number
document_number?: string
show_in_invoice?: boolean
show_in_estimate?: boolean
show_in_statement?: boolean
created_at: string
updated_at: string
}
export default function InvoiceDocumentsPage() {
const { id: invoiceId } = useParams<{ id: string }>()
const api = useAuthApi()
const queryClient = useQueryClient()
const [dialogOpen, setDialogOpen] = useState(false)
const queryKey = ["invoice-documents", invoiceId]
const { data, isLoading } = useQuery({
queryKey,
queryFn: () => api.invoices.listDocuments({ invoice_id: invoiceId }),
})
const deleteMutation = useMutation({
mutationFn: (id: number) => api.invoices.destroyDocument(String(id)),
onSuccess: () => {
toast.success("Document deleted successfully.")
queryClient.invalidateQueries({ queryKey })
},
onError: () => {
toast.error("Failed to delete document.")
},
})
const handleDelete = async (doc: InvoiceDocument) => {
const confirmed = await confirm({
title: "Delete Document",
description: `Are you sure you want to delete "${doc.document_number || "this document"}"?`,
confirmLabel: "Delete",
variant: "destructive",
})
if (confirmed) {
deleteMutation.mutate(doc.id)
}
}
const columns: ColumnDef<InvoiceDocument>[] = [
{
accessorKey: "document_number",
header: ({ column }) => <ColumnHeader column={column} title="Document Number" />,
},
{
accessorKey: "show_in_invoice",
header: ({ column }) => <ColumnHeader column={column} title="Show in Invoice" />,
cell: ({ getValue }) => (getValue<boolean>() ? "Yes" : "No"),
},
{
accessorKey: "show_in_estimate",
header: ({ column }) => <ColumnHeader column={column} title="Show in Estimate" />,
cell: ({ getValue }) => (getValue<boolean>() ? "Yes" : "No"),
},
{
accessorKey: "show_in_statement",
header: ({ column }) => <ColumnHeader column={column} title="Show in Statement" />,
cell: ({ getValue }) => (getValue<boolean>() ? "Yes" : "No"),
},
{
accessorKey: "created_at",
header: ({ column }) => <ColumnHeader column={column} title="Created" />,
cell: ({ getValue }) => {
const val = getValue<string>()
return val ? new Date(val).toLocaleDateString() : "—"
},
},
{
id: "actions",
header: () => <span className="sr-only">Actions</span>,
cell: ({ row }) => (
<Button
variant="ghost"
size="icon-sm"
onClick={() => handleDelete(row.original)}
title="Delete document"
>
<Trash2 className="size-4 text-destructive" />
</Button>
),
enableSorting: false,
},
]
const documents = (data as any)?.data ?? []
const meta = (data as any)?.meta
const pagination = {
page: meta?.current_page ?? 1,
pageSize: meta?.per_page ?? 15,
pageCount: meta?.last_page ?? 1,
total: meta?.total ?? 0,
}
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-end">
<Button onClick={() => setDialogOpen(true)}>
<Plus className="size-4" />
Add Document
</Button>
</div>
<Card>
<CardContent>
<DataTable
columns={columns}
data={documents}
pagination={pagination}
sorting={[]}
onChange={() => {}}
isLoading={isLoading}
/>
</CardContent>
</Card>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Document</DialogTitle>
</DialogHeader>
<InvoiceDocumentForm
invoiceId={invoiceId}
onSuccess={() => {
setDialogOpen(false)
queryClient.invalidateQueries({ queryKey })
}}
/>
</DialogContent>
</Dialog>
</div>
)
}
@@ -0,0 +1,52 @@
import { DashboardDetailsPage } from '@/base/components/layout/dashboard'
import { getServerApi } from '@garage/api/server'
import { InvoiceActions } from '@/modules/invoices/invoice-actions'
import { InvoiceProvider } from '@/modules/invoices/invoice-context'
import { ReceiptIcon } from 'lucide-react'
import React from 'react'
import InvoiceStatusBadge from '@/modules/invoices/invoice-status-badge'
import { ShareDocumentButton } from '@/shared/components/share-document-button'
export default async function InvoiceDetailLayout(props: { params: Promise<{ id: string }>, children: React.ReactNode }) {
const { id } = await props.params
const api = await getServerApi()
const invoice = await api.invoices.show(id)
const data = (invoice as any)?.data ?? invoice
const title = data?.subject || data?.invoice_number || 'Invoice Details'
return (
<InvoiceProvider invoice={data}>
<DashboardDetailsPage
title={title}
description={data?.invoice_number ? `Invoice #: ${data.invoice_number}` : undefined}
icon={<ReceiptIcon className="size-5" />}
backHref="/sales/invoice"
actions={
<div className="flex space-x-2 items-center">
<InvoiceStatusBadge invoice={{id, status:data?.status}} />
<ShareDocumentButton type="invoice" id={id} />
<InvoiceActions invoiceId={id} />
</div>
}
tabs={[
{
href: `/sales/invoice/${id}`,
label: 'Details'
},
{
href: `/sales/invoice/${id}/documents`,
label: 'Documents'
},
{
href: `/sales/invoice/${id}/notes`,
label: 'Notes'
},
]}
>
{props.children}
</DashboardDetailsPage>
</InvoiceProvider>
)
}
@@ -0,0 +1,148 @@
"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 { InvoiceNoteForm } from "@/modules/invoices/invoice-note-form"
type InvoiceNote = {
id: number
note?: string
created_at: string
updated_at: string
}
export default function InvoiceNotesPage() {
const { id: invoiceId } = useParams<{ id: string }>()
const api = useAuthApi()
const queryClient = useQueryClient()
const [dialogOpen, setDialogOpen] = useState(false)
const queryKey = ["invoice-notes", invoiceId]
const { data, isLoading } = useQuery({
queryKey,
queryFn: () => api.invoices.listNotes({ invoice_id: invoiceId }),
})
const deleteMutation = useMutation({
mutationFn: (id: number) => api.invoices.destroyNote(String(id)),
onSuccess: () => {
toast.success("Note deleted successfully.")
queryClient.invalidateQueries({ queryKey })
},
onError: () => {
toast.error("Failed to delete note.")
},
})
const handleDelete = async (note: InvoiceNote) => {
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<InvoiceNote>[] = [
{
accessorKey: "note",
header: ({ column }) => <ColumnHeader column={column} title="Note" />,
cell: ({ getValue }) => {
const val = getValue<string>()
return val || "—"
},
},
{
accessorKey: "created_at",
header: ({ column }) => <ColumnHeader column={column} title="Created" />,
cell: ({ getValue }) => {
const val = getValue<string>()
return val ? new Date(val).toLocaleDateString() : "—"
},
},
{
id: "actions",
header: () => <span className="sr-only">Actions</span>,
cell: ({ row }) => (
<Button
variant="ghost"
size="icon-sm"
onClick={() => handleDelete(row.original)}
title="Delete note"
>
<Trash2 className="size-4 text-destructive" />
</Button>
),
enableSorting: false,
},
]
const notes = (data as any)?.data ?? []
const meta = (data as any)?.meta
const pagination = {
page: meta?.current_page ?? 1,
pageSize: meta?.per_page ?? 15,
pageCount: meta?.last_page ?? 1,
total: meta?.total ?? 0,
}
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-end">
<Button onClick={() => setDialogOpen(true)}>
<Plus className="size-4" />
Add Note
</Button>
</div>
<Card>
<CardContent>
<DataTable
columns={columns}
data={notes}
pagination={pagination}
sorting={[]}
onChange={() => {}}
isLoading={isLoading}
/>
</CardContent>
</Card>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Note</DialogTitle>
</DialogHeader>
<InvoiceNoteForm
invoiceId={invoiceId}
onSuccess={() => {
setDialogOpen(false)
queryClient.invalidateQueries({ queryKey })
}}
/>
</DialogContent>
</Dialog>
</div>
)
}
@@ -0,0 +1,33 @@
import { getServerApi } from '@garage/api/server'
import { InvoiceGeneralInfo } from '@/modules/invoices/invoice-general-info'
import { InvoicePartsSection } from '@/modules/invoices/invoice-parts-section'
import { InvoiceServicesSection } from '@/modules/invoices/invoice-services-section'
import { InvoiceExpensesSection } from '@/modules/invoices/invoice-expenses-section'
import { InvoiceTotalsSummary } from '@/modules/invoices/invoice-totals-summary'
import DashboardPage from '@/base/components/layout/dashboard/dashboard-page'
import InvoicePaymentsSection from '@/modules/invoices/invoice-payments-section'
export default async function InvoiceDetailPage(props: { params: Promise<{ id: string }> }) {
const { id } = await props.params
const api = await getServerApi()
const invoice = await api.invoices.show(id)
const data = (invoice as any)?.data ?? invoice
if (!data) {
return <div className="text-muted-foreground">Invoice not found.</div>
}
return (
<DashboardPage header={null}>
<div className="grid gap-6">
<InvoiceGeneralInfo />
<InvoicePartsSection parts={data.invoice_parts} />
<InvoiceServicesSection services={data.invoice_services} />
<InvoiceExpensesSection expenses={data.invoice_expenses} />
<InvoicePaymentsSection></InvoicePaymentsSection>
<InvoiceTotalsSummary />
</div>
</DashboardPage>
)
}
@@ -0,0 +1,258 @@
"use client"
import Link from "next/link"
import { UserIcon } from "lucide-react"
import { useRouter } from "next/navigation"
import { ResourcePage } from "@/shared/data-view/resource-page"
import { ColumnHeader } from "@/shared/data-view/table-view"
import FormDialog from "@/shared/components/form-dialog"
import { InvoiceForm } from "@/modules/invoices/invoice-form"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { formatCurrency, formatDate, formatEnum } from "@/shared/utils/formatters"
import { INVOICE_ROUTES } from "@garage/api"
import type { InvoicesClient } from "@garage/api"
type InvoiceItem = {
id: number
subject?: string
invoice_title?: string
invoice_number?: string
customer_name?: string
status?: string
invoice_date?: string
due_date?: string
created_at?: string
total?: number | string
balance_due?: number | string
customer?: {
id?: number | string
first_name?: string
last_name?: string
company_name?: string
phone?: string
} | null
vehicle?: {
make?: string
model?: string
sub_model?: string
year?: number | string
license_plate?: string
} | null
}
function getCustomerLabel(item: InvoiceItem): string {
const firstName = item.customer?.first_name?.trim()
const lastName = item.customer?.last_name?.trim()
const fullName = [firstName, lastName].filter(Boolean).join(" ").trim()
return fullName || item.customer_name || item.customer?.company_name || "—"
}
function getVehicleLabel(item: InvoiceItem): string {
const vehicle = item.vehicle
if (!vehicle) return "—"
const core = [vehicle.make, vehicle.model, vehicle.sub_model].filter(Boolean).join(" ")
const suffix = [vehicle.year, vehicle.license_plate].filter(Boolean).join(" • ")
if (core && suffix) return `${core} (${suffix})`
return core || suffix || "—"
}
function getDueMeta(dueDate?: string) {
if (!dueDate) return { text: "No due date", tone: "muted" as const }
const due = new Date(dueDate)
if (isNaN(due.getTime())) return { text: "Invalid due date", tone: "muted" as const }
const today = new Date()
today.setHours(0, 0, 0, 0)
due.setHours(0, 0, 0, 0)
const days = Math.ceil((due.getTime() - today.getTime()) / (1000 * 60 * 60 * 24))
if (days < 0) return { text: `${Math.abs(days)} day(s) overdue`, tone: "danger" as const }
if (days === 0) return { text: "Due today", tone: "warning" as const }
if (days <= 3) return { text: `Due in ${days} day(s)`, tone: "warning" as const }
return { text: `Due in ${days} day(s)`, tone: "ok" as const }
}
export default function InvoicesPage() {
const router = useRouter()
return (
<ResourcePage<InvoicesClient>
pageTitle="Invoices"
routeKey={INVOICE_ROUTES.INDEX}
getClient={(api) => api.invoices}
onRowClick={(row) => router.push(`/sales/invoice/${(row as any).id}`)}
headerProps={({ selectedItem, invalidateQuery }) => ({
actions: (
<FormDialog classNames={{dialogContent:'lg:min-w-6xl'}} title="Invoice">
{(resourceId) => (
<InvoiceForm
resourceId={resourceId}
initialData={selectedItem}
onSuccess={invalidateQuery}
/>
)}
</FormDialog>
),
})}
columns={({ actionsColumn }) => [
{
accessorKey: "subject",
header: ({ column }) => <ColumnHeader column={column} title="Invoice" />,
cell: ({ row }) => {
const item = row.original as unknown as InvoiceItem
const title = item.subject || item.invoice_title || "Untitled invoice"
return (
<div className="min-w-[220px]">
<p className="font-medium leading-none">{title}</p>
<p className="mt-1 text-xs text-muted-foreground">
{item.invoice_number || "No invoice number"}
</p>
</div>
)
},
},
{
accessorKey: "invoice_number",
header: ({ column }) => <ColumnHeader column={column} title="Invoice #" />,
cell: ({ row }) => {
const item = row.original as unknown as InvoiceItem
return (
<span className="font-mono text-xs text-muted-foreground">
{item.invoice_number || "—"}
</span>
)
},
},
{
accessorKey: "customer_name",
header: ({ column }) => <ColumnHeader column={column} title="Customer" />,
cell: ({ row }) => {
const item = row.original as unknown as InvoiceItem
const customerLabel = getCustomerLabel(item)
const phone = item.customer?.phone
return (
<div className="min-w-[190px]">
{item.customer?.id ? (
<Button variant="outline" asChild size="sm" onClick={(e) => e.stopPropagation()}>
<Link href={`/sales/customers/${item.customer.id}`}>
<UserIcon /> {customerLabel}
{phone && (
<span className="ms-1 text-muted-foreground">· {phone}</span>
)}
</Link>
</Button>
) : (
<p className="font-medium leading-none">
{customerLabel}
{phone && (
<span className="ms-1 text-muted-foreground">· {phone}</span>
)}
</p>
)}
</div>
)
},
},
{
id: "vehicle",
header: ({ column }) => <ColumnHeader column={column} title="Vehicle" />,
cell: ({ row }) => {
const item = row.original as unknown as InvoiceItem
return (
<div className="min-w-[220px]">
<p className="font-medium leading-none">{getVehicleLabel(item)}</p>
<p className="mt-1 text-xs text-muted-foreground">
{item.vehicle?.license_plate || "—"}
</p>
</div>
)
},
},
{
accessorKey: "status",
header: ({ column }) => <ColumnHeader column={column} title="Status" />,
cell: ({ row }) => {
const item = row.original as unknown as InvoiceItem
const status = item.status
const classMap: Record<string, string> = {
draft: "bg-slate-100 text-slate-700",
open: "bg-blue-100 text-blue-700",
paid: "bg-emerald-100 text-emerald-700",
over_due: "bg-red-100 text-red-700",
un_paid: "bg-orange-100 text-orange-700",
partially_paid: "bg-amber-100 text-amber-700",
void: "bg-zinc-100 text-zinc-700",
}
return (
<Badge variant="outline" className={classMap[status ?? ""] ?? ""}>
{formatEnum(status) || "—"}
</Badge>
)
},
},
{
accessorKey: "invoice_date",
header: ({ column }) => <ColumnHeader column={column} title="Invoice Date" />,
cell: ({ row }) => {
const item = row.original as unknown as InvoiceItem
return formatDate(item.invoice_date)
},
},
{
accessorKey: "due_date",
header: ({ column }) => <ColumnHeader column={column} title="Due Date" />,
cell: ({ row }) => {
const item = row.original as unknown as InvoiceItem
const dueMeta = getDueMeta(item.due_date)
const toneClassMap = {
danger: "text-red-600",
warning: "text-amber-600",
ok: "text-emerald-600",
muted: "text-muted-foreground",
}
return (
<div>
<p className="font-medium leading-none">{formatDate(item.due_date)}</p>
<p className={`mt-1 text-xs ${toneClassMap[dueMeta.tone]}`}>
{dueMeta.text}
</p>
</div>
)
},
},
{
id: "amounts",
header: ({ column }) => <ColumnHeader column={column} title="Amounts" />,
cell: ({ row }) => {
const item = row.original as unknown as InvoiceItem
return (
<div className="min-w-[150px]">
<p className="font-medium leading-none">
Total: {formatCurrency(item.total ?? 0)}
</p>
<p className="mt-1 text-xs text-muted-foreground">
Due: {formatCurrency(item.balance_due ?? 0)}
</p>
</div>
)
},
},
actionsColumn(),
]}
/>
)
}
@@ -0,0 +1,134 @@
"use client"
import { useEffect } from "react"
import { use } from "react"
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { ResourcePage } from "@/shared/data-view/resource-page"
import { ColumnHeader } from "@/shared/data-view/table-view"
import FormDialog from "@/shared/components/form-dialog"
import { AppointmentForm } from "@/modules/appointments/appointment-form"
import { APPOINTMENT_ROUTES } from "@garage/api"
import type { AppointmentsClient } from "@garage/api"
import { CalendarCheck2Icon, ClockIcon } from "lucide-react"
import { Badge } from "@/shared/components/ui/badge"
import { useJobCard } from "@/modules/job-cards/job-card-context"
import { getFullName } from "@/shared/utils/getFullName"
import { getVehicleLabel } from "@/modules/vehicles/utils/getVehicleLabel"
const STATUS_COLORS: Record<string, string> = {
requested: "bg-yellow-100 text-yellow-800",
confirmed: "bg-blue-100 text-blue-800",
in_progress: "bg-purple-100 text-purple-800",
completed: "bg-green-100 text-green-800",
cancelled: "bg-red-100 text-red-800",
}
export default function JobCardAppointmentsPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id: jobCardId } = use(params)
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
const jobCard = useJobCard()
useEffect(() => {
if (searchParams.get("create") !== "1") return
const params = new URLSearchParams(searchParams.toString())
params.delete("create")
params.set("dialog", "true")
router.replace(`${pathname}?${params.toString()}`)
}, [pathname, router, searchParams])
const jc = jobCard as any
const defaultJobCard = jc
? { value: String(jc.id), label: jc.label || jc.title || `Job Card` }
: null
const appointmentDefaults = jc
? {
job_card_id: jc.id,
job_card_title: jc.title || `Job Card #${jc.id}`,
customer_id: jc.customer?.id,
customer_name: getFullName(jc.customer),
vehicle_id: jc.vehicle?.id,
vehicle_name: getVehicleLabel(jc.vehicle),
service_writer_id: jc.service_writer?.id,
service_writer_name: getFullName(jc.service_writer),
technician_id: jc.primary_technician?.id,
technician_name: getFullName(jc.primary_technician),
department_id: jc.department?.id,
department_name: jc.department?.name,
}
: { job_card: defaultJobCard }
return (
<ResourcePage<AppointmentsClient>
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}`)}
tableHeader={({ invalidateQuery, selectedItem, closeDialog }) => (
<div className="flex justify-end">
<FormDialog title="Appointment">
{(resourceId) => (
<AppointmentForm
resourceId={resourceId}
initialData={selectedItem ?? appointmentDefaults}
onSuccess={() => { closeDialog(); invalidateQuery(); router.refresh() }}
/>
)}
</FormDialog>
</div>
)}
columns={({ actionsColumn }) => [
{
accessorKey: "title",
header: ({ column }) => <ColumnHeader column={column} title="Title" />,
cell: ({ row }) => (
<div className="flex items-center gap-2">
<CalendarCheck2Icon className="size-4 text-muted-foreground" />
<span>{(row.original as any).title}</span>
</div>
),
},
{
accessorKey: "date",
header: ({ column }) => <ColumnHeader column={column} title="Date" />,
},
{
accessorKey: "from_time",
header: ({ column }) => <ColumnHeader column={column} title="Time" />,
cell: ({ row }) => {
const r = row.original as any
return (
<div className="flex items-center gap-1">
<ClockIcon className="size-3 text-muted-foreground" />
<span>{r.from_time} - {r.to_time}</span>
</div>
)
},
},
{
accessorKey: "status",
header: ({ column }) => <ColumnHeader column={column} title="Status" />,
cell: ({ row }) => {
const status = (row.original as any).status
const colorClass = STATUS_COLORS[status] ?? "bg-gray-100 text-gray-800"
return (
<Badge className={colorClass}>
{status?.replace("_", " ") ?? "—"}
</Badge>
)
},
},
actionsColumn(),
]}
/>
)
}
@@ -0,0 +1,216 @@
"use client"
import { useParams, useRouter } from "next/navigation"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { useState, useRef, useTransition } from "react"
import { Plus, Trash2, FileIcon, ImageIcon, FileTextIcon } 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 } from "@/shared/components/ui/card"
import { ApiError, JOB_CARD_ROUTES } from "@garage/api"
import DashboardPage from "@/base/components/layout/dashboard/dashboard-page"
import { useJobCard } from "@/modules/job-cards/job-card-context"
import { CONSTANTS } from "@/config/constants"
function getFileIcon(mimeType?: string) {
if (mimeType?.startsWith("image/")) return ImageIcon
if (mimeType?.includes("pdf")) return FileTextIcon
return FileIcon
}
const ALLOWED_HINT = "Allowed: images, PDF, Office docs, audio, video."
function describeAttachmentError(rawMessage: string, filename: string): string {
const msg = rawMessage.toLowerCase()
if (msg.includes("must be a file of type") || msg.includes("mimes")) {
return `${filename}: unsupported file type. ${ALLOWED_HINT}`
}
if (msg.includes("may not be greater") || msg.includes("max")) {
return `${filename}: exceeds 5 MB limit.`
}
if (msg.includes("must be a file")) {
return `${filename}: invalid file.`
}
return `${filename}: ${rawMessage}`
}
function showUploadError(err: unknown, files: File[]) {
if (!(err instanceof ApiError)) {
toast.error("Failed to upload attachment(s)")
return
}
const validation = err.validationErrors
if (!validation || Object.keys(validation).length === 0) {
toast.error(err.payload?.message ?? "Failed to upload attachment(s)")
return
}
const messages: string[] = []
for (const [key, msgs] of Object.entries(validation)) {
const firstMsg = Array.isArray(msgs) ? msgs[0] : String(msgs)
if (!firstMsg) continue
const match = key.match(/^attachments\.(\d+)$/)
if (match) {
const idx = Number(match[1])
const filename = files[idx]?.name ?? `File #${idx + 1}`
messages.push(describeAttachmentError(firstMsg, filename))
} else if (key === "attachments") {
messages.push(firstMsg)
} else {
messages.push(firstMsg)
}
}
if (messages.length === 0) {
toast.error(err.payload?.message ?? "Failed to upload attachment(s)")
return
}
const shown = messages.slice(0, 3)
const extra = messages.length - shown.length
shown.forEach((m) => toast.error(m))
if (extra > 0) toast.error(`...and ${extra} more`)
}
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<HTMLInputElement>(null)
const [isUploading, setIsUploading] = useState(false)
const queryKey = [JOB_CARD_ROUTES.INDEX, jobCardId, "attachments"]
const jobcard = useJobCard()
const attachments = jobcard?.attachment_files
const deleteMutation = useMutation({
mutationFn: (attachmentId: number) =>
api.jobCards.deleteAttachment(jobCardId, attachmentId),
onSuccess: () => {
toast.success("Attachment deleted successfully.")
queryClient.invalidateQueries({ queryKey })
startRefreshTransition(() => router.refresh())
},
onError: () => {
toast.error("Failed to delete attachment.")
},
})
const handleDelete = async (attachment: any) => {
const confirmed = await confirm({
title: "Delete Attachment",
description: `Are you sure you want to delete "${attachment.original_name}"?`,
confirmLabel: "Delete",
variant: "destructive",
})
if (confirmed) {
deleteMutation.mutate(attachment.id)
}
}
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (!files || files.length === 0) return
const fileList = Array.from(files)
setIsUploading(true)
const loadingToast = toast.loading("Uploading attachment(s)...")
try {
await api.jobCards.addAttachment(jobCardId, fileList)
toast.dismiss(loadingToast)
toast.success("Attachment(s) uploaded successfully")
queryClient.invalidateQueries({ queryKey })
startRefreshTransition(() => router.refresh())
} catch (err) {
toast.dismiss(loadingToast)
showUploadError(err, fileList)
} finally {
setIsUploading(false)
if (fileInputRef.current) {
fileInputRef.current.value = ""
}
}
}
return (
<DashboardPage
header={null}
>
<div className="flex items-center justify-end mb-4">
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={handleUpload}
/>
<Button
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
>
<Plus className="size-4" />
{isUploading ? "Uploading..." : "Upload Attachment"}
</Button>
</div>
{attachments?.length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
No attachments yet. Click "Upload Attachment" to add files.
</CardContent>
</Card>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{(attachments as any[])?.map((attachment) => {
const Icon = getFileIcon(attachment.attachment_path)
return (
<Card key={attachment.id}>
<CardContent className="flex items-center gap-3 p-4">
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
<Icon className="size-5" />
</div>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<a
href={CONSTANTS.getAssetUrl(attachment.attachment_path)}
target="_blank"
rel="noopener noreferrer"
className="truncate text-sm font-medium hover:underline"
title={attachment.original_name}
>
{attachment.original_name}
</a>
{attachment.created_at && (
<span className="text-xs text-muted-foreground">
{new Date(attachment.created_at).toLocaleDateString()}
</span>
)}
</div>
<Button
variant="ghost"
size="icon-sm"
onClick={() => handleDelete(attachment)}
title="Delete attachment"
>
<Trash2 className="size-4 text-destructive" />
</Button>
</CardContent>
</Card>
)
})}
</div>
)}
</DashboardPage>
)
}
@@ -0,0 +1,88 @@
"use client"
import { use } from "react"
import FormDialog from "@/shared/components/form-dialog"
import { Badge } from "@/shared/components/ui/badge"
import { ResourcePage } from "@/shared/data-view/resource-page"
import { ColumnHeader } from "@/shared/data-view/table-view"
import { BillForm } from "@/modules/bills/bill-form"
import { BILL_ROUTES } from "@garage/api"
import type { BillsClient } from "@garage/api"
import { useJobCard } from "@/modules/job-cards/job-card-context"
export default function JobCardBillsPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id: jobCardId } = use(params)
const jobCard = useJobCard()
const defaultJobCard = jobCard
? { value: String((jobCard as any).id), label: (jobCard as any).label || (jobCard as any).title || `Job Card` }
: null
return (
<ResourcePage<BillsClient>
routeKey={BILL_ROUTES.INDEX}
getClient={(api) => api.bills}
extraParams={{ job_card_id: jobCardId }}
header={null}
tableHeader={({ invalidateQuery, selectedItem, closeDialog }) => (
<div className="flex justify-end">
<FormDialog title="Bill">
{(resourceId) => (
<BillForm
resourceId={resourceId}
initialData={selectedItem ?? { job_card: defaultJobCard }}
onSuccess={() => { closeDialog(); invalidateQuery() }}
/>
)}
</FormDialog>
</div>
)}
columns={({ actionsColumn }) => [
{
accessorKey: "bill_number",
header: ({ column }) => <ColumnHeader column={column} title="Bill #" />,
cell: ({ row }) => (row.original as any).bill_number || "—",
},
{
accessorKey: "title",
header: ({ column }) => <ColumnHeader column={column} title="Title" />,
},
{
accessorKey: "vendor_name",
header: ({ column }) => <ColumnHeader column={column} title="Vendor" />,
cell: ({ row }) => (row.original as any).vendor_name || "—",
},
{
accessorKey: "bill_date",
header: ({ column }) => <ColumnHeader column={column} title="Bill Date" />,
cell: ({ row }) => {
const value = (row.original as any).bill_date
return value ? new Date(value).toLocaleDateString() : "—"
},
},
{
accessorKey: "bill_due_date",
header: ({ column }) => <ColumnHeader column={column} title="Due Date" />,
cell: ({ row }) => {
const value = (row.original as any).bill_due_date
return value ? new Date(value).toLocaleDateString() : "—"
},
},
{
accessorKey: "status",
header: ({ column }) => <ColumnHeader column={column} title="Status" />,
cell: ({ row }) => {
const status = (row.original as any).status
return <Badge variant={status === "paid" ? "default" : "secondary"}>{status || "—"}</Badge>
},
},
actionsColumn(),
]}
/>
)
}
@@ -0,0 +1,208 @@
"use client"
import { use, useState } from "react"
import { useRouter } from "next/navigation"
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 { JobCardExpenseItemForm } from "@/modules/job-cards/job-card-expense-item-form"
import { formatDate } from "@/shared/utils/formatters"
// TODO: expense items invalidation is not working properly when create new expense item line. Need to investigate why and fix it.
export default function JobCardExpenseItemsPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id: jobCardId } = use(params)
const api = useAuthApi()
const queryClient = useQueryClient()
const router = useRouter()
const queryKey = ["job-card-expense-items", jobCardId]
const [dialogOpen, setDialogOpen] = useState(false)
const [editItem, setEditItem] = useState<any | null>(null)
const { data, isLoading } = useQuery({
queryKey,
queryFn: () => api.jobCards.getExpenseItems(jobCardId),
staleTime: 30_000,
})
const rows = (data as any)?.data ?? []
const invalidate = () => queryClient.invalidateQueries({ queryKey , refetchType:'all'}).then(() => router.refresh())
async function handleDelete(row: any) {
const confirmed = await confirm({
title: "Delete this expense item?",
description: `Remove "${row.expense_item?.item_name ?? "this expense item"}" from the job card?`,
})
if (!confirmed) return
const promise = api.jobCards.deleteExpenseItem(jobCardId, row.id)
toast.promise(promise, {
loading: "Deleting...",
success: "Expense item deleted",
error: "Failed to delete expense item",
})
await promise
invalidate()
}
const columns: ColumnDef<any>[] = [
{
accessorKey: "expense_item.item_name",
header: ({ column }) => <ColumnHeader column={column} title="Expense Item" />,
cell: ({ row }) => {
const item = row.original.expense_item
return item ? (
<div>
<span className="font-medium">{item.item_name}</span>
{item.sku && (
<span className="ml-2 text-xs text-muted-foreground">{item.sku}</span>
)}
</div>
) : "—"
},
},
{
accessorKey: "quantity",
header: ({ column }) => <ColumnHeader column={column} title="Qty" />,
cell: ({ row }) => row.original.quantity ?? "—",
},
{
accessorKey: "rate",
header: ({ column }) => <ColumnHeader column={column} title="Rate" />,
cell: ({ row }) => {
const val = row.original.rate
return val != null ? `$${Number(val).toFixed(2)}` : "—"
},
},
{
accessorKey: "discount_amount",
header: ({ column }) => <ColumnHeader column={column} title="Discount" />,
cell: ({ row }) => {
const val = row.original.discount_amount
return val != null && val > 0 ? `$${Number(val).toFixed(2)}` : "—"
},
},
{
accessorKey: "tax_id",
header: ({ column }) => <ColumnHeader column={column} title="Tax" />,
cell: ({ row }) => row.original.tax_id ?? "—",
},
{
accessorKey: "department.name",
header: ({ column }) => <ColumnHeader column={column} title="Department" />,
cell: ({ row }) => row.original.department?.name || "—",
},
{
accessorKey: "description",
header: ({ column }) => <ColumnHeader column={column} title="Description" />,
cell: ({ row }) => row.original.description || "—",
},
{
accessorKey: "created_at",
header: ({ column }) => <ColumnHeader column={column} title="Added" />,
cell: ({ row }) => formatDate(row.original.created_at),
},
{
id: "actions",
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<Ellipsis className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setEditItem(row.original)
setDialogOpen(true)
}}
>
Edit
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive"
onClick={() => handleDelete(row.original)}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
},
]
return (
<div className="flex flex-col gap-4 p-4">
<div className="flex justify-end">
<Dialog
open={dialogOpen}
onOpenChange={(open) => {
setDialogOpen(open)
if (!open) setEditItem(null)
}}
>
<DialogTrigger asChild>
<Button onClick={() => setEditItem(null)}>
<Plus className="me-2 h-4 w-4" />
Add Expense Item
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{editItem ? "Edit Expense Item" : "Add Expense Item"}</DialogTitle>
</DialogHeader>
<JobCardExpenseItemForm
jobCardId={jobCardId}
jobCardExpenseItemId={editItem?.id ?? null}
initialData={editItem}
onSuccess={() => {
setDialogOpen(false)
setEditItem(null)
invalidate()
}}
onCancel={() => {
setDialogOpen(false)
setEditItem(null)
}}
/>
</DialogContent>
</Dialog>
</div>
<DataTable
columns={columns}
data={rows}
pagination={{
page: 1,
pageSize: rows.length || 15,
pageCount: 1,
total: rows.length,
}}
isLoading={isLoading}
/>
</div>
)
}
@@ -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 (
<ResourcePage<InspectionsClient>
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: (
<FormDialog title="Inspection">
{(resourceId) => (
<InspectionForm
resourceId={resourceId}
initialData={selectedItem ?? {job_card: {value: jobCard?.id, label: jobCard?.title || `Job Card #${jobCard?.id}`}}}
onSuccess={invalidateQuery}
/>
)}
</FormDialog>
),
})}
columns={({ actionsColumn }) => [
{
accessorKey: "title",
header: ({ column }) => <ColumnHeader column={column} title="Title" />,
},
{
accessorKey: "customer",
header: ({ column }) => <ColumnHeader column={column} title="Customer" />,
cell: ({ row }) => {
const c = (row.original as any).customer
return c ? `${c.first_name ?? ""} ${c.last_name ?? ""}`.trim() : "—"
},
},
{
accessorKey: "vehicle",
header: ({ column }) => <ColumnHeader column={column} title="Vehicle" />,
cell: ({ row }) => {
const v = (row.original as any).vehicle
return v ? `${v.make ?? ""} ${v.model ?? ""}`.trim() : "—"
},
},
{
accessorKey: "inspection_category",
header: ({ column }) => <ColumnHeader column={column} title="Category" />,
cell: ({ row }) => (row.original as any).inspection_category?.name ?? "—",
},
{
accessorKey: "status",
header: ({ column }) => <ColumnHeader column={column} title="Status" />,
cell: ({ row }) => {
const status = (row.original as any).status
return (
<span className={status === "completed" ? "text-green-600" : "text-yellow-600"}>
{status ?? "—"}
</span>
)
},
},
actionsColumn(),
]}
/>
)
}
@@ -0,0 +1,91 @@
import { DashboardDetailsPage } from '@/base/components/layout/dashboard'
import { getServerApi } from '@garage/api/server'
import { JobCardActions } from '@/modules/job-cards/job-card-actions'
import { JobCardProvider } from '@/modules/job-cards/job-card-context'
import { JobCardStatusStepper } from '@/modules/job-cards/job-card-status-stepper'
import { ClipboardListIcon, Ellipsis, Pencil, Trash2 } from 'lucide-react'
import React from 'react'
import JobCardDropdown from '@/modules/job-cards/job-card-dropdown'
export default async function JobCardDetailLayout(props: { params: Promise<{ id: string }>, children: React.ReactNode }) {
const { id } = await props.params
const api = await getServerApi()
const jobCard:any = await api.jobCards.show(id).then(res => res.data)
const title = jobCard?.title || 'Job Card Details'
const status = jobCard?.status || 'draft'
const attachmentsCount = jobCard?.attachment_files?.length ?? jobCard?.documents?.length ?? 0
return (
<JobCardProvider jobCard={{ ...jobCard }}>
<DashboardDetailsPage
className='p-0 lg:p-0'
title={title}
description={status ? `Status: ${status.split("_").map((w: string) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ")}` : undefined}
icon={<ClipboardListIcon className="size-5" />}
backHref="/sales/job-cards"
actions={
<JobCardDropdown id={id} />
}
subHeader={<JobCardStatusStepper jobCardId={id} />}
tabs={[
{
href: `/sales/job-cards/${id}`,
label: 'Details'
},
{
href: `/sales/job-cards/${id}/parts`,
label: `Parts (${jobCard?.parts_count || 0})`
},
{
href: `/sales/job-cards/${id}/services`,
label: `Services (${jobCard?.services_count })`
},
{
href: `/sales/job-cards/${id}/expense-items`,
label: `Expense Items (${jobCard?.expense_items_count || 0})`
},
// TODO: Needs refactor from API side then refactor in frontend
{
href: `/sales/job-cards/${id}/attachments`,
label: `Attachments (${attachmentsCount})`
},
{
href: `/sales/job-cards/${id}/appointments`,
label: `Appointments (${jobCard?.appointments_count || 0})`
},
// {
// href: `/sales/job-cards/${id}/inspections`,
// label: `Inspections (${(jobCard as any)?.inspections_count || 0})`
// },
// {
// href: `/sales/job-cards/${id}/tasks`,
// label: `Tasks (${jobCard?.tasks_count || 0})`
// },
// {
// href: `/sales/job-cards/${id}/purchase-orders`,
// label: `Purchase Orders (${jobCard?.purchase_orders_count || 0})`
// },
// {
// href: `/sales/job-cards/${id}/bills`,
// label: `Bills (${jobCard?.bills_count || 0})`
// },
]}
>
{props.children}
</DashboardDetailsPage>
</JobCardProvider>
)
}
@@ -0,0 +1,10 @@
import { JobCardGeneralInfo } from '@/modules/job-cards/job-card-general-info'
import DashboardPage from '@/base/components/layout/dashboard/dashboard-page'
export default async function JobCardDetailPage() {
return (
<DashboardPage header={null}>
<JobCardGeneralInfo />
</DashboardPage>
)
}
@@ -0,0 +1,208 @@
"use client"
import { use, useState } from "react"
import { useRouter } from "next/navigation"
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"
import { JOB_CARD_ROUTES } from "@garage/api"
export default function JobCardPartsPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id: jobCardId } = use(params)
const api = useAuthApi()
const queryClient = useQueryClient()
const router = useRouter()
const queryKey = [JOB_CARD_ROUTES.GET_PARTS, jobCardId]
const [dialogOpen, setDialogOpen] = useState(false)
const [editItem, setEditItem] = useState<any | null>(null)
const { data, isLoading } = useQuery({
queryKey,
queryFn: () => api.jobCards.getParts(jobCardId),
staleTime: 30_000,
})
const rows = (data as any)?.data ?? []
const invalidate = () => queryClient.invalidateQueries({ queryKey, type: 'all', refetchType: 'all' },).then(() => router.refresh())
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<any>[] = [
{
accessorKey: "part.title",
header: ({ column }) => <ColumnHeader column={column} title="Part" />,
cell: ({ row }) => {
const part = row.original.part
return part ? (
<div>
<span className="font-medium">{part.title}</span>
{part.sku && (
<span className="ml-2 text-xs text-muted-foreground">{part.sku}</span>
)}
</div>
) : "—"
},
},
{
accessorKey: "quantity",
header: ({ column }) => <ColumnHeader column={column} title="Qty" />,
cell: ({ row }) => row.original.quantity ?? "—",
},
{
accessorKey: "rate",
header: ({ column }) => <ColumnHeader column={column} title="Rate" />,
cell: ({ row }) => {
const val = row.original.rate
return val != null ? `$${Number(val).toFixed(2)}` : "—"
},
},
{
accessorKey: "discount_amount",
header: ({ column }) => <ColumnHeader column={column} title="Discount" />,
cell: ({ row }) => {
const val = row.original.discount_amount
return val != null && val > 0 ? `$${Number(val).toFixed(2)}` : "—"
},
},
{
accessorKey: "tax_id",
header: ({ column }) => <ColumnHeader column={column} title="Tax" />,
cell: ({ row }) => row.original.tax_id ?? "—",
},
{
accessorKey: "department.name",
header: ({ column }) => <ColumnHeader column={column} title="Department" />,
cell: ({ row }) => row.original.department?.name || "—",
},
{
accessorKey: "description",
header: ({ column }) => <ColumnHeader column={column} title="Description" />,
cell: ({ row }) => row.original.description || "—",
},
{
accessorKey: "created_at",
header: ({ column }) => <ColumnHeader column={column} title="Added" />,
cell: ({ row }) => formatDate(row.original.created_at),
},
{
id: "actions",
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<Ellipsis className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setEditItem(row.original)
setDialogOpen(true)
}}
>
Edit
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive"
onClick={() => handleDelete(row.original)}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
},
]
return (
<div className="flex flex-col gap-4 p-4">
<div className="flex justify-end">
<Dialog
open={dialogOpen}
onOpenChange={(open) => {
setDialogOpen(open)
if (!open) setEditItem(null)
}}
>
<DialogTrigger asChild>
<Button onClick={() => setEditItem(null)}>
<Plus className="me-2 h-4 w-4" />
Add Part
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{editItem ? "Edit Part" : "Add Part"}</DialogTitle>
</DialogHeader>
<JobCardPartForm
jobCardId={jobCardId}
jobCardPartId={editItem?.id ?? null}
initialData={editItem}
onSuccess={() => {
setDialogOpen(false)
setEditItem(null)
invalidate()
}}
onCancel={() => {
setDialogOpen(false)
setEditItem(null)
}}
/>
</DialogContent>
</Dialog>
</div>
<DataTable
columns={columns}
data={rows}
pagination={{
page: 1,
pageSize: rows.length || 15,
pageCount: 1,
total: rows.length,
}}
isLoading={isLoading}
/>
</div>
)
}
@@ -0,0 +1,88 @@
"use client"
import { use } from "react"
import { ResourcePage } from "@/shared/data-view/resource-page"
import { ColumnHeader } from "@/shared/data-view/table-view"
import FormDialog from "@/shared/components/form-dialog"
import { PurchaseOrderForm } from "@/modules/purchase-orders/purchase-order-form"
import { PURCHASE_ORDER_ROUTES } from "@garage/api"
import type { PurchaseOrdersClient } from "@garage/api"
import { useJobCard } from "@/modules/job-cards/job-card-context"
export default function JobCardPurchaseOrdersPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id: jobCardId } = use(params)
const jobCard = useJobCard()
const defaultJobCard = jobCard
? { value: String((jobCard as any).id), label: (jobCard as any).label || (jobCard as any).title || `Job Card` }
: null
return (
<ResourcePage<PurchaseOrdersClient>
routeKey={PURCHASE_ORDER_ROUTES.INDEX}
getClient={(api) => api.purchaseOrders}
extraParams={{ job_card_id: jobCardId }}
header={null}
tableHeader={({ invalidateQuery, selectedItem, closeDialog }) => (
<div className="flex justify-end">
<FormDialog title="Purchase Order">
{(resourceId) => (
<PurchaseOrderForm
resourceId={resourceId}
initialData={selectedItem ?? { job_card: defaultJobCard }}
onSuccess={() => { closeDialog(); invalidateQuery() }}
/>
)}
</FormDialog>
</div>
)}
columns={({ actionsColumn }) => [
{
accessorKey: "order_number",
header: ({ column }) => <ColumnHeader column={column} title="Order #" />,
cell: ({ row }) => (row.original as any).order_number || "—",
},
{
accessorKey: "title",
header: ({ column }) => <ColumnHeader column={column} title="Title" />,
},
{
accessorKey: "vendor_name",
header: ({ column }) => <ColumnHeader column={column} title="Vendor" />,
cell: ({ row }) => (row.original as any).vendor_name || "—",
},
{
accessorKey: "order_date",
header: ({ column }) => <ColumnHeader column={column} title="Order Date" />,
cell: ({ row }) => {
const val = (row.original as any).order_date
return val ? new Date(val).toLocaleDateString() : "—"
},
},
{
accessorKey: "delivery_date",
header: ({ column }) => <ColumnHeader column={column} title="Delivery Date" />,
cell: ({ row }) => {
const val = (row.original as any).delivery_date
return val ? new Date(val).toLocaleDateString() : "—"
},
},
{
accessorKey: "created_at",
header: ({ column }) => <ColumnHeader column={column} title="Created" />,
cell: ({ row }) => {
const val = (row.original as any).created_at
return val ? new Date(val).toLocaleDateString() : "—"
},
},
actionsColumn(),
]}
/>
)
}
@@ -0,0 +1,241 @@
"use client"
import { use, useState } from "react"
import { useRouter } from "next/navigation"
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 { JobCardServiceForm } from "@/modules/job-cards/job-card-service-form"
import { formatDate } from "@/shared/utils/formatters"
import { Badge } from "@/shared/components/ui/badge"
// TODO: services invalidation is not working properly when create new service line. Need to investigate why and fix it.
export default function JobCardServicesPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id: jobCardId } = use(params)
const api = useAuthApi()
const queryClient = useQueryClient()
const router = useRouter()
const queryKey = ["job-card-services", jobCardId]
const [dialogOpen, setDialogOpen] = useState(false)
const [editItem, setEditItem] = useState<any | null>(null)
const { data, isLoading } = useQuery({
queryKey,
queryFn: () => api.jobCards.getServices(jobCardId),
staleTime: 30_000,
})
const rows = (data as any)?.data ?? []
const invalidate = () => queryClient.invalidateQueries({ queryKey , refetchType:'all'}).then(() => router.refresh())
async function handleDelete(row: any) {
const confirmed = await confirm({
title: "Delete this service?",
description: `Remove service "${row.service?.labor_name ?? "this service"}" from the job card?`,
})
if (!confirmed) return
const promise = api.jobCards.deleteService(jobCardId, row.id)
toast.promise(promise, {
loading: "Deleting...",
success: "Service deleted",
error: "Failed to delete service",
})
await promise
invalidate()
}
const columns: ColumnDef<any>[] = [
{
accessorKey: "service.labor_name",
header: ({ column }) => <ColumnHeader column={column} title="Service" />,
cell: ({ row }) => {
const service = row.original.service
return service ? (
<div>
<span className="font-medium">{service.labor_name}</span>
{service.service_code && (
<span className="ml-2 text-xs text-muted-foreground">{service.service_code}</span>
)}
</div>
) : "—"
},
},
{
accessorKey: "rate_type",
header: ({ column }) => <ColumnHeader column={column} title="Rate Type" />,
cell: ({ row }) => {
const val = row.original.rate_type
if (!val) return "—"
return (
<Badge variant="outline">
{val === "flat_rate" ? "Flat Rate" : val === "hourly" ? "Hourly" : val}
</Badge>
)
},
},
{
accessorKey: "quantity",
header: ({ column }) => <ColumnHeader column={column} title="Qty" />,
cell: ({ row }) => row.original.quantity ?? "—",
},
{
accessorKey: "rate",
header: ({ column }) => <ColumnHeader column={column} title="Rate" />,
cell: ({ row }) => {
const val = row.original.rate
return val != null ? `$${Number(val).toFixed(2)}` : "—"
},
},
{
accessorKey: "labor_rate.title",
header: ({ column }) => <ColumnHeader column={column} title="Labor Rate" />,
cell: ({ row }) => {
const lr = row.original.labor_rate
return lr ? `${lr.title} ($${Number(lr.rate).toFixed(2)})` : "—"
},
},
{
accessorKey: "working_hours",
header: ({ column }) => <ColumnHeader column={column} title="Working Hrs" />,
cell: ({ row }) => row.original.working_hours ?? "—",
},
{
accessorKey: "labor_hours",
header: ({ column }) => <ColumnHeader column={column} title="Labor Hrs" />,
cell: ({ row }) => row.original.labor_hours ?? "—",
},
{
accessorKey: "discount_amount",
header: ({ column }) => <ColumnHeader column={column} title="Discount" />,
cell: ({ row }) => {
const val = row.original.discount_amount
return val != null && val > 0 ? `$${Number(val).toFixed(2)}` : "—"
},
},
{
accessorKey: "tax_id",
header: ({ column }) => <ColumnHeader column={column} title="Tax" />,
cell: ({ row }) => row.original.tax_id ?? "—",
},
{
accessorKey: "department.name",
header: ({ column }) => <ColumnHeader column={column} title="Department" />,
cell: ({ row }) => row.original.department?.name || "—",
},
{
accessorKey: "description",
header: ({ column }) => <ColumnHeader column={column} title="Description" />,
cell: ({ row }) => row.original.description || "—",
},
{
accessorKey: "created_at",
header: ({ column }) => <ColumnHeader column={column} title="Added" />,
cell: ({ row }) => formatDate(row.original.created_at),
},
{
id: "actions",
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<Ellipsis className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setEditItem(row.original)
setDialogOpen(true)
}}
>
Edit
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive"
onClick={() => handleDelete(row.original)}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
},
]
return (
<div className="flex flex-col gap-4 p-4">
<div className="flex justify-end">
<Dialog
open={dialogOpen}
onOpenChange={(open) => {
setDialogOpen(open)
if (!open) setEditItem(null)
}}
>
<DialogTrigger asChild>
<Button onClick={() => setEditItem(null)}>
<Plus className="me-2 h-4 w-4" />
Add Service
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{editItem ? "Edit Service" : "Add Service"}</DialogTitle>
</DialogHeader>
<JobCardServiceForm
jobCardId={jobCardId}
jobCardServiceId={editItem?.id ?? null}
initialData={editItem}
onSuccess={() => {
setDialogOpen(false)
setEditItem(null)
invalidate()
}}
onCancel={() => {
setDialogOpen(false)
setEditItem(null)
}}
/>
</DialogContent>
</Dialog>
</div>
<DataTable
columns={columns}
data={rows}
pagination={{
page: 1,
pageSize: rows.length || 15,
pageCount: 1,
total: rows.length,
}}
isLoading={isLoading}
/>
</div>
)
}
@@ -0,0 +1,75 @@
"use client"
import { use } from "react"
import { ResourcePage } from "@/shared/data-view/resource-page"
import { ColumnHeader } from "@/shared/data-view/table-view"
import FormDialog from "@/shared/components/form-dialog"
import { 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,
}: {
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 (
<ResourcePage<TasksClient>
routeKey={TASK_ROUTES.INDEX}
getClient={(api) => api.tasks}
extraParams={{ job_card_id: jobCardId }}
header={null}
tableHeader={({ invalidateQuery, selectedItem, closeDialog }) => (
<div className="flex justify-end">
<FormDialog title="Task">
{(resourceId) => (
<TaskForm
resourceId={resourceId}
initialData={selectedItem ?? { job_card: defaultJobCard }}
onSuccess={() => { closeDialog(); invalidateQuery() }}
/>
)}
</FormDialog>
</div>
)}
columns={({ actionsColumn }) => [
{
accessorKey: "task_number",
header: ({ column }) => <ColumnHeader column={column} title="Task #" />,
cell: ({ row }) => (row.original as any).task_number || "—",
},
{
accessorKey: "subject",
header: ({ column }) => <ColumnHeader column={column} title="Subject" />,
cell: ({ row }) => (row.original as any).subject || "—",
},
{
accessorKey: "due_date",
header: ({ column }) => <ColumnHeader column={column} title="Due Date" />,
cell: ({ row }) => formatDate((row.original as any).due_date),
},
{
accessorKey: "priority",
header: ({ column }) => <ColumnHeader column={column} title="Priority" />,
},
{
accessorKey: "status",
header: ({ column }) => <ColumnHeader column={column} title="Status" />,
},
actionsColumn(),
]}
/>
)
}
@@ -0,0 +1,177 @@
"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 { JobCardForm } from '@/modules/job-cards/job-card-form'
import { JOB_CARD_ROUTES, JobCardStatus } from '@garage/api'
import type { JobCardsClient } from '@garage/api'
import { Tabs, TabsList, TabsTrigger } from '@/shared/components/ui/tabs'
import { ClipboardListIcon, SearchIcon } from 'lucide-react'
import { Badge } from '@/shared/components/ui/badge'
import { Input } from '@/shared/components/ui/input'
import { useRouter } from 'next/navigation'
import { useState, useEffect, 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
title?: string
status?: string
check_in_date?: string
km_in?: number
created_at?: string
}
const statusColorMap: Record<string, string> = {
draft: "secondary",
check_in: "default",
in_progress: "default",
completed: "default",
invoiced: "outline",
cancelled: "destructive",
}
export default function JobCardsPage() {
const router = useRouter()
const [searchInput, setSearchInput] = useState("")
const [search, setSearch] = useState("")
const [statusFilter, setStatusFilter] = useState<string>("check_in")
const filter = useFilterParams(jobCardFilterConfig)
useEffect(() => {
const timer = setTimeout(() => setSearch(searchInput), 400)
return () => clearTimeout(timer)
}, [searchInput])
const extraParams = useMemo(() => {
const params: Record<string, unknown> = { ...filter.appliedParams }
if (search) params.search = search
if (statusFilter !== "all") params.status = statusFilter
return params
}, [filter.appliedParams, search, statusFilter])
return (
<>
<ResourcePage<JobCardsClient>
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: (
<div className="flex items-center gap-2">
<FilterTrigger onClick={filter.open} activeFilterCount={filter.activeFilterCount} />
<FormDialog classNames={{ dialogContent: 'xl:min-w-6xl' }} title="Job Card" >
{(resourceId, {close}) => (
<JobCardForm
resourceId={resourceId}
initialData={selectedItem}
onSuccess={()=>{ invalidateQuery(); close();}}
/>
)}
</FormDialog>
</div>
),
})}
columns={({ actionsColumn }) => [
{
accessorKey: "title",
header: ({ column }) => <ColumnHeader column={column} title="Title" />,
cell: ({ row }) => {
const item = row.original as unknown as JobCardItem
return (
<div className="flex items-center gap-2">
<ClipboardListIcon className="text-muted-foreground h-4 w-4" />
<span>{item.title}</span>
</div>
)
},
},
{
accessorKey: "order_number",
header: ({ column }) => <ColumnHeader column={column} title="Order Number" />,
},
{
accessorKey: "check_in_date",
header: ({ column }) => <ColumnHeader column={column} title="Check-in Date" />,
cell: ({ getValue }) => {
const val = getValue<string>()
return formatDate(val)
}
},
{
accessorKey: "vehicle_id",
header: ({ column }) => <ColumnHeader column={column} title="KM In" />,
cell: ({ row }) => {
const item = row.original as unknown as JobCardItem
return item.km_in ? formatNumber(item.km_in) : "—"
},
},
{
accessorKey: "created_at",
header: ({ column }) => <ColumnHeader column={column} title="Created" />,
cell: ({ row }) => {
const item = row.original as unknown as JobCardItem
return formatDate(item.created_at)
},
},
{
accessorKey: "status",
header: ({ column }) => <ColumnHeader column={column} title="Status" />,
cell: ({ row }) => {
const item = row.original as unknown as JobCardItem
return (
<Badge variant={statusColorMap[item.status ?? ""] as any ?? "outline"}>
{formatEnum(item.status)}
</Badge>
)
},
},
actionsColumn(),
]}
tableHeader={
<div className="flex justify-between">
<Tabs value={statusFilter} onValueChange={setStatusFilter}>
<TabsList variant="line">
<TabsTrigger value="all">All</TabsTrigger>
{JobCardStatus.map((status) => (
<TabsTrigger key={status} value={status}>
{formatEnum(status)}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<div className="relative w-64">
<SearchIcon className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search job cards..."
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="pl-8"
/>
</div>
</div>
}
/>
<FilterDrawer
form={filter.form}
isOpen={filter.isOpen}
onOpenChange={(open) => { if (!open) filter.close() }}
onSubmit={filter.onSubmit}
onReset={filter.reset}
activeFilterCount={filter.activeFilterCount}
title="Filter Job Cards"
>
<JobCardFilterFields />
</FilterDrawer>
</>
)
}
@@ -0,0 +1,166 @@
"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 { PaymentReceivedForm } from "@/modules/payment-received/payment-received-form"
import { PAYMENT_RECEIVED_ROUTES } from "@garage/api"
import {
BadgeDollarSignIcon,
CalendarIcon,
CreditCardIcon,
HashIcon,
UserIcon,
ClipboardListIcon,
} from "lucide-react"
import { getFullName } from "@/shared/utils/getFullName"
type PaymentReceivedItem = {
id: number
payment_number?: string
customer_name?: string
job_card_name?: string
job_card_number?: string
payment_mode_name?: string
amount_received?: string | number
payment_date?: string
note?: string
status?: string
created_at?: string
}
export default function PaymentReceivedPage() {
return (
<ResourcePage<{ list(query?: any): Promise<any>; destroy(id: string): Promise<any> }>
routeKey={PAYMENT_RECEIVED_ROUTES.INDEX}
getClient={(api) => ({
list: (query?: any) => api.paymentReceived.list(query),
destroy: (id: string) => api.paymentReceived.destroy(id),
})}
headerProps={({ invalidateQuery }) => ({
actions: (
<FormDialog title="Record Payment">
{(resourceId) => (
<PaymentReceivedForm
resourceId={resourceId}
onSuccess={invalidateQuery}
/>
)}
</FormDialog>
),
})}
columns={({ actionsColumn }) => [
{
accessorKey: "payment_number",
header: ({ column }) => <ColumnHeader column={column} title="Payment #" />,
cell: ({ row }) => {
const item = row.original as unknown as PaymentReceivedItem
return (
<div className="flex items-center gap-2">
<HashIcon className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{item.payment_number || "—"}</span>
</div>
)
},
},
{
accessorKey: "customer",
header: ({ column }) => <ColumnHeader column={column} title="Customer" />,
cell: ({ row }) => {
const item:any = row.original as unknown as PaymentReceivedItem
return (
<div className="flex items-center gap-2">
<UserIcon className="h-4 w-4 text-muted-foreground" />
<span>{getFullName(item.customer) || "—"}</span>
</div>
)
},
},
{
accessorKey: "job_card",
header: ({ column }) => <ColumnHeader column={column} title="Job Card" />,
cell: ({ row }) => {
const item:any = row.original as unknown as PaymentReceivedItem
const label = item.job_card?.title
return (
<div className="flex items-center gap-2">
<ClipboardListIcon className="h-4 w-4 text-muted-foreground" />
<span>{label || "—"}</span>
</div>
)
},
},
{
accessorKey: "amount_received",
header: ({ column }) => <ColumnHeader column={column} title="Amount" />,
cell: ({ row }) => {
const item = row.original as unknown as PaymentReceivedItem
const amount = item.amount_received
? Number(item.amount_received).toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
: "—"
return (
<div className="flex items-center gap-2">
<BadgeDollarSignIcon className="h-4 w-4 text-emerald-600" />
<span className="font-semibold text-emerald-700 dark:text-emerald-400">
{amount}
</span>
</div>
)
},
},
{
accessorKey: "payment_mode",
header: ({ column }) => <ColumnHeader column={column} title="Payment Mode" />,
cell: ({ row }) => {
const item:any = row.original as unknown as PaymentReceivedItem
return (
<div className="flex items-center gap-2">
<CreditCardIcon className="h-4 w-4 text-muted-foreground" />
<span className="capitalize">{item.payment_mode?.title || "—"}</span>
</div>
)
},
},
{
accessorKey: "payment_date",
header: ({ column }) => <ColumnHeader column={column} title="Date" />,
cell: ({ row }) => {
const item = row.original as unknown as PaymentReceivedItem
const formatted = item.payment_date
? new Date(item.payment_date).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
})
: "—"
return (
<div className="flex items-center gap-2">
<CalendarIcon className="h-4 w-4 text-muted-foreground" />
<span>{formatted}</span>
</div>
)
},
},
{
accessorKey: "note",
header: () => <span>Note</span>,
enableSorting: false,
cell: ({ row }) => {
const item = row.original as unknown as PaymentReceivedItem
const note = item.note
if (!note) return <span className="text-muted-foreground">—</span>
return (
<span className="max-w-50 truncate block" title={note}>
{note}
</span>
)
},
},
actionsColumn(),
]}
/>
)
}
@@ -0,0 +1,235 @@
"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 { Download, ExternalLink, 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 { VehicleDocumentForm } from "@/modules/vehicles/vehicle-document-form"
import { formatDate, formatDateTime } from "@/shared/utils/formatters"
import { getFullName } from "@/shared/utils/getFullName"
type VehicleDocument = {
id: number
document_number?: string
document_type_id?: number
document_type?: {
id?: number
name?: string
} | null
customer_id?: number
customer?: {
id?: number
first_name?: string
last_name?: string
name?: string
} | null
document_expire?: string
document_file?: string
document_file_url?: string | null
created_at: string
updated_at: string
}
export default function VehicleDocumentsPage() {
const { id: vehicleId } = useParams<{ id: string }>()
const api = useAuthApi()
const queryClient = useQueryClient()
const [dialogOpen, setDialogOpen] = useState(false)
const queryKey = ["vehicle-documents", vehicleId]
const { data, isLoading } = useQuery({
queryKey,
queryFn: () => api.vehicleDocuments.listDocuments({ vehicle_id: vehicleId }),
})
const deleteMutation = useMutation({
mutationFn: (id: number) => api.vehicleDocuments.destroyDocument(String(id)),
onSuccess: () => {
toast.success("Document deleted successfully.")
queryClient.invalidateQueries({ queryKey })
},
onError: () => {
toast.error("Failed to delete document.")
},
})
const handleDelete = async (doc: VehicleDocument) => {
const confirmed = await confirm({
title: "Delete Document",
description: `Are you sure you want to delete "${doc.document_number || `Document #${doc.id}`}"?`,
confirmLabel: "Delete",
variant: "destructive",
})
if (confirmed) {
deleteMutation.mutate(doc.id)
}
}
const columns: ColumnDef<VehicleDocument>[] = [
{
accessorKey: "document_number",
header: ({ column }) => <ColumnHeader column={column} title="Document Number" />,
cell: ({ getValue, row }) => {
const number = getValue<string>()
const fallbackName = row.original.document_file?.split("/").pop()
return number || fallbackName || `Document #${row.original.id}`
},
},
{
accessorKey: "document_type_id",
header: ({ column }) => <ColumnHeader column={column} title="Document Type" />,
cell: ({ row, getValue }) => {
const typeName = row.original.document_type?.name
const value = getValue<number | undefined>()
return typeName || (value ? `#${value}` : "—")
},
},
{
accessorKey: "customer_id",
header: ({ column }) => <ColumnHeader column={column} title="Customer" />,
cell: ({ row, getValue }) => {
const name = getFullName(row.original.customer as any) || row.original.customer?.name
const value = getValue<number | undefined>()
return name || (value ? `#${value}` : "—")
},
},
{
accessorKey: "document_expire",
header: ({ column }) => <ColumnHeader column={column} title="Expiry Date" />,
cell: ({ getValue }) => formatDate(getValue<string | undefined>()),
},
{
id: "file",
header: ({ column }) => <ColumnHeader column={column} title="File" />,
cell: ({ row }) => {
const url = row.original.document_file_url
if (!url) return "No file"
return (
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-primary underline"
title="Open document"
>
<ExternalLink className="size-3.5" />
Open
</a>
<a
href={url}
download
className="inline-flex items-center gap-1 text-primary underline"
title="Download document"
>
<Download className="size-3.5" />
Download
</a>
</div>
)
},
enableSorting: false,
},
{
accessorKey: "created_at",
header: ({ column }) => <ColumnHeader column={column} title="Uploaded At" />,
cell: ({ getValue }) => formatDateTime(getValue<string | undefined>()),
},
{
id: "actions",
header: () => <span className="sr-only">Actions</span>,
cell: ({ row }) => (
<Button
variant="ghost"
size="icon-sm"
onClick={(e) => {
e.stopPropagation()
handleDelete(row.original)
}}
title="Delete document"
>
<Trash2 className="size-4 text-destructive" />
</Button>
),
enableSorting: false,
},
]
const handleRowClick = (doc: VehicleDocument) => {
if (doc.document_file_url) {
window.open(doc.document_file_url, "_blank", "noopener,noreferrer")
return
}
toast.info("This document has no file to open.")
}
const documents = (data as any)?.data ?? []
const meta = (data as any)?.meta
const pagination = {
page: meta?.current_page ?? 1,
pageSize: meta?.per_page ?? 15,
pageCount: meta?.last_page ?? 1,
total: meta?.total ?? 0,
}
return (
<div className="flex flex-col gap-4">
<Card>
<CardContent>
<DataTable
slots={{
actions: <div className="flex items-center justify-end">
<Button onClick={() => setDialogOpen(true)}>
<Plus className="size-4" />
Upload Document
</Button>
</div>
}}
columns={columns}
data={documents}
pagination={pagination}
sorting={[]}
onChange={() => { }}
onRowClick={handleRowClick}
isLoading={isLoading}
/>
</CardContent>
</Card>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="min-w-xl">
<DialogHeader>
<DialogTitle>Upload Document</DialogTitle>
</DialogHeader>
<VehicleDocumentForm
vehicleId={vehicleId}
onSuccess={() => {
queryClient.invalidateQueries({ queryKey })
setDialogOpen(false)
}}
/>
</DialogContent>
</Dialog>
</div>
)
}
@@ -0,0 +1,92 @@
"use client"
import { use } from "react"
import { ResourcePage } from '@/shared/data-view/resource-page'
import { ColumnHeader } from '@/shared/data-view/table-view'
import FormDialog from '@/shared/components/form-dialog'
import { EstimateForm } from '@/modules/estimates/estimate-form'
import { ESTIMATE_ROUTES } from '@garage/api'
import type { EstimatesClient } from '@garage/api'
import { FileTextIcon } from 'lucide-react'
import { useVehicle } from '@/modules/vehicles/vehicle-context'
export default function VehicleEstimatesPage({ params }: { params: Promise<{ id: string }> }) {
const { id: vehicleId } = use(params)
const vehicle = useVehicle()
return (
<>
<ResourcePage<EstimatesClient>
tableHeader={({ invalidateQuery, selectedItem, closeDialog }) => (
<FormDialog title="Estimate">
{(resourceId) => (
<EstimateForm
resourceId={resourceId}
initialData={{
vehicle: vehicle ? { value: vehicle.id, label: vehicle.label } : null,
}}
onSuccess={() => {
closeDialog();
invalidateQuery();
}}
/>
)}
</FormDialog>
)
}
pageTitle="Vehicle Estimates"
routeKey={ESTIMATE_ROUTES.INDEX}
getClient={(api) => api.estimates}
extraParams={{ vehicle_id: vehicleId }}
header={
null
}
columns={({ actionsColumn }) => [
{
accessorKey: "title",
header: ({ column }) => <ColumnHeader column={column} title="Title" />,
cell: ({ row }) => {
const item = row.original
return (
<div className="flex items-center gap-2">
<FileTextIcon className="text-muted-foreground h-4 w-4" />
<span>{item.title}</span>
</div>
)
},
},
{
accessorKey: "estimate_number",
header: ({ column }) => <ColumnHeader column={column} title="Estimate #" />,
},
{
accessorKey: "customer_name",
header: ({ column }) => <ColumnHeader column={column} title="Customer" />,
},
{
accessorKey: "date",
header: ({ column }) => <ColumnHeader column={column} title="Date" />,
},
{
accessorKey: "has_insurance",
header: ({ column }) => <ColumnHeader column={column} title="Insurance" />,
cell: ({ row }) => {
const item = row.original
return item.has_insurance ? "Yes" : "No"
},
},
{
accessorKey: "created_at",
header: ({ column }) => <ColumnHeader column={column} title="Created" />,
cell: ({ row }) => {
const item = row.original
return item.created_at ? new Date(item.created_at).toLocaleDateString() : "—"
},
},
actionsColumn(),
]}
/>
</>
)
}
@@ -0,0 +1,49 @@
import { DashboardDetailsPageLayoutProps, DashboardDetailsPage } from '@/base/components/layout/dashboard'
import { getServerApi } from '@garage/api/server'
import { VehicleActions } from '@/modules/vehicles/vehicle-actions'
import { Car } from 'lucide-react'
import React from 'react'
import { CONSTANTS } from '@/config/constants'
export default async function layout(props: { params: Promise<{ id: string }>, children: React.ReactNode }) {
const { id } = await props.params
const api = await getServerApi()
const vehicle = await api.vehicles.getById(id)
const title = `${vehicle.data?.make || ''} ${vehicle.data?.model || ''}`.trim() || 'Vehicle Details'
return (
<>
<DashboardDetailsPage
avatarSrc={vehicle.data?.image_url || ""}
// avatarSrc={vehicle.data?.image_url || ""}
title={title}
description={vehicle.data?.license_plate ? `License Plate: ${vehicle.data.license_plate}` : undefined}
backHref="/sales/vehicles"
actions={<VehicleActions vehicleId={id} />}
tabs={[
{
href: `/sales/vehicles/${id}`,
label: 'Details'
},
{
href: `/sales/vehicles/${id}/owners`,
label: 'Owners'
},
{
href: `/sales/vehicles/${id}/documents`,
label: "Documents"
},
{
href: `/sales/vehicles/${id}/mileage`,
label: "Mileage"
},
{
href: `/sales/vehicles/${id}/estimates`,
label: "Estimates"
}
]}
>
{props.children}
</DashboardDetailsPage>
</>
)
}
@@ -0,0 +1,242 @@
"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, Pencil, Trash2, MoreHorizontal } 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 {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import { MileageForm } from "@/modules/vehicles/mileage-form"
import { formatDate, formatDateTime } from "@/shared/utils/formatters"
type MileageRecord = {
id: number
vehicle_id?: number
miles?: number | null
fuel_level?: number | null
date?: string | null
time?: string | null
note?: string | null
vehicle?: {
id?: number
make?: string | null
model?: string | null
year?: number | null
license_plate?: string | null
} | null
created_at: string
updated_at: string
}
export default function VehicleMileagePage() {
const { id: vehicleId } = useParams<{ id: string }>()
const api = useAuthApi()
const queryClient = useQueryClient()
const [dialogOpen, setDialogOpen] = useState(false)
const [editItem, setEditItem] = useState<MileageRecord | null>(null)
const queryKey = ["vehicle-mileage", vehicleId]
const { data, isLoading } = useQuery({
queryKey,
queryFn: () => api.vehicleDocuments.listMileage({ vehicle_id: vehicleId }),
})
const deleteMutation = useMutation({
mutationFn: (id: number) => {
const promise = api.vehicleDocuments.destroyMileage(String(id))
toast.promise(promise, {
loading: "Deleting...",
success: "Mileage record deleted successfully.",
error: "Failed to delete mileage record.",
})
return promise
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey })
},
})
const handleDelete = async (record: MileageRecord) => {
const label = record.miles != null ? `${record.miles} mi` : `Record #${record.id}`
const confirmed = await confirm({
title: "Delete Mileage Record",
description: `Are you sure you want to delete ${label}?`,
confirmLabel: "Delete",
variant: "destructive",
})
if (confirmed) {
deleteMutation.mutate(record.id)
}
}
const handleEdit = (record: MileageRecord) => {
setEditItem(record)
setDialogOpen(true)
}
const handleCreate = () => {
setEditItem(null)
setDialogOpen(true)
}
const columns: ColumnDef<MileageRecord>[] = [
{
accessorKey: "miles",
header: ({ column }) => <ColumnHeader column={column} title="Miles" />,
cell: ({ getValue }) => {
const value = getValue<number | null | undefined>()
return value == null ? "—" : value.toLocaleString()
},
},
{
accessorKey: "fuel_level",
header: ({ column }) => <ColumnHeader column={column} title="Fuel Level" />,
cell: ({ getValue }) => {
const value = getValue<number | null | undefined>()
return value == null ? "—" : `${value}%`
},
},
{
accessorKey: "date",
header: ({ column }) => <ColumnHeader column={column} title="Date" />,
cell: ({ getValue }) => formatDate(getValue<string | null | undefined>()),
},
{
accessorKey: "time",
header: ({ column }) => <ColumnHeader column={column} title="Time" />,
cell: ({ getValue }) => {
const value = getValue<string | null | undefined>()
if (!value) return "—"
if (value.includes("T")) {
const timePart = value.split("T")[1]
return timePart ? timePart.slice(0, 5) : "—"
}
return value.slice(0, 5)
},
},
{
accessorKey: "note",
header: ({ column }) => <ColumnHeader column={column} title="Note" />,
cell: ({ getValue }) => getValue<string | null | undefined>() || "—",
},
{
id: "vehicle",
header: ({ column }) => <ColumnHeader column={column} title="Vehicle" />,
cell: ({ row }) => {
const vehicle = row.original.vehicle
if (!vehicle) return "—"
const details = [vehicle.make, vehicle.model, vehicle.year].filter(Boolean).join(" ")
return details || vehicle.license_plate || "—"
},
enableSorting: false,
},
{
accessorKey: "created_at",
header: ({ column }) => <ColumnHeader column={column} title="Recorded At" />,
cell: ({ getValue }) => formatDateTime(getValue<string | undefined>()),
},
{
id: "actions",
header: () => <span className="sr-only">Actions</span>,
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon-sm">
<MoreHorizontal className="size-4" />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleEdit(row.original)}>
<Pencil className="size-3.5 text-muted-foreground" />
Edit
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onClick={() => handleDelete(row.original)}
>
<Trash2 className="size-3.5" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
enableSorting: false,
},
]
const records = (data as any)?.data ?? []
const meta = (data as any)?.meta
const pagination = {
page: meta?.current_page ?? 1,
pageSize: meta?.per_page ?? 15,
pageCount: meta?.last_page ?? 1,
total: meta?.total ?? 0,
}
return (
<div className="flex flex-col gap-4">
<Card>
<CardContent>
<DataTable
slots={{
actions: <Button onClick={handleCreate}>
<Plus className="size-4" />
Add Mileage
</Button>
}}
columns={columns}
data={records}
pagination={pagination}
sorting={[]}
onChange={() => { }}
isLoading={isLoading}
/>
</CardContent>
</Card>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="min-w-md">
<DialogHeader>
<DialogTitle>
{editItem ? "Edit Mileage" : "Add Mileage"}
</DialogTitle>
</DialogHeader>
<MileageForm
vehicleId={vehicleId}
resourceId={editItem ? String(editItem.id) : null}
initialData={editItem}
onSuccess={() => {
queryClient.invalidateQueries({ queryKey })
setDialogOpen(false)
setEditItem(null)
}}
/>
</DialogContent>
</Dialog>
</div>
)
}
@@ -0,0 +1,250 @@
"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 { Unlink, Plus, Loader2 } from "lucide-react"
import { toast } from "sonner"
import { useAuthApi } from "@/shared/useApi"
import { DataTable } from "@/shared/data-view/table-view"
import { 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 { RhfAsyncSelectField } from "@/shared/components/form"
import { Rhform } from "@/shared/components/form"
import { useForm } from "react-hook-form"
import { TableCell, TableRow } from "@/shared/components/ui/table"
import { ApiResponse, CustomersClient } from "@garage/api"
import { paths } from "@garage/api/types"
import DashboardPage from "@/base/components/layout/dashboard/dashboard-page"
type Owner = {
id: number
name: string
created_at: string
updated_at: string
}
const mapCustomerOption = (item: any) => ({
value: String(item.id),
label: `${item.first_name ?? ""} ${item.last_name ?? ""}`.trim() || item.name || `Customer #${item.id}`,
})
const STORE_OBJECT = {
getOptionValue: (o: any) => o,
getOptionLabel: (o: any) => o.label,
}
export default function VehicleOwnersPage() {
const { id: vehicleId } = useParams<{ id: string }>()
const api = useAuthApi()
const queryClient = useQueryClient()
const [linkDialogOpen, setLinkDialogOpen] = useState(false)
const queryKey = ["vehicle-owners", vehicleId]
const { data, isLoading } = useQuery({
queryKey,
queryFn: () => api.vehicles.getOwners(vehicleId),
})
const unlinkMutation = useMutation({
mutationFn: (customerId: number) =>
api.vehicles.unlinkCustomer({ customer_id: customerId, vehicle_id: Number(vehicleId) }),
onSuccess: () => {
toast.success("Owner unlinked successfully.")
queryClient.invalidateQueries({ queryKey })
},
onError: () => {
toast.error("Failed to unlink owner.")
},
})
const handleUnlink = async (owner: Owner) => {
const confirmed = await confirm({
title: "Unlink Owner",
description: `Are you sure you want to unlink "${owner.name}" from this vehicle?`,
confirmLabel: "Unlink",
variant: "destructive",
})
if (confirmed) {
unlinkMutation.mutate(owner.id)
}
}
const columns: ColumnDef<any>[] = [
{
accessorKey: "first_name",
header: ({ column }) => <ColumnHeader column={column} title="Name" />,
},
{
accessorKey: "phone",
header: ({ column }) => <ColumnHeader column={column} title="Phone" />,
},
{
accessorKey: "created_at",
header: ({ column }) => <ColumnHeader column={column} title="Linked At" />,
cell: ({ getValue }) => {
const val = getValue<string>()
return val ? new Date(val).toLocaleDateString() : "—"
},
},
{
id: "actions",
header: () => <span className="sr-only">Actions</span>,
cell: ({ row }) => (
<Button
variant="ghost"
size="icon-sm"
onClick={() => handleUnlink(row.original)}
title="Unlink owner"
>
<Unlink className="size-4 text-destructive" />
</Button>
),
enableSorting: false,
},
]
const owners = (data as any)?.data ?? []
// [BackendIssue] [FrontendWorkaround] : pagination should be replaced with "meta" property
const meta = (data as any)?.pagination
const pagination = {
page: meta?.current_page ?? 1,
pageSize: meta?.per_page ?? 15,
pageCount: meta?.last_page ?? 1,
total: meta?.total ?? 0,
}
return (
<Card>
<CardContent>
<DataTable
slots={{
actions:
<div className="flex justify-end">
<Button size={'lg'} onClick={() => setLinkDialogOpen(true)}>
<Plus />
Add Owner
</Button>
</div>,
}
}
columns={columns}
data={owners}
pagination={pagination}
sorting={[]}
onChange={() => { }}
isLoading={isLoading}
/>
<LinkOwnerDialog
vehicleId={vehicleId}
open={linkDialogOpen}
onOpenChange={setLinkDialogOpen}
onSuccess={() => {
queryClient.invalidateQueries({ queryKey })
setLinkDialogOpen(false)
}}
/>
</CardContent>
</Card>
)
}
function LinkOwnerDialog({
vehicleId,
open,
onOpenChange,
onSuccess,
}: {
vehicleId: string
open: boolean
onOpenChange: (open: boolean) => void
onSuccess: () => void
}) {
const api = useAuthApi()
const form = useForm<{ customer: { value: string; label: string } | null }>({
defaultValues: { customer: null },
})
const linkMutation = useMutation({
mutationFn: (customerId: number) =>
api.vehicles.linkCustomer({ customer_id: customerId, vehicle_id: Number(vehicleId) }),
onSuccess: () => {
toast.success("Owner linked successfully.")
form.reset()
onSuccess()
},
onError: () => {
toast.error("Failed to link owner.")
},
})
const handleSubmit = (values: { customer: { value: string; label: string } | null }) => {
if (!values.customer) return
linkMutation.mutate(Number(values.customer.value))
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="min-w-md">
<DialogHeader>
<DialogTitle>Add Owner</DialogTitle>
</DialogHeader>
<Rhform form={form} onSubmit={handleSubmit}>
<div className="flex flex-col gap-4">
<RhfAsyncSelectField
name="customer"
label="Customer"
placeholder="Search customers..."
queryKey={["customers-lookup"]}
listFn={() => api.customers.list()}
mapOption={mapCustomerOption}
{...STORE_OBJECT}
/>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button
type="submit"
disabled={linkMutation.isPending || !form.watch("customer")}
>
{linkMutation.isPending && <Loader2 className="size-4 animate-spin" />}
Link Owner
</Button>
</div>
</div>
</Rhform>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,18 @@
import { getServerApi } from '@garage/api/server'
import { VehicleGeneralInfo } from '@/modules/vehicles/vehicle-general-info'
import DashboardPage from '@/base/components/layout/dashboard/dashboard-page'
export default async function page(props: { params: Promise<{ id: string }> }) {
const { id } = await props.params
const api = await getServerApi()
const vehicle = await api.vehicles.getById(id)
if (!vehicle.data) {
return <div className="text-muted-foreground">Vehicle not found.</div>
}
return <DashboardPage header={null}>
<VehicleGeneralInfo vehicle={vehicle.data} />
</DashboardPage>
}
@@ -0,0 +1,130 @@
"use client"
import { useRouter } from 'next/navigation'
import { ResourcePage } from '@/shared/data-view/resource-page'
import { ColumnHeader } from '@/shared/data-view/table-view'
import type { ColumnDef } from '@tanstack/react-table'
import FormDialog from '@/shared/components/form-dialog'
import { ImportDataButton } from '@/shared/components/import-data-button'
import { ExportDataButton } from '@/shared/components/export-data-button'
import { DownloadSampleButton } from '@/shared/components/download-sample-button'
import { useAuthApi } from '@/shared/useApi'
import type { CrudResourceColumnHelpers, ResourceItem } from '@/shared/data-view/resource-page'
import { VehicleForm } from '@/modules/vehicles/vehicle-form'
import { VEHICLE_ROUTES } from '@garage/api'
import type { VehiclesClient } from '@garage/api'
import { CarIcon } from 'lucide-react'
import { getFullName } from '@/shared/utils/getFullName'
export default function VehiclesPage() {
const router = useRouter()
const api = useAuthApi()
return (
<ResourcePage<VehiclesClient>
pageTitle="Vehicles"
routeKey={VEHICLE_ROUTES.INDEX}
getClient={(api) => api.vehicles}
onRowClick={(row) => router.push(`/sales/vehicles/${(row as any).id}`)}
headerProps={({ selectedItem, invalidateQuery }) => ({
actions: (
<div className='flex items-center gap-2'>
<DownloadSampleButton
onDownload={() => api.vehicles.downloadImportSample()}
fileName='vehicles-import-sample'
/>
<ImportDataButton
onImport={(file) => api.vehicles.importData(file)}
onSuccess={invalidateQuery}
/>
<ExportDataButton
onExport={(filters) => api.vehicles.exportData(filters)}
fileName='vehicles'
/>
<FormDialog title="Vehicle" classNames={{ dialogContent: 'lg:min-w-4xl' }}>
{(resourceId) => (
<VehicleForm
resourceId={resourceId}
initialData={selectedItem}
onSuccess={invalidateQuery}
/>
)}
</FormDialog>
</div>
),
})}
columns={({ actionsColumn }) => [
{
id: "name",
header: ({ column }) => <ColumnHeader column={column} title="Vehicle" />,
cell: ({ row }) => {
const r = row.original as any
const make = r.make ?? ""
const model = r.model ?? ""
const display = r.name || `${make} ${model}`.trim() || "—"
return (
<div className="flex items-center gap-2">
<CarIcon className="h-4 w-4 text-muted-foreground" />
<div>
<span className="font-medium">{display}</span>
{r.sub_model && (
<span className="ml-1 text-xs text-muted-foreground">{r.sub_model}</span>
)}
</div>
</div>
)
},
},
{
accessorKey: "customers",
header: ({ column }: any) => <ColumnHeader column={column} title="Customer" />,
cell: ({ row }: any) => {
const val = (row.original as any).customers?.[0]
return getFullName(val)
},
} as any,
{
accessorKey: "year",
header: ({ column }) => <ColumnHeader column={column} title="Year" />,
cell: ({ row }) => (row.original as any).year ?? "—",
},
{
accessorKey: "license_plate",
header: ({ column }) => <ColumnHeader column={column} title="License Plate" />,
cell: ({ row }) => {
const val = (row.original as any).license_plate
return val
? <span className="font-mono text-xs">{val}</span>
: "—"
},
},
{
accessorKey: "vin_number",
header: ({ column }) => <ColumnHeader column={column} title="VIN" />,
cell: ({ row }) => {
const val = (row.original as any).vin_number
return val
? <span className="max-w-30 truncate block font-mono text-xs">{val}</span>
: "—"
},
},
{
accessorKey: "engine_size",
header: ({ column }) => <ColumnHeader column={column} title="Engine" />,
cell: ({ row }) => (row.original as any).engine_size ?? "—",
},
{
accessorKey: "mileage",
header: ({ column }) => <ColumnHeader column={column} title="Mileage" />,
cell: ({ row }) => {
const val = (row.original as any).mileage
return val != null ? `${Number(val).toLocaleString()} mi` : "—"
},
},
actionsColumn(),
]}
/>
)
}
@@ -0,0 +1,17 @@
"use client"
import DashboardPage from "@/base/components/layout/dashboard/dashboard-page"
import { SettingsForm } from "@/modules/settings/company/settings-form"
export default function CompanySettingsPage() {
return (
<DashboardPage headerProps={{title:'Company Settings'}}>
<div>
<p className="text-muted-foreground text-sm">
Manage your workshop profile, contact details, and preferences.
</p>
</div>
<SettingsForm />
</DashboardPage>
)
}
@@ -0,0 +1,7 @@
"use client"
import { GeneralPreferencesForm } from "@/modules/settings/configurations/general-preferences-form"
export default function GeneralPreferencesPage() {
return <GeneralPreferencesForm />
}

Some files were not shown because too many files have changed in this diff Show More