This commit is contained in:
Mohammad Khyata 2026-03-27 16:03:58 +03:00
commit 174cdd6323
83 changed files with 96235 additions and 0 deletions

182
.github/skills/crud-page/SKILL.md vendored Normal file
View File

@ -0,0 +1,182 @@
---
name: crud-page
description: "Create CRUD resource pages, forms, schemas, and API clients for the carage-erp dashboard. Use when: adding a new resource page, creating a CRUD feature, building a list/create/edit/delete page, scaffolding a new module, adding a new entity to the dashboard. Covers API client, Zod schema, form component, and page component creation."
---
# CRUD Page Generator
Create fully functional CRUD resource pages following the established codebase patterns. This skill covers the full stack: API client → Zod schema → form component → page component.
## When to Use
- User asks to create a new resource/entity page (e.g. "create a vendors page", "add invoices CRUD")
- User asks to add list/create/edit/delete functionality for a domain entity
- User asks to scaffold a new module or feature page
- User wants to extend the dashboard with a new data management page
## Decision: ResourcePage vs Manual DataTable
**Use `ResourcePage` (preferred)** when the resource needs full CRUD (list + create + edit + delete in a dialog). This is the standard pattern.
**Use manual `DataTable` + `useDataTableQuery`** only when the page is read-only or has highly custom layout needs.
Always prefer `ResourcePage` unless the user explicitly needs something different.
## Procedure
Follow these steps **in order**. Each step produces one file. Check the [reference files](./references/) for complete templates and patterns.
### Step 1: Check if API Client Exists
Look in `packages/api/src/clients/` for an existing client. Also check `packages/api/src/clients/index.ts` for all registered clients, and `packages/api/src/api.ts` for the factory.
- If client exists → skip to Step 3
- If client doesn't exist → continue to Step 2
### Step 2: Create API Client
Read the [API Client Reference](./references/api-client.md) for patterns and template.
Create the domain client file at `packages/api/src/clients/<resource>.ts`:
1. Define `RESOURCE_ROUTES` const with `INDEX` and `BY_ID` routes (and any extras)
2. Create a class extending `CrudClient` with the route types
3. Add any domain-specific methods beyond standard CRUD
4. Register in `packages/api/src/clients/index.ts` (export class + routes)
5. Register in `packages/api/src/api.ts` (import + add to `createApi()`)
**Route pattern**: `"/api/<plural-resource>"` for INDEX, `"/api/<plural-resource>/{id}"` for BY_ID.
**IMPORTANT**: Routes must exist in the OpenAPI schema (`packages/api/types/index.ts`) for type safety. If the route doesn't exist in the schema yet, inform the user and ask if they want to proceed with `any` types or wait for schema update.
### Step 3: Create Zod Schema
Read the [Schema Reference](./references/schema.md) for patterns and template.
Create `apps/dashboard/modules/<feature>/<feature>.schema.ts`:
1. Define `relationFieldSchema` (reuse if already exported) for foreign-key fields
2. Build the Zod object schema with all form fields
3. Use `.optional()` for non-required fields, `.min(1, "...")` for required strings
4. Use `z.union([z.string().email(...), z.literal("")]).optional()` for optional emails
5. Export the schema, the inferred type, and `relationFieldSchema` if new
### Step 4: Create Form Component
Read the [Form Reference](./references/form.md) for the complete template.
Create `apps/dashboard/modules/<feature>/<feature>-form.tsx`:
1. Define default values matching the schema
2. Create `mapToFormValues(data)` — transforms API shape → form shape using `toRelation()`
3. Create `mapFormToPayload(values)` — transforms form shape → API shape using `toId()`
4. Use `useResourceForm()` for form initialization + edit pre-filling
5. Use `useFormMutation()` for submit with automatic validation error mapping
6. Render with `Rhform` + `RhfTextField` / `RhfSelectField` / `RhfAsyncSelectField` etc.
7. Include error alert, submit button with loading/edit states
### Step 5: Create Page Component
Read the [Page Reference](./references/page.md) for the complete template.
Create `apps/dashboard/app/(authenticated)/<section>/<feature>/page.tsx`:
1. Add `"use client"` directive
2. Import `ResourcePage`, `ColumnHeader`, the form, client type, and routes
3. Configure: `pageTitle`, `title`, `routeKey`, `getClient`, `columns`, `renderForm`
4. Use `columns` callback to receive `actionsColumn` helper
5. Add sortable column headers with `<ColumnHeader>`
6. Include `actionsColumn()` as last column
### Step 6: Verify
- Ensure all imports resolve
- Check that route constants match OpenAPI paths
- Confirm the client is registered in both `clients/index.ts` and `api.ts`
## Key Conventions
### Naming
| Item | Pattern | Example |
|---|---|---|
| Client file | `packages/api/src/clients/<kebab-resource>.ts` | `job-cards.ts` |
| Client class | `<PascalResource>Client` | `JobCardsClient` |
| Routes const | `<UPPER_SNAKE>_ROUTES` | `JOB_CARD_ROUTES` |
| Schema file | `modules/<feature>/<feature>.schema.ts` | `job-card.schema.ts` |
| Form file | `modules/<feature>/<feature>-form.tsx` | `job-card-form.tsx` |
| Page file | `app/(authenticated)/<section>/<feature>/page.tsx` | `sales/job-cards/page.tsx` |
| Zod schema | `<camelFeature>FormSchema` | `jobCardFormSchema` |
| Form values type | `<PascalFeature>FormValues` | `JobCardFormValues` |
| Form component | `<PascalFeature>Form` | `JobCardForm` |
| Page component | `<PascalFeature>Page` (default export) | `JobCardsPage` |
### Relation Fields (Foreign Keys)
- Stored in form as `{ value: string, label: string } | null`
- Use `toRelation(id, name)` to convert API data → form value
- Use `toId(relation)` to convert form value → API payload
- Schema uses `relationFieldSchema = z.object({ value: z.string(), label: z.string() }).nullable()`
- Rendered with `<RhfAsyncSelectField>` (fetches options via React Query)
### Async Select Pattern
```tsx
const mapLookupOption = (item: any) => ({ value: String(item.id), label: item.name })
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
<RhfAsyncSelectField
name="field_name"
label="Field Label"
placeholder="Select..."
queryKey={["query-key"]}
listFn={() => api.resource.listSomething()}
mapOption={mapLookupOption}
{...STORE_OBJECT}
/>
```
### Available Form Field Components
| Component | Use For |
|---|---|
| `RhfTextField` | Text, email, phone, URL inputs |
| `RhfTextareaField` | Multi-line text |
| `RhfCheckboxField` | Boolean toggles |
| `RhfSelectField` | Static option dropdowns |
| `RhfAsyncSelectField` | Server-fetched single-select combobox |
| `RhfAsyncMultiSelectField` | Server-fetched multi-select combobox |
### Imports Cheat Sheet
```tsx
// Page
import { ResourcePage } from '@/shared/data-view/resource-page'
import { ColumnHeader } from '@/shared/data-view/table-view'
import type { <Resource>Client } from '@repo/api'
import { <RESOURCE>_ROUTES } from '@repo/api'
// Form
import { Rhform, RhfTextField, RhfSelectField, RhfAsyncSelectField } from "@/shared/components/form"
import { useResourceForm } from "@/shared/hooks/use-resource-form"
import { useFormMutation } from "@/shared/hooks/use-form-mutation"
import { useAuthApi } from "@/shared/useApi"
import { toRelation, toId } from "@/shared/lib/utils"
import { Button } from "@/shared/components/ui/button"
import { Alert, AlertTitle } from "@/shared/components/ui/alert"
import { FieldGroup } from "@/shared/components/ui/field"
import { toast } from "sonner"
// Schema
import { z } from "zod"
```
## Extending the CRUD Codebase
If a feature requires functionality not covered by existing utilities (e.g. inline editing, tab-based forms, file uploads, nested resources), you are encouraged to extend the shared infrastructure:
- Add new form field components in `shared/components/form/controls/` and `shared/components/form/fields/`
- Add new hooks in `shared/hooks/`
- Extend `ResourcePage` props if needed
- Add new column helper factories in `shared/data-view/table-view/`
- Keep extensions generic and reusable — follow the same patterns as existing code

View File

@ -0,0 +1,140 @@
# API Client Reference
## File Location
`packages/api/src/clients/<kebab-resource>.ts`
## Standard CrudClient Pattern (Preferred)
Use this when the resource has standard CRUD endpoints that exist in the OpenAPI schema.
```ts
import { CrudClient } from "../infra/crud-client"
import type { ApiClientOptions } from "../infra/client"
import type { ApiPath, ApiRequestBody } from "../infra/types"
export const <RESOURCE>_ROUTES = {
INDEX: "/api/<plural-resource>",
BY_ID: "/api/<plural-resource>/{id}",
// Add extra routes as needed:
// EXPORT: "/api/<plural-resource>/export",
// IMPORT: "/api/<plural-resource>/import",
// RELATED: "/api/<related-resource>",
} as const satisfies Record<string, ApiPath>
export class <Resource>Client extends CrudClient<
typeof <RESOURCE>_ROUTES.INDEX,
typeof <RESOURCE>_ROUTES.BY_ID
> {
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
super(baseUrl, defaultOptions, <RESOURCE>_ROUTES.INDEX, <RESOURCE>_ROUTES.BY_ID)
}
// Add domain-specific methods:
// async listCategories() {
// return this.get(<RESOURCE>_ROUTES.RELATED)
// }
//
// async export() {
// return this.get(<RESOURCE>_ROUTES.EXPORT)
// }
}
```
### CrudClient Gives You For Free
| Method | HTTP | Description |
|---|---|---|
| `list(query?)` | `GET /api/<resource>` | Paginated list with query params |
| `show(id)` | `GET /api/<resource>/{id}` | Single item fetch |
| `create(payload)` | `POST /api/<resource>` | Create new item |
| `update(id, payload)` | `PUT /api/<resource>/{id}` | Update existing item |
| `destroy(id)` | `DELETE /api/<resource>/{id}` | Delete item |
## Minimal CrudClient (No Custom Methods)
For simple resources with only standard CRUD:
```ts
import { CrudClient } from "../infra/crud-client"
import type { ApiClientOptions } from "../infra/client"
import type { ApiPath } from "../infra/types"
export const <RESOURCE>_ROUTES = {
INDEX: "/api/<plural-resource>",
BY_ID: "/api/<plural-resource>/{id}",
} as const satisfies Record<string, ApiPath>
export class <Resource>Client extends CrudClient<
typeof <RESOURCE>_ROUTES.INDEX,
typeof <RESOURCE>_ROUTES.BY_ID
> {
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
super(baseUrl, defaultOptions, <RESOURCE>_ROUTES.INDEX, <RESOURCE>_ROUTES.BY_ID)
}
}
```
## Registration
After creating the client, register it in two files:
### 1. `packages/api/src/clients/index.ts`
```ts
export { <Resource>Client, <RESOURCE>_ROUTES } from "./<kebab-resource>"
```
### 2. `packages/api/src/api.ts`
Add the import at the top:
```ts
import { <Resource>Client } from "./clients/<kebab-resource>"
```
Add to the `createApi()` return object:
```ts
export function createApi(options?: ApiClientOptions) {
return {
// ...existing clients...
<camelResource>: new <Resource>Client(undefined, options),
}
}
```
## Real Example: CustomersClient
```ts
import { CrudClient } from "../infra/crud-client"
import { ApiClient, type ApiClientOptions } from "../infra/client"
import type { ApiPath, ApiRequestBody } from "../infra/types"
export const CUSTOMER_ROUTES = {
INDEX: "/api/customers",
BY_ID: "/api/customers/{id}",
EXPORT: "/api/customers/export",
IMPORT: "/api/customers/import",
CUSTOMER_TYPES: "/api/customer-types",
} as const satisfies Record<string, ApiPath>
export class CustomersClient extends CrudClient<
typeof CUSTOMER_ROUTES.INDEX,
typeof CUSTOMER_ROUTES.BY_ID
> {
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
super(baseUrl, defaultOptions, CUSTOMER_ROUTES.INDEX, CUSTOMER_ROUTES.BY_ID)
}
async listCustomerTypes() {
return this.get(CUSTOMER_ROUTES.CUSTOMER_TYPES)
}
async export() {
return this.get(CUSTOMER_ROUTES.EXPORT)
}
async import(payload: ApiRequestBody<typeof CUSTOMER_ROUTES.IMPORT, "post">) {
return this.post(CUSTOMER_ROUTES.IMPORT, payload)
}
}
```

View File

@ -0,0 +1,234 @@
# Form Reference
## File Location
`apps/dashboard/modules/<feature>/<feature>-form.tsx`
## Complete Template
```tsx
"use client"
import { AlertTriangle, Plus, Save } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { Alert, AlertTitle } from "@/shared/components/ui/alert"
import { FieldGroup } from "@/shared/components/ui/field"
import {
Rhform,
RhfTextField,
RhfSelectField,
RhfAsyncSelectField,
// RhfTextareaField,
// RhfCheckboxField,
// RhfAsyncMultiSelectField,
} from "@/shared/components/form"
import { toast } from "sonner"
import { useAuthApi } from "@/shared/useApi"
import { useResourceForm } from "@/shared/hooks/use-resource-form"
import { useFormMutation } from "@/shared/hooks/use-form-mutation"
import { toRelation, toId } from "@/shared/lib/utils"
import {
<feature>FormSchema,
type <Feature>FormValues,
} from "./<feature>.schema"
import { <RESOURCE>_ROUTES } from "@repo/api"
// ── Constants ──
// Static select options (if needed):
// const STATUS_OPTIONS = [
// { value: "active", label: "Active" },
// { value: "inactive", label: "Inactive" },
// ]
// ── Props ──
export type <Feature>FormProps = {
resourceId?: string | null
initialData?: unknown
onSuccess?: () => void
}
// ── Default values ──
const DEFAULT_VALUES: <Feature>FormValues = {
// Match every field in the Zod schema:
// name: "",
// email: "",
// category: null, // relation fields default to null
// is_active: true, // booleans
}
// ── Mapping helpers ──
function mapToFormValues(data: unknown): <Feature>FormValues {
const d = (data as any)?.data ?? data ?? {}
return {
// String fields:
// name: d.name || "",
// email: d.email || "",
// Relation fields (API returns id + name separately):
// category: toRelation(d.category_id, d.category_name),
// Booleans:
// is_active: d.is_active ?? true,
}
}
function mapFormToPayload(values: <Feature>FormValues) {
return {
// String fields — use `|| undefined` to send null for empty strings:
// name: values.name,
// email: values.email || undefined,
// Relation fields — extract the numeric ID:
// category_id: toId(values.category),
// Booleans:
// is_active: values.is_active,
}
}
// ── Shared mapOption for async selects ──
const mapLookupOption = (item: any) => ({
value: String(item.id),
label: item.name,
})
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
// ── Component ──
export function <Feature>Form({ resourceId, initialData, onSuccess }: <Feature>FormProps) {
const api = useAuthApi()
const { form, isEditing } = useResourceForm<<Feature>FormValues, any>({
schema: <feature>FormSchema,
defaultValues: DEFAULT_VALUES,
resourceId,
initialData,
initialize: (id) => api.<camelResource>.show(id),
queryKey: [<RESOURCE>_ROUTES.BY_ID, resourceId],
mapToFormValues: mapToFormValues,
})
const { mutate, error, isPending } = useFormMutation(form, {
mutationFn: (values: <Feature>FormValues) => {
const payload = mapFormToPayload(values)
const promise = isEditing && resourceId
? api.<camelResource>.update(resourceId, payload)
: api.<camelResource>.create(payload)
toast.promise(promise, {
loading: isEditing ? "Updating <resource>..." : "Creating <resource>...",
success: isEditing ? "<Resource> updated successfully" : "<Resource> created successfully",
error: isEditing ? "Failed to update <resource>" : "Failed to create <resource>",
})
return promise
},
onSuccess: () => {
form.reset()
onSuccess?.()
},
})
return (
<Rhform form={form} onSubmit={(values) => mutate(values)}>
{error && (
<Alert variant="destructive" className="mb-4">
<AlertTriangle className="me-2 h-4 w-4" />
<AlertTitle>
{isEditing ? "Failed to update <resource>" : "Failed to create <resource>"}
</AlertTitle>
{error.message}
</Alert>
)}
<FieldGroup>
{/* Text fields */}
{/* <RhfTextField name="name" label="Name" placeholder="Enter name" required /> */}
{/* Grid layout for side-by-side fields */}
{/* <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField name="email" label="Email" type="email" />
<RhfTextField name="phone" label="Phone" type="tel" />
</div> */}
{/* Static select */}
{/* <RhfSelectField
name="status"
label="Status"
placeholder="Select status"
options={STATUS_OPTIONS}
/> */}
{/* Async select (fetches options from API) */}
{/* <RhfAsyncSelectField
name="category"
label="Category"
placeholder="Select category"
queryKey={["categories"]}
listFn={() => api.categories.list()}
mapOption={mapLookupOption}
{...STORE_OBJECT}
/> */}
{/* Textarea */}
{/* <RhfTextareaField name="notes" label="Notes" rows={4} /> */}
{/* Checkbox */}
{/* <RhfCheckboxField name="is_active" label="Active" /> */}
<Button type="submit" variant="default" disabled={isPending}>
{isEditing ? <Save /> : <Plus />}
{isPending
? (isEditing ? "Updating..." : "Creating...")
: (isEditing ? "Update <Resource>" : "Create <Resource>")}
</Button>
</FieldGroup>
</Rhform>
)
}
```
## Key Patterns
### mapToFormValues
Transforms API response → form values. Always handle:
- Null safety: `d.field || ""`
- Relation fields: `toRelation(d.relation_id, d.relation_name)`
- Nested data: `(data as any)?.data ?? data ?? {}`
- Booleans: `d.field ?? defaultValue`
### mapFormToPayload
Transforms form values → API request body. Always handle:
- Empty strings to undefined: `values.field || undefined`
- Relation to ID: `toId(values.relation)`
- Keep required fields as-is: `values.name`
### useResourceForm
Initializes react-hook-form with Zod validation. Handles both create and edit modes:
- `resourceId` null → create mode (uses `defaultValues`)
- `resourceId` set → edit mode (fetches via `initialize`, maps with `mapToFormValues`)
### useFormMutation
Wraps `useMutation` with automatic Laravel validation error mapping to form fields. No need to manually handle `ApiError.validationErrors`.
### Toast Pattern
Always use `toast.promise()` wrapping the API call for consistent loading/success/error feedback.
## Layout Conventions
- Use `<FieldGroup>` to wrap all fields
- Use `<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">` for side-by-side fields
- Place the submit button at the bottom inside `<FieldGroup>`
- Show error alert above fields when mutation fails

