init
This commit is contained in:
@@ -0,0 +1,278 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,225 @@
|
||||
# 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:** M–L 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:** XS–S.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
@@ -0,0 +1,308 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,128 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,179 @@
|
||||
# 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
|
||||
```
|
||||
@@ -0,0 +1,261 @@
|
||||
# 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 0–2 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 0–3 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
|
||||
```
|
||||
Reference in New Issue
Block a user