init
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
```
|
||||
Reference in New Issue
Block a user