This commit is contained in:
2026-03-27 16:03:58 +03:00
commit 174cdd6323
83 changed files with 96235 additions and 0 deletions
+278
View File
@@ -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.
+225
View File
@@ -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
+308
View File
@@ -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.
+128
View File
@@ -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.
+179
View File
@@ -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
```
+261
View File
@@ -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
```