Compare commits

...

No commits in common. "master" and "employee-setup" have entirely different histories.

675 changed files with 213822 additions and 2178 deletions

BIN
.build-output.log Normal file

Binary file not shown.

View File

@ -0,0 +1,13 @@
{
"permissions": {
"allow": [
"Bash(pnpm --version)",
"Bash(grep -E \"\\\\.\\(tsx?|jsx?\\)$\")",
"Bash(grep -E \"\\\\.\\(tsx?\\)$\")"
]
},
"enabledMcpjsonServers": [
"code-review-graph"
],
"enableAllProjectMcpServers": true
}

BIN
.dashboard-build.log Normal file

Binary file not shown.

1
.env
View File

@ -1 +0,0 @@
NEXT_PUBLIC_API_URL=https://newgarage.yslootahtech.com

View File

@ -0,0 +1,76 @@
---
name: api-enums-reference
description: "Use the central API enums file as the source of truth for enum values in this project. Use when: adding enum fields, updating enum options, creating form selects, typing status/discount/rate fields, syncing backend enum changes, or avoiding duplicated hardcoded enum literals."
---
# API Enums Reference
Use this skill whenever work touches enum-like fields in API clients, schemas, forms, table filters, or page logic.
## Source of Truth
All shared enum values and enum union types must come from:
- packages/api/src/contracts/enums.ts
Do not recreate enum arrays inline when an equivalent enum already exists in this file.
## Rules
1. Reuse before creating.
Search and import from `@garage/api` exports (or local contracts path inside packages/api) before adding new literals.
2. Keep runtime and type together.
For every enum, keep this pattern in `enums.ts`:
```ts
export const ExampleStatus = ['a', 'b'] as const;
export type ExampleStatus = (typeof ExampleStatus)[number];
```
3. Preserve backend values exactly.
Enum string values are case- and space-sensitive; keep exact spelling from backend migrations/spec.
4. Avoid duplicate synonyms.
If two domains share the same canonical values, prefer reusing an existing enum unless domain separation is intentional.
5. Update centrally first.
When backend enum options change, update `packages/api/src/contracts/enums.ts` first, then update consuming UI/API code.
6. Prefer imports in forms and schemas.
Use central enums for select options and for typed payload/status fields instead of hardcoded string unions.
## Workflow
1. Identify the enum field and backend values.
2. Check `packages/api/src/contracts/enums.ts` for an existing enum.
3. If found, import and use it.
4. If missing, add a new const+type pair in `enums.ts`.
5. Update consumers to reference the central enum.
6. Verify there are no duplicated literal arrays for the same field.
## Examples
```ts
import { InvoiceStatus, type InvoiceStatus as InvoiceStatusType } from '@garage/api'
const statusOptions = InvoiceStatus
type Payload = {
status: InvoiceStatusType
}
```
```ts
import { DiscountType } from '@garage/api'
const discountOptions = DiscountType.map((value) => ({
label: value,
value,
}))
```
## Notes
- If a module needs a presentation-specific label, map from the central enum value instead of changing raw enum literals.
- If backend adds/removes values, keep API and dashboard aligned in the same change set.

304
.github/skills/crud-dialog/SKILL.md vendored Normal file
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"
```

230
.github/skills/crud-page/SKILL.md vendored Normal file
View File

@ -0,0 +1,230 @@
---
name: crud-page
description: "Create CRUD resource pages, forms, schemas, and API clients for the carage-erp dashboard. Use when: adding a new resource page, creating a CRUD feature, building a list/create/edit/delete page, scaffolding a new module, adding a new entity to the dashboard. Covers API client, Zod schema, form component, and page component creation."
---
# CRUD Page Generator
Create fully functional CRUD resource pages following the established codebase patterns. This skill covers the full stack: API client → Zod schema → form component → page component.
## When to Use
- User asks to create a new resource/entity page (e.g. "create a vendors page", "add invoices CRUD")
- User asks to add list/create/edit/delete functionality for a domain entity
- User asks to scaffold a new module or feature page
- User wants to extend the dashboard with a new data management page
## Decision: ResourcePage vs Manual DataTable
**Use `ResourcePage` (preferred)** when the resource needs full CRUD (list + create + edit + delete in a dialog). This is the standard pattern.
**Use manual `DataTable` + `useDataTableQuery`** only when the page is read-only or has highly custom layout needs.
Always prefer `ResourcePage` unless the user explicitly needs something different.
## Procedure
Follow these steps **in order**. Each step produces one file. Check the [reference files](./references/) for complete templates and patterns.
### Step 1: Check if API Client Exists
Look in `packages/api/src/clients/` for an existing client. Also check `packages/api/src/clients/index.ts` for all registered clients, and `packages/api/src/api.ts` for the factory.
- If client exists → skip to Step 3
- If client doesn't exist → continue to Step 2
### Step 2: Create API Client
Read the [API Client Reference](./references/api-client.md) for patterns and template.
Create the domain client file at `packages/api/src/clients/<resource>.ts`:
1. Define `RESOURCE_ROUTES` const with `INDEX` and `BY_ID` routes (and any extras)
2. Create a class extending `CrudClient` with the route types
3. Add any domain-specific methods beyond standard CRUD
4. Register in `packages/api/src/clients/index.ts` (export class + routes)
5. Register in `packages/api/src/api.ts` (import + add to `createApi()`)
**Route pattern**: `"/api/<plural-resource>"` for INDEX, `"/api/<plural-resource>/{id}"` for BY_ID.
**IMPORTANT**: Routes must exist in the OpenAPI schema (`packages/api/types/index.ts`) for type safety. If the route doesn't exist in the schema yet, inform the user and ask if they want to proceed with `any` types or wait for schema update.
### Step 3: Create Zod Schema
Read the [Schema Reference](./references/schema.md) for patterns and template.
Create `apps/dashboard/modules/<feature>/<feature>.schema.ts`:
1. Define `relationFieldSchema` (reuse if already exported) for foreign-key fields
2. Build the Zod object schema with all form fields
3. Use `.optional()` for non-required fields, `.min(1, "...")` for required strings
4. Use `z.union([z.string().email(...), z.literal("")]).optional()` for optional emails
5. Export the schema, the inferred type, and `relationFieldSchema` if new
### Step 4: Create Form Component
Read the [Form Reference](./references/form.md) for the complete template.
Create `apps/dashboard/modules/<feature>/<feature>-form.tsx`:
1. Define default values matching the schema
2. Create `mapToFormValues(data)` — transforms API shape → form shape using `toRelation()`
3. Create `mapFormToPayload(values)` — transforms form shape → API shape using `toId()`
4. Use `useResourceForm()` for form initialization + edit pre-filling
5. Use `useFormMutation()` for submit with automatic validation error mapping
6. Render with `Rhform` + `RhfTextField` / `RhfSelectField` / `RhfAsyncSelectField` etc.
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
Read the [Page Reference](./references/page.md) for the complete template.
Create `apps/dashboard/app/(authenticated)/<section>/<feature>/page.tsx`:
1. Add `"use client"` directive
2. Import `ResourcePage`, `ColumnHeader`, the form, client type, and routes
3. Configure: `pageTitle`, `title`, `routeKey`, `getClient`, `columns`, `renderForm`
4. Use `columns` callback to receive `actionsColumn` helper
5. Add sortable column headers with `<ColumnHeader>`
6. Include `actionsColumn()` as last column
### Step 6: Verify
- Ensure all imports resolve
- Check that route constants match OpenAPI paths
- Confirm the client is registered in both `clients/index.ts` and `api.ts`
## Key Conventions
### Naming
| Item | Pattern | Example |
|---|---|---|
| Client file | `packages/api/src/clients/<kebab-resource>.ts` | `job-cards.ts` |
| Client class | `<PascalResource>Client` | `JobCardsClient` |
| Routes const | `<UPPER_SNAKE>_ROUTES` | `JOB_CARD_ROUTES` |
| Schema file | `modules/<feature>/<feature>.schema.ts` | `job-card.schema.ts` |
| Form file | `modules/<feature>/<feature>-form.tsx` | `job-card-form.tsx` |
| Page file | `app/(authenticated)/<section>/<feature>/page.tsx` | `sales/job-cards/page.tsx` |
| Zod schema | `<camelFeature>FormSchema` | `jobCardFormSchema` |
| Form values type | `<PascalFeature>FormValues` | `JobCardFormValues` |
| Form component | `<PascalFeature>Form` | `JobCardForm` |
| Page component | `<PascalFeature>Page` (default export) | `JobCardsPage` |
### 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`
- Use `toRelation(id, name)` to convert API data → form value
- Use `toId(relation)` to convert form value → API payload
- Schema uses `relationFieldSchema = z.object({ value: z.string(), label: z.string() }).nullable()`
#### 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
```tsx
const mapLookupOption = (item: any) => ({ value: String(item.id), label: item.name })
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
<RhfAsyncSelectField
name="field_name"
label="Field Label"
placeholder="Select..."
queryKey={["query-key"]}
listFn={() => api.resource.listSomething()}
mapOption={mapLookupOption}
{...STORE_OBJECT}
/>
```
### Available Form Field Components
| Component | Use For |
|---|---|
| `RhfTextField` | Text, email, phone, URL inputs |
| `RhfTextareaField` | Multi-line text |
| `RhfCheckboxField` | Boolean toggles |
| `RhfSelectField` | Static option dropdowns |
| `RhfAsyncSelectField` | Server-fetched single FK combobox (same-domain relation) |
| `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
```tsx
// Page
import { ResourcePage } from '@/shared/data-view/resource-page'
import { ColumnHeader } from '@/shared/data-view/table-view'
import type { <Resource>Client } from '@garage/api'
import { <RESOURCE>_ROUTES } from '@garage/api'
// Form
import { Rhform, RhfTextField, RhfSelectField, RhfAsyncSelectField } from "@/shared/components/form"
import { useResourceForm } from "@/shared/hooks/use-resource-form"
import { useFormMutation } from "@/shared/hooks/use-form-mutation"
import { useAuthApi } from "@/shared/useApi"
import { toRelation, toId } from "@/shared/lib/utils"
import { Button } from "@/shared/components/ui/button"
import { Alert, AlertTitle } from "@/shared/components/ui/alert"
import { FieldGroup } from "@/shared/components/ui/field"
import { toast } from "sonner"
// Schema
import { z } from "zod"
```
## Extending the CRUD Codebase
If a feature requires functionality not covered by existing utilities (e.g. inline editing, tab-based forms, file uploads, nested resources), you are encouraged to extend the shared infrastructure:
- Add new form field components in `shared/components/form/controls/` and `shared/components/form/fields/`
- Add new hooks in `shared/hooks/`
- Extend `ResourcePage` props if needed
- Add new column helper factories in `shared/data-view/table-view/`
- Keep extensions generic and reusable — follow the same patterns as existing code

View File

@ -0,0 +1,140 @@
# API Client Reference
## File Location
`packages/api/src/clients/<kebab-resource>.ts`
## Standard CrudClient Pattern (Preferred)
Use this when the resource has standard CRUD endpoints that exist in the OpenAPI schema.
```ts
import { CrudClient } from "../infra/crud-client"
import type { ApiClientOptions } from "../infra/client"
import type { ApiPath, ApiRequestBody } from "../infra/types"
export const <RESOURCE>_ROUTES = {
INDEX: "/api/<plural-resource>",
BY_ID: "/api/<plural-resource>/{id}",
// Add extra routes as needed:
// EXPORT: "/api/<plural-resource>/export",
// IMPORT: "/api/<plural-resource>/import",
// RELATED: "/api/<related-resource>",
} as const satisfies Record<string, ApiPath>
export class <Resource>Client extends CrudClient<
typeof <RESOURCE>_ROUTES.INDEX,
typeof <RESOURCE>_ROUTES.BY_ID
> {
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
super(baseUrl, defaultOptions, <RESOURCE>_ROUTES.INDEX, <RESOURCE>_ROUTES.BY_ID)
}
// Add domain-specific methods:
// async listCategories() {
// return this.get(<RESOURCE>_ROUTES.RELATED)
// }
//
// async export() {
// return this.get(<RESOURCE>_ROUTES.EXPORT)
// }
}
```
### CrudClient Gives You For Free
| Method | HTTP | Description |
|---|---|---|
| `list(query?)` | `GET /api/<resource>` | Paginated list with query params |
| `show(id)` | `GET /api/<resource>/{id}` | Single item fetch |
| `create(payload)` | `POST /api/<resource>` | Create new item |
| `update(id, payload)` | `PUT /api/<resource>/{id}` | Update existing item |
| `destroy(id)` | `DELETE /api/<resource>/{id}` | Delete item |
## Minimal CrudClient (No Custom Methods)
For simple resources with only standard CRUD:
```ts
import { CrudClient } from "../infra/crud-client"
import type { ApiClientOptions } from "../infra/client"
import type { ApiPath } from "../infra/types"
export const <RESOURCE>_ROUTES = {
INDEX: "/api/<plural-resource>",
BY_ID: "/api/<plural-resource>/{id}",
} as const satisfies Record<string, ApiPath>
export class <Resource>Client extends CrudClient<
typeof <RESOURCE>_ROUTES.INDEX,
typeof <RESOURCE>_ROUTES.BY_ID
> {
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
super(baseUrl, defaultOptions, <RESOURCE>_ROUTES.INDEX, <RESOURCE>_ROUTES.BY_ID)
}
}
```
## Registration
After creating the client, register it in two files:
### 1. `packages/api/src/clients/index.ts`
```ts
export { <Resource>Client, <RESOURCE>_ROUTES } from "./<kebab-resource>"
```
### 2. `packages/api/src/api.ts`
Add the import at the top:
```ts
import { <Resource>Client } from "./clients/<kebab-resource>"
```
Add to the `createApi()` return object:
```ts
export function createApi(options?: ApiClientOptions) {
return {
// ...existing clients...
<camelResource>: new <Resource>Client(undefined, options),
}
}
```
## Real Example: CustomersClient
```ts
import { CrudClient } from "../infra/crud-client"
import { ApiClient, type ApiClientOptions } from "../infra/client"
import type { ApiPath, ApiRequestBody } from "../infra/types"
export const CUSTOMER_ROUTES = {
INDEX: "/api/customers",
BY_ID: "/api/customers/{id}",
EXPORT: "/api/customers/export",
IMPORT: "/api/customers/import",
CUSTOMER_TYPES: "/api/customer-types",
} as const satisfies Record<string, ApiPath>
export class CustomersClient extends CrudClient<
typeof CUSTOMER_ROUTES.INDEX,
typeof CUSTOMER_ROUTES.BY_ID
> {
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
super(baseUrl, defaultOptions, CUSTOMER_ROUTES.INDEX, CUSTOMER_ROUTES.BY_ID)
}
async listCustomerTypes() {
return this.get(CUSTOMER_ROUTES.CUSTOMER_TYPES)
}
async export() {
return this.get(CUSTOMER_ROUTES.EXPORT)
}
async import(payload: ApiRequestBody<typeof CUSTOMER_ROUTES.IMPORT, "post">) {
return this.post(CUSTOMER_ROUTES.IMPORT, payload)
}
}
```

View File

@ -0,0 +1,241 @@
# Form Reference
## File Location
`apps/dashboard/modules/<feature>/<feature>-form.tsx`
## Complete Template
```tsx
"use client"
import { AlertTriangle, Plus, Save } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { Alert, AlertTitle } from "@/shared/components/ui/alert"
import { FieldGroup } from "@/shared/components/ui/field"
import {
Rhform,
RhfTextField,
RhfSelectField,
RhfAsyncSelectField,
// RhfTextareaField,
// RhfCheckboxField,
// RhfAsyncMultiSelectField,
} from "@/shared/components/form"
import { toast } from "sonner"
import { useAuthApi } from "@/shared/useApi"
import { useResourceForm } from "@/shared/hooks/use-resource-form"
import { useFormMutation } from "@/shared/hooks/use-form-mutation"
import { toRelation, toId } from "@/shared/lib/utils"
import {
<feature>FormSchema,
type <Feature>FormValues,
} from "./<feature>.schema"
import { <RESOURCE>_ROUTES } from "@garage/api"
// ── Constants ──
// ALWAYS derive select options from API enums imported from "@garage/api".
// NEVER hardcode enum values inline — they must stay in sync with the backend.
//
// 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 ──
export type <Feature>FormProps = {
resourceId?: string | null
initialData?: unknown
onSuccess?: () => void
}
// ── Default values ──
const DEFAULT_VALUES: <Feature>FormValues = {
// Match every field in the Zod schema:
// name: "",
// email: "",
// category: null, // relation fields default to null
// is_active: true, // booleans
}
// ── Mapping helpers ──
function mapToFormValues(data: unknown): <Feature>FormValues {
const d = (data as any)?.data ?? data ?? {}
return {
// String fields:
// name: d.name || "",
// email: d.email || "",
// Relation fields (API returns id + name separately):
// category: toRelation(d.category_id, d.category_name),
// Booleans:
// is_active: d.is_active ?? true,
}
}
function mapFormToPayload(values: <Feature>FormValues) {
return {
// String fields — use `|| undefined` to send null for empty strings:
// name: values.name,
// email: values.email || undefined,
// Relation fields — extract the numeric ID:
// category_id: toId(values.category),
// Booleans:
// is_active: values.is_active,
}
}
// ── Shared mapOption for async selects ──
const mapLookupOption = (item: any) => ({
value: String(item.id),
label: item.name,
})
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
// ── Component ──
export function <Feature>Form({ resourceId, initialData, onSuccess }: <Feature>FormProps) {
const api = useAuthApi()
const { form, isEditing } = useResourceForm<<Feature>FormValues, any>({
schema: <feature>FormSchema,
defaultValues: DEFAULT_VALUES,
resourceId,
initialData,
initialize: (id) => api.<camelResource>.show(id),
queryKey: [<RESOURCE>_ROUTES.BY_ID, resourceId],
mapToFormValues: mapToFormValues,
})
const { mutate, error, isPending } = useFormMutation(form, {
mutationFn: (values: <Feature>FormValues) => {
const payload = mapFormToPayload(values)
const promise = isEditing && resourceId
? api.<camelResource>.update(resourceId, payload)
: api.<camelResource>.create(payload)
toast.promise(promise, {
loading: isEditing ? "Updating <resource>..." : "Creating <resource>...",
success: isEditing ? "<Resource> updated successfully" : "<Resource> created successfully",
error: isEditing ? "Failed to update <resource>" : "Failed to create <resource>",
})
return promise
},
onSuccess: () => {
form.reset()
onSuccess?.()
},
})
return (
<Rhform form={form} onSubmit={(values) => mutate(values)}>
{error && (
<Alert variant="destructive" className="mb-4">
<AlertTriangle className="me-2 h-4 w-4" />
<AlertTitle>
{isEditing ? "Failed to update <resource>" : "Failed to create <resource>"}
</AlertTitle>
{error.message}
</Alert>
)}
<FieldGroup>
{/* Text fields */}
{/* <RhfTextField name="name" label="Name" placeholder="Enter name" required /> */}
{/* Grid layout for side-by-side fields */}
{/* <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField name="email" label="Email" type="email" />
<RhfTextField name="phone" label="Phone" type="tel" />
</div> */}
{/* Static select */}
{/* <RhfSelectField
name="status"
label="Status"
placeholder="Select status"
options={STATUS_OPTIONS}
/> */}
{/* Async select (fetches options from API) */}
{/* <RhfAsyncSelectField
name="category"
label="Category"
placeholder="Select category"
queryKey={["categories"]}
listFn={() => api.categories.list()}
mapOption={mapLookupOption}
{...STORE_OBJECT}
/> */}
{/* Textarea */}
{/* <RhfTextareaField name="notes" label="Notes" rows={4} /> */}
{/* Checkbox */}
{/* <RhfCheckboxField name="is_active" label="Active" /> */}
<Button type="submit" variant="default" disabled={isPending}>
{isEditing ? <Save /> : <Plus />}
{isPending
? (isEditing ? "Updating..." : "Creating...")
: (isEditing ? "Update <Resource>" : "Create <Resource>")}
</Button>
</FieldGroup>
</Rhform>
)
}
```
## Key Patterns
### mapToFormValues
Transforms API response → form values. Always handle:
- Null safety: `d.field || ""`
- Relation fields: `toRelation(d.relation_id, d.relation_name)`
- Nested data: `(data as any)?.data ?? data ?? {}`
- Booleans: `d.field ?? defaultValue`
### mapFormToPayload
Transforms form values → API request body. Always handle:
- Empty strings to undefined: `values.field || undefined`
- Relation to ID: `toId(values.relation)`
- Keep required fields as-is: `values.name`
### useResourceForm
Initializes react-hook-form with Zod validation. Handles both create and edit modes:
- `resourceId` null → create mode (uses `defaultValues`)
- `resourceId` set → edit mode (fetches via `initialize`, maps with `mapToFormValues`)
### useFormMutation
Wraps `useMutation` with automatic Laravel validation error mapping to form fields. No need to manually handle `ApiError.validationErrors`.
### Toast Pattern
Always use `toast.promise()` wrapping the API call for consistent loading/success/error feedback.
## Layout Conventions
- Use `<FieldGroup>` to wrap all fields
- Use `<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">` for side-by-side fields
- Place the submit button at the bottom inside `<FieldGroup>`
- Show error alert above fields when mutation fails

View File

@ -0,0 +1,225 @@
# Page Reference
## File Location
`apps/dashboard/app/(authenticated)/<section>/<feature>/page.tsx`
Where `<section>` is the navigation section (e.g. `sales`, `inventory`, `hr`) and `<feature>` is the resource in kebab-case plural.
## Complete Template (ResourcePage Pattern)
```tsx
"use client"
import { ResourcePage } from '@/shared/data-view/resource-page'
import { ColumnHeader } from '@/shared/data-view/table-view'
import { <Feature>Form } from '@/modules/<feature>/<feature>-form'
import { <RESOURCE>_ROUTES } from '@garage/api'
import type { <Resource>Client } from '@garage/api'
export default function <Features>Page() {
return (
<ResourcePage<<Resource>Client>
pageTitle="<Features>"
title="<Feature>"
routeKey={<RESOURCE>_ROUTES.INDEX}
getClient={(api) => api.<camelResource>}
columns={({ actionsColumn }) => [
{
accessorKey: "<primary_field>",
header: ({ column }) => <ColumnHeader column={column} title="<Primary Field>" />,
},
{
accessorKey: "<field_2>",
header: ({ column }) => <ColumnHeader column={column} title="<Field 2>" />,
},
// Add more columns as needed...
actionsColumn(),
]}
renderForm={({ resourceId, initialData, onSuccess }) => (
<<Feature>Form
resourceId={resourceId}
initialData={initialData}
onSuccess={onSuccess}
/>
)}
/>
)
}
```
## ResourcePage Props
| Prop | Required | Description |
|---|---|---|
| `pageTitle` | No | Page heading text (e.g. "Customers") |
| `title` | Yes | Singular noun for button/dialog (e.g. "Customer" → "Add Customer") |
| `routeKey` | Yes | React Query cache key, use `ROUTES.INDEX` |
| `getClient` | Yes | Selects the domain client from the authenticated API |
| `columns` | Yes | Column definitions — use callback form to get `actionsColumn` helper |
| `renderForm` | Yes | Renders the form component inside the dialog |
| `queryOptions` | No | React Query overrides (`staleTime`, etc.) |
## Column Patterns
### Simple text column (sortable)
```tsx
{
accessorKey: "name",
header: ({ column }) => <ColumnHeader column={column} title="Name" />,
},
```
### Custom cell renderer
```tsx
{
accessorKey: "status",
header: ({ column }) => <ColumnHeader column={column} title="Status" />,
cell: ({ row }) => (
<span className={row.original.status === "active" ? "text-green-600" : "text-red-600"}>
{row.original.status}
</span>
),
},
```
### Column with icon
```tsx
{
accessorKey: "name",
header: ({ column }) => <ColumnHeader column={column} title="Name" />,
cell: ({ row }) => (
<div className="flex items-center gap-2">
<UserIcon className="text-muted-foreground" />
<span>{row.original.name}</span>
</div>
),
},
```
### Non-sortable column
```tsx
{
accessorKey: "notes",
header: () => <span>Notes</span>,
enableSorting: false,
},
```
### Actions column (always last)
```tsx
actionsColumn(),
```
## Real Example: Customers Page
```tsx
"use client"
import { ResourcePage } from '@/shared/data-view/resource-page'
import { ColumnHeader } from '@/shared/data-view/table-view'
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() {
return (
<ResourcePage<CustomersClient>
pageTitle='Customers'
title="Customer"
routeKey={CUSTOMER_ROUTES.INDEX}
getClient={(api) => api.customers}
columns={({ actionsColumn }) => [
{
accessorKey: "first_name",
header: ({ column }) => <ColumnHeader column={column} title="Customer" />,
cell: ({ row }) => {
const customerName = row.original.first_name
const isCompany = row.original.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(),
]}
renderForm={({ resourceId, initialData, onSuccess }) => (
<CustomerForm
resourceId={resourceId}
initialData={initialData}
onSuccess={onSuccess}
/>
)}
/>
)
}
```
## Alternative: Manual DataTable Pattern (Read-Only or Custom Layout)
Use only when you don't need create/edit/delete in a dialog:
```tsx
"use client"
import { DashboardHeader } from '@/base/components/layout/dashboard'
import DashboardPage from '@/base/components/layout/dashboard/dashboard-page'
import { ColumnHeader, DataTable, useDataTableQuery } from '@/shared/data-view/table-view'
import { useAuthApi } from '@/shared/useApi'
import { <RESOURCE>_ROUTES } from '@garage/api'
import type { ColumnDef } from '@tanstack/react-table'
const columns: ColumnDef<any>[] = [
{
accessorKey: "name",
header: ({ column }) => <ColumnHeader column={column} title="Name" />,
},
// ... more columns
]
export default function <Features>Page() {
const api = useAuthApi()
const { data, isLoading, pagination, sorting, handleChange } = useDataTableQuery({
queryKey: [<RESOURCE>_ROUTES.INDEX],
client: api.<camelResource>,
})
const response = data as any
return (
<DashboardPage header={<DashboardHeader />}>
<DataTable
columns={columns}
data={response?.data ?? []}
pagination={{
...pagination,
pageCount: response?.meta?.last_page ?? 1,
total: response?.meta?.total ?? 0,
}}
sorting={sorting}
onChange={handleChange}
isLoading={isLoading}
/>
</DashboardPage>
)
}
```

View File

@ -0,0 +1,143 @@
# Schema Reference
## File Location
`apps/dashboard/modules/<feature>/<feature>.schema.ts`
## Template
```ts
import { z } from "zod"
// Reusable relation field schema — use for all foreign-key / lookup fields
const relationFieldSchema = z
.object({ value: z.string(), label: z.string() })
.nullable()
const <feature>FormSchema = z.object({
// ── Relations (stored as { value, label } objects, mapped to IDs on submit) ──
// category: relationFieldSchema,
// ── Required strings ──
// name: z.string().min(1, "Name is required"),
// ── Optional strings ──
// description: z.string().optional(),
// ── Optional email (allows empty string) ──
// email: z.union([
// z.string().email("Enter a valid email address"),
// z.literal(""),
// ]).optional(),
// ── Optional phone ──
// phone: z.string().optional(),
// ── Boolean ──
// is_active: z.boolean().default(true),
// ── Number ──
// quantity: z.coerce.number().min(0),
// ── Date ──
// due_date: z.string().optional(),
})
type <Feature>FormValues = z.infer<typeof <feature>FormSchema>
export { <feature>FormSchema, relationFieldSchema }
export type { <Feature>FormValues }
```
## Field Type Patterns
### Required string
```ts
name: z.string().min(1, "Name is required"),
```
### Optional string
```ts
notes: z.string().optional(),
```
### Optional email (allows empty)
```ts
email: z.union([
z.string().email("Enter a valid email address"),
z.literal(""),
]).optional(),
```
### Relation / Foreign key
```ts
const relationFieldSchema = z
.object({ value: z.string(), label: z.string() })
.nullable()
// In schema:
department: relationFieldSchema,
```
### Required relation
```ts
department: z
.object({ value: z.string(), label: z.string() })
.refine((v) => v !== null, { message: "Department is required" }),
```
### Boolean with default
```ts
is_active: z.boolean().default(true),
```
### Number (from string input)
```ts
quantity: z.coerce.number().min(0, "Must be non-negative"),
price: z.coerce.number().min(0),
```
### Static enum select
```ts
status: z.enum(["active", "inactive", "pending"]).default("active"),
salutation: z.string().optional(),
```
## Real Example: CustomerFormSchema
```ts
import { z } from "zod"
const relationFieldSchema = z
.object({ value: z.string(), label: z.string() })
.nullable()
type RelationField = z.infer<typeof relationFieldSchema>
const customerFormSchema = z.object({
customer_type: relationFieldSchema,
referral_source: relationFieldSchema,
payment_terms: relationFieldSchema,
country: relationFieldSchema,
state: relationFieldSchema,
salutation: z.string().optional(),
first_name: z.string().min(1, "First name is required"),
last_name: z.string().min(1, "Last name is required"),
company_name: z.string().optional(),
email: z.union([
z.string().email("Enter a valid email address"),
z.literal(""),
]).optional(),
phone: z.string().optional(),
alternate_phone: z.string().optional(),
address_line_1: z.string().optional(),
address_line_2: z.string().optional(),
city: z.string().optional(),
zip_code: z.string().optional(),
})
type CustomerFormValues = z.infer<typeof customerFormSchema>
export { customerFormSchema, relationFieldSchema }
export type { CustomerFormValues, RelationField }
```

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
.github/skills/invoice-pattern/SKILL.md vendored Normal file
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>
```

View File

@ -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
.github/skills/resource-filters/SKILL.md vendored Normal file
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.

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" />`.

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>
```

60
.gitignore vendored
View File

@ -1,39 +1,39 @@
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# testing
/coverage
/cypress/videos
/cypress/screenshots
/cypress/downloads
# Dependencies
node_modules
.pnp
.pnp.js
# next.js
/.next/
/out/
# Local env files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
.env.prod
# production
/build
# Testing
coverage
# misc
.DS_Store
*.pem
# Turbo
.turbo
# debug
# Vercel
.vercel
# Build Outputs
.next/
out/
build
dist
# Debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# env files
.env*.local
# typescript
*.tsbuildinfo
next-env.d.ts
# Misc
.DS_Store
*.pem

View File

@ -1,49 +0,0 @@
[?9001h[?1004h]0;C:\WINDOWS\system32\cmd.exe[?25h[?25l
> dashboard@0.0.1 prebuild C:\Users\LOQ\Desktop\workspace\carage-erp\apps\dashboard
> pnpm --filter @repo/api run generate[?25h[?25l
> @repo/api@0.0.0 generate C:\Users\LOQ\Desktop\workspace\carage-erp\packages\api
> pnpm run generate:openapi && pnpm run generate:types[?25h[?25l
> @repo/api@0.0.0 generate:openapi C:\Users\LOQ\Desktop\workspace\carage-erp\packages\api
> pnpm run prepare:dirs && node scripts/generate-openapi.cjs[?25h[?25l
> @repo/api@0.0.0 prepare:dirs C:\Users\LOQ\Desktop\workspace\carage-erp\packages\api
> node -e "const fs=require('fs');fs.mkdirSync('open-api',{recursive:true});fs.mkdirSync('types',{recursive:true});"[?25hOpenAPI schema written to open-api/schema.json
> @repo/api@0.0.0 generate:types C:\Users\LOQ\Desktop\workspace\carage-erp\packages\api
> node scripts/generate-types.cjs
]0;npmnpm warn Unknown env config "recursive". This will stop working in the next major version of npm. 
]0;npm exec openapi-typescript open-api/schema.json -o types/index.ts✨ openapi-typescript 7.13.0 
🚀 open-api/schema.json → types/index.ts [286.8ms] 

> dashboard@0.0.1 build C:\Users\LOQ\Desktop\workspace\carage-erp\apps\dashboard
> next build
▲ Next.js 16.1.7 (Turbopack)
- Environments: .env
  Creating an optimized production build ...
✓ Compiled successfully in 6.9s
[?25l  Running TypeScript .   Running TypeScript ..   Running TypeScript ...   Running TypeScript .   Running TypeScript ..   Running TypeScript ...   Running TypeScript .   Running TypeScript ..   Running TypeScript ...   Running TypeScript .   Running TypeScript ..   Running TypeScript ...   Running TypeScript .   Running TypeScript ..   Running TypeScript ...   Running TypeScript .   Running TypeScript ..   Running TypeScript ...   Running TypeScript .   Running TypeScript ..   Running TypeScript ...   Running TypeScript .   Running TypeScript ..   Running TypeScript ...   Running TypeScript .   Running TypeScript ..   Running TypeScript ...   Running TypeScript .   Running TypeScript ..   Running TypeScript ...   Running TypeScript .   Running TypeScript ..   Running TypeScript ...   Running TypeScript .   Running TypeScript ..  ✓ Finished TypeScript in 7.0s [?25h
  Collecting page data using 27 workers .[?25l   Collecting page data using 27 workers ..   Collecting page data using 27 workers ...   Collecting page data using 27 workers .   Collecting page data using 27 workers .. ✓ Collecting page data using 27 workers in 823.0ms [?25h
  Generating static pages using 27 workers (0/15) [ ][?25l   Generating static pages using 27 workers (0/15) [= ]   Generating static pages using 27 workers (0/15) [== ]   Generating static pages using 27 workers (0/15) [=== ]   Generating static pages using 27 workers (8/15) [ ===]  ✓ Generating static pages using 27 workers (15/15) in 876.0ms [?25h
  Finalizing page optimization .[?25l ✓ Finalizing page optimization in 10.5ms [?25h
Route (app)
┌ ○ /
├ ○ /_not-found
├ ○ /items/parts
├ ○ /items/service-group
├ ○ /items/services
├ ○ /login
├ ○ /productivity/employees
├ ○ /productivity/shop-calendars
├ ○ /productivity/shop-timings
├ ○ /sales/customers
├ ○ /sales/inspections
├ ○ /sales/vehicles
└ ○ /settings/shop-type
○ (Static) prerendered as static content
[?9001l[?1004l

486
FEATURE_GAP_PLAN.md Normal file
View File

@ -0,0 +1,486 @@
# Garage ERP — Feature Gap Plan vs. Industry Leaders
> Comparison of the current Reparee Garage ERP against established automotive
> workshop management platforms (GarageBox, Tekmetric, Shopmonkey, AutoLeap,
> Garage Hive, Mitchell 1 Manager SE, MaxxTraxx, Workshop Software).
>
> Goal: list what does **not** yet exist in this codebase, group it by impact,
> and give each gap an implementation hint so it can be picked up later.
---
## 1. What Reparee already has (baseline)
Captured from the current backend (`reparee_backend/`) controllers/models and
dashboard navigation (`apps/dashboard/config/navGroups.tsx`):
**Sales & service workflow**
- Customers (with notes, types, referral sources, import/export)
- Vehicles (owners, makes/models, body types, fuel, transmission, colors,
documents, mileage history, import/export)
- Inspections + Inspection Templates (categories, checkpoints, labels)
- Public share link for inspections (token-based, rate-limited)
- Estimates → Job Cards → Invoices (services, parts, expense items, internal
notes, labels, document attachments)
- Credit Notes
- Payments Received
- Appointments (calendar)
**Purchases**
- Vendors (addresses, status, credits)
- Expenses & Expense Items
- Purchase Orders (with internal notes, labels)
- Bills
- Payments Made
- Vendor Credits
**Items / inventory**
- Services and Service Groups (with includes/pricing/parts)
- Parts (with categories, unit types)
- Inventory Adjustments
- Labor Rates, Taxes
**HR / productivity**
- Employees (with documents, certifications, avatar, performance, permissions)
- Departments, Roles (with `permission.check` middleware on every route)
- Leave Requests + Leave Balance
- Payroll Runs + Entries + Slips
- Tasks, Task Sections, Task Types, TimeSheet
- Shop Calendar, Shop Timing, Holidays
**Platform**
- PDF generation via `DocumentPrintController` + Blade templates
- Document share tokens
- Settings (company, including logo + Terms & Conditions in `privacy_policy`)
- i18n EN/AR with RTL
- OpenAPI client (`packages/api/`) regenerated from backend spec
**Sidebar shows planned-but-disabled groups** (commented out in
`navGroups.tsx`): CRM (leads/calls/tasks), Marketing (service reminders,
ratings/reviews, Google reviews), Accountants (manual journals, chart of
accounts), Reports, Payroll, Integrations, Templates.
---
## 2. Priority matrix
| Tier | Meaning |
|------|---------|
| **P0** | Table-stakes for a modern garage SaaS — competitors all ship it, customers ask first |
| **P1** | Significant value-add, expected by mid-market shops |
| **P2** | Differentiators / nice-to-haves |
| **P3** | Long-tail integrations and advanced workflows |
---
## 3. P0 — Table-stakes gaps
### 3.1 Reporting & Analytics dashboard
**Status:** No `/reports` route exists; sidebar entry is commented out. A
`/` dashboard page exists but is not analytics-rich.
**What competitors ship:**
- Revenue by day/week/month, by service category, by technician
- Open jobs / WIP value, ARO (Average Repair Order), gross profit %
- Aging receivables (already implicit in invoices, no view)
- Parts margin, labor margin
- Technician productivity (billed hours vs. clocked hours)
- Inventory turnover, stock-outs
- Customer retention / first-visit vs. repeat
**Plan:**
- New backend controllers: `ReportController` exposing aggregations
(revenue, AR aging, technician utilization, inventory turnover).
- New dashboard area: `apps/dashboard/app/(authenticated)/reports/`
with sub-routes per report.
- Re-use TanStack Query + a chart lib already approved (or ask before adding
recharts / visx). KPIs on home page should pull from the same endpoints.
### 3.2 Service Reminders (mileage- and time-based)
**Status:** `Vehicle` has mileage logs (`VehicleMileAndKm`) and documents
have expiry dates, but no reminder engine. Sidebar Marketing group is
commented out.
**What competitors ship:**
- "Next service in 5,000 km" rules per vehicle/service
- Insurance / registration / inspection expiry reminders
- Auto-send via email/SMS X days before due
- Customer can click to book
**Plan:**
- New model: `ServiceReminderRule` (per vehicle or template per make/model:
due_at_date, due_at_mileage, service_group_id, channel).
- Scheduled job (Laravel scheduler) that scans daily and queues notifications.
- Channel adapters wired to the notification stack from §3.3.
- UI under `productivity` or new `marketing/` area.
### 3.3 Customer notifications (Email / SMS / WhatsApp)
**Status:** No notification channels found beyond document share links. No
`Notification` or messaging tables in models.
**What competitors ship:**
- Twilio/Vonage SMS, WhatsApp Business API
- Two-way SMS thread per customer/job (Tekmetric, Shopmonkey)
- Templated estimate-ready / job-ready / pickup-ready messages
- Inbox view of all customer conversations
**Plan:**
- Add `notification_channels` config + adapter interface
(`App\Services\Notifications\Channel`).
- Provider drivers behind feature flags (SMS via Twilio/MessageBird, email
via existing Laravel mail, WhatsApp via Meta Cloud API).
- Tables: `customer_messages` (inbound + outbound), `notification_templates`
(subject/body, locale, type).
- Dashboard inbox under `/sales/customers/[id]/messages` + global `/inbox`.
### 3.4 Customer-facing portal (estimate approval, history)
**Status:** Only `PublicInspectionController` exists for public viewing.
Estimates and invoices have no customer-side approval flow.
**What competitors ship:**
- Customer logs in (or magic-link) and sees: vehicles, history, estimates,
invoices, upcoming appointments.
- One-click approve / decline / partial-approve on estimate line items
(digital authorization with audit trail).
- Pay invoice via portal.
**Plan:**
- Reuse `DocumentShare` token pattern, broaden into a `customer_portal_token`.
- New `PublicEstimateController` mirroring `PublicInspectionController`,
with POST endpoints for approve/decline per line item.
- Persist signature/IP/timestamp on `EstimateAuthorisationHistory` (model
already exists — confirm fields cover approvals from portal).
- Frontend: new public route group `app/portal/` (no auth, token-scoped).
### 3.5 Online payments
**Status:** `PaymentMode` table exists, payments are recorded manually. No
gateway integration.
**What competitors ship:**
- Stripe / Square / regional gateway integration
- "Pay invoice" link inside email/SMS
- Saved cards, partial payments, tips
- Reconciliation back to `PaymentRecieved`
**Plan:**
- Pluggable gateway driver under `App\Services\Payments\`.
- New endpoint `POST /api/invoices/{id}/payment-link` returns hosted URL.
- Webhook handler that creates `PaymentRecieved` rows on capture.
- Frontend `pay/[token]` page bundled with the portal in §3.4.
### 3.6 Audit log
**Status:** No audit/activity table found.
**What competitors ship:** Who changed what, when, on every business
record. Required for shops with multiple staff.
**Plan:**
- Adopt `spatie/laravel-activitylog` (request before adding the dep) or
hand-roll a polymorphic `activity_log` table.
- Log on estimate/job-card/invoice state transitions, permission changes,
payment events.
- Read-only view at `/settings/activity-log`.
### 3.7 Workflow / Kanban board for Job Cards
**Status:** Job Cards live in a list view. No status pipeline UI.
**What competitors ship:** Drag-and-drop columns ("Checked-in", "Awaiting
Parts", "In Progress", "QC", "Ready for Pickup"). Drives the shop's day.
**Plan:**
- Add `job_card_status` (enum or FK to `job_card_statuses` table editable
in settings).
- Backend: status transition endpoint with allowed-transition guard.
- Frontend: new `/sales/job-cards/board` route using dnd-kit (already in
the React ecosystem; confirm dep). Cards link to existing detail page.
### 3.8 Vehicle service history (per VIN, cross-customer)
**Status:** History is currently scoped via the vehicle's job cards, but
there is no consolidated "vehicle history" tab summarizing services,
inspections, mileage, and recommendations together.
**Plan:**
- New endpoint `GET /api/vehicles/{id}/timeline` merging job cards,
inspections, recommendations, document expiries, mileage events into a
single chronological feed.
- Tab on the vehicle detail page rendering the timeline.
### 3.9 Recommended / deferred work tracking
**Status:** `FastShopRecommendation` and `ShopRecommendation` models exist
but there is no "deferred services" view that follows the vehicle across
visits.
**Plan:**
- Extend recommendations with `status` (open/accepted/declined/expired) and
`next_followup_at`.
- Surface on vehicle detail, estimate creation ("pull deferred items"), and
in service-reminder logic (§3.2).
---
## 4. P1 — Significant value-add
### 4.1 Technician time tracking on job cards (clock in/out per labor line)
**Status:** Generic `TimeSheet` exists but is not bound to job-card lines.
The sidebar already shows "Time Clocks" — confirm whether implemented or
stubbed.
**Plan:**
- New table `job_card_labor_time` (job_card_service_id, employee_id,
started_at, stopped_at).
- Mobile-friendly start/stop UI on the job-card page (tap-and-go).
- Feed billed-vs-clocked into the productivity report (§3.1).
### 4.2 Estimate templates / service packages with menus
**Status:** Service Groups exist (`ServiceGroup`, `ServiceGroupService`,
`ServiceGroupPart`, `ServiceGroupPricing`). Gap: no canned "menus" UI
(e.g., "30k-mile service" preset that drops services + parts + labor onto
an estimate in one click).
**Plan:**
- Repurpose Service Groups as menus; add a "Add menu" picker on the
estimate editor that expands the group into individual lines (so they
can still be edited).
### 4.3 Customer digital signature on documents
**Status:** Documents print to PDF; no signature capture.
**Plan:**
- Add a signature pad component (svg path → PNG) on the customer portal
approval screen (§3.4) and in-shop tablet flow.
- Persist as a `document_signatures` row (signer name, type, timestamp,
IP/UA, image blob in storage).
- Embed signature in printed PDF.
### 4.4 Parts barcode / QR scanning
**Status:** Parts have SKUs but no barcode field or scan UI.
**Plan:**
- Add `barcode` column to `parts`.
- Use the device camera (BarcodeDetector API) on the inventory adjustment
and job-card "Add part" dialogs.
### 4.5 Tablet / shop-floor UI
**Status:** UI is desktop-first.
**Plan:**
- Audit job-card detail, inspection editor, and time-clock screens for
touch targets and one-hand portrait use.
- Optional dedicated route `/floor/[job-card]` with a stripped-down layout.
### 4.6 Photos & videos on inspections (with public share)
**Status:** `InspectionCheckPointAttachment` and `InspectionAttachment`
models exist — confirm whether the share link renders attachments.
**Plan:**
- Verify the public inspection view (`PublicInspectionController@show`)
exposes media URLs with signed access.
- Add front-camera capture in the inspection editor for fast photo-taking.
### 4.7 Multi-location / branch support
**Status:** `Settings` is single-tenant; no `branch_id` scoping on
business records.
**Plan:** This is a bigger lift. Phased:
1. Add `branches` table and `branch_id` FK on the major aggregates
(job_cards, invoices, vehicles, parts, employees).
2. Default to a single seeded branch for existing deployments.
3. Add branch selector in header and scope queries via global scope.
### 4.8 Custom fields
**Status:** No custom-field infrastructure.
**Plan:**
- New `custom_field_definitions` (entity_type, key, label, type, options)
and `custom_field_values` (polymorphic).
- Render dynamically on customer/vehicle/job-card forms.
### 4.9 Marketing campaigns
**Status:** Sidebar has the group commented out.
**Plan:**
- Reuse §3.3 channels.
- New `marketing_campaigns` (name, audience filter, template_id, schedule)
and `marketing_sends` (per-recipient log).
- Audience builder uses the customer/vehicle filters already in the table
views.
### 4.10 Ratings & reviews capture
**Plan:**
- Post-pickup auto-message asking for a rating (15) and free text.
- If 45, deep-link to Google review URL (configurable in settings).
- Internal review wall + ability to publish to a public testimonials
endpoint.
### 4.11 Accounting export (QuickBooks / Xero / Zoho)
**Status:** No GL / chart of accounts (sidebar group is disabled).
**Plan (lightweight first):**
- CSV export of invoices, payments, bills, payments-made tagged with
configurable account codes.
- Later: OAuth integration with QuickBooks Online / Xero APIs.
### 4.12 Tire & wheel module (heavy in GarageBox, Tekmetric)
**Plan:** Tire size lookup, DOT tracking, storage racks (off-season tire
storage is a common revenue line in cold-climate markets). Skip unless
target market needs it.
### 4.13 Warranty tracking on parts and services
**Plan:**
- Add `warranty_months` / `warranty_km` on parts and services.
- Show "under warranty" badge when re-invoicing the same VIN within the
window. Block billing or flag for review.
### 4.14 Fleet customer accounts
**Plan:**
- Allow a customer to own many vehicles already supported, but add a
"fleet" customer type with: bulk PO upload, monthly statement instead of
per-invoice billing, optional driver/vehicle list.
### 4.15 Online appointment booking widget
**Plan:**
- Public route `/book` that surfaces shop services + free calendar slots
(derived from `ShopTiming` and existing appointments).
- Creates a draft `Appointment` + customer/vehicle if new.
- Embed snippet for the shop's marketing site.
---
## 5. P2 — Differentiators
### 5.1 VIN decoder integration (NHTSA free API + paid Carfax)
- NHTSA `vpic.nhtsa.dot.gov/api/` is free, no auth — wire it into the
vehicle create form to auto-fill make/model/year/engine from VIN.
- Carfax/AutoCheck for paid history reports (US/CA).
### 5.2 OEM repair info integration (Mitchell 1, AllData, Identifix, Haynes)
- Big lift, paid feeds. Out of scope for v1; design the parts/services
data model so labor times can be imported.
### 5.3 Parts catalog integrations (WorldPac, NAPA PROLink, PartsTech)
- Real-time pricing + availability + ordering from inside a job card.
- Phase 1: PartsTech (aggregator) since it covers the most vendors.
### 5.4 Two-way calendar sync (Google / Microsoft)
- iCal feed for read-only first.
- Full two-way sync via Google Calendar API later.
### 5.5 AI-assisted estimate writer
- "Customer says: knocking noise on left front when braking" → suggested
service lines + parts list from history of similar jobs. Useful and
novel; defer until enough invoice data exists.
### 5.6 Mobile apps (technician + customer)
- React Native (or PWA first) sharing `@garage/api`.
- Technician: today's jobs, start/stop labor, take photos, mark complete.
- Customer: portal features (§3.4) packaged as an app.
### 5.7 OBD-II / telematics integration
- Long tail. Plan only the data model now (`vehicle_telemetry_events`).
### 5.8 Loyalty / gift cards / membership plans
- "Service plan: 2 oil changes/year + 10% off for $X/mo".
- Recurring billing on top of §3.5.
### 5.9 Discounts and promo codes at line-item or document level
- Currently no promo entity. Add `discounts` (code, type, %/amount, scope,
expiry, usage cap) with hook in estimate/invoice totals.
### 5.10 Multi-currency
- `Settings` is implicitly single-currency. Add currency on documents and a
daily rates table if shops need cross-border invoicing.
---
## 6. P3 — Long-tail / future
- Insurance claim workflow (estimator → adjuster handoff, photos packet,
EMS export).
- Inventory replenishment automation (min/max → auto PO draft).
- Bay / lift management (assign job card to a bay; show bay utilization).
- AI photo damage assessment for inspections.
- Recall lookup (NHTSA recall API by VIN).
- TPMS / tire pressure history per vehicle.
- Public price-list / "instant quote" widget.
- Webhook system for third-party integrators.
- Bring-your-own-domain customer portal branding.
- Backup export of entire tenant data (GDPR-friendly).
---
## 7. Cross-cutting platform work
These aren't a single feature, but unlock many of the above:
1. **Background job runner** — Laravel Horizon + Redis if not already set
up; required for §3.2, §3.3, §4.9.
2. **Storage policy** — confirm S3-compatible storage so signatures,
media, and PDFs scale.
3. **Feature flag system** — many of the above are tenant-gated; even a
simple `tenant_features` table beats hard-coded toggles.
4. **OpenAPI coverage** — every new backend endpoint must add an
annotation so `packages/api` stays in sync (already a project
convention).
5. **Permission seeds** — every new route requires a matching permission
(project rule: routes are gated by `permission.check`).
6. **Lang pairs** — every `lang/en/*.php` key must have a matching
`lang/ar/*.php` key (project rule).
7. **PDF template** — every new printable type needs an entry in
`DocumentPrintController::TYPES` plus a Blade template under
`resources/views/pdf/`.
---
## 8. Suggested phased roadmap
A pragmatic build order that maximizes early customer value and reuses
infrastructure:
**Phase 1 — "Looks competitive"** (P0 core)
- Reporting dashboard (§3.1)
- Workflow board for job cards (§3.7)
- Audit log (§3.6)
- Vehicle timeline (§3.8)
- Recommended / deferred work tracking (§3.9)
**Phase 2 — "Talks to the customer"**
- Notifications channel (§3.3) — SMS + email first
- Customer portal w/ estimate approval (§3.4)
- Online payments (§3.5)
- Service reminders (§3.2)
- Ratings & reviews capture (§4.10)
**Phase 3 — "Runs the shop floor"**
- Technician time tracking on labor (§4.1)
- Estimate menus / service packages UI (§4.2)
- Photos on inspections, end-to-end (§4.6)
- Customer digital signature (§4.3)
- Tablet / shop-floor UI polish (§4.5)
**Phase 4 — "Scales the business"**
- Multi-location (§4.7)
- Custom fields (§4.8)
- Marketing campaigns (§4.9)
- Online booking widget (§4.15)
- Accounting exports (§4.11)
**Phase 5 — "Differentiators"**
- VIN decoder (§5.1)
- Parts catalog integrations (§5.3)
- Calendar sync (§5.4)
- Loyalty / memberships (§5.8)
- Mobile apps (§5.6)
---
## 9. Open questions to confirm before starting
1. Target market geography — drives gateway choice (Stripe vs. local), SMS
provider, accounting integration (QuickBooks vs. Zoho vs. Tally), and
whether tire/snow modules matter.
2. Is multi-tenancy single-DB (current) or DB-per-tenant?
3. Are Horizon + Redis available in production today?
4. Is there an approved chart library, or does adding one need a green
light? (CLAUDE.md says no new deps without approval.)
5. Should the customer portal share auth with the dashboard (Sanctum) or
use a token-only public route like inspections do today?

Binary file not shown.

160
README.md
View File

@ -1,21 +1,159 @@
# Next.js template
# Turborepo starter
This is a Next.js template with shadcn/ui.
This Turborepo starter is maintained by the Turborepo core team.
## Adding components
## Using this example
To add components to your app, run the following command:
Run the following command:
```bash
npx shadcn@latest add button
```sh
npx create-turbo@latest
```
This will place the ui components in the `components` directory.
## What's inside?
## Using components
This Turborepo includes the following packages/apps:
To use the components in your app, import them as follows:
### Apps and Packages
```tsx
import { Button } from "@/components/ui/button";
- `docs`: a [Next.js](https://nextjs.org/) app
- `web`: another [Next.js](https://nextjs.org/) app
- `@repo/ui`: a stub React component library shared by both `web` and `docs` applications
- `@repo/eslint-config`: `eslint` configurations (includes `eslint-config-next` and `eslint-config-prettier`)
- `@repo/typescript-config`: `tsconfig.json`s used throughout the monorepo
Each package/app is 100% [TypeScript](https://www.typescriptlang.org/).
### Utilities
This Turborepo has some additional tools already setup for you:
- [TypeScript](https://www.typescriptlang.org/) for static type checking
- [ESLint](https://eslint.org/) for code linting
- [Prettier](https://prettier.io) for code formatting
### Build
To build all apps and packages, run the following command:
With [global `turbo`](https://turborepo.dev/docs/getting-started/installation#global-installation) installed (recommended):
```sh
cd my-turborepo
turbo build
```
Without global `turbo`, use your package manager:
```sh
cd my-turborepo
npx turbo build
yarn dlx turbo build
pnpm exec turbo build
```
You can build a specific package by using a [filter](https://turborepo.dev/docs/crafting-your-repository/running-tasks#using-filters):
With [global `turbo`](https://turborepo.dev/docs/getting-started/installation#global-installation) installed:
```sh
turbo build --filter=docs
```
Without global `turbo`:
```sh
npx turbo build --filter=docs
yarn exec turbo build --filter=docs
pnpm exec turbo build --filter=docs
```
### Develop
To develop all apps and packages, run the following command:
With [global `turbo`](https://turborepo.dev/docs/getting-started/installation#global-installation) installed (recommended):
```sh
cd my-turborepo
turbo dev
```
Without global `turbo`, use your package manager:
```sh
cd my-turborepo
npx turbo dev
yarn exec turbo dev
pnpm exec turbo dev
```
You can develop a specific package by using a [filter](https://turborepo.dev/docs/crafting-your-repository/running-tasks#using-filters):
With [global `turbo`](https://turborepo.dev/docs/getting-started/installation#global-installation) installed:
```sh
turbo dev --filter=web
```
Without global `turbo`:
```sh
npx turbo dev --filter=web
yarn exec turbo dev --filter=web
pnpm exec turbo dev --filter=web
```
### Remote Caching
> [!TIP]
> Vercel Remote Cache is free for all plans. Get started today at [vercel.com](https://vercel.com/signup?utm_source=remote-cache-sdk&utm_campaign=free_remote_cache).
Turborepo can use a technique known as [Remote Caching](https://turborepo.dev/docs/core-concepts/remote-caching) to share cache artifacts across machines, enabling you to share build caches with your team and CI/CD pipelines.
By default, Turborepo will cache locally. To enable Remote Caching you will need an account with Vercel. If you don't have an account you can [create one](https://vercel.com/signup?utm_source=turborepo-examples), then enter the following commands:
With [global `turbo`](https://turborepo.dev/docs/getting-started/installation#global-installation) installed (recommended):
```sh
cd my-turborepo
turbo login
```
Without global `turbo`, use your package manager:
```sh
cd my-turborepo
npx turbo login
yarn exec turbo login
pnpm exec turbo login
```
This will authenticate the Turborepo CLI with your [Vercel account](https://vercel.com/docs/concepts/personal-accounts/overview).
Next, you can link your Turborepo to your Remote Cache by running the following command from the root of your Turborepo:
With [global `turbo`](https://turborepo.dev/docs/getting-started/installation#global-installation) installed:
```sh
turbo link
```
Without global `turbo`:
```sh
npx turbo link
yarn exec turbo link
pnpm exec turbo link
```
## Useful Links
Learn more about the power of Turborepo:
- [Tasks](https://turborepo.dev/docs/crafting-your-repository/running-tasks)
- [Caching](https://turborepo.dev/docs/crafting-your-repository/caching)
- [Remote Caching](https://turborepo.dev/docs/core-concepts/remote-caching)
- [Filtering](https://turborepo.dev/docs/crafting-your-repository/running-tasks#using-filters)
- [Configuration Options](https://turborepo.dev/docs/reference/configuration)
- [CLI Usage](https://turborepo.dev/docs/reference/command-line-reference)

551
Reparee Full Version .md Normal file
View File

@ -0,0 +1,551 @@
# Reparee → Garage Box Feature Parity — Implementation Roadmap
> **Purpose of this doc.** Single source of truth for closing the gap between Reparee's current dashboard and Garage Box. Captures the gap analysis, the canonical Reparee patterns to mimic, and a per-phase implementation spec detailed enough for a fresh agent to pick up any phase and ship it without re-discovering conventions.
> **Status (2026-05-21).** Phase 1 (Integrations placeholder) is the only phase ready to execute. Phases 28 are spec'd here but not started. Pick one, re-open it in a focused planning session, and implement.
---
## 0. Reading order
1. **Part A — Gap analysis** (what's missing and why).
2. **Part B — Project conventions cheatsheet** (the patterns every phase MUST follow).
3. **Part C — Phase index** (the at-a-glance table).
4. **Part D — Per-phase implementation specs** (Phases 18, in recommended build order).
---
## Part A — Gap analysis (Garage Box vs Reparee)
Legend: ✅ exists ⚠️ partial / different location ❌ missing
### Already covered
- **Sales** (Customers, Vehicles, Inspections, Estimates, Job Cards, Invoices, Payments Received, Credit Notes) — ✅
- **Purchases** (Vendors, Expenses, POs, Bills, Payments Made, Vendor Credits) — ✅
- **Employees / Productivity** (Employees, Time Clocks, Time Sheets, Payroll, Shop Calendars, Shop Timing, Holidays, Tasks, Leave Requests) — ✅ (Reparee adds Leave Requests beyond Garage Box)
- **Items** (Services, Parts, Expense Item, Service Group, Inventory Adjustments) — ✅
- **Settings** Company / Shop Types / Tax & Rates / Configurations — ✅
### Gaps
| Garage Box | Status in Reparee | Phase |
|---|---|---|
| Settings → Integrations | ❌ → placeholder | **Phase 1** |
| Settings → Master (hub for body types + make & model + other lookups) | ❌ as a hub (data scattered) | **Phase 2** |
| Settings → Templates (notification template library, ~35 templates × 4 channels) | ⚠️ only inspection templates | **Phase 3** |
| Reports (top-level module) | ❌ | **Phase 4** |
| CRM (Leads pipeline, Calls log + calendar, Tasks pipeline) | ❌ (Reparee has only productivity tasks, not CRM tasks) | **Phase 5** |
| Marketing (Service Reminders, Rating & Reviews, Google Business Reviews) | ❌ | **Phase 6** |
| Settings → Integrations (real connectors: Google, Microsoft, Zoho, Interakt, Respond, Vonage, Geidea, MSG91, Wati, Xero, WhatsApp) | ❌ | **Phase 7** |
| Accountants (Chart of Accounts + Manual Journals + default account wiring) | ❌ | **Phase 8** |
### Cross-cutting Garage Box conventions to mimic
- **Pipelines** (Leads, Tasks) have user-editable column **sections** (add / rename / reorder). Default sections seeded on first run.
- **`+ Label`** chip on every record (taxonomy / colored tags).
- **Configurable doc-number sequences** with a gear icon next to the `#` field (`LD-000001`, `CL-000001`, `TK-000001`, `JN-000001`, etc.).
- **Notification templates** are channel-aware (SMS / WhatsApp / Email / Push) with per-channel toggles.
- **Per-row Department filter** is universal — every list view has a Department dropdown in the toolbar.
---
## Part B — Project conventions cheatsheet (MUST follow in every phase)
> Every phase below assumes these patterns. Don't re-invent.
### B1. Frontend module structure
- **Modules live at** `garage-erp/apps/dashboard/modules/<area>/`.
- Each resource gets: `<resource>-form.tsx`, `<resource>.schema.ts` (Zod), optional `<resource>-actions.tsx`, optional inline section components (e.g. `<resource>-services-section.tsx`).
- **Reference**: `garage-erp/apps/dashboard/modules/estimates/` is the canonical example. Mirror its file layout for any new resource.
### B2. Routes (Next.js App Router)
- List page: `app/(authenticated)/<area>/<resource>/page.tsx` (`"use client"` + `ResourcePage<Client>`).
- Detail page: `app/(authenticated)/<area>/<resource>/[id]/page.tsx` (async server component, uses `getServerApi()` + `DashboardPage`).
- Sub-tabs of a detail (e.g. notes, documents): `[id]/<subroute>/page.tsx`.
- **Reference**: `app/(authenticated)/sales/estimates/page.tsx` (list) + `[id]/page.tsx` (detail).
### B3. Tables & row actions
- Use TanStack Table via `ResourcePage` wrapper.
- Row actions via `actionsColumn({ extraItems: (row) => [...] })`.
- Print buttons via `useDocumentPrint(type, id)` from `apps/dashboard/shared/hooks/use-document-print.ts`.
### B4. Forms
- React Hook Form 7 + Zod 4. Schema lives in `<resource>.schema.ts`.
- Use `useResourceForm<FormValues, ApiPayload>({ schema, resourceId, initialize, mapToFormValues })` from `apps/dashboard/shared/hooks/`.
- Mutations: `useFormMutation` for create/update; invalidate via `tableQuery.invalidateQuery()` or the resource's query key.
### B5. Sidebar navigation
- Single source: `garage-erp/apps/dashboard/config/navGroups.tsx` — hard-coded, **no i18n layer**.
- Add a new group entry or a sub-item; pick a `lucide-react` icon already imported (or add to the import block at top of file).
### B6. API client (`@garage/api`)
- All clients extend `CrudClient<INDEX_ROUTE, BY_ID_ROUTE>` (path: `packages/api/src/clients/<name>.ts`).
- After backend changes, **regenerate**: `pnpm --filter @garage/api generate` (runs `generate:openapi && generate:types`).
- The generated files in `packages/api/` are NOT hand-edited; only the thin client wrappers are.
### B7. Backend routes
- **Legacy string controller resolution** in `reparee_backend/routes/api.php`:
```php
Route::get('/leads', 'LeadController@index');
Route::post('/leads', 'LeadController@store');
// etc.
```
- **Every route is gated** by `permission.check` middleware (auto-applied group-wide).
### B8. Backend controllers
- Standard CRUD shape: `index`, `show`, `store`, `update`, `destroy` returning `JsonResponse`.
- Path: `reparee_backend/app/Http/Controllers/Api/<Name>Controller.php`.
- Use `vyuldashev/laravel-openapi` annotations so OpenAPI spec stays in sync (mirror `EstimateController`).
### B9. Models & migrations
- Models in `reparee_backend/app/Models/`. Use `$fillable`, `$casts`, relations.
- Migrations: `php artisan make:migration create_<table>_table` → standard `Schema::create` with `$table->id()` + `timestamps()`.
### B10. Permissions
- Permissions are **columns on the `roles` table**: `can_{view|create|update|delete}_{resource}`.
- To add a new resource:
1. Create a migration that adds the four boolean columns to `roles` (default `false`).
2. Update `database/seeders/RoleSeeder.php` so Super Admin gets `true` for all new columns (it auto-discovers `can_*` columns — verify).
3. The `permission.check` middleware reads the column name from the route name; ensure route is named `<resource>.<ability>` or matches its existing convention.
- **Reference migration**: `2026_04_06_120000_create_roles_table_and_refactor_employee_permissions.php`.
### B11. i18n (backend)
- Pairs required: every new key in `lang/en/<file>.php` MUST have the matching `lang/ar/<file>.php` key in the same change.
- Use `__('api.<area>.<key>')` in controllers and responses.
- Dashboard nav labels are NOT translated (hard-coded in `navGroups.tsx`).
### B12. Document-print (PDF)
- Single entry: `POST /api/document-print``DocumentPrintController@handle`.
- To add a printable type:
1. Add string to `DocumentPrintController::TYPES`.
2. Add `payload<Type>()` method on the controller.
3. Add `resources/views/pdf/document-print-<type>.blade.php`.
4. Add the type to the `DocumentPrintType` union in `packages/api/src/clients/document-print.ts`.
### B13. Pipeline-with-sections precedent
- `TaskSection` model + `task-section-form.tsx` already exist. Fields: `title`, `arrangement` (sort order), `is_default`.
- Reuse this pattern for `LeadSection`, `CallSection` (if needed), `JournalSection`, etc.
### B14. House rules (from `CLAUDE.md`)
- Never `git commit / push / tag / reset --hard / rebase` — stop at file edits.
- Don't add new dependencies without explicit approval.
- After edits, run `pnpm --filter dashboard typecheck`, `pnpm --filter dashboard lint`, and `php -l` on changed PHP files, then report results.
- Match existing conventions before introducing new patterns.
---
## Part C — Phase index
| # | Phase | Effort | Backend changes | Frontend changes | Depends on |
|---|---|---|---|---|---|
| 1 | Integrations placeholder | XS (1 hr) | none | 2 files | — |
| 2 | Settings → Master hub | S (1 day) | none (regroup) | 1 layout + tabs | — |
| 3 | Notification Templates library | M (3 days) | new table + CRUD + permission | full module | — |
| 4 | Reports (4 reports locked) | M (1 wk) | 4 aggregation endpoints | reports module + 4 reports w/ Recharts | reads existing data |
| 5 | CRM (Leads, Calls, Tasks-CRM) | XL (23 wks) | 3 resources + sections + permissions + i18n | 3 modules + pipelines | reuses TaskSection pattern |
| 6 | Marketing (Reminders email+SMS, Reviews) | L (12 wks) | reminder engine + Twilio integration + reviews | 3 sub-pages | needs Phase 3 (templates); Twilio composer dep |
| 7 | ~~Integrations (real)~~ | DEFERRED | — | — | — |
| 8A | Accountants — COA + Manual Journals | L (1.5 wks) | ledger UI only, no auto-post | 3 sub-pages | — |
| 8B | Accountants — auto-posting | L (12 wks) | observers on Invoice/Bill/Payment/Expense + reversal | linkage panels on source docs | requires 8A stable |
---
## Part D — Per-phase implementation specs
# Phase 1 — Settings → Integrations (Coming Soon)
**Goal.** Add an Integrations entry under Settings that renders a "Coming soon" placeholder.
### Backend
None.
### Frontend
1. **Edit** `garage-erp/apps/dashboard/config/navGroups.tsx` (around line 183):
- There is already a commented-out entry:
```tsx
// { title: "Integrations", href: "/settings/integrations/providers", icon: <PlugZapIcon /> }
```
- Uncomment it but change `href` to `/settings/integrations` (single page, no sub-tabs yet).
- Insert between `Inspection Templates` and `Configurations`. Confirm `PlugZapIcon` is in the top-of-file imports; if not, add `PlugZapIcon` to the `lucide-react` import.
2. **Create** `garage-erp/apps/dashboard/app/(authenticated)/settings/integrations/page.tsx`:
- Use `DashboardPage` with `headerProps={{ title: "Integrations" }}` (match `settings/insurance-types/page.tsx`).
- Centered `Empty` block (from `@/shared/components/ui/empty`):
- `EmptyMedia`: `<PlugZapIcon className="size-12 text-muted-foreground" />`
- `EmptyTitle`: "Coming soon"
- `EmptyDescription`: "Connect WhatsApp, payments, accounting and email providers. Available in an upcoming release."
### Verification
1. `pnpm --filter dashboard dev`.
2. Sidebar → Settings expands → new "Integrations" entry visible between Inspection Templates and Configurations.
3. Click → page renders with the Empty state.
4. `pnpm --filter dashboard typecheck && pnpm --filter dashboard lint`.
### Out of scope
Sub-tabs (Providers / Integrations), any provider logic, backend.
---
# Phase 2 — Settings → Master hub
**Goal.** Group existing lookup data screens under one "Master" sidebar entry with tabs, matching Garage Box's UX.
### Tabs (in order)
1. Body Types (existing `/settings/...`)
2. Make & Model (existing `/settings/make-and-models`)
3. Insurance Types (existing `/settings/insurance-types`)
4. Departments (existing `/settings/departments`)
5. Shop Types (move from top-level OR alias)
6. Vehicle Transmissions, Fuel Types, Colors, Unit Types, Payment Terms, Payment Modes, Labor Rates, Reasons, Referral Sources, Document Types, Labels — surface those that currently lack a dedicated screen.
### Backend
None new. Verify every tab's data source has a controller in `app/Http/Controllers/Api/` (most already do: `MakeAndModelController`, `InsuranceTypeController`, `DepartmentController`, `VehicleBodyTypeController`, `VehicleTransmissionController`, `VehicleFuelTypeController`, `VehicleColorController`, `UnitTypeController`, `PaymentTermController`, `PaymentModeController`, `LaborRateController`, `ReasonController`, `ReferralSourceController`, `DocumentTypeController`, `LabelController`).
### Frontend
1. **Create** `app/(authenticated)/settings/master/layout.tsx` with a `Tabs` header (use existing `Tabs` from `packages/ui`) and the 11+ tab labels above.
2. **Create** `app/(authenticated)/settings/master/page.tsx` that redirects to `./body-types`.
3. **Create** `app/(authenticated)/settings/master/<each-tab>/page.tsx`. Each is a thin wrapper that imports and renders the existing module list component from `modules/settings/<existing-folder>/`.
4. **Edit** `config/navGroups.tsx`: add `{ title: "Master", href: "/settings/master", icon: <DatabaseIcon /> }` after Integrations. **Keep** the existing top-level entries (Insurance Types, Departments, Make & Models) in the Settings list — they coexist as aliases for backwards-compat (per Part F decision).
### Verification
1. Sidebar → Settings → Master → tabs render and each tab CRUDs correctly.
2. Direct URL `/settings/master/insurance-types` works.
3. Removed/aliased entries still resolve to the same screen.
---
# Phase 3 — Notification Templates library
**Goal.** Manage ~35 notification templates (per Garage Box screenshot 16) with per-channel toggles and bodies. Required for Phase 6 (Marketing) and any reminder/automation.
### Data model
| Table | Columns |
|---|---|
| `notification_templates` | `id`, `key` (unique e.g. `appointment_created`), `title`, `description` (nullable), `created_at`, `updated_at` |
| `notification_template_channels` | `id`, `notification_template_id` (FK), `channel` enum(`sms`,`whatsapp`,`email`,`push`), `enabled` bool, `subject` (nullable, for email), `body` text, `created_at`, `updated_at` |
Seed all template `key`s from this list (extracted from Garage Box image 16):
- `appointment_assigned`, `appointment_cancelled`, `appointment_confirmed`, `appointment_create`, `appointment_no_shows`, `appointment_reminder`, `appointment_requested`, `appointment_reschedule`, `appointment_unconfirmed`
- `attendance_details`, `attendance_reminder`
- `call_assigned`, `call_cancelled`, `call_completed`, `call_rescheduled`, `call_scheduled`
- `estimate_approved_by_admin`, `estimate_approved_by_customer`, `estimate_send`
- `job_card_customer`, `job_card_technician`
- `login_otp`
- `new_lead_notification`
- `part_requisition_create`
- `purchase_order_create`
- `ratings_reviews_link`
- `salary_change_notification`
- `send_customer_statement`, `send_inspection`, `send_invoice`, `send_vendor_statement`
- `service_reminder_cancelled`, `service_reminder_reschedule`, `service_reminder_scheduled`, `service_reminder_send`
- `work_request_form_send`, `workorder_create`
### Backend
- `NotificationTemplateController` (`index`, `show`, `update` only — templates are seeded, not user-created).
- Routes (string-resolution style):
```php
Route::get('/notification-templates', 'NotificationTemplateController@index');
Route::get('/notification-templates/{key}', 'NotificationTemplateController@show');
Route::put('/notification-templates/{key}', 'NotificationTemplateController@update');
```
- Permissions: `can_view_notification_templates`, `can_update_notification_templates` (skip create/delete — seed-managed).
- Lang keys in `lang/en/api.php` + `lang/ar/api.php` under `notification_templates`.
### Frontend
1. **Module** `apps/dashboard/modules/notification-templates/` with `notification-template-row.tsx`, `notification-template-edit-dialog.tsx`, `notification-template.schema.ts`.
2. **Route** `app/(authenticated)/settings/templates/notifications/page.tsx` — TanStack Table with columns: Title, SMS (✓/—), WhatsApp, Email, Push, Actions.
3. **Sidebar entry**: `config/navGroups.tsx` add `{ title: "Templates", href: "/settings/templates/notifications", icon: <FileTextIcon /> }` between Inspection Templates and Integrations. (Or restructure existing `/settings/templates` if free.)
4. **Edit dialog**: tabs per channel, body editor with **variable picker** (e.g. `{{customer.name}}`, `{{vehicle.plate}}`, `{{appointment.date}}`). Variables list lives in a `notification-template-variables.ts` constants file co-located with the module.
5. **API client**: `packages/api/src/clients/notification-templates.ts` extends `CrudClient`.
### Verification
1. Seeded list shows all 35+ rows.
2. Toggle a channel → persists.
3. Edit body → re-render shows updated body.
4. Backend `__('api.notification_templates.updated')` returns Arabic when locale is `ar`.
---
# Phase 4 — Reports
**Goal.** Top-level Reports module with categorized reports across Sales, Purchases, Inventory, Employees, Accountants.
### Reports to ship (v1 — locked per Part F)
1. **Sales by Customer** — revenue grouped by customer with date range + department filter.
2. **Invoices Aging** — outstanding invoices bucketed 030 / 3160 / 6190 / 90+ days.
3. **Technician Productivity** — hours logged vs billable per technician, derived from `TimeSheet` + job-card service rows.
4. **Inventory Valuation + Low-Stock** — current stock value by part + list of parts under reorder threshold.
All other reports candidates (Sales by Service/Vehicle, Payments Summary, Estimates Conversion, Purchases by Vendor, Bills Aging, Stock Movement, Attendance, Payroll, Job Card Turnaround/Status) are deferred to a follow-up phase. Architect the reports module so adding the 5th report is trivial.
### Charting
Use **Recharts** via the already-installed wrapper `garage-erp/apps/dashboard/shared/components/ui/chart.tsx`. No new dependency. Wrapper exports `ChartContainer`, `ChartTooltip`, `ChartTooltipContent`, etc. — mimic shadcn's chart usage docs.
### Backend
- `app/Http/Controllers/Api/Reports/` directory — one controller per report category (`SalesReportController`, `PurchasesReportController`, etc.).
- Each report = single GET endpoint accepting `from`, `to`, optional `department_id`, `customer_id`, etc. Returns aggregated rows.
- Use Eloquent + `selectRaw` aggregations. Index any frequently-grouped columns via migration.
- Routes grouped under `/api/reports/...`. New permission per category: `can_view_reports_sales`, `can_view_reports_purchases`, ...
- **No new models or tables** — pure read aggregation over existing data.
- Lang keys for report titles in `lang/en/reports.php` + `lang/ar/reports.php`.
### Frontend
1. **Module** `apps/dashboard/modules/reports/` with:
- `report-card.tsx` (preview tile in the index).
- `report-filters.tsx` (date range, department, customer/vendor).
- One subdirectory per category: `sales/`, `purchases/`, `inventory/`, `employees/`, `job-cards/`. Each contains a `<report-name>-report.tsx` data view.
2. **Routes**:
- `app/(authenticated)/reports/page.tsx` — index grid of report cards grouped by category.
- `app/(authenticated)/reports/<category>/<slug>/page.tsx` — individual report view with filters + table + export buttons.
3. **Sidebar**: add a new top-level group `{ title: "Reports", href: "/reports", icon: <BarChart3Icon /> }` after Items/Employees in `navGroups.tsx`.
4. **Export**: reuse `useDocumentPrint` pattern for PDF; for Excel use existing `maatwebsite/excel` backend integration (add new export classes in `app/Exports/Reports/`).
5. **Charts**: prefer `recharts` if already in `packages/ui` dependencies; otherwise propose to user before adding.
### Verification
1. Each report renders with sample data filtered by date range.
2. PDF export uses the same layout shell as other prints (`resources/views/pdf/layouts/document.blade.php`).
3. Excel export downloads a valid `.xlsx`.
4. Permissions block access when role lacks `can_view_reports_<category>`.
---
# Phase 5 — CRM (Leads, Calls, Tasks-CRM)
**Goal.** Add the missing CRM module with three pipelines: Leads, Calls, Tasks (distinct from existing productivity tasks). Mirrors Garage Box screenshots 18.
### Sub-phase 5a — Leads
#### Data model
| Table | Key columns |
|---|---|
| `lead_sections` | `id`, `title`, `arrangement`, `is_default`, `kind` enum(`open`,`won`,`lost`) (kind drives default behavior). Reuse `TaskSection` pattern. |
| `leads` | `id`, `number` (unique, e.g. `LD-000001`), `title`, `date`, `lead_owner_id` (FK users), `referral_source_id` (FK), `lead_status_id` (FK), `department_id`, `description`, `lead_section_id` (FK), `customer_id` (nullable), `vehicle_id` (nullable), salutation, first/last/company name, phone, email, address fields, vehicle fields (make, model, plate, body_type_id). |
| `lead_services` | pivot to services |
| `lead_parts` | pivot to parts |
| `lead_labels` | pivot to `labels` table |
#### Backend
- `LeadController`, `LeadSectionController`, `LeadServiceController`, `LeadPartController` (mirror Estimate's split).
- Permissions: `can_view_leads`, `can_create_leads`, `can_update_leads`, `can_delete_leads`, plus `can_manage_lead_sections`.
- Number sequence: reuse `InvoiceSequenceController` pattern (add `LeadSequenceController`).
- Lang keys under `lang/en/api.php` `leads` section + `lang/ar/api.php`.
- OpenAPI annotations on each method.
#### Frontend
- **Module** `apps/dashboard/modules/leads/` with `lead-form.tsx`, `lead.schema.ts`, `lead-pipeline.tsx` (Kanban view), `lead-actions.tsx`, inline sections for services/parts.
- **Routes** `app/(authenticated)/crm/leads/page.tsx` (Kanban + table toggle), `[id]/page.tsx` (detail).
- **Sidebar** new group `{ title: "CRM", icon: <UsersIcon />, items: [Leads, Calls, Tasks] }` after Purchases in `navGroups.tsx`.
- **Pipeline UI**: Kanban with `@dnd-kit` (check if already in deps before adding). Each column = a `LeadSection`. "+ Section" button opens a section dialog (reuse `task-section-form.tsx` pattern).
- **API client** `packages/api/src/clients/leads.ts`.
### Sub-phase 5b — Calls
#### Data model
| Table | Key columns |
|---|---|
| `calls` | `id`, `number` (`CL-000001`), `call_for` (polymorphic morphs to Customer/Lead), `vehicle_id`, `status` enum(`requested`,`scheduled`,`completed`,`cancelled`), `type` enum(`inbound`,`outbound`), `subject`, `call_owner_id`, `call_date`, `from_time`, `to_time`, `department_id`, `purpose_id` (FK to lookup), `agenda` text, `outcome_id` (nullable FK), `description`, `duration_minutes`, `duration_seconds`, `voice_recording_path`, `send_notification` bool. |
| `call_reminders` | `id`, `call_id`, `unit` int, `unit_type` enum(`minute`,`hour`,`day`), `event` enum(`before`,`after`), `channel` enum(`sms`,`email`,`whatsapp`). |
| `call_purposes`, `call_outcomes` | lookups |
#### Backend
- `CallController` (CRUD + special endpoints: `complete`, `cancel`, `reschedule`).
- `CallReminderController` (nested).
- Reminder dispatcher: a Laravel scheduled job in `app/Console/Commands/SendDueCallReminders.php` runs every minute, sends pending reminders via the relevant notification template (Phase 3) through the provider configured in Phase 7. If Phase 7 not yet shipped, log-only.
- Permissions: standard four + `can_log_calls`, `can_complete_calls`.
#### Frontend
- **Module** `apps/dashboard/modules/calls/` with `call-form.tsx` (schedule), `call-log-form.tsx` (log), `call.schema.ts`, `call-calendar.tsx`.
- **Routes** `app/(authenticated)/crm/calls/page.tsx` (table + tabs Requested/Scheduled/Completed/Cancelled), `crm/calls/calendar/page.tsx` (month view), `[id]/page.tsx`.
### Sub-phase 5c — Tasks (CRM)
**Approach locked per Part F: Option A — extend existing `tasks` table.**
#### Migration
Add to the existing `tasks` table:
- `context` enum (`productivity`, `crm`), default `productivity`, NOT NULL.
- `customer_id` nullable FK to `customers`.
- `vehicle_id` nullable FK to `vehicles`.
- Index on `(context, status)` for the pipeline filter.
#### Backend
- Backfill existing rows to `context = 'productivity'`.
- All existing `TaskController` queries get a `->where('context', 'productivity')` filter so `/productivity/tasks` keeps showing only what it shows today.
- Add `CrmTaskController` (thin) that scopes to `context = 'crm'` and exposes the same CRUD + section endpoints. Reuse the `TaskSection` table for sections (add a `context` column there too, same enum).
- Permissions: introduce `can_view_crm_tasks`, `can_create_crm_tasks`, `can_update_crm_tasks`, `can_delete_crm_tasks` separate from the existing productivity-task permissions.
#### Frontend
- New module `apps/dashboard/modules/crm-tasks/` mirroring Leads pipeline pattern (Kanban with sections).
- Route `app/(authenticated)/crm/tasks/page.tsx` (Kanban + table toggle).
- Re-use schema fragments from `modules/tasks/` where possible — extract shared bits to a `modules/tasks/shared/` if duplication grows.
### Verification (Phase 5 overall)
1. Pipeline drag-and-drop persists section + arrangement.
2. New lead with vehicle info creates a corresponding draft Customer + Vehicle (or links existing) — confirm desired behavior.
3. Call scheduled with a 10-min reminder → cron job fires at expected time.
4. Number sequences advance correctly when a record is created.
---
# Phase 6 — Marketing (Service Reminders + Reviews)
**Goal.** Automate service reminders based on mileage / time, plus collect and surface customer reviews.
### Service Reminders
- New table `service_reminders`: `id`, `customer_id`, `vehicle_id`, `reminder_type` (`mileage` / `time`), `interval_value`, `interval_unit`, `last_triggered_at`, `next_due_at`, `status` enum(`scheduled`,`sent`,`cancelled`), `channels` JSON (subset of `['email','sms']`).
- Scheduled command `app/Console/Commands/SendDueServiceReminders.php` runs daily; sends via Phase 3 templates (`service_reminder_send`).
- **Channels (per Part F)**: **Email** via Laravel `Mail::to(...)->send(...)` using the configured mail sender; **SMS** via Twilio.
- Install `twilio/sdk` (Composer) — flag this dependency add for user approval per CLAUDE.md house rules.
- Add a `NotificationDispatcher` service in `app/Services/Notifications/` with `sendEmail()` and `sendSms()` methods. Reads Twilio credentials from `config/services.php` (`twilio.sid`, `twilio.token`, `twilio.from`) which read from `.env` (`TWILIO_SID`, `TWILIO_TOKEN`, `TWILIO_FROM`).
- WhatsApp/Push channel toggles render but are disabled with a "Requires integration" tooltip — wiring up to providers happens in deferred Phase 7.
- Frontend: `app/(authenticated)/marketing/service-reminders/page.tsx` — list with status tabs, "Create reminder" form with Email + SMS channel checkboxes.
### Rating & Reviews
- New table `customer_reviews`: `id`, `customer_id`, `job_card_id`, `rating` 15, `comment`, `submitted_at`, `source` enum(`internal`,`google`).
- Public-facing review form already partially served by `PublicInspectionController` pattern — extend with `PublicReviewController`.
- After job-card completion, fire `ratings_reviews_link` template (Phase 3).
- Frontend list at `app/(authenticated)/marketing/reviews/page.tsx`.
### Google Business Reviews
- Read-only sync via Google Business Profile API (requires Phase 7 Google integration).
- Background job polls every 6 h.
- Render in same Reviews list with `source = google` badge.
### Sidebar
- Add CRM-style group `{ title: "Marketing", icon: <MegaphoneIcon />, items: [Service Reminders, Reviews, Google Reviews] }` after CRM.
### Verification
1. Create a mileage-based reminder → next_due_at calculated.
2. Cron tick at due time → template send is recorded (or logged if Phase 7 incomplete).
3. Submit a public review → appears in admin list.
---
# Phase 7 — Integrations (real connectors) — DEFERRED
**Status (per Part F decision): NOT IN ROADMAP.** The Phase 1 "Coming soon" placeholder is the long-term state for now. Twilio (SMS) and Laravel mail (email) used by Phase 6 are configured via `.env` only — no user-facing integrations UI.
Keep this spec as reference for when integrations become a priority later.
**Goal (when revived).** Replace the Phase 1 placeholder with a real Providers/Integrations page connecting Google, Microsoft, Zoho, Interakt, Respond, Vonage, Geidea, MSG91, Wati, Xero, WhatsApp. Twilio also moves into the UI at that point.
### Data model
| Table | Key columns |
|---|---|
| `integration_providers` | seeded list: `id`, `key` (slug), `name`, `purpose`, `icon_path`, `auth_type` (`oauth2`/`api_key`/`webhook`), `is_active`. |
| `integration_connections` | per-tenant connection: `id`, `provider_id`, `credentials` (encrypted JSON), `status` (`connected`/`disconnected`/`error`), `last_synced_at`. |
### Backend
- `IntegrationProviderController` (list), `IntegrationConnectionController` (CRUD + `connect`, `disconnect`, `test`).
- One service class per provider in `app/Services/Integrations/<Provider>/`. Each implements a common contract `IntegrationDriver` with `connect()`, `disconnect()`, `sendMessage()`, `pullData()`.
- OAuth callback routes: `/integrations/{provider}/callback`.
- Permissions: `can_manage_integrations`.
- Lang keys for connection status messages.
### Frontend
- **Replace** the Phase 1 placeholder page with `app/(authenticated)/settings/integrations/page.tsx` showing a `Tabs` of "Providers" (catalog) + "Integrations" (connected list).
- **Provider catalog tile** with "Connect" button → opens OAuth popup or credentials dialog.
- Update `navGroups.tsx`: `href: "/settings/integrations/providers"` (the original commented-out path).
### Verification
1. Connect Google → OAuth popup → callback persists tokens → "Connected" badge appears.
2. Test SMS via Vonage → message delivered (in sandbox).
3. Disconnect → credentials zeroed.
### Out of scope for first cut
Real-time webhook handlers for each provider (do them incrementally, one provider per follow-up PR).
---
# Phase 8 — Accountants
**Goal.** Introduce a real general ledger. Split into two sub-phases per Part F: ship the ledger UI first (8A), wire auto-posting into existing modules afterward (8B).
## Phase 8A — Chart of Accounts + Manual Journals + Default Configuration
Ship the standalone ledger. No existing controllers are touched. Users can manually post journal entries; nothing auto-posts.
### Data model
| Table | Key columns |
|---|---|
| `chart_of_accounts` | `id`, `name`, `code` (nullable), `account_type` enum(`asset`,`liability`,`equity`,`income`,`expense`,`cogs`), `parent_id` (nullable, for sub-accounts), `is_system` bool (lock icon in UI), `note`. |
| `journals` | `id`, `number` (`JN-000001`), `date`, `reference`, `notes`, `total_amount` (must balance), `posted_by`, `posted_at`. |
| `journal_lines` | `id`, `journal_id`, `account_id`, `debit`, `credit`, `description`. Constraint: sum(debit) == sum(credit) per journal. |
| `default_account_settings` | single-row config table for: `sales_account_id`, `purchase_account_id`, `inventory_account_id`, `adjustment_account_id`, `customer_advance_account_id`, `deposit_to_account_id`, `vendor_advance_account_id`, `paid_through_account_id`, `purchase_discount_account_id`, `expense_account_id`. |
### Seed
Seed ~80 standard accounts matching Garage Box screenshot 9 (Accounts Payable, Accounts Receivable, Advance Tax, … VAT Payable). Mark system accounts (`is_system = true`).
### Backend (8A only)
- `ChartOfAccountController`, `JournalController`, `DefaultAccountSettingsController`.
- Permissions: `can_view_accountants`, `can_manage_chart_of_accounts`, `can_post_journals`.
- OpenAPI annotations.
- **No changes to existing controllers** in this sub-phase — Invoices/Bills/Payments/Expenses are untouched.
### Frontend
- **Sidebar group** `{ title: "Accountants", icon: <BookOpenIcon />, items: [Manual Journals, Chart Of Accounts] }` after Purchases.
- **Module** `apps/dashboard/modules/accountants/` with `chart-of-account-list.tsx`, `chart-of-account-form.tsx`, `journal-form.tsx` (multi-line debit/credit with running balance check), `default-account-settings-form.tsx`.
- **Routes**: `app/(authenticated)/accountants/chart-of-accounts/page.tsx`, `accountants/manual-journals/page.tsx`, `[id]/page.tsx`.
### Verification (8A)
1. Seed list matches Garage Box exactly (account names + types).
2. Create a manual journal with unbalanced lines → backend rejects.
3. Default Configuration dialog persists.
4. Invoices/Bills/Payments/Expenses still work unchanged (no regression).
## Phase 8B — Auto-posting + cross-doc linking
Wire the ledger into operational modules. **Only start after 8A is stable in production.**
### Backend
- New service `app/Services/Accounting/JournalPoster.php` with methods like `postInvoice(Invoice $i)`, `postBill(Bill $b)`, `postPaymentReceived(...)`, `postPaymentMade(...)`, `postExpense(...)`.
- Hook into existing controllers via Eloquent `created` / `updated` model observers (cleaner than editing every controller). Observers live in `app/Observers/`, registered in `EventServiceProvider`.
- Each posted journal gets a `source_type` + `source_id` morphable pair so it links back to the originating document.
- Reversal logic: when a source doc is deleted or voided, post the reverse journal (don't soft-delete the original).
- New permission: `can_post_automatic_journals` (default true for finance roles).
### Frontend
- On Invoice / Bill / Payment / Expense detail pages: add a "Journal Entry" panel showing the posted journal lines (read-only) with a deep link to `/accountants/manual-journals/<id>`.
- On Journal detail: show "Source Document" link back to the originating doc.
### Verification (8B)
1. Create an Invoice → Journal auto-posts (debit AR, credit Sales, credit Tax).
2. Edit Invoice amount → existing journal reversed + new one posted.
3. Delete Invoice → reversal posted; original journal preserved for audit.
4. Default Configuration changes are respected on next post.
5. Audit trail: every Journal has a non-null `source_type`/`source_id` when auto-posted.
---
## Part E — Cross-cutting follow-ups (every phase finishes with)
1. Run **`pnpm --filter dashboard typecheck && pnpm --filter dashboard lint`** — must pass.
2. Run **`php -l`** on every changed PHP file.
3. Regenerate API client if backend changed: **`pnpm --filter @garage/api generate`**.
4. Update lang pairs (`lang/en/*.php` + `lang/ar/*.php`).
5. Update permission migration + seeder for any new permission columns.
6. Use the `code-review-graph` MCP `detect_changes_tool` + `get_review_context_tool` before opening the PR to self-review.
7. Update `claude-timeline/YYYY-MM-DD.md` with a one-line note about the phase shipped (per global memory rule).
8. **Do not commit** — leave the diff for the user to review.
---
## Part F — Resolved decisions (2026-05-21)
All open questions answered by the user. These are now binding for the phases referenced.
- **Phase 2 (Master)****Keep top-level entries as aliases.** Insurance Types, Departments, Make & Models remain in the Settings sidebar AND appear under Master tabs. Backwards-compat for bookmarks. No redirects, no removals.
- **Phase 4 (Reports) — charts****Use Recharts (already installed)** via the existing wrapper at `garage-erp/apps/dashboard/shared/components/ui/chart.tsx`. No new dependency.
- **Phase 4 (Reports) — v1 scope** → ship exactly these four reports:
1. Sales by Customer
2. Invoices Aging (030 / 3160 / 6190 / 90+ days)
3. Technician Productivity (hours logged vs billable per technician)
4. Inventory Valuation + Low-Stock
All other reports listed in Phase 4 are deferred.
- **Phase 5c (CRM Tasks)****Extend the existing `tasks` table** (Option A). Add `context` enum (`productivity` | `crm`), nullable `customer_id`, nullable `vehicle_id`. All existing task queries get a `context = 'productivity'` filter.
- **Phase 6 (Marketing reminders) — channels** → **Email + SMS in v1; WhatsApp deferred.**
- Email sends via Laravel's configured mail sender (`config/mail.php`) — works out of the box, no Integrations UI required.
- SMS sends via **Twilio**. Configured server-side via `.env` (`TWILIO_SID`, `TWILIO_TOKEN`, `TWILIO_FROM`) — not surfaced in the Integrations page yet.
- WhatsApp channel toggles are visible but disabled with a "Requires integration" tooltip.
- **Phase 7 (Integrations real)****Stays "coming soon."** The Phase 1 placeholder is the long-term state for now. Twilio + Laravel mail are configured server-side only; no user-facing connector UI in this roadmap. Phase 7's detailed spec is preserved as a future reference but no work is planned.
- **Phase 8 (Accountants)** → **Split into two sub-phases:**
- **Phase 8A**: Chart of Accounts + Manual Journals + Default Configuration UI. No automatic posting from other modules.
- **Phase 8B**: Auto-post journals on Invoice / Bill / Payment / Expense create + link from each source doc to its posted journal entry. Big blast radius — ship only after 8A is stable.

View File

@ -1,90 +0,0 @@
"use client"
import { ResourcePage } from "@/shared/data-view/resource-page"
import { ColumnHeader } from "@/shared/data-view/table-view"
import { PartForm } from "@/modules/parts/part-form"
import { Badge } from "@/shared/components/ui/badge"
import { PARTS_ROUTES } from "@repo/api"
import type { PartsClient } from "@repo/api"
export default function PartsPage() {
return (
<ResourcePage<PartsClient>
pageTitle="Parts"
title="Part"
routeKey={PARTS_ROUTES.INDEX}
getClient={(api) => api.parts}
columns={({ actionsColumn }) => [
{
accessorKey: "title",
header: ({ column }) => <ColumnHeader column={column} title="Title" />,
cell: ({ row }) => {
const r = row.original as any
return (
<div>
<span className="font-medium">{r.title || "—"}</span>
{r.sku && (
<span className="ml-2 text-xs text-muted-foreground">{r.sku}</span>
)}
</div>
)
},
},
{
accessorKey: "part_number",
header: ({ column }) => <ColumnHeader column={column} title="Part #" />,
cell: ({ row }) => (row.original as any).part_number || "—",
},
{
accessorKey: "manufactured_by",
header: ({ column }) => <ColumnHeader column={column} title="Manufacturer" />,
cell: ({ row }) => (row.original as any).manufactured_by || "—",
},
{
accessorKey: "selling_price",
header: ({ column }) => <ColumnHeader column={column} title="Sell Price" />,
cell: ({ row }) => {
const val = (row.original as any).selling_price
return val != null ? `$${Number(val).toFixed(2)}` : "—"
},
},
{
accessorKey: "purchase_price",
header: ({ column }) => <ColumnHeader column={column} title="Cost" />,
cell: ({ row }) => {
const val = (row.original as any).purchase_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(),
]}
renderForm={({ resourceId, initialData, onSuccess }) => (
<PartForm
resourceId={resourceId}
initialData={initialData}
onSuccess={onSuccess}
/>
)}
/>
)
}

View File

@ -1,14 +0,0 @@
import { DashboardHeader } from "@/base/components/layout/dashboard";
import DashboardPage from "@/base/components/layout/dashboard/dashboard-page";
export default function page() {
return (
<DashboardPage header={<DashboardHeader />} >
<div className="space-y-6">
<h1 className="text-2xl font-bold tracking-tight">Dashboard</h1>
<p className="text-muted-foreground">
Welcome to your dashboard. Select an item from the sidebar to get started.
</p>
</div>
</DashboardPage>
)
}

View File

@ -1,65 +0,0 @@
"use client"
import { ResourcePage } from "@/shared/data-view/resource-page"
import { ColumnHeader } from "@/shared/data-view/table-view"
import { EmployeeForm } from "@/modules/employees/employee-form"
import { EMPLOYEE_ROUTES } from "@repo/api"
import type { EmployeesClient } from "@repo/api"
export default function EmployeesPage() {
return (
<ResourcePage<EmployeesClient>
pageTitle="Employees"
title="Employee"
routeKey={EMPLOYEE_ROUTES.INDEX}
getClient={(api) => api.employees}
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(),
]}
renderForm={({ resourceId, initialData, onSuccess }) => (
<EmployeeForm
resourceId={resourceId}
initialData={initialData}
onSuccess={onSuccess}
/>
)}
/>
)
}

View File

@ -1,54 +0,0 @@
"use client"
import { ResourcePage } from '@/shared/data-view/resource-page'
import { ColumnHeader } from '@/shared/data-view/table-view'
import { CustomerForm } from '@/modules/customers/customer-form'
import { CUSTOMER_ROUTES } from '@repo/api'
import type { CustomersClient } from '@repo/api'
import { Building2Icon, UserIcon } from 'lucide-react'
export default function CustomersPage() {
return (
<ResourcePage<CustomersClient>
pageTitle='Customers'
title="Customer"
routeKey={CUSTOMER_ROUTES.INDEX}
getClient={(api) => api.customers}
columns={({ actionsColumn }) => [
{
accessorKey: "first_name",
header: ({ column }) => <ColumnHeader column={column} title="Customer" />,
cell: ({ row }) => {
const customerName = row.original.first_name
const isCompany = row.original.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(),
]}
renderForm={({ resourceId, initialData, onSuccess }) => (
<CustomerForm
resourceId={resourceId}
initialData={initialData}
onSuccess={onSuccess}
/>
)}
/>
)
}

View File

@ -1,65 +0,0 @@
"use client"
import { ResourcePage } from "@/shared/data-view/resource-page"
import { ColumnHeader } from "@/shared/data-view/table-view"
import { InspectionForm } from "@/modules/inspections/inspection-form"
import { INSPECTION_ROUTES } from "@repo/api"
import type { InspectionsClient } from "@repo/api"
export default function InspectionsPage() {
return (
<ResourcePage<InspectionsClient>
pageTitle="Inspections"
title="Inspection"
routeKey={INSPECTION_ROUTES.INDEX}
getClient={(api) => api.inspections}
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(),
]}
renderForm={({ resourceId, initialData, onSuccess }) => (
<InspectionForm
resourceId={resourceId}
initialData={initialData}
onSuccess={onSuccess}
/>
)}
/>
)
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

39
apps/dashboard/.gitignore vendored Normal file
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

11
apps/dashboard/.vscode/mcp.json vendored Normal file
View File

@ -0,0 +1,11 @@
{
"servers": {
"shadcn": {
"command": "npx",
"args": [
"shadcn@latest",
"mcp"
]
}
}
}

21
apps/dashboard/README.md Normal file
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";
```

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -0,0 +1,108 @@
"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, AppointmentStatus } from "@garage/api"
import type { AppointmentsClient } from "@garage/api"
import { CalendarCheck2Icon, ClipboardListIcon, ClockIcon } from "lucide-react"
import { Badge } from "@/shared/components/ui/badge"
import { RelationLink } from "@/shared/components/relation-link"
import { formatEnum } from "@/shared/utils/formatters"
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}
searchable
searchPlaceholder="Search appointments..."
statusFilter={{ statuses: AppointmentStatus }}
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}>
{formatEnum(status)}
</Badge>
)
},
},
{
id: "job_card",
header: ({ column }) => <ColumnHeader column={column} title="Job Card" />,
cell: ({ row }) => {
const item = row.original as any
const jobCardId = item.job_card_id ?? item.job_card?.id
return (
<RelationLink
href={jobCardId ? `/sales/job-cards/${jobCardId}` : null}
icon={ClipboardListIcon}
label={item.job_card?.title || (jobCardId ? `#${jobCardId}` : null)}
/>
)
},
},
actionsColumn(),
]}
/>
)
}

View File

@ -0,0 +1,5 @@
import { redirect } from "next/navigation"
export default function CalendarsPage() {
return redirect("/calendar/appointment/list")
}

View File

@ -0,0 +1,297 @@
"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}
searchable
searchPlaceholder="Search adjustments..."
getClient={(api) => api.inventoryAdjustments}
headerProps={({ selectedItem, invalidateQuery }) => ({
actions: (
<FormDialog
title="Inventory Adjustment"
classNames={{ dialogContent: "lg:min-w-4xl" }}
>
{(resourceId, { close }) => (
<InventoryAdjustmentForm
resourceId={resourceId}
initialData={selectedItem}
onSuccess={() => { invalidateQuery(); close() }}
/>
)}
</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)}
/>
)}
</>
)
}

View File

@ -0,0 +1,68 @@
"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}
searchable
searchPlaceholder="Search expense items..."
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(),
]}
/>
)
}

View File

@ -0,0 +1,61 @@
"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}
searchable
searchPlaceholder="Search parts..."
getClient={(api) => api.parts}
headerProps={({ selectedItem, invalidateQuery }) => ({
actions: (
<div className="flex items-center gap-2">
<ImportDataButton
onImport={(file) => api.parts.importData(file)}
onSuccess={invalidateQuery}
entityLabel="Parts"
/>
<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(),
]}
/>
)
}

View File

@ -2,18 +2,33 @@
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 "@repo/api"
import type { ServiceGroupsClient } from "@repo/api"
import { SERVICE_GROUP_ROUTES } from "@garage/api"
import type { ServiceGroupsClient } from "@garage/api"
export default function ServiceGroupPage() {
return (
<ResourcePage<ServiceGroupsClient>
pageTitle="Service Groups"
title="Service Group"
routeKey={SERVICE_GROUP_ROUTES.INDEX}
searchable
searchPlaceholder="Search service groups..."
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",
@ -60,13 +75,6 @@ export default function ServiceGroupPage() {
},
actionsColumn(),
]}
renderForm={({ resourceId, initialData, onSuccess }) => (
<ServiceGroupForm
resourceId={resourceId}
initialData={initialData}
onSuccess={onSuccess}
/>
)}
/>
)
}

View File

@ -2,17 +2,48 @@
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 "@repo/api"
import type { ServicesClient } from "@repo/api"
import { SERVICE_ROUTES } from "@garage/api"
import type { ServicesClient } from "@garage/api"
export default function ServicesPage() {
const api = useAuthApi()
return (
<ResourcePage<ServicesClient>
pageTitle="Services"
title="Service"
routeKey={SERVICE_ROUTES.INDEX}
searchable
searchPlaceholder="Search services..."
getClient={(api) => api.services}
headerProps={({ selectedItem, invalidateQuery }) => ({
actions: (
<div className="flex items-center gap-2">
<ImportDataButton
onImport={(file) => api.services.importData(file)}
onSuccess={invalidateQuery}
entityLabel="Services"
/>
<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",
@ -35,7 +66,7 @@ export default function ServicesPage() {
cell: ({ row }) => {
const val = (row.original as any).description
return val
? <span className="max-w-[200px] truncate block">{val}</span>
? <span className="max-w-50 truncate block">{val}</span>
: "—"
},
},
@ -57,13 +88,6 @@ export default function ServicesPage() {
},
actionsColumn(),
]}
renderForm={({ resourceId, initialData, onSuccess }) => (
<ServiceForm
resourceId={resourceId}
initialData={initialData}
onSuccess={onSuccess}
/>
)}
/>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -0,0 +1,100 @@
"use client"
import { use } from "react"
import { ResourcePage } from "@/shared/data-view/resource-page"
import { ColumnHeader } from "@/shared/data-view/table-view"
import { Badge } from "@/shared/components/ui/badge"
import { TIME_SHEET_ROUTES } from "@garage/api"
import type { TimeSheetsClient } from "@garage/api"
import { ClockIcon } from "lucide-react"
const ACTIVITY_VARIANT: Record<string, "default" | "secondary" | "outline"> = {
general: "secondary",
order: "default",
task: "outline",
}
function formatTime(value: unknown) {
if (!value || typeof value !== "string") return "—"
return value.length >= 5 ? value.slice(0, 5) : value
}
function formatDate(value: unknown) {
if (!value || typeof value !== "string") return "—"
const d = new Date(value)
return Number.isNaN(d.getTime()) ? value : d.toLocaleDateString()
}
function formatCost(value: unknown) {
if (value === null || value === undefined) return "—"
const n = Number(value)
if (!Number.isFinite(n)) return "—"
return n.toFixed(2)
}
export default function EmployeeAttendancePage({ params }: { params: Promise<{ id: string }> }) {
const { id: employeeId } = use(params)
return (
<ResourcePage<TimeSheetsClient>
pageTitle="Attendance"
routeKey={TIME_SHEET_ROUTES.INDEX}
getClient={(api) => api.timeSheets}
extraParams={{ employee_id: employeeId }}
header={null}
statusFilter={{
statuses: ["general", "order", "task"],
paramKey: "activity_type",
allLabel: "All",
}}
columns={() => [
{
accessorKey: "date",
header: ({ column }) => <ColumnHeader column={column} title="Date" />,
cell: ({ row }) => (
<div className="flex items-center gap-2">
<ClockIcon className="size-4 text-muted-foreground" />
<span>{formatDate((row.original as any).date)}</span>
</div>
),
},
{
accessorKey: "clock_in",
header: ({ column }) => <ColumnHeader column={column} title="Clock In" />,
cell: ({ row }) => <span className="font-mono text-xs">{formatTime((row.original as any).clock_in)}</span>,
},
{
accessorKey: "clock_out",
header: ({ column }) => <ColumnHeader column={column} title="Clock Out" />,
cell: ({ row }) => <span className="font-mono text-xs">{formatTime((row.original as any).clock_out)}</span>,
},
{
accessorKey: "duration",
header: ({ column }) => <ColumnHeader column={column} title="Duration" />,
cell: ({ row }) => <span className="font-mono text-xs">{formatTime((row.original as any).duration)}</span>,
},
{
accessorKey: "activity_type",
header: ({ column }) => <ColumnHeader column={column} title="Activity" />,
cell: ({ row }) => {
const type = (row.original as any).activity_type ?? "general"
return <Badge variant={ACTIVITY_VARIANT[type] ?? "secondary"}>{type}</Badge>
},
},
{
accessorKey: "calculated_total_cost",
header: ({ column }) => <ColumnHeader column={column} title="Cost" />,
cell: ({ row }) => formatCost((row.original as any).calculated_total_cost),
},
{
accessorKey: "note",
header: ({ column }) => <ColumnHeader column={column} title="Note" />,
cell: ({ row }) => {
const v = (row.original as any).note
return v ? <span className="text-sm">{v}</span> : "—"
},
},
]}
/>
)
}

View File

@ -0,0 +1,179 @@
"use client"
import { useState } from "react"
import { useParams } from "next/navigation"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { type ColumnDef } from "@tanstack/react-table"
import { Plus, Trash2, ExternalLinkIcon, AwardIcon } 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 { Badge } from "@/shared/components/ui/badge"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog"
import DashboardPage from "@/base/components/layout/dashboard/dashboard-page"
import { EmployeeCertificationForm } from "@/modules/employees/employee-certification-form"
type CertRow = {
id: number
name: string
issuer?: string | null
certificate_number?: string | null
issued_date?: string | null
expiry_date?: string | null
file_url?: string | null
is_expired?: boolean
notes?: string | null
}
function formatDate(value: unknown) {
if (!value || typeof value !== "string") return "—"
const d = new Date(value)
return Number.isNaN(d.getTime()) ? value : d.toLocaleDateString()
}
export default function EmployeeCertificationsPage() {
const { id: employeeId } = useParams<{ id: string }>()
const api = useAuthApi()
const queryClient = useQueryClient()
const [open, setOpen] = useState(false)
const queryKey = ["employee-certifications", employeeId]
const { data, isLoading } = useQuery({
queryKey,
queryFn: () => api.employees.listCertifications(employeeId) as Promise<{ data: CertRow[] | { data: CertRow[] } }>,
})
const rows: CertRow[] = Array.isArray((data as any)?.data)
? (data as any).data
: ((data as any)?.data?.data ?? [])
const deleteMutation = useMutation({
mutationFn: (id: number) => api.employees.destroyCertification(employeeId, String(id)),
onSuccess: () => {
toast.success("Certification deleted")
queryClient.invalidateQueries({ queryKey })
},
onError: () => toast.error("Failed to delete"),
})
async function handleDelete(row: CertRow) {
const ok = await confirm({
title: "Delete certification?",
description: `Permanently delete "${row.name}"?`,
confirmLabel: "Delete",
variant: "destructive",
})
if (ok) deleteMutation.mutate(row.id)
}
const columns: ColumnDef<CertRow>[] = [
{
accessorKey: "name",
header: ({ column }) => <ColumnHeader column={column} title="Name" />,
cell: ({ row }) => (
<div className="flex items-center gap-2">
<AwardIcon className="size-4 text-muted-foreground" />
<span className="font-medium">{row.original.name}</span>
</div>
),
},
{
accessorKey: "issuer",
header: ({ column }) => <ColumnHeader column={column} title="Issuer" />,
cell: ({ row }) => row.original.issuer ?? "—",
},
{
accessorKey: "certificate_number",
header: ({ column }) => <ColumnHeader column={column} title="Cert #" />,
cell: ({ row }) => {
const v = row.original.certificate_number
return v ? <span className="font-mono text-xs">{v}</span> : "—"
},
},
{
accessorKey: "issued_date",
header: ({ column }) => <ColumnHeader column={column} title="Issued" />,
cell: ({ row }) => formatDate(row.original.issued_date),
},
{
accessorKey: "expiry_date",
header: ({ column }) => <ColumnHeader column={column} title="Expiry" />,
cell: ({ row }) => (
<div className="flex items-center gap-2">
<span>{formatDate(row.original.expiry_date)}</span>
{row.original.is_expired && <Badge variant="destructive">Expired</Badge>}
</div>
),
},
{
id: "actions",
header: () => <span className="sr-only">Actions</span>,
cell: ({ row }) => (
<div className="flex justify-end gap-1">
{row.original.file_url && (
<Button variant="ghost" size="icon" asChild>
<a href={row.original.file_url} target="_blank" rel="noopener noreferrer">
<ExternalLinkIcon className="size-4" />
</a>
</Button>
)}
<Button
variant="ghost"
size="icon"
disabled={deleteMutation.isPending}
onClick={() => handleDelete(row.original)}
>
<Trash2 className="size-4 text-destructive" />
</Button>
</div>
),
},
]
return (
<DashboardPage
headerProps={{
title: "Certifications",
actions: (
<Button size="sm" onClick={() => setOpen(true)}>
<Plus className="size-4" />
Add Certification
</Button>
),
}}
>
<DataTable
columns={columns}
data={rows}
pagination={{ page: 1, pageSize: rows.length || 10, pageCount: 1, total: rows.length }}
sorting={[]}
onChange={() => {}}
isLoading={isLoading}
/>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Certification</DialogTitle>
</DialogHeader>
<EmployeeCertificationForm
employeeId={employeeId}
onSuccess={() => {
setOpen(false)
queryClient.invalidateQueries({ queryKey })
}}
onCancel={() => setOpen(false)}
/>
</DialogContent>
</Dialog>
</DashboardPage>
)
}

View File

@ -0,0 +1,182 @@
"use client"
import { useState } from "react"
import { useParams } from "next/navigation"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { type ColumnDef } from "@tanstack/react-table"
import { Plus, Trash2, ExternalLinkIcon, FileTextIcon } 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 { Badge } from "@/shared/components/ui/badge"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog"
import DashboardPage from "@/base/components/layout/dashboard/dashboard-page"
import { EmployeeDocumentForm } from "@/modules/employees/employee-document-form"
import { formatEnum } from "@/shared/utils/formatters"
type DocRow = {
id: number
name: string
type: string
file_url?: string
issued_date?: string | null
expiry_date?: string | null
notes?: string | null
created_at?: string
}
function formatDate(value: unknown) {
if (!value || typeof value !== "string") return "—"
const d = new Date(value)
return Number.isNaN(d.getTime()) ? value : d.toLocaleDateString()
}
function isExpired(value: unknown) {
if (!value || typeof value !== "string") return false
const d = new Date(value)
return !Number.isNaN(d.getTime()) && d.getTime() < Date.now()
}
export default function EmployeeDocumentsPage() {
const { id: employeeId } = useParams<{ id: string }>()
const api = useAuthApi()
const queryClient = useQueryClient()
const [open, setOpen] = useState(false)
const queryKey = ["employee-documents", employeeId]
const { data, isLoading } = useQuery({
queryKey,
queryFn: () => api.employees.listDocuments(employeeId) as Promise<{ data: DocRow[] | { data: DocRow[] } }>,
})
const rows: DocRow[] = Array.isArray((data as any)?.data)
? (data as any).data
: ((data as any)?.data?.data ?? [])
const deleteMutation = useMutation({
mutationFn: (id: number) => api.employees.destroyDocument(employeeId, String(id)),
onSuccess: () => {
toast.success("Document deleted")
queryClient.invalidateQueries({ queryKey })
},
onError: () => toast.error("Failed to delete document"),
})
async function handleDelete(row: DocRow) {
const ok = await confirm({
title: "Delete document?",
description: `Permanently delete "${row.name}"?`,
confirmLabel: "Delete",
variant: "destructive",
})
if (ok) deleteMutation.mutate(row.id)
}
const columns: ColumnDef<DocRow>[] = [
{
accessorKey: "name",
header: ({ column }) => <ColumnHeader column={column} title="Name" />,
cell: ({ row }) => (
<div className="flex items-center gap-2">
<FileTextIcon className="size-4 text-muted-foreground" />
<span className="font-medium">{row.original.name}</span>
</div>
),
},
{
accessorKey: "type",
header: ({ column }) => <ColumnHeader column={column} title="Type" />,
cell: ({ row }) => <Badge variant="outline">{formatEnum(row.original.type)}</Badge>,
},
{
accessorKey: "issued_date",
header: ({ column }) => <ColumnHeader column={column} title="Issued" />,
cell: ({ row }) => formatDate(row.original.issued_date),
},
{
accessorKey: "expiry_date",
header: ({ column }) => <ColumnHeader column={column} title="Expiry" />,
cell: ({ row }) => {
const v = row.original.expiry_date
if (!v) return "—"
return (
<div className="flex items-center gap-2">
<span>{formatDate(v)}</span>
{isExpired(v) && <Badge variant="destructive">Expired</Badge>}
</div>
)
},
},
{
id: "actions",
header: () => <span className="sr-only">Actions</span>,
cell: ({ row }) => (
<div className="flex justify-end gap-1">
{row.original.file_url && (
<Button variant="ghost" size="icon" asChild>
<a href={row.original.file_url} target="_blank" rel="noopener noreferrer">
<ExternalLinkIcon className="size-4" />
</a>
</Button>
)}
<Button
variant="ghost"
size="icon"
disabled={deleteMutation.isPending}
onClick={() => handleDelete(row.original)}
>
<Trash2 className="size-4 text-destructive" />
</Button>
</div>
),
},
]
return (
<DashboardPage
headerProps={{
title: "Documents",
actions: (
<Button size="sm" onClick={() => setOpen(true)}>
<Plus className="size-4" />
Upload Document
</Button>
),
}}
>
<DataTable
columns={columns}
data={rows}
pagination={{ page: 1, pageSize: rows.length || 10, pageCount: 1, total: rows.length }}
sorting={[]}
onChange={() => {}}
isLoading={isLoading}
/>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Upload Document</DialogTitle>
</DialogHeader>
<EmployeeDocumentForm
employeeId={employeeId}
onSuccess={() => {
setOpen(false)
queryClient.invalidateQueries({ queryKey })
}}
onCancel={() => setOpen(false)}
/>
</DialogContent>
</Dialog>
</DashboardPage>
)
}

View File

@ -0,0 +1,46 @@
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}/attendance`, label: 'Attendance' },
{ href: `/productivity/employees/${id}/work-history`, label: 'Work History' },
{ href: `/productivity/employees/${id}/documents`, label: 'Documents' },
{ href: `/productivity/employees/${id}/certifications`, label: 'Certifications' },
{ href: `/productivity/employees/${id}/leave`, label: 'Leave' },
{ href: `/productivity/employees/${id}/payroll`, label: 'Payroll' },
{ href: `/productivity/employees/${id}/performance`, label: 'Performance' },
{ href: `/productivity/employees/${id}/permissions`, label: 'Permissions' },
]}
>
{props.children}
</DashboardDetailsPage>
</EmployeeProvider>
</>
)
}

View File

@ -0,0 +1,164 @@
"use client"
import { use, useState } from "react"
import { useQuery } from "@tanstack/react-query"
import { ResourcePage } from "@/shared/data-view/resource-page"
import { ColumnHeader } from "@/shared/data-view/table-view"
import FormDialog from "@/shared/components/form-dialog"
import { Badge } from "@/shared/components/ui/badge"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { useAuthApi } from "@/shared/useApi"
import { useEmployee } from "@/modules/employees/employee-context"
import { LeaveRequestForm } from "@/modules/leave-requests/leave-request-form"
import { LEAVE_REQUEST_ROUTES } from "@garage/api"
import type { LeaveRequestsClient } from "@garage/api"
import { CalendarIcon, PlaneIcon, ActivityIcon } from "lucide-react"
import { formatEnum } from "@/shared/utils/formatters"
type Balance = {
annual_total: number
annual_used: number
annual_remaining: number
sick_total: number
sick_used: number
sick_remaining: number
}
function formatDate(value: unknown) {
if (!value || typeof value !== "string") return "—"
const d = new Date(value)
return Number.isNaN(d.getTime()) ? value : d.toLocaleDateString()
}
function statusVariant(status: string): "default" | "secondary" | "outline" | "destructive" {
if (status === "approved") return "default"
if (status === "rejected") return "destructive"
if (status === "cancelled") return "outline"
return "secondary"
}
function BalanceCard({ icon: Icon, label, used, total, remaining }: {
icon: React.ComponentType<{ className?: string }>
label: string
used: number
total: number
remaining: number
}) {
const pct = total > 0 ? Math.min(100, (used / total) * 100) : 0
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<Icon className="size-4" />
{label}
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-semibold">{remaining}<span className="ml-1 text-sm font-normal text-muted-foreground">/ {total}</span></div>
<p className="mt-1 text-xs text-muted-foreground">{used} used</p>
<div className="mt-2 h-2 w-full overflow-hidden rounded bg-muted">
<div className="h-full bg-primary" style={{ width: `${pct}%` }} />
</div>
</CardContent>
</Card>
)
}
export default function EmployeeLeavePage({ params }: { params: Promise<{ id: string }> }) {
const { id: employeeId } = use(params)
const api = useAuthApi()
const employee = useEmployee()
const balanceQuery = useQuery({
queryKey: ["employee-leave-balance", employeeId],
queryFn: () => api.employees.getLeaveBalance(employeeId) as Promise<{ data: Balance }>,
})
const balance = (balanceQuery.data as any)?.data as Balance | undefined
return (
<div className="space-y-4">
{balance && (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-2 px-4 pt-4">
<BalanceCard
icon={PlaneIcon}
label="Annual Leave"
used={Number(balance.annual_used)}
total={Number(balance.annual_total)}
remaining={Number(balance.annual_remaining)}
/>
<BalanceCard
icon={ActivityIcon}
label="Sick Leave"
used={Number(balance.sick_used)}
total={Number(balance.sick_total)}
remaining={Number(balance.sick_remaining)}
/>
</div>
)}
<ResourcePage<LeaveRequestsClient>
pageTitle="Leave History"
routeKey={LEAVE_REQUEST_ROUTES.INDEX}
getClient={(api) => api.leaveRequests}
extraParams={{ employee_id: employeeId }}
header={null}
headerProps={({ invalidateQuery }) => ({
actions: (
<FormDialog title="New Leave Request">
{() => (
<LeaveRequestForm
presetEmployee={{ id: employeeId, label: employee?.label ?? "" }}
onSuccess={() => {
invalidateQuery()
balanceQuery.refetch()
}}
/>
)}
</FormDialog>
),
})}
columns={() => [
{
accessorKey: "leave_type",
header: ({ column }) => <ColumnHeader column={column} title="Type" />,
cell: ({ row }) => (
<div className="flex items-center gap-2">
<CalendarIcon className="size-4 text-muted-foreground" />
<Badge variant="outline">{formatEnum((row.original as any).leave_type)}</Badge>
</div>
),
},
{
accessorKey: "start_date",
header: ({ column }) => <ColumnHeader column={column} title="Start" />,
cell: ({ row }) => formatDate((row.original as any).start_date),
},
{
accessorKey: "end_date",
header: ({ column }) => <ColumnHeader column={column} title="End" />,
cell: ({ row }) => formatDate((row.original as any).end_date),
},
{
accessorKey: "days",
header: ({ column }) => <ColumnHeader column={column} title="Days" />,
cell: ({ row }) => (row.original as any).days ?? "—",
},
{
accessorKey: "status",
header: ({ column }) => <ColumnHeader column={column} title="Status" />,
cell: ({ row }) => {
const s = (row.original as any).status as string
return <Badge variant={statusVariant(s)}>{formatEnum(s)}</Badge>
},
},
{
accessorKey: "reason",
header: ({ column }) => <ColumnHeader column={column} title="Reason" />,
cell: ({ row }) => (row.original as any).reason ?? "—",
},
]}
/>
</div>
)
}

View File

@ -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>
)
}

View File

@ -0,0 +1,121 @@
"use client"
import { use } from "react"
import { useQuery } from "@tanstack/react-query"
import { type ColumnDef } from "@tanstack/react-table"
import { useAuthApi } from "@/shared/useApi"
import DashboardPage from "@/base/components/layout/dashboard/dashboard-page"
import { DataTable, ColumnHeader } from "@/shared/data-view/table-view"
import { Badge } from "@/shared/components/ui/badge"
import { formatEnum } from "@/shared/utils/formatters"
type Slip = {
id: number
payroll_run?: { id?: number; reference?: string; period_start?: string; period_end?: string; status?: string }
hours_worked: number
base_salary: number
commission_amount: number
allowances: number
deductions: number
net_pay: number
}
function formatDate(value: unknown) {
if (!value || typeof value !== "string") return "—"
const d = new Date(value)
return Number.isNaN(d.getTime()) ? value : d.toLocaleDateString()
}
function fmt(v: unknown) {
const n = Number(v)
if (!Number.isFinite(n)) return "—"
return n.toFixed(2)
}
function statusVariant(status: string | undefined): "default" | "secondary" | "outline" {
if (status === "paid") return "default"
if (status === "finalized") return "outline"
return "secondary"
}
export default function EmployeePayrollPage({ params }: { params: Promise<{ id: string }> }) {
const { id: employeeId } = use(params)
const api = useAuthApi()
const { data, isLoading } = useQuery({
queryKey: ["employee-payroll-slips", employeeId],
queryFn: () => api.employees.listPayrollSlips(employeeId) as Promise<{ data: Slip[] | { data: Slip[] } }>,
})
const rows: Slip[] = Array.isArray((data as any)?.data)
? (data as any).data
: ((data as any)?.data?.data ?? [])
const columns: ColumnDef<Slip>[] = [
{
accessorKey: "payroll_run",
header: ({ column }) => <ColumnHeader column={column} title="Run" />,
cell: ({ row }) => {
const r = row.original.payroll_run
return r?.reference ?? (r?.id ? `#${r.id}` : "—")
},
},
{
accessorKey: "period",
header: () => <span>Period</span>,
cell: ({ row }) => {
const r = row.original.payroll_run
if (!r) return "—"
return `${formatDate(r.period_start)}${formatDate(r.period_end)}`
},
},
{
accessorKey: "hours_worked",
header: ({ column }) => <ColumnHeader column={column} title="Hours" />,
cell: ({ row }) => fmt(row.original.hours_worked),
},
{
accessorKey: "base_salary",
header: ({ column }) => <ColumnHeader column={column} title="Base" />,
cell: ({ row }) => fmt(row.original.base_salary),
},
{
accessorKey: "commission_amount",
header: ({ column }) => <ColumnHeader column={column} title="Commission" />,
cell: ({ row }) => fmt(row.original.commission_amount),
},
{
accessorKey: "deductions",
header: ({ column }) => <ColumnHeader column={column} title="Deductions" />,
cell: ({ row }) => fmt(row.original.deductions),
},
{
accessorKey: "net_pay",
header: ({ column }) => <ColumnHeader column={column} title="Net Pay" />,
cell: ({ row }) => <span className="font-semibold">{fmt(row.original.net_pay)}</span>,
},
{
id: "status",
header: ({ column }) => <ColumnHeader column={column} title="Status" />,
cell: ({ row }) => {
const s = row.original.payroll_run?.status
if (!s) return "—"
return <Badge variant={statusVariant(s)}>{formatEnum(s)}</Badge>
},
},
]
return (
<DashboardPage headerProps={{ title: "Payroll Slips" }}>
<DataTable
columns={columns}
data={rows}
pagination={{ page: 1, pageSize: rows.length || 10, pageCount: 1, total: rows.length }}
sorting={[]}
onChange={() => {}}
isLoading={isLoading}
/>
</DashboardPage>
)
}

View File

@ -0,0 +1,121 @@
"use client"
import { useMemo, useState } from "react"
import { useParams } from "next/navigation"
import { useQuery } from "@tanstack/react-query"
import { ClockIcon, WrenchIcon, CheckCircle2Icon, TimerIcon, PercentIcon } from "lucide-react"
import { useAuthApi } from "@/shared/useApi"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Input } from "@/shared/components/ui/input"
import { Button } from "@/shared/components/ui/button"
import { Field, FieldLabel } from "@/shared/components/ui/field"
import DashboardPage from "@/base/components/layout/dashboard/dashboard-page"
type PerfData = {
period: { from: string; to: string }
hours_worked: number
jobs_total: number
jobs_completed: number
avg_job_duration_hours: number
completion_rate: number
}
function StatCard({
icon: Icon,
label,
value,
suffix,
}: {
icon: React.ComponentType<{ className?: string }>
label: string
value: string | number
suffix?: string
}) {
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<Icon className="size-4" />
{label}
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-semibold">
{value}
{suffix && <span className="ml-1 text-base font-normal text-muted-foreground">{suffix}</span>}
</div>
</CardContent>
</Card>
)
}
function firstOfMonth() {
const d = new Date()
return new Date(d.getFullYear(), d.getMonth(), 1).toISOString().slice(0, 10)
}
function lastOfMonth() {
const d = new Date()
return new Date(d.getFullYear(), d.getMonth() + 1, 0).toISOString().slice(0, 10)
}
export default function EmployeePerformancePage() {
const { id: employeeId } = useParams<{ id: string }>()
const api = useAuthApi()
const [fromInput, setFromInput] = useState(firstOfMonth())
const [toInput, setToInput] = useState(lastOfMonth())
const [from, setFrom] = useState(fromInput)
const [to, setTo] = useState(toInput)
const { data, isLoading, isFetching } = useQuery({
queryKey: ["employee-performance", employeeId, from, to],
queryFn: () => api.employees.performance(employeeId, { from, to }) as Promise<{ data: PerfData }>,
})
const perf = useMemo(() => (data as any)?.data as PerfData | undefined, [data])
return (
<DashboardPage
headerProps={{
title: perf ? `Performance · ${perf.period.from}${perf.period.to}` : "Performance",
actions: (
<div className="flex items-end gap-2">
<Field>
<FieldLabel>From</FieldLabel>
<Input type="date" value={fromInput} onChange={(e) => setFromInput(e.target.value)} />
</Field>
<Field>
<FieldLabel>To</FieldLabel>
<Input type="date" value={toInput} onChange={(e) => setToInput(e.target.value)} />
</Field>
<Button
size="sm"
disabled={isFetching}
onClick={() => {
setFrom(fromInput)
setTo(toInput)
}}
>
{isFetching ? "Loading..." : "Apply"}
</Button>
</div>
),
}}
>
{isLoading && !perf ? (
<p className="text-sm text-muted-foreground">Loading...</p>
) : !perf ? (
<p className="text-sm text-muted-foreground">No data.</p>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
<StatCard icon={ClockIcon} label="Hours Worked" value={perf.hours_worked.toFixed(2)} suffix="h" />
<StatCard icon={WrenchIcon} label="Jobs Assigned" value={perf.jobs_total} />
<StatCard icon={CheckCircle2Icon} label="Jobs Completed" value={perf.jobs_completed} />
<StatCard icon={TimerIcon} label="Avg Job Duration" value={perf.avg_job_duration_hours.toFixed(2)} suffix="h" />
<StatCard icon={PercentIcon} label="Completion Rate" value={perf.completion_rate} suffix="%" />
</div>
)}
</DashboardPage>
)
}

View File

@ -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} />
}

View File

@ -0,0 +1,110 @@
"use client"
import { use, useState } from "react"
import { ResourcePage } from "@/shared/data-view/resource-page"
import { ColumnHeader } from "@/shared/data-view/table-view"
import { Badge } from "@/shared/components/ui/badge"
import { Tabs, TabsList, TabsTrigger } from "@/shared/components/ui/tabs"
import { JOB_CARD_ROUTES } from "@garage/api"
import type { JobCardsClient } from "@garage/api"
import { useRouter } from "next/navigation"
import { WrenchIcon } from "lucide-react"
import { formatEnum } from "@/shared/utils/formatters"
const ROLE_PARAM = {
technician: "primary_technician_id",
sales: "sales_person_id",
writer: "service_writer_id",
} as const
type RoleKey = keyof typeof ROLE_PARAM
function formatDate(value: unknown) {
if (!value || typeof value !== "string") return "—"
const d = new Date(value)
return Number.isNaN(d.getTime()) ? value : d.toLocaleDateString()
}
function statusVariant(status: string | undefined): "default" | "secondary" | "outline" | "destructive" {
if (!status) return "secondary"
if (status === "completed" || status === "delivered") return "default"
if (status === "cancelled") return "destructive"
return "outline"
}
export default function EmployeeWorkHistoryPage({ params }: { params: Promise<{ id: string }> }) {
const { id: employeeId } = use(params)
const router = useRouter()
const [role, setRole] = useState<RoleKey>("technician")
const paramKey = ROLE_PARAM[role]
return (
<div className="space-y-3">
<div className="px-4 pt-4">
<Tabs value={role} onValueChange={(v) => setRole(v as RoleKey)}>
<TabsList variant="line">
<TabsTrigger value="technician">As Technician</TabsTrigger>
<TabsTrigger value="sales">As Sales Person</TabsTrigger>
<TabsTrigger value="writer">As Service Writer</TabsTrigger>
</TabsList>
</Tabs>
</div>
<ResourcePage<JobCardsClient>
key={role}
pageTitle="Work History"
routeKey={JOB_CARD_ROUTES.INDEX}
searchable
searchPlaceholder="Search by job number, plate, customer..."
getClient={(api) => api.jobCards}
extraParams={{ [paramKey]: employeeId }}
header={null}
onRowClick={(row) => router.push(`/sales/job-cards/${(row as any).id}`)}
columns={() => [
{
accessorKey: "order_number",
header: ({ column }) => <ColumnHeader column={column} title="Job #" />,
cell: ({ row }) => (
<div className="flex items-center gap-2">
<WrenchIcon className="size-4 text-muted-foreground" />
<span className="font-mono text-xs">{(row.original as any).order_number ?? "—"}</span>
</div>
),
},
{
accessorKey: "customer",
header: ({ column }) => <ColumnHeader column={column} title="Customer" />,
cell: ({ row }) => {
const c = (row.original as any).customer
if (!c) return "—"
return `${c.first_name ?? ""} ${c.last_name ?? ""}`.trim() || c.email || "—"
},
},
{
accessorKey: "vehicle",
header: ({ column }) => <ColumnHeader column={column} title="Vehicle" />,
cell: ({ row }) => {
const v = (row.original as any).vehicle
if (!v) return "—"
const display = `${v.make ?? ""} ${v.model ?? ""}`.trim()
return display || v.license_plate || "—"
},
},
{
accessorKey: "status",
header: ({ column }) => <ColumnHeader column={column} title="Status" />,
cell: ({ row }) => {
const s = (row.original as any).status
return s ? <Badge variant={statusVariant(s)}>{formatEnum(s)}</Badge> : "—"
},
},
{
accessorKey: "created_at",
header: ({ column }) => <ColumnHeader column={column} title="Opened" />,
cell: ({ row }) => formatDate((row.original as any).created_at),
},
]}
/>
</div>
)
}

View File

@ -0,0 +1,166 @@
"use client"
import { useMemo, useState } 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 { ImportDataButton } from "@/shared/components/import-data-button"
import { ExportDataButton } from "@/shared/components/export-data-button"
import { EmployeeForm } from "@/modules/employees/employee-form"
import { useAuthApi } from "@/shared/useApi"
import { EMPLOYEE_ROUTES } from "@garage/api"
import type { EmployeesClient } from "@garage/api"
import { Avatar, AvatarFallback } from "@/shared/components/ui/avatar"
import { Badge } from "@/shared/components/ui/badge"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import { useRouter } from "next/navigation"
import { formatEnum } from "@/shared/utils/formatters"
const TYPE_OPTIONS = [
{ value: "all", label: "All types" },
{ value: "employee", label: "Employee" },
{ value: "sales_person", label: "Sales Person" },
]
function initialsOf(first?: string | null, last?: string | null) {
const f = (first ?? "").trim()[0] ?? ""
const l = (last ?? "").trim()[0] ?? ""
return (f + l).toUpperCase() || "?"
}
export default function EmployeesPage() {
const router = useRouter()
const api = useAuthApi()
const [typeFilter, setTypeFilter] = useState<string>("all")
const extraParams = useMemo(() => {
if (typeFilter === "all") return undefined
return { type: typeFilter }
}, [typeFilter])
return (
<ResourcePage<EmployeesClient>
pageTitle="Employees"
routeKey={EMPLOYEE_ROUTES.INDEX}
searchable
searchPlaceholder="Search employees..."
statusFilter={{ statuses: ["active", "inactive"] }}
extraParams={extraParams}
getClient={(api) => api.employees}
onRowClick={(row) => router.push(`/productivity/employees/${(row as any).id}`)}
tableHeader={() => (
<div className="flex items-center gap-2">
<Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger className="w-44">
<SelectValue placeholder="All types" />
</SelectTrigger>
<SelectContent>
{TYPE_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
headerProps={({ selectedItem, invalidateQuery }) => ({
actions: (
<div className="flex items-center gap-2">
<ImportDataButton
onImport={(file) => api.employees.importData(file)}
onSuccess={invalidateQuery}
entityLabel="Employees"
onDownloadSample={() => api.employees.downloadImportSample() as Promise<Blob>}
sampleFileName="employees-import-sample"
/>
<ExportDataButton
onExport={(filters) => api.employees.exportData(filters)}
fileName="employees"
/>
<FormDialog title="Employee">
{(resourceId) => (
<EmployeeForm
resourceId={resourceId}
initialData={selectedItem}
onSuccess={invalidateQuery}
/>
)}
</FormDialog>
</div>
),
})}
columns={({ actionsColumn }) => [
{
accessorKey: "first_name",
header: ({ column }) => <ColumnHeader column={column} title="Name" />,
cell: ({ row }) => {
const r = row.original as any
const fullName = `${r.first_name ?? ""} ${r.last_name ?? ""}`.trim() || "—"
return (
<div className="flex items-center gap-2">
<Avatar size="sm">
<AvatarFallback>{initialsOf(r.first_name, r.last_name)}</AvatarFallback>
</Avatar>
<span className="font-medium">{fullName}</span>
</div>
)
},
},
{
accessorKey: "email",
header: ({ column }) => <ColumnHeader column={column} title="Email" />,
},
{
accessorKey: "phone",
header: ({ column }) => <ColumnHeader column={column} title="Phone" />,
cell: ({ row }) => (row.original as any).phone ?? "—",
},
{
accessorKey: "type",
header: ({ column }) => <ColumnHeader column={column} title="Type" />,
cell: ({ row }) => {
const t = (row.original as any).type
if (!t) return "—"
return <Badge variant="outline">{t === "sales_person" ? "Sales Person" : "Employee"}</Badge>
},
},
{
accessorKey: "department",
header: ({ column }) => <ColumnHeader column={column} title="Department" />,
cell: ({ row }) => {
const d = (row.original as any).department?.name
return d ? <Badge variant="secondary">{d}</Badge> : "—"
},
},
{
accessorKey: "role",
header: ({ column }) => <ColumnHeader column={column} title="Role" />,
cell: ({ row }) => {
const r = (row.original as any).role?.name
return r ? <Badge variant="secondary">{r}</Badge> : "—"
},
},
{
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"}>
{formatEnum(status)}
</span>
)
},
},
actionsColumn(),
]}
/>
)
}

View File

@ -0,0 +1,48 @@
"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}
searchable
searchPlaceholder="Search holidays..."
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 }),
]}
/>
)
}

View File

@ -0,0 +1,152 @@
"use client"
import { useState } 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 { LeaveRequestForm } from "@/modules/leave-requests/leave-request-form"
import { LEAVE_REQUEST_ROUTES } from "@garage/api"
import type { LeaveRequestsClient } from "@garage/api"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { useAuthApi } from "@/shared/useApi"
import { useQueryClient } from "@tanstack/react-query"
import { toast } from "sonner"
import { CheckIcon, XIcon } from "lucide-react"
import { confirm } from "@/shared/components/confirm-dialog"
import { formatEnum } from "@/shared/utils/formatters"
import { usePermissions } from "@/shared/hooks/use-permissions"
type LeaveRow = {
id: number
leave_type: string
start_date?: string
end_date?: string
days?: number
status: string
reason?: string | null
employee?: { id?: number; first_name?: string; last_name?: string; email?: string }
}
function formatDate(value: unknown) {
if (!value || typeof value !== "string") return "—"
const d = new Date(value)
return Number.isNaN(d.getTime()) ? value : d.toLocaleDateString()
}
function statusVariant(status: string): "default" | "secondary" | "outline" | "destructive" {
if (status === "approved") return "default"
if (status === "rejected") return "destructive"
if (status === "cancelled") return "outline"
return "secondary"
}
export default function LeaveRequestsPage() {
const api = useAuthApi()
const queryClient = useQueryClient()
const perms = usePermissions()
async function handleDecision(id: number, action: "approve" | "reject") {
const ok = await confirm({
title: action === "approve" ? "Approve request?" : "Reject request?",
description: `This will mark the leave request as ${action}d.`,
confirmLabel: action === "approve" ? "Approve" : "Reject",
variant: action === "approve" ? "default" : "destructive",
})
if (!ok) return
const promise = action === "approve"
? api.leaveRequests.approve(String(id))
: api.leaveRequests.reject(String(id))
toast.promise(promise, {
loading: action === "approve" ? "Approving..." : "Rejecting...",
success: action === "approve" ? "Approved" : "Rejected",
error: "Failed",
})
await promise
queryClient.invalidateQueries({ queryKey: [LEAVE_REQUEST_ROUTES.INDEX] })
}
return (
<ResourcePage<LeaveRequestsClient>
pageTitle="Leave Requests"
routeKey={LEAVE_REQUEST_ROUTES.INDEX}
getClient={(api) => api.leaveRequests}
statusFilter={{
statuses: ["pending", "approved", "rejected", "cancelled"],
paramKey: "status",
allLabel: "All",
}}
headerProps={({ selectedItem, invalidateQuery }) => ({
actions: perms.canCreate("leave_requests") ? (
<FormDialog title="Leave Request">
{(resourceId) => (
<LeaveRequestForm
resourceId={resourceId}
initialData={selectedItem}
onSuccess={invalidateQuery}
/>
)}
</FormDialog>
) : null,
})}
columns={({ actionsColumn }) => [
{
accessorKey: "employee",
header: ({ column }) => <ColumnHeader column={column} title="Employee" />,
cell: ({ row }) => {
const e = (row.original as any).employee
if (!e) return "—"
return `${e.first_name ?? ""} ${e.last_name ?? ""}`.trim() || e.email || "—"
},
},
{
accessorKey: "leave_type",
header: ({ column }) => <ColumnHeader column={column} title="Type" />,
cell: ({ row }) => <Badge variant="outline">{formatEnum((row.original as any).leave_type)}</Badge>,
},
{
accessorKey: "start_date",
header: ({ column }) => <ColumnHeader column={column} title="Start" />,
cell: ({ row }) => formatDate((row.original as any).start_date),
},
{
accessorKey: "end_date",
header: ({ column }) => <ColumnHeader column={column} title="End" />,
cell: ({ row }) => formatDate((row.original as any).end_date),
},
{
accessorKey: "days",
header: ({ column }) => <ColumnHeader column={column} title="Days" />,
cell: ({ row }) => (row.original as any).days ?? "—",
},
{
accessorKey: "status",
header: ({ column }) => <ColumnHeader column={column} title="Status" />,
cell: ({ row }) => {
const s = (row.original as any).status as string
return <Badge variant={statusVariant(s)}>{formatEnum(s)}</Badge>
},
},
{
id: "decide",
header: () => <span className="sr-only">Decide</span>,
cell: ({ row }) => {
const r = row.original as LeaveRow
if (r.status !== "pending" || !perms.canUpdate("leave_requests")) return null
return (
<div className="flex justify-end gap-1">
<Button size="icon" variant="ghost" onClick={() => handleDecision(r.id, "approve")}>
<CheckIcon className="size-4 text-green-600" />
</Button>
<Button size="icon" variant="ghost" onClick={() => handleDecision(r.id, "reject")}>
<XIcon className="size-4 text-destructive" />
</Button>
</div>
)
},
},
actionsColumn(),
]}
/>
)
}

View File

@ -0,0 +1,259 @@
"use client"
import { use } from "react"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { type ColumnDef } from "@tanstack/react-table"
import { toast } from "sonner"
import { CheckCircle2Icon, RefreshCwIcon, BanknoteIcon, Trash2, DownloadIcon } from "lucide-react"
import { useAuthApi } from "@/shared/useApi"
import DashboardPage from "@/base/components/layout/dashboard/dashboard-page"
import { DataTable, ColumnHeader } from "@/shared/data-view/table-view"
import { Button } from "@/shared/components/ui/button"
import { Badge } from "@/shared/components/ui/badge"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { confirm } from "@/shared/components/confirm-dialog"
import { useRouter } from "next/navigation"
import { formatEnum } from "@/shared/utils/formatters"
type Entry = {
id: number
employee_id: number
employee?: { id?: number; first_name?: string; last_name?: string; email?: string }
hours_worked: number
overtime_hours: number
base_salary: number
commission_amount: number
allowances: number
deductions: number
net_pay: number
note?: string | null
}
type Run = {
id: number
reference?: string
period_start: string
period_end: string
status: "draft" | "finalized" | "paid"
entries: Entry[]
note?: string | null
finalized_at?: string | null
paid_at?: string | null
}
function formatDate(value: unknown) {
if (!value || typeof value !== "string") return "—"
const d = new Date(value)
return Number.isNaN(d.getTime()) ? value : d.toLocaleDateString()
}
function fmtCurrency(value: unknown) {
const n = Number(value)
if (!Number.isFinite(n)) return "—"
return n.toFixed(2)
}
function statusVariant(status: string): "default" | "secondary" | "outline" {
if (status === "paid") return "default"
if (status === "finalized") return "outline"
return "secondary"
}
export default function PayrollRunPage({ params }: { params: Promise<{ id: string }> }) {
const { id: runId } = use(params)
const api = useAuthApi()
const queryClient = useQueryClient()
const router = useRouter()
const queryKey = ["payroll-run", runId]
const { data, isLoading } = useQuery({
queryKey,
queryFn: () => api.payroll.show(runId) as Promise<{ data: Run; totals: Record<string, number> }>,
})
const run = (data as any)?.data as Run | undefined
const totals = (data as any)?.totals as Record<string, number> | undefined
const refetch = () => queryClient.invalidateQueries({ queryKey })
const finalizeMutation = useMutation({
mutationFn: () => api.payroll.finalize(runId),
onSuccess: () => {
toast.success("Run finalized")
refetch()
},
onError: () => toast.error("Failed to finalize"),
})
const regenerateMutation = useMutation({
mutationFn: () => api.payroll.regenerate(runId),
onSuccess: () => {
toast.success("Entries regenerated")
refetch()
},
onError: () => toast.error("Failed to regenerate"),
})
const markPaidMutation = useMutation({
mutationFn: () => api.payroll.markPaid(runId),
onSuccess: () => {
toast.success("Marked as paid")
refetch()
},
onError: () => toast.error("Failed to mark paid"),
})
const deleteMutation = useMutation({
mutationFn: () => api.payroll.destroy(runId),
onSuccess: () => {
toast.success("Run deleted")
router.push("/productivity/payroll")
},
onError: () => toast.error("Failed to delete"),
})
async function handleDelete() {
const ok = await confirm({
title: "Delete payroll run?",
description: "This permanently removes the run and all its entries.",
confirmLabel: "Delete",
variant: "destructive",
})
if (ok) deleteMutation.mutate()
}
const columns: ColumnDef<Entry>[] = [
{
accessorKey: "employee",
header: ({ column }) => <ColumnHeader column={column} title="Employee" />,
cell: ({ row }) => {
const e = row.original.employee
if (!e) return `#${row.original.employee_id}`
return `${e.first_name ?? ""} ${e.last_name ?? ""}`.trim() || e.email || "—"
},
},
{
accessorKey: "hours_worked",
header: ({ column }) => <ColumnHeader column={column} title="Hours" />,
cell: ({ row }) => fmtCurrency(row.original.hours_worked),
},
{
accessorKey: "base_salary",
header: ({ column }) => <ColumnHeader column={column} title="Base" />,
cell: ({ row }) => fmtCurrency(row.original.base_salary),
},
{
accessorKey: "commission_amount",
header: ({ column }) => <ColumnHeader column={column} title="Commission" />,
cell: ({ row }) => fmtCurrency(row.original.commission_amount),
},
{
accessorKey: "allowances",
header: ({ column }) => <ColumnHeader column={column} title="Allowances" />,
cell: ({ row }) => fmtCurrency(row.original.allowances),
},
{
accessorKey: "deductions",
header: ({ column }) => <ColumnHeader column={column} title="Deductions" />,
cell: ({ row }) => fmtCurrency(row.original.deductions),
},
{
accessorKey: "net_pay",
header: ({ column }) => <ColumnHeader column={column} title="Net Pay" />,
cell: ({ row }) => <span className="font-semibold">{fmtCurrency(row.original.net_pay)}</span>,
},
{
id: "slip",
header: () => <span className="sr-only">Slip</span>,
cell: ({ row }) => (
<Button variant="ghost" size="icon" asChild>
<a
href={`/api/payroll/runs/${runId}/entries/${row.original.id}/slip`}
target="_blank"
rel="noopener noreferrer"
title="Download slip PDF"
>
<DownloadIcon className="size-4" />
</a>
</Button>
),
},
]
if (isLoading && !run) {
return <DashboardPage>Loading...</DashboardPage>
}
if (!run) {
return <DashboardPage>Run not found.</DashboardPage>
}
const entries = run.entries ?? []
return (
<DashboardPage
headerProps={{
title: `${run.reference ?? `Payroll #${run.id}`} · ${formatDate(run.period_start)}${formatDate(run.period_end)}`,
actions: (
<div className="flex items-center gap-2">
<Badge variant={statusVariant(run.status)}>{formatEnum(run.status)}</Badge>
{run.status === "draft" && (
<>
<Button size="sm" variant="outline" disabled={regenerateMutation.isPending} onClick={() => regenerateMutation.mutate()}>
<RefreshCwIcon className="size-4" />
Regenerate
</Button>
<Button size="sm" disabled={finalizeMutation.isPending} onClick={() => finalizeMutation.mutate()}>
<CheckCircle2Icon className="size-4" />
Finalize
</Button>
<Button size="sm" variant="destructive" disabled={deleteMutation.isPending} onClick={handleDelete}>
<Trash2 className="size-4" />
Delete
</Button>
</>
)}
{run.status === "finalized" && (
<Button size="sm" disabled={markPaidMutation.isPending} onClick={() => markPaidMutation.mutate()}>
<BanknoteIcon className="size-4" />
Mark Paid
</Button>
)}
</div>
),
}}
>
{totals && (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5 mb-4">
{[
{ label: "Gross Base", value: totals.gross_base },
{ label: "Commission", value: totals.commission },
{ label: "Allowances", value: totals.allowances },
{ label: "Deductions", value: totals.deductions },
{ label: "Net Total", value: totals.net_pay },
].map((t) => (
<Card key={t.label}>
<CardHeader className="pb-1">
<CardTitle className="text-xs font-medium text-muted-foreground">{t.label}</CardTitle>
</CardHeader>
<CardContent>
<div className="text-lg font-semibold">{fmtCurrency(t.value)}</div>
</CardContent>
</Card>
))}
</div>
)}
<DataTable
columns={columns}
data={entries}
pagination={{ page: 1, pageSize: entries.length || 10, pageCount: 1, total: entries.length }}
sorting={[]}
onChange={() => {}}
isLoading={false}
/>
</DashboardPage>
)
}

View File

@ -0,0 +1,102 @@
"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 { PayrollRunForm } from "@/modules/payroll/payroll-run-form"
import { PAYROLL_ROUTES } from "@garage/api"
import type { PayrollClient } from "@garage/api"
import { Badge } from "@/shared/components/ui/badge"
import { useRouter } from "next/navigation"
import { formatEnum } from "@/shared/utils/formatters"
type RunRow = {
id: number
reference?: string | null
period_start?: string
period_end?: string
status: string
entries_count?: number
generated_at?: string | null
finalized_at?: string | null
paid_at?: string | null
}
function formatDate(value: unknown) {
if (!value || typeof value !== "string") return "—"
const d = new Date(value)
return Number.isNaN(d.getTime()) ? value : d.toLocaleDateString()
}
function statusVariant(status: string): "default" | "secondary" | "outline" {
if (status === "paid") return "default"
if (status === "finalized") return "outline"
return "secondary"
}
export default function PayrollPage() {
const router = useRouter()
return (
<ResourcePage<PayrollClient>
pageTitle="Payroll"
routeKey={PAYROLL_ROUTES.RUNS_INDEX}
getClient={(api) => api.payroll}
statusFilter={{
statuses: ["draft", "finalized", "paid"],
paramKey: "status",
}}
onRowClick={(row) => router.push(`/productivity/payroll/${(row as any).id}`)}
headerProps={({ invalidateQuery }) => ({
actions: (
<FormDialog title="Payroll Run">
{() => (
<PayrollRunForm
onSuccess={(runId) => {
invalidateQuery()
if (runId) router.push(`/productivity/payroll/${runId}`)
}}
/>
)}
</FormDialog>
),
})}
columns={({ actionsColumn }) => [
{
accessorKey: "reference",
header: ({ column }) => <ColumnHeader column={column} title="Reference" />,
cell: ({ row }) => <span className="font-mono text-xs">{(row.original as any).reference ?? `#${(row.original as any).id}`}</span>,
},
{
accessorKey: "period_start",
header: ({ column }) => <ColumnHeader column={column} title="Period Start" />,
cell: ({ row }) => formatDate((row.original as any).period_start),
},
{
accessorKey: "period_end",
header: ({ column }) => <ColumnHeader column={column} title="Period End" />,
cell: ({ row }) => formatDate((row.original as any).period_end),
},
{
accessorKey: "entries_count",
header: ({ column }) => <ColumnHeader column={column} title="Entries" />,
cell: ({ row }) => (row.original as any).entries_count ?? "—",
},
{
accessorKey: "status",
header: ({ column }) => <ColumnHeader column={column} title="Status" />,
cell: ({ row }) => {
const s = (row.original as any).status as string
return <Badge variant={statusVariant(s)}>{formatEnum(s)}</Badge>
},
},
{
accessorKey: "finalized_at",
header: ({ column }) => <ColumnHeader column={column} title="Finalized" />,
cell: ({ row }) => formatDate((row.original as any).finalized_at),
},
actionsColumn({ onEdit: undefined }),
]}
/>
)
}

View File

@ -2,18 +2,33 @@
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 "@repo/api"
import type { ShopCalendarsClient } from "@repo/api"
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"
title="Shop Calendar"
routeKey={SHOP_CALENDAR_ROUTES.INDEX}
searchable
searchPlaceholder="Search shop calendars..."
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",
@ -38,13 +53,6 @@ export default function ShopCalendarsPage() {
},
actionsColumn({ onEdit: undefined }),
]}
renderForm={({ resourceId, initialData, onSuccess }) => (
<ShopCalendarForm
resourceId={resourceId}
initialData={initialData}
onSuccess={onSuccess}
/>
)}
/>
)
}

View File

@ -2,18 +2,33 @@
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 "@repo/api"
import type { ShopTimingsClient } from "@repo/api"
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"
title="Shop Timing"
routeKey={SHOP_TIMING_ROUTES.INDEX}
searchable
searchPlaceholder="Search shop timings..."
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",
@ -45,13 +60,6 @@ export default function ShopTimingsPage() {
},
actionsColumn(),
]}
renderForm={({ resourceId, initialData, onSuccess }) => (
<ShopTimingForm
resourceId={resourceId}
initialData={initialData}
onSuccess={onSuccess}
/>
)}
/>
)
}

View File

@ -0,0 +1,60 @@
"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, TaskStatus } from "@garage/api"
import type { TasksClient } from "@garage/api"
export default function TasksPage() {
return (
<ResourcePage<TasksClient>
routeKey={TASK_ROUTES.INDEX}
searchable
searchPlaceholder="Search tasks..."
statusFilter={{ statuses: TaskStatus }}
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(),
]}
/>
)
}

View File

@ -0,0 +1,163 @@
"use client"
import { useMemo, useState } from "react"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { LogInIcon, LogOutIcon, TimerIcon, SearchIcon } from "lucide-react"
import { toast } from "sonner"
import { useAuthApi } from "@/shared/useApi"
import { Card, CardContent } from "@/shared/components/ui/card"
import { Avatar, AvatarFallback } from "@/shared/components/ui/avatar"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import DashboardPage from "@/base/components/layout/dashboard/dashboard-page"
import { EMPLOYEE_ROUTES } from "@garage/api"
type Employee = {
id: number
first_name?: string
last_name?: string
email?: string
designation?: string
department?: { id?: number; name?: string }
track_attendance?: boolean
has_active_time_sheet?: boolean
active_time_sheet?: { id?: number; clock_in?: string; date?: string } | null
status?: string
}
function initialsOf(first?: string | null, last?: string | null) {
const f = (first ?? "").trim()[0] ?? ""
const l = (last ?? "").trim()[0] ?? ""
return (f + l).toUpperCase() || "?"
}
function formatTime(value: unknown) {
if (!value || typeof value !== "string") return "—"
return value.length >= 5 ? value.slice(0, 5) : value
}
export default function TimeClocksPage() {
const api = useAuthApi()
const queryClient = useQueryClient()
const [search, setSearch] = useState("")
const employeesQuery = useQuery({
queryKey: [EMPLOYEE_ROUTES.INDEX, "time-clocks", { per_page: 100, status: "active" }],
queryFn: () => api.employees.list({ per_page: 100, status: "active" } as any) as Promise<{ data: Employee[] }>,
})
const employees: Employee[] = useMemo(() => {
const rows: Employee[] = (employeesQuery.data as any)?.data ?? []
if (!search) return rows
const q = search.toLowerCase()
return rows.filter((e) => {
const name = `${e.first_name ?? ""} ${e.last_name ?? ""}`.toLowerCase()
return name.includes(q) || (e.email ?? "").toLowerCase().includes(q)
})
}, [employeesQuery.data, search])
const refetch = () => queryClient.invalidateQueries({ queryKey: [EMPLOYEE_ROUTES.INDEX, "time-clocks", { per_page: 100, status: "active" }] })
const clockInMutation = useMutation({
mutationFn: (employeeId: number) => api.timeSheets.clockIn({ employee_id: employeeId } as any),
onSuccess: () => {
toast.success("Clocked in")
refetch()
},
onError: (err: any) => toast.error(err?.payload?.message ?? err?.message ?? "Failed to clock in"),
})
const clockOutMutation = useMutation({
mutationFn: (timeSheetId: number) => api.timeSheets.clockOut({ time_sheet_id: timeSheetId } as any),
onSuccess: () => {
toast.success("Clocked out")
refetch()
},
onError: (err: any) => toast.error(err?.payload?.message ?? err?.message ?? "Failed to clock out"),
})
return (
<DashboardPage
headerProps={{
title: "Time Clocks",
actions: (
<div className="relative w-64">
<SearchIcon className="absolute left-2.5 top-2.5 size-4 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search employees..."
className="pl-8"
/>
</div>
),
}}
>
{employeesQuery.isLoading ? (
<p className="text-sm text-muted-foreground">Loading...</p>
) : employees.length === 0 ? (
<p className="text-sm text-muted-foreground">No employees found.</p>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{employees.map((emp) => {
const fullName = `${emp.first_name ?? ""} ${emp.last_name ?? ""}`.trim() || emp.email
const isClockedIn = !!emp.has_active_time_sheet
const activeId = emp.active_time_sheet?.id
const clockedInAt = emp.active_time_sheet?.clock_in
return (
<Card key={emp.id}>
<CardContent className="flex items-center justify-between gap-3 p-4">
<div className="flex items-center gap-3 min-w-0">
<Avatar size="lg">
<AvatarFallback>{initialsOf(emp.first_name, emp.last_name)}</AvatarFallback>
</Avatar>
<div className="min-w-0">
<div className="font-medium truncate">{fullName}</div>
<div className="text-xs text-muted-foreground truncate">
{emp.designation ?? emp.department?.name ?? emp.email}
</div>
<div className="mt-1 flex items-center gap-2">
{isClockedIn ? (
<Badge variant="default" className="gap-1">
<TimerIcon className="size-3" />
Since {formatTime(clockedInAt)}
</Badge>
) : (
<Badge variant="secondary">Idle</Badge>
)}
</div>
</div>
</div>
{isClockedIn && activeId ? (
<Button
size="sm"
variant="destructive"
disabled={clockOutMutation.isPending}
onClick={() => clockOutMutation.mutate(activeId)}
>
<LogOutIcon className="size-4" />
Clock Out
</Button>
) : (
<Button
size="sm"
disabled={clockInMutation.isPending}
onClick={() => clockInMutation.mutate(emp.id)}
>
<LogInIcon className="size-4" />
Clock In
</Button>
)}
</CardContent>
</Card>
)
})}
</div>
)}
</DashboardPage>
)
}

View File

@ -0,0 +1,96 @@
"use client"
import { ResourcePage } from "@/shared/data-view/resource-page"
import { ColumnHeader } from "@/shared/data-view/table-view"
import { Badge } from "@/shared/components/ui/badge"
import { TIME_SHEET_ROUTES } from "@garage/api"
import type { TimeSheetsClient } from "@garage/api"
import { ClockIcon } from "lucide-react"
import { formatEnum } from "@/shared/utils/formatters"
function formatDate(value: unknown) {
if (!value || typeof value !== "string") return "—"
const d = new Date(value)
return Number.isNaN(d.getTime()) ? value : d.toLocaleDateString()
}
function formatTime(value: unknown) {
if (!value || typeof value !== "string") return "—"
return value.length >= 5 ? value.slice(0, 5) : value
}
const ACTIVITY_VARIANT: Record<string, "default" | "secondary" | "outline"> = {
general: "secondary",
order: "default",
task: "outline",
}
export default function TimeSheetsPage() {
return (
<ResourcePage<TimeSheetsClient>
pageTitle="Time Sheets"
routeKey={TIME_SHEET_ROUTES.INDEX}
getClient={(api) => api.timeSheets}
statusFilter={{
statuses: ["general", "order", "task"],
paramKey: "activity_type",
allLabel: "All",
}}
columns={({ actionsColumn }) => [
{
accessorKey: "date",
header: ({ column }) => <ColumnHeader column={column} title="Date" />,
cell: ({ row }) => (
<div className="flex items-center gap-2">
<ClockIcon className="size-4 text-muted-foreground" />
<span>{formatDate((row.original as any).date)}</span>
</div>
),
},
{
accessorKey: "employee",
header: ({ column }) => <ColumnHeader column={column} title="Employee" />,
cell: ({ row }) => {
const e = (row.original as any).employee
if (!e) return "—"
return `${e.first_name ?? ""} ${e.last_name ?? ""}`.trim() || e.email || "—"
},
},
{
accessorKey: "clock_in",
header: ({ column }) => <ColumnHeader column={column} title="Clock In" />,
cell: ({ row }) => <span className="font-mono text-xs">{formatTime((row.original as any).clock_in)}</span>,
},
{
accessorKey: "clock_out",
header: ({ column }) => <ColumnHeader column={column} title="Clock Out" />,
cell: ({ row }) => <span className="font-mono text-xs">{formatTime((row.original as any).clock_out)}</span>,
},
{
accessorKey: "duration",
header: ({ column }) => <ColumnHeader column={column} title="Duration" />,
cell: ({ row }) => <span className="font-mono text-xs">{formatTime((row.original as any).duration)}</span>,
},
{
accessorKey: "activity_type",
header: ({ column }) => <ColumnHeader column={column} title="Activity" />,
cell: ({ row }) => {
const t = (row.original as any).activity_type ?? "general"
return <Badge variant={ACTIVITY_VARIANT[t] ?? "secondary"}>{formatEnum(t)}</Badge>
},
},
{
accessorKey: "calculated_total_cost",
header: ({ column }) => <ColumnHeader column={column} title="Cost" />,
cell: ({ row }) => {
const v = (row.original as any).calculated_total_cost
if (v == null) return "—"
const n = Number(v)
return Number.isFinite(n) ? n.toFixed(2) : "—"
},
},
actionsColumn({ onEdit: undefined }),
]}
/>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -0,0 +1,119 @@
"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, BillStatus } from "@garage/api"
import type { BillsClient } from "@garage/api"
import { formatDate, formatEnum } from "@/shared/utils/formatters"
import { getFullName } from "@/shared/utils/getFullName"
import { Money } from "@/shared/components/money"
import { RelationLink } from "@/shared/components/relation-link"
import { Building2, Printer } from "lucide-react"
import { useDocumentPrint } from "@/shared/hooks/use-document-print"
export default function BillsPage() {
const router = useRouter()
const { print, isPrinting } = useDocumentPrint()
return (
<ResourcePage<BillsClient>
pageTitle="Bills"
routeKey={BILL_ROUTES.INDEX}
searchable
searchPlaceholder="Search bills..."
statusFilter={{ statuses: BillStatus }}
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 }) => {
const v = (row.original as any).total
return v != null && v !== "" ? <Money value={v} /> : "—"
},
},
{
accessorKey: "balance_due",
header: ({ column }) => <ColumnHeader column={column} title="Balance Due" />,
cell: ({ row }) => {
const v = (row.original as any).balance_due
return v != null && v !== "" ? <Money value={v} /> : "—"
},
},
{
accessorKey: "title",
header: ({ column }) => <ColumnHeader column={column} title="Title" />,
},
{
accessorKey: "vendor",
header: ({ column }) => <ColumnHeader column={column} title="Vendor" />,
cell: ({ row }) => {
const vendor = (row.original as any).vendor
return (
<RelationLink
href={vendor?.id ? `/purchase/vendor/${vendor.id}` : null}
icon={Building2}
label={vendor?.company_name || getFullName(vendor)}
/>
)
},
},
{
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"}>
{formatEnum(status)}
</Badge>
)
},
},
actionsColumn({
extraItems: (row) => [
{
label: isPrinting ? "Printing..." : "Print",
icon: Printer,
onClick: (r) => print("bill", String(r.id), "print"),
},
],
}),
]}
/>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -0,0 +1,116 @@
"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, ExpenseStatus } from "@garage/api"
import type { ExpensesClient } from "@garage/api"
import { useRouter } from "next/navigation"
import { formatDate, formatEnum } from "@/shared/utils/formatters"
import { Money } from "@/shared/components/money"
import { RelationLink } from "@/shared/components/relation-link"
import { Building2, Printer } from "lucide-react"
import { getFullName } from "@/shared/utils/getFullName"
import { useDocumentPrint } from "@/shared/hooks/use-document-print"
export default function ExpensesPage() {
const router = useRouter()
const { print, isPrinting } = useDocumentPrint()
return (
<ResourcePage<ExpensesClient>
pageTitle="Expenses"
routeKey={EXPENSE_ROUTES.INDEX}
searchable
searchPlaceholder="Search expenses..."
statusFilter={{ statuses: ExpenseStatus }}
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 (
<RelationLink
href={vendor?.id ? `/purchase/vendor/${vendor.id}` : null}
icon={Building2}
label={vendor?.company_name || getFullName(vendor) || 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 }) => <Money value={(row.original as any).total ?? 0} />,
},
{
accessorKey: "balance_due",
header: () => "Balance Due",
cell: ({ row }) => <Money value={(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({
extraItems: (row) => [
{
label: isPrinting ? "Printing..." : "Print",
icon: Printer,
onClick: (r) => print("expense", String(r.id), "print"),
},
],
}),
]}
/>
)
}

View File

@ -0,0 +1,34 @@
import { DashboardDetailsPage } from '@/base/components/layout/dashboard'
import { getServerApi } from '@garage/api/server'
import { PaymentMadeActions } from '@/modules/payment-mades/payment-made-actions'
import { BanknoteIcon } from 'lucide-react'
import React from 'react'
export default async function PaymentMadeDetailLayout(props: {
params: Promise<{ id: string }>
children: React.ReactNode
}) {
const { id } = await props.params
const api = await getServerApi()
const payment = await api.paymentMades.show(id)
const data = (payment as any)?.data ?? payment
const title = data?.payment_number || 'Payment Details'
return (
<DashboardDetailsPage
title={title}
description={data?.payment_number ? `Payment #: ${data.payment_number}` : undefined}
icon={<BanknoteIcon className="size-5" />}
backHref="/purchase/payments-made"
actions={<PaymentMadeActions paymentId={id} />}
tabs={[
{
href: `/purchase/payments-made/${id}`,
label: 'Details',
},
]}
>
{props.children}
</DashboardDetailsPage>
)
}

View File

@ -0,0 +1,119 @@
import { getServerApi } from '@garage/api/server'
import DashboardPage from '@/base/components/layout/dashboard/dashboard-page'
import {
BadgeDollarSignIcon,
BriefcaseIcon,
Building2Icon,
CalendarIcon,
CreditCardIcon,
HashIcon,
} from 'lucide-react'
export default async function PaymentMadeDetailPage(props: { params: Promise<{ id: string }> }) {
const { id } = await props.params
const api = await getServerApi()
const payment = await api.paymentMades.show(id)
const data = (payment as any)?.data ?? payment
if (!data) {
return <div className="text-muted-foreground">Payment not found.</div>
}
const amount = data.payment_made != null
? Number(data.payment_made).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
: '—'
const paymentDate = data.payment_date
? new Date(data.payment_date).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })
: '—'
const vendorLabel = data.vendor?.company_name
?? (data.vendor?.first_name ? `${data.vendor.first_name} ${data.vendor.last_name ?? ''}`.trim() : null)
?? data.vendor_name
?? (data.employee?.first_name ? `${data.employee.first_name} ${data.employee.last_name ?? ''}`.trim() : null)
?? data.employee_name
?? '—'
const paymentMode = data.payment_mode?.title ?? data.payment_mode?.name ?? data.payment_mode_name ?? '—'
return (
<DashboardPage header={null}>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div className="flex items-center gap-3 rounded-lg border p-4">
<HashIcon className="size-5 text-muted-foreground" />
<div>
<p className="text-xs text-muted-foreground">Payment Number</p>
<p className="font-medium">{data.payment_number || '—'}</p>
</div>
</div>
<div className="flex items-center gap-3 rounded-lg border p-4">
<Building2Icon className="size-5 text-muted-foreground" />
<div>
<p className="text-xs text-muted-foreground">Vendor / Employee</p>
<p className="font-medium">{vendorLabel}</p>
</div>
</div>
<div className="flex items-center gap-3 rounded-lg border p-4">
<BriefcaseIcon className="size-5 text-muted-foreground" />
<div>
<p className="text-xs text-muted-foreground">Payment For</p>
<p className="font-medium capitalize">{data.payment_for || '—'}</p>
</div>
</div>
<div className="flex items-center gap-3 rounded-lg border p-4">
<BadgeDollarSignIcon className="size-5 text-emerald-600" />
<div>
<p className="text-xs text-muted-foreground">Amount</p>
<p className="font-semibold text-emerald-700 dark:text-emerald-400">{amount}</p>
</div>
</div>
<div className="flex items-center gap-3 rounded-lg border p-4">
<CreditCardIcon className="size-5 text-muted-foreground" />
<div>
<p className="text-xs text-muted-foreground">Payment Mode</p>
<p className="font-medium capitalize">{paymentMode}</p>
</div>
</div>
<div className="flex items-center gap-3 rounded-lg border p-4">
<CalendarIcon className="size-5 text-muted-foreground" />
<div>
<p className="text-xs text-muted-foreground">Payment Date</p>
<p className="font-medium">{paymentDate}</p>
</div>
</div>
{data.payment_reference && (
<div className="flex items-center gap-3 rounded-lg border p-4">
<HashIcon className="size-5 text-muted-foreground" />
<div>
<p className="text-xs text-muted-foreground">Reference</p>
<p className="font-medium">{data.payment_reference}</p>
</div>
</div>
)}
{data.paid_through && (
<div className="flex items-center gap-3 rounded-lg border p-4">
<CreditCardIcon className="size-5 text-muted-foreground" />
<div>
<p className="text-xs text-muted-foreground">Paid Through</p>
<p className="font-medium">{data.paid_through}</p>
</div>
</div>
)}
{data.notes && (
<div className="sm:col-span-2 lg:col-span-3 rounded-lg border p-4">
<p className="text-xs text-muted-foreground mb-1">Notes</p>
<p className="text-sm">{data.notes}</p>
</div>
)}
</div>
</DashboardPage>
)
}

View File

@ -0,0 +1,423 @@
"use client"
import { useState, useRef } from "react"
import { useRouter } from "next/navigation"
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 { getFullName } from "@/shared/utils/getFullName"
import { PAYMENT_MADE_ROUTES } from "@garage/api"
import type { PaymentMadesClient } from "@garage/api"
import { RelationLink } from "@/shared/components/relation-link"
import { Building2, Printer } from "lucide-react"
import { useDocumentPrint } from "@/shared/hooks/use-document-print"
// ── 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?: { id?: number | string; company_name?: string | null; first_name?: string | null; last_name?: string | null; name?: string | null } | null
vendor_name?: string
employee?: { id?: number | string; first_name?: string | null; last_name?: string | null; name?: string | null } | null
employee_name?: string
payment_for?: string
payment_made?: string | number
payment_mode?: { name?: string | null; title?: string | null } | null
payment_mode_name?: string
payment_date?: string
paid_through?: string
notes?: string
created_at?: string
}
export default function PaymentsMadePage() {
const router = useRouter()
const { print, isPrinting } = useDocumentPrint()
const [attachmentTarget, setAttachmentTarget] = useState<{
id: string
ref: string
} | null>(null)
return (
<>
<ResourcePage<PaymentMadesClient>
pageTitle="Payments Made"
routeKey={PAYMENT_MADE_ROUTES.INDEX}
searchable
searchPlaceholder="Search payments..."
getClient={(api) => api.paymentMades}
onRowClick={(row) => router.push(`/purchase/payments-made/${(row as any).id}`)}
headerProps={({ selectedItem, invalidateQuery }) => ({
actions: (
<FormDialog title="Record Payment">
{(resourceId, { close }) => (
<PaymentMadeForm
resourceId={resourceId}
initialData={selectedItem}
onSuccess={() => {
invalidateQuery()
close()
}}
/>
)}
</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
const isVendor =
item.vendor?.company_name ||
getFullName(item.vendor) ||
item.vendor?.name ||
item.vendor_name
const label =
isVendor ||
getFullName(item.employee) ||
item.employee?.name ||
item.employee_name
const href = isVendor && item.vendor?.id
? `/purchase/vendor/${item.vendor.id}`
: item.employee?.id
? `/productivity/employees/${item.employee.id}`
: null
return (
<RelationLink
href={href}
icon={isVendor ? Building2 : UserIcon}
label={label}
/>
)
},
},
{
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
const label =
item.payment_mode?.name ||
item.payment_mode?.title ||
item.payment_mode_name ||
"—"
return (
<div className="flex items-center gap-2">
<CreditCardIcon className="h-4 w-4 text-muted-foreground" />
<span className="capitalize">{label}</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({
extraItems: (row) => [
{
label: isPrinting ? "Printing..." : "Print",
icon: Printer,
onClick: (r) => print("payment_made", String(r.id), "print"),
},
],
}),
]}
/>
{attachmentTarget && (
<AttachmentsDialog
open={!!attachmentTarget}
paymentId={attachmentTarget.id}
paymentRef={attachmentTarget.ref}
onClose={() => setAttachmentTarget(null)}
/>
)}
</>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -0,0 +1,100 @@
"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"
import { RelationLink } from "@/shared/components/relation-link"
import { Building2, Printer } from "lucide-react"
import { getFullName } from "@/shared/utils/getFullName"
import { useDocumentPrint } from "@/shared/hooks/use-document-print"
export default function PurchaseOrdersPage() {
const router = useRouter()
const { print, isPrinting } = useDocumentPrint()
return (
<ResourcePage<PurchaseOrdersClient>
pageTitle="Purchase Orders"
routeKey={PURCHASE_ORDER_ROUTES.INDEX}
searchable
searchPlaceholder="Search purchase orders..."
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 }) => {
const item = row.original as any
return (
<RelationLink
href={item.vendor?.id ? `/purchase/vendor/${item.vendor.id}` : null}
icon={Building2}
label={item.vendor?.company_name || getFullName(item.vendor) || item.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({
extraItems: (row) => [
{
label: isPrinting ? "Printing..." : "Print",
icon: Printer,
onClick: (r) => print("purchase_order", String(r.id), "print"),
},
],
}),
]}
/>
)
}

View File

@ -0,0 +1,90 @@
"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, VendorCreditStatus } from "@garage/api"
import type { VendorCreditsClient } from "@garage/api"
import { RelationLink } from "@/shared/components/relation-link"
import { Building2, FileTextIcon } from "lucide-react"
import { getFullName } from "@/shared/utils/getFullName"
import { formatEnum } from "@/shared/utils/formatters"
export default function VendorCreditsPage() {
return (
<ResourcePage<VendorCreditsClient>
pageTitle="Vendor Credits"
routeKey={VENDOR_CREDIT_ROUTES.INDEX}
searchable
searchPlaceholder="Search vendor credits..."
statusFilter={{ statuses: VendorCreditStatus }}
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 }) => {
const item = row.original as any
return (
<RelationLink
href={item.vendor?.id ? `/purchase/vendor/${item.vendor.id}` : null}
icon={Building2}
label={item.vendor?.company_name || getFullName(item.vendor) || item.vendor_name}
/>
)
},
},
{
accessorKey: "bill_number",
header: ({ column }) => <ColumnHeader column={column} title="Bill #" />,
cell: ({ row }) => {
const item = row.original as any
return (
<RelationLink
href={item.bill?.id ? `/purchase/bill/${item.bill.id}` : null}
icon={FileTextIcon}
label={item.bill?.bill_number || item.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"}>{formatEnum(status)}</Badge>
},
},
actionsColumn(),
]}
/>
)
}

View File

@ -0,0 +1,37 @@
import { DashboardDetailsPage } from "@/base/components/layout/dashboard"
import { getServerApi } from "@garage/api/server"
import { VendorActions } from "@/modules/vendors/vendor-actions"
import { VendorProvider } from "@/modules/vendors/vendor-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 response = (await api.vendors.getById(id)) as { data?: Record<string, any> }
const vendor = response?.data
const company = vendor?.company_name as string | undefined
const fullName = [vendor?.first_name, vendor?.last_name].filter(Boolean).join(" ").trim()
const title = company || fullName || "Vendor Details"
const isActive = vendor?.is_active ?? vendor?.status === "active"
return (
<VendorProvider vendor={{ id, label: title }}>
<DashboardDetailsPage
className="p-0 lg:p-0"
title={title}
description={vendor?.email ?? vendor?.phone ?? undefined}
backHref="/purchase/vendor"
actions={<VendorActions vendorId={id} isActive={Boolean(isActive)} />}
tabs={[
{ href: `/purchase/vendor/${id}`, label: "Details" },
]}
>
{props.children}
</DashboardDetailsPage>
</VendorProvider>
)
}

View File

@ -0,0 +1,17 @@
import { getServerApi } from "@garage/api/server"
import { VendorGeneralInfo } from "@/modules/vendors/vendor-general-info"
export default async function VendorDetailPage(props: { params: Promise<{ id: string }> }) {
const { id } = await props.params
const api = await getServerApi()
// The OpenAPI schema doesn't yet describe GET /api/vendors/{id} (only PUT/DELETE),
// so cast through `any` here until the schema is regenerated.
const response = (await api.vendors.getById(id)) as { data?: Record<string, any> }
const vendor = response?.data
if (!vendor) {
return <div className="text-muted-foreground p-6">Vendor not found.</div>
}
return <VendorGeneralInfo vendor={vendor} />
}

View File

@ -0,0 +1,102 @@
"use client"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { Power } from "lucide-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 { Badge } from "@/shared/components/ui/badge"
import { VendorForm } from "@/modules/vendors/vendor-form"
import { useAuthApi } from "@/shared/useApi"
import { VENDOR_ROUTES } from "@garage/api"
import type { VendorsClient } from "@garage/api"
export default function VendorsPage() {
const router = useRouter()
const api = useAuthApi()
return (
<ResourcePage<VendorsClient>
pageTitle="Vendors"
routeKey={VENDOR_ROUTES.INDEX}
searchable
searchPlaceholder="Search vendors..."
getClient={(api) => api.vendors}
onRowClick={(row) => router.push(`/purchase/vendor/${(row as any).id}`)}
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: "is_active",
header: ({ column }) => <ColumnHeader column={column} title="Status" />,
cell: ({ row }) => {
const isActive = Boolean((row.original as any).is_active)
return (
<Badge variant={isActive ? "default" : "outline"}>
{isActive ? "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({
extraItems: (row) => {
const isActive = Boolean((row as any).is_active)
return [
{
label: isActive ? "Deactivate" : "Activate",
icon: Power,
onClick: async () => {
try {
await api.vendors.toggleStatus({ id: Number((row as any).id) } as any)
toast.success(isActive ? "Vendor deactivated." : "Vendor activated.")
router.refresh()
} catch {
toast.error("Failed to update vendor status.")
}
},
},
]
},
}),
]}
/>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -0,0 +1,94 @@
"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, CreditNoteStatus } from "@garage/api"
import type { CreditNotesClient } from "@garage/api"
import { formatEnum } from "@/shared/utils/formatters"
import { Printer } from "lucide-react"
import { useDocumentPrint } from "@/shared/hooks/use-document-print"
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()
const { print, isPrinting } = useDocumentPrint()
return (
<ResourcePage<CreditNotesClient>
pageTitle="Credit Notes"
routeKey={CREDIT_NOTE_ROUTES.INDEX}
searchable
searchPlaceholder="Search credit notes..."
statusFilter={{ statuses: CreditNoteStatus }}
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 ?? ""] ?? ""}>
{formatEnum(status)}
</span>
)
},
},
{
accessorKey: "date",
header: ({ column }) => <ColumnHeader column={column} title="Date" />,
},
actionsColumn({
extraItems: (row) => [
{
label: isPrinting ? "Printing..." : "Print",
icon: Printer,
onClick: (r) => print("credit_note", String(r.id), "print"),
},
],
}),
]}
/>
)
}

View File

@ -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>
</>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -0,0 +1,74 @@
"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}
searchable
searchPlaceholder="Search vehicles..."
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(),
]}
/>
)
}

View File

@ -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 { 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}
searchable
searchPlaceholder="Search customers..."
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'>
<ImportDataButton
onImport={(file) => api.customers.importData(file)}
onSuccess={invalidateQuery}
entityLabel="Customers"
onDownloadSample={() => api.customers.downloadImportSample()}
sampleFileName='customers-import-sample'
/>
<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(),
]}
/>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -0,0 +1,126 @@
"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, Printer, UserIcon } from 'lucide-react'
import { useDocumentPrint } from '@/shared/hooks/use-document-print'
import Link from 'next/link'
import { formatDate } from '@/shared/utils/formatters'
import { getVehicleLabel } from '@/modules/vehicles/utils/getVehicleLabel'
import { getFullName } from '@/shared/utils/getFullName'
import { RelationLink } from '@/shared/components/relation-link'
export default function EstimatesPage() {
const router = useRouter()
const { print, isPrinting } = useDocumentPrint()
return (
<ResourcePage<EstimatesClient>
pageTitle="Estimates"
routeKey={ESTIMATE_ROUTES.INDEX}
searchable
searchPlaceholder="Search estimates..."
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
return (
<RelationLink
href={item.customer?.id ? `/sales/customers/${item.customer.id}` : null}
icon={UserIcon}
label={getFullName(item.customer)}
/>
)
},
},
{
accessorKey: "vehicle",
header: ({ column }) => <ColumnHeader column={column} title="Vehicle" />,
cell: ({ row }) => {
const item: any = row.original
return (
<RelationLink
href={item.vehicle?.id ? `/sales/vehicles/${item.vehicle.id}` : null}
icon={Car}
label={getVehicleLabel(item.vehicle as any)}
meta={item.vehicle?.license_plate}
/>
)
},
},
{
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({
extraItems: (row) => [
{
label: isPrinting ? "Printing..." : "Print",
icon: Printer,
onClick: (r) => print("estimate", String(r.id), "print"),
},
],
}),
]}
/>
)
}

View File

@ -0,0 +1,458 @@
"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { useParams } from "next/navigation"
import { toast } from "sonner"
import {
AlertTriangle,
Car,
ChevronLeft,
ChevronRight,
CircleCheck,
FileSignature,
Gauge,
Image as ImageIcon,
Loader2,
Share2,
StickyNote,
User,
} from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { Badge } from "@/shared/components/ui/badge"
import {
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card"
import { Progress } from "@/shared/components/ui/progress"
import { Separator } from "@/shared/components/ui/separator"
import { Tabs, TabsList, TabsTrigger } from "@/shared/components/ui/tabs"
import { useAuthApi } from "@/shared/useApi"
import {
CheckpointFillDialog,
isCheckpointComplete,
type Checkpoint,
type Severity,
} from "@/modules/inspections/checkpoint-fill-dialog"
import { SignaturePad } from "@/modules/inspections/signature-pad"
import { InspectionShareDialog } from "@/modules/inspections/inspection-share-dialog"
type Inspection = {
id: number
title: string
order_number?: string | null
status?: string | null
odometer?: number | null
template?: { id: number; name: string } | null
customer?: { first_name?: string; last_name?: string } | null
vehicle?: { make?: string; model?: string; year?: string | number; license_plate?: string } | null
check_points: Checkpoint[]
technician_signature_url?: string | null
customer_signature_url?: string | null
}
const SEVERITY_DOT: Record<Severity, string> = {
good: "bg-emerald-500 shadow-emerald-500/30",
attention: "bg-amber-500 shadow-amber-500/30",
critical: "bg-rose-500 shadow-rose-500/30",
na: "bg-slate-400 shadow-slate-400/30",
not_inspected: "bg-muted shadow-none",
}
const SEVERITY_LABEL: Record<Severity, string> = {
good: "Good",
attention: "Attention",
critical: "Critical",
na: "N/A",
not_inspected: "Not inspected",
}
const SEVERITY_BADGE: Record<Severity, "default" | "secondary" | "destructive" | "outline"> = {
good: "default",
attention: "secondary",
critical: "destructive",
na: "outline",
not_inspected: "outline",
}
function groupBySection(cps: Checkpoint[]) {
const map = new Map<string, Checkpoint[]>()
for (const cp of cps) {
const key = cp.section_name || "Other"
if (!map.has(key)) map.set(key, [])
map.get(key)!.push(cp)
}
return Array.from(map.entries()).map(([name, items]) => ({ name, items }))
}
export default function InspectionCheckpointsPage() {
const params = useParams<{ id: string }>()
const id = params.id
const api = useAuthApi()
const [data, setData] = useState<Inspection | null>(null)
const [initialLoad, setInitialLoad] = useState(true)
const [refetching, setRefetching] = useState(false)
const [activeIndex, setActiveIndex] = useState<number | null>(null)
const [shareOpen, setShareOpen] = useState(false)
const [mode, setMode] = useState<"all" | "wizard">("all")
const [wizardSectionIdx, setWizardSectionIdx] = useState(0)
const [signing, setSigning] = useState<{ who: "technician" | "customer" } | null>(null)
const load = useCallback(async () => {
const isFirst = !data
if (isFirst) setInitialLoad(true)
else setRefetching(true)
try {
const res = await api.inspections.showOne(id)
setData(res.data as Inspection)
} catch (e: any) {
toast.error(e?.payload?.message ?? "Failed to load inspection")
} finally {
setInitialLoad(false)
setRefetching(false)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id])
const patchCheckpoint = useCallback((checkpointId: number, patch: Partial<Checkpoint>) => {
setData((prev) => {
if (!prev) return prev
return {
...prev,
check_points: prev.check_points.map((c) =>
c.id === checkpointId ? { ...c, ...patch } : c
),
}
})
}, [])
useEffect(() => { load() }, [load])
const sections = useMemo(() => data ? groupBySection(data.check_points ?? []) : [], [data])
const flatCheckpoints = useMemo(() => sections.flatMap((s) => s.items), [sections])
const totals = useMemo(() => {
const t = { good: 0, attention: 0, critical: 0, na: 0, not_inspected: 0 }
for (const cp of data?.check_points ?? []) {
const s = (cp.severity as Severity) ?? "not_inspected"
t[s]++
}
return t
}, [data])
const setSeverity = async (cp: Checkpoint, severity: Severity) => {
patchCheckpoint(cp.id, { severity })
try {
await api.inspections.updateCheckpoint(String(cp.id), { severity } as any)
} catch (e: any) {
toast.error(e?.payload?.message ?? "Failed to save")
load()
}
}
const handleSign = async (who: "technician" | "customer", dataUrl: string) => {
setSigning({ who })
try {
await api.inspections.sign(id, who, dataUrl)
toast.success(who === "technician" ? "Technician signature saved" : "Customer signature saved")
load()
} catch (e: any) {
toast.error(e?.payload?.message ?? "Failed to save signature")
} finally {
setSigning(null)
}
}
if (initialLoad) {
return (
<div className="min-h-[60vh] flex items-center justify-center">
<div className="flex items-center gap-2 text-muted-foreground text-sm">
<Loader2 className="size-4 animate-spin" /> Loading inspection
</div>
</div>
)
}
if (!data) return <div className="p-6 text-muted-foreground">Inspection not found</div>
const vehicleLine = data.vehicle
? [data.vehicle.year, data.vehicle.make, data.vehicle.model].filter(Boolean).join(" ")
: "—"
const customerName = data.customer ? `${data.customer.first_name ?? ""} ${data.customer.last_name ?? ""}`.trim() : "—"
const totalCount = data.check_points.length
const inspectedCount = totalCount - totals.not_inspected
const progress = totalCount > 0 ? Math.round((inspectedCount / totalCount) * 100) : 0
const wizardSection = sections[wizardSectionIdx]
return (
<div className="max-w-5xl mx-auto p-3 sm:p-6 space-y-4">
{/* Header */}
<Card>
<CardHeader className="gap-3">
<div className="flex items-center justify-between gap-2 flex-wrap">
<Link href={`/sales/inspections/${id}`}>
<Button variant="ghost" size="sm" className="-ms-2">
<ChevronLeft className="size-4" /> Back to inspection
</Button>
</Link>
<div className="flex items-center gap-2">
{refetching && <Loader2 className="size-3.5 animate-spin text-muted-foreground" />}
<Button variant="outline" size="sm" onClick={() => setShareOpen(true)}>
<Share2 className="size-4" /> Share with customer
</Button>
</div>
</div>
<Separator />
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="min-w-0">
<CardDescription className="uppercase tracking-wide text-[11px]">Inspection</CardDescription>
<CardTitle className="text-2xl">{data.title}</CardTitle>
<div className="flex items-center gap-4 mt-2 text-sm text-muted-foreground flex-wrap">
<span className="inline-flex items-center gap-1.5"><User className="size-3.5" /> {customerName}</span>
<span className="inline-flex items-center gap-1.5"><Car className="size-3.5" /> {vehicleLine}</span>
{data.vehicle?.license_plate && (
<Badge variant="outline" className="font-mono">{data.vehicle.license_plate}</Badge>
)}
{data.odometer != null && (
<span className="inline-flex items-center gap-1.5"><Gauge className="size-3.5" /> {Number(data.odometer).toLocaleString()} km</span>
)}
</div>
</div>
</div>
</CardHeader>
<CardContent className="gap-3">
{/* Progress */}
<div className="space-y-1.5">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>Progress</span>
<span className="tabular-nums">{inspectedCount} / {totalCount} ({progress}%)</span>
</div>
<Progress value={progress} className="h-2" />
</div>
{/* Severity tally */}
<div className="flex items-center gap-2 flex-wrap mt-3">
{(["good", "attention", "critical", "na", "not_inspected"] as Severity[]).map((s) => (
<Badge key={s} variant={SEVERITY_BADGE[s]} className="gap-1.5">
<span className={`size-1.5 rounded-full ${SEVERITY_DOT[s].split(" ")[0]}`} />
{totals[s]} {SEVERITY_LABEL[s]}
</Badge>
))}
</div>
{/* Legend */}
<div className="mt-3 rounded-md border bg-muted/30 px-3 py-2 text-xs text-muted-foreground flex items-center gap-3 flex-wrap">
<span className="font-medium text-foreground">Tap a dot to mark:</span>
{(["good", "attention", "critical", "na"] as Severity[]).map((s) => (
<span key={s} className="inline-flex items-center gap-1.5">
<span className={`size-3 rounded-full ${SEVERITY_DOT[s].split(" ")[0]} shadow-md`} />
{SEVERITY_LABEL[s]}
</span>
))}
</div>
</CardContent>
</Card>
{/* Mode tabs */}
{totalCount > 0 && (
<Tabs value={mode} onValueChange={(v) => setMode(v as any)} className="w-full">
<TabsList className="w-full sm:w-auto">
<TabsTrigger value="all">All sections</TabsTrigger>
<TabsTrigger value="wizard">Wizard</TabsTrigger>
</TabsList>
</Tabs>
)}
{/* Sections */}
{totalCount === 0 ? (
<Card>
<CardContent className="py-10 text-center">
<CircleCheck className="size-8 mx-auto text-muted-foreground/60 mb-3" />
<p className="text-sm text-muted-foreground">
This inspection has no checkpoints yet. It was probably created from an empty template.
</p>
</CardContent>
</Card>
) : mode === "all" ? (
sections.map((section) => (
<Card key={section.name}>
<CardHeader className="py-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base">{section.name}</CardTitle>
<Badge variant="outline" className="font-normal">
{section.items.filter(isCheckpointComplete).length} / {section.items.length} done
</Badge>
</div>
</CardHeader>
<CardContent className="p-0">
<ul className="divide-y">
{section.items.map((cp) => (
<CheckpointRow key={cp.id} cp={cp} onSeverity={setSeverity} onOpen={() => setActiveIndex(flatCheckpoints.findIndex((c) => c.id === cp.id))} />
))}
</ul>
</CardContent>
</Card>
))
) : (
wizardSection && (() => {
const pendingItems = wizardSection.items.filter((cp) => !isCheckpointComplete(cp))
const sectionDone = pendingItems.length === 0
return (
<Card>
<CardHeader className="py-3 gap-1">
<div className="flex items-center justify-between flex-wrap gap-2">
<CardTitle className="text-base">{wizardSection.name}</CardTitle>
<Badge variant="outline" className="font-normal">
Section {wizardSectionIdx + 1} / {sections.length}
</Badge>
</div>
</CardHeader>
<CardContent className="p-0">
<ul className="divide-y">
{wizardSection.items.map((cp) => (
<CheckpointRow key={cp.id} cp={cp} onSeverity={setSeverity} onOpen={() => setActiveIndex(flatCheckpoints.findIndex((c) => c.id === cp.id))} />
))}
</ul>
</CardContent>
<div className="px-4 py-3 border-t bg-muted/30 space-y-2">
{!sectionDone && (
<div className="flex items-start gap-2 text-xs text-amber-900">
<AlertTriangle className="size-3.5 mt-0.5 shrink-0 text-amber-600" />
<div>
<span className="font-medium">{pendingItems.length} pending.</span>{' '}
Each checkpoint needs a finding and its required recording before you can move on.
</div>
</div>
)}
<div className="flex items-center justify-between">
<Button
variant="outline"
size="sm"
disabled={wizardSectionIdx === 0}
onClick={() => setWizardSectionIdx((i) => Math.max(0, i - 1))}
>
<ChevronLeft className="size-4" /> Previous
</Button>
<Button
size="sm"
disabled={wizardSectionIdx >= sections.length - 1 || !sectionDone}
onClick={() => setWizardSectionIdx((i) => Math.min(sections.length - 1, i + 1))}
>
Next <ChevronRight className="size-4" />
</Button>
</div>
</div>
</Card>
)
})()
)}
{/* Signatures */}
{totalCount > 0 && (
<Card>
<CardHeader className="py-3 flex-row items-center gap-2">
<FileSignature className="size-4 text-muted-foreground" />
<CardTitle className="text-base">Sign-off</CardTitle>
</CardHeader>
<CardContent className="gap-3 grid grid-cols-1 md:grid-cols-2">
<SignaturePad
label="Technician signature"
existingUrl={data.technician_signature_url}
saving={signing?.who === "technician"}
onSave={(d) => handleSign("technician", d)}
/>
<SignaturePad
label="Customer signature"
existingUrl={data.customer_signature_url}
saving={signing?.who === "customer"}
onSave={(d) => handleSign("customer", d)}
/>
</CardContent>
</Card>
)}
<CheckpointFillDialog
open={activeIndex !== null}
onOpenChange={(o) => { if (!o) setActiveIndex(null) }}
checkpoints={flatCheckpoints}
activeIndex={activeIndex}
onIndexChange={setActiveIndex}
onSaved={load}
onPatch={patchCheckpoint}
/>
<InspectionShareDialog
open={shareOpen}
onOpenChange={setShareOpen}
inspectionId={id}
/>
</div>
)
}
function CheckpointRow({
cp,
onSeverity,
onOpen,
}: {
cp: Checkpoint
onSeverity: (cp: Checkpoint, s: Severity) => void
onOpen: () => void
}) {
const current = (cp.severity as Severity) ?? "not_inspected"
const attachments = cp.attachments ?? cp.media ?? []
const photoCount = attachments.filter((m) => m.media_type === "photo").length
const complete = isCheckpointComplete(cp)
return (
<li
className={`px-3 sm:px-4 py-3 transition-colors hover:bg-muted/30 ${!complete ? "bg-amber-50/40" : ""}`}
>
<div className="flex items-start gap-3 flex-wrap sm:flex-nowrap">
<button
type="button"
onClick={onOpen}
className="flex-1 min-w-0 text-left group"
>
<div className="font-medium text-sm flex items-center gap-2">
<span className="group-hover:text-primary transition">{cp.name}</span>
{!complete && (
<Badge variant="secondary" className="text-[10px] uppercase tracking-wide">
Pending
</Badge>
)}
</div>
{cp.technician_notes && (
<div className="flex items-start gap-1 text-xs text-muted-foreground mt-1">
<StickyNote className="size-3 mt-0.5 shrink-0" />
<span className="italic line-clamp-1">{cp.technician_notes}</span>
</div>
)}
{photoCount > 0 && (
<div className="flex items-center gap-1 mt-1 text-xs text-muted-foreground">
<ImageIcon className="size-3" /> {photoCount} photo{photoCount === 1 ? "" : "s"}
</div>
)}
</button>
<div className="flex gap-2 justify-end shrink-0 w-full sm:w-auto">
{(["good", "attention", "critical", "na"] as Severity[]).map((s) => {
const active = current === s
return (
<button
key={s}
type="button"
onClick={(e) => { e.stopPropagation(); onSeverity(cp, s) }}
aria-label={SEVERITY_LABEL[s]}
title={SEVERITY_LABEL[s]}
className={`size-10 sm:size-9 rounded-full transition shadow-md ${SEVERITY_DOT[s]} ${active ? "ring-2 ring-offset-2 ring-foreground/40 scale-105" : "opacity-60 hover:opacity-100"}`}
/>
)
})}
</div>
</div>
</li>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

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