Compare commits

...

No commits in common. "main" and "master" have entirely different histories.
main ... master

257 changed files with 14910 additions and 96198 deletions

1
.env Normal file
View File

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

View File

@ -1,182 +0,0 @@
---
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
### 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 (Foreign Keys)
- 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()`
- Rendered with `<RhfAsyncSelectField>` (fetches options via React Query)
### 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-select combobox |
| `RhfAsyncMultiSelectField` | Server-fetched multi-select combobox |
### 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 '@repo/api'
import { <RESOURCE>_ROUTES } from '@repo/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

@ -1,140 +0,0 @@
# 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

@ -1,234 +0,0 @@
# 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 "@repo/api"
// ── Constants ──
// Static select options (if needed):
// const STATUS_OPTIONS = [
// { value: "active", label: "Active" },
// { value: "inactive", label: "Inactive" },
// ]
// ── 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

@ -1,225 +0,0 @@
# 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 '@repo/api'
import type { <Resource>Client } from '@repo/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 '@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}
/>
)}
/>
)
}
```
## 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 '@repo/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

@ -1,143 +0,0 @@
# 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 }
```

59
.gitignore vendored
View File

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

7
.prettierignore Normal file
View File

@ -0,0 +1,7 @@
dist/
node_modules/
.next/
.turbo/
coverage/
pnpm-lock.yaml
.pnpm-store/

11
.prettierrc Normal file
View File

@ -0,0 +1,11 @@
{
"endOfLine": "lf",
"semi": false,
"singleQuote": false,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 80,
"plugins": ["prettier-plugin-tailwindcss"],
"tailwindStylesheet": "app/globals.css",
"tailwindFunctions": ["cn", "cva"]
}

49
.turbo/turbo-build.log Normal file
View File

@ -0,0 +1,49 @@
[?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

Binary file not shown.

160
README.md
View File

@ -1,159 +1,21 @@
# Turborepo starter # Next.js template
This Turborepo starter is maintained by the Turborepo core team. This is a Next.js template with shadcn/ui.
## Using this example ## Adding components
Run the following command: To add components to your app, run the following command:
```sh ```bash
npx create-turbo@latest npx shadcn@latest add button
``` ```
## What's inside? This will place the ui components in the `components` directory.
This Turborepo includes the following packages/apps: et ## Using components
### Apps and Packages To use the components in your app, import them as follows:
- `docs`: a [Next.js](https://nextjs.org/) app ```tsx
- `web`: another [Next.js](https://nextjs.org/) app import { Button } from "@/components/ui/button";
- `@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)

12
app/(auth)/login/page.tsx Normal file
View File

@ -0,0 +1,12 @@
import { LoginForm } from "@/modules/auth/login-form";
export default function Page() {
return (
<div className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
<div className="w-full max-w-sm">
<LoginForm />
</div>
</div>
)
}

View File

@ -0,0 +1,90 @@
"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

@ -0,0 +1,72 @@
"use client"
import { ResourcePage } from "@/shared/data-view/resource-page"
import { ColumnHeader } from "@/shared/data-view/table-view"
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"
export default function ServiceGroupPage() {
return (
<ResourcePage<ServiceGroupsClient>
pageTitle="Service Groups"
title="Service Group"
routeKey={SERVICE_GROUP_ROUTES.INDEX}
getClient={(api) => api.serviceGroups}
columns={({ actionsColumn }) => [
{
accessorKey: "name",
header: ({ column }) => <ColumnHeader column={column} title="Name" />,
cell: ({ row }) => {
const r = row.original as any
return (
<div>
<span className="font-medium">{r.service_name || r.name || "—"}</span>
{r.code && (
<span className="ml-2 text-xs text-muted-foreground">{r.code}</span>
)}
</div>
)
},
},
{
accessorKey: "selling_price",
header: ({ column }) => <ColumnHeader column={column} title="Price" />,
cell: ({ row }) => {
const val = (row.original as any).selling_price
return val != null ? `$${Number(val).toFixed(2)}` : "—"
},
},
{
accessorKey: "is_active",
header: ({ column }) => <ColumnHeader column={column} title="Status" />,
cell: ({ row }) => {
const active = (row.original as any).is_active
return (
<Badge variant={active ? "default" : "secondary"}>
{active ? "Active" : "Inactive"}
</Badge>
)
},
},
{
accessorKey: "created_at",
header: ({ column }) => <ColumnHeader column={column} title="Created" />,
cell: ({ row }) => {
const val = (row.original as any).created_at
return val ? new Date(val).toLocaleDateString() : "—"
},
},
actionsColumn(),
]}
renderForm={({ resourceId, initialData, onSuccess }) => (
<ServiceGroupForm
resourceId={resourceId}
initialData={initialData}
onSuccess={onSuccess}
/>
)}
/>
)
}

View File

@ -0,0 +1,69 @@
"use client"
import { ResourcePage } from "@/shared/data-view/resource-page"
import { ColumnHeader } from "@/shared/data-view/table-view"
import { ServiceForm } from "@/modules/services/service-form"
import { SERVICE_ROUTES } from "@repo/api"
import type { ServicesClient } from "@repo/api"
export default function ServicesPage() {
return (
<ResourcePage<ServicesClient>
pageTitle="Services"
title="Service"
routeKey={SERVICE_ROUTES.INDEX}
getClient={(api) => api.services}
columns={({ actionsColumn }) => [
{
accessorKey: "labor_name",
header: ({ column }) => <ColumnHeader column={column} title="Name" />,
cell: ({ row }) => {
const r = row.original as any
return (
<div>
<span className="font-medium">{r.labor_name || r.name || "—"}</span>
{r.service_code && (
<span className="ml-2 text-xs text-muted-foreground">{r.service_code}</span>
)}
</div>
)
},
},
{
accessorKey: "description",
header: ({ column }) => <ColumnHeader column={column} title="Description" />,
cell: ({ row }) => {
const val = (row.original as any).description
return val
? <span className="max-w-[200px] truncate block">{val}</span>
: "—"
},
},
{
accessorKey: "selling_price",
header: ({ column }) => <ColumnHeader column={column} title="Price" />,
cell: ({ row }) => {
const val = (row.original as any).selling_price
return val != null ? `$${Number(val).toFixed(2)}` : "—"
},
},
{
accessorKey: "created_at",
header: ({ column }) => <ColumnHeader column={column} title="Created" />,
cell: ({ row }) => {
const val = (row.original as any).created_at
return val ? new Date(val).toLocaleDateString() : "—"
},
},
actionsColumn(),
]}
renderForm={({ resourceId, initialData, onSuccess }) => (
<ServiceForm
resourceId={resourceId}
initialData={initialData}
onSuccess={onSuccess}
/>
)}
/>
)
}

View File

@ -0,0 +1,219 @@
"use client"
import { Suspense } from "react"
import type { NavGroup } from "@/base/types/navigation"
import {
AlarmClockIcon,
AwardIcon,
BanknoteArrowDownIcon,
BarChart3Icon,
BellRingIcon,
BookIcon,
BriefcaseBusinessIcon,
Building2Icon,
CalendarCheck2Icon,
CalendarDaysIcon,
LayoutDashboardIcon,
ClipboardListIcon,
UsersIcon,
CalendarIcon,
CarIcon,
ClipboardCheckIcon,
Clock3Icon,
ClockIcon,
GemIcon,
GitBranchIcon,
HandCoinsIcon,
ListIcon,
ListTodoIcon,
MegaphoneIcon,
PackageIcon,
PhoneCallIcon,
PlugZapIcon,
ReceiptIcon,
ReceiptTextIcon,
SettingsIcon,
ShoppingBasketIcon,
CircleDollarSign,
StarIcon,
StoreIcon,
TimerIcon,
UserCogIcon,
WalletIcon,
WrenchIcon,
ShoppingCartIcon,
} from "lucide-react"
import Image from "next/image"
import { DashboardLayout } from "@/base/components/layout/dashboard"
import { useAuth } from "@/shared/hooks/use-auth"
const navGroups: NavGroup[] = [
{
items: [
{
title: "Dashboard",
href: "/",
icon: <LayoutDashboardIcon />,
},
{
title: "Job Cards",
href: "/sales/workorder/list",
icon: <ClipboardListIcon />,
},
{
title: "Customer & Vehicles",
href: "/customer-vehicles",
icon: <UsersIcon />,
},
{
title: "Reports",
href: "/reports",
icon: <BarChart3Icon />,
},
],
},
{
label: "Management",
items: [
{
title: "Calendars",
href: "/calendars",
icon: <CalendarIcon />,
items: [
{ title: "Work Schedule", href: "/calendar/work-schedule/list", icon: <Clock3Icon /> },
{ title: "Appointments", href: "/calendar/appointment/list", icon: <CalendarCheck2Icon /> },
],
},
{
title: "Sales",
href: "/sales",
icon: <CircleDollarSign />,
items: [
{ title: "Customers", href: "/sales/customers", icon: <UsersIcon /> },
{ title: "Vehicles", href: "/sales/vehicles", icon: <CarIcon /> },
{ title: "Inspections", href: "/sales/inspections", icon: <ClipboardCheckIcon /> },
{ title: "Estimates", href: "/sales/estimate", icon: <ReceiptTextIcon /> },
{ title: "Job Cards", href: "/sales/workorder/list", icon: <ClipboardListIcon /> },
{ title: "Invoices", href: "/sales/invoice", icon: <ReceiptIcon /> },
{ title: "Payments Received", href: "/sales/payment-received", icon: <HandCoinsIcon /> },
{ title: "Credit Notes", href: "/sales/credit-notes", icon: <ReceiptTextIcon /> },
],
},
{
title: "Purchases",
href: "/purchases",
icon: <ShoppingCartIcon />,
items: [
{ title: "Vendors", href: "/purchase/vendor", icon: <StoreIcon /> },
{ title: "Expenses", href: "/purchase/expense", icon: <WalletIcon /> },
{ title: "Purchase Orders", href: "/purchase/purchase-order", icon: <ShoppingBasketIcon /> },
{ title: "Bills", href: "/purchase/bill", icon: <ReceiptIcon /> },
{ title: "Payments Made", href: "/purchase/payments-made", icon: <BanknoteArrowDownIcon /> },
{ title: "Vendor Credits", href: "/purchase/vendor-credit", icon: <ReceiptTextIcon /> },
],
},
{
title: "CRM",
href: "/crm",
icon: <BriefcaseBusinessIcon />,
items: [
{ title: "Leads", href: "/crm/leads/list", icon: <GemIcon /> },
{ title: "Calls", href: "/crm/calls-follow-up/list", icon: <PhoneCallIcon /> },
{ title: "Tasks", href: "/crm/tasks/list", icon: <ListTodoIcon /> },
],
},
{
title: "Marketing",
href: "/marketing",
icon: <MegaphoneIcon />,
items: [
{ title: "Service Reminders", href: "/marketing/service-reminder/list", icon: <AlarmClockIcon /> },
{ title: "Rating & Reviews", href: "/marketing/rating-review", icon: <StarIcon /> },
{ title: "Google Business Reviews", href: "/marketing/google-rating-review", icon: <AwardIcon /> },
],
},
{
title: "Accountants",
href: "/accountants",
icon: <BookIcon />,
items: [
{ title: "Manual Journals", href: "/accountants/manual-journal", icon: <BookIcon /> },
{ title: "Chart of Accounts", href: "/accountants/chart-of-account", icon: <GitBranchIcon /> },
],
},
{
title: "Employees",
href: "/productivity",
icon: <UserCogIcon />,
items: [
{ title: "Employees", href: "/productivity/employees", icon: <UsersIcon /> },
{ title: "Time Clocks", href: "/productivity/time-clocks", icon: <TimerIcon /> },
{ title: "Time Sheets", href: "/productivity/timesheet", icon: <ClockIcon /> },
{ title: "Payroll", href: "/productivity/payroll", icon: <WalletIcon /> },
{ title: "Payments Made", href: "/productivity/employee-payments-made", icon: <HandCoinsIcon /> },
{ title: "Shop Calendars", href: "/productivity/shop-calendars", icon: <CalendarDaysIcon /> },
{ title: "Shop Timing", href: "/productivity/shop-timings", icon: <Clock3Icon /> },
{ title: "Holidays", href: "/productivity/holidays", icon: <CalendarIcon /> },
],
},
{
title: "Items",
href: "/items",
icon: <PackageIcon />,
items: [
{ title: "Services", href: "/items/services", icon: <WrenchIcon /> },
{ title: "Parts", href: "/items/parts", icon: <WrenchIcon /> },
{ title: "Expense Item", href: "/items/expense-item", icon: <WalletIcon /> },
{ title: "Service Group", href: "/items/service-group", icon: <PackageIcon /> },
{ title: "Inspections", href: "/items/inspection", icon: <ClipboardCheckIcon /> },
{ title: "Inventory Adjustments", href: "/items/adjustment", icon: <ListIcon /> },
],
},
{
title: "Settings",
href: "/setting",
icon: <SettingsIcon />,
items: [
{ title: "Company", href: "/setting/company", icon: <Building2Icon /> },
{ title: "Shop Types", href: "/setting/shop-type", icon: <CarIcon /> },
{ title: "Tax & Rates", href: "/setting/tax-rates", icon: <ReceiptTextIcon /> },
{ title: "Configurations", href: "/setting/configurations/preferences/sales", icon: <SettingsIcon /> },
{ title: "Templates", href: "/setting/templates", icon: <ClipboardListIcon /> },
{ title: "Integrations", href: "/setting/integrations/providers", icon: <PlugZapIcon /> },
{ title: "Master", href: "/setting/master/body-type", icon: <ListIcon /> },
],
},
],
},
]
function Logo() {
return (
<div className="flex items-center gap-2">
<Image alt="Logo" src={'/assets/logo.png'} height={200} width={200}/>
</div>
)
}
export default function AuthenticatedLayout({
children,
}: {
children: React.ReactNode
}) {
const { user } = useAuth()
const userInfo = user
? {
name: user.name,
email: user.email,
initials: user.name.charAt(0).toUpperCase(),
}
: undefined
return (
<DashboardLayout navGroups={navGroups} logo={<Logo />} user={userInfo}>
<Suspense>{children}</Suspense>
</DashboardLayout>
)
}

View File

@ -0,0 +1,14 @@
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

@ -0,0 +1,65 @@
"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

@ -0,0 +1,50 @@
"use client"
import { ResourcePage } from "@/shared/data-view/resource-page"
import { ColumnHeader } from "@/shared/data-view/table-view"
import { ShopCalendarForm } from "@/modules/shop-calendars/shop-calendar-form"
import { SHOP_CALENDAR_ROUTES } from "@repo/api"
import type { ShopCalendarsClient } from "@repo/api"
import { CheckCircle2Icon } from "lucide-react"
export default function ShopCalendarsPage() {
return (
<ResourcePage<ShopCalendarsClient>
pageTitle="Shop Calendars"
title="Shop Calendar"
routeKey={SHOP_CALENDAR_ROUTES.INDEX}
getClient={(api) => api.shopCalendars}
columns={({ actionsColumn }) => [
{
accessorKey: "title",
header: ({ column }) => <ColumnHeader column={column} title="Title" />,
},
{
accessorKey: "is_default",
header: ({ column }) => <ColumnHeader column={column} title="Default" />,
cell: ({ row }) =>
(row.original as any).is_default ? (
<CheckCircle2Icon className="text-green-600 h-5 w-5" />
) : null,
},
{
accessorKey: "shop_calender_days",
header: () => <span>Days</span>,
enableSorting: false,
cell: ({ row }) => {
const days = (row.original as any).shop_calender_days
return days?.length ?? 0
},
},
actionsColumn({ onEdit: undefined }),
]}
renderForm={({ resourceId, initialData, onSuccess }) => (
<ShopCalendarForm
resourceId={resourceId}
initialData={initialData}
onSuccess={onSuccess}
/>
)}
/>
)
}

View File

@ -0,0 +1,57 @@
"use client"
import { ResourcePage } from "@/shared/data-view/resource-page"
import { ColumnHeader } from "@/shared/data-view/table-view"
import { ShopTimingForm } from "@/modules/shop-timings/shop-timing-form"
import { SHOP_TIMING_ROUTES } from "@repo/api"
import type { ShopTimingsClient } from "@repo/api"
import { CheckCircle2Icon } from "lucide-react"
export default function ShopTimingsPage() {
return (
<ResourcePage<ShopTimingsClient>
pageTitle="Shop Timings"
title="Shop Timing"
routeKey={SHOP_TIMING_ROUTES.INDEX}
getClient={(api) => api.shopTimings}
columns={({ actionsColumn }) => [
{
accessorKey: "title",
header: ({ column }) => <ColumnHeader column={column} title="Title" />,
},
{
accessorKey: "in_time",
header: ({ column }) => <ColumnHeader column={column} title="In Time" />,
},
{
accessorKey: "out_time",
header: ({ column }) => <ColumnHeader column={column} title="Out Time" />,
},
{
accessorKey: "full_day_hours",
header: ({ column }) => <ColumnHeader column={column} title="Full Day Hours" />,
},
{
accessorKey: "half_day_hours",
header: ({ column }) => <ColumnHeader column={column} title="Half Day Hours" />,
},
{
accessorKey: "is_default",
header: ({ column }) => <ColumnHeader column={column} title="Default" />,
cell: ({ row }) =>
row.original.is_default ? (
<CheckCircle2Icon className="text-green-600 h-5 w-5" />
) : null,
},
actionsColumn(),
]}
renderForm={({ resourceId, initialData, onSuccess }) => (
<ShopTimingForm
resourceId={resourceId}
initialData={initialData}
onSuccess={onSuccess}
/>
)}
/>
)
}

View File

@ -0,0 +1,54 @@
"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

@ -0,0 +1,65 @@
"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}
/>
)}
/>
)
}

View File

@ -0,0 +1,98 @@
"use client"
import { ResourcePage } from '@/shared/data-view/resource-page'
import { ColumnHeader } from '@/shared/data-view/table-view'
import { VehicleForm } from '@/modules/vehicles/vehicle-form'
import { VEHICLE_ROUTES } from '@repo/api'
import type { VehiclesClient } from '@repo/api'
import { CarIcon } from 'lucide-react'
export default function VehiclesPage() {
return (
<ResourcePage<VehiclesClient>
pageTitle="Vehicles"
title="Vehicle"
routeKey={VEHICLE_ROUTES.INDEX}
getClient={(api) => api.vehicles}
columns={({ actionsColumn }) => [
{
accessorKey: "name",
header: ({ column }) => <ColumnHeader column={column} title="Vehicle" />,
cell: ({ row }) => {
const r = row.original as any
const make = r.make ?? ""
const model = r.model ?? ""
const display = r.name || `${make} ${model}`.trim() || "—"
return (
<div className="flex items-center gap-2">
<CarIcon className="h-4 w-4 text-muted-foreground" />
<div>
<span className="font-medium">{display}</span>
{r.sub_model && (
<span className="ml-1 text-xs text-muted-foreground">{r.sub_model}</span>
)}
</div>
</div>
)
},
},
{
accessorKey: "year",
header: ({ column }) => <ColumnHeader column={column} title="Year" />,
cell: ({ row }) => (row.original as any).year ?? "—",
},
{
accessorKey: "license_plate",
header: ({ column }) => <ColumnHeader column={column} title="License Plate" />,
cell: ({ row }) => {
const val = (row.original as any).license_plate
return val
? <span className="font-mono text-xs">{val}</span>
: "—"
},
},
{
accessorKey: "vin_number",
header: ({ column }) => <ColumnHeader column={column} title="VIN" />,
cell: ({ row }) => {
const val = (row.original as any).vin_number
return val
? <span className="max-w-30 truncate block font-mono text-xs">{val}</span>
: "—"
},
},
{
accessorKey: "engine_size",
header: ({ column }) => <ColumnHeader column={column} title="Engine" />,
cell: ({ row }) => (row.original as any).engine_size ?? "—",
},
{
accessorKey: "mileage",
header: ({ column }) => <ColumnHeader column={column} title="Mileage" />,
cell: ({ row }) => {
const val = (row.original as any).mileage
return val != null ? `${Number(val).toLocaleString()} mi` : "—"
},
},
{
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 }) => (
<VehicleForm
resourceId={resourceId}
initialData={initialData}
onSuccess={onSuccess}
/>
)}
/>
)
}

View File

@ -0,0 +1,54 @@
"use client"
import { ResourcePage } from "@/shared/data-view/resource-page"
import { ColumnHeader } from "@/shared/data-view/table-view"
import { ShopTypeForm } from "@/modules/settings/shop-type/shop-type-form"
import { SHOP_TYPE_ROUTES } from "@repo/api"
import type { ShopTypesClient } from "@repo/api"
import { CheckIcon, XIcon } from "lucide-react"
export default function ShopTypesPage() {
return (
<ResourcePage<ShopTypesClient>
pageTitle="Shop Types"
title="Shop Type"
routeKey={SHOP_TYPE_ROUTES.INDEX}
getClient={(api) => api.shopTypes}
columns={({ actionsColumn }) => [
{
accessorKey: "title",
header: ({ column }) => <ColumnHeader column={column} title="Title" />,
},
{
accessorKey: "shop_type",
header: ({ column }) => <ColumnHeader column={column} title="Type" />,
},
{
accessorKey: "note",
header: ({ column }) => <ColumnHeader column={column} title="Note" />,
cell: ({ row }) => (
<span className="text-muted-foreground line-clamp-1">
{(row.original as any).note ?? "—"}
</span>
),
},
{
accessorKey: "is_default",
header: ({ column }) => <ColumnHeader column={column} title="Default" />,
cell: ({ row }) =>
(row.original as any).is_default
? <CheckIcon className="h-4 w-4 text-green-600" />
: <XIcon className="h-4 w-4 text-muted-foreground" />,
},
actionsColumn(),
]}
renderForm={({ resourceId, initialData, onSuccess }) => (
<ShopTypeForm
resourceId={resourceId}
initialData={initialData}
onSuccess={onSuccess}
/>
)}
/>
)
}

BIN
app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

179
app/globals.css Normal file
View File

@ -0,0 +1,179 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--font-heading: var(--font-sans);
--font-sans: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
--shadow-glow : 0 0 10px var(--primary);
}
:root {
--background: oklch(96.416% 0.00011 271.152);
--foreground: oklch(0.062 0 0);
--card: oklch(0.975 0 0);
--card-foreground: oklch(0.281 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.577 0.245 27.325);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.949 0 0);
--muted-foreground: oklch(0.6 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(75.417% 0.14818 18.15);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}
@layer utilities {
.dashboard-nav-item {
@apply
relative
overflow-hidden
data-active:bg-primary/10
data-active:text-primary
data-active:hover:text-primary
data-active:hover:bg-primary/15
transition-all
duration-300;
}
/* Accent bar — only in expanded mode */
/* .dashboard-nav-item:not([data-collapsed="true"])::after {
content: "";
position: absolute;
inset-inline-end: 0.25rem;
height: 80%;
border-radius: var(--radius-md);
z-index: 10;
box-shadow: 0 0 6px var(--primary);
}
.dashboard-nav-item:not([data-collapsed="true"])[data-active="true"]::after {
width: 0.25rem;
background-color: var(--primary);
} */
/* Collapsed mode: icon centered, no bar */
.dashboard-nav-item[data-collapsed="true"] {
@apply justify-center;
}
.dashboard-nav-sub-item {
@apply
transition-colors
duration-200
data-active:text-primary
data-active:font-medium
data-active:bg-primary/5
hover:text-primary/80;
}
}

40
app/layout.tsx Normal file
View File

@ -0,0 +1,40 @@
import { Geist_Mono, Inter } from "next/font/google"
import "./globals.css"
import { QueryProvider } from "@/shared/components/query-provider"
import { ThemeProvider } from "@/shared/components/theme-provider"
import { Toaster } from "@/shared/components/ui/sonner"
import { ConfirmDialog } from "@/shared/components/confirm-dialog"
import { NuqsAdapter } from "nuqs/adapters/next/app"
import { cn } from "@/shared/lib/utils"
const inter = Inter({ subsets: ["latin"], variable: "--font-sans" })
const fontMono = Geist_Mono({
subsets: ["latin"],
variable: "--font-mono",
})
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<html
lang="en"
suppressHydrationWarning
className={cn("antialiased", fontMono.variable, "font-sans", inter.variable)}
>
<body>
<NuqsAdapter>
<ThemeProvider>
<QueryProvider>{children}</QueryProvider>
<Toaster />
<ConfirmDialog />
</ThemeProvider>
</NuqsAdapter>
</body>
</html>
)
}

@ -1 +0,0 @@
Subproject commit 078e5383bae07bf1eaddc6236f25b5b2e98c52f6

View File

@ -0,0 +1,19 @@
"use client"
import { useRef } from "react"
import { useAuthStore } from "@/shared/stores/auth-store"
import type { AuthUser } from "@repo/api"
/**
* Synchronously initializes the auth store from server-side token/user before
* any child component renders. This avoids the first-render race condition where
* useEffect-based hydration hasn't fired yet and API requests go out without a token.
*/
export function AuthStoreInitializer({ token, user }: { token: string; user: AuthUser }) {
const initialized = useRef(false)
if (!initialized.current) {
initialized.current = true
useAuthStore.setState({ token, user, isAuthenticated: true })
}
return null
}

View File

@ -0,0 +1,240 @@
"use client"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { ChevronRight, Circle } from "lucide-react"
import type { NavGroup, NavItem } from "@/base/types/navigation"
import { cn } from "@/shared/lib/utils"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/shared/components/ui/collapsible"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarRail,
useSidebar,
} from "@/shared/components/ui/sidebar"
type AppSidebarProps = React.ComponentProps<typeof Sidebar> & {
navGroups: NavGroup[]
logo?: React.ReactNode
}
export function AppSidebar({ navGroups, logo, ...props }: AppSidebarProps) {
const { state, isMobile } = useSidebar()
const isCollapsed = state === "collapsed" && !isMobile
return (
<Sidebar collapsible="icon" {...props} className="bg-card">
{logo && (
<SidebarHeader className="flex p-4">
{logo}
</SidebarHeader>
)}
<SidebarContent className={cn("transition-[padding] duration-200", !isCollapsed && "ps-2")}>
{navGroups.map((group, groupIndex) => (
<SidebarGroup key={group.label ?? groupIndex}>
{group.label && (
<SidebarGroupLabel className="uppercase text-xs tracking-wider text-muted-foreground">
{group.label}
</SidebarGroupLabel>
)}
<SidebarMenu>
{group.items.map((item) =>
item.items && item.items.length > 0 ? (
<CollapsibleNavItem key={item.href} item={item} isCollapsed={isCollapsed} />
) : (
<SimpleNavItem key={item.href} item={item} isCollapsed={isCollapsed} />
)
)}
</SidebarMenu>
</SidebarGroup>
))}
</SidebarContent>
<SidebarRail />
</Sidebar>
)
}
function SimpleNavItem({ item, isCollapsed }: { item: NavItem; isCollapsed: boolean }) {
const pathname = usePathname()
const isActive = item.isActive ?? pathname === item.href
return (
<SidebarMenuItem>
<SidebarMenuButton
asChild
isActive={isActive}
tooltip={item.title}
className="dashboard-nav-item"
data-collapsed={isCollapsed}
>
<Link href={item.href}>
{item.icon}
{
!isCollapsed &&
<span>{item.title}</span>
}
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
)
}
function CollapsibleNavItem({ item, isCollapsed }: { item: NavItem; isCollapsed: boolean }) {
const pathname = usePathname()
const isChildActive = item.items?.some((sub) => pathname === sub.href)
const isActive = item.isActive ?? (pathname === item.href || isChildActive === true)
// Collapsed sidebar → flyout dropdown with sub-items
if (isCollapsed) {
return (
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
isActive={isActive}
tooltip={item.title}
className="dashboard-nav-item"
data-collapsed={isCollapsed}
>
<span
className={cn(
"transition-transform duration-300",
isActive && "text-primary"
)}
>
{item.icon}
</span>
{
!isCollapsed &&
<span>{item.title}</span>
}
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
side="right"
align="start"
sideOffset={4}
className="min-w-45"
>
<DropdownMenuLabel className="text-xs text-muted-foreground">
{item.title}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{item.items?.map((sub) => {
const isSubActive = sub.isActive ?? pathname === sub.href
return (
<DropdownMenuItem key={sub.href} asChild>
<Link
href={sub.href}
className={cn(
"flex items-center gap-2",
isSubActive && "bg-primary/10 text-primary font-medium"
)}
>
{sub.icon ? (
<span className={cn("shrink-0 [&>svg]:size-4", isSubActive ? "text-primary" : "text-muted-foreground/70")}>
{sub.icon}
</span>
) : (
<Circle
className={cn(
"size-1.5",
isSubActive ? "fill-primary text-primary" : "fill-muted-foreground/50 text-muted-foreground/50"
)}
/>
)}
{sub.title}
</Link>
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
)
}
// Expanded sidebar → collapsible/accordion sub-menu
return (
<Collapsible asChild defaultOpen={isActive} className="group/collapsible">
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<SidebarMenuButton tooltip={item.title} isActive={isActive} className="dashboard-nav-item" data-collapsed={isCollapsed}>
<span
className={cn(
"transition-transform duration-300",
isActive && "text-primary"
)}
>
{item.icon}
</span>
<span>{item.title}</span>
<ChevronRight
className={cn(
"ms-auto size-4 shrink-0 transition-transform duration-300 ease-[cubic-bezier(0.87,0,0.13,1)]",
"group-data-[state=open]/collapsible:rotate-90"
)}
/>
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent className="overflow-hidden py-2 data-[state=open]:animate-collapsible-down data-[state=closed]:animate-collapsible-up">
<SidebarMenuSub>
{item.items?.map((sub) => {
const isSubActive = sub.isActive ?? pathname === sub.href
return (
<SidebarMenuSubItem key={sub.href}>
<SidebarMenuSubButton asChild isActive={isSubActive} className="dashboard-nav-sub-item my-0.5">
<Link href={sub.href}>
{sub.icon ? (
<span className={cn("shrink-0 transition-colors duration-200 [&>svg]:size-4", isSubActive ? "text-primary" : "text-muted-foreground/70 group-hover/menu-sub-item:text-primary")}>
{sub.icon}
</span>
) : (
<Circle
className={cn(
"size-1.5 transition-colors duration-200",
isSubActive
? "fill-primary text-primary"
: "fill-muted-foreground/40 text-muted-foreground/40 group-hover/menu-sub-item:fill-foreground group-hover/menu-sub-item:text-primary"
)}
/>
)}
<span>{sub.title}</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
)
})}
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
)
}

View File

@ -0,0 +1,210 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { useTheme } from "next-themes"
import {
BellIcon,
LogOutIcon,
MoonIcon,
SearchIcon,
SunIcon,
UserIcon,
} from "lucide-react"
import type { UserInfo } from "@/base/types/navigation"
import { useAuthStore } from "@/shared/stores/auth-store"
import { cn } from "@/shared/lib/utils"
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
import { Button } from "@/shared/components/ui/button"
import { SidebarTrigger } from "@/shared/components/ui/sidebar"
import {
CommandDialog,
Command,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
} from "@/shared/components/ui/command"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import { Separator } from "@/shared/components/ui/separator"
type DashboardHeaderProps = {
user?: UserInfo
actions?: React.ReactNode
className?: string
}
export function DashboardHeader({ actions, className }: DashboardHeaderProps) {
const { resolvedTheme, setTheme } = useTheme()
const [searchOpen, setSearchOpen] = useState(false)
const { logout, user } = useAuthStore((s) => s)
const router = useRouter()
const handleLogout = useCallback(async () => {
await logout()
router.push("/login")
}, [logout, router])
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault()
setSearchOpen((prev) => !prev)
}
}
window.addEventListener("keydown", onKeyDown)
return () => window.removeEventListener("keydown", onKeyDown)
}, [])
const toggleTheme = useCallback(() => {
setTheme(resolvedTheme === "dark" ? "light" : "dark")
}, [resolvedTheme, setTheme])
return (
<header
className={cn(
"sticky top-0 z-30 flex h-18 shrink-0 items-center gap-2 border-b bg-card px-4",
className,
)}
>
{/* Sidebar toggle — mobile: hamburger, desktop: collapse */}
<SidebarTrigger className="-ms-2" />
<Separator orientation="vertical" />
{/* Left side — default actions */}
<div className="flex items-center gap-1">
{/* User dropdown */}
{/* {user && ( */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="flex items-center gap-2 px-2">
<Avatar >
{user?.avatar && <AvatarImage src={user?.avatar as string} alt={user?.name} />}
<AvatarFallback>
{user?.initials ?? user?.name.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<span className="hidden text-sm font-medium md:inline-block">
{user?.name}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
{/* User info header */}
<DropdownMenuLabel className="font-normal">
<div className="flex items-center gap-3 py-1">
<Avatar size="lg">
{user?.avatar && <AvatarImage src={user?.avatar as string} alt={user?.name} />}
<AvatarFallback className="text-base">
{user?.initials ?? user?.name.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex flex-col">
<span className="text-sm font-medium text-foreground">{user?.name}</span>
{user?.email && (
<span className="text-xs text-muted-foreground">{user?.email}</span>
)}
{user?.role && (
<span className="mt-0.5 text-xs font-medium text-primary">{user?.role}</span>
)}
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem asChild>
<Link href="/profile">
<UserIcon />
Profile
</Link>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onSelect={handleLogout}>
<LogOutIcon />
Logout
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* )} */}
{/* Search trigger */}
<Button
variant="outline"
className="hidden h-8 w-56 justify-start gap-2 text-muted-foreground md:flex"
onClick={() => setSearchOpen(true)}
>
<SearchIcon className="size-4" />
<span className="text-sm">Search</span>
<kbd className="pointer-events-none ms-auto inline-flex h-5 select-none items-center gap-0.5 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground">
K
</kbd>
</Button>
{/* Mobile search icon */}
<Button
variant="ghost"
size="icon-sm"
className="md:hidden"
aria-label="Search"
onClick={() => setSearchOpen(true)}
>
<SearchIcon className="size-4" />
</Button>
{/* Theme toggle */}
<Button
variant="ghost"
size="icon-sm"
aria-label="Toggle theme"
onClick={toggleTheme}
>
<SunIcon className="size-4 rotate-0 scale-100 transition-transform dark:-rotate-90 dark:scale-0" />
<MoonIcon className="absolute size-4 rotate-90 scale-0 transition-transform dark:rotate-0 dark:scale-100" />
</Button>
{/* Notifications */}
<Button variant="ghost" size="icon-sm" aria-label="Notifications">
<BellIcon className="size-4" />
</Button>
</div>
{/* Search command dialog */}
<CommandDialog open={searchOpen} onOpenChange={setSearchOpen}>
<Command>
<CommandInput placeholder="Type to search…" />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup heading="Quick Actions">
<CommandItem>Dashboard</CommandItem>
<CommandItem>Job Cards</CommandItem>
<CommandItem>Customers</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</CommandDialog>
{/* Right side — custom actions */}
{actions && (
<div className="ms-auto flex items-center gap-2">{actions}</div>
)}
</header>
)
}

View File

@ -0,0 +1,41 @@
"use client"
import type { NavGroup, UserInfo } from "@/base/types/navigation"
import { SidebarInset, SidebarProvider } from "@/shared/components/ui/sidebar"
import { TooltipProvider } from "@/shared/components/ui/tooltip"
import { AppSidebar } from "./app-sidebar"
import { DashboardHeader } from "./dashboard-header"
type DashboardLayoutProps = {
children: React.ReactNode
/** Navigation groups rendered in the sidebar */
navGroups: NavGroup[]
/** Logo element displayed at the top of the sidebar */
logo?: React.ReactNode
/** Current user info shown in the header */
user?: UserInfo
/** Custom actions rendered in the header (e.g. session timer, clock-in button) */
headerActions?: React.ReactNode
/** Default sidebar open state */
defaultOpen?: boolean
}
export function DashboardLayout({
children,
navGroups,
logo,
user,
headerActions,
defaultOpen = true,
}: DashboardLayoutProps) {
return (
<TooltipProvider>
<SidebarProvider defaultOpen={defaultOpen}>
<AppSidebar navGroups={navGroups} logo={logo} />
<SidebarInset>
{children}
</SidebarInset>
</SidebarProvider>
</TooltipProvider>
)
}

View File

@ -0,0 +1,20 @@
import { cn } from '@/shared/lib/utils'
import { title } from 'process'
import React from 'react'
export default function DashboardPage({ children, header, title, fullscreen }: { children: React.ReactNode, header: React.ReactNode, title?: string, fullscreen?: boolean }) {
return (
<div className='page'>
<header>
{header}
</header>
<main className={cn('p-4 lg:p-8 w-full h-full', fullscreen && 'h-screen p-0 lg:p-0')}>
{
title &&
<h2 className='text-lg lg:text-2xl font-bold mb-4'> {title}</h2>
}
{children}
</main>
</div>
)
}

View File

@ -0,0 +1,3 @@
export { DashboardLayout } from "./dashboard-layout"
export { AppSidebar } from "./app-sidebar"
export { DashboardHeader } from "./dashboard-header"

31
base/types/navigation.ts Normal file
View File

@ -0,0 +1,31 @@
import { ReactNode } from "react"
export type NavItem = {
title: string
href: string
icon?: ReactNode
isActive?: boolean
badge?: string | number
items?: NavSubItem[]
}
export type NavSubItem = {
title: string
href: string
icon?: ReactNode
isActive?: boolean
}
export type NavGroup = {
label?: string
items: NavItem[]
}
export type UserInfo = {
name: string
email?: string
avatar?: string
initials?: string
role?: string
}

25
components.json Normal file
View File

@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "radix-vega",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": true,
"aliases": {
"components": "@/shared/components",
"utils": "@/shared/lib/utils",
"ui": "@/shared/components/ui",
"lib": "@/shared/lib",
"hooks": "@/shared/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}

16
cypress.config.ts Normal file
View File

@ -0,0 +1,16 @@
import { defineConfig } from "cypress"
export default defineConfig({
e2e: {
baseUrl: "http://localhost:3000",
env: {
NEXT_PUBLIC_API_URL: "https://newgarage.yslootahtech.com"
},
specPattern: "cypress/e2e/**/*.cy.{ts,tsx}",
supportFile: "cypress/support/e2e.ts",
viewportWidth: 1280,
viewportHeight: 720,
defaultCommandTimeout: 10000,
requestTimeout: 10000,
},
})

View File

@ -0,0 +1,293 @@
describe("Customer Form Integration Tests", () => {
beforeEach(() => {
cy.login()
cy.fixture("customers").then((data) => {
cy.intercept("GET", "**/api/referral-sources", {
statusCode: 200,
body: data.referral_sources,
}).as("getReferralSources")
cy.intercept("GET", "**/api/payment-terms", {
statusCode: 200,
body: data.payment_terms,
}).as("getPaymentTerms")
cy.intercept("GET", "**/api/countries", {
statusCode: 200,
body: data.countries,
}).as("getCountries")
cy.intercept("GET", "**/api/states", {
statusCode: 200,
body: data.states,
}).as("getStates")
cy.intercept("GET", "**/api/customers*", {
statusCode: 200,
body: { success: true, data: { data: [], pagination: { total: 0 } } },
}).as("getCustomers")
})
cy.visit("/sales/customers")
cy.contains("button", "Create Customer").click()
cy.get("[role='dialog']").should("be.visible")
})
// ── Form interaction flow ──
describe("Field interactions", () => {
it("should clear a text field after typing", () => {
cy.get("[role='dialog']").within(() => {
cy.get("input[name='first_name']")
.type("John")
.should("have.value", "John")
.clear()
.should("have.value", "")
})
})
it("should handle special characters in text inputs", () => {
cy.get("[role='dialog']").within(() => {
cy.get("input[name='first_name']").type("José-María").should("have.value", "José-María")
cy.get("input[name='last_name']").type("O'Brien").should("have.value", "O'Brien")
cy.get("input[name='company_name']").type("Smith & Co.").should("have.value", "Smith & Co.")
})
})
it("should accept various email formats", () => {
cy.get("[role='dialog']").within(() => {
cy.get("input[name='first_name']").type("Test")
cy.get("input[name='last_name']").type("User")
// Valid email should not show error
cy.get("input[name='email']").type("user+tag@sub.domain.com")
cy.contains("button", "Create Customer").click()
cy.contains("Enter a valid email address").should("not.exist")
})
})
it("should handle phone number input", () => {
cy.get("[role='dialog']").within(() => {
cy.get("input[name='phone']")
.type("0501234567")
.should("have.value", "0501234567")
cy.get("input[name='alternate_phone']")
.type("+971501234567")
.should("have.value", "+971501234567")
})
})
})
// ── Async select integration ──
describe("Async select fields", () => {
it("should show loading state while fetching referral sources", () => {
cy.intercept("GET", "**/api/referral-sources", {
statusCode: 200,
body: { success: true, data: { data: [{ id: 1, name: "Google" }] } },
delay: 2000,
}).as("slowReferralSources")
// Reload to get the delayed intercept
cy.visit("/sales/customers")
cy.contains("button", "Create Customer").click()
cy.get("[role='dialog']").should("be.visible")
cy.get("[role='dialog']").within(() => {
cy.contains("label", "Referral Source").parent().find("input").click()
})
// The component should show a loading spinner
cy.get("[role='listbox']").should("be.visible")
})
it("should filter options by text input in combobox", () => {
cy.wait("@getReferralSources")
cy.get("[role='dialog']").within(() => {
cy.contains("label", "Referral Source").parent().find("input").click().type("Goo")
})
// Should show Google, shouldn't show Friend Referral
cy.get("[role='option']").contains("Google").should("exist")
})
it("should show empty state when no options match", () => {
cy.wait("@getCountries")
cy.get("[role='dialog']").within(() => {
cy.contains("label", "Country").parent().find("input").click().type("zzzzz")
})
cy.contains("No results found").should("be.visible")
})
it("should select a payment term from the combobox", () => {
cy.wait("@getPaymentTerms")
cy.get("[role='dialog']").within(() => {
cy.contains("label", "Payment Terms").parent().find("input").click()
})
cy.get("[role='option']").contains("Net 30").click()
})
it("should select a state from the combobox", () => {
cy.wait("@getStates")
cy.get("[role='dialog']").within(() => {
cy.contains("label", "State").parent().find("input").click()
})
cy.get("[role='option']").contains("Dubai").click()
})
})
// ── Validation edge cases ──
describe("Validation edge cases", () => {
it("should validate only on submit (not on blur)", () => {
cy.get("[role='dialog']").within(() => {
// Focus and blur first_name without typing
cy.get("input[name='first_name']").focus().blur()
// Error should NOT appear yet (react-hook-form validates on submit by default)
cy.contains("First name is required").should("not.exist")
})
})
it("should clear validation errors when user corrects input", () => {
cy.get("[role='dialog']").within(() => {
// Trigger validation
cy.contains("button", "Create Customer").click()
cy.contains("First name is required").should("be.visible")
// Fix the error
cy.get("input[name='first_name']").type("John")
cy.contains("First name is required").should("not.exist")
})
})
it("should trim whitespace-only inputs and still require first_name", () => {
cy.get("[role='dialog']").within(() => {
cy.get("input[name='first_name']").type(" ")
cy.get("input[name='last_name']").type("Doe")
cy.contains("button", "Create Customer").click()
})
})
it("should allow submission with only required fields", () => {
cy.fixture("customers").then((data) => {
cy.intercept("POST", "**/api/customers", {
statusCode: 201,
body: data.customer_created,
}).as("createCustomer")
})
cy.get("[role='dialog']").within(() => {
cy.get("input[name='first_name']").type("Jane")
cy.get("input[name='last_name']").type("Smith")
cy.contains("button", "Create Customer").click()
})
cy.wait("@createCustomer").its("request.body").should((body) => {
expect(body.first_name).to.eq("Jane")
expect(body.last_name).to.eq("Smith")
// Optional fields should be empty or undefined
expect(body.company_name).to.satisfy(
(v: unknown) => v === "" || v === undefined || v === null,
)
})
})
})
// ── API error scenarios ──
describe("API error handling", () => {
it("should handle network error gracefully", () => {
cy.intercept("POST", "**/api/customers", { forceNetworkError: true }).as(
"networkError",
)
cy.get("[role='dialog']").within(() => {
cy.get("input[name='first_name']").type("John")
cy.get("input[name='last_name']").type("Doe")
cy.contains("button", "Create Customer").click()
})
cy.wait("@networkError")
cy.get("[role='dialog']").within(() => {
cy.contains("Failed to create customer").should("be.visible")
})
})
it("should handle 500 server error", () => {
cy.intercept("POST", "**/api/customers", {
statusCode: 500,
body: { success: false, message: "Internal server error" },
}).as("serverError")
cy.get("[role='dialog']").within(() => {
cy.get("input[name='first_name']").type("John")
cy.get("input[name='last_name']").type("Doe")
cy.contains("button", "Create Customer").click()
})
cy.wait("@serverError")
cy.get("[role='dialog']").within(() => {
cy.contains("Failed to create customer").should("be.visible")
})
})
it("should handle 422 validation error from server", () => {
cy.intercept("POST", "**/api/customers", {
statusCode: 422,
body: {
success: false,
message: "The email has already been taken.",
errors: { email: ["The email has already been taken."] },
},
}).as("validationError")
cy.get("[role='dialog']").within(() => {
cy.get("input[name='first_name']").type("John")
cy.get("input[name='last_name']").type("Doe")
cy.get("input[name='email']").type("existing@example.com")
cy.contains("button", "Create Customer").click()
})
cy.wait("@validationError")
cy.get("[role='dialog']").within(() => {
cy.contains("Failed to create customer").should("be.visible")
})
})
it("should re-enable submit button after a failed request", () => {
cy.intercept("POST", "**/api/customers", {
statusCode: 422,
body: {
success: false,
message: "Validation failed",
errors: {},
},
}).as("failedRequest")
cy.get("[role='dialog']").within(() => {
cy.get("input[name='first_name']").type("John")
cy.get("input[name='last_name']").type("Doe")
cy.contains("button", "Create Customer").click()
})
cy.wait("@failedRequest")
cy.get("[role='dialog']").within(() => {
cy.contains("button", "Create Customer").should("not.be.disabled")
})
})
})
})

View File

@ -0,0 +1,347 @@
describe("Customer Form", () => {
beforeEach(() => {
// Authenticate via API and set cookies
cy.login()
// Intercept lookup APIs with fixture data
cy.fixture("customers").then((data) => {
cy.intercept("GET", "**/api/referral-sources", {
statusCode: 200,
body: data.referral_sources,
}).as("getReferralSources")
cy.intercept("GET", "**/api/payment-terms", {
statusCode: 200,
body: data.payment_terms,
}).as("getPaymentTerms")
cy.intercept("GET", "**/api/countries", {
statusCode: 200,
body: data.countries,
}).as("getCountries")
cy.intercept("GET", "**/api/states", {
statusCode: 200,
body: data.states,
}).as("getStates")
// Intercept customer list (GET) for the data table
cy.intercept("GET", "**/api/customers*", {
statusCode: 200,
body: { success: true, data: { data: [], pagination: { total: 0 } } },
}).as("getCustomers")
})
cy.visit("/sales/customers")
})
function openCustomerDialog() {
cy.contains("button", "Create Customer").click()
cy.get("[role='dialog']").should("be.visible")
}
// ── Rendering ──
it("should open the create customer dialog", () => {
openCustomerDialog()
cy.get("[role='dialog']").within(() => {
cy.contains("Create Customer").should("exist")
})
})
it("should display all form fields", () => {
openCustomerDialog()
cy.get("[role='dialog']").within(() => {
// Text fields
cy.get("input[name='first_name']").should("exist")
cy.get("input[name='last_name']").should("exist")
cy.get("input[name='company_name']").should("exist")
cy.get("input[name='email']").should("exist")
cy.get("input[name='phone']").should("exist")
cy.get("input[name='alternate_phone']").should("exist")
cy.get("input[name='address_line_1']").should("exist")
cy.get("input[name='address_line_2']").should("exist")
cy.get("input[name='city']").should("exist")
cy.get("input[name='zip_code']").should("exist")
// Labels
cy.contains("label", "First Name").should("exist")
cy.contains("label", "Last Name").should("exist")
cy.contains("label", "Email").should("exist")
cy.contains("label", "Salutation").should("exist")
cy.contains("label", "Customer Type").should("exist")
cy.contains("label", "Referral Source").should("exist")
cy.contains("label", "Payment Terms").should("exist")
cy.contains("label", "Country").should("exist")
cy.contains("label", "State").should("exist")
})
})
// ── Validation ──
it("should show validation errors for required fields", () => {
openCustomerDialog()
cy.get("[role='dialog']").within(() => {
cy.contains("button", "Create Customer").click()
// first_name and last_name are required
cy.contains("First name is required").should("be.visible")
cy.contains("Last name is required").should("be.visible")
})
})
it("should show email validation error for invalid email", () => {
openCustomerDialog()
cy.get("[role='dialog']").within(() => {
cy.get("input[name='email']").type("not-an-email")
cy.contains("button", "Create Customer").click()
cy.contains("Enter a valid email address").should("be.visible")
})
})
it("should not show email error when email is empty", () => {
openCustomerDialog()
cy.get("[role='dialog']").within(() => {
cy.get("input[name='first_name']").type("John")
cy.get("input[name='last_name']").type("Doe")
cy.contains("button", "Create Customer").click()
cy.contains("Enter a valid email address").should("not.exist")
})
})
// ── Text input ──
it("should fill in text fields", () => {
openCustomerDialog()
cy.get("[role='dialog']").within(() => {
cy.get("input[name='first_name']").type("John").should("have.value", "John")
cy.get("input[name='last_name']").type("Doe").should("have.value", "Doe")
cy.get("input[name='company_name']").type("Acme Corp").should("have.value", "Acme Corp")
cy.get("input[name='email']").type("john@example.com").should("have.value", "john@example.com")
cy.get("input[name='phone']").type("0501234567").should("have.value", "0501234567")
cy.get("input[name='address_line_1']").type("123 Main St").should("have.value", "123 Main St")
cy.get("input[name='city']").type("Dubai").should("have.value", "Dubai")
cy.get("input[name='zip_code']").type("00000").should("have.value", "00000")
})
})
// ── Select fields ──
it("should select a salutation from the dropdown", () => {
openCustomerDialog()
cy.get("[role='dialog']").within(() => {
// Click the Salutation select trigger
cy.contains("label", "Salutation")
.parent()
.find("[role='combobox'], button[data-slot='select-trigger']")
.click()
})
// Select option from the popover (may render outside the dialog)
cy.get("[role='option'], [role='listbox'] [data-value='Mr']")
.contains("Mr")
.click()
})
it("should select a customer type", () => {
openCustomerDialog()
cy.get("[role='dialog']").within(() => {
cy.contains("label", "Customer Type")
.parent()
.find("[role='combobox'], button[data-slot='select-trigger']")
.click()
})
cy.get("[role='option']").contains("Individual").click()
})
// ── Async select (Combobox) fields ──
it("should load and select a referral source", () => {
openCustomerDialog()
cy.wait("@getReferralSources")
cy.get("[role='dialog']").within(() => {
cy.contains("label", "Referral Source")
.parent()
.find("input")
.click()
.type("Google")
})
cy.get("[role='option']").contains("Google").click()
})
it("should load and select a country", () => {
openCustomerDialog()
cy.wait("@getCountries")
cy.get("[role='dialog']").within(() => {
cy.contains("label", "Country")
.parent()
.find("input")
.click()
.type("United")
})
cy.get("[role='option']").contains("United Arab Emirates").click()
})
// ── Successful submission ──
it("should submit the form successfully with required fields", () => {
cy.fixture("customers").then((data) => {
cy.intercept("POST", "**/api/customers", {
statusCode: 201,
body: data.customer_created,
}).as("createCustomer")
})
openCustomerDialog()
cy.get("[role='dialog']").within(() => {
cy.get("input[name='first_name']").type("John")
cy.get("input[name='last_name']").type("Doe")
cy.contains("button", "Create Customer").click()
})
cy.wait("@createCustomer").its("request.body").should((body) => {
expect(body.first_name).to.eq("John")
expect(body.last_name).to.eq("Doe")
})
})
it("should submit a fully filled form", () => {
cy.fixture("customers").then((data) => {
cy.intercept("POST", "**/api/customers", {
statusCode: 201,
body: data.customer_created,
}).as("createCustomer")
})
openCustomerDialog()
// Wait for async data
cy.wait("@getReferralSources")
cy.wait("@getPaymentTerms")
cy.wait("@getCountries")
cy.wait("@getStates")
cy.get("[role='dialog']").within(() => {
// Text fields
cy.get("input[name='first_name']").type("John")
cy.get("input[name='last_name']").type("Doe")
cy.get("input[name='company_name']").type("Doe Holdings")
cy.get("input[name='email']").type("john@example.com")
cy.get("input[name='phone']").type("0501234567")
cy.get("input[name='alternate_phone']").type("0551234567")
cy.get("input[name='address_line_1']").type("Street 10")
cy.get("input[name='address_line_2']").type("Near Central Plaza")
cy.get("input[name='city']").type("Dubai")
cy.get("input[name='zip_code']").type("00000")
// Submit
cy.contains("button", "Create Customer").click()
})
cy.wait("@createCustomer").its("request.body").should((body) => {
expect(body.first_name).to.eq("John")
expect(body.last_name).to.eq("Doe")
expect(body.company_name).to.eq("Doe Holdings")
expect(body.email).to.eq("john@example.com")
expect(body.phone).to.eq("0501234567")
})
})
// ── Error handling ──
it("should display API error on submission failure", () => {
cy.intercept("POST", "**/api/customers", {
statusCode: 422,
body: {
success: false,
message: "The given data was invalid.",
errors: { email: ["The email has already been taken."] },
},
}).as("createCustomerFail")
openCustomerDialog()
cy.get("[role='dialog']").within(() => {
cy.get("input[name='first_name']").type("John")
cy.get("input[name='last_name']").type("Doe")
cy.get("input[name='email']").type("john@example.com")
cy.contains("button", "Create Customer").click()
})
cy.wait("@createCustomerFail")
cy.get("[role='dialog']").within(() => {
cy.contains("Failed to create customer").should("be.visible")
})
})
it("should show loading state while submitting", () => {
cy.intercept("POST", "**/api/customers", {
statusCode: 201,
body: { success: true, data: { id: 1 } },
delay: 1000,
}).as("createCustomerSlow")
openCustomerDialog()
cy.get("[role='dialog']").within(() => {
cy.get("input[name='first_name']").type("John")
cy.get("input[name='last_name']").type("Doe")
cy.contains("button", "Create Customer").click()
// Button should show loading text and be disabled
cy.contains("button", "Creating...").should("be.visible").and("be.disabled")
})
})
// ── Form reset after success ──
it("should reset the form after successful submission", () => {
cy.fixture("customers").then((data) => {
cy.intercept("POST", "**/api/customers", {
statusCode: 201,
body: data.customer_created,
}).as("createCustomer")
})
openCustomerDialog()
cy.get("[role='dialog']").within(() => {
cy.get("input[name='first_name']").type("John")
cy.get("input[name='last_name']").type("Doe")
cy.contains("button", "Create Customer").click()
})
cy.wait("@createCustomer")
// After success, re-open the dialog and verify fields are empty
openCustomerDialog()
cy.get("[role='dialog']").within(() => {
cy.get("input[name='first_name']").should("have.value", "")
cy.get("input[name='last_name']").should("have.value", "")
})
})
})

View File

@ -0,0 +1,47 @@
{
"referral_sources": {
"success": true,
"data": {
"data": [
{ "id": 1, "name": "Google" },
{ "id": 2, "name": "Friend Referral" },
{ "id": 3, "name": "Social Media" }
]
}
},
"payment_terms": {
"success": true,
"data": {
"data": [
{ "id": 1, "name": "Net 30" },
{ "id": 2, "name": "Net 60" },
{ "id": 3, "name": "Due on Receipt" }
]
}
},
"countries": {
"success": true,
"data": [
{ "id": 1, "name": "United Arab Emirates" },
{ "id": 2, "name": "Saudi Arabia" },
{ "id": 3, "name": "United States" }
]
},
"states": {
"success": true,
"data": [
{ "id": 1, "name": "Dubai" },
{ "id": 2, "name": "Abu Dhabi" },
{ "id": 3, "name": "Sharjah" }
]
},
"customer_created": {
"success": true,
"data": {
"id": 101,
"first_name": "John",
"last_name": "Doe",
"email": "john@example.com"
}
}
}

View File

@ -0,0 +1,33 @@
/// <reference types="cypress" />
declare global {
namespace Cypress {
interface Chainable {
/**
* Log in via the API and set auth cookies so the app
* recognises the user as authenticated.
*/
login(email?: string, password?: string): Chainable<void>
}
}
}
Cypress.Commands.add("login", (email?: string, password?: string) => {
const userEmail = email ?? Cypress.env("TEST_USER_EMAIL") ?? "admin@admin.com"
const userPassword = password ?? Cypress.env("TEST_USER_PASSWORD") ?? "12345678"
cy.request({
method: "POST",
url: `${Cypress.env("API_URL") ?? "http://localhost:8000"}/api/login`,
body: { email: userEmail, password: userPassword },
}).then((response) => {
const { token, user } = response.body
cy.setCookie("auth_token", token, { path: "/" })
cy.setCookie("auth_user", encodeURIComponent(JSON.stringify(user)), {
path: "/",
})
})
})
export {}

1
cypress/support/e2e.ts Normal file
View File

@ -0,0 +1 @@
import "./commands"

15
cypress/tsconfig.json Normal file
View File

@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["ES2017", "DOM"],
"types": ["cypress"],
"moduleResolution": "bundler",
"module": "ESNext",
"strict": true,
"baseUrl": ".",
"paths": {
"@/*": ["../../*"]
}
},
"include": ["**/*.ts", "../support/**/*.ts"]
}

View File

@ -1,278 +0,0 @@
# Data Fetching
This document covers the full data-fetching stack: API client creation, authentication injection, query state management, and URL synchronization.
---
## Layer Overview
```
useAuthApi()
└─ createApi({ headers: { Authorization: "Bearer <token>" } })
└─ new CustomersClient(...) ← one per domain (CrudClient subclass)
└─ ApiClient ← openapi-fetch wrapper, type-safe from OpenAPI schema
```
```
useDataTableQuery({ queryKey, client, queryOptions })
└─ React Query useQuery
└─ client.list({ page, per_page, sort_by, sort_order })
└─ nuqs useQueryStates ← URL ↔ pagination & sort params
```
---
## `useAuthApi` — Authenticated API Factory
**File:** `shared/useApi.ts`
```ts
import { useAuthApi } from "@/shared/useApi"
const api = useAuthApi()
// api.customers, api.vehicles, api.employees, …
```
Reads the JWT token from the `useAuthStore` Zustand store and passes it as the `Authorization: Bearer <token>` header. Called inside any component or hook that needs to make authenticated requests.
> **Note:** `createApi()` is called on every render. If performance is a concern, wrap in `useMemo` (see [Enhancement Plan](./enhancement-plan.md)).
### Server-Side Variant
For `async` server components or server actions:
```ts
import { getAuthApi } from "@/shared/api"
const api = await getAuthApi() // reads token from cookies (Next.js server-side)
```
---
## `CrudClient` — Generic CRUD Base Class
**File:** `packages/api/src/infra/crud-client.ts`
All domain clients extend `CrudClient`. It provides four standard operations:
| Method | HTTP | Endpoint |
|---|---|---|
| `list(query?)` | `GET` | `indexRoute` (e.g. `/api/customers`) |
| `create(payload)` | `POST` | `indexRoute` |
| `update(id, payload)` | `PUT` | `byIdRoute` (e.g. `/api/customers/{id}`) |
| `destroy(id)` | `DELETE` | `byIdRoute` |
All methods are **fully type-safe** — parameter types, request body shapes, and response types are all derived from the OpenAPI schema via `packages/api/types/index.ts`.
### Exported Type Utilities
```ts
// Extract the list response type from a client class
type CrudListResponse<C> // e.g. { data: Customer[], meta: { last_page, total, ... } }
// Extract a single item type from the list data array
type CrudListItem<C> // e.g. Customer
// Extract query params accepted by list()
type CrudListParams<C>
// Base interface: all list items have `id: number`
type BaseCrudItem = { id: number }
```
### Example: Creating a Domain Client
```ts
// packages/api/src/clients/my-resource.ts
import { CrudClient } from "../infra/crud-client"
export const MY_ROUTES = {
INDEX: "/api/my-resources",
BY_ID: "/api/my-resources/{id}",
} as const
export class MyResourceClient extends CrudClient<
typeof MY_ROUTES.INDEX,
typeof MY_ROUTES.BY_ID
> {
constructor(baseUrl?: string, options?: ApiClientOptions) {
super(baseUrl, options, MY_ROUTES.INDEX, MY_ROUTES.BY_ID)
}
// Add domain-specific endpoints here:
async listCategories() {
return this.get("/api/my-resource-categories")
}
}
```
Then register it in `packages/api/src/api.ts`:
```ts
export function createApi(options?: ApiClientOptions) {
return {
// ...
myResources: new MyResourceClient(undefined, options),
}
}
```
---
## `ApiClient` — Low-Level HTTP Client
**File:** `packages/api/src/infra/client.ts`
Wraps `openapi-fetch`. All requests are typed against `paths` from `packages/api/types/index.ts`, which is generated from the OpenAPI schema.
### Error Handling
Failed requests throw an `ApiError`:
```ts
class ApiError extends Error {
status: number // HTTP status code
statusText: string
endpoint: string
method: string
payload?: {
message?: string
errors?: Record<string, string[]> // Laravel validation errors
}
get validationErrors(): Record<string, string[]> | undefined
}
```
### `ApiError` in Form Context
In mutation `onError` handlers, check for validation errors and apply them to individual form fields:
```ts
onError: (err) => {
if (err instanceof ApiError && err.validationErrors) {
Object.entries(err.validationErrors).forEach(([field, messages]) => {
form.setError(field as any, { message: messages[0] })
})
}
}
```
---
## `useDataTableQuery` — Paginated List + URL State
**File:** `shared/data-view/table-view/use-data-table-query.ts`
Wraps React Query + `nuqs` to keep the table's pagination and sort state synchronized with the URL.
```ts
const tableQuery = useDataTableQuery({
queryKey: ["customers"], // React Query cache key prefix
client, // Any object with a .list(query?) method
queryOptions, // Optional React Query overrides (staleTime, etc.)
})
```
### Returns
| Key | Description |
|---|---|
| `data` | The raw API response (`CrudListResponse<C>`) |
| `isLoading` | True during initial fetch |
| `pagination` | `{ page, pageSize, pageCount: 1, total: 0 }` — pageCount/total come from `data.meta` |
| `sorting` | `SortingState` derived from URL params |
| `params` | Raw parsed URL params (`page`, `per_page`, `sort_by`, `sort_order`) |
| `setParams` | Direct URL param setter |
| `handleChange` | Normalized event handler for `DataTable` (see below) |
| `invalidateQuery` | Busts the cache (called after mutations) |
### URL Query Parameters
| Param | Default | Description |
|---|---|---|
| `page` | `1` | Current page (1-based) |
| `per_page` | `10` | Rows per page |
| `sort_by` | `null` | Column `accessorKey` to sort by |
| `sort_order` | `null` | `"asc"` or `"desc"` |
### `handleChange` Event Types
```ts
// Triggered by DataViewPagination (page navigation, rows per page)
{ type: "pagination", pagination: { page, pageSize, ... } }
// Triggered by ColumnHeader sort dropdown
{ type: "sorting", sorting: [{ id: "email", desc: false }] }
// → resets page to 1 automatically
```
---
## `DataTable` — Table UI
**File:** `shared/data-view/table-view/data-table.tsx`
Thin wrapper around TanStack Table v8 with manual server-side pagination and sorting:
```tsx
<DataTable
columns={columns}
data={data}
pagination={{ page, pageSize, pageCount, total }}
sorting={sorting}
onChange={handleChange}
isLoading={isLoading}
/>
```
While `isLoading` is `true`, the table renders `pageSize` skeleton rows instead of data.
### `ColumnHeader` — Sortable Column Header
```tsx
import { ColumnHeader } from "@/shared/data-view/table-view"
{
accessorKey: "email",
header: ({ column }) => <ColumnHeader column={column} title="Email" />,
}
```
Renders a sort dropdown (Asc / Desc / Clear) if the column `canSort`. Shows a plain `<span>` otherwise.
---
## Auth Store
**File:** `shared/stores/auth-store.ts`
A Zustand store that holds the authenticated user state:
| Key | Type | Description |
|---|---|---|
| `token` | `string \| undefined` | JWT access token |
| `user` | `AuthUser \| undefined` | Authenticated user profile |
| `isAuthenticated` | `boolean` | True when token + user are set |
| `login(token, user, expiresIn?)` | fn | Persists to cookie + sets store |
| `logout()` | fn | Calls API logout, clears cookie + store |
| `hydrate()` | fn | Reads cookies on app boot (call in root layout) |
---
## Type System — OpenAPI-Derived Types
The entire API type surface is generated from `packages/api/open-api/schema.yaml` via scripts in `packages/api/scripts/`. The generated output is `packages/api/types/index.ts`.
Key exported type helpers from `packages/api/src/infra/types.ts`:
| Type | Description |
|---|---|
| `ApiPath` | Union of all known API paths |
| `ApiPathByMethod<M>` | All paths that support HTTP method `M` |
| `ApiQueryParams<Path, Method>` | Query parameter shape for a given path+method |
| `ApiRequestBody<Path, Method>` | Request body shape |
| `ApiResponse<Path, Method>` | Successful response shape |
| `ApiPathParams<Path, Method>` | URL path parameters (e.g. `{ id: string }`) |
These types flow through `CrudClient``useDataTableQuery``ResourcePage` → feature page, providing end-to-end type safety without any manual typing.

View File

@ -1,225 +0,0 @@
# Enhancement Plan
This document identifies current gaps and improvement opportunities in the CRUD flow. Items are grouped by priority and annotated with implementation effort.
---
## Summary
The current CRUD pattern is clean, composable, and type-safe. Most issues are edge cases or developer-experience refinements rather than critical bugs. The highest-priority items are the ones marked **High**.
---
## High Priority
### 1. `useAuthApi` — Missing Memoization
**File:** `shared/useApi.ts`
**Problem:** `createApi()` is called on every render. Every component that calls `useAuthApi()` creates new `ApiClient` instances per render cycle. This can cause unintended React Query cache misses or stale closures in edge cases.
**Fix:**
```ts
// shared/useApi.ts
import { useMemo } from "react"
export const useAuthApi = () => {
const token = useAuthStore(s => s.token)
return useMemo(
() => createApi({ headers: token ? { Authorization: `Bearer ${token}` } : undefined }),
[token],
)
}
```
**Effort:** XS (2 lines) — no API changes required.
---
### 2. Edit Mode — No Server Re-fetch on Dialog Open
**File:** `shared/hooks/use-resource-form.ts`, `modules/customers/customer-form.tsx`
**Problem:** When the "Edit" button is clicked, `useResourcePage` passes `selectedItem` (the in-memory table row) as `initialData`. The form is pre-populated from this snapshot. However:
- The snapshot may be stale if the item was modified elsewhere.
- On page refresh with `?dialog=true&resourceId=5` in the URL, `selectedItem` is `null` (not hydrated) → the form opens empty.
**Fix:** Add an `initialize` function to each feature form that fetches the full resource by ID:
```ts
// In CustomerForm (use-resource-form options)
initialize: (id) => api.customers.show(id), // requires CrudClient.show()
queryKey: [CUSTOMER_ROUTES.BY_ID, resourceId],
// In CrudClient (packages/api/src/infra/crud-client.ts)
async show(id: string) {
return this.get(this.byIdRoute, { params: { id } } as never)
}
```
**Effort:** S — needs `CrudClient.show()` added and each form updated to pass `initialize`.
---
### 3. `FormDialog` — Single Dialog Limitation
**File:** `shared/components/form-dialog.tsx`
**Problem:** Dialog state is keyed to fixed URL params `dialog` and `resourceId`. If two independent `FormDialog` instances are on the same page (e.g., a main resource form and a nested "Add Customer" side panel), they share the same URL params and will conflict.
**Fix:** Accept a configurable `paramKey` prop:
```ts
export const createFormDialogParams = (key: string) => ({
[`${key}_dialog`]: parseAsBoolean.withDefault(false),
[`${key}_resourceId`]: parseAsString,
})
```
**Effort:** S — requires updating `FormDialog`, `useFormDialog`, and `useResourcePage` to accept a `paramKey`.
---
## Medium Priority
### 4. No Global Search / Filter Support
**File:** `shared/data-view/table-view/use-data-table-query.ts`, `shared/data-view/table-view/search-params.ts`
**Problem:** `dataTableSearchParams` only supports `page`, `per_page`, `sort_by`, `sort_order`. There is no standard way to add resource-specific filters (e.g., search by name, filter by status).
**Proposed:** Add an optional `filters` object to `useDataTableQuery` that maps to additional query params:
```ts
useDataTableQuery({
queryKey: ["customers"],
client,
filters: {
search: parseAsString,
status: parseAsStringEnum(["active", "inactive"] as const),
},
})
// → adds ?search=&status= params and includes them in client.list()
```
**Effort:** M — requires a design decision and updates to `use-data-table-query`, `data-table.tsx`, and `resource-page`.
---
### 5. Grid View — Not Implemented
**File:** `shared/data-view/grid-view/` (empty directory)
**Problem:** The `grid-view` folder was scaffolded but never implemented. The data-view layer is clearly designed to support multiple views (table/grid), but no toggle exists.
**Proposed:**
- Implement a `GridView` component that accepts the same `DataViewProps` as `DataTable`.
- Add a view toggle (Table | Grid) in the `ResourcePage` header or `DashboardHeader`.
- Persist the selected view in a URL param (`?view=grid`).
**Effort:** ML depending on grid card design requirements.
---
### 6. `useMutation` Error Handling — Not Reusable
**File:** `modules/customers/customer-form.tsx` (and all other feature forms)
**Problem:** The pattern of mapping `ApiError.validationErrors` to `form.setError` is duplicated in every form's `onError` handler.
**Fix:** Extract a `useFormMutation` hook:
```ts
// shared/hooks/use-form-mutation.ts
export function useFormMutation<TValues extends FieldValues, TResponse = unknown>(
form: UseFormReturn<TValues>,
options: UseMutationOptions<TResponse, Error, TValues>,
) {
return useMutation({
...options,
onError: (err, vars, ctx) => {
if (err instanceof ApiError && err.validationErrors) {
Object.entries(err.validationErrors).forEach(([field, msgs]) => {
form.setError(field as keyof TValues as any, { message: msgs[0] })
})
}
options.onError?.(err, vars, ctx)
},
})
}
```
**Effort:** XS — purely additive. Existing forms can be migrated incrementally.
---
### 7. `CUSTOMER_CREATED_EVENT` — Unused Custom Event
**File:** `modules/customers/customer-form.tsx`
**Problem:** `CustomerForm` dispatches `window.dispatchEvent(new CustomEvent("customer:created"))` on success, but nothing in the codebase listens to this event. Cache invalidation is already handled via `onSuccess``invalidateQuery()`. The event dispatch is dead code.
**Fix:** Remove the event dispatch from `CustomerForm` (and the `CUSTOMER_CREATED_EVENT` export) unless there is a known future use case, such as notifying a sibling component outside the React tree.
**Effort:** XS.
---
### 8. Pagination Meta Split — Inconsistency
**File:** `shared/data-view/table-view/use-data-table-query.ts`
**Problem:** `useDataTableQuery` returns `pagination` with `pageCount: 1, total: 0` as placeholders. The real values come from `data.meta` and are calculated inside `ResourcePage.tsx`. This means:
- `useResourcePage` consumers who render the table directly (outside `ResourcePage`) need to duplicate the `pageCount`/`total` derivation.
- The `pagination` object returned by the hook is misleading until data loads.
**Fix:** Either move the meta derivation inside `useDataTableQuery` (requiring it to accept a response shape hint), or document this as an intentional split and annotate it.
**Effort:** XSS.
---
## Low Priority / Nice to Have
### 9. Row Selection for Bulk Actions
There is no row selection or bulk-delete support. TanStack Table supports `rowSelection` state natively. Adding a checkbox column and a "Delete selected" toolbar would benefit resource-heavy pages.
### 10. Error Boundary Around Table and Form
If a render error occurs inside `DataTable` or a feature form, it will bubble up and crash the whole page. Wrapping with `<ErrorBoundary>` (e.g., via `react-error-boundary`) would improve resilience.
### 11. `ConfirmDialog` — Not Enforced in Layout
**Problem:** `<ConfirmDialog />` must be manually mounted in `app/(authenticated)/layout.tsx`. There is no lint rule or runtime warning if it is missing. If a developer forgets to add it to a new layout, `confirm()` calls will silently resolve to `false` (no dialog shown, deletion blocked).
**Fix:** Add a development-mode warning inside the `confirm()` function if the store's `resolve` is never set after a timeout.
### 12. Column Visibility / Hide
`ColumnHeader` has a "Hide" dropdown menu item (via `column.toggleVisibility(false)`), but there is no global "Show columns" control to restore hidden columns. Either remove the hide option or add a column visibility popup to `DataTable`.
### 13. `dataTableSearchParamsCache` — Imported but Unused in App Router
`dataTableSearchParamsCache` is exported but the pages use `"use client"` throughout. If server components are introduced for any list page, the cache needs wiring in the layout via `nuqs`'s `SearchParamsProvider`.
---
## Checklist
- [x] #1 — Memoize `useAuthApi`
- [x] #2 — Add `CrudClient.show()` and wire `initialize` in feature forms
- [x] #3 — Make `FormDialog` param key configurable
- [ ] #4 — Design and implement filter/search param support in `useDataTableQuery`
- [ ] #5 — Implement `GridView` and view toggle
- [x] #6 — Extract `useFormMutation` hook
- [x] #7 — Remove unused `CUSTOMER_CREATED_EVENT`
- [x] #8 — Move pagination meta derivation into `useDataTableQuery`
- [ ] #9 — Row selection + bulk actions
- [ ] #10 — Error boundaries
- [x] #11 — Dev-mode warning for missing `ConfirmDialog`
- [ ] #12 — Column visibility restore control
- [ ] #13 — Wire `SearchParamsProvider` if server components are adopted

View File

@ -1,308 +0,0 @@
# Form System
This document covers the generic form infrastructure used by all resource forms in the dashboard.
---
## Layer Overview
```
<CustomerForm> ← Feature-specific form component
└─ useResourceForm(...) ← State: RHF form + optional fetch for edit mode
└─ useMutation(...) ← Create or update mutation with toast
└─ <Rhform form onSubmit> ← FormProvider + <form> wrapper
└─ <RhfTextField> ← RHF-connected text input
└─ <RhfSelectField> ← RHF-connected static select
└─ <RhfAsyncSelectField> ← RHF-connected async combobox (fetches options)
└─ <Button type="submit">
```
---
## `useResourceForm` — Form Initialization Hook
**File:** `shared/hooks/use-resource-form.ts`
Manages the react-hook-form instance with Zod validation and handles pre-filling the form when editing an existing item.
```ts
const { form, isEditing, isInitializing } = useResourceForm<TFormValues, TApiData>({
schema, // Zod schema → resolver
defaultValues, // Default form values for create mode
resourceId, // null → create, "5" → edit
initialData, // Optional: pre-fetched data (e.g. from table row)
mapToFormValues, // Maps API data shape → form values shape
initialize, // Optional: fetch fn called when resourceId is set (re-fetch from server)
queryKey, // Optional: React Query key for the initialize query
})
```
### Behavior
| Scenario | Result |
|---|---|
| `resourceId` is null | `isEditing = false`, form uses `defaultValues` |
| `resourceId` set, `initialData` provided, no `initialize` | Form pre-filled from `initialData` |
| `resourceId` set, `initialize` provided | `useQuery` calls `initialize(resourceId)`; form pre-filled from response |
| `resourceId` set, both provided | `useQuery` result takes precedence over `initialData` |
### `mapToFormValues`
Transforms the API response shape into the form's internal value shape. Field names, null handling, and relation objects are all resolved here:
```ts
function mapCustomerToFormValues(data: unknown): CustomerFormValues {
const c = (data as any)?.data ?? data ?? {}
return {
first_name: c.first_name || "",
customer_type: toRelation(c.customer_type_id, c.customer_type_name),
// ...
}
}
```
### `toRelation` / `toId` Helpers
**File:** `shared/lib/utils.ts`
Relation fields (foreign keys) are stored in the form as `{ value: string, label: string } | null` objects (combobox-compatible), not raw IDs.
```ts
// API data → form object
toRelation(id, name) // → { value: String(id), label: name } or null
// Form object → API payload
toId(relation) // → relation?.value ?? null
```
---
## `Rhform` — Form Provider Wrapper
**File:** `shared/components/form/rhform.tsx`
Wraps `react-hook-form`'s `FormProvider` and a `<form>` element. Avoids passing the `form` instance manually through every field.
```tsx
<Rhform form={form} onSubmit={handleSubmit}>
{/* children have access to form context via useFormContext */}
</Rhform>
```
---
## `RhfField` — Generic RHF Controller Connector
**File:** `shared/components/form/rhf-field.tsx`
Low-level generic component that connects any field control to react-hook-form. Used internally by all `Rhf*` field wrappers. You rarely need to use this directly.
```tsx
<RhfField
name="email"
label="Email"
required
component={TextInputField} // Any BaseFieldControlProps-compatible control
placeholder="john@example.com"
type="email"
/>
```
---
## `FieldShell` — Label + Error Layout
**File:** `shared/components/form/field-shell.tsx`
Renders the `FieldLabel`, `FieldDescription`, and `FieldError` around a control. Used by `RhfField` and `RhfAsyncSelectField` directly.
---
## Ready-Made `Rhf*` Field Wrappers
All wrappers follow the same pattern: they accept `name`, `label`, `description`, `required`, `disabled` plus any control-specific props.
### `RhfTextField`
```tsx
<RhfTextField name="first_name" label="First Name" placeholder="John" required />
<RhfTextField name="email" label="Email" type="email" />
<RhfTextField name="phone" label="Phone" type="tel" />
```
### `RhfTextareaField`
```tsx
<RhfTextareaField name="notes" label="Notes" rows={4} />
```
### `RhfCheckboxField`
```tsx
<RhfCheckboxField name="is_active" label="Active" />
```
### `RhfSelectField` — Static Options
```tsx
const options = [
{ value: "Mr.", label: "Mr." },
{ value: "Mrs.", label: "Mrs." },
]
<RhfSelectField name="salutation" label="Salutation" options={options} />
```
### `RhfAsyncSelectField` — Remote Options (Single)
Fetches options via React Query and renders a searchable combobox.
```tsx
<RhfAsyncSelectField
name="customer_type"
label="Customer Type"
placeholder="Select customer type"
queryKey={["customer-types"]}
listFn={() => api.customers.listCustomerTypes()}
mapOption={(item) => ({ value: String(item.id), label: item.name })}
getOptionValue={(o) => o} // store the full object, not just the string value
getOptionLabel={(o) => o.label}
/>
```
**Data source options** (choose one):
| Prop | Description |
|---|---|
| `listFn` | Calls an API method; response is unwrapped automatically via `extractItems` |
| `loadOptions` | Returns `Promise<any[]>` directly (custom logic) |
The `mapOption` prop transforms raw API items to `{ value, label }` objects.
**`staleTime`** defaults to 5 minutes. Override for highly dynamic lookups.
### `RhfAsyncMultiSelectField` — Remote Options (Multi)
Same as `RhfAsyncSelectField` but stores an array of values:
```tsx
<RhfAsyncMultiSelectField
name="tags"
multiple
label="Tags"
queryKey={["tags"]}
listFn={() => api.tags.list()}
mapOption={(item) => ({ value: String(item.id), label: item.name })}
getOptionValue={(o) => o}
getOptionLabel={(o) => o.label}
/>
```
---
## Anatomy of a Feature Form
```tsx
// modules/my-feature/my-feature-form.tsx
const DEFAULT_VALUES: MyFormValues = { name: "", ... }
function mapToFormValues(data: unknown): MyFormValues { ... }
function mapFormToPayload(values: MyFormValues) { ... }
export function MyFeatureForm({ resourceId, initialData, onSuccess }: ResourceFormProps) {
const api = useAuthApi()
// 1. Form initialization
const { form, isEditing } = useResourceForm<MyFormValues>({
schema: myFormSchema,
defaultValues: DEFAULT_VALUES,
resourceId,
initialData,
mapToFormValues,
})
// 2. Mutation
const { mutate, error, isPending } = useMutation({
mutationFn: (values: MyFormValues) => {
const payload = mapFormToPayload(values)
const promise = isEditing
? api.myResources.update(resourceId!, payload)
: api.myResources.create(payload)
toast.promise(promise, { loading: "Saving...", success: "Saved!", error: "Failed." })
return promise
},
onSuccess: () => { form.reset(); onSuccess?.() },
onError: (err) => {
if (err instanceof ApiError && err.validationErrors) {
Object.entries(err.validationErrors).forEach(([field, msgs]) => {
form.setError(field as any, { message: msgs[0] })
})
}
},
})
// 3. Render
return (
<Rhform form={form} onSubmit={(v) => mutate(v)}>
{error && <Alert variant="destructive">...</Alert>}
<FieldGroup>
<RhfTextField name="name" label="Name" required />
<Button type="submit" disabled={isPending}>
{isPending ? "Saving..." : isEditing ? "Update" : "Create"}
</Button>
</FieldGroup>
</Rhform>
)
}
```
---
## Zod Schema Conventions
**File:** `modules/<feature>/<feature>.schema.ts`
### Relation Fields
Relation fields (foreign-key selects) use a shared `relationFieldSchema`:
```ts
import { z } from "zod"
const relationFieldSchema = z
.object({ value: z.string(), label: z.string() })
.nullable()
// In the schema:
const mySchema = z.object({
category: relationFieldSchema, // → { value: "3", label: "Electronics" } | null
name: z.string().min(1, "Name is required"),
})
```
### Email Validation Pattern
Use union to allow empty strings:
```ts
email: z.union([
z.string().email("Enter a valid email address"),
z.literal(""),
]).optional(),
```
---
## `extractItems` — Response Unwrapper
Used internally by `RhfAsyncSelectField` to normalize different API response shapes:
```ts
// Handles all of:
extractItems([{ id: 1, name: "A" }]) // → same array
extractItems({ data: [{ id: 1, name: "A" }] }) // → data array
extractItems({ data: { data: [{ id: 1, name: "A" }] } }) // → nested data
```
This handles both plain arrays, standard Laravel list responses, and nested pagination wrappers.

View File

@ -1,128 +0,0 @@
# CRUD Pattern — Overview
This document describes the full-stack CRUD pattern used across the dashboard. Every resource page (Customers, Vendors, Employees, etc.) is built from the same thin layer of generic, composable utilities.
---
## Architecture Layers
```
┌──────────────────────────────────────────────────────────────────┐
│ Feature Page (apps/dashboard/app/…/page.tsx) │
│ • Declares columns, title, routeKey, getClient, renderForm │
└──────────────────┬───────────────────────────────────────────────┘
│ uses
┌──────────────────▼───────────────────────────────────────────────┐
│ ResourcePage (shared/data-view/resource-page) │
│ • Combines DashboardPage + FormDialog + DataTable │
│ • Delegates state to useResourcePage │
└──────┬───────────────────────────────┬────────────────────────────┘
│ │
┌──────▼──────────────┐ ┌────────────▼───────────────────────────┐
│ useResourcePage │ │ DataTable (shared/data-view/table-view)│
│ • useDataTableQuery │ │ • TanStack Table (manual mode) │
│ • form dialog state │ │ • Pagination, sorting, skeleton │
│ • delete mutation │ │ • DataViewProvider context │
└──────┬──────────────┘ └────────────────────────────────────────┘
┌──────▼──────────────────────────────────────────────────────────┐
│ useAuthApi → createApi() → CrudClient → openapi-fetch │
│ (packages/api — fully type-safe from OpenAPI schema) │
└─────────────────────────────────────────────────────────────────┘
┌──────▼──────────────────────────────────────────────────────────┐
│ Feature Form (modules/<feature>/<feature>-form.tsx) │
│ • useResourceForm (react-hook-form + Zod + optional fetch) │
│ • useMutation (create / update) │
│ • RhfTextField / RhfSelectField / RhfAsyncSelectField … │
└─────────────────────────────────────────────────────────────────┘
```
---
## Key Files
| Layer | File | Purpose |
|---|---|---|
| Feature page | `app/(authenticated)/sales/customers/page.tsx` | Minimal feature config |
| Generic page shell | `shared/data-view/resource-page/resource-page.tsx` | Layout + wiring |
| Page logic hook | `shared/data-view/resource-page/use-resource-page.ts` | State + mutations |
| Table | `shared/data-view/table-view/data-table.tsx` | TanStack Table UI |
| Table data hook | `shared/data-view/table-view/use-data-table-query.ts` | React Query + URL state |
| Form dialog | `shared/components/form-dialog.tsx` | URL-driven dialog trigger |
| Confirm dialog | `shared/components/confirm-dialog.tsx` | Imperative async confirm |
| Form state hook | `shared/hooks/use-resource-form.ts` | RHF + Zod + optional re-fetch |
| Form field wrappers | `shared/components/form/` | RhfTextField, RhfSelectField, … |
| API layer | `packages/api/src/infra/crud-client.ts` | Generic CRUD HTTP client |
| Auth API hook | `shared/useApi.ts` | Creates authenticated API instance |
---
## Data Flow — Read (LIST)
```
URL params change (page / sort)
→ useDataTableQuery re-runs queryFn
→ client.list({ page, per_page, sort_by, sort_order })
→ CrudClient.list()
→ ApiClient.get(indexRoute, { query })
→ openapi-fetch GET /api/customers
← { data: [...], meta: { last_page, total, ... } }
→ DataTable renders rows + pagination
```
## Data Flow — Write (CREATE / UPDATE)
```
User fills form → submits
→ useMutation mutationFn
→ api.customers.create(payload) or .update(id, payload)
→ CrudClient.create() / .update()
→ ApiClient.post() / .put()
→ openapi-fetch POST|PUT /api/customers[/{id}]
← 200/201 response
→ onSuccess: invalidateQuery() → list refreshes
→ onError: ApiError.validationErrors → form.setError(field, msg)
```
## Data Flow — Delete
```
User clicks Delete in actions column
→ confirm({ title, description, variant: "destructive" }) → awaits boolean
→ if confirmed: deleteItem(id)
→ useMutation → client.destroy(id)
→ ApiClient.delete()
← success
→ invalidateQuery()
```
---
## Creating a New Feature Page
To add a new resource page following this pattern:
1. Create schema: `modules/<feature>/<feature>.schema.ts`
2. Create form: `modules/<feature>/<feature>-form.tsx`
3. Create page: `app/(authenticated)/<section>/<feature>/page.tsx`
The page needs only 5 props on `<ResourcePage>`:
```tsx
<ResourcePage<MyClient>
title="My Resource"
pageTitle="My Resources"
routeKey={MY_ROUTES.INDEX}
getClient={(api) => api.myClient}
columns={({ actionsColumn }) => [
{ accessorKey: "name", header: () => <ColumnHeader ... /> },
actionsColumn(),
]}
renderForm={({ resourceId, initialData, onSuccess }) => (
<MyForm resourceId={resourceId} initialData={initialData} onSuccess={onSuccess} />
)}
/>
```
See the [Resource Page](./resource-page.md), [Data Fetching](./data-fetching.md), and [Form System](./form-system.md) docs for details on each layer.

View File

@ -1,179 +0,0 @@
# Resource Page
The `ResourcePage` component is the primary generic shell for any CRUD list page. It composes layout, table, dialog, and state management into a single, reusable component.
---
## Files
| File | Description |
|---|---|
| `shared/data-view/resource-page/resource-page.tsx` | The React component |
| `shared/data-view/resource-page/use-resource-page.ts` | The state/logic hook |
| `shared/data-view/resource-page/index.ts` | Public exports |
---
## `<ResourcePage>` Component
### Props
```ts
type ResourcePageProps<TClient extends ResourcePageClient> = {
// Required
title: string // Used for the "Add" button label and dialog title
routeKey: string // React Query cache key (e.g. CUSTOMER_ROUTES.INDEX)
getClient: (api: ApiInstance) => TClient // Selects the domain client from the API
columns: // Column definitions or a factory receiving helpers
| ColumnDef<ResourceItem<TClient>>[]
| ((helpers: ResourcePageColumnHelpers<TClient>) => ColumnDef<ResourceItem<TClient>>[])
renderForm: (props: ResourceFormProps<TClient>) => React.ReactNode
// Optional
pageTitle?: string // Heading text (defaults to undefined)
queryOptions?: Omit<UseQueryOptions<...>, "queryKey" | "queryFn">
}
```
### `ResourcePageColumnHelpers`
Passed to the `columns` callback, providing three pre-wired helpers:
| Helper | Type | Description |
|---|---|---|
| `actionsColumn(options?)` | `ColumnDef` | Pre-built Edit + Delete dropdown column |
| `openEdit(row)` | `(row: TItem) => void` | Opens dialog with row pre-filled |
| `deleteItem(id)` | `(id: string) => Promise<unknown>` | Deletes with toast + confirmation |
The `actionsColumn` factory (`createActionsColumn`) can be further customized:
```ts
actionsColumn({
onEdit: (row) => customOpen(row),
onDelete: async (row) => {
// completely override delete behavior
},
})
```
### `ResourceFormProps`
Passed to the `renderForm` callback:
| Prop | Type | Description |
|---|---|---|
| `resourceId` | `string \| null` | `null` on create; the item's `id` string on edit |
| `initialData` | `TItem \| null` | The full row object on edit (from the table's in-memory state) |
| `onSuccess` | `() => void` | Call this after a successful mutation to refresh the list |
---
## `useResourcePage` Hook
Encapsulates all state and logic. Returned by `ResourcePage` internally but also exported for use in custom page layouts.
```ts
const page = useResourcePage<MyClient>({ routeKey, getClient, queryOptions })
```
### Returns
| Key | Type | Description |
|---|---|---|
| `data` | `CrudListResponse<TClient>` | Raw API response |
| `isLoading` | `boolean` | True while initial fetch is in progress |
| `pagination` | `DataViewPaginationState` | `{ page, pageSize, pageCount, total }` |
| `sorting` | `DataViewSorting` | Current sort state |
| `handleChange` | `(event: DataViewChangeEvent) => void` | Handles pagination and sort events |
| `invalidateQuery` | `() => void` | Busts the React Query cache for the current query key |
| `selectedItem` | `TItem \| null` | The row being edited (populated by `openEdit`) |
| `openEdit(row)` | fn | Sets `selectedItem` and opens dialog |
| `openCreate()` | fn | Clears `selectedItem` and opens dialog |
| `openDialog(id?)` | fn | Low-level dialog open (sets `?dialog=true&resourceId=id` in URL) |
| `closeDialog()` | fn | Closes dialog (removes URL params) |
| `isDialogOpen` | `boolean` | Current dialog open state |
| `dialogResourceId` | `string \| null` | Current resource ID from URL |
| `deleteItem(id)` | `(id: string) => Promise<unknown>` | Mutation that destroys a resource |
| `actionsColumn(options?)` | fn | Generates the actions `ColumnDef` |
| `client` | `TClient` | The domain API client |
| `api` | `ApiInstance` | Full authenticated API object |
---
## `FormDialog` — URL-Driven Dialog
`FormDialog` and its companion hook `useFormDialog` manage dialog open/close state **via URL query parameters**:
| URL param | Value | Meaning |
|---|---|---|
| `dialog` | `true` / absent | Dialog open/closed |
| `resourceId` | string / absent | ID of the item being edited |
This means sharing or refreshing the URL with `?dialog=true&resourceId=5` will reopen the dialog on the same item (as long as `initialData` is in memory—see [Enhancement Plan](./enhancement-plan.md)).
### `useFormDialog`
```ts
const { isOpen, resourceId, open, close } = useFormDialog()
open("5") // opens dialog in edit mode
open() // opens dialog in create mode
close() // closes dialog, clears resourceId
```
---
## `ConfirmDialog` — Imperative Async Confirm
`ConfirmDialog` is a **singleton store-driven dialog** mounted once in the root layout. It exposes an imperative `confirm()` function:
```ts
import { confirm } from "@/shared/components/confirm-dialog"
const ok = await confirm({
title: "Delete this item?",
description: "This action cannot be undone.",
confirmLabel: "Delete",
variant: "destructive", // shows destructive styling + trash icon
})
if (ok) { /* proceed */ }
```
> **Important:** `<ConfirmDialog />` must be rendered once in the root layout. If it is not mounted, `confirm()` will open a dialog that is never displayed.
---
## `createActionsColumn`
A standalone factory for generating the standard Edit + Delete column:
```ts
import { createActionsColumn } from "@/shared/data-view/table-view"
createActionsColumn<MyItem>({
onEdit: (row) => openEdit(row),
onDelete: async (row) => {
const confirmed = await confirm({ ... })
if (confirmed) await deleteItem(String(row.id))
},
})
```
---
## Component Tree
```
<ResourcePage>
└─ <DashboardPage header={...} title={pageTitle}>
├─ <DashboardHeader>
│ └─ <FormDialog title={title}> ← "Add Customer" button + Dialog shell
│ └─ renderForm(resourceId) ← Feature-specific form
└─ <Card>
└─ <CardContent>
└─ <DataTable columns data pagination sorting onChange isLoading>
├─ <DataViewProvider> ← Shares state via context
├─ TanStack Table (manual pagination + sorting)
├─ Skeleton rows while loading
└─ <DataViewPagination> ← Page controls + rows-per-page
```

View File

@ -1,261 +0,0 @@
# Garage Management System — Feature Implementation Checklist
> **Generated**: March 27, 2026
> **Reference**: Postman API Collection (`packages/api/postman/collection.json`)
> **Ordered by**: Dependency level (no dependencies → most complex relations)
---
## How to Read This Checklist
- **✅ Full** = Page + Module (Form + Schema) + API Client all exist
- **🔧 API Only** = API Client exists, but no dashboard page/module yet
- **⬜ Not Started** = No implementation found
- **Depends on** = Other resources that must exist before this one (based on foreign keys in Postman collection)
---
## Level 0 — Zero Dependencies (Standalone Reference Data)
These resources have no foreign key references. They are the foundation.
| # | Resource | Status | Implementation Details |
|---|----------|--------|----------------------|
| 1 | Auth (Login / Profile / Logout) | ✅ Full | Page: `(auth)/login` · Module: `auth/` · Client: `AuthClient` |
| 2 | Countries | 🔧 API Only | Client: `GeoClient` — used by Customer form |
| 3 | Customer Types | 🔧 API Only | Client: `CustomersClient.listCustomerTypes()` |
| 4 | Referral Sources | 🔧 API Only | Client: `ReferralSourcesClient` |
| 5 | Payment Terms | 🔧 API Only | Client: `PaymentTermsClient` |
| 6 | Payment Modes | 🔧 API Only | Client: `PaymentsClient` |
| 7 | Shop Types | ✅ Full | Page: `settings/shop-type` · Module: `settings/shop-type/` · Client: `ShopTypesClient` |
| 8 | Vehicle Body Types | 🔧 API Only | Client: `VehicleAttributesClient` — inline form in Vehicles |
| 9 | Vehicle Fuel Types | 🔧 API Only | Client: `VehicleAttributesClient` — inline form in Vehicles |
| 10 | Vehicle Transmissions | 🔧 API Only | Client: `VehicleAttributesClient` — inline form in Vehicles |
| 11 | Vehicle Colors | 🔧 API Only | Client: `VehicleAttributesClient` — inline form in Vehicles |
| 12 | Document Types | 🔧 API Only | Client: `VehicleDocumentsClient` |
| 13 | Unit Types | 🔧 API Only | Client: `InventoryClient` — inline form in Services |
| 14 | Labels | 🔧 API Only | Client: `LabelsClient` |
| 15 | Insurance Types | 🔧 API Only | Client: `InsuranceTypesClient` |
| 16 | Inspection Categories | 🔧 API Only | Client: `InspectionsClient` — inline form in Inspections |
| 17 | Check Point Labels | 🔧 API Only | Client: `InspectionsClient` |
| 18 | Quick Remarks | 🔧 API Only | Client: `EstimatesClient` |
| 19 | Quick Notes | 🔧 API Only | Client: `EstimatesClient` |
| 20 | Reasons | 🔧 API Only | Client: `LabelsClient` (or standalone) |
| 21 | Task Types | 🔧 API Only | Client: `TasksClient` |
| 22 | Task Sections | 🔧 API Only | Client: `TasksClient` |
| 23 | Invoice Labels | 🔧 API Only | Client: exists in collection |
| 24 | Holiday Years | 🔧 API Only | Client: exists in collection |
| 25 | Taxes | 🔧 API Only | Client: exists in collection |
| 26 | Departments | 🔧 API Only | Client: `DepartmentsClient` — inline form in Services |
| 27 | Labor Rates | 🔧 API Only | Client: `InventoryClient` |
| 28 | Vendors | 🔧 API Only | Client: `VendorsClient` |
| 29 | Shop Calendars | ✅ Full | Page: `productivity/shop-calendars` · Module: `shop-calendars/` · Client: `ShopCalendarsClient` |
| 30 | Shop Timings | ✅ Full | Page: `productivity/shop-timings` · Module: `shop-timings/` · Client: `ShopTimingsClient` |
| 31 | Settings | 🔧 API Only | Client: exists in collection (GET/PUT only) |
---
## Level 1 — Single-Level Dependencies
These depend only on Level 0 resources.
| # | Resource | Status | Depends On | Implementation Details |
|---|----------|--------|------------|----------------------|
| 32 | States | 🔧 API Only | Countries | Client: `GeoClient` |
| 33 | Inventory Categories | 🔧 API Only | Shop Types | Client: `InventoryClient` — inline form in Services |
| 34 | Vendor Addresses | 🔧 API Only | Vendors, Countries, States | Client: `VendorsClient.createAddress()` |
| 35 | Holidays | 🔧 API Only | Holiday Years | Client: exists in collection |
| 36 | Make and Models | 🔧 API Only | Shop Types, Body Types, Fuel Types, Transmissions | Client: exists in collection |
---
## Level 2 — Core Business Entities
These depend on Level 0 + Level 1 resources and are used by many higher-level features.
| # | Resource | Status | Depends On | Implementation Details |
|---|----------|--------|------------|----------------------|
| 37 | Customers | ✅ Full | Customer Types, Referral Sources, Payment Terms, Countries, States | Page: `sales/customers` · Module: `customers/` · Client: `CustomersClient` |
| 38 | Vehicles | ✅ Full | Shop Types, Body Types, Fuel Types, Transmissions, Colors | Page: `sales/vehicles` · Module: `vehicles/` · Client: `VehiclesClient` · 5 inline forms |
| 39 | Expense Items | 🔧 API Only | Inventory Categories, Unit Types, Departments | Client: `ExpensesClient` |
---
## Level 3 — Operational Resources
These depend on Level 02 resources.
| # | Resource | Status | Depends On | Implementation Details |
|---|----------|--------|------------|----------------------|
| 40 | Employees | ✅ Full | Departments, Shop Calendars, Shop Timings | Page: `productivity/employees` · Module: `employees/` · Client: `EmployeesClient` |
| 41 | Parts | ✅ Full | Shop Types, Inventory Categories, Unit Types, Departments, Vendors | Page: `items/parts` · Module: `parts/` · Client: `PartsClient` |
| 42 | Services | ✅ Full | Shop Types, Inventory Categories, Unit Types, Departments | Page: `items/services` · Module: `services/` · Client: `ServicesClient` · 4 inline forms |
| 43 | Vehicle Documents | 🔧 API Only | Vehicles, Document Types | Client: `VehicleDocumentsClient` |
| 44 | Vehicle Mileage | 🔧 API Only | Vehicles | Client: `VehicleDocumentsClient` |
| 45 | Time Sheets | 🔧 API Only | Employees | Client: exists in collection |
| 46 | Invoice Sequences | 🔧 API Only | Departments | Client: exists in collection |
---
## Level 4 — Composite Service Resources
These depend on Level 03 resources.
| # | Resource | Status | Depends On | Implementation Details |
|---|----------|--------|------------|----------------------|
| 47 | Service Groups | ✅ Full | Shop Types, Inventory Categories, Unit Types, Departments | Page: `items/service-group` · Module: `service-groups/` · Client: `ServiceGroupsClient` |
| 48 | Service Group Includes | 🔧 API Only | Service Groups | Client: part of Service Group Details |
| 49 | Service Group Services | 🔧 API Only | Service Groups, Services, Labor Rates, Taxes | Client: part of Service Group Details |
| 50 | Service Group Parts | 🔧 API Only | Service Groups, Parts, Taxes | Client: part of Service Group Details |
| 51 | Service Group Pricings | 🔧 API Only | Service Groups, Shop Types, Labor Rates, Fuel Types, Body Types | Client: part of Service Group Details |
---
## Level 5 — Workflow & Operations
These are core garage workflow features depending on customers, vehicles, employees, etc.
| # | Resource | Status | Depends On | Implementation Details |
|---|----------|--------|------------|----------------------|
| 52 | Inspections | ✅ Full | Customers, Vehicles, Departments, Inspection Categories, Employees | Page: N/A (module exists) · Module: `inspections/` · Client: `InspectionsClient` · 1 inline form |
| 53 | Inspection Check Points | 🔧 API Only | Inspections, Check Point Labels | Client: `InspectionsClient` |
| 54 | Estimates | 🔧 API Only | Customers, Vehicles, Departments, Labels | Client: `EstimatesClient` |
| 55 | Job Cards | 🔧 API Only | Customers, Vehicles, Departments, Labels, Employees | Client: `JobCardsClient` (richest API — status workflow, remarks, attachments) |
---
## Level 6 — Financial & Scheduling
These depend on Job Cards and other Level 5 resources.
| # | Resource | Status | Depends On | Implementation Details |
|---|----------|--------|------------|----------------------|
| 56 | Appointments | 🔧 API Only | Customers, Vehicles, Departments, Job Cards, Employees, Labels | Client: `AppointmentsClient` |
| 57 | Tasks | 🔧 API Only | Task Types, Task Sections, Job Cards, Employees, Departments | Client: `TasksClient` |
| 58 | Purchase Orders | 🔧 API Only | Job Cards, Vendors, Departments, Labels, Parts | Client: `PurchaseOrdersClient` |
| 59 | Bills | 🔧 API Only | Job Cards, Vendors, Vendor Addresses, Payment Terms, Departments, Labels, Parts | Client: `ExpensesClient` |
| 60 | Expenses | 🔧 API Only | Job Cards, Expense Items, Vendors, Departments, Labels | Client: `ExpensesClient` |
| 61 | Payment Received | 🔧 API Only | Job Cards, Payment Modes, Customers | Client: `PaymentsClient` |
| 62 | Inventory Adjustments | 🔧 API Only | Parts, Job Cards, Invoices, Reasons | Client: exists in collection |
---
## Level 7 — Invoicing & Credit System (Most Complex)
These are the most complex resources with the deepest dependency chains.
| # | Resource | Status | Depends On | Implementation Details |
|---|----------|--------|------------|----------------------|
| 63 | Invoices | 🔧 API Only | Customers, Vehicles, Departments, Invoice Sequences, Labels, Inspection Categories, Parts, Services, Expense Items, Service Groups | Client: exists in collection |
| 64 | Invoice Documents | 🔧 API Only | Invoices, Customers, Vehicles, Document Types | Client: exists in collection |
| 65 | Invoice Notes | 🔧 API Only | Invoices | Client: exists in collection |
| 66 | Credit Notes | 🔧 API Only | Customers, Parts, Services, Expenses, Inspection Categories, Labels | Client: exists in collection |
| 67 | Payment Mades | 🔧 API Only | Vendors, Employees, Bills, Expenses, Payment Modes | Client: exists in collection |
| 68 | Vendor Credits | 🔧 API Only | Vendors, Departments, Parts, Services, Expenses, Labels | Client: exists in collection |
---
## Summary
### Implementation Progress
| Category | Total | ✅ Full | 🔧 API Only | ⬜ Not Started |
|----------|-------|---------|-------------|----------------|
| Level 0 — Standalone | 31 | 4 | 27 | 0 |
| Level 1 — Single Dep | 5 | 0 | 5 | 0 |
| Level 2 — Core Entities | 3 | 2 | 1 | 0 |
| Level 3 — Operational | 7 | 3 | 4 | 0 |
| Level 4 — Composite | 5 | 1 | 4 | 0 |
| Level 5 — Workflows | 4 | 1 | 3 | 0 |
| Level 6 — Financial | 7 | 0 | 7 | 0 |
| Level 7 — Invoicing | 6 | 0 | 6 | 0 |
| **Total** | **68** | **11** | **57** | **0** |
### Pages with Full UI (11 total)
1. Auth (Login)
2. Shop Types (Settings)
3. Shop Calendars (Productivity)
4. Shop Timings (Productivity)
5. Customers (Sales)
6. Vehicles (Sales)
7. Employees (Productivity)
8. Parts (Items)
9. Services (Items)
10. Service Groups (Items)
11. Inspections (Module only — no page route yet)
### API Clients Without Pages — Priority Recommendations
Based on the roadmap (Phase 1 — Garage Operations), these are the highest-priority missing pages:
1. **Job Cards** — Core garage workflow, API client is the most feature-rich
2. **Estimates** — Pre-job-card workflow
3. **Appointments** — Scheduling system
4. **Inspections Page** — Module exists but no page route
5. **Departments** — Referenced by almost every form
6. **Vendors** — Needed for Parts purchasing and Bills
7. **Invoices** — Phase 2 but API is ready
---
## Dependency Graph (Simplified)
```
Level 0 (Foundation)
├── Auth, Countries, Shop Types, Customer Types, Referral Sources
├── Payment Terms, Payment Modes, Document Types, Unit Types, Labels
├── Vehicle Attributes (Body, Fuel, Transmission, Colors)
├── Inspection Categories, Check Point Labels, Insurance Types
├── Quick Remarks/Notes, Reasons, Task Types/Sections
├── Holiday Years, Taxes, Departments, Labor Rates
├── Vendors, Shop Calendars, Shop Timings, Invoice Labels, Settings
Level 1 (Single Dependency)
├── States → Countries
├── Inventory Categories → Shop Types
├── Vendor Addresses → Vendors + Countries + States
├── Holidays → Holiday Years
├── Make and Models → Shop Types + Vehicle Attributes
Level 2 (Core Entities)
├── Customers → Customer Types + Referral Sources + Payment Terms + Geo
├── Vehicles → Shop Types + Vehicle Attributes
├── Expense Items → Inventory Categories + Unit Types + Departments
Level 3 (Operational)
├── Employees → Departments + Shop Calendars + Shop Timings
├── Parts → Shop Types + Inventory Categories + Unit Types + Departments + Vendors
├── Services → Shop Types + Inventory Categories + Unit Types + Departments
├── Vehicle Documents → Vehicles + Document Types
├── Vehicle Mileage → Vehicles
├── Time Sheets → Employees
├── Invoice Sequences → Departments
Level 4 (Composite)
├── Service Groups → Shop Types + Inv. Categories + Unit Types + Departments
├── SG Includes/Services/Parts/Pricings → Service Groups + ...
Level 5 (Workflows)
├── Inspections → Customers + Vehicles + Departments + Insp. Categories + Employees
├── Inspection Check Points → Inspections + Check Point Labels
├── Estimates → Customers + Vehicles + Departments + Labels
├── Job Cards → Customers + Vehicles + Departments + Labels + Employees
Level 6 (Financial)
├── Appointments → Customers + Vehicles + Departments + Job Cards + Employees
├── Tasks → Task Types + Task Sections + Job Cards + Employees + Departments
├── Purchase Orders → Job Cards + Vendors + Departments + Labels + Parts
├── Bills → Job Cards + Vendors + Payment Terms + Departments + Labels + Parts
├── Expenses → Job Cards + Expense Items + Vendors + Departments + Labels
├── Payment Received → Job Cards + Payment Modes + Customers
├── Inventory Adjustments → Parts + Job Cards + Invoices + Reasons
Level 7 (Invoicing — Most Complex)
├── Invoices → Customers + Vehicles + Departments + Inv. Sequences + Labels + Parts + Services + Expenses + Service Groups
├── Invoice Documents → Invoices + Customers + Vehicles + Document Types
├── Invoice Notes → Invoices
├── Credit Notes → Customers + Parts + Services + Expenses + Insp. Categories + Labels
├── Payment Mades → Vendors + Employees + Bills + Expenses + Payment Modes
└── Vendor Credits → Vendors + Departments + Parts + Services + Expenses + Labels
```

18
eslint.config.mjs Normal file
View File

@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

View File

@ -0,0 +1,55 @@
"use server"
import { cookies } from "next/headers"
import type { AuthUser } from "@repo/api"
const TOKEN_COOKIE = "auth_token"
const USER_COOKIE = "auth_user"
const DEFAULT_EXPIRES_IN = 60 * 60 * 24 * 7 // 7 days in seconds
export async function setAuthCookies(
token: string,
user: AuthUser,
expiresIn: number = DEFAULT_EXPIRES_IN,
) {
const cookieStore = await cookies()
const expires = new Date(Date.now() + expiresIn * 1000)
cookieStore.set(TOKEN_COOKIE, token, {
expires,
path: "/",
sameSite: "strict",
})
cookieStore.set(USER_COOKIE, JSON.stringify(user), {
expires,
path: "/",
sameSite: "strict",
})
}
export async function clearAuthCookies() {
const cookieStore = await cookies()
cookieStore.delete(TOKEN_COOKIE)
cookieStore.delete(USER_COOKIE)
}
export async function getAuthCookies(): Promise<{
token: string | undefined
user: AuthUser | undefined
}> {
const cookieStore = await cookies()
const token = cookieStore.get(TOKEN_COOKIE)?.value
const rawUser = cookieStore.get(USER_COOKIE)?.value
let user: AuthUser | undefined
if (rawUser) {
try {
user = JSON.parse(rawUser) as AuthUser
} catch {
user = undefined
}
}
return { token, user }
}

View File

@ -0,0 +1,11 @@
import { z } from "zod"
const loginFormSchema = z.object({
email: z.string().trim().email("Enter a valid email address"),
password: z.string().min(8, "Password must be at least 8 characters"),
})
type LoginFormValues = z.infer<typeof loginFormSchema>
export { loginFormSchema }
export type { LoginFormValues }

148
modules/auth/login-form.tsx Normal file
View File

@ -0,0 +1,148 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { Button } from "@/shared/components/ui/button"
import { api } from '@repo/api'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card"
import {
Field,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
} from "@/shared/components/ui/field"
import { Input } from "@/shared/components/ui/input"
import { useAppStore } from "@/shared/stores/app-store"
import { useAuthStore } from "@/shared/stores/auth-store"
import { cn } from "@/shared/lib/utils"
import Image from "next/image"
import { useRouter } from "next/navigation"
import { loginFormSchema, type LoginFormValues } from "./login-form.schema"
import { useMutation } from "@tanstack/react-query"
import { Alert, AlertTitle } from "@/shared/components/ui/alert"
import { AlertTriangle } from "lucide-react"
export function LoginForm({
className,
...props
}: React.ComponentProps<"div">) {
const lastLoginEmail = useAppStore((state) => state.lastLoginEmail)
const setLastLoginEmail = useAppStore((state) => state.setLastLoginEmail)
const login = useAuthStore((state) => state.login)
const router = useRouter()
const {
handleSubmit,
register,
formState: { errors, },
} = useForm<LoginFormValues>({
resolver: zodResolver(loginFormSchema),
defaultValues: process.env.NODE_ENV === "development" ? {
"email": "admin@admin.com",
"password": "12345678"
} : {
email: lastLoginEmail,
password: "",
},
})
const { mutate, error, isPending: isSubmitting } = useMutation({
mutationFn: (values: LoginFormValues) => api.auth.login(values),
onSuccess: async (data) => {
if (data.token && data.user) {
await login(data.token, data.user as Parameters<typeof login>[1])
router.push("/")
}
},
})
async function onSubmit(values: LoginFormValues) {
setLastLoginEmail(values.email)
mutate(values)
}
return (
<div className={cn("flex flex-col gap-6", className)} {...props}>
<Card>
<CardHeader>
<Image
className="mx-auto mb-8 h-20 w-48"
alt="Logo"
src="/assets/logo.png"
height={200}
width={200}
/>
<CardTitle>Login to your account</CardTitle>
<CardDescription>
Enter your email below to login to your account
</CardDescription>
</CardHeader>
<CardContent>
{error ? (
<Alert variant='destructive' className="mb-4">
<AlertTriangle className="me-2 h-4 w-4" />
<AlertTitle>Login failed</AlertTitle>
{error.message}
</Alert>
) : null}
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<FieldGroup>
<Field>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input
id="email"
type="email"
placeholder="m@example.com"
aria-invalid={!!errors.email}
{...register("email")}
/>
<FieldError errors={[errors.email]} />
</Field>
<Field>
<div className="flex items-center">
<FieldLabel htmlFor="password">Password</FieldLabel>
<a
href="#"
className="ml-auto inline-block text-sm underline-offset-4 hover:underline"
>
Forgot your password?
</a>
</div>
<Input
id="password"
type="password"
aria-invalid={!!errors.password}
{...register("password")}
/>
<FieldError errors={[errors.password]} />
</Field>
<Field>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Logging in..." : "Login"}
</Button>
{lastLoginEmail ? (
<FieldDescription className="text-center">
Last email used: {lastLoginEmail}
</FieldDescription>
) : null}
{/* <FieldDescription className="text-center">
Don&apos;t have an account? <a href="#">Sign up</a>
</FieldDescription> */}
</Field>
</FieldGroup>
</form>
</CardContent>
</Card>
</div>
)
}

View File

@ -0,0 +1,264 @@
"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,
} 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 {
customerFormSchema,
type CustomerFormValues,
} from "./customer.schema"
import { CUSTOMER_ROUTES } from "@repo/api"
// ── Constants ──
const SALUTATION_OPTIONS = [
{ value: "Mr.", label: "Mr." },
{ value: "Mrs.", label: "Mrs." },
{ value: "Ms.", label: "Ms." },
{ value: "Miss", label: "Miss" },
{ value: "Dr.", label: "Dr." },
{ value: "Prof.", label: "Prof." },
]
// ── Props ──
export type CustomerFormProps = {
resourceId?: string | null
initialData?: unknown
onSuccess?: () => void
}
// ── Default values ──
const CUSTOMER_DEFAULT_VALUES: CustomerFormValues = {
customer_type: null,
referral_source: null,
payment_terms: null,
country: null,
state: null,
salutation: "",
first_name: "",
last_name: "",
company_name: "",
email: "",
phone: "",
alternate_phone: "",
address_line_1: "",
address_line_2: "",
city: "",
zip_code: "",
}
// ── Mapping helpers ──
function mapCustomerToFormValues(data: unknown): CustomerFormValues {
const c = (data as any)?.data ?? data ?? {}
return {
customer_type: toRelation(c.customer_type_id, c.customer_type_name),
referral_source: toRelation(c.referral_source_id, c.referral_source_name),
payment_terms: toRelation(c.payment_terms_id, c.payment_terms_name),
country: toRelation(c.country_id, c.country_name),
state: toRelation(c.state_id, c.state_name),
salutation: c.salutation || "",
first_name: c.first_name || "",
last_name: c.last_name || "",
company_name: c.company_name || "",
email: c.email || "",
phone: c.phone || "",
alternate_phone: c.alternate_phone || "",
address_line_1: c.address_line_1 || "",
address_line_2: c.address_line_2 || "",
city: c.city || "",
zip_code: c.zip_code || "",
}
}
function mapFormToPayload(values: CustomerFormValues) {
return {
customer_type_id: toId(values.customer_type),
referral_source_id: toId(values.referral_source),
payment_terms_id: toId(values.payment_terms),
country_id: toId(values.country),
state_id: toId(values.state),
salutation: values.salutation || undefined,
first_name: values.first_name,
last_name: values.last_name,
company_name: values.company_name || undefined,
email: values.email || undefined,
phone: values.phone || undefined,
alternate_phone: values.alternate_phone || undefined,
address_line_1: values.address_line_1 || undefined,
address_line_2: values.address_line_2 || undefined,
city: values.city || undefined,
zip_code: values.zip_code || undefined,
}
}
// ── Shared mapOption for async selects ──
const mapLookupOption = (item: any) => ({
value: String(item.id),
label: item.name,
})
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
// ── Component ──
export function CustomerForm({ resourceId, initialData, onSuccess }: CustomerFormProps) {
const api = useAuthApi()
const { form, isEditing } = useResourceForm<CustomerFormValues, any>({
schema: customerFormSchema,
defaultValues: CUSTOMER_DEFAULT_VALUES,
resourceId,
initialData,
initialize: (id) => api.customers.show(id),
queryKey: [CUSTOMER_ROUTES.BY_ID, resourceId],
mapToFormValues: mapCustomerToFormValues,
})
const { mutate, error, isPending } = useFormMutation(form, {
mutationFn: (values: CustomerFormValues) => {
const payload = mapFormToPayload(values)
const promise = isEditing && resourceId
? api.customers.update(resourceId, payload)
: api.customers.create(payload)
toast.promise(promise, {
loading: isEditing ? "Updating customer..." : "Creating customer...",
success: isEditing ? "Customer updated successfully" : "Customer created successfully",
error: isEditing ? "Failed to update customer" : "Failed to create customer",
})
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 customer" : "Failed to create customer"}</AlertTitle>
{error.message}
</Alert>
)}
<FieldGroup>
{/* Basic Info */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfSelectField
name="salutation"
label="Salutation"
placeholder="Select salutation"
options={SALUTATION_OPTIONS}
/>
<RhfAsyncSelectField
name="customer_type"
label="Customer Type"
placeholder="Select customer type"
queryKey={[CUSTOMER_ROUTES.CUSTOMER_TYPES]}
listFn={() => api.customers.listCustomerTypes()}
mapOption={mapLookupOption}
{...STORE_OBJECT}
/>
</div>
{/* Name */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField name="first_name" label="First Name" placeholder="John" required />
<RhfTextField name="last_name" label="Last Name" placeholder="Doe" required />
</div>
<RhfTextField name="company_name" label="Company Name" placeholder="Doe Holdings" />
{/* Contact */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField name="email" label="Email" placeholder="john@example.com" type="email" />
<RhfTextField name="phone" label="Phone" placeholder="0501234567" type="tel" />
</div>
<RhfTextField name="alternate_phone" label="Alternate Phone" placeholder="0551234567" type="tel" />
{/* Relations */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfAsyncSelectField
name="referral_source"
label="Referral Source"
placeholder="Select referral source"
queryKey={["referral-sources"]}
listFn={() => api.referralSources.list()}
mapOption={mapLookupOption}
{...STORE_OBJECT}
/>
<RhfAsyncSelectField
name="payment_terms"
label="Payment Terms"
placeholder="Select payment terms"
queryKey={["payment-terms"]}
listFn={() => api.paymentTerms.list()}
mapOption={mapLookupOption}
{...STORE_OBJECT}
/>
</div>
{/* Address */}
<RhfTextField name="address_line_1" label="Address Line 1" placeholder="Street 10" />
<RhfTextField name="address_line_2" label="Address Line 2" placeholder="Near Central Plaza" />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfAsyncSelectField
name="country"
label="Country"
placeholder="Select country"
queryKey={["countries"]}
listFn={() => api.geo.countries()}
mapOption={mapLookupOption}
{...STORE_OBJECT}
/>
<RhfAsyncSelectField
name="state"
label="State"
placeholder="Select state"
queryKey={["states"]}
listFn={() => api.geo.states()}
mapOption={mapLookupOption}
{...STORE_OBJECT}
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField name="city" label="City" placeholder="Dubai" />
<RhfTextField name="zip_code" label="Zip Code" placeholder="00000" />
</div>
<Button type="submit" variant="default" disabled={isPending}>
{isEditing ? <Save /> : <Plus />}
{isPending
? (isEditing ? "Updating..." : "Creating...")
: (isEditing ? "Update Customer" : "Create Customer")}
</Button>
</FieldGroup>
</Rhform>
)
}

View File

@ -0,0 +1,44 @@
import { z } from "zod"
/**
* Reusable schema for relation/lookup fields stored as `{ value, label }` objects.
* Use `.nullable()` when the field is optional but explicitly clearable.
*/
const relationFieldSchema = z
.object({ value: z.string(), label: z.string() })
.nullable()
type RelationField = z.infer<typeof relationFieldSchema>
const customerFormSchema = z.object({
// ── Relations (stored as objects, mapped to IDs on submit) ──
customer_type: relationFieldSchema,
referral_source: relationFieldSchema,
payment_terms: relationFieldSchema,
country: relationFieldSchema,
state: relationFieldSchema,
// ── Basic info ──
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(),
// ── Contact ──
email: z
.union([z.string().email("Enter a valid email address"), z.literal("")])
.optional(),
phone: z.string().optional(),
alternate_phone: z.string().optional(),
// ── Address ──
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,236 @@
"use client"
import { AlertTriangle, Plus, Save } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { Alert, AlertTitle } from "@/shared/components/ui/alert"
import { FieldGroup } from "@/shared/components/ui/field"
import {
Rhform,
RhfTextField,
RhfSelectField,
RhfAsyncSelectField,
RhfCheckboxField,
} from "@/shared/components/form"
import { toast } from "sonner"
import { useAuthApi } from "@/shared/useApi"
import { useResourceForm } from "@/shared/hooks/use-resource-form"
import { useFormMutation } from "@/shared/hooks/use-form-mutation"
import { toRelation, toId } from "@/shared/lib/utils"
import {
employeeFormSchema,
type EmployeeFormValues,
} from "./employee.schema"
import { EMPLOYEE_ROUTES, DEPARTMENT_ROUTES, SHOP_TIMING_ROUTES, SHOP_CALENDAR_ROUTES } from "@repo/api"
// ── Constants ──
const STATUS_OPTIONS = [
{ value: "active", label: "Active" },
{ value: "inactive", label: "Inactive" },
]
const TYPE_OPTIONS = [
{ value: "employee", label: "Employee" },
{ value: "contractor", label: "Contractor" },
]
// ── Props ──
export type EmployeeFormProps = {
resourceId?: string | null
initialData?: unknown
onSuccess?: () => void
}
// ── Default values ──
const DEFAULT_VALUES: EmployeeFormValues = {
department: null,
shop_calender: null,
shop_timing: null,
first_name: "",
last_name: "",
email: "",
phone: "",
position: "",
status: "active",
type: "employee",
track_attendance: true,
notify_owner_when_punch_in_out: false,
geo_fence_radius: 100,
}
// ── Mapping helpers ──
function mapToFormValues(data: unknown): EmployeeFormValues {
const d = (data as any)?.data ?? data ?? {}
return {
department: toRelation(d.department_id, d.department?.name),
shop_calender: toRelation(d.shop_calender_id, d.shop_calender?.title),
shop_timing: toRelation(d.shop_timing_id, d.shop_timing?.title),
first_name: d.first_name || "",
last_name: d.last_name || "",
email: d.email || "",
phone: d.phone || "",
position: d.position || "",
status: d.status || "active",
type: d.type || "employee",
track_attendance: d.track_attendance ?? true,
notify_owner_when_punch_in_out: d.notify_owner_when_punch_in_out ?? false,
geo_fence_radius: d.geo_fence_radius ?? 100,
}
}
function mapFormToPayload(values: EmployeeFormValues) {
return {
department_id: toId(values.department),
shop_calender_id: toId(values.shop_calender),
shop_timing_id: toId(values.shop_timing),
first_name: values.first_name,
last_name: values.last_name,
email: values.email || undefined,
phone: values.phone || undefined,
position: values.position || undefined,
status: values.status || undefined,
type: values.type || undefined,
track_attendance: values.track_attendance,
notify_owner_when_punch_in_out: values.notify_owner_when_punch_in_out,
geo_fence_radius: values.geo_fence_radius,
}
}
// ── Shared mapOption for async selects ──
const mapLookupOption = (item: any) => ({
value: String(item.id),
label: item.name ?? item.title ?? String(item.id),
})
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
// ── Component ──
export function EmployeeForm({ resourceId, initialData, onSuccess }: EmployeeFormProps) {
const api = useAuthApi()
const { form, isEditing } = useResourceForm<EmployeeFormValues, any>({
schema: employeeFormSchema,
defaultValues: DEFAULT_VALUES,
resourceId,
initialData,
initialize: (id) => api.employees.show(id),
queryKey: [EMPLOYEE_ROUTES.BY_ID, resourceId],
mapToFormValues: mapToFormValues,
})
const { mutate, error, isPending } = useFormMutation(form, {
mutationFn: (values: EmployeeFormValues) => {
const payload = mapFormToPayload(values)
const promise = isEditing && resourceId
? api.employees.update(resourceId, payload)
: api.employees.create(payload)
toast.promise(promise, {
loading: isEditing ? "Updating employee..." : "Creating employee...",
success: isEditing ? "Employee updated successfully" : "Employee created successfully",
error: isEditing ? "Failed to update employee" : "Failed to create employee",
})
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 employee" : "Failed to create employee"}
</AlertTitle>
{error.message}
</Alert>
)}
<FieldGroup>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField name="first_name" label="First Name" placeholder="Jane" required />
<RhfTextField name="last_name" label="Last Name" placeholder="Smith" required />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField name="email" label="Email" placeholder="jane@example.com" type="email" />
<RhfTextField name="phone" label="Phone" placeholder="0501234567" type="tel" />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField name="position" label="Position" placeholder="Technician" />
<RhfAsyncSelectField
name="department"
label="Department"
placeholder="Select department"
queryKey={[DEPARTMENT_ROUTES.INDEX]}
listFn={() => api.departments.list()}
mapOption={mapLookupOption}
{...STORE_OBJECT}
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfSelectField
name="status"
label="Status"
placeholder="Select status"
options={STATUS_OPTIONS}
/>
<RhfSelectField
name="type"
label="Type"
placeholder="Select type"
options={TYPE_OPTIONS}
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfAsyncSelectField
name="shop_calender"
label="Shop Calendar"
placeholder="Select calendar"
queryKey={[SHOP_CALENDAR_ROUTES.INDEX]}
listFn={() => api.shopCalendars.list()}
mapOption={mapLookupOption}
{...STORE_OBJECT}
/>
<RhfAsyncSelectField
name="shop_timing"
label="Shop Timing"
placeholder="Select timing"
queryKey={[SHOP_TIMING_ROUTES.INDEX]}
listFn={() => api.shopTimings.list()}
mapOption={mapLookupOption}
{...STORE_OBJECT}
/>
</div>
<RhfTextField name="geo_fence_radius" label="Geo Fence Radius (m)" placeholder="100" type="number" />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfCheckboxField name="track_attendance" label="Track Attendance" />
<RhfCheckboxField name="notify_owner_when_punch_in_out" label="Notify Owner on Punch In/Out" />
</div>
<Button type="submit" variant="default" disabled={isPending}>
{isEditing ? <Save /> : <Plus />}
{isPending
? (isEditing ? "Updating..." : "Creating...")
: (isEditing ? "Update Employee" : "Create Employee")}
</Button>
</FieldGroup>
</Rhform>
)
}

View File

@ -0,0 +1,32 @@
import { z } from "zod"
const relationFieldSchema = z
.object({ value: z.string(), label: z.string() })
.nullable()
const STATUS_OPTIONS = ["active", "inactive"] as const
const TYPE_OPTIONS = ["employee", "contractor"] as const
const employeeFormSchema = z.object({
department: relationFieldSchema,
shop_calender: relationFieldSchema,
shop_timing: relationFieldSchema,
first_name: z.string().min(1, "First name is required"),
last_name: z.string().min(1, "Last name is required"),
email: z.union([
z.string().email("Enter a valid email address"),
z.literal(""),
]).optional(),
phone: z.string().optional(),
position: z.string().optional(),
status: z.string().optional(),
type: z.string().optional(),
track_attendance: z.boolean(),
notify_owner_when_punch_in_out: z.boolean(),
geo_fence_radius: z.coerce.number().min(0).optional(),
})
type EmployeeFormValues = z.infer<typeof employeeFormSchema>
export { employeeFormSchema, relationFieldSchema, STATUS_OPTIONS, TYPE_OPTIONS }
export type { EmployeeFormValues }

View File

@ -0,0 +1,57 @@
"use client"
import { z } from "zod"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { Plus } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { FieldGroup } from "@/shared/components/ui/field"
import { Rhform, RhfTextField, type InlineCreateFormProps } from "@/shared/components/form"
import { toast } from "sonner"
import { useAuthApi } from "@/shared/useApi"
const schema = z.object({
inspection_name: z.string().min(1, "Name is required"),
})
type FormValues = z.infer<typeof schema>
export function InspectionCategoryInlineForm({ onSuccess }: InlineCreateFormProps) {
const api = useAuthApi()
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { inspection_name: "" },
})
const handleSubmit = async (values: FormValues) => {
try {
const result = await api.inspections.createCategory({
inspection_name: values.inspection_name,
})
toast.success("Inspection category created")
form.reset()
const item = (result as any)?.data ?? result
onSuccess({ value: String(item.id), label: item.name ?? values.inspection_name })
} catch {
toast.error("Failed to create inspection category")
}
}
return (
<Rhform form={form} onSubmit={handleSubmit}>
<FieldGroup>
<RhfTextField
name="inspection_name"
label="Name"
placeholder="e.g. Brake Check"
required
/>
<Button type="submit" disabled={form.formState.isSubmitting}>
<Plus />
{form.formState.isSubmitting ? "Creating..." : "Create Category"}
</Button>
</FieldGroup>
</Rhform>
)
}

View File

@ -0,0 +1,231 @@
"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,
RhfAsyncSelectField,
} from "@/shared/components/form"
import { toast } from "sonner"
import { useAuthApi } from "@/shared/useApi"
import { useResourceForm } from "@/shared/hooks/use-resource-form"
import { useFormMutation } from "@/shared/hooks/use-form-mutation"
import { toRelation, toId } from "@/shared/lib/utils"
import { InspectionCategoryInlineForm } from "./inline-forms/inspection-category-inline-form"
import { DepartmentInlineForm } from "@/modules/services/inline-forms/department-inline-form"
import {
inspectionFormSchema,
type InspectionFormValues,
} from "./inspection.schema"
import {
INSPECTION_ROUTES,
CUSTOMER_ROUTES,
VEHICLE_ROUTES,
DEPARTMENT_ROUTES,
EMPLOYEE_ROUTES,
} from "@repo/api"
// ── Props ──
export type InspectionFormProps = {
resourceId?: string | null
initialData?: unknown
onSuccess?: () => void
}
// ── Default values ──
const DEFAULT_VALUES: InspectionFormValues = {
customer: null,
vehicle: null,
department: null,
inspection_category: null,
employee: null,
title: "",
order_number: "",
date: "",
time: "",
}
// ── Mapping helpers ──
function mapToFormValues(data: unknown): InspectionFormValues {
const d = (data as any)?.data ?? data ?? {}
return {
customer: toRelation(d.customer_id, d.customer?.first_name ? `${d.customer.first_name} ${d.customer.last_name ?? ""}`.trim() : undefined),
vehicle: toRelation(d.vehicle_id, d.vehicle?.make ? `${d.vehicle.make} ${d.vehicle.model ?? ""}`.trim() : undefined),
department: toRelation(d.department_id, d.department?.name),
inspection_category: toRelation(d.inspection_category_id, d.inspection_category?.name),
employee: toRelation(d.employee_id, d.employee?.first_name ? `${d.employee.first_name} ${d.employee.last_name ?? ""}`.trim() : undefined),
title: d.title ?? "",
order_number: d.order_number ?? "",
date: d.date ?? "",
time: d.time ?? "",
}
}
function mapFormToPayload(values: InspectionFormValues) {
return {
customer_id: toId(values.customer),
vehicle_id: toId(values.vehicle),
department_id: toId(values.department),
inspection_category_id: toId(values.inspection_category),
employee_id: toId(values.employee),
title: values.title,
order_number: values.order_number || undefined,
date: values.date || undefined,
time: values.time || undefined,
}
}
// ── Shared mapOption for async selects ──
const mapLookupOption = (item: any) => ({
value: String(item.id),
label: item.name ?? item.title ?? String(item.id),
})
const mapCustomerOption = (item: any) => ({
value: String(item.id),
label: `${item.first_name ?? ""} ${item.last_name ?? ""}`.trim() || String(item.id),
})
const mapVehicleOption = (item: any) => ({
value: String(item.id),
label: `${item.make ?? ""} ${item.model ?? ""} ${item.year ?? ""}`.trim() || String(item.id),
})
const mapEmployeeOption = (item: any) => ({
value: String(item.id),
label: `${item.first_name ?? ""} ${item.last_name ?? ""}`.trim() || String(item.id),
})
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
// ── Component ──
export function InspectionForm({ resourceId, initialData, onSuccess }: InspectionFormProps) {
const api = useAuthApi()
const { form, isEditing } = useResourceForm<InspectionFormValues, any>({
schema: inspectionFormSchema,
defaultValues: DEFAULT_VALUES,
resourceId,
initialData,
queryKey: [INSPECTION_ROUTES.BY_ID, resourceId],
mapToFormValues: mapToFormValues,
})
const { mutate, error, isPending } = useFormMutation(form, {
mutationFn: (values: InspectionFormValues) => {
const payload = mapFormToPayload(values)
const promise = isEditing && resourceId
? api.inspections.update(resourceId, payload)
: api.inspections.create(payload)
toast.promise(promise, {
loading: isEditing ? "Updating inspection..." : "Creating inspection...",
success: isEditing ? "Inspection updated successfully" : "Inspection created successfully",
error: isEditing ? "Failed to update inspection" : "Failed to create inspection",
})
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 inspection" : "Failed to create inspection"}
</AlertTitle>
{error.message}
</Alert>
)}
<FieldGroup>
<RhfTextField name="title" label="Title" placeholder="e.g. Pre-purchase" required />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfAsyncSelectField
name="customer"
label="Customer"
placeholder="Select customer"
queryKey={[CUSTOMER_ROUTES.INDEX]}
listFn={() => api.customers.list()}
mapOption={mapCustomerOption}
{...STORE_OBJECT}
/>
<RhfAsyncSelectField
name="vehicle"
label="Vehicle"
placeholder="Select vehicle"
queryKey={[VEHICLE_ROUTES.INDEX]}
listFn={() => api.vehicles.list()}
mapOption={mapVehicleOption}
{...STORE_OBJECT}
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfAsyncSelectField
name="department"
label="Department"
placeholder="Select department"
queryKey={[DEPARTMENT_ROUTES.INDEX]}
listFn={() => api.departments.list()}
mapOption={mapLookupOption}
createForm={(props) => <DepartmentInlineForm {...props} />}
createLabel="Department"
{...STORE_OBJECT}
/>
<RhfAsyncSelectField
name="inspection_category"
label="Inspection Category"
placeholder="Select category"
queryKey={[INSPECTION_ROUTES.CATEGORIES]}
listFn={() => api.inspections.listCategories()}
mapOption={mapLookupOption}
createForm={(props) => <InspectionCategoryInlineForm {...props} />}
createLabel="Inspection Category"
{...STORE_OBJECT}
/>
</div>
<RhfAsyncSelectField
name="employee"
label="Employee"
placeholder="Select employee"
queryKey={[EMPLOYEE_ROUTES.INDEX]}
listFn={() => api.employees.list()}
mapOption={mapEmployeeOption}
{...STORE_OBJECT}
/>
<RhfTextField name="order_number" label="Order Number" placeholder="e.g. ORD-001" />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField name="date" label="Date" placeholder="YYYY-MM-DD" type="date" />
<RhfTextField name="time" label="Time" placeholder="HH:MM:SS" type="time" />
</div>
<Button type="submit" variant="default" disabled={isPending}>
{isEditing ? <Save /> : <Plus />}
{isPending
? (isEditing ? "Updating..." : "Creating...")
: (isEditing ? "Update Inspection" : "Create Inspection")}
</Button>
</FieldGroup>
</Rhform>
)
}

View File

@ -0,0 +1,22 @@
import { z } from "zod"
const relationFieldSchema = z
.object({ value: z.string(), label: z.string() })
.nullable()
const inspectionFormSchema = z.object({
customer: relationFieldSchema,
vehicle: relationFieldSchema,
department: relationFieldSchema,
inspection_category: relationFieldSchema,
employee: relationFieldSchema,
title: z.string().min(1, "Title is required"),
order_number: z.string().optional(),
date: z.string().optional(),
time: z.string().optional(),
})
type InspectionFormValues = z.infer<typeof inspectionFormSchema>
export { inspectionFormSchema, relationFieldSchema }
export type { InspectionFormValues }

242
modules/parts/part-form.tsx Normal file
View File

@ -0,0 +1,242 @@
"use client"
import { AlertTriangle, Plus, Save } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { Alert, AlertTitle } from "@/shared/components/ui/alert"
import { FieldGroup } from "@/shared/components/ui/field"
import {
Rhform,
RhfTextField,
RhfTextareaField,
RhfAsyncSelectField,
} from "@/shared/components/form"
import { ShopTypeInlineForm } from "@/modules/vehicles/inline-forms/shop-type-inline-form"
import { InventoryCategoryInlineForm } from "@/modules/services/inline-forms/inventory-category-inline-form"
import { UnitTypeInlineForm } from "@/modules/services/inline-forms/unit-type-inline-form"
import { DepartmentInlineForm } from "@/modules/services/inline-forms/department-inline-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 { toId } from "@/shared/lib/utils"
import { partFormSchema, type PartFormValues } from "./part.schema"
import { PARTS_ROUTES } from "@repo/api"
// ── Props ──
export type PartFormProps = {
resourceId?: string | null
initialData?: unknown
onSuccess?: () => void
}
// ── Default values ──
const DEFAULT_VALUES: PartFormValues = {
shop_type: null,
category: null,
unit_type: null,
department: null,
title: "",
sku: "",
description: "",
selling_price: undefined,
purchase_price: undefined,
}
// ── Mapping helpers ──
const mapLookupOption = (item: any) => ({
value: String(item.id),
label: item.name ?? item.title ?? String(item.id),
})
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
function mapToFormValues(data: unknown): PartFormValues {
const d = (data as any)?.data ?? data ?? {}
return {
shop_type: null,
category: null,
unit_type: null,
department: null,
title: d.title ?? d.name ?? "",
sku: d.sku ?? "",
description: d.description ?? "",
selling_price: d.selling_price ?? undefined,
purchase_price: d.purchase_price ?? undefined,
}
}
function mapCreatePayload(values: PartFormValues) {
return {
shop_type_id: toId(values.shop_type),
category_id: toId(values.category),
unit_type_id: toId(values.unit_type),
department_id: toId(values.department),
title: values.title,
sku: values.sku || undefined,
description: values.description || undefined,
selling_price: values.selling_price,
purchase_price: values.purchase_price,
}
}
function mapUpdatePayload(values: PartFormValues) {
return {
title: values.title,
selling_price: values.selling_price,
}
}
// ── Component ──
export function PartForm({ resourceId, initialData, onSuccess }: PartFormProps) {
const api = useAuthApi()
const { form, isEditing } = useResourceForm<PartFormValues, any>({
schema: partFormSchema,
defaultValues: DEFAULT_VALUES,
resourceId,
initialData,
mapToFormValues,
})
const { mutate, error, isPending } = useFormMutation(form, {
mutationFn: (values: PartFormValues) => {
const promise = isEditing && resourceId
? api.parts.update(resourceId, mapUpdatePayload(values))
: api.parts.create(mapCreatePayload(values))
toast.promise(promise, {
loading: isEditing ? "Updating part..." : "Creating part...",
success: isEditing ? "Part updated successfully" : "Part created successfully",
error: isEditing ? "Failed to update part" : "Failed to create part",
})
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 part" : "Failed to create part"}
</AlertTitle>
{error.message}
</Alert>
)}
<FieldGroup>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField
name="title"
label="Title"
placeholder="e.g. Brake Pad"
required
/>
<RhfTextField
name="sku"
label="SKU"
placeholder="e.g. BP-001"
/>
</div>
{!isEditing && (
<>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfAsyncSelectField
name="shop_type"
label="Shop Type"
placeholder="Select shop type"
queryKey={["shop-types"]}
listFn={() => api.shopTypes.list()}
mapOption={mapLookupOption}
createForm={(props) => <ShopTypeInlineForm {...props} />}
createLabel="Shop Type"
{...STORE_OBJECT}
/>
<RhfAsyncSelectField
name="category"
label="Category"
placeholder="Select category"
queryKey={["inventory-categories"]}
listFn={() => api.inventory.listCategories()}
mapOption={mapLookupOption}
createForm={(props) => <InventoryCategoryInlineForm {...props} />}
createLabel="Category"
{...STORE_OBJECT}
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfAsyncSelectField
name="unit_type"
label="Unit Type"
placeholder="Select unit type"
queryKey={["unit-types"]}
listFn={() => api.inventory.listUnitTypes()}
mapOption={mapLookupOption}
createForm={(props) => <UnitTypeInlineForm {...props} />}
createLabel="Unit Type"
{...STORE_OBJECT}
/>
<RhfAsyncSelectField
name="department"
label="Department"
placeholder="Select department"
queryKey={["departments"]}
listFn={() => api.departments.list()}
mapOption={(item: any) => ({ value: String(item.id), label: item.name ?? String(item.id) })}
createForm={(props) => <DepartmentInlineForm {...props} />}
createLabel="Department"
{...STORE_OBJECT}
/>
</div>
</>
)}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField
name="selling_price"
label="Selling Price"
placeholder="0.00"
type="number"
/>
{!isEditing && (
<RhfTextField
name="purchase_price"
label="Purchase Price"
placeholder="0.00"
type="number"
/>
)}
</div>
<RhfTextareaField
name="description"
label="Description"
placeholder="Optional description"
rows={3}
/>
</FieldGroup>
<div className="mt-4 flex justify-end">
<Button type="submit" disabled={isPending}>
{isEditing ? <Save className="me-2 h-4 w-4" /> : <Plus className="me-2 h-4 w-4" />}
{isPending
? isEditing ? "Updating..." : "Creating..."
: isEditing ? "Update Part" : "Create Part"}
</Button>
</div>
</Rhform>
)
}

View File

@ -0,0 +1,19 @@
import { z } from "zod"
export const relationFieldSchema = z
.object({ value: z.string(), label: z.string() })
.nullable()
export const partFormSchema = z.object({
shop_type: relationFieldSchema,
category: relationFieldSchema,
unit_type: relationFieldSchema,
department: relationFieldSchema,
title: z.string().min(1, "Title is required"),
sku: z.string().optional(),
description: z.string().optional(),
selling_price: z.coerce.number().min(0).optional(),
purchase_price: z.coerce.number().min(0).optional(),
})
export type PartFormValues = z.infer<typeof partFormSchema>

View File

@ -0,0 +1,274 @@
"use client"
import { AlertTriangle, Plus, Save } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { Alert, AlertTitle } from "@/shared/components/ui/alert"
import { FieldGroup } from "@/shared/components/ui/field"
import {
Rhform,
RhfTextField,
RhfTextareaField,
RhfAsyncSelectField,
RhfCheckboxField,
} from "@/shared/components/form"
import { ShopTypeInlineForm } from "@/modules/vehicles/inline-forms/shop-type-inline-form"
import { InventoryCategoryInlineForm } from "@/modules/services/inline-forms/inventory-category-inline-form"
import { UnitTypeInlineForm } from "@/modules/services/inline-forms/unit-type-inline-form"
import { DepartmentInlineForm } from "@/modules/services/inline-forms/department-inline-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 { toId } from "@/shared/lib/utils"
import { serviceGroupFormSchema, type ServiceGroupFormValues } from "./service-group.schema"
import { SERVICE_GROUP_ROUTES } from "@repo/api"
// ── Props ──
export type ServiceGroupFormProps = {
resourceId?: string | null
initialData?: unknown
onSuccess?: () => void
}
// ── Default values ──
const DEFAULT_VALUES: ServiceGroupFormValues = {
shop_type: null,
inventory_category: null,
unit_type: null,
department: null,
service_name: "",
code: "",
service_description: "",
selling_price: undefined,
selling_chart_of_account: "",
show_as_lump_sum: false,
mark_as_recommended: false,
set_packaged_pricing: false,
is_active: true,
}
// ── Mapping helpers ──
const mapLookupOption = (item: any) => ({
value: String(item.id),
label: item.name ?? item.title ?? String(item.id),
})
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
function mapToFormValues(data: unknown): ServiceGroupFormValues {
const d = (data as any)?.data ?? data ?? {}
return {
shop_type: null,
inventory_category: null,
unit_type: null,
department: null,
service_name: d.service_name ?? d.name ?? "",
code: d.code ?? "",
service_description: d.service_description ?? "",
selling_price: d.selling_price ?? undefined,
selling_chart_of_account: d.selling_chart_of_account ?? "",
show_as_lump_sum: d.show_as_lump_sum ?? false,
mark_as_recommended: d.mark_as_recommended ?? false,
set_packaged_pricing: d.set_packaged_pricing ?? false,
is_active: d.is_active ?? true,
}
}
function mapCreatePayload(values: ServiceGroupFormValues) {
return {
service_name: values.service_name,
shop_type_id: toId(values.shop_type),
code: values.code || undefined,
inventory_category_id: toId(values.inventory_category),
unit_type_id: toId(values.unit_type),
department_id: toId(values.department),
service_description: values.service_description || undefined,
show_as_lump_sum: values.show_as_lump_sum,
mark_as_recommended: values.mark_as_recommended,
set_packaged_pricing: values.set_packaged_pricing,
selling_price: values.selling_price,
selling_chart_of_account: values.selling_chart_of_account || undefined,
is_active: values.is_active,
}
}
function mapUpdatePayload(values: ServiceGroupFormValues) {
return {
service_name: values.service_name,
selling_price: values.selling_price,
is_active: values.is_active,
}
}
// ── Component ──
export function ServiceGroupForm({ resourceId, initialData, onSuccess }: ServiceGroupFormProps) {
const api = useAuthApi()
const { form, isEditing } = useResourceForm<ServiceGroupFormValues, any>({
schema: serviceGroupFormSchema,
defaultValues: DEFAULT_VALUES,
resourceId,
initialData,
mapToFormValues,
})
const { mutate, error, isPending } = useFormMutation(form, {
mutationFn: (values: ServiceGroupFormValues) => {
const promise = isEditing && resourceId
? api.serviceGroups.update(resourceId, mapUpdatePayload(values))
: api.serviceGroups.create(mapCreatePayload(values))
toast.promise(promise, {
loading: isEditing ? "Updating service group..." : "Creating service group...",
success: isEditing ? "Service group updated" : "Service group created",
error: isEditing ? "Failed to update service group" : "Failed to create service group",
})
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 service group" : "Failed to create service group"}
</AlertTitle>
{error.message}
</Alert>
)}
<FieldGroup>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField
name="service_name"
label="Service Name"
placeholder="e.g. Engine Service Group"
required
/>
<RhfTextField
name="code"
label="Code"
placeholder="e.g. SG-001"
/>
</div>
{!isEditing && (
<>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfAsyncSelectField
name="shop_type"
label="Shop Type"
placeholder="Select shop type"
queryKey={["shop-types"]}
listFn={() => api.shopTypes.list()}
mapOption={mapLookupOption}
createForm={(props) => <ShopTypeInlineForm {...props} />}
createLabel="Shop Type"
{...STORE_OBJECT}
/>
<RhfAsyncSelectField
name="inventory_category"
label="Category"
placeholder="Select category"
queryKey={["inventory-categories"]}
listFn={() => api.inventory.listCategories()}
mapOption={mapLookupOption}
createForm={(props) => <InventoryCategoryInlineForm {...props} />}
createLabel="Category"
{...STORE_OBJECT}
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfAsyncSelectField
name="unit_type"
label="Unit Type"
placeholder="Select unit type"
queryKey={["unit-types"]}
listFn={() => api.inventory.listUnitTypes()}
mapOption={mapLookupOption}
createForm={(props) => <UnitTypeInlineForm {...props} />}
createLabel="Unit Type"
{...STORE_OBJECT}
/>
<RhfAsyncSelectField
name="department"
label="Department"
placeholder="Select department"
queryKey={["departments"]}
listFn={() => api.departments.list()}
mapOption={(item: any) => ({ value: String(item.id), label: item.name ?? String(item.id) })}
createForm={(props) => <DepartmentInlineForm {...props} />}
createLabel="Department"
{...STORE_OBJECT}
/>
</div>
</>
)}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField
name="selling_price"
label="Selling Price"
placeholder="0.00"
type="number"
/>
<RhfTextField
name="selling_chart_of_account"
label="Selling Chart of Account"
placeholder="e.g. 4000"
/>
</div>
<RhfTextareaField
name="service_description"
label="Description"
placeholder="Optional description"
rows={3}
/>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfCheckboxField
name="show_as_lump_sum"
label="Show as Lump Sum"
/>
<RhfCheckboxField
name="mark_as_recommended"
label="Recommended"
/>
<RhfCheckboxField
name="set_packaged_pricing"
label="Set Packaged Pricing"
/>
<RhfCheckboxField
name="is_active"
label="Active"
/>
</div>
</FieldGroup>
<div className="mt-4 flex justify-end">
<Button type="submit" disabled={isPending}>
{isEditing ? <Save className="me-2 h-4 w-4" /> : <Plus className="me-2 h-4 w-4" />}
{isPending
? isEditing ? "Updating..." : "Creating..."
: isEditing ? "Update Service Group" : "Create Service Group"}
</Button>
</div>
</Rhform>
)
}

View File

@ -0,0 +1,23 @@
import { z } from "zod"
export const relationFieldSchema = z
.object({ value: z.string(), label: z.string() })
.nullable()
export const serviceGroupFormSchema = z.object({
shop_type: relationFieldSchema,
inventory_category: relationFieldSchema,
unit_type: relationFieldSchema,
department: relationFieldSchema,
service_name: z.string().min(1, "Service name is required"),
code: z.string().optional(),
service_description: z.string().optional(),
selling_price: z.coerce.number().min(0).optional(),
selling_chart_of_account: z.string().optional(),
show_as_lump_sum: z.boolean().optional(),
mark_as_recommended: z.boolean().optional(),
set_packaged_pricing: z.boolean().optional(),
is_active: z.boolean().optional(),
})
export type ServiceGroupFormValues = z.infer<typeof serviceGroupFormSchema>

View File

@ -0,0 +1,7 @@
export const DEPARTMENT_ASSIGNMENT_TYPE_OPTIONS = [
{ value: "none", label: "None" },
{ value: "bays", label: "Bays" },
{ value: "outsourced", label: "Outsourced" },
] as const
export type DepartmentAssignmentType = typeof DEPARTMENT_ASSIGNMENT_TYPE_OPTIONS[number]["value"]

View File

@ -0,0 +1,2 @@
// Renamed to inventory-category-inline-form.tsx
export { InventoryCategoryInlineForm as CategoryInlineForm } from "./inventory-category-inline-form"

View File

@ -0,0 +1,66 @@
"use client"
import { z } from "zod"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { Plus } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { FieldGroup } from "@/shared/components/ui/field"
import { Rhform, RhfTextField, RhfSelectField, type InlineCreateFormProps } from "@/shared/components/form"
import { toast } from "sonner"
import { useAuthApi } from "@/shared/useApi"
import { DEPARTMENT_ASSIGNMENT_TYPE_OPTIONS } from "../department-assignment-types"
const schema = z.object({
name: z.string().min(1, "Name is required"),
assignment_type: z.string().optional(),
})
type FormValues = z.infer<typeof schema>
export function DepartmentInlineForm({ onSuccess }: InlineCreateFormProps) {
const api = useAuthApi()
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { name: "", assignment_type: "none" },
})
const handleSubmit = async (values: FormValues) => {
try {
const result = await api.departments.create({
name: values.name,
assignment_type: values.assignment_type || undefined,
})
toast.success("Department created")
form.reset()
const item = (result as any)?.data ?? result
onSuccess({ value: String(item.id), label: item.name ?? String(item.id) })
} catch {
toast.error("Failed to create department")
}
}
return (
<Rhform form={form} onSubmit={handleSubmit}>
<FieldGroup>
<RhfTextField
name="name"
label="Name"
placeholder="e.g. Mechanical"
required
/>
<RhfSelectField
name="assignment_type"
label="Assignment Type"
placeholder="Select assignment type"
options={[...DEPARTMENT_ASSIGNMENT_TYPE_OPTIONS]}
/>
<Button type="submit" disabled={form.formState.isSubmitting}>
<Plus />
{form.formState.isSubmitting ? "Creating..." : "Create Department"}
</Button>
</FieldGroup>
</Rhform>
)
}

View File

@ -0,0 +1,78 @@
"use client"
import { z } from "zod"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { Plus } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { FieldGroup } from "@/shared/components/ui/field"
import { Rhform, RhfTextField, RhfAsyncSelectField, type InlineCreateFormProps } from "@/shared/components/form"
import { ShopTypeInlineForm } from "@/modules/vehicles/inline-forms/shop-type-inline-form"
import { toast } from "sonner"
import { useAuthApi } from "@/shared/useApi"
const schema = z.object({
title: z.string().min(1, "Title is required"),
shop_type: z.object({ value: z.string(), label: z.string() }).nullable(),
})
type FormValues = z.infer<typeof schema>
const mapLookupOption = (item: any) => ({
value: String(item.id),
label: item.name ?? item.title ?? String(item.id),
})
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
export function InventoryCategoryInlineForm({ onSuccess }: InlineCreateFormProps) {
const api = useAuthApi()
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { title: "", shop_type: null },
})
const handleSubmit = async (values: FormValues) => {
try {
const result = await api.inventory.createCategory({
title: values.title,
shop_type_id: values.shop_type ? Number(values.shop_type.value) : undefined,
})
toast.success("Category created")
form.reset()
const item = (result as any)?.data ?? result
onSuccess({ value: String(item.id), label: item.title ?? item.name ?? String(item.id) })
} catch {
toast.error("Failed to create category")
}
}
return (
<Rhform form={form} onSubmit={handleSubmit}>
<FieldGroup>
<RhfTextField
name="title"
label="Title"
placeholder="e.g. Parts"
required
/>
<RhfAsyncSelectField
name="shop_type"
label="Shop Type"
placeholder="Select shop type"
queryKey={["shop-types"]}
listFn={() => api.shopTypes.list()}
mapOption={mapLookupOption}
createForm={(props) => <ShopTypeInlineForm {...props} />}
createLabel="Shop Type"
{...STORE_OBJECT}
/>
<Button type="submit" disabled={form.formState.isSubmitting}>
<Plus />
{form.formState.isSubmitting ? "Creating..." : "Create Category"}
</Button>
</FieldGroup>
</Rhform>
)
}

View File

@ -0,0 +1,55 @@
"use client"
import { z } from "zod"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { Plus } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { FieldGroup } from "@/shared/components/ui/field"
import { Rhform, RhfTextField, type InlineCreateFormProps } from "@/shared/components/form"
import { toast } from "sonner"
import { useAuthApi } from "@/shared/useApi"
const schema = z.object({
title: z.string().min(1, "Title is required"),
})
type FormValues = z.infer<typeof schema>
export function UnitTypeInlineForm({ onSuccess }: InlineCreateFormProps) {
const api = useAuthApi()
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { title: "" },
})
const handleSubmit = async (values: FormValues) => {
try {
const result = await api.inventory.createUnitType({ title: values.title })
toast.success("Unit type created")
form.reset()
const item = (result as any)?.data ?? result
onSuccess({ value: String(item.id), label: item.title ?? item.name ?? String(item.id) })
} catch {
toast.error("Failed to create unit type")
}
}
return (
<Rhform form={form} onSubmit={handleSubmit}>
<FieldGroup>
<RhfTextField
name="title"
label="Title"
placeholder="e.g. Hour"
required
/>
<Button type="submit" disabled={form.formState.isSubmitting}>
<Plus />
{form.formState.isSubmitting ? "Creating..." : "Create Unit Type"}
</Button>
</FieldGroup>
</Rhform>
)
}

View File

@ -0,0 +1,238 @@
"use client"
import { AlertTriangle, Plus, Save } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { Alert, AlertTitle } from "@/shared/components/ui/alert"
import { FieldGroup } from "@/shared/components/ui/field"
import {
Rhform,
RhfTextField,
RhfTextareaField,
RhfAsyncSelectField,
} from "@/shared/components/form"
import { ShopTypeInlineForm } from "@/modules/vehicles/inline-forms/shop-type-inline-form"
import { InventoryCategoryInlineForm } from "./inline-forms/inventory-category-inline-form"
import { UnitTypeInlineForm } from "./inline-forms/unit-type-inline-form"
import { DepartmentInlineForm } from "./inline-forms/department-inline-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 { toId } from "@/shared/lib/utils"
import { serviceFormSchema, type ServiceFormValues } from "./service.schema"
import { SERVICE_ROUTES } from "@repo/api"
// ── Props ──
export type ServiceFormProps = {
resourceId?: string | null
initialData?: unknown
onSuccess?: () => void
}
// ── Default values ──
const DEFAULT_VALUES: ServiceFormValues = {
shop_type: null,
category: null,
unit_type: null,
department: null,
labor_name: "",
service_code: "",
labor_matrix: "",
description: "",
selling_price: undefined,
}
// ── Mapping helpers ──
const mapLookupOption = (item: any) => ({
value: String(item.id),
label: item.name ?? item.title ?? String(item.id),
})
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
function mapToFormValues(data: unknown): ServiceFormValues {
const d = (data as any)?.data ?? data ?? {}
return {
shop_type: null,
category: null,
unit_type: null,
department: null,
labor_name: d.name || d.labor_name || "",
service_code: d.service_code || "",
labor_matrix: d.labor_matrix || "",
description: d.description || "",
selling_price: d.selling_price ?? undefined,
}
}
function mapCreatePayload(values: ServiceFormValues) {
return {
shop_type_id: toId(values.shop_type),
category_id: toId(values.category),
unit_type_id: toId(values.unit_type),
department_id: toId(values.department),
labor_name: values.labor_name,
service_code: values.service_code || undefined,
labor_matrix: values.labor_matrix || undefined,
description: values.description || undefined,
selling_price: values.selling_price,
}
}
function mapUpdatePayload(values: ServiceFormValues) {
return {
labor_name: values.labor_name,
selling_price: values.selling_price,
}
}
// ── Component ──
export function ServiceForm({ resourceId, initialData, onSuccess }: ServiceFormProps) {
const api = useAuthApi()
const { form, isEditing } = useResourceForm<ServiceFormValues, any>({
schema: serviceFormSchema,
defaultValues: DEFAULT_VALUES,
resourceId,
initialData,
mapToFormValues,
})
const { mutate, error, isPending } = useFormMutation(form, {
mutationFn: (values: ServiceFormValues) => {
const promise = isEditing && resourceId
? api.services.update(resourceId, mapUpdatePayload(values))
: api.services.create(mapCreatePayload(values))
toast.promise(promise, {
loading: isEditing ? "Updating service..." : "Creating service...",
success: isEditing ? "Service updated successfully" : "Service created successfully",
error: isEditing ? "Failed to update service" : "Failed to create service",
})
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 service" : "Failed to create service"}
</AlertTitle>
{error.message}
</Alert>
)}
<FieldGroup>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField
name="labor_name"
label="Labor Name"
placeholder="e.g. Oil Change"
required
/>
<RhfTextField
name="service_code"
label="Service Code"
placeholder="e.g. SVC-001"
/>
</div>
{!isEditing && (
<>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfAsyncSelectField
name="shop_type"
label="Shop Type"
placeholder="Select shop type"
queryKey={["shop-types"]}
listFn={() => api.shopTypes.list()}
mapOption={mapLookupOption}
createForm={(props) => <ShopTypeInlineForm {...props} />}
createLabel="Shop Type"
{...STORE_OBJECT}
/>
<RhfAsyncSelectField
name="category"
label="Category"
placeholder="Select category"
queryKey={["inventory-categories"]}
listFn={() => api.inventory.listCategories()}
mapOption={mapLookupOption}
createForm={(props) => <InventoryCategoryInlineForm {...props} />}
createLabel="Category"
{...STORE_OBJECT}
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfAsyncSelectField
name="unit_type"
label="Unit Type"
placeholder="Select unit type"
queryKey={["unit-types"]}
listFn={() => api.inventory.listUnitTypes()}
mapOption={mapLookupOption}
createForm={(props) => <UnitTypeInlineForm {...props} />}
createLabel="Unit Type"
{...STORE_OBJECT}
/>
<RhfAsyncSelectField
name="department"
label="Department"
placeholder="Select department"
queryKey={["departments"]}
listFn={() => api.departments.list()}
mapOption={(item: any) => ({ value: String(item.id), label: item.name ?? String(item.id) })}
createForm={(props) => <DepartmentInlineForm {...props} />}
createLabel="Department"
{...STORE_OBJECT}
/>
</div>
<RhfTextField
name="labor_matrix"
label="Labor Matrix"
placeholder="e.g. Standard"
/>
</>
)}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField
name="selling_price"
label="Selling Price"
type="number"
placeholder="e.g. 75"
/>
</div>
<RhfTextareaField
name="description"
label="Description"
placeholder="Describe the service..."
rows={3}
/>
<Button type="submit" variant="default" disabled={isPending}>
{isEditing ? <Save /> : <Plus />}
{isPending
? (isEditing ? "Updating..." : "Creating...")
: (isEditing ? "Update Service" : "Create Service")}
</Button>
</FieldGroup>
</Rhform>
)
}

View File

@ -0,0 +1,19 @@
import { z } from "zod"
export const relationFieldSchema = z
.object({ value: z.string(), label: z.string() })
.nullable()
export const serviceFormSchema = z.object({
shop_type: relationFieldSchema,
category: relationFieldSchema,
unit_type: relationFieldSchema,
department: relationFieldSchema,
labor_name: z.string().min(1, "Labor name is required"),
service_code: z.string().optional(),
labor_matrix: z.string().optional(),
description: z.string().optional(),
selling_price: z.coerce.number().min(0).optional(),
})
export type ServiceFormValues = z.infer<typeof serviceFormSchema>

View File

@ -0,0 +1,157 @@
"use client"
import { AlertTriangle, Plus, Save } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { Alert, AlertTitle } from "@/shared/components/ui/alert"
import { FieldGroup } from "@/shared/components/ui/field"
import {
Rhform,
RhfTextField,
RhfTextareaField,
RhfCheckboxField,
RhfFileField,
} 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 { shopTypeFormSchema, type ShopTypeFormValues } from "./shop-type.schema"
import { SHOP_TYPE_ROUTES } from "@repo/api"
// ── Props ──
export type ShopTypeFormProps = {
resourceId?: string | null
initialData?: unknown
onSuccess?: () => void
}
// ── Default values ──
const DEFAULT_VALUES: ShopTypeFormValues = {
title: "",
shop_type: "",
note: "",
is_default: false,
inspection: null,
image: null,
}
// ── Mapping helpers ──
function mapToFormValues(data: unknown): ShopTypeFormValues {
const d = (data as any)?.data ?? data ?? {}
return {
title: d.title || "",
shop_type: d.shop_type || "",
note: d.note || "",
is_default: d.is_default ?? false,
// File fields cannot be pre-filled from URL strings
inspection: null,
image: null,
}
}
function mapFormToPayload(values: ShopTypeFormValues) {
return {
title: values.title,
shop_type: values.shop_type || undefined,
note: values.note || undefined,
is_default: values.is_default,
inspection: values.inspection ?? undefined,
image: values.image ?? undefined,
}
}
// ── Component ──
export function ShopTypeForm({ resourceId, initialData, onSuccess }: ShopTypeFormProps) {
const api = useAuthApi()
const { form, isEditing } = useResourceForm<ShopTypeFormValues, any>({
schema: shopTypeFormSchema,
defaultValues: DEFAULT_VALUES,
resourceId,
initialData,
mapToFormValues,
})
const { mutate, error, isPending } = useFormMutation(form, {
mutationFn: (values: ShopTypeFormValues) => {
const payload = mapFormToPayload(values)
const promise = isEditing && resourceId
? api.shopTypes.update(resourceId, payload)
: api.shopTypes.create({ ...payload, title: values.title })
toast.promise(promise, {
loading: isEditing ? "Updating shop type..." : "Creating shop type...",
success: isEditing ? "Shop type updated successfully" : "Shop type created successfully",
error: isEditing ? "Failed to update shop type" : "Failed to create shop type",
})
return promise
},
onSuccess: () => {
form.reset()
onSuccess?.()
},
})
return (
<Rhform form={form} onSubmit={(values) => mutate(values)}>
{error && (
<Alert variant="destructive" className="mb-4">
<AlertTriangle className="me-2 h-4 w-4" />
<AlertTitle>
{isEditing ? "Failed to update shop type" : "Failed to create shop type"}
</AlertTitle>
{error.message}
</Alert>
)}
<FieldGroup>
<RhfTextField
name="title"
label="Title"
placeholder="e.g. Main Workshop"
required
/>
<RhfTextField
name="shop_type"
label="Type"
placeholder="e.g. Car, Truck"
/>
<RhfTextareaField
name="note"
label="Note"
placeholder="Optional description"
rows={3}
/>
<RhfCheckboxField
name="is_default"
label="Set as default"
/>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfFileField
name="inspection"
label="Inspection Template"
accept=".pdf,.doc,.docx"
/>
<RhfFileField
name="image"
label="Image"
accept="image/*"
/>
</div>
<Button type="submit" variant="default" disabled={isPending}>
{isEditing ? <Save /> : <Plus />}
{isPending
? (isEditing ? "Updating..." : "Creating...")
: (isEditing ? "Update Shop Type" : "Create Shop Type")}
</Button>
</FieldGroup>
</Rhform>
)
}

View File

@ -0,0 +1,19 @@
import { z } from "zod"
export const shopTypeFormSchema = z.object({
title: z.string().min(1, "Title is required"),
shop_type: z.string().optional(),
note: z.string().optional(),
is_default: z.boolean().optional(),
inspection: z.any().optional(),
image: z.any().optional(),
})
export type ShopTypeFormValues = {
title: string
shop_type?: string
note?: string
is_default?: boolean
inspection?: File | null
image?: File | null
}

View File

@ -0,0 +1,99 @@
"use client"
import { AlertTriangle, Plus } 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,
RhfCheckboxField,
} from "@/shared/components/form"
import { toast } from "sonner"
import { useAuthApi } from "@/shared/useApi"
import { useResourceForm } from "@/shared/hooks/use-resource-form"
import { useFormMutation } from "@/shared/hooks/use-form-mutation"
import {
shopCalendarFormSchema,
type ShopCalendarFormValues,
} from "./shop-calendar.schema"
import { SHOP_CALENDAR_ROUTES } from "@repo/api"
// ── Props ──
export type ShopCalendarFormProps = {
resourceId?: string | null
initialData?: unknown
onSuccess?: () => void
}
// ── Default values ──
const DEFAULT_VALUES: ShopCalendarFormValues = {
title: "",
is_default: false,
}
// ── Component ──
export function ShopCalendarForm({ resourceId, onSuccess }: ShopCalendarFormProps) {
const api = useAuthApi()
const { form } = useResourceForm<ShopCalendarFormValues, any>({
schema: shopCalendarFormSchema,
defaultValues: DEFAULT_VALUES,
resourceId: null,
queryKey: [SHOP_CALENDAR_ROUTES.INDEX],
mapToFormValues: (data: unknown) => {
const d = (data as any)?.data ?? data ?? {}
return {
title: d.title ?? "",
is_default: d.is_default ?? false,
}
},
})
const { mutate, error, isPending } = useFormMutation(form, {
mutationFn: (values: ShopCalendarFormValues) => {
const payload = {
title: values.title,
is_default: values.is_default,
}
const promise = api.shopCalendars.create(payload)
toast.promise(promise, {
loading: "Creating shop calendar...",
success: "Shop calendar created successfully",
error: "Failed to create shop calendar",
})
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>Failed to create shop calendar</AlertTitle>
{error.message}
</Alert>
)}
<FieldGroup>
<RhfTextField name="title" label="Title" placeholder="Enter calendar title" required />
<RhfCheckboxField name="is_default" label="Set as default" />
<Button type="submit" variant="default" disabled={isPending}>
<Plus />
{isPending ? "Creating..." : "Create Shop Calendar"}
</Button>
</FieldGroup>
</Rhform>
)
}

View File

@ -0,0 +1,11 @@
import { z } from "zod"
const shopCalendarFormSchema = z.object({
title: z.string().min(1, "Title is required"),
is_default: z.boolean(),
})
type ShopCalendarFormValues = z.infer<typeof shopCalendarFormSchema>
export { shopCalendarFormSchema }
export type { ShopCalendarFormValues }

View File

@ -0,0 +1,161 @@
"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,
RhfCheckboxField,
} from "@/shared/components/form"
import { toast } from "sonner"
import { useAuthApi } from "@/shared/useApi"
import { useResourceForm } from "@/shared/hooks/use-resource-form"
import { useFormMutation } from "@/shared/hooks/use-form-mutation"
import {
shopTimingFormSchema,
type ShopTimingFormValues,
} from "./shop-timing.schema"
import { SHOP_TIMING_ROUTES } from "@repo/api"
// ── Props ──
export type ShopTimingFormProps = {
resourceId?: string | null
initialData?: unknown
onSuccess?: () => void
}
// ── Default values ──
const DEFAULT_VALUES: ShopTimingFormValues = {
title: "",
in_time: "",
out_time: "",
full_day_hours: "",
half_day_hours: "",
punch_in: "",
punch_out: "",
before_time: "",
after_time: "",
is_default: false,
}
// ── Mapping helpers ──
function mapToFormValues(data: unknown): ShopTimingFormValues {
const d = (data as any)?.data ?? data ?? {}
return {
title: d.title ?? "",
in_time: d.in_time ?? "",
out_time: d.out_time ?? "",
full_day_hours: d.full_day_hours ?? "",
half_day_hours: d.half_day_hours ?? "",
punch_in: d.punch_in ?? "",
punch_out: d.punch_out ?? "",
before_time: d.before_time ?? "",
after_time: d.after_time ?? "",
is_default: d.is_default ?? false,
}
}
function mapFormToPayload(values: ShopTimingFormValues) {
return {
title: values.title,
in_time: values.in_time,
out_time: values.out_time,
full_day_hours: values.full_day_hours || undefined,
half_day_hours: values.half_day_hours || undefined,
punch_in: values.punch_in || undefined,
punch_out: values.punch_out || undefined,
before_time: values.before_time || undefined,
after_time: values.after_time || undefined,
is_default: values.is_default,
}
}
// ── Component ──
export function ShopTimingForm({ resourceId, initialData, onSuccess }: ShopTimingFormProps) {
const api = useAuthApi()
const { form, isEditing } = useResourceForm<ShopTimingFormValues, any>({
schema: shopTimingFormSchema,
defaultValues: DEFAULT_VALUES,
resourceId,
initialData,
initialize: (id) => api.shopTimings.show(id),
queryKey: [SHOP_TIMING_ROUTES.BY_ID, resourceId],
mapToFormValues: mapToFormValues,
})
const { mutate, error, isPending } = useFormMutation(form, {
mutationFn: (values: ShopTimingFormValues) => {
const payload = mapFormToPayload(values)
const promise = isEditing && resourceId
? api.shopTimings.update(resourceId, payload)
: api.shopTimings.create(payload)
toast.promise(promise, {
loading: isEditing ? "Updating shop timing..." : "Creating shop timing...",
success: isEditing ? "Shop timing updated successfully" : "Shop timing created successfully",
error: isEditing ? "Failed to update shop timing" : "Failed to create shop timing",
})
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 shop timing" : "Failed to create shop timing"}
</AlertTitle>
{error.message}
</Alert>
)}
<FieldGroup>
<RhfTextField name="title" label="Title" placeholder="Enter title" required />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField name="in_time" label="In Time" placeholder="HH:MM:SS" required />
<RhfTextField name="out_time" label="Out Time" placeholder="HH:MM:SS" required />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField name="full_day_hours" label="Full Day Hours" placeholder="HH:MM:SS" />
<RhfTextField name="half_day_hours" label="Half Day Hours" placeholder="HH:MM:SS" />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField name="punch_in" label="Punch In" placeholder="HH:MM:SS" />
<RhfTextField name="punch_out" label="Punch Out" placeholder="HH:MM:SS" />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField name="before_time" label="Before Time" placeholder="HH:MM:SS" />
<RhfTextField name="after_time" label="After Time" placeholder="HH:MM:SS" />
</div>
<RhfCheckboxField name="is_default" label="Set as default" />
<Button type="submit" variant="default" disabled={isPending}>
{isEditing ? <Save /> : <Plus />}
{isPending
? (isEditing ? "Updating..." : "Creating...")
: (isEditing ? "Update Shop Timing" : "Create Shop Timing")}
</Button>
</FieldGroup>
</Rhform>
)
}

View File

@ -0,0 +1,19 @@
import { z } from "zod"
const shopTimingFormSchema = z.object({
title: z.string().min(1, "Title is required"),
in_time: z.string().min(1, "In time is required"),
out_time: z.string().min(1, "Out time is required"),
full_day_hours: z.string().optional(),
half_day_hours: z.string().optional(),
punch_in: z.string().optional(),
punch_out: z.string().optional(),
before_time: z.string().optional(),
after_time: z.string().optional(),
is_default: z.boolean().default(false),
})
type ShopTimingFormValues = z.infer<typeof shopTimingFormSchema>
export { shopTimingFormSchema }
export type { ShopTimingFormValues }

View File

@ -0,0 +1,55 @@
"use client"
import { z } from "zod"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { Plus } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { FieldGroup } from "@/shared/components/ui/field"
import { Rhform, RhfTextField, type InlineCreateFormProps } from "@/shared/components/form"
import { toast } from "sonner"
import { useAuthApi } from "@/shared/useApi"
const schema = z.object({
title: z.string().min(1, "Title is required"),
})
type FormValues = z.infer<typeof schema>
export function BodyTypeInlineForm({ onSuccess }: InlineCreateFormProps) {
const api = useAuthApi()
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { title: "" },
})
const handleSubmit = async (values: FormValues) => {
try {
const result = await api.vehicleAttributes.createBodyType({ title: values.title })
toast.success("Body type created")
form.reset()
const item = (result as any)?.data ?? result
onSuccess({ value: String(item.id), label: item.title ?? String(item.id) })
} catch {
toast.error("Failed to create body type")
}
}
return (
<Rhform form={form} onSubmit={handleSubmit}>
<FieldGroup>
<RhfTextField
name="title"
label="Title"
placeholder="e.g. Sedan"
required
/>
<Button type="submit" disabled={form.formState.isSubmitting}>
<Plus />
{form.formState.isSubmitting ? "Creating..." : "Create Body Type"}
</Button>
</FieldGroup>
</Rhform>
)
}

View File

@ -0,0 +1,55 @@
"use client"
import { z } from "zod"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { Plus } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { FieldGroup } from "@/shared/components/ui/field"
import { Rhform, RhfTextField, type InlineCreateFormProps } from "@/shared/components/form"
import { toast } from "sonner"
import { useAuthApi } from "@/shared/useApi"
const schema = z.object({
title: z.string().min(1, "Title is required"),
})
type FormValues = z.infer<typeof schema>
export function ColorInlineForm({ onSuccess }: InlineCreateFormProps) {
const api = useAuthApi()
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { title: "" },
})
const handleSubmit = async (values: FormValues) => {
try {
const result = await api.vehicleAttributes.createColor({ title: values.title })
toast.success("Color created")
form.reset()
const item = (result as any)?.data ?? result
onSuccess({ value: String(item.id), label: item.title ?? String(item.id) })
} catch {
toast.error("Failed to create color")
}
}
return (
<Rhform form={form} onSubmit={handleSubmit}>
<FieldGroup>
<RhfTextField
name="title"
label="Title"
placeholder="e.g. Black"
required
/>
<Button type="submit" disabled={form.formState.isSubmitting}>
<Plus />
{form.formState.isSubmitting ? "Creating..." : "Create Color"}
</Button>
</FieldGroup>
</Rhform>
)
}

View File

@ -0,0 +1,55 @@
"use client"
import { z } from "zod"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { Plus } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { FieldGroup } from "@/shared/components/ui/field"
import { Rhform, RhfTextField, type InlineCreateFormProps } from "@/shared/components/form"
import { toast } from "sonner"
import { useAuthApi } from "@/shared/useApi"
const schema = z.object({
title: z.string().min(1, "Title is required"),
})
type FormValues = z.infer<typeof schema>
export function FuelTypeInlineForm({ onSuccess }: InlineCreateFormProps) {
const api = useAuthApi()
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { title: "" },
})
const handleSubmit = async (values: FormValues) => {
try {
const result = await api.vehicleAttributes.createFuelType({ title: values.title })
toast.success("Fuel type created")
form.reset()
const item = (result as any)?.data ?? result
onSuccess({ value: String(item.id), label: item.title ?? String(item.id) })
} catch {
toast.error("Failed to create fuel type")
}
}
return (
<Rhform form={form} onSubmit={handleSubmit}>
<FieldGroup>
<RhfTextField
name="title"
label="Title"
placeholder="e.g. Gasoline"
required
/>
<Button type="submit" disabled={form.formState.isSubmitting}>
<Plus />
{form.formState.isSubmitting ? "Creating..." : "Create Fuel Type"}
</Button>
</FieldGroup>
</Rhform>
)
}

View File

@ -0,0 +1,114 @@
"use client"
import { z } from "zod"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { Plus } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { FieldGroup } from "@/shared/components/ui/field"
import {
Rhform,
RhfTextField,
RhfTextareaField,
RhfCheckboxField,
RhfFileField,
type InlineCreateFormProps,
} from "@/shared/components/form"
import { toast } from "sonner"
import { useAuthApi } from "@/shared/useApi"
const schema = z.object({
title: z.string().min(1, "Title is required"),
shop_type: z.string().optional(),
note: z.string().optional(),
is_default: z.boolean().optional(),
inspection: z.any().optional(),
image: z.any().optional(),
})
type FormValues = {
title: string
shop_type?: string
note?: string
is_default?: boolean
inspection?: File | null
image?: File | null
}
export function ShopTypeInlineForm({ onSuccess }: InlineCreateFormProps) {
const api = useAuthApi()
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: {
title: "",
shop_type: "",
note: "",
is_default: false,
inspection: null,
image: null,
},
})
const handleSubmit = async (values: FormValues) => {
try {
const result = await api.shopTypes.create({
title: values.title,
shop_type: values.shop_type || undefined,
note: values.note || undefined,
is_default: values.is_default,
inspection: values.inspection ?? undefined,
image: values.image ?? undefined,
})
toast.success("Shop type created")
form.reset()
const item = (result as any)?.data ?? result
onSuccess({ value: String(item.id), label: item.title ?? String(item.id) })
} catch {
toast.error("Failed to create shop type")
}
}
return (
<Rhform form={form} onSubmit={handleSubmit}>
<FieldGroup>
<RhfTextField
name="title"
label="Title"
placeholder="e.g. Main Workshop"
required
/>
<RhfTextField
name="shop_type"
label="Type"
placeholder="e.g. Car, Truck"
/>
<RhfTextareaField
name="note"
label="Note"
placeholder="Optional description"
rows={3}
/>
<RhfCheckboxField
name="is_default"
label="Set as default"
/>
<RhfFileField
name="inspection"
label="Inspection Template"
accept=".pdf,.doc,.docx"
/>
<RhfFileField
name="image"
label="Image"
accept="image/*"
/>
<Button type="submit" disabled={form.formState.isSubmitting}>
<Plus />
{form.formState.isSubmitting ? "Creating..." : "Create Shop Type"}
</Button>
</FieldGroup>
</Rhform>
)
}

View File

@ -0,0 +1,55 @@
"use client"
import { z } from "zod"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { Plus } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { FieldGroup } from "@/shared/components/ui/field"
import { Rhform, RhfTextField, type InlineCreateFormProps } from "@/shared/components/form"
import { toast } from "sonner"
import { useAuthApi } from "@/shared/useApi"
const schema = z.object({
title: z.string().min(1, "Title is required"),
})
type FormValues = z.infer<typeof schema>
export function TransmissionInlineForm({ onSuccess }: InlineCreateFormProps) {
const api = useAuthApi()
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { title: "" },
})
const handleSubmit = async (values: FormValues) => {
try {
const result = await api.vehicleAttributes.createTransmission({ title: values.title })
toast.success("Transmission created")
form.reset()
const item = (result as any)?.data ?? result
onSuccess({ value: String(item.id), label: item.title ?? String(item.id) })
} catch {
toast.error("Failed to create transmission")
}
}
return (
<Rhform form={form} onSubmit={handleSubmit}>
<FieldGroup>
<RhfTextField
name="title"
label="Title"
placeholder="e.g. Automatic"
required
/>
<Button type="submit" disabled={form.formState.isSubmitting}>
<Plus />
{form.formState.isSubmitting ? "Creating..." : "Create Transmission"}
</Button>
</FieldGroup>
</Rhform>
)
}

View File

@ -0,0 +1,267 @@
"use client"
import { AlertTriangle, Plus, Save } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { Alert, AlertTitle } from "@/shared/components/ui/alert"
import { FieldGroup } from "@/shared/components/ui/field"
import {
Rhform,
RhfTextField,
RhfTextareaField,
RhfAsyncSelectField,
} from "@/shared/components/form"
import { ShopTypeInlineForm } from "./inline-forms/shop-type-inline-form"
import { BodyTypeInlineForm } from "./inline-forms/body-type-inline-form"
import { FuelTypeInlineForm } from "./inline-forms/fuel-type-inline-form"
import { TransmissionInlineForm } from "./inline-forms/transmission-inline-form"
import { ColorInlineForm } from "./inline-forms/color-inline-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 { vehicleFormSchema, type VehicleFormValues } from "./vehicle.schema"
import { VEHICLE_ROUTES } from "@repo/api"
// ── Props ──
export type VehicleFormProps = {
resourceId?: string | null
initialData?: unknown
onSuccess?: () => void
}
// ── Default values ──
const DEFAULT_VALUES: VehicleFormValues = {
shop_type: null,
vehicle_body_type: null,
vehicle_fuel_type: null,
vehicle_transmission: null,
vehicle_color: null,
make: "",
model: "",
year: "",
sub_model: "",
license_plate: "",
vin_number: "",
engine_size: "",
drivetrain: "",
mileage: "",
owners_number: "",
note: "",
}
// ── Mapping helpers ──
const mapLookupOption = (item: any) => ({
value: String(item.id),
label: item.name ?? item.title ?? String(item.id),
})
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
function mapToFormValues(data: unknown): VehicleFormValues {
const d = (data as any)?.data ?? data ?? {}
return {
shop_type: toRelation(d.shop_type_id, d.shop_type?.title),
vehicle_body_type: toRelation(d.vehicle_body_type_id, d.vehicle_body_type?.title),
vehicle_fuel_type: toRelation(d.vehicle_fuel_type_id, d.vehicle_fuel_type?.title),
vehicle_transmission: toRelation(d.vehicle_transmission_id, d.vehicle_transmission?.title),
vehicle_color: toRelation(d.vehicle_color_id, d.vehicle_color?.title),
make: d.make || "",
model: d.model || "",
year: d.year || "",
sub_model: d.sub_model || "",
license_plate: d.license_plate || "",
vin_number: d.vin_number || "",
engine_size: d.engine_size || "",
drivetrain: d.drivetrain || "",
mileage: d.mileage || "",
owners_number: d.owners_number || "",
note: d.note || "",
}
}
function mapCreatePayload(values: VehicleFormValues) {
return {
shop_type_id: toId(values.shop_type),
vehicle_body_type_id: toId(values.vehicle_body_type),
vehicle_fuel_type_id: toId(values.vehicle_fuel_type),
vehicle_transmission_id: toId(values.vehicle_transmission),
vehicle_color_id: toId(values.vehicle_color),
make: values.make,
model: values.model,
year: values.year,
sub_model: values.sub_model || undefined,
license_plate: values.license_plate || undefined,
vin_number: values.vin_number || undefined,
engine_size: values.engine_size || undefined,
drivetrain: values.drivetrain || undefined,
mileage: values.mileage || undefined,
owners_number: values.owners_number || undefined,
note: values.note || undefined,
}
}
function mapUpdatePayload(values: VehicleFormValues) {
return {
mileage: values.mileage || undefined,
license_plate: values.license_plate || undefined,
}
}
// ── Component ──
export function VehicleForm({ resourceId, initialData, onSuccess }: VehicleFormProps) {
const api = useAuthApi()
const { form, isEditing } = useResourceForm<VehicleFormValues, any>({
schema: vehicleFormSchema,
defaultValues: DEFAULT_VALUES,
resourceId,
initialData,
mapToFormValues,
})
const { mutate, error, isPending } = useFormMutation(form, {
mutationFn: (values: VehicleFormValues) => {
const promise = isEditing && resourceId
? api.vehicles.update(resourceId, mapUpdatePayload(values))
: api.vehicles.create(mapCreatePayload(values))
toast.promise(promise, {
loading: isEditing ? "Updating vehicle..." : "Creating vehicle...",
success: isEditing ? "Vehicle updated successfully" : "Vehicle created successfully",
error: isEditing ? "Failed to update vehicle" : "Failed to create vehicle",
})
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 vehicle" : "Failed to create vehicle"}
</AlertTitle>
{error.message}
</Alert>
)}
<FieldGroup>
{!isEditing && (
<>
{/* Vehicle identity */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<RhfTextField name="make" label="Make" placeholder="e.g. Toyota" required />
<RhfTextField name="model" label="Model" placeholder="e.g. Camry" required />
<RhfTextField name="year" label="Year" placeholder="e.g. 2024" required />
</div>
<RhfTextField name="sub_model" label="Sub Model" placeholder="e.g. LE" />
{/* Associations */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfAsyncSelectField
name="shop_type"
label="Shop Type"
placeholder="Select shop type"
queryKey={["shop-types"]}
listFn={() => api.shopTypes.list()}
mapOption={mapLookupOption}
createForm={(props) => <ShopTypeInlineForm {...props} />}
createLabel="Shop Type"
{...STORE_OBJECT}
/>
<RhfAsyncSelectField
name="vehicle_body_type"
label="Body Type"
placeholder="Select body type"
queryKey={["vehicle-body-types"]}
listFn={() => api.vehicleAttributes.listBodyTypes()}
mapOption={mapLookupOption}
createForm={(props) => <BodyTypeInlineForm {...props} />}
createLabel="Body Type"
{...STORE_OBJECT}
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfAsyncSelectField
name="vehicle_fuel_type"
label="Fuel Type"
placeholder="Select fuel type"
queryKey={["vehicle-fuel-types"]}
listFn={() => api.vehicleAttributes.listFuelTypes()}
mapOption={mapLookupOption}
createForm={(props) => <FuelTypeInlineForm {...props} />}
createLabel="Fuel Type"
{...STORE_OBJECT}
/>
<RhfAsyncSelectField
name="vehicle_transmission"
label="Transmission"
placeholder="Select transmission"
queryKey={["vehicle-transmissions"]}
listFn={() => api.vehicleAttributes.listTransmissions()}
mapOption={mapLookupOption}
createForm={(props) => <TransmissionInlineForm {...props} />}
createLabel="Transmission"
{...STORE_OBJECT}
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfAsyncSelectField
name="vehicle_color"
label="Color"
placeholder="Select color"
queryKey={["vehicle-colors"]}
listFn={() => api.vehicleAttributes.listColors()}
mapOption={mapLookupOption}
createForm={(props) => <ColorInlineForm {...props} />}
createLabel="Color"
{...STORE_OBJECT}
/>
<RhfTextField name="vin_number" label="VIN Number" placeholder="e.g. 1HGBH41JXMN109186" />
</div>
{/* Technical specs */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField name="engine_size" label="Engine Size" placeholder="e.g. 2.5L" />
<RhfTextField name="drivetrain" label="Drivetrain" placeholder="e.g. FWD" />
</div>
<RhfTextField name="owners_number" label="Number of Owners" placeholder="e.g. 1" />
</>
)}
{/* Editable in both create and update */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField name="license_plate" label="License Plate" placeholder="e.g. ABC-123" />
<RhfTextField name="mileage" label="Mileage" placeholder="e.g. 10000" />
</div>
{!isEditing && (
<RhfTextareaField name="note" label="Notes" rows={3} />
)}
<Button type="submit" variant="default" disabled={isPending}>
{isEditing ? <Save /> : <Plus />}
{isPending
? (isEditing ? "Updating..." : "Creating...")
: (isEditing ? "Update Vehicle" : "Create Vehicle")}
</Button>
</FieldGroup>
</Rhform>
)
}

View File

@ -0,0 +1,35 @@
import { z } from "zod"
export const relationFieldSchema = z
.object({ value: z.string(), label: z.string() })
.nullable()
export const vehicleFormSchema = z.object({
// ── Relations ──
shop_type: relationFieldSchema,
vehicle_body_type: relationFieldSchema,
vehicle_fuel_type: relationFieldSchema,
vehicle_transmission: relationFieldSchema,
vehicle_color: relationFieldSchema,
// ── Vehicle identity ──
make: z.string().optional(),
model: z.string().optional(),
year: z.string().optional(),
sub_model: z.string().optional(),
// ── License & identifiers ──
license_plate: z.string().optional(),
vin_number: z.string().optional(),
// ── Technical specs ──
engine_size: z.string().optional(),
drivetrain: z.string().optional(),
mileage: z.string().optional(),
owners_number: z.string().optional(),
// ── Notes ──
note: z.string().optional(),
})
export type VehicleFormValues = z.infer<typeof vehicleFormSchema>

4
next.config.mjs Normal file
View File

@ -0,0 +1,4 @@
/** @type {import('next').NextConfig} */
const nextConfig = {}
export default nextConfig

View File

@ -1,22 +1,65 @@
{ {
"name": "carage-erp", "name": "dashboard",
"version": "0.0.1",
"type": "module",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "turbo run build", "predev": "pnpm --filter @repo/api run generate",
"dev": "turbo run dev", "dev": "next dev --turbopack",
"lint": "turbo run lint", "prebuild": "pnpm --filter @repo/api run generate",
"start": "turbo run start", "build": "next build",
"format": "prettier --write \"**/*.{ts,tsx,md}\"", "start": "next start",
"check-types": "turbo run check-types", "lint": "eslint",
"test:e2e": "turbo run test:e2e" "format": "prettier --write \"**/*.{ts,tsx}\"",
"typecheck": "tsc --noEmit",
"cypress:open": "cypress open",
"cypress:run": "cypress run",
"test:e2e": "cypress run"
},
"dependencies": {
"@base-ui/react": "^1.3.0",
"@hookform/resolvers": "^5.2.2",
"@repo/api": "workspace:*",
"@tanstack/react-query": "^5.95.2",
"@tanstack/react-table": "^8.21.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"embla-carousel-react": "^8.6.0",
"input-otp": "^1.4.2",
"lucide-react": "^0.577.0",
"next": "16.1.7",
"next-themes": "^0.4.6",
"nuqs": "^2.8.9",
"radix-ui": "^1.4.3",
"react": "^19.2.4",
"react-day-picker": "^9.14.0",
"react-dom": "^19.2.4",
"react-hook-form": "^7.72.0",
"react-resizable-panels": "^4.7.5",
"recharts": "3.8.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",
"vaul": "^1.1.2",
"zod": "^4.3.6",
"zustand": "^5.0.12"
}, },
"devDependencies": { "devDependencies": {
"prettier": "^3.7.4", "@eslint/eslintrc": "^3",
"turbo": "^2.8.20", "@tailwindcss/postcss": "^4.2.1",
"typescript": "5.9.2" "@types/node": "^25.5.0",
}, "@types/react": "^19.2.14",
"packageManager": "pnpm@9.0.0", "@types/react-dom": "^19.2.3",
"engines": { "cypress": "^15.13.0",
"node": ">=18" "eslint": "^9.39.4",
"eslint-config-next": "16.1.7",
"postcss": "^8",
"prettier": "^3.8.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"shadcn": "^4.1.0",
"tailwindcss": "^4.2.1",
"typescript": "^5.9.3"
} }
} }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,39 +0,0 @@
{
"name": "@repo/api",
"version": "0.0.0",
"private": true,
"exports": {
".": "./src/index.ts",
"./infra": "./src/infra/index.ts",
"./clients": "./src/clients/index.ts",
"./server": "./src/server.ts",
"./postman/*": "./postman/*",
"./open-api/*": "./open-api/*",
"./types": "./types/index.ts",
"./types/*": "./types/*"
},
"scripts": {
"prepare:dirs": "node -e \"const fs=require('fs');fs.mkdirSync('open-api',{recursive:true});fs.mkdirSync('types',{recursive:true});\"",
"generate:openapi": "pnpm run prepare:dirs && node scripts/generate-openapi.cjs",
"generate:types": "node scripts/generate-types.cjs",
"generate": "pnpm run generate:openapi && pnpm run generate:types",
"dev": "pnpm run generate",
"build": "pnpm run generate",
"lint": "echo \"No lint configured for @repo/api\"",
"check-types": "echo \"No typecheck configured for @repo/api\""
},
"dependencies": {
"openapi-fetch": "^0.14.0"
},
"devDependencies": {
"openapi-typescript": "^7.10.1"
},
"peerDependencies": {
"next": ">=14",
"server-only": "*"
},
"peerDependenciesMeta": {
"next": { "optional": true },
"server-only": { "optional": true }
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,223 +0,0 @@
const fs = require("fs");
const path = require("path");
const collectionPath = process.argv[2] || "postman/collection.json";
const outputPath = "open-api/schema.json";
// ── Schema inference from JSON examples ─────────────────────────────
function inferSchema(value) {
if (value === null || value === undefined) {
return { type: "string", nullable: true };
}
if (typeof value === "boolean") {
return { type: "boolean" };
}
if (typeof value === "number") {
return Number.isInteger(value) ? { type: "integer" } : { type: "number" };
}
if (typeof value === "string") {
if (/^\d{4}-\d{2}-\d{2}T/.test(value)) {
return { type: "string", format: "date-time" };
}
return { type: "string" };
}
if (Array.isArray(value)) {
if (value.length === 0) {
return { type: "array", items: {} };
}
return { type: "array", items: inferSchema(value[0]) };
}
if (typeof value === "object") {
const properties = {};
for (const [key, val] of Object.entries(value)) {
properties[key] = inferSchema(val);
}
return { type: "object", properties };
}
return {};
}
// ── Path helpers ────────────────────────────────────────────────────
function extractPath(url) {
const parts = url.path || [];
const raw = "/" + parts.join("/");
return raw.replace(/\{\{(\w+)\}\}/g, "{$1}");
}
function extractPathParams(apiPath) {
const params = [];
const re = /\{(\w+)\}/g;
let m;
while ((m = re.exec(apiPath)) !== null) {
params.push({
name: m[1],
in: "path",
required: true,
schema: { type: "string" },
});
}
return params;
}
// ── Request body ────────────────────────────────────────────────────
function buildRequestBody(body) {
if (!body) return undefined;
if (body.mode === "raw" && body.raw) {
try {
const parsed = JSON.parse(body.raw);
return {
required: true,
content: {
"application/json": {
schema: inferSchema(parsed),
example: parsed,
},
},
};
} catch {
return {
content: {
"text/plain": { schema: { type: "string" } },
},
};
}
}
if (body.mode === "formdata" && body.formdata) {
const properties = {};
for (const field of body.formdata) {
properties[field.key] =
field.type === "file"
? { type: "string", format: "binary" }
: { type: "string" };
}
return {
content: {
"multipart/form-data": {
schema: { type: "object", properties },
},
},
};
}
return undefined;
}
// ── Response schemas ────────────────────────────────────────────────
function buildResponses(responses) {
const out = {};
if (!responses || responses.length === 0) {
out["200"] = { description: "OK" };
return out;
}
for (const resp of responses) {
const code = String(resp.code || 200);
const desc = resp.status || "OK";
const entry = { description: desc };
if (resp.body) {
try {
const parsed = JSON.parse(resp.body);
entry.content = {
"application/json": {
schema: inferSchema(parsed),
example: parsed,
},
};
} catch {
entry.content = {
"text/plain": { schema: { type: "string" } },
};
}
}
out[code] = entry;
}
return out;
}
// ── Tree walker ─────────────────────────────────────────────────────
function processItem(item, tag, paths) {
const req = item.request;
if (!req) return;
const method = req.method.toLowerCase();
const apiPath = extractPath(req.url);
const pathParams = extractPathParams(apiPath);
if (!paths[apiPath]) paths[apiPath] = {};
const operation = {
tags: [tag],
summary: item.name,
};
const reqBody = buildRequestBody(req.body);
if (reqBody) operation.requestBody = reqBody;
if (pathParams.length > 0) operation.parameters = pathParams;
operation.responses = buildResponses(item.response);
paths[apiPath][method] = operation;
}
function walkFolder(folder, paths, tag) {
const currentTag = folder.name || tag;
if (!folder.item) return;
for (const child of folder.item) {
if (child.item) {
walkFolder(child, paths, currentTag);
} else {
processItem(child, currentTag, paths);
}
}
}
// ── Main ────────────────────────────────────────────────────────────
function main() {
const collection = JSON.parse(fs.readFileSync(collectionPath, "utf-8"));
const tags = new Set();
const paths = {};
for (const folder of collection.item) {
tags.add(folder.name);
walkFolder(folder, paths, folder.name);
}
const spec = {
openapi: "3.0.0",
info: {
title: collection.info.name || "API",
description: collection.info.description || "",
version: "1.0.0",
},
servers: [{ url: "http://{{base_url}}" }],
components: {
securitySchemes: {
bearerAuth: { type: "http", scheme: "bearer" },
},
},
security: [{ bearerAuth: [] }],
tags: Array.from(tags).map((name) => ({ name })),
paths,
};
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
fs.writeFileSync(outputPath, JSON.stringify(spec, null, 2));
console.log(`OpenAPI schema written to ${outputPath}`);
}
main();

View File

@ -1,13 +0,0 @@
const { execSync } = require("child_process");
const schemaSource = process.argv[2] || "open-api/schema.json";
const outputPath = "types/index.ts";
try {
execSync(`npx openapi-typescript ${schemaSource} -o ${outputPath}`, {
stdio: "inherit",
});
} catch (error) {
console.error("Failed to generate TypeScript types from OpenAPI schema.");
process.exit(1);
}

View File

@ -1,65 +0,0 @@
import type { ApiClientOptions } from "./infra/client"
import { AuthClient } from "./clients/auth"
import { CustomersClient } from "./clients/customers"
import { ReferralSourcesClient } from "./clients/referral-sources"
import { VehiclesClient } from "./clients/vehicles"
import { VehicleAttributesClient } from "./clients/vehicle-attributes"
import { VehicleDocumentsClient } from "./clients/vehicle-documents"
import { DepartmentsClient } from "./clients/departments"
import { EmployeesClient } from "./clients/employees"
import { GeoClient } from "./clients/geo"
import { PaymentTermsClient } from "./clients/payment-terms"
import { ShopTypesClient } from "./clients/shop-types"
import { InventoryClient } from "./clients/inventory"
import { VendorsClient } from "./clients/vendors"
import { InspectionsClient } from "./clients/inspections"
import { LabelsClient } from "./clients/labels"
import { InsuranceTypesClient } from "./clients/insurance-types"
import { EstimatesClient } from "./clients/estimates"
import { JobCardsClient } from "./clients/job-cards"
import { PaymentsClient } from "./clients/payments"
import { PartsClient } from "./clients/parts"
import { PurchaseOrdersClient } from "./clients/purchase-orders"
import { ServicesClient } from "./clients/services"
import { ServiceGroupsClient } from "./clients/service-groups"
import { ExpensesClient } from "./clients/expenses"
import { TasksClient } from "./clients/tasks"
import { AppointmentsClient } from "./clients/appointments"
import { ShopTimingsClient } from "./clients/shop-timings"
import { ShopCalendarsClient } from "./clients/shop-calendars"
export function createApi(options?: ApiClientOptions) {
return {
auth: new AuthClient(undefined, options),
customers: new CustomersClient(undefined, options),
referralSources: new ReferralSourcesClient(undefined, options),
vehicles: new VehiclesClient(undefined, options),
vehicleAttributes: new VehicleAttributesClient(undefined, options),
vehicleDocuments: new VehicleDocumentsClient(undefined, options),
departments: new DepartmentsClient(undefined, options),
employees: new EmployeesClient(undefined, options),
geo: new GeoClient(undefined, options),
paymentTerms: new PaymentTermsClient(undefined, options),
shopTypes: new ShopTypesClient(undefined, options),
inventory: new InventoryClient(undefined, options),
vendors: new VendorsClient(undefined, options),
inspections: new InspectionsClient(undefined, options),
labels: new LabelsClient(undefined, options),
insuranceTypes: new InsuranceTypesClient(undefined, options),
estimates: new EstimatesClient(undefined, options),
jobCards: new JobCardsClient(undefined, options),
payments: new PaymentsClient(undefined, options),
parts: new PartsClient(undefined, options),
purchaseOrders: new PurchaseOrdersClient(undefined, options),
services: new ServicesClient(undefined, options),
serviceGroups: new ServiceGroupsClient(undefined, options),
expenses: new ExpensesClient(undefined, options),
tasks: new TasksClient(undefined, options),
appointments: new AppointmentsClient(undefined, options),
shopTimings: new ShopTimingsClient(undefined, options),
shopCalendars: new ShopCalendarsClient(undefined, options),
}
}
/** Unauthenticated singleton — use for public calls (login, register) */
export const api = createApi()

View File

@ -1,35 +0,0 @@
import { ApiClient, type ApiClientOptions } from "../infra/client"
import type { ApiPath, ApiRequestBody } from "../infra/types"
import type { ApiListQueryParams } from "../contracts/types"
export const APPOINTMENT_ROUTES = {
INDEX: "/api/appointments",
BY_ID: "/api/appointments/{id}",
UNLINK_JOB_CARD: "/api/appointments/{id}/un-link-job-card",
} as const satisfies Record<string, ApiPath>
export class AppointmentsClient extends ApiClient {
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
super(baseUrl, defaultOptions)
}
async list(query?: ApiListQueryParams) {
return this.get(APPOINTMENT_ROUTES.INDEX, query ? { query } as never : undefined)
}
async create(payload: ApiRequestBody<typeof APPOINTMENT_ROUTES.INDEX, "post">) {
return this.post(APPOINTMENT_ROUTES.INDEX, payload)
}
async update(id: string, payload: ApiRequestBody<typeof APPOINTMENT_ROUTES.BY_ID, "put">) {
return this.put(APPOINTMENT_ROUTES.BY_ID, payload, { params: { id } })
}
async destroy(id: string) {
return this.delete(APPOINTMENT_ROUTES.BY_ID, { params: { id } })
}
async unlinkJobCard(id: string, payload: ApiRequestBody<typeof APPOINTMENT_ROUTES.UNLINK_JOB_CARD, "post">) {
return this.post(APPOINTMENT_ROUTES.UNLINK_JOB_CARD, payload, { params: { id } })
}
}

View File

@ -1,26 +0,0 @@
import { ApiClient, type ApiClientOptions } from "../infra/client"
import type { ApiPath, ApiRequestBody } from "../infra/types"
export const AUTH_ROUTES = {
LOGIN: "/api/login",
PROFILE: "/api/profile",
LOGOUT: "/api/logout",
} as const satisfies Record<string, ApiPath>
export class AuthClient extends ApiClient {
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
super(baseUrl, defaultOptions)
}
async login(payload: ApiRequestBody<typeof AUTH_ROUTES.LOGIN, "post">) {
return this.post(AUTH_ROUTES.LOGIN, payload)
}
async profile() {
return this.get(AUTH_ROUTES.PROFILE)
}
async logout() {
return this.post(AUTH_ROUTES.LOGOUT, undefined)
}
}

View File

@ -1,32 +0,0 @@
import { CrudClient } from "../infra/crud-client"
import { ApiClient, type ApiClientOptions } from "../infra/client"
import type { ApiPath, ApiRequestBody } from "../infra/types"
import type { ApiListQueryParams } from "../contracts/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(query?: ApiListQueryParams) {
return this.get(CUSTOMER_ROUTES.CUSTOMER_TYPES, query ? { query } as never : undefined)
}
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

@ -1,40 +0,0 @@
import { ApiClient, type ApiClientOptions } from "../infra/client"
import type { ApiPath, ApiRequestBody } from "../infra/types"
import type { ApiListQueryParams } from "../contracts/types"
export const DEPARTMENT_ROUTES = {
INDEX: "/api/departments",
BY_ID: "/api/departments/{id}",
SET_FAVORITE: "/api/set-favorite-department",
REMOVE_FAVORITE: "/api/remove-favorite-department",
} as const satisfies Record<string, ApiPath>
export class DepartmentsClient extends ApiClient {
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
super(baseUrl, defaultOptions)
}
async list(query?: ApiListQueryParams) {
return this.get(DEPARTMENT_ROUTES.INDEX, query ? { query } as never : undefined)
}
async create(payload: ApiRequestBody<typeof DEPARTMENT_ROUTES.INDEX, "post">) {
return this.post(DEPARTMENT_ROUTES.INDEX, payload)
}
async update(id: string, payload: ApiRequestBody<typeof DEPARTMENT_ROUTES.BY_ID, "put">) {
return this.put(DEPARTMENT_ROUTES.BY_ID, payload, { params: { id } })
}
async destroy(id: string) {
return this.delete(DEPARTMENT_ROUTES.BY_ID, { params: { id } })
}
async setFavorite(payload: ApiRequestBody<typeof DEPARTMENT_ROUTES.SET_FAVORITE, "post">) {
return this.post(DEPARTMENT_ROUTES.SET_FAVORITE, payload)
}
async removeFavorite(payload: ApiRequestBody<typeof DEPARTMENT_ROUTES.REMOVE_FAVORITE, "post">) {
return this.post(DEPARTMENT_ROUTES.REMOVE_FAVORITE, payload)
}
}

View File

@ -1,17 +0,0 @@
import { CrudClient } from "../infra/crud-client"
import type { ApiClientOptions } from "../infra/client"
import type { ApiPath } from "../infra/types"
export const EMPLOYEE_ROUTES = {
INDEX: "/api/employees",
BY_ID: "/api/employees/{id}",
} as const satisfies Record<string, ApiPath>
export class EmployeesClient extends CrudClient<
typeof EMPLOYEE_ROUTES.INDEX,
typeof EMPLOYEE_ROUTES.BY_ID
> {
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
super(baseUrl, defaultOptions, EMPLOYEE_ROUTES.INDEX, EMPLOYEE_ROUTES.BY_ID)
}
}

View File

@ -1,69 +0,0 @@
import { ApiClient, type ApiClientOptions } from "../infra/client"
import type { ApiPath, ApiRequestBody } from "../infra/types"
import type { ApiListQueryParams } from "../contracts/types"
export const ESTIMATE_ROUTES = {
INDEX: "/api/estimates",
BY_ID: "/api/estimates/{id}",
QUICK_REMARKS: "/api/quick-remark",
QUICK_REMARK_BY_ID: "/api/quick-remark/{id}",
QUICK_NOTES: "/api/quick-notes",
QUICK_NOTE_BY_ID: "/api/quick-notes/{id}",
} as const satisfies Record<string, ApiPath>
export class EstimatesClient extends ApiClient {
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
super(baseUrl, defaultOptions)
}
// ── Estimates ──
async list(query?: ApiListQueryParams) {
return this.get(ESTIMATE_ROUTES.INDEX, query ? { query } as never : undefined)
}
async create(payload: ApiRequestBody<typeof ESTIMATE_ROUTES.INDEX, "post">) {
return this.post(ESTIMATE_ROUTES.INDEX, payload)
}
async update(id: string, payload: ApiRequestBody<typeof ESTIMATE_ROUTES.BY_ID, "put">) {
return this.put(ESTIMATE_ROUTES.BY_ID, payload, { params: { id } })
}
async destroy(id: string) {
return this.delete(ESTIMATE_ROUTES.BY_ID, { params: { id } })
}
// ── Quick Remarks ──
async listQuickRemarks(query?: ApiListQueryParams) {
return this.get(ESTIMATE_ROUTES.QUICK_REMARKS, query ? { query } as never : undefined)
}
async createQuickRemark(payload: ApiRequestBody<typeof ESTIMATE_ROUTES.QUICK_REMARKS, "post">) {
return this.post(ESTIMATE_ROUTES.QUICK_REMARKS, payload)
}
async updateQuickRemark(id: string, payload: ApiRequestBody<typeof ESTIMATE_ROUTES.QUICK_REMARK_BY_ID, "put">) {
return this.put(ESTIMATE_ROUTES.QUICK_REMARK_BY_ID, payload, { params: { id } })
}
async destroyQuickRemark(id: string) {
return this.delete(ESTIMATE_ROUTES.QUICK_REMARK_BY_ID, { params: { id } })
}
// ── Quick Notes ──
async listQuickNotes(query?: ApiListQueryParams) {
return this.get(ESTIMATE_ROUTES.QUICK_NOTES, query ? { query } as never : undefined)
}
async createQuickNote(payload: ApiRequestBody<typeof ESTIMATE_ROUTES.QUICK_NOTES, "post">) {
return this.post(ESTIMATE_ROUTES.QUICK_NOTES, payload)
}
async updateQuickNote(id: string, payload: ApiRequestBody<typeof ESTIMATE_ROUTES.QUICK_NOTE_BY_ID, "put">) {
return this.put(ESTIMATE_ROUTES.QUICK_NOTE_BY_ID, payload, { params: { id } })
}
async destroyQuickNote(id: string) {
return this.delete(ESTIMATE_ROUTES.QUICK_NOTE_BY_ID, { params: { id } })
}
}

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