This commit is contained in:
2026-03-27 16:03:58 +03:00
commit 174cdd6323
83 changed files with 96235 additions and 0 deletions
+182
View File
@@ -0,0 +1,182 @@
---
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
@@ -0,0 +1,140 @@
# API Client Reference
## File Location
`packages/api/src/clients/<kebab-resource>.ts`
## Standard CrudClient Pattern (Preferred)
Use this when the resource has standard CRUD endpoints that exist in the OpenAPI schema.
```ts
import { CrudClient } from "../infra/crud-client"
import type { ApiClientOptions } from "../infra/client"
import type { ApiPath, ApiRequestBody } from "../infra/types"
export const <RESOURCE>_ROUTES = {
INDEX: "/api/<plural-resource>",
BY_ID: "/api/<plural-resource>/{id}",
// Add extra routes as needed:
// EXPORT: "/api/<plural-resource>/export",
// IMPORT: "/api/<plural-resource>/import",
// RELATED: "/api/<related-resource>",
} as const satisfies Record<string, ApiPath>
export class <Resource>Client extends CrudClient<
typeof <RESOURCE>_ROUTES.INDEX,
typeof <RESOURCE>_ROUTES.BY_ID
> {
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
super(baseUrl, defaultOptions, <RESOURCE>_ROUTES.INDEX, <RESOURCE>_ROUTES.BY_ID)
}
// Add domain-specific methods:
// async listCategories() {
// return this.get(<RESOURCE>_ROUTES.RELATED)
// }
//
// async export() {
// return this.get(<RESOURCE>_ROUTES.EXPORT)
// }
}
```
### CrudClient Gives You For Free
| Method | HTTP | Description |
|---|---|---|
| `list(query?)` | `GET /api/<resource>` | Paginated list with query params |
| `show(id)` | `GET /api/<resource>/{id}` | Single item fetch |
| `create(payload)` | `POST /api/<resource>` | Create new item |
| `update(id, payload)` | `PUT /api/<resource>/{id}` | Update existing item |
| `destroy(id)` | `DELETE /api/<resource>/{id}` | Delete item |
## Minimal CrudClient (No Custom Methods)
For simple resources with only standard CRUD:
```ts
import { CrudClient } from "../infra/crud-client"
import type { ApiClientOptions } from "../infra/client"
import type { ApiPath } from "../infra/types"
export const <RESOURCE>_ROUTES = {
INDEX: "/api/<plural-resource>",
BY_ID: "/api/<plural-resource>/{id}",
} as const satisfies Record<string, ApiPath>
export class <Resource>Client extends CrudClient<
typeof <RESOURCE>_ROUTES.INDEX,
typeof <RESOURCE>_ROUTES.BY_ID
> {
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
super(baseUrl, defaultOptions, <RESOURCE>_ROUTES.INDEX, <RESOURCE>_ROUTES.BY_ID)
}
}
```
## Registration
After creating the client, register it in two files:
### 1. `packages/api/src/clients/index.ts`
```ts
export { <Resource>Client, <RESOURCE>_ROUTES } from "./<kebab-resource>"
```
### 2. `packages/api/src/api.ts`
Add the import at the top:
```ts
import { <Resource>Client } from "./clients/<kebab-resource>"
```
Add to the `createApi()` return object:
```ts
export function createApi(options?: ApiClientOptions) {
return {
// ...existing clients...
<camelResource>: new <Resource>Client(undefined, options),
}
}
```
## Real Example: CustomersClient
```ts
import { CrudClient } from "../infra/crud-client"
import { ApiClient, type ApiClientOptions } from "../infra/client"
import type { ApiPath, ApiRequestBody } from "../infra/types"
export const CUSTOMER_ROUTES = {
INDEX: "/api/customers",
BY_ID: "/api/customers/{id}",
EXPORT: "/api/customers/export",
IMPORT: "/api/customers/import",
CUSTOMER_TYPES: "/api/customer-types",
} as const satisfies Record<string, ApiPath>
export class CustomersClient extends CrudClient<
typeof CUSTOMER_ROUTES.INDEX,
typeof CUSTOMER_ROUTES.BY_ID
> {
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
super(baseUrl, defaultOptions, CUSTOMER_ROUTES.INDEX, CUSTOMER_ROUTES.BY_ID)
}
async listCustomerTypes() {
return this.get(CUSTOMER_ROUTES.CUSTOMER_TYPES)
}
async export() {
return this.get(CUSTOMER_ROUTES.EXPORT)
}
async import(payload: ApiRequestBody<typeof CUSTOMER_ROUTES.IMPORT, "post">) {
return this.post(CUSTOMER_ROUTES.IMPORT, payload)
}
}
```
+234
View File
@@ -0,0 +1,234 @@
# 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
+225
View File
@@ -0,0 +1,225 @@
# Page Reference
## File Location
`apps/dashboard/app/(authenticated)/<section>/<feature>/page.tsx`
Where `<section>` is the navigation section (e.g. `sales`, `inventory`, `hr`) and `<feature>` is the resource in kebab-case plural.
## Complete Template (ResourcePage Pattern)
```tsx
"use client"
import { ResourcePage } from '@/shared/data-view/resource-page'
import { ColumnHeader } from '@/shared/data-view/table-view'
import { <Feature>Form } from '@/modules/<feature>/<feature>-form'
import { <RESOURCE>_ROUTES } from '@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>
)
}
```
@@ -0,0 +1,143 @@
# Schema Reference
## File Location
`apps/dashboard/modules/<feature>/<feature>.schema.ts`
## Template
```ts
import { z } from "zod"
// Reusable relation field schema — use for all foreign-key / lookup fields
const relationFieldSchema = z
.object({ value: z.string(), label: z.string() })
.nullable()
const <feature>FormSchema = z.object({
// ── Relations (stored as { value, label } objects, mapped to IDs on submit) ──
// category: relationFieldSchema,
// ── Required strings ──
// name: z.string().min(1, "Name is required"),
// ── Optional strings ──
// description: z.string().optional(),
// ── Optional email (allows empty string) ──
// email: z.union([
// z.string().email("Enter a valid email address"),
// z.literal(""),
// ]).optional(),
// ── Optional phone ──
// phone: z.string().optional(),
// ── Boolean ──
// is_active: z.boolean().default(true),
// ── Number ──
// quantity: z.coerce.number().min(0),
// ── Date ──
// due_date: z.string().optional(),
})
type <Feature>FormValues = z.infer<typeof <feature>FormSchema>
export { <feature>FormSchema, relationFieldSchema }
export type { <Feature>FormValues }
```
## Field Type Patterns
### Required string
```ts
name: z.string().min(1, "Name is required"),
```
### Optional string
```ts
notes: z.string().optional(),
```
### Optional email (allows empty)
```ts
email: z.union([
z.string().email("Enter a valid email address"),
z.literal(""),
]).optional(),
```
### Relation / Foreign key
```ts
const relationFieldSchema = z
.object({ value: z.string(), label: z.string() })
.nullable()
// In schema:
department: relationFieldSchema,
```
### Required relation
```ts
department: z
.object({ value: z.string(), label: z.string() })
.refine((v) => v !== null, { message: "Department is required" }),
```
### Boolean with default
```ts
is_active: z.boolean().default(true),
```
### Number (from string input)
```ts
quantity: z.coerce.number().min(0, "Must be non-negative"),
price: z.coerce.number().min(0),
```
### Static enum select
```ts
status: z.enum(["active", "inactive", "pending"]).default("active"),
salutation: z.string().optional(),
```
## Real Example: CustomerFormSchema
```ts
import { z } from "zod"
const relationFieldSchema = z
.object({ value: z.string(), label: z.string() })
.nullable()
type RelationField = z.infer<typeof relationFieldSchema>
const customerFormSchema = z.object({
customer_type: relationFieldSchema,
referral_source: relationFieldSchema,
payment_terms: relationFieldSchema,
country: relationFieldSchema,
state: relationFieldSchema,
salutation: z.string().optional(),
first_name: z.string().min(1, "First name is required"),
last_name: z.string().min(1, "Last name is required"),
company_name: z.string().optional(),
email: z.union([
z.string().email("Enter a valid email address"),
z.literal(""),
]).optional(),
phone: z.string().optional(),
alternate_phone: z.string().optional(),
address_line_1: z.string().optional(),
address_line_2: z.string().optional(),
city: z.string().optional(),
zip_code: z.string().optional(),
})
type CustomerFormValues = z.infer<typeof customerFormSchema>
export { customerFormSchema, relationFieldSchema }
export type { CustomerFormValues, RelationField }
```