View File

@ -0,0 +1,225 @@
# Page Reference
## File Location
`apps/dashboard/app/(authenticated)/<section>/<feature>/page.tsx`
Where `<section>` is the navigation section (e.g. `sales`, `inventory`, `hr`) and `<feature>` is the resource in kebab-case plural.
## Complete Template (ResourcePage Pattern)
```tsx
"use client"
import { ResourcePage } from '@/shared/data-view/resource-page'
import { ColumnHeader } from '@/shared/data-view/table-view'
import { <Feature>Form } from '@/modules/<feature>/<feature>-form'
import { <RESOURCE>_ROUTES } from '@repo/api'
import type { <Resource>Client } from '@repo/api'
export default function <Features>Page() {
return (
<ResourcePage<<Resource>Client>
pageTitle="<Features>"
title="<Feature>"
routeKey={<RESOURCE>_ROUTES.INDEX}
getClient={(api) => api.<camelResource>}
columns={({ actionsColumn }) => [
{
accessorKey: "<primary_field>",
header: ({ column }) => <ColumnHeader column={column} title="<Primary Field>" />,
},
{
accessorKey: "<field_2>",
header: ({ column }) => <ColumnHeader column={column} title="<Field 2>" />,
},
// Add more columns as needed...
actionsColumn(),
]}
renderForm={({ resourceId, initialData, onSuccess }) => (
<<Feature>Form
resourceId={resourceId}
initialData={initialData}
onSuccess={onSuccess}
/>
)}
/>
)
}
```
## ResourcePage Props
| Prop | Required | Description |
|---|---|---|
| `pageTitle` | No | Page heading text (e.g. "Customers") |
| `title` | Yes | Singular noun for button/dialog (e.g. "Customer" → "Add Customer") |
| `routeKey` | Yes | React Query cache key, use `ROUTES.INDEX` |
| `getClient` | Yes | Selects the domain client from the authenticated API |
| `columns` | Yes | Column definitions — use callback form to get `actionsColumn` helper |
| `renderForm` | Yes | Renders the form component inside the dialog |
| `queryOptions` | No | React Query overrides (`staleTime`, etc.) |
## Column Patterns
### Simple text column (sortable)
```tsx
{
accessorKey: "name",
header: ({ column }) => <ColumnHeader column={column} title="Name" />,
},
```
### Custom cell renderer
```tsx
{
accessorKey: "status",
header: ({ column }) => <ColumnHeader column={column} title="Status" />,
cell: ({ row }) => (
<span className={row.original.status === "active" ? "text-green-600" : "text-red-600"}>
{row.original.status}
</span>
),
},
```
### Column with icon
```tsx
{
accessorKey: "name",
header: ({ column }) => <ColumnHeader column={column} title="Name" />,
cell: ({ row }) => (
<div className="flex items-center gap-2">
<UserIcon className="text-muted-foreground" />
<span>{row.original.name}</span>
</div>
),
},
```
### Non-sortable column
```tsx
{
accessorKey: "notes",
header: () => <span>Notes</span>,
enableSorting: false,
},
```
### Actions column (always last)
```tsx
actionsColumn(),
```
## Real Example: Customers Page
```tsx
"use client"
import { ResourcePage } from '@/shared/data-view/resource-page'
import { ColumnHeader } from '@/shared/data-view/table-view'
import { CustomerForm } from '@/modules/customers/customer-form'
import { CUSTOMER_ROUTES } from '@repo/api'
import type { CustomersClient } from '@repo/api'
import { Building2Icon, UserIcon } from 'lucide-react'
export default function CustomersPage() {
return (
<ResourcePage<CustomersClient>
pageTitle='Customers'
title="Customer"
routeKey={CUSTOMER_ROUTES.INDEX}
getClient={(api) => api.customers}
columns={({ actionsColumn }) => [
{
accessorKey: "first_name",
header: ({ column }) => <ColumnHeader column={column} title="Customer" />,
cell: ({ row }) => {
const customerName = row.original.first_name
const isCompany = row.original.customer_type?.name?.toLocaleLowerCase() === "company";
const companyName = row.original.company_name
const name = isCompany && companyName
? `${customerName} (${row.original.last_name})`
: customerName
return (
<div className="flex items-center gap-2">
{isCompany
? <Building2Icon className="text-muted-foreground" />
: <UserIcon className="text-muted-foreground" />}
<span>{name}</span>
</div>
)
},
},
{
accessorKey: "email",
header: ({ column }) => <ColumnHeader column={column} title="Email" />,
},
{
accessorKey: "phone",
header: ({ column }) => <ColumnHeader column={column} title="Phone" />,
},
actionsColumn(),
]}
renderForm={({ resourceId, initialData, onSuccess }) => (
<CustomerForm
resourceId={resourceId}
initialData={initialData}
onSuccess={onSuccess}
/>
)}
/>
)
}
```
## Alternative: Manual DataTable Pattern (Read-Only or Custom Layout)
Use only when you don't need create/edit/delete in a dialog:
```tsx
"use client"
import { DashboardHeader } from '@/base/components/layout/dashboard'
import DashboardPage from '@/base/components/layout/dashboard/dashboard-page'
import { ColumnHeader, DataTable, useDataTableQuery } from '@/shared/data-view/table-view'
import { useAuthApi } from '@/shared/useApi'
import { <RESOURCE>_ROUTES } from '@repo/api'
import type { ColumnDef } from '@tanstack/react-table'
const columns: ColumnDef<any>[] = [
{
accessorKey: "name",
header: ({ column }) => <ColumnHeader column={column} title="Name" />,
},
// ... more columns
]
export default function <Features>Page() {
const api = useAuthApi()
const { data, isLoading, pagination, sorting, handleChange } = useDataTableQuery({
queryKey: [<RESOURCE>_ROUTES.INDEX],
client: api.<camelResource>,
})
const response = data as any
return (
<DashboardPage header={<DashboardHeader />}>
<DataTable
columns={columns}
data={response?.data ?? []}
pagination={{
...pagination,
pageCount: response?.meta?.last_page ?? 1,
total: response?.meta?.total ?? 0,
}}
sorting={sorting}
onChange={handleChange}
isLoading={isLoading}
/>
</DashboardPage>
)
}
```

View File

@ -0,0 +1,143 @@
# Schema Reference
## File Location
`apps/dashboard/modules/<feature>/<feature>.schema.ts`
## Template
```ts
import { z } from "zod"
// Reusable relation field schema — use for all foreign-key / lookup fields
const relationFieldSchema = z
.object({ value: z.string(), label: z.string() })
.nullable()
const <feature>FormSchema = z.object({
// ── Relations (stored as { value, label } objects, mapped to IDs on submit) ──
// category: relationFieldSchema,
// ── Required strings ──
// name: z.string().min(1, "Name is required"),
// ── Optional strings ──
// description: z.string().optional(),
// ── Optional email (allows empty string) ──
// email: z.union([
// z.string().email("Enter a valid email address"),
// z.literal(""),
// ]).optional(),
// ── Optional phone ──
// phone: z.string().optional(),
// ── Boolean ──
// is_active: z.boolean().default(true),
// ── Number ──
// quantity: z.coerce.number().min(0),
// ── Date ──
// due_date: z.string().optional(),
})
type <Feature>FormValues = z.infer<typeof <feature>FormSchema>
export { <feature>FormSchema, relationFieldSchema }
export type { <Feature>FormValues }
```
## Field Type Patterns
### Required string
```ts
name: z.string().min(1, "Name is required"),
```
### Optional string
```ts
notes: z.string().optional(),
```
### Optional email (allows empty)
```ts
email: z.union([
z.string().email("Enter a valid email address"),
z.literal(""),
]).optional(),
```
### Relation / Foreign key
```ts
const relationFieldSchema = z
.object({ value: z.string(), label: z.string() })
.nullable()
// In schema:
department: relationFieldSchema,
```
### Required relation
```ts
department: z
.object({ value: z.string(), label: z.string() })
.refine((v) => v !== null, { message: "Department is required" }),
```
### Boolean with default
```ts
is_active: z.boolean().default(true),
```
### Number (from string input)
```ts
quantity: z.coerce.number().min(0, "Must be non-negative"),
price: z.coerce.number().min(0),
```
### Static enum select
```ts
status: z.enum(["active", "inactive", "pending"]).default("active"),
salutation: z.string().optional(),
```
## Real Example: CustomerFormSchema
```ts
import { z } from "zod"
const relationFieldSchema = z
.object({ value: z.string(), label: z.string() })
.nullable()
type RelationField = z.infer<typeof relationFieldSchema>
const customerFormSchema = z.object({
customer_type: relationFieldSchema,
referral_source: relationFieldSchema,
payment_terms: relationFieldSchema,
country: relationFieldSchema,
state: relationFieldSchema,
salutation: z.string().optional(),
first_name: z.string().min(1, "First name is required"),
last_name: z.string().min(1, "Last name is required"),
company_name: z.string().optional(),
email: z.union([
z.string().email("Enter a valid email address"),
z.literal(""),
]).optional(),
phone: z.string().optional(),
alternate_phone: z.string().optional(),
address_line_1: z.string().optional(),
address_line_2: z.string().optional(),
city: z.string().optional(),
zip_code: z.string().optional(),
})
type CustomerFormValues = z.infer<typeof customerFormSchema>
export { customerFormSchema, relationFieldSchema }
export type { CustomerFormValues, RelationField }
```

38
.gitignore vendored Normal file
View File

@ -0,0 +1,38 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# Dependencies
node_modules
.pnp
.pnp.js
# Local env files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Testing
coverage
# Turbo
.turbo
# Vercel
.vercel
# Build Outputs
.next/
out/
build
dist
# Debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Misc
.DS_Store
*.pem

0
.npmrc Normal file
View File

11
.vscode/mcp.json vendored Normal file
View File

@ -0,0 +1,11 @@
{
"servers": {
"shadcn": {
"command": "npx",
"args": [
"shadcn@latest",
"mcp"
]
}
}
}

Binary file not shown.

159
README.md Normal file
View File

@ -0,0 +1,159 @@
# Turborepo starter
This Turborepo starter is maintained by the Turborepo core team.
## Using this example
Run the following command:
```sh
npx create-turbo@latest
```
## What's inside?
This Turborepo includes the following packages/apps: et
### Apps and Packages
- `docs`: a [Next.js](https://nextjs.org/) app
- `web`: another [Next.js](https://nextjs.org/) app
- `@repo/ui`: a stub React component library shared by both `web` and `docs` applications
- `@repo/eslint-config`: `eslint` configurations (includes `eslint-config-next` and `eslint-config-prettier`)
- `@repo/typescript-config`: `tsconfig.json`s used throughout the monorepo
Each package/app is 100% [TypeScript](https://www.typescriptlang.org/).
### Utilities
This Turborepo has some additional tools already setup for you:
- [TypeScript](https://www.typescriptlang.org/) for static type checking
- [ESLint](https://eslint.org/) for code linting
- [Prettier](https://prettier.io) for code formatting
### Build
To build all apps and packages, run the following command:
With [global `turbo`](https://turborepo.dev/docs/getting-started/installation#global-installation) installed (recommended):
```sh
cd my-turborepo
turbo build
```
Without global `turbo`, use your package manager:
```sh
cd my-turborepo
npx turbo build
yarn dlx turbo build
pnpm exec turbo build
```
You can build a specific package by using a [filter](https://turborepo.dev/docs/crafting-your-repository/running-tasks#using-filters):
With [global `turbo`](https://turborepo.dev/docs/getting-started/installation#global-installation) installed:
```sh
turbo build --filter=docs
```
Without global `turbo`:
```sh
npx turbo build --filter=docs
yarn exec turbo build --filter=docs
pnpm exec turbo build --filter=docs
```
### Develop
To develop all apps and packages, run the following command:
With [global `turbo`](https://turborepo.dev/docs/getting-started/installation#global-installation) installed (recommended):
```sh
cd my-turborepo
turbo dev
```
Without global `turbo`, use your package manager:
```sh
cd my-turborepo
npx turbo dev
yarn exec turbo dev
pnpm exec turbo dev
```
You can develop a specific package by using a [filter](https://turborepo.dev/docs/crafting-your-repository/running-tasks#using-filters):
With [global `turbo`](https://turborepo.dev/docs/getting-started/installation#global-installation) installed:
```sh
turbo dev --filter=web
```
Without global `turbo`:
```sh
npx turbo dev --filter=web
yarn exec turbo dev --filter=web
pnpm exec turbo dev --filter=web
```
### Remote Caching
> [!TIP]
> Vercel Remote Cache is free for all plans. Get started today at [vercel.com](https://vercel.com/signup?utm_source=remote-cache-sdk&utm_campaign=free_remote_cache).
Turborepo can use a technique known as [Remote Caching](https://turborepo.dev/docs/core-concepts/remote-caching) to share cache artifacts across machines, enabling you to share build caches with your team and CI/CD pipelines.
By default, Turborepo will cache locally. To enable Remote Caching you will need an account with Vercel. If you don't have an account you can [create one](https://vercel.com/signup?utm_source=turborepo-examples), then enter the following commands:
With [global `turbo`](https://turborepo.dev/docs/getting-started/installation#global-installation) installed (recommended):
```sh
cd my-turborepo
turbo login
```
Without global `turbo`, use your package manager:
```sh
cd my-turborepo
npx turbo login
yarn exec turbo login
pnpm exec turbo login
```
This will authenticate the Turborepo CLI with your [Vercel account](https://vercel.com/docs/concepts/personal-accounts/overview).
Next, you can link your Turborepo to your Remote Cache by running the following command from the root of your Turborepo:
With [global `turbo`](https://turborepo.dev/docs/getting-started/installation#global-installation) installed:
```sh
turbo link
```
Without global `turbo`:
```sh
npx turbo link
yarn exec turbo link
pnpm exec turbo link
```
## Useful Links
Learn more about the power of Turborepo:
- [Tasks](https://turborepo.dev/docs/crafting-your-repository/running-tasks)
- [Caching](https://turborepo.dev/docs/crafting-your-repository/caching)
- [Remote Caching](https://turborepo.dev/docs/core-concepts/remote-caching)
- [Filtering](https://turborepo.dev/docs/crafting-your-repository/running-tasks#using-filters)
- [Configuration Options](https://turborepo.dev/docs/reference/configuration)
- [CLI Usage](https://turborepo.dev/docs/reference/command-line-reference)

1
apps/dashboard Submodule

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

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.

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:** ML depending on grid card design requirements.
---
### 6. `useMutation` Error Handling — Not Reusable
**File:** `modules/customers/customer-form.tsx` (and all other feature forms)
**Problem:** The pattern of mapping `ApiError.validationErrors` to `form.setError` is duplicated in every form's `onError` handler.
**Fix:** Extract a `useFormMutation` hook:
```ts
// shared/hooks/use-form-mutation.ts
export function useFormMutation<TValues extends FieldValues, TResponse = unknown>(
form: UseFormReturn<TValues>,
options: UseMutationOptions<TResponse, Error, TValues>,
) {
return useMutation({
...options,
onError: (err, vars, ctx) => {
if (err instanceof ApiError && err.validationErrors) {
Object.entries(err.validationErrors).forEach(([field, msgs]) => {
form.setError(field as keyof TValues as any, { message: msgs[0] })
})
}
options.onError?.(err, vars, ctx)
},
})
}
```
**Effort:** XS — purely additive. Existing forms can be migrated incrementally.
---
### 7. `CUSTOMER_CREATED_EVENT` — Unused Custom Event
**File:** `modules/customers/customer-form.tsx`
**Problem:** `CustomerForm` dispatches `window.dispatchEvent(new CustomEvent("customer:created"))` on success, but nothing in the codebase listens to this event. Cache invalidation is already handled via `onSuccess``invalidateQuery()`. The event dispatch is dead code.
**Fix:** Remove the event dispatch from `CustomerForm` (and the `CUSTOMER_CREATED_EVENT` export) unless there is a known future use case, such as notifying a sibling component outside the React tree.
**Effort:** XS.
---
### 8. Pagination Meta Split — Inconsistency
**File:** `shared/data-view/table-view/use-data-table-query.ts`
**Problem:** `useDataTableQuery` returns `pagination` with `pageCount: 1, total: 0` as placeholders. The real values come from `data.meta` and are calculated inside `ResourcePage.tsx`. This means:
- `useResourcePage` consumers who render the table directly (outside `ResourcePage`) need to duplicate the `pageCount`/`total` derivation.
- The `pagination` object returned by the hook is misleading until data loads.
**Fix:** Either move the meta derivation inside `useDataTableQuery` (requiring it to accept a response shape hint), or document this as an intentional split and annotate it.
**Effort:** XSS.
---
## Low Priority / Nice to Have
### 9. Row Selection for Bulk Actions
There is no row selection or bulk-delete support. TanStack Table supports `rowSelection` state natively. Adding a checkbox column and a "Delete selected" toolbar would benefit resource-heavy pages.
### 10. Error Boundary Around Table and Form
If a render error occurs inside `DataTable` or a feature form, it will bubble up and crash the whole page. Wrapping with `<ErrorBoundary>` (e.g., via `react-error-boundary`) would improve resilience.
### 11. `ConfirmDialog` — Not Enforced in Layout
**Problem:** `<ConfirmDialog />` must be manually mounted in `app/(authenticated)/layout.tsx`. There is no lint rule or runtime warning if it is missing. If a developer forgets to add it to a new layout, `confirm()` calls will silently resolve to `false` (no dialog shown, deletion blocked).
**Fix:** Add a development-mode warning inside the `confirm()` function if the store's `resolve` is never set after a timeout.
### 12. Column Visibility / Hide
`ColumnHeader` has a "Hide" dropdown menu item (via `column.toggleVisibility(false)`), but there is no global "Show columns" control to restore hidden columns. Either remove the hide option or add a column visibility popup to `DataTable`.
### 13. `dataTableSearchParamsCache` — Imported but Unused in App Router
`dataTableSearchParamsCache` is exported but the pages use `"use client"` throughout. If server components are introduced for any list page, the cache needs wiring in the layout via `nuqs`'s `SearchParamsProvider`.
---
## Checklist
- [x] #1 — Memoize `useAuthApi`
- [x] #2 — Add `CrudClient.show()` and wire `initialize` in feature forms
- [x] #3 — Make `FormDialog` param key configurable
- [ ] #4 — Design and implement filter/search param support in `useDataTableQuery`
- [ ] #5 — Implement `GridView` and view toggle
- [x] #6 — Extract `useFormMutation` hook
- [x] #7 — Remove unused `CUSTOMER_CREATED_EVENT`
- [x] #8 — Move pagination meta derivation into `useDataTableQuery`
- [ ] #9 — Row selection + bulk actions
- [ ] #10 — Error boundaries
- [x] #11 — Dev-mode warning for missing `ConfirmDialog`
- [ ] #12 — Column visibility restore control
- [ ] #13 — Wire `SearchParamsProvider` if server components are adopted

View File

@ -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.

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.

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
```

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 02 resources.
| # | Resource | Status | Depends On | Implementation Details |
|---|----------|--------|------------|----------------------|
| 40 | Employees | ✅ Full | Departments, Shop Calendars, Shop Timings | Page: `productivity/employees` · Module: `employees/` · Client: `EmployeesClient` |
| 41 | Parts | ✅ Full | Shop Types, Inventory Categories, Unit Types, Departments, Vendors | Page: `items/parts` · Module: `parts/` · Client: `PartsClient` |
| 42 | Services | ✅ Full | Shop Types, Inventory Categories, Unit Types, Departments | Page: `items/services` · Module: `services/` · Client: `ServicesClient` · 4 inline forms |
| 43 | Vehicle Documents | 🔧 API Only | Vehicles, Document Types | Client: `VehicleDocumentsClient` |
| 44 | Vehicle Mileage | 🔧 API Only | Vehicles | Client: `VehicleDocumentsClient` |
| 45 | Time Sheets | 🔧 API Only | Employees | Client: exists in collection |
| 46 | Invoice Sequences | 🔧 API Only | Departments | Client: exists in collection |
---
## Level 4 — Composite Service Resources
These depend on Level 03 resources.
| # | Resource | Status | Depends On | Implementation Details |
|---|----------|--------|------------|----------------------|
| 47 | Service Groups | ✅ Full | Shop Types, Inventory Categories, Unit Types, Departments | Page: `items/service-group` · Module: `service-groups/` · Client: `ServiceGroupsClient` |
| 48 | Service Group Includes | 🔧 API Only | Service Groups | Client: part of Service Group Details |
| 49 | Service Group Services | 🔧 API Only | Service Groups, Services, Labor Rates, Taxes | Client: part of Service Group Details |
| 50 | Service Group Parts | 🔧 API Only | Service Groups, Parts, Taxes | Client: part of Service Group Details |
| 51 | Service Group Pricings | 🔧 API Only | Service Groups, Shop Types, Labor Rates, Fuel Types, Body Types | Client: part of Service Group Details |
---
## Level 5 — Workflow & Operations
These are core garage workflow features depending on customers, vehicles, employees, etc.
| # | Resource | Status | Depends On | Implementation Details |
|---|----------|--------|------------|----------------------|
| 52 | Inspections | ✅ Full | Customers, Vehicles, Departments, Inspection Categories, Employees | Page: N/A (module exists) · Module: `inspections/` · Client: `InspectionsClient` · 1 inline form |
| 53 | Inspection Check Points | 🔧 API Only | Inspections, Check Point Labels | Client: `InspectionsClient` |
| 54 | Estimates | 🔧 API Only | Customers, Vehicles, Departments, Labels | Client: `EstimatesClient` |
| 55 | Job Cards | 🔧 API Only | Customers, Vehicles, Departments, Labels, Employees | Client: `JobCardsClient` (richest API — status workflow, remarks, attachments) |
---
## Level 6 — Financial & Scheduling
These depend on Job Cards and other Level 5 resources.
| # | Resource | Status | Depends On | Implementation Details |
|---|----------|--------|------------|----------------------|
| 56 | Appointments | 🔧 API Only | Customers, Vehicles, Departments, Job Cards, Employees, Labels | Client: `AppointmentsClient` |
| 57 | Tasks | 🔧 API Only | Task Types, Task Sections, Job Cards, Employees, Departments | Client: `TasksClient` |
| 58 | Purchase Orders | 🔧 API Only | Job Cards, Vendors, Departments, Labels, Parts | Client: `PurchaseOrdersClient` |
| 59 | Bills | 🔧 API Only | Job Cards, Vendors, Vendor Addresses, Payment Terms, Departments, Labels, Parts | Client: `ExpensesClient` |
| 60 | Expenses | 🔧 API Only | Job Cards, Expense Items, Vendors, Departments, Labels | Client: `ExpensesClient` |
| 61 | Payment Received | 🔧 API Only | Job Cards, Payment Modes, Customers | Client: `PaymentsClient` |
| 62 | Inventory Adjustments | 🔧 API Only | Parts, Job Cards, Invoices, Reasons | Client: exists in collection |
---
## Level 7 — Invoicing & Credit System (Most Complex)
These are the most complex resources with the deepest dependency chains.
| # | Resource | Status | Depends On | Implementation Details |
|---|----------|--------|------------|----------------------|
| 63 | Invoices | 🔧 API Only | Customers, Vehicles, Departments, Invoice Sequences, Labels, Inspection Categories, Parts, Services, Expense Items, Service Groups | Client: exists in collection |
| 64 | Invoice Documents | 🔧 API Only | Invoices, Customers, Vehicles, Document Types | Client: exists in collection |
| 65 | Invoice Notes | 🔧 API Only | Invoices | Client: exists in collection |
| 66 | Credit Notes | 🔧 API Only | Customers, Parts, Services, Expenses, Inspection Categories, Labels | Client: exists in collection |
| 67 | Payment Mades | 🔧 API Only | Vendors, Employees, Bills, Expenses, Payment Modes | Client: exists in collection |
| 68 | Vendor Credits | 🔧 API Only | Vendors, Departments, Parts, Services, Expenses, Labels | Client: exists in collection |
---
## Summary
### Implementation Progress
| Category | Total | ✅ Full | 🔧 API Only | ⬜ Not Started |
|----------|-------|---------|-------------|----------------|
| Level 0 — Standalone | 31 | 4 | 27 | 0 |
| Level 1 — Single Dep | 5 | 0 | 5 | 0 |
| Level 2 — Core Entities | 3 | 2 | 1 | 0 |
| Level 3 — Operational | 7 | 3 | 4 | 0 |
| Level 4 — Composite | 5 | 1 | 4 | 0 |
| Level 5 — Workflows | 4 | 1 | 3 | 0 |
| Level 6 — Financial | 7 | 0 | 7 | 0 |
| Level 7 — Invoicing | 6 | 0 | 6 | 0 |
| **Total** | **68** | **11** | **57** | **0** |
### Pages with Full UI (11 total)
1. Auth (Login)
2. Shop Types (Settings)
3. Shop Calendars (Productivity)
4. Shop Timings (Productivity)
5. Customers (Sales)
6. Vehicles (Sales)
7. Employees (Productivity)
8. Parts (Items)
9. Services (Items)
10. Service Groups (Items)
11. Inspections (Module only — no page route yet)
### API Clients Without Pages — Priority Recommendations
Based on the roadmap (Phase 1 — Garage Operations), these are the highest-priority missing pages:
1. **Job Cards** — Core garage workflow, API client is the most feature-rich
2. **Estimates** — Pre-job-card workflow
3. **Appointments** — Scheduling system
4. **Inspections Page** — Module exists but no page route
5. **Departments** — Referenced by almost every form
6. **Vendors** — Needed for Parts purchasing and Bills
7. **Invoices** — Phase 2 but API is ready
---
## Dependency Graph (Simplified)
```
Level 0 (Foundation)
├── Auth, Countries, Shop Types, Customer Types, Referral Sources
├── Payment Terms, Payment Modes, Document Types, Unit Types, Labels
├── Vehicle Attributes (Body, Fuel, Transmission, Colors)
├── Inspection Categories, Check Point Labels, Insurance Types
├── Quick Remarks/Notes, Reasons, Task Types/Sections
├── Holiday Years, Taxes, Departments, Labor Rates
├── Vendors, Shop Calendars, Shop Timings, Invoice Labels, Settings
Level 1 (Single Dependency)
├── States → Countries
├── Inventory Categories → Shop Types
├── Vendor Addresses → Vendors + Countries + States
├── Holidays → Holiday Years
├── Make and Models → Shop Types + Vehicle Attributes
Level 2 (Core Entities)
├── Customers → Customer Types + Referral Sources + Payment Terms + Geo
├── Vehicles → Shop Types + Vehicle Attributes
├── Expense Items → Inventory Categories + Unit Types + Departments
Level 3 (Operational)
├── Employees → Departments + Shop Calendars + Shop Timings
├── Parts → Shop Types + Inventory Categories + Unit Types + Departments + Vendors
├── Services → Shop Types + Inventory Categories + Unit Types + Departments
├── Vehicle Documents → Vehicles + Document Types
├── Vehicle Mileage → Vehicles
├── Time Sheets → Employees
├── Invoice Sequences → Departments
Level 4 (Composite)
├── Service Groups → Shop Types + Inv. Categories + Unit Types + Departments
├── SG Includes/Services/Parts/Pricings → Service Groups + ...
Level 5 (Workflows)
├── Inspections → Customers + Vehicles + Departments + Insp. Categories + Employees
├── Inspection Check Points → Inspections + Check Point Labels
├── Estimates → Customers + Vehicles + Departments + Labels
├── Job Cards → Customers + Vehicles + Departments + Labels + Employees
Level 6 (Financial)
├── Appointments → Customers + Vehicles + Departments + Job Cards + Employees
├── Tasks → Task Types + Task Sections + Job Cards + Employees + Departments
├── Purchase Orders → Job Cards + Vendors + Departments + Labels + Parts
├── Bills → Job Cards + Vendors + Payment Terms + Departments + Labels + Parts
├── Expenses → Job Cards + Expense Items + Vendors + Departments + Labels
├── Payment Received → Job Cards + Payment Modes + Customers
├── Inventory Adjustments → Parts + Job Cards + Invoices + Reasons
Level 7 (Invoicing — Most Complex)
├── Invoices → Customers + Vehicles + Departments + Inv. Sequences + Labels + Parts + Services + Expenses + Service Groups
├── Invoice Documents → Invoices + Customers + Vehicles + Document Types
├── Invoice Notes → Invoices
├── Credit Notes → Customers + Parts + Services + Expenses + Insp. Categories + Labels
├── Payment Mades → Vendors + Employees + Bills + Expenses + Payment Modes
└── Vendor Credits → Vendors + Departments + Parts + Services + Expenses + Labels
```

22
package.json Normal file
View File

@ -0,0 +1,22 @@
{
"name": "carage-erp",
"private": true,
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev",
"lint": "turbo run lint",
"start": "turbo run start",
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
"check-types": "turbo run check-types",
"test:e2e": "turbo run test:e2e"
},
"devDependencies": {
"prettier": "^3.7.4",
"turbo": "^2.8.20",
"typescript": "5.9.2"
},
"packageManager": "pnpm@9.0.0",
"engines": {
"node": ">=18"
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

39
packages/api/package.json Normal file
View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

65
packages/api/src/api.ts Normal file
View File

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

View File

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

View File

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

View File

@ -0,0 +1,32 @@
import { CrudClient } from "../infra/crud-client"
import { ApiClient, type ApiClientOptions } from "../infra/client"
import type { ApiPath, ApiRequestBody } from "../infra/types"
import type { ApiListQueryParams } from "../contracts/types"
export const CUSTOMER_ROUTES = {
INDEX: "/api/customers",
BY_ID: "/api/customers/{id}",
EXPORT: "/api/customers/export",
IMPORT: "/api/customers/import",
CUSTOMER_TYPES: "/api/customer-types",
} as const satisfies Record<string, ApiPath>
export class CustomersClient extends CrudClient<typeof CUSTOMER_ROUTES.INDEX, typeof CUSTOMER_ROUTES.BY_ID> {
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
super(baseUrl, defaultOptions, CUSTOMER_ROUTES.INDEX, CUSTOMER_ROUTES.BY_ID)
}
async listCustomerTypes(query?: ApiListQueryParams) {
return this.get(CUSTOMER_ROUTES.CUSTOMER_TYPES, query ? { query } as never : undefined)
}
async export() {
return this.get(CUSTOMER_ROUTES.EXPORT)
}
async import(payload: ApiRequestBody<typeof CUSTOMER_ROUTES.IMPORT, "post">) {
return this.post(CUSTOMER_ROUTES.IMPORT, payload)
}
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,74 @@
import { ApiClient, type ApiClientOptions } from "../infra/client"
import type { ApiPath, ApiRequestBody } from "../infra/types"
import type { ApiListQueryParams } from "../contracts/types"
export const EXPENSE_ROUTES = {
ITEMS: "/api/expense-items",
ITEM_BY_ID: "/api/expense-items/{id}",
TOGGLE_ITEM_STATUS: "/api/toggle-expense-item-status",
BILLS: "/api/bills",
BILL_BY_ID: "/api/bills/{id}",
EXPENSES: "/api/expenses",
EXPENSE_BY_ID: "/api/expenses/{id}",
} as const satisfies Record<string, ApiPath>
export class ExpensesClient extends ApiClient {
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
super(baseUrl, defaultOptions)
}
// ── Expense Items ──
async listItems(query?: ApiListQueryParams) {
return this.get(EXPENSE_ROUTES.ITEMS, query ? { query } as never : undefined)
}
async createItem(payload: ApiRequestBody<typeof EXPENSE_ROUTES.ITEMS, "post">) {
return this.post(EXPENSE_ROUTES.ITEMS, payload)
}
async updateItem(id: string, payload: ApiRequestBody<typeof EXPENSE_ROUTES.ITEM_BY_ID, "put">) {
return this.put(EXPENSE_ROUTES.ITEM_BY_ID, payload, { params: { id } })
}
async destroyItem(id: string) {
return this.delete(EXPENSE_ROUTES.ITEM_BY_ID, { params: { id } })
}
async toggleItemStatus(payload: ApiRequestBody<typeof EXPENSE_ROUTES.TOGGLE_ITEM_STATUS, "post">) {
return this.post(EXPENSE_ROUTES.TOGGLE_ITEM_STATUS, payload)
}
// ── Bills ──
async listBills(query?: ApiListQueryParams) {
return this.get(EXPENSE_ROUTES.BILLS, query ? { query } as never : undefined)
}
async createBill(payload: ApiRequestBody<typeof EXPENSE_ROUTES.BILLS, "post">) {
return this.post(EXPENSE_ROUTES.BILLS, payload)
}
async updateBill(id: string, payload: ApiRequestBody<typeof EXPENSE_ROUTES.BILL_BY_ID, "put">) {
return this.put(EXPENSE_ROUTES.BILL_BY_ID, payload, { params: { id } })
}
async destroyBill(id: string) {
return this.delete(EXPENSE_ROUTES.BILL_BY_ID, { params: { id } })
}
// ── Expenses ──
async listExpenses(query?: ApiListQueryParams) {
return this.get(EXPENSE_ROUTES.EXPENSES, query ? { query } as never : undefined)
}
async createExpense(payload: ApiRequestBody<typeof EXPENSE_ROUTES.EXPENSES, "post">) {
return this.post(EXPENSE_ROUTES.EXPENSES, payload)
}
async updateExpense(id: string, payload: ApiRequestBody<typeof EXPENSE_ROUTES.EXPENSE_BY_ID, "put">) {
return this.put(EXPENSE_ROUTES.EXPENSE_BY_ID, payload, { params: { id } })
}
async destroyExpense(id: string) {
return this.delete(EXPENSE_ROUTES.EXPENSE_BY_ID, { params: { id } })
}
}

View File

@ -0,0 +1,21 @@
import { ApiClient, type ApiClientOptions } from "../infra/client"
import type { ApiPath } from "../infra/types"
export const GEO_ROUTES = {
COUNTRIES: "/api/countries",
STATES: "/api/states",
} as const satisfies Record<string, ApiPath>
export class GeoClient extends ApiClient {
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
super(baseUrl, defaultOptions)
}
async countries() {
return this.get(GEO_ROUTES.COUNTRIES)
}
async states() {
return this.get(GEO_ROUTES.STATES)
}
}

View File

@ -0,0 +1,28 @@
export { AuthClient, AUTH_ROUTES } from "./auth"
export { CustomersClient, CUSTOMER_ROUTES } from "./customers"
export { ReferralSourcesClient, REFERRAL_SOURCE_ROUTES } from "./referral-sources"
export { VehiclesClient, VEHICLE_ROUTES } from "./vehicles"
export { VehicleAttributesClient, VEHICLE_ATTRIBUTE_ROUTES } from "./vehicle-attributes"
export { VehicleDocumentsClient, VEHICLE_DOCUMENT_ROUTES } from "./vehicle-documents"
export { DepartmentsClient, DEPARTMENT_ROUTES } from "./departments"
export { EmployeesClient, EMPLOYEE_ROUTES } from "./employees"
export { GeoClient, GEO_ROUTES } from "./geo"
export { PaymentTermsClient, PAYMENT_TERM_ROUTES } from "./payment-terms"
export { ShopTypesClient, SHOP_TYPE_ROUTES, type ShopTypeCreatePayload, type ShopTypeUpdatePayload } from "./shop-types"
export { InventoryClient, INVENTORY_ROUTES } from "./inventory"
export { VendorsClient, VENDOR_ROUTES } from "./vendors"
export { InspectionsClient, INSPECTION_ROUTES } from "./inspections"
export { LabelsClient, LABEL_ROUTES } from "./labels"
export { InsuranceTypesClient, INSURANCE_TYPE_ROUTES } from "./insurance-types"
export { EstimatesClient, ESTIMATE_ROUTES } from "./estimates"
export { JobCardsClient, JOB_CARD_ROUTES } from "./job-cards"
export { PaymentsClient, PAYMENT_ROUTES } from "./payments"
export { PartsClient, PARTS_ROUTES } from "./parts"
export { PurchaseOrdersClient, PURCHASE_ORDER_ROUTES } from "./purchase-orders"
export { ServicesClient, SERVICE_ROUTES } from "./services"
export { ServiceGroupsClient, SERVICE_GROUP_ROUTES } from "./service-groups"
export { ExpensesClient, EXPENSE_ROUTES } from "./expenses"
export { TasksClient, TASK_ROUTES } from "./tasks"
export { AppointmentsClient, APPOINTMENT_ROUTES } from "./appointments"
export { ShopTimingsClient, SHOP_TIMING_ROUTES } from "./shop-timings"
export { ShopCalendarsClient, SHOP_CALENDAR_ROUTES } from "./shop-calendars"

View File

@ -0,0 +1,118 @@
import { ApiClient, type ApiClientOptions } from "../infra/client"
import type { ApiPath, ApiRequestBody } from "../infra/types"
import type { ApiListQueryParams } from "../contracts/types"
export const INSPECTION_ROUTES = {
CATEGORIES: "/api/inspection-categories",
CATEGORY_BY_ID: "/api/inspection-categories/{id}",
INDEX: "/api/inspections",
BY_ID: "/api/inspections/{id}",
CHANGE_STATUS: "/api/change-inspection-status",
CHECKPOINT_LABELS: "/api/check-point-label",
CHECKPOINT_LABEL_BY_ID: "/api/check-point-label/{id}",
CHECKPOINTS: "/api/inspection-check-points",
CHECKPOINT_BY_ID: "/api/inspection-check-points/{id}",
TOGGLE_LABEL_TO_CHECKPOINT: "/api/toggle-label-to-checkpoint",
CHECKPOINT_CHANGE_STATUS: "/api/inspection-check-points/change-status",
CHECKPOINT_ADD_ATTACHMENT: "/api/inspection-check-points/add-attachment",
CHECKPOINT_UPLOAD_MEDIA: "/api/inspection-check-points/{id}/upload-media",
CHECKPOINT_MEDIA: "/api/inspection-check-points/{id}/media",
} as const satisfies Record<string, ApiPath>
export class InspectionsClient extends ApiClient {
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
super(baseUrl, defaultOptions)
}
// ── Categories ──
async listCategories(query?: ApiListQueryParams) {
return this.get(INSPECTION_ROUTES.CATEGORIES, query ? { query } as never : undefined)
}
async createCategory(payload: ApiRequestBody<typeof INSPECTION_ROUTES.CATEGORIES, "post">) {
return this.post(INSPECTION_ROUTES.CATEGORIES, payload)
}
async updateCategory(id: string, payload: ApiRequestBody<typeof INSPECTION_ROUTES.CATEGORY_BY_ID, "put">) {
return this.put(INSPECTION_ROUTES.CATEGORY_BY_ID, payload, { params: { id } })
}
async destroyCategory(id: string) {
return this.delete(INSPECTION_ROUTES.CATEGORY_BY_ID, { params: { id } })
}
// ── Inspections ──
async list(query?: ApiListQueryParams) {
return this.get(INSPECTION_ROUTES.INDEX, query ? { query } as never : undefined)
}
async create(payload: ApiRequestBody<typeof INSPECTION_ROUTES.INDEX, "post">) {
return this.post(INSPECTION_ROUTES.INDEX, payload)
}
async update(id: string, payload: ApiRequestBody<typeof INSPECTION_ROUTES.BY_ID, "put">) {
return this.put(INSPECTION_ROUTES.BY_ID, payload, { params: { id } })
}
async destroy(id: string) {
return this.delete(INSPECTION_ROUTES.BY_ID, { params: { id } })
}
async changeStatus(payload: ApiRequestBody<typeof INSPECTION_ROUTES.CHANGE_STATUS, "post">) {
return this.post(INSPECTION_ROUTES.CHANGE_STATUS, payload)
}
// ── Checkpoint Labels ──
async listCheckpointLabels(query?: ApiListQueryParams) {
return this.get(INSPECTION_ROUTES.CHECKPOINT_LABELS, query ? { query } as never : undefined)
}
async createCheckpointLabel(payload: ApiRequestBody<typeof INSPECTION_ROUTES.CHECKPOINT_LABELS, "post">) {
return this.post(INSPECTION_ROUTES.CHECKPOINT_LABELS, payload)
}
async updateCheckpointLabel(id: string, payload: ApiRequestBody<typeof INSPECTION_ROUTES.CHECKPOINT_LABEL_BY_ID, "put">) {
return this.put(INSPECTION_ROUTES.CHECKPOINT_LABEL_BY_ID, payload, { params: { id } })
}
async destroyCheckpointLabel(id: string) {
return this.delete(INSPECTION_ROUTES.CHECKPOINT_LABEL_BY_ID, { params: { id } })
}
// ── Checkpoints ──
async listCheckpoints(query?: ApiListQueryParams) {
return this.get(INSPECTION_ROUTES.CHECKPOINTS, query ? { query } as never : undefined)
}
async createCheckpoint(payload: ApiRequestBody<typeof INSPECTION_ROUTES.CHECKPOINTS, "post">) {
return this.post(INSPECTION_ROUTES.CHECKPOINTS, payload)
}
async updateCheckpoint(id: string, payload: ApiRequestBody<typeof INSPECTION_ROUTES.CHECKPOINT_BY_ID, "put">) {
return this.put(INSPECTION_ROUTES.CHECKPOINT_BY_ID, payload, { params: { id } })
}
async destroyCheckpoint(id: string) {
return this.delete(INSPECTION_ROUTES.CHECKPOINT_BY_ID, { params: { id } })
}
async toggleLabelToCheckpoint(payload: ApiRequestBody<typeof INSPECTION_ROUTES.TOGGLE_LABEL_TO_CHECKPOINT, "post">) {
return this.post(INSPECTION_ROUTES.TOGGLE_LABEL_TO_CHECKPOINT, payload)
}
async changeCheckpointStatus(payload: ApiRequestBody<typeof INSPECTION_ROUTES.CHECKPOINT_CHANGE_STATUS, "post">) {
return this.post(INSPECTION_ROUTES.CHECKPOINT_CHANGE_STATUS, payload)
}
async addCheckpointAttachment(payload: ApiRequestBody<typeof INSPECTION_ROUTES.CHECKPOINT_ADD_ATTACHMENT, "post">) {
return this.post(INSPECTION_ROUTES.CHECKPOINT_ADD_ATTACHMENT, payload)
}
async uploadCheckpointMedia(id: string, payload: ApiRequestBody<typeof INSPECTION_ROUTES.CHECKPOINT_UPLOAD_MEDIA, "post">) {
return this.post(INSPECTION_ROUTES.CHECKPOINT_UPLOAD_MEDIA, payload, { params: { id } })
}
async deleteCheckpointMedia(id: string) {
return this.delete(INSPECTION_ROUTES.CHECKPOINT_MEDIA, { params: { id } })
}
}

View File

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

View File

@ -0,0 +1,99 @@
import { ApiClient, type ApiClientOptions } from "../infra/client"
import type { ApiPath, ApiRequestBody } from "../infra/types"
import type { ApiListQueryParams } from "../contracts/types"
export const INVENTORY_ROUTES = {
UNIT_TYPES: "/api/unit-types",
UNIT_TYPE_BY_ID: "/api/unit-types/{id}",
SET_FAVORITE_UNIT_TYPE: "/api/set-favorite-unit-type",
REMOVE_FAVORITE_UNIT_TYPE: "/api/remove-favorite-unit-type",
CATEGORIES: "/api/inventory-categories",
CATEGORY_BY_ID: "/api/inventory-categories/{id}",
SET_FAVORITE_CATEGORY: "/api/set-favorite-inventory-category",
REMOVE_FAVORITE_CATEGORY: "/api/remove-favorite-inventory-category",
LABOR_RATES: "/api/labor-rates",
LABOR_RATE_BY_ID: "/api/labor-rates/{id}",
SET_FAVORITE_LABOR_RATE: "/api/set-favorite-labor-rate",
REMOVE_FAVORITE_LABOR_RATE: "/api/remove-favorite-labor-rate",
} as const satisfies Record<string, ApiPath>
export class InventoryClient extends ApiClient {
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
super(baseUrl, defaultOptions)
}
// ── Unit Types ──
async listUnitTypes(query?: ApiListQueryParams) {
return this.get(INVENTORY_ROUTES.UNIT_TYPES, query ? { query } as never : undefined)
}
async createUnitType(payload: ApiRequestBody<typeof INVENTORY_ROUTES.UNIT_TYPES, "post">) {
return this.post(INVENTORY_ROUTES.UNIT_TYPES, payload)
}
async updateUnitType(id: string, payload: ApiRequestBody<typeof INVENTORY_ROUTES.UNIT_TYPE_BY_ID, "put">) {
return this.put(INVENTORY_ROUTES.UNIT_TYPE_BY_ID, payload, { params: { id } })
}
async destroyUnitType(id: string) {
return this.delete(INVENTORY_ROUTES.UNIT_TYPE_BY_ID, { params: { id } })
}
async setFavoriteUnitType(payload: ApiRequestBody<typeof INVENTORY_ROUTES.SET_FAVORITE_UNIT_TYPE, "post">) {
return this.post(INVENTORY_ROUTES.SET_FAVORITE_UNIT_TYPE, payload)
}
async removeFavoriteUnitType(payload: ApiRequestBody<typeof INVENTORY_ROUTES.REMOVE_FAVORITE_UNIT_TYPE, "post">) {
return this.post(INVENTORY_ROUTES.REMOVE_FAVORITE_UNIT_TYPE, payload)
}
// ── Inventory Categories ──
async listCategories(query?: ApiListQueryParams) {
return this.get(INVENTORY_ROUTES.CATEGORIES, query ? { query } as never : undefined)
}
async createCategory(payload: ApiRequestBody<typeof INVENTORY_ROUTES.CATEGORIES, "post">) {
return this.post(INVENTORY_ROUTES.CATEGORIES, payload)
}
async updateCategory(id: string, payload: ApiRequestBody<typeof INVENTORY_ROUTES.CATEGORY_BY_ID, "put">) {
return this.put(INVENTORY_ROUTES.CATEGORY_BY_ID, payload, { params: { id } })
}
async destroyCategory(id: string) {
return this.delete(INVENTORY_ROUTES.CATEGORY_BY_ID, { params: { id } })
}
async setFavoriteCategory(payload: ApiRequestBody<typeof INVENTORY_ROUTES.SET_FAVORITE_CATEGORY, "post">) {
return this.post(INVENTORY_ROUTES.SET_FAVORITE_CATEGORY, payload)
}
async removeFavoriteCategory(payload: ApiRequestBody<typeof INVENTORY_ROUTES.REMOVE_FAVORITE_CATEGORY, "post">) {
return this.post(INVENTORY_ROUTES.REMOVE_FAVORITE_CATEGORY, payload)
}
// ── Labor Rates ──
async listLaborRates(query?: ApiListQueryParams) {
return this.get(INVENTORY_ROUTES.LABOR_RATES, query ? { query } as never : undefined)
}
async createLaborRate(payload: ApiRequestBody<typeof INVENTORY_ROUTES.LABOR_RATES, "post">) {
return this.post(INVENTORY_ROUTES.LABOR_RATES, payload)
}
async updateLaborRate(id: string, payload: ApiRequestBody<typeof INVENTORY_ROUTES.LABOR_RATE_BY_ID, "put">) {
return this.put(INVENTORY_ROUTES.LABOR_RATE_BY_ID, payload, { params: { id } })
}
async destroyLaborRate(id: string) {
return this.delete(INVENTORY_ROUTES.LABOR_RATE_BY_ID, { params: { id } })
}
async setFavoriteLaborRate(payload: ApiRequestBody<typeof INVENTORY_ROUTES.SET_FAVORITE_LABOR_RATE, "post">) {
return this.post(INVENTORY_ROUTES.SET_FAVORITE_LABOR_RATE, payload)
}
async removeFavoriteLaborRate(payload: ApiRequestBody<typeof INVENTORY_ROUTES.REMOVE_FAVORITE_LABOR_RATE, "post">) {
return this.post(INVENTORY_ROUTES.REMOVE_FAVORITE_LABOR_RATE, payload)
}
}

View File

@ -0,0 +1,90 @@
import { ApiClient, type ApiClientOptions } from "../infra/client"
import type { ApiPath, ApiRequestBody } from "../infra/types"
import type { ApiListQueryParams } from "../contracts/types"
export const JOB_CARD_ROUTES = {
INDEX: "/api/job-cards",
BY_ID: "/api/job-cards/{id}",
CHANGE_DATE: "/api/job-cards/{id}/change-date",
CHANGE_STATUS: "/api/job-cards/{id}/change-status",
ADD_CUSTOMER_REMARK: "/api/job-cards/{id}/add-customer-remark",
EDIT_CUSTOMER_REMARK: "/api/job-cards/{id}/edit-customer-remark",
DELETE_CUSTOMER_REMARK: "/api/job-cards/{id}/delete-customer-remark",
ADD_SHOP_RECOMMENDATION: "/api/job-cards/{id}/add-shop-recommendation",
EDIT_SHOP_RECOMMENDATION: "/api/job-cards/{id}/edit-shop-recommendation",
DELETE_SHOP_RECOMMENDATION: "/api/job-cards/{id}/delete-shop-recommendation",
ADD_ATTACHMENT: "/api/job-cards/{id}/add-attachment",
DELETE_ATTACHMENT: "/api/job-cards/{id}/delete-attachment",
CHANGE_SERVICE_WRITER: "/api/job-cards/{id}/change-service-writer-id",
CHANGE_SALES_PERSON: "/api/job-cards/{id}/change-sales-person-id",
} as const satisfies Record<string, ApiPath>
export class JobCardsClient extends ApiClient {
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
super(baseUrl, defaultOptions)
}
async list(query?: ApiListQueryParams) {
return this.get(JOB_CARD_ROUTES.INDEX, query ? { query } as never : undefined)
}
async create(payload: ApiRequestBody<typeof JOB_CARD_ROUTES.INDEX, "post">) {
return this.post(JOB_CARD_ROUTES.INDEX, payload)
}
async update(id: string, payload: ApiRequestBody<typeof JOB_CARD_ROUTES.BY_ID, "put">) {
return this.put(JOB_CARD_ROUTES.BY_ID, payload, { params: { id } })
}
async destroy(id: string) {
return this.delete(JOB_CARD_ROUTES.BY_ID, { params: { id } })
}
async changeDate(id: string, payload: ApiRequestBody<typeof JOB_CARD_ROUTES.CHANGE_DATE, "post">) {
return this.post(JOB_CARD_ROUTES.CHANGE_DATE, payload, { params: { id } })
}
async changeStatus(id: string, payload: ApiRequestBody<typeof JOB_CARD_ROUTES.CHANGE_STATUS, "post">) {
return this.post(JOB_CARD_ROUTES.CHANGE_STATUS, payload, { params: { id } })
}
async addCustomerRemark(id: string, payload: ApiRequestBody<typeof JOB_CARD_ROUTES.ADD_CUSTOMER_REMARK, "post">) {
return this.post(JOB_CARD_ROUTES.ADD_CUSTOMER_REMARK, payload, { params: { id } })
}
async editCustomerRemark(id: string, payload: ApiRequestBody<typeof JOB_CARD_ROUTES.EDIT_CUSTOMER_REMARK, "post">) {
return this.post(JOB_CARD_ROUTES.EDIT_CUSTOMER_REMARK, payload, { params: { id } })
}
async deleteCustomerRemark(id: string) {
return this.delete(JOB_CARD_ROUTES.DELETE_CUSTOMER_REMARK, { params: { id } })
}
async addShopRecommendation(id: string, payload: ApiRequestBody<typeof JOB_CARD_ROUTES.ADD_SHOP_RECOMMENDATION, "post">) {
return this.post(JOB_CARD_ROUTES.ADD_SHOP_RECOMMENDATION, payload, { params: { id } })
}
async editShopRecommendation(id: string, payload: ApiRequestBody<typeof JOB_CARD_ROUTES.EDIT_SHOP_RECOMMENDATION, "post">) {
return this.post(JOB_CARD_ROUTES.EDIT_SHOP_RECOMMENDATION, payload, { params: { id } })
}
async deleteShopRecommendation(id: string) {
return this.delete(JOB_CARD_ROUTES.DELETE_SHOP_RECOMMENDATION, { params: { id } })
}
async addAttachment(id: string, payload: ApiRequestBody<typeof JOB_CARD_ROUTES.ADD_ATTACHMENT, "post">) {
return this.post(JOB_CARD_ROUTES.ADD_ATTACHMENT, payload, { params: { id } })
}
async deleteAttachment(id: string, payload: ApiRequestBody<typeof JOB_CARD_ROUTES.DELETE_ATTACHMENT, "post">) {
return this.post(JOB_CARD_ROUTES.DELETE_ATTACHMENT, payload, { params: { id } })
}
async changeServiceWriter(id: string, payload: ApiRequestBody<typeof JOB_CARD_ROUTES.CHANGE_SERVICE_WRITER, "post">) {
return this.post(JOB_CARD_ROUTES.CHANGE_SERVICE_WRITER, payload, { params: { id } })
}
async changeSalesPerson(id: string, payload: ApiRequestBody<typeof JOB_CARD_ROUTES.CHANGE_SALES_PERSON, "post">) {
return this.post(JOB_CARD_ROUTES.CHANGE_SALES_PERSON, payload, { params: { id } })
}
}

View File

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

View File

@ -0,0 +1,45 @@
import { ApiClient, type ApiClientOptions } from "../infra/client"
import type { ApiPath, ApiRequestBody } from "../infra/types"
import type { ApiListQueryParams } from "../contracts/types"
export const PARTS_ROUTES = {
INDEX: "/api/parts",
BY_ID: "/api/parts/{id}",
IMPORT: "/api/import-parts",
EXPORT: "/api/export-parts",
TOGGLE_STATUS: "/api/toggle-part-status",
} as const satisfies Record<string, ApiPath>
export class PartsClient extends ApiClient {
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
super(baseUrl, defaultOptions)
}
async list(query?: ApiListQueryParams) {
return this.get(PARTS_ROUTES.INDEX, query ? { query } as never : undefined)
}
async create(payload: ApiRequestBody<typeof PARTS_ROUTES.INDEX, "post">) {
return this.post(PARTS_ROUTES.INDEX, payload)
}
async update(id: string, payload: ApiRequestBody<typeof PARTS_ROUTES.BY_ID, "put">) {
return this.put(PARTS_ROUTES.BY_ID, payload, { params: { id } })
}
async destroy(id: string) {
return this.delete(PARTS_ROUTES.BY_ID, { params: { id } })
}
async import(payload: ApiRequestBody<typeof PARTS_ROUTES.IMPORT, "post">) {
return this.post(PARTS_ROUTES.IMPORT, payload)
}
async export(payload: ApiRequestBody<typeof PARTS_ROUTES.EXPORT, "post">) {
return this.post(PARTS_ROUTES.EXPORT, payload)
}
async toggleStatus(payload: ApiRequestBody<typeof PARTS_ROUTES.TOGGLE_STATUS, "post">) {
return this.post(PARTS_ROUTES.TOGGLE_STATUS, payload)
}
}

View File

@ -0,0 +1,35 @@
import { ApiClient, type ApiClientOptions } from "../infra/client"
import type { ApiPath, ApiRequestBody } from "../infra/types"
import type { ApiListQueryParams } from "../contracts/types"
export const PAYMENT_TERM_ROUTES = {
INDEX: "/api/payment-terms",
BY_ID: "/api/payment-terms/{id}",
SET_DEFAULT: "/api/set-default-payment-term",
} as const satisfies Record<string, ApiPath>
export class PaymentTermsClient extends ApiClient {
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
super(baseUrl, defaultOptions)
}
async list(query?: ApiListQueryParams) {
return this.get(PAYMENT_TERM_ROUTES.INDEX, query ? { query } as never : undefined)
}
async create(payload: ApiRequestBody<typeof PAYMENT_TERM_ROUTES.INDEX, "post">) {
return this.post(PAYMENT_TERM_ROUTES.INDEX, payload)
}
async update(id: string, payload: ApiRequestBody<typeof PAYMENT_TERM_ROUTES.BY_ID, "put">) {
return this.put(PAYMENT_TERM_ROUTES.BY_ID, payload, { params: { id } })
}
async destroy(id: string) {
return this.delete(PAYMENT_TERM_ROUTES.BY_ID, { params: { id } })
}
async setDefault(payload: ApiRequestBody<typeof PAYMENT_TERM_ROUTES.SET_DEFAULT, "post">) {
return this.post(PAYMENT_TERM_ROUTES.SET_DEFAULT, payload)
}
}

View File

@ -0,0 +1,50 @@
import { ApiClient, type ApiClientOptions } from "../infra/client"
import type { ApiPath, ApiRequestBody } from "../infra/types"
import type { ApiListQueryParams } from "../contracts/types"
export const PAYMENT_ROUTES = {
MODES: "/api/payment-mode",
MODE_BY_ID: "/api/payment-mode/{id}",
RECEIVED: "/api/payment-recieved",
RECEIVED_BY_ID: "/api/payment-recieved/{id}",
} as const satisfies Record<string, ApiPath>
export class PaymentsClient extends ApiClient {
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
super(baseUrl, defaultOptions)
}
// ── Payment Modes ──
async listModes(query?: ApiListQueryParams) {
return this.get(PAYMENT_ROUTES.MODES, query ? { query } as never : undefined)
}
async createMode(payload: ApiRequestBody<typeof PAYMENT_ROUTES.MODES, "post">) {
return this.post(PAYMENT_ROUTES.MODES, payload)
}
async updateMode(id: string, payload: ApiRequestBody<typeof PAYMENT_ROUTES.MODE_BY_ID, "put">) {
return this.put(PAYMENT_ROUTES.MODE_BY_ID, payload, { params: { id } })
}
async destroyMode(id: string) {
return this.delete(PAYMENT_ROUTES.MODE_BY_ID, { params: { id } })
}
// ── Payment Received ──
async listReceived(query?: ApiListQueryParams) {
return this.get(PAYMENT_ROUTES.RECEIVED, query ? { query } as never : undefined)
}
async createReceived(payload: ApiRequestBody<typeof PAYMENT_ROUTES.RECEIVED, "post">) {
return this.post(PAYMENT_ROUTES.RECEIVED, payload)
}
async updateReceived(id: string, payload: ApiRequestBody<typeof PAYMENT_ROUTES.RECEIVED_BY_ID, "post">) {
return this.post(PAYMENT_ROUTES.RECEIVED_BY_ID, payload, { params: { id } })
}
async destroyReceived(id: string) {
return this.delete(PAYMENT_ROUTES.RECEIVED_BY_ID, { params: { id } })
}
}

View File

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

View File

@ -0,0 +1,35 @@
import { ApiClient, type ApiClientOptions } from "../infra/client"
import type { ApiPath, ApiRequestBody } from "../infra/types"
import type { ApiListQueryParams } from "../contracts/types"
export const REFERRAL_SOURCE_ROUTES = {
INDEX: "/api/referral-sources",
BY_ID: "/api/referral-sources/{id}",
SET_DEFAULT: "/api/set-default-referral-source",
} as const satisfies Record<string, ApiPath>
export class ReferralSourcesClient extends ApiClient {
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
super(baseUrl, defaultOptions)
}
async list(query?: ApiListQueryParams) {
return this.get(REFERRAL_SOURCE_ROUTES.INDEX, query ? { query } as never : undefined)
}
async create(payload: ApiRequestBody<typeof REFERRAL_SOURCE_ROUTES.INDEX, "post">) {
return this.post(REFERRAL_SOURCE_ROUTES.INDEX, payload)
}
async update(id: string, payload: ApiRequestBody<typeof REFERRAL_SOURCE_ROUTES.BY_ID, "put">) {
return this.put(REFERRAL_SOURCE_ROUTES.BY_ID, payload, { params: { id } })
}
async destroy(id: string) {
return this.delete(REFERRAL_SOURCE_ROUTES.BY_ID, { params: { id } })
}
async setDefault(payload: ApiRequestBody<typeof REFERRAL_SOURCE_ROUTES.SET_DEFAULT, "post">) {
return this.post(REFERRAL_SOURCE_ROUTES.SET_DEFAULT, payload)
}
}

View File

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

View File

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

View File

@ -0,0 +1,41 @@
import { ApiClient, type ApiClientOptions } from "../infra/client"
import type { ApiPath, ApiRequestBody } from "../infra/types"
import type { ApiListQueryParams } from "../contracts/types"
export const SHOP_CALENDAR_ROUTES = {
INDEX: "/api/shop-calenders",
BY_ID: "/api/shop-calenders/{id}",
SET_DEFAULT: "/api/set-default-shop-calender",
REMOVE_DEFAULT: "/api/remove-default-shop-calender",
UPDATE_DAY_TYPE: "/api/shop-calenders/{id}/update-day-type",
} as const satisfies Record<string, ApiPath>
export class ShopCalendarsClient extends ApiClient {
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
super(baseUrl, defaultOptions)
}
async list(query?: ApiListQueryParams) {
return this.get(SHOP_CALENDAR_ROUTES.INDEX, query ? { query } as never : undefined)
}
async create(payload: ApiRequestBody<typeof SHOP_CALENDAR_ROUTES.INDEX, "post">) {
return this.post(SHOP_CALENDAR_ROUTES.INDEX, payload)
}
async destroy(id: string) {
return this.delete(SHOP_CALENDAR_ROUTES.BY_ID, { params: { id } })
}
async setDefault(payload: ApiRequestBody<typeof SHOP_CALENDAR_ROUTES.SET_DEFAULT, "post">) {
return this.post(SHOP_CALENDAR_ROUTES.SET_DEFAULT, payload)
}
async removeDefault(payload: ApiRequestBody<typeof SHOP_CALENDAR_ROUTES.REMOVE_DEFAULT, "post">) {
return this.post(SHOP_CALENDAR_ROUTES.REMOVE_DEFAULT, payload)
}
async updateDayType(id: string, payload: ApiRequestBody<typeof SHOP_CALENDAR_ROUTES.UPDATE_DAY_TYPE, "post">) {
return this.post(SHOP_CALENDAR_ROUTES.UPDATE_DAY_TYPE, payload, { params: { id } } as never)
}
}

View File

@ -0,0 +1,27 @@
import { CrudClient } from "../infra/crud-client"
import type { ApiClientOptions } from "../infra/client"
import type { ApiPath, ApiRequestBody } from "../infra/types"
export const SHOP_TIMING_ROUTES = {
INDEX: "/api/shop-timings",
BY_ID: "/api/shop-timings/{id}",
SET_DEFAULT: "/api/set-default-shop-timing",
REMOVE_DEFAULT: "/api/remove-default-shop-timing",
} as const satisfies Record<string, ApiPath>
export class ShopTimingsClient extends CrudClient<
typeof SHOP_TIMING_ROUTES.INDEX,
typeof SHOP_TIMING_ROUTES.BY_ID
> {
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
super(baseUrl, defaultOptions, SHOP_TIMING_ROUTES.INDEX, SHOP_TIMING_ROUTES.BY_ID)
}
async setDefault(payload: ApiRequestBody<typeof SHOP_TIMING_ROUTES.SET_DEFAULT, "post">) {
return this.post(SHOP_TIMING_ROUTES.SET_DEFAULT, payload)
}
async removeDefault(payload: ApiRequestBody<typeof SHOP_TIMING_ROUTES.REMOVE_DEFAULT, "post">) {
return this.post(SHOP_TIMING_ROUTES.REMOVE_DEFAULT, payload)
}
}

View File

@ -0,0 +1,60 @@
import { ApiClient, type ApiClientOptions } from "../infra/client"
import type { ApiPath } from "../infra/types"
import type { ApiListQueryParams } from "../contracts/types"
export const SHOP_TYPE_ROUTES = {
INDEX: "/api/shop-types",
BY_ID: "/api/shop-types/{id}",
} as const satisfies Record<string, ApiPath>
export type ShopTypeCreatePayload = {
title: string
shop_type?: string
note?: string
is_default?: boolean
inspection?: File | null
image?: File | null
}
export type ShopTypeUpdatePayload = Partial<Omit<ShopTypeCreatePayload, "title">> & {
title?: string
}
function buildShopTypeFormData(payload: ShopTypeCreatePayload | ShopTypeUpdatePayload): FormData {
const fd = new FormData()
if (payload.title) fd.append("title", payload.title)
if (payload.shop_type) fd.append("shop_type", payload.shop_type)
if (payload.note) fd.append("note", payload.note)
if (payload.is_default != null) fd.append("is_default", String(Number(payload.is_default)))
if (payload.inspection instanceof File) fd.append("inspection", payload.inspection)
if (payload.image instanceof File) fd.append("image", payload.image)
return fd
}
export class ShopTypesClient extends ApiClient {
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
super(baseUrl, defaultOptions)
}
async list(query?: ApiListQueryParams) {
return this.get(SHOP_TYPE_ROUTES.INDEX, query ? { query } as never : undefined)
}
async create(payload: ShopTypeCreatePayload) {
const fd = buildShopTypeFormData(payload)
return this.postFormData(SHOP_TYPE_ROUTES.INDEX, fd)
}
async update(id: string, payload: ShopTypeUpdatePayload) {
const fd = buildShopTypeFormData(payload)
fd.append("_method", "PUT")
const url = SHOP_TYPE_ROUTES.BY_ID.replace("{id}", id)
return this.postFormData(url, fd)
}
async destroy(id: string) {
return this.delete(SHOP_TYPE_ROUTES.BY_ID, { params: { id } })
}
}

View File

@ -0,0 +1,99 @@
import { ApiClient, type ApiClientOptions } from "../infra/client"
import type { ApiPath, ApiRequestBody } from "../infra/types"
import type { ApiListQueryParams } from "../contracts/types"
export const TASK_ROUTES = {
TYPES: "/api/task-types",
TYPE_BY_ID: "/api/task-types/{id}",
SET_DEFAULT_TYPE: "/api/set-default-task-type",
REMOVE_DEFAULT_TYPE: "/api/remove-default-task-type",
SECTIONS: "/api/task-sections",
SECTION_BY_ID: "/api/task-sections/{id}",
SET_DEFAULT_SECTION: "/api/set-default-task-section",
REMOVE_DEFAULT_SECTION: "/api/remove-default-task-section",
CHANGE_SECTION_ARRANGEMENT: "/api/change-task-section-arrangement",
TASKS: "/api/tasks",
TASK_BY_ID: "/api/tasks/{id}",
COMPLETE: "/api/tasks/{id}/complete",
} as const satisfies Record<string, ApiPath>
export class TasksClient extends ApiClient {
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
super(baseUrl, defaultOptions)
}
// ── Task Types ──
async listTypes(query?: ApiListQueryParams) {
return this.get(TASK_ROUTES.TYPES, query ? { query } as never : undefined)
}
async createType(payload: ApiRequestBody<typeof TASK_ROUTES.TYPES, "post">) {
return this.post(TASK_ROUTES.TYPES, payload)
}
async updateType(id: string, payload: ApiRequestBody<typeof TASK_ROUTES.TYPE_BY_ID, "put">) {
return this.put(TASK_ROUTES.TYPE_BY_ID, payload, { params: { id } })
}
async destroyType(id: string) {
return this.delete(TASK_ROUTES.TYPE_BY_ID, { params: { id } })
}
async setDefaultType(payload: ApiRequestBody<typeof TASK_ROUTES.SET_DEFAULT_TYPE, "post">) {
return this.post(TASK_ROUTES.SET_DEFAULT_TYPE, payload)
}
async removeDefaultType(payload: ApiRequestBody<typeof TASK_ROUTES.REMOVE_DEFAULT_TYPE, "post">) {
return this.post(TASK_ROUTES.REMOVE_DEFAULT_TYPE, payload)
}
// ── Task Sections ──
async listSections(query?: ApiListQueryParams) {
return this.get(TASK_ROUTES.SECTIONS, query ? { query } as never : undefined)
}
async createSection(payload: ApiRequestBody<typeof TASK_ROUTES.SECTIONS, "post">) {
return this.post(TASK_ROUTES.SECTIONS, payload)
}
async updateSection(id: string, payload: ApiRequestBody<typeof TASK_ROUTES.SECTION_BY_ID, "put">) {
return this.put(TASK_ROUTES.SECTION_BY_ID, payload, { params: { id } })
}
async destroySection(id: string) {
return this.delete(TASK_ROUTES.SECTION_BY_ID, { params: { id } })
}
async setDefaultSection(payload: ApiRequestBody<typeof TASK_ROUTES.SET_DEFAULT_SECTION, "post">) {
return this.post(TASK_ROUTES.SET_DEFAULT_SECTION, payload)
}
async removeDefaultSection(payload: ApiRequestBody<typeof TASK_ROUTES.REMOVE_DEFAULT_SECTION, "post">) {
return this.post(TASK_ROUTES.REMOVE_DEFAULT_SECTION, payload)
}
async changeSectionArrangement(payload: ApiRequestBody<typeof TASK_ROUTES.CHANGE_SECTION_ARRANGEMENT, "post">) {
return this.post(TASK_ROUTES.CHANGE_SECTION_ARRANGEMENT, payload)
}
// ── Tasks ──
async list(query?: ApiListQueryParams) {
return this.get(TASK_ROUTES.TASKS, query ? { query } as never : undefined)
}
async create(payload: ApiRequestBody<typeof TASK_ROUTES.TASKS, "post">) {
return this.post(TASK_ROUTES.TASKS, payload)
}
async update(id: string, payload: ApiRequestBody<typeof TASK_ROUTES.TASK_BY_ID, "put">) {
return this.put(TASK_ROUTES.TASK_BY_ID, payload, { params: { id } })
}
async destroy(id: string) {
return this.delete(TASK_ROUTES.TASK_BY_ID, { params: { id } })
}
async complete(id: string, payload: ApiRequestBody<typeof TASK_ROUTES.COMPLETE, "post">) {
return this.post(TASK_ROUTES.COMPLETE, payload, { params: { id } })
}
}

View File

@ -0,0 +1,88 @@
import { ApiClient, type ApiClientOptions } from "../infra/client"
import type { ApiPath, ApiRequestBody } from "../infra/types"
import type { ApiListQueryParams } from "../contracts/types"
export const VEHICLE_ATTRIBUTE_ROUTES = {
BODY_TYPES: "/api/vehicle-body-types",
BODY_TYPE_BY_ID: "/api/vehicle-body-types/{id}",
FUEL_TYPES: "/api/vehicle-fuel-types",
FUEL_TYPE_BY_ID: "/api/vehicle-fuel-types/{id}",
TRANSMISSIONS: "/api/vehicle-transmissions",
TRANSMISSION_BY_ID: "/api/vehicle-transmissions/{id}",
COLORS: "/api/vehicle-colors",
COLOR_BY_ID: "/api/vehicle-colors/{id}",
} as const satisfies Record<string, ApiPath>
export class VehicleAttributesClient extends ApiClient {
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
super(baseUrl, defaultOptions)
}
// ── Body Types ──
async listBodyTypes(query?: ApiListQueryParams) {
return this.get(VEHICLE_ATTRIBUTE_ROUTES.BODY_TYPES, query ? { query } as never : undefined)
}
async createBodyType(payload: ApiRequestBody<typeof VEHICLE_ATTRIBUTE_ROUTES.BODY_TYPES, "post">) {
return this.post(VEHICLE_ATTRIBUTE_ROUTES.BODY_TYPES, payload)
}
async updateBodyType(id: string, payload: ApiRequestBody<typeof VEHICLE_ATTRIBUTE_ROUTES.BODY_TYPE_BY_ID, "put">) {
return this.put(VEHICLE_ATTRIBUTE_ROUTES.BODY_TYPE_BY_ID, payload, { params: { id } })
}
async destroyBodyType(id: string) {
return this.delete(VEHICLE_ATTRIBUTE_ROUTES.BODY_TYPE_BY_ID, { params: { id } })
}
// ── Fuel Types ──
async listFuelTypes(query?: ApiListQueryParams) {
return this.get(VEHICLE_ATTRIBUTE_ROUTES.FUEL_TYPES, query ? { query } as never : undefined)
}
async createFuelType(payload: ApiRequestBody<typeof VEHICLE_ATTRIBUTE_ROUTES.FUEL_TYPES, "post">) {
return this.post(VEHICLE_ATTRIBUTE_ROUTES.FUEL_TYPES, payload)
}
async updateFuelType(id: string, payload: ApiRequestBody<typeof VEHICLE_ATTRIBUTE_ROUTES.FUEL_TYPE_BY_ID, "put">) {
return this.put(VEHICLE_ATTRIBUTE_ROUTES.FUEL_TYPE_BY_ID, payload, { params: { id } })
}
async destroyFuelType(id: string) {
return this.delete(VEHICLE_ATTRIBUTE_ROUTES.FUEL_TYPE_BY_ID, { params: { id } })
}
// ── Transmissions ──
async listTransmissions(query?: ApiListQueryParams) {
return this.get(VEHICLE_ATTRIBUTE_ROUTES.TRANSMISSIONS, query ? { query } as never : undefined)
}
async createTransmission(payload: ApiRequestBody<typeof VEHICLE_ATTRIBUTE_ROUTES.TRANSMISSIONS, "post">) {
return this.post(VEHICLE_ATTRIBUTE_ROUTES.TRANSMISSIONS, payload)
}
async updateTransmission(id: string, payload: ApiRequestBody<typeof VEHICLE_ATTRIBUTE_ROUTES.TRANSMISSION_BY_ID, "put">) {
return this.put(VEHICLE_ATTRIBUTE_ROUTES.TRANSMISSION_BY_ID, payload, { params: { id } })
}
async destroyTransmission(id: string) {
return this.delete(VEHICLE_ATTRIBUTE_ROUTES.TRANSMISSION_BY_ID, { params: { id } })
}
// ── Colors ──
async listColors(query?: ApiListQueryParams) {
return this.get(VEHICLE_ATTRIBUTE_ROUTES.COLORS, query ? { query } as never : undefined)
}
async createColor(payload: ApiRequestBody<typeof VEHICLE_ATTRIBUTE_ROUTES.COLORS, "post">) {
return this.post(VEHICLE_ATTRIBUTE_ROUTES.COLORS, payload)
}
async updateColor(id: string, payload: ApiRequestBody<typeof VEHICLE_ATTRIBUTE_ROUTES.COLOR_BY_ID, "put">) {
return this.put(VEHICLE_ATTRIBUTE_ROUTES.COLOR_BY_ID, payload, { params: { id } })
}
async destroyColor(id: string) {
return this.delete(VEHICLE_ATTRIBUTE_ROUTES.COLOR_BY_ID, { params: { id } })
}
}

View File

@ -0,0 +1,69 @@
import { ApiClient, type ApiClientOptions } from "../infra/client"
import type { ApiPath, ApiRequestBody } from "../infra/types"
import type { ApiListQueryParams } from "../contracts/types"
export const VEHICLE_DOCUMENT_ROUTES = {
DOCUMENT_TYPES: "/api/document-types",
DOCUMENT_TYPE_BY_ID: "/api/document-types/{id}",
DOCUMENTS: "/api/vehicle-documents",
DOCUMENT_BY_ID: "/api/vehicle-documents/{id}",
MILEAGE: "/api/vehicle-mile-and-kms",
MILEAGE_BY_ID: "/api/vehicle-mile-and-kms/{id}",
} as const satisfies Record<string, ApiPath>
export class VehicleDocumentsClient extends ApiClient {
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
super(baseUrl, defaultOptions)
}
// ── Document Types ──
async listDocumentTypes(query?: ApiListQueryParams) {
return this.get(VEHICLE_DOCUMENT_ROUTES.DOCUMENT_TYPES, query ? { query } as never : undefined)
}
async createDocumentType(payload: ApiRequestBody<typeof VEHICLE_DOCUMENT_ROUTES.DOCUMENT_TYPES, "post">) {
return this.post(VEHICLE_DOCUMENT_ROUTES.DOCUMENT_TYPES, payload)
}
async updateDocumentType(id: string, payload: ApiRequestBody<typeof VEHICLE_DOCUMENT_ROUTES.DOCUMENT_TYPE_BY_ID, "put">) {
return this.put(VEHICLE_DOCUMENT_ROUTES.DOCUMENT_TYPE_BY_ID, payload, { params: { id } })
}
async destroyDocumentType(id: string) {
return this.delete(VEHICLE_DOCUMENT_ROUTES.DOCUMENT_TYPE_BY_ID, { params: { id } })
}
// ── Vehicle Documents ──
async listDocuments(query?: ApiListQueryParams) {
return this.get(VEHICLE_DOCUMENT_ROUTES.DOCUMENTS, query ? { query } as never : undefined)
}
async createDocument(payload: ApiRequestBody<typeof VEHICLE_DOCUMENT_ROUTES.DOCUMENTS, "post">) {
return this.post(VEHICLE_DOCUMENT_ROUTES.DOCUMENTS, payload)
}
async updateDocument(id: string, payload: ApiRequestBody<typeof VEHICLE_DOCUMENT_ROUTES.DOCUMENT_BY_ID, "put">) {
return this.put(VEHICLE_DOCUMENT_ROUTES.DOCUMENT_BY_ID, payload, { params: { id } })
}
async destroyDocument(id: string) {
return this.delete(VEHICLE_DOCUMENT_ROUTES.DOCUMENT_BY_ID, { params: { id } })
}
// ── Mileage ──
async listMileage(query?: ApiListQueryParams) {
return this.get(VEHICLE_DOCUMENT_ROUTES.MILEAGE, query ? { query } as never : undefined)
}
async createMileage(payload: ApiRequestBody<typeof VEHICLE_DOCUMENT_ROUTES.MILEAGE, "post">) {
return this.post(VEHICLE_DOCUMENT_ROUTES.MILEAGE, payload)
}
async updateMileage(id: string, payload: ApiRequestBody<typeof VEHICLE_DOCUMENT_ROUTES.MILEAGE_BY_ID, "put">) {
return this.put(VEHICLE_DOCUMENT_ROUTES.MILEAGE_BY_ID, payload, { params: { id } })
}
async destroyMileage(id: string) {
return this.delete(VEHICLE_DOCUMENT_ROUTES.MILEAGE_BY_ID, { params: { id } })
}
}

View File

@ -0,0 +1,55 @@
import { ApiClient, type ApiClientOptions } from "../infra/client"
import type { ApiPath, ApiRequestBody } from "../infra/types"
import type { ApiListQueryParams } from "../contracts/types"
export const VEHICLE_ROUTES = {
INDEX: "/api/vehicles",
BY_ID: "/api/vehicles/{id}",
EXPORT: "/api/vehicles/export",
IMPORT: "/api/vehicles/import",
GET_OWNERS: "/api/get-vehicle-owners",
LINK_CUSTOMER: "/api/link-customer-to-vehicle",
UNLINK_CUSTOMER: "/api/unlink-customer-from-vehicle",
} as const satisfies Record<string, ApiPath>
export class VehiclesClient extends ApiClient {
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
super(baseUrl, defaultOptions)
}
async list(query?: ApiListQueryParams) {
return this.get(VEHICLE_ROUTES.INDEX, query ? { query } as never : undefined)
}
async create(payload: ApiRequestBody<typeof VEHICLE_ROUTES.INDEX, "post">) {
return this.post(VEHICLE_ROUTES.INDEX, payload)
}
async update(id: string, payload: ApiRequestBody<typeof VEHICLE_ROUTES.BY_ID, "put">) {
return this.put(VEHICLE_ROUTES.BY_ID, payload, { params: { id } })
}
async destroy(id: string) {
return this.delete(VEHICLE_ROUTES.BY_ID, { params: { id } })
}
async export() {
return this.get(VEHICLE_ROUTES.EXPORT)
}
async import(payload: ApiRequestBody<typeof VEHICLE_ROUTES.IMPORT, "post">) {
return this.post(VEHICLE_ROUTES.IMPORT, payload)
}
async getOwners() {
return this.get(VEHICLE_ROUTES.GET_OWNERS)
}
async linkCustomer(payload: ApiRequestBody<typeof VEHICLE_ROUTES.LINK_CUSTOMER, "post">) {
return this.post(VEHICLE_ROUTES.LINK_CUSTOMER, payload)
}
async unlinkCustomer(payload: ApiRequestBody<typeof VEHICLE_ROUTES.UNLINK_CUSTOMER, "post">) {
return this.post(VEHICLE_ROUTES.UNLINK_CUSTOMER, payload)
}
}

View File

@ -0,0 +1,45 @@
import { ApiClient, type ApiClientOptions } from "../infra/client"
import type { ApiPath, ApiRequestBody } from "../infra/types"
import type { ApiListQueryParams } from "../contracts/types"
export const VENDOR_ROUTES = {
INDEX: "/api/vendors",
BY_ID: "/api/vendors/{id}",
TOGGLE_STATUS: "/api/toggle-vendor-status",
CREATE_ADDRESS: "/api/create-vendor-address",
ADDRESS_BY_ID: "/api/vendor-address/{id}",
} as const satisfies Record<string, ApiPath>
export class VendorsClient extends ApiClient {
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
super(baseUrl, defaultOptions)
}
async list(query?: ApiListQueryParams) {
return this.get(VENDOR_ROUTES.INDEX, query ? { query } as never : undefined)
}
async create(payload: ApiRequestBody<typeof VENDOR_ROUTES.INDEX, "post">) {
return this.post(VENDOR_ROUTES.INDEX, payload)
}
async update(id: string, payload: ApiRequestBody<typeof VENDOR_ROUTES.BY_ID, "put">) {
return this.put(VENDOR_ROUTES.BY_ID, payload, { params: { id } })
}
async destroy(id: string) {
return this.delete(VENDOR_ROUTES.BY_ID, { params: { id } })
}
async toggleStatus(payload: ApiRequestBody<typeof VENDOR_ROUTES.TOGGLE_STATUS, "post">) {
return this.post(VENDOR_ROUTES.TOGGLE_STATUS, payload)
}
async createAddress(payload: ApiRequestBody<typeof VENDOR_ROUTES.CREATE_ADDRESS, "post">) {
return this.post(VENDOR_ROUTES.CREATE_ADDRESS, payload)
}
async getAddress(id: string) {
return this.get(VENDOR_ROUTES.ADDRESS_BY_ID, { params: { id } })
}
}

View File

@ -0,0 +1,8 @@
import { ApiBaseResponse, ApiListQueryParams } from "./types"
export interface CrudOperations {
list: <T>(params: ApiListQueryParams) => Promise<ApiBaseResponse<T>>
create: <T>(payload: unknown) => Promise<ApiBaseResponse<T>>
update: <T>(id: string, payload: unknown) => Promise<ApiBaseResponse<T>>
destroy: <T>(id: string) => Promise<ApiBaseResponse<T>>
}

View File

@ -0,0 +1,19 @@
export type ApiBaseResponse<T> = {
data: T;
meta: {
current_page: number,
last_page: number,
per_page: number,
total: number,
from: number,
to: number
}
}
export type ApiListQueryParams = {
page?: number;
per_page?: number;
sort_by?: string;
sort_order?: 'asc' | 'desc';
[key: string]: any; // For additional filters
}

13
packages/api/src/index.ts Normal file
View File

@ -0,0 +1,13 @@
// ── Infrastructure ──
export * from "./infra/index"
// ── Contracts ──
export * from "./contracts/types"
// ── Domain Clients ──
export * from "./clients/index"
// ── Factory ──
export { createApi, api } from "./api"

View File

@ -0,0 +1,269 @@
import type {
ApiPath,
ApiPathByMethod,
ApiPathParams,
ApiQueryParams,
ApiRequestBody,
ApiResponse,
} from "./types"
import createClient from "openapi-fetch"
import type { paths } from "../../types/index"
type HttpMethod = "get" | "post" | "put" | "delete" | "patch"
export type ApiClientOptions = {
headers?: Record<string, string>
}
type ApiRequestOptions<Path extends ApiPath, Method extends HttpMethod> =
Omit<RequestInit, "method" | "body"> & {
params?: ApiPathParams<Path, Method> extends never ? never : ApiPathParams<Path, Method>
query?: ApiQueryParams<Path, Method> extends never ? never : ApiQueryParams<Path, Method>
body?: ApiRequestBody<Path, Method> extends never ? never : ApiRequestBody<Path, Method>
}
type LaravelValidationErrors = Record<string, string[]>
type LaravelErrorPayload = {
success?: boolean
message?: string
data?: unknown
errors?: LaravelValidationErrors | string[] | Record<string, unknown> | null
pagination?: Record<string, unknown> | null
}
export class ApiError extends Error {
public readonly name = "ApiError"
constructor(
public readonly status: number,
public readonly statusText: string,
public readonly endpoint: string,
public readonly method: string,
public readonly payload?: LaravelErrorPayload,
) {
super(payload?.message ?? `${method.toUpperCase()} ${endpoint} failed with ${status} ${statusText}`.trim())
}
get validationErrors(): LaravelValidationErrors | undefined {
return this.payload?.errors && !Array.isArray(this.payload.errors)
? (this.payload.errors as LaravelValidationErrors)
: undefined
}
}
export class ApiClient {
private client
constructor(
protected baseUrl: string = process.env.NEXT_PUBLIC_API_URL ?? "",
protected defaultOptions: ApiClientOptions = {},
) {
this.client = createClient<paths>({
baseUrl: `${this.normalizeBaseUrl(baseUrl)}/`,
})
}
async request<Path extends ApiPath, Method extends HttpMethod>(
endpoint: Path,
method: Method,
options: ApiRequestOptions<Path, Method> = {} as ApiRequestOptions<Path, Method>,
): Promise<ApiResponse<Path, Method>> {
const ep = endpoint as never
const opts = options as never
const body = (options as Record<string, unknown>).body as never
switch (method) {
case "get":
return this.get(ep, opts) as Promise<ApiResponse<Path, Method>>
case "post":
return this.post(ep, body, opts) as Promise<ApiResponse<Path, Method>>
case "put":
return this.put(ep, body, opts) as Promise<ApiResponse<Path, Method>>
case "delete":
return this.delete(ep, opts) as Promise<ApiResponse<Path, Method>>
case "patch":
return this.patch(ep, body, opts) as Promise<ApiResponse<Path, Method>>
default:
throw new ApiError(0, "Unsupported Method", endpoint, method, {
message: `Unsupported method: ${String(method)}`,
})
}
}
async get<Path extends ApiPathByMethod<"get">>(
endpoint: Path,
options: ApiRequestOptions<Path, "get"> = {} as ApiRequestOptions<Path, "get">,
): Promise<ApiResponse<Path, "get">> {
const requestOptions = this.toFetchOptions(options)
try {
const { data, error, response } = await this.client.GET(endpoint, requestOptions as never)
return this.resolveResult(endpoint, "get", data, error, response)
} catch (err) {
if (err instanceof ApiError) throw err
throw this.createNetworkError(endpoint, "get")
}
}
async post<Path extends ApiPathByMethod<"post">>(
endpoint: Path,
body: ApiRequestBody<Path, "post"> extends never ? undefined : ApiRequestBody<Path, "post">,
options: Omit<ApiRequestOptions<Path, "post">, "body"> = {} as Omit<ApiRequestOptions<Path, "post">, "body">,
): Promise<ApiResponse<Path, "post">> {
const requestOptions = this.toFetchOptions({ ...options, body })
try {
const { data, error, response } = await this.client.POST(endpoint, requestOptions as never)
return this.resolveResult(endpoint, "post", data, error, response)
} catch (err) {
if (err instanceof ApiError) throw err
throw this.createNetworkError(endpoint, "post")
}
}
async put<Path extends ApiPathByMethod<"put">>(
endpoint: Path,
body: ApiRequestBody<Path, "put"> extends never ? undefined : ApiRequestBody<Path, "put">,
options: Omit<ApiRequestOptions<Path, "put">, "body"> = {} as Omit<ApiRequestOptions<Path, "put">, "body">,
): Promise<ApiResponse<Path, "put">> {
const requestOptions = this.toFetchOptions({ ...options, body })
try {
const { data, error, response } = await this.client.PUT(endpoint, requestOptions as never)
return this.resolveResult(endpoint, "put", data, error, response)
} catch (err) {
if (err instanceof ApiError) throw err
throw this.createNetworkError(endpoint, "put")
}
}
async delete<Path extends ApiPathByMethod<"delete">>(
endpoint: Path,
options: ApiRequestOptions<Path, "delete"> = {} as ApiRequestOptions<Path, "delete">,
): Promise<ApiResponse<Path, "delete">> {
const requestOptions = this.toFetchOptions(options)
try {
const { data, error, response } = await this.client.DELETE(endpoint, requestOptions as never)
return this.resolveResult(endpoint, "delete", data, error, response)
} catch (err) {
if (err instanceof ApiError) throw err
throw this.createNetworkError(endpoint, "delete")
}
}
async patch<Path extends ApiPathByMethod<"patch">>(
endpoint: Path,
body: ApiRequestBody<Path, "patch"> extends never ? undefined : ApiRequestBody<Path, "patch">,
options: Omit<ApiRequestOptions<Path, "patch">, "body"> = {} as Omit<ApiRequestOptions<Path, "patch">, "body">,
): Promise<ApiResponse<Path, "patch">> {
const requestOptions = this.toFetchOptions({ ...options, body })
try {
const { data, error, response } = await this.client.PATCH(endpoint, requestOptions as never)
return this.resolveResult(endpoint, "patch", data, error, response)
} catch (err) {
if (err instanceof ApiError) throw err
throw this.createNetworkError(endpoint, "patch")
}
}
protected normalizeBaseUrl(baseUrl: string): string {
return baseUrl.replace(/\/+$/, "")
}
protected async postFormData(endpoint: string, formData: FormData): Promise<any> {
const url = `${this.normalizeBaseUrl(this.baseUrl)}${endpoint}`
const headers = new Headers(this.defaultOptions.headers as Record<string, string>)
headers.set("Accept", "application/json")
// Content-Type is intentionally omitted — fetch sets multipart/form-data + boundary automatically
const response = await fetch(url, { method: "POST", headers, body: formData })
const text = await response.text()
const data = text ? JSON.parse(text) : null
if (!response.ok) {
throw new ApiError(response.status, response.statusText, endpoint, "post", data)
}
return data
}
private toFetchOptions<Path extends ApiPath, Method extends HttpMethod>(
options: ApiRequestOptions<Path, Method>,
): Record<string, unknown> {
const { params, query, body, headers, ...requestInit } = options as Record<string, unknown>
const requestOptions: Record<string, unknown> = {
...requestInit,
headers: this.withDefaultHeaders(headers as HeadersInit | undefined),
}
if (params || query) {
requestOptions.params = {
...(params ? { path: params } : {}),
...(query ? { query } : {}),
}
}
if (body !== undefined) {
requestOptions.body = body
}
return requestOptions
}
private withDefaultHeaders(headers?: HeadersInit): Headers {
const finalHeaders = new Headers(this.defaultOptions.headers)
finalHeaders.set("Accept", "application/json")
if (headers) {
new Headers(headers).forEach((value, key) => finalHeaders.set(key, value))
}
return finalHeaders
}
private resolveResult<Path extends ApiPath, Method extends HttpMethod>(
endpoint: Path,
method: Method,
data: unknown,
error: unknown,
response: Response,
): ApiResponse<Path, Method> {
if (error !== undefined) {
throw new ApiError(
response.status,
response.statusText,
endpoint,
method,
this.normalizeErrorPayload(error),
)
}
return data as ApiResponse<Path, Method>
}
private normalizeErrorPayload(error: unknown): LaravelErrorPayload | undefined {
if (!error || typeof error !== "object") {
return undefined
}
if ("message" in error || "errors" in error) {
return error as LaravelErrorPayload
}
return {
message: "Request failed",
data: error,
}
}
private createNetworkError(endpoint: string, method: string): ApiError {
return new ApiError(
0,
"Network Error",
endpoint,
method,
{ message: "Network error occurred. Please check your connection and try again." },
)
}
}

View File

@ -0,0 +1,58 @@
import { ApiClient, type ApiClientOptions } from "./client"
import type { ApiPathByMethod, ApiQueryParams, ApiRequestBody, ApiResponse } from "./types"
import type { ApiListQueryParams } from "../contracts/types"
export const DEFAULT_PER_PAGE = 10
type CrudIndexRoute = ApiPathByMethod<"get"> & ApiPathByMethod<"post">
type CrudByIdRoute = ApiPathByMethod<"put"> & ApiPathByMethod<"delete">
export abstract class CrudClient<
IndexRoute extends CrudIndexRoute,
ByIdRoute extends CrudByIdRoute,
> extends ApiClient {
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions,
public indexRoute?: IndexRoute,
public byIdRoute?: ByIdRoute) {
super(baseUrl, defaultOptions)
}
async list(query?: ApiListQueryParams): Promise<ApiResponse<IndexRoute, "get">> {
return this.get(this.indexRoute as IndexRoute, query ? { query } as never : undefined)
}
async show(id: string) {
return this.get(this.byIdRoute as ByIdRoute & ApiPathByMethod<"get">, { params: { id } } as never)
}
async create(payload: ApiRequestBody<IndexRoute, "post">) {
return this.post<IndexRoute>(this.indexRoute as IndexRoute, payload as never)
}
async update(id: string, payload: ApiRequestBody<ByIdRoute, "put">) {
return this.put<ByIdRoute>(this.byIdRoute as ByIdRoute, payload as never, { params: { id } } as never)
}
async destroy(id: string) {
return this.delete<ByIdRoute>(this.byIdRoute as ByIdRoute, { params: { id } } as never)
}
}
export type BaseCrudItem = { id: number }
/** Extract the list (GET index) response type from a CrudClient subclass. */
export type CrudListResponse<C> = C extends CrudClient<infer IR, infer _BR> ? ApiResponse<IR, "get"> : never
/** Extract the show (GET by-id) response type from a CrudClient subclass. */
export type CrudShowResponse<C> = C extends CrudClient<infer _IR, infer BR> ? ApiResponse<BR, "get"> : never
/** Extract a single item type from the `data` array of a CrudClient list response. */
export type CrudListItem<C> = CrudListResponse<C> extends { data?: (infer Item)[] } ? Item : never
/** Extract the query-parameter type accepted by a CrudClient's `list()` method. */
export type CrudListParams<C> = C extends CrudClient<infer IR, infer _BR> ? ApiQueryParams<IR, "get"> : never

View File

@ -0,0 +1,22 @@
export {
type ApiPaths,
type ApiComponents,
type ApiOperations,
type ApiPath,
type ApiMethod,
type ApiPathByMethod,
type ApiPathParams,
type ApiQueryParams,
type ApiHeaderParams,
type ApiCookieParams,
type ApiRequestBody,
type ApiResponse,
type ApiOperationId,
type ApiOperationRequestBody,
type ApiOperationResponse,
} from "./types"
export { ApiClient, ApiError, type ApiClientOptions } from "./client"
export { DEFAULT_PER_PAGE } from "./crud-client"
export * from "./crud-client"
export type { AuthUser } from "./token"

View File

@ -0,0 +1,6 @@
export type AuthUser = {
id: number
name: string
email: string
[key: string]: string | number
}

View File

@ -0,0 +1,112 @@
import type { components, operations, paths } from "../../types/index"
type HttpMethod = "get" | "put" | "post" | "delete" | "options" | "head" | "patch" | "trace"
type NotNeverOrUndefined<T> = [Exclude<T, undefined>] extends [never] ? never : Exclude<T, undefined>
type OperationFor<Path extends ApiPath, Method extends HttpMethod> = Method extends keyof paths[Path]
? NotNeverOrUndefined<paths[Path][Method]>
: never
type WithContent<T> = T extends { content: infer Content } ? Content : never
type JsonContent<T> = T extends { "application/json": infer Payload } ? Payload : never
type RequestContent<T> =
T extends { "application/json": infer Payload } ? Payload :
T extends { "*/*": infer Payload } ? Payload :
T extends { "multipart/form-data": infer Payload } ? Payload :
never
type ResponseByCode<Responses, Code extends number> = Code extends keyof Responses
? Responses[Code]
: never
type SuccessResponse<Responses> =
| ResponseByCode<Responses, 200>
| ResponseByCode<Responses, 201>
| ResponseByCode<Responses, 202>
| ResponseByCode<Responses, 203>
| ResponseByCode<Responses, 204>
| ResponseByCode<Responses, 205>
| ResponseByCode<Responses, 206>
| ResponseByCode<Responses, 207>
| ResponseByCode<Responses, 208>
| ResponseByCode<Responses, 226>
| ("default" extends keyof Responses ? Responses["default"] : never)
// ── Re-exports ──
export type ApiPaths = paths
export type ApiComponents = components
export type ApiOperations = operations
// ── Path & Method helpers ──
export type ApiPath = keyof paths
export type ApiMethod<Path extends ApiPath = ApiPath> = {
[Method in HttpMethod]: OperationFor<Path, Method> extends never ? never : Method
}[HttpMethod]
export type ApiPathByMethod<Method extends HttpMethod> = {
[Path in ApiPath]: OperationFor<Path, Method> extends never ? never : Path
}[ApiPath]
// ── Parameter helpers ──
export type ApiPathParams<Path extends ApiPath, Method extends HttpMethod> =
OperationFor<Path, Method> extends { parameters: { path: infer Params } }
? Params
: never
export type ApiQueryParams<Path extends ApiPath, Method extends HttpMethod> =
OperationFor<Path, Method> extends { parameters: { query: infer Params } }
? Params
: never
export type ApiHeaderParams<Path extends ApiPath, Method extends HttpMethod> =
OperationFor<Path, Method> extends { parameters: { header: infer Params } }
? Params
: never
export type ApiCookieParams<Path extends ApiPath, Method extends HttpMethod> =
OperationFor<Path, Method> extends { parameters: { cookie: infer Params } }
? Params
: never
// ── Request / Response body helpers ──
export type ApiRequestBody<Path extends ApiPath, Method extends HttpMethod> =
RequestContent<
WithContent<
OperationFor<Path, Method> extends { requestBody?: infer RequestBody }
? RequestBody
: never
>
>
export type ApiResponse<Path extends ApiPath, Method extends HttpMethod> =
JsonContent<
WithContent<
SuccessResponse<
OperationFor<Path, Method> extends { responses: infer Responses }
? Responses
: never
>
>
>
// ── Operation-level helpers ──
export type ApiOperationId = keyof operations
export type ApiOperationRequestBody<OperationId extends ApiOperationId> =
RequestContent<
WithContent<
operations[OperationId] extends { requestBody?: infer RequestBody }
? RequestBody
: never
>
>
export type ApiOperationResponse<OperationId extends ApiOperationId> =
JsonContent<
WithContent<
SuccessResponse<
operations[OperationId] extends { responses: infer Responses }
? Responses
: never
>
>
>

View File

@ -0,0 +1,24 @@
import "server-only"
import { cookies } from "next/headers"
import { createApi } from "./api"
import type { AuthUser } from "./infra/token"
export async function getServerApi() {
const cookieStore = await cookies()
const token = cookieStore.get("auth_token")?.value
return createApi(
token ? { headers: { Authorization: `Bearer ${token}` } } : undefined,
)
}
export async function getServerUser(): Promise<AuthUser | null> {
const cookieStore = await cookies()
const raw = cookieStore.get("auth_user")?.value
if (!raw) return null
try {
return JSON.parse(decodeURIComponent(raw)) as AuthUser
} catch {
return null
}
}

View File

@ -0,0 +1,11 @@
{
"extends": "@repo/typescript-config/base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": ".",
"baseUrl": ".",
"module": "ESNext",
"moduleResolution": "Bundler"
},
"include": ["src/**/*.ts", "types/**/*.ts"]
}

22335
packages/api/types/index.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,3 @@
# `@turbo/eslint-config`
Collection of internal eslint configurations.

View File

@ -0,0 +1,32 @@
import js from "@eslint/js";
import eslintConfigPrettier from "eslint-config-prettier";
import turboPlugin from "eslint-plugin-turbo";
import tseslint from "typescript-eslint";
import onlyWarn from "eslint-plugin-only-warn";
/**
* A shared ESLint configuration for the repository.
*
* @type {import("eslint").Linter.Config[]}
* */
export const config = [
js.configs.recommended,
eslintConfigPrettier,
...tseslint.configs.recommended,
{
plugins: {
turbo: turboPlugin,
},
rules: {
"turbo/no-undeclared-env-vars": "warn",
},
},
{
plugins: {
onlyWarn,
},
},
{
ignores: ["dist/**"],
},
];

View File

@ -0,0 +1,57 @@
import js from "@eslint/js";
import { globalIgnores } from "eslint/config";
import eslintConfigPrettier from "eslint-config-prettier";
import tseslint from "typescript-eslint";
import pluginReactHooks from "eslint-plugin-react-hooks";
import pluginReact from "eslint-plugin-react";
import globals from "globals";
import pluginNext from "@next/eslint-plugin-next";
import { config as baseConfig } from "./base.js";
/**
* A custom ESLint configuration for libraries that use Next.js.
*
* @type {import("eslint").Linter.Config[]}
* */
export const nextJsConfig = [
...baseConfig,
js.configs.recommended,
eslintConfigPrettier,
...tseslint.configs.recommended,
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
{
...pluginReact.configs.flat.recommended,
languageOptions: {
...pluginReact.configs.flat.recommended.languageOptions,
globals: {
...globals.serviceworker,
},
},
},
{
plugins: {
"@next/next": pluginNext,
},
rules: {
...pluginNext.configs.recommended.rules,
...pluginNext.configs["core-web-vitals"].rules,
},
},
{
plugins: {
"react-hooks": pluginReactHooks,
},
settings: { react: { version: "detect" } },
rules: {
...pluginReactHooks.configs.recommended.rules,
// React scope no longer necessary with new JSX transform.
"react/react-in-jsx-scope": "off",
},
},
];

View File

@ -0,0 +1,24 @@
{
"name": "@repo/eslint-config",
"version": "0.0.0",
"type": "module",
"private": true,
"exports": {
"./base": "./base.js",
"./next-js": "./next.js",
"./react-internal": "./react-internal.js"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@next/eslint-plugin-next": "^16.2.0",
"eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.1",
"eslint-plugin-only-warn": "^1.1.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-turbo": "^2.7.1",
"globals": "^16.5.0",
"typescript": "^5.9.2",
"typescript-eslint": "^8.50.0"
}
}

View File

@ -0,0 +1,39 @@
import js from "@eslint/js";
import eslintConfigPrettier from "eslint-config-prettier";
import tseslint from "typescript-eslint";
import pluginReactHooks from "eslint-plugin-react-hooks";
import pluginReact from "eslint-plugin-react";
import globals from "globals";
import { config as baseConfig } from "./base.js";
/**
* A custom ESLint configuration for libraries that use React.
*
* @type {import("eslint").Linter.Config[]} */
export const config = [
...baseConfig,
js.configs.recommended,
eslintConfigPrettier,
...tseslint.configs.recommended,
pluginReact.configs.flat.recommended,
{
languageOptions: {
...pluginReact.configs.flat.recommended.languageOptions,
globals: {
...globals.serviceworker,
...globals.browser,
},
},
},
{
plugins: {
"react-hooks": pluginReactHooks,
},
settings: { react: { version: "detect" } },
rules: {
...pluginReactHooks.configs.recommended.rules,
// React scope no longer necessary with new JSX transform.
"react/react-in-jsx-scope": "off",
},
},
];

View File

@ -0,0 +1,19 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"esModuleInterop": true,
"incremental": false,
"isolatedModules": true,
"lib": ["es2022", "DOM", "DOM.Iterable"],
"module": "NodeNext",
"moduleDetection": "force",
"moduleResolution": "NodeNext",
"noUncheckedIndexedAccess": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"strict": true,
"target": "ES2022"
}
}

View File

@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./base.json",
"compilerOptions": {
"plugins": [{ "name": "next" }],
"module": "ESNext",
"moduleResolution": "Bundler",
"allowJs": true,
"jsx": "preserve",
"noEmit": true
}
}

View File

@ -0,0 +1,9 @@
{
"name": "@repo/typescript-config",
"version": "0.0.0",
"private": true,
"license": "MIT",
"publishConfig": {
"access": "public"
}
}

View File

@ -0,0 +1,7 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./base.json",
"compilerOptions": {
"jsx": "react-jsx"
}
}

View File

@ -0,0 +1,4 @@
import { config } from "@repo/eslint-config/react-internal";
/** @type {import("eslint").Linter.Config} */
export default config;

26
packages/ui/package.json Normal file
View File

@ -0,0 +1,26 @@
{
"name": "@repo/ui",
"version": "0.0.0",
"private": true,
"exports": {
"./*": "./src/*.tsx"
},
"scripts": {
"lint": "eslint . --max-warnings 0",
"generate:component": "turbo gen react-component",
"check-types": "tsc --noEmit"
},
"devDependencies": {
"@repo/eslint-config": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@types/node": "^22.15.3",
"@types/react": "19.2.2",
"@types/react-dom": "19.2.2",
"eslint": "^9.39.1",
"typescript": "5.9.2"
},
"dependencies": {
"react": "^19.2.0",
"react-dom": "^19.2.0"
}
}

View File

@ -0,0 +1,20 @@
"use client";
import { ReactNode } from "react";
interface ButtonProps {
children: ReactNode;
className?: string;
appName: string;
}
export const Button = ({ children, className, appName }: ButtonProps) => {
return (
<button
className={className}
onClick={() => alert(`Hello from your ${appName} app!`)}
>
{children}
</button>
);
};

27
packages/ui/src/card.tsx Normal file
View File

@ -0,0 +1,27 @@
import { type JSX } from "react";
export function Card({
className,
title,
children,
href,
}: {
className?: string;
title: string;
children: React.ReactNode;
href: string;
}): JSX.Element {
return (
<a
className={className}
href={`${href}?utm_source=create-turbo&utm_medium=basic&utm_campaign=create-turbo"`}
rel="noopener noreferrer"
target="_blank"
>
<h2>
{title} <span>-&gt;</span>
</h2>
<p>{children}</p>
</a>
);
}

11
packages/ui/src/code.tsx Normal file
View File

@ -0,0 +1,11 @@
import { type JSX } from "react";
export function Code({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}): JSX.Element {
return <code className={className}>{children}</code>;
}

View File

@ -0,0 +1,9 @@
{
"extends": "@repo/typescript-config/react-library.json",
"compilerOptions": {
"outDir": "dist",
"strictNullChecks": true
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}

9926
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

3
pnpm-workspace.yaml Normal file
View File

@ -0,0 +1,3 @@
packages:
- "apps/*"
- "packages/*"

46
turbo.json Normal file
View File

@ -0,0 +1,46 @@
{
"$schema": "https://turborepo.dev/schema.json",
"ui": "tui",
"tasks": {
"build": {
"dependsOn": [
"^build"
],
"inputs": [
"$TURBO_DEFAULT$",
".env*"
],
"outputs": [
".next/**",
"!.next/cache/**",
"open-api/**",
"types/**"
]
},
"lint": {
"dependsOn": [
"^lint"
]
},
"check-types": {
"dependsOn": [
"^check-types"
]
},
"dev": {
"cache": false,
"persistent": true
},
"start": {
"cache": false,
"persistent": true
},
"test:e2e": {
"dependsOn": [
"build"
],
"cache": false
}
}
}