init
This commit is contained in:
commit
174cdd6323
182
.github/skills/crud-page/SKILL.md
vendored
Normal file
182
.github/skills/crud-page/SKILL.md
vendored
Normal 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
|
||||||
140
.github/skills/crud-page/references/api-client.md
vendored
Normal file
140
.github/skills/crud-page/references/api-client.md
vendored
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
234
.github/skills/crud-page/references/form.md
vendored
Normal file
234
.github/skills/crud-page/references/form.md
vendored
Normal file
@ -0,0 +1,234 @@
|
|||||||
|
# Form Reference
|
||||||
|
|
||||||
|
## File Location
|
||||||
|
|
||||||
|
`apps/dashboard/modules/<feature>/<feature>-form.tsx`
|
||||||
|
|
||||||
|
## Complete Template
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { AlertTriangle, Plus, Save } from "lucide-react"
|
||||||
|
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { Alert, AlertTitle } from "@/shared/components/ui/alert"
|
||||||
|
import { FieldGroup } from "@/shared/components/ui/field"
|
||||||
|
import {
|
||||||
|
Rhform,
|
||||||
|
RhfTextField,
|
||||||
|
RhfSelectField,
|
||||||
|
RhfAsyncSelectField,
|
||||||
|
// RhfTextareaField,
|
||||||
|
// RhfCheckboxField,
|
||||||
|
// RhfAsyncMultiSelectField,
|
||||||
|
} from "@/shared/components/form"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { useAuthApi } from "@/shared/useApi"
|
||||||
|
import { useResourceForm } from "@/shared/hooks/use-resource-form"
|
||||||
|
import { useFormMutation } from "@/shared/hooks/use-form-mutation"
|
||||||
|
import { toRelation, toId } from "@/shared/lib/utils"
|
||||||
|
|
||||||
|
import {
|
||||||
|
<feature>FormSchema,
|
||||||
|
type <Feature>FormValues,
|
||||||
|
} from "./<feature>.schema"
|
||||||
|
import { <RESOURCE>_ROUTES } from "@repo/api"
|
||||||
|
|
||||||
|
// ── Constants ──
|
||||||
|
|
||||||
|
// Static select options (if needed):
|
||||||
|
// const STATUS_OPTIONS = [
|
||||||
|
// { value: "active", label: "Active" },
|
||||||
|
// { value: "inactive", label: "Inactive" },
|
||||||
|
// ]
|
||||||
|
|
||||||
|
// ── Props ──
|
||||||
|
|
||||||
|
export type <Feature>FormProps = {
|
||||||
|
resourceId?: string | null
|
||||||
|
initialData?: unknown
|
||||||
|
onSuccess?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Default values ──
|
||||||
|
|
||||||
|
const DEFAULT_VALUES: <Feature>FormValues = {
|
||||||
|
// Match every field in the Zod schema:
|
||||||
|
// name: "",
|
||||||
|
// email: "",
|
||||||
|
// category: null, // relation fields default to null
|
||||||
|
// is_active: true, // booleans
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mapping helpers ──
|
||||||
|
|
||||||
|
function mapToFormValues(data: unknown): <Feature>FormValues {
|
||||||
|
const d = (data as any)?.data ?? data ?? {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// String fields:
|
||||||
|
// name: d.name || "",
|
||||||
|
// email: d.email || "",
|
||||||
|
|
||||||
|
// Relation fields (API returns id + name separately):
|
||||||
|
// category: toRelation(d.category_id, d.category_name),
|
||||||
|
|
||||||
|
// Booleans:
|
||||||
|
// is_active: d.is_active ?? true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapFormToPayload(values: <Feature>FormValues) {
|
||||||
|
return {
|
||||||
|
// String fields — use `|| undefined` to send null for empty strings:
|
||||||
|
// name: values.name,
|
||||||
|
// email: values.email || undefined,
|
||||||
|
|
||||||
|
// Relation fields — extract the numeric ID:
|
||||||
|
// category_id: toId(values.category),
|
||||||
|
|
||||||
|
// Booleans:
|
||||||
|
// is_active: values.is_active,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shared mapOption for async selects ──
|
||||||
|
|
||||||
|
const mapLookupOption = (item: any) => ({
|
||||||
|
value: String(item.id),
|
||||||
|
label: item.name,
|
||||||
|
})
|
||||||
|
|
||||||
|
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
|
||||||
|
|
||||||
|
// ── Component ──
|
||||||
|
|
||||||
|
export function <Feature>Form({ resourceId, initialData, onSuccess }: <Feature>FormProps) {
|
||||||
|
const api = useAuthApi()
|
||||||
|
|
||||||
|
const { form, isEditing } = useResourceForm<<Feature>FormValues, any>({
|
||||||
|
schema: <feature>FormSchema,
|
||||||
|
defaultValues: DEFAULT_VALUES,
|
||||||
|
resourceId,
|
||||||
|
initialData,
|
||||||
|
initialize: (id) => api.<camelResource>.show(id),
|
||||||
|
queryKey: [<RESOURCE>_ROUTES.BY_ID, resourceId],
|
||||||
|
mapToFormValues: mapToFormValues,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { mutate, error, isPending } = useFormMutation(form, {
|
||||||
|
mutationFn: (values: <Feature>FormValues) => {
|
||||||
|
const payload = mapFormToPayload(values)
|
||||||
|
const promise = isEditing && resourceId
|
||||||
|
? api.<camelResource>.update(resourceId, payload)
|
||||||
|
: api.<camelResource>.create(payload)
|
||||||
|
toast.promise(promise, {
|
||||||
|
loading: isEditing ? "Updating <resource>..." : "Creating <resource>...",
|
||||||
|
success: isEditing ? "<Resource> updated successfully" : "<Resource> created successfully",
|
||||||
|
error: isEditing ? "Failed to update <resource>" : "Failed to create <resource>",
|
||||||
|
})
|
||||||
|
return promise
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
form.reset()
|
||||||
|
onSuccess?.()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Rhform form={form} onSubmit={(values) => mutate(values)}>
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive" className="mb-4">
|
||||||
|
<AlertTriangle className="me-2 h-4 w-4" />
|
||||||
|
<AlertTitle>
|
||||||
|
{isEditing ? "Failed to update <resource>" : "Failed to create <resource>"}
|
||||||
|
</AlertTitle>
|
||||||
|
{error.message}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FieldGroup>
|
||||||
|
{/* Text fields */}
|
||||||
|
{/* <RhfTextField name="name" label="Name" placeholder="Enter name" required /> */}
|
||||||
|
|
||||||
|
{/* Grid layout for side-by-side fields */}
|
||||||
|
{/* <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<RhfTextField name="email" label="Email" type="email" />
|
||||||
|
<RhfTextField name="phone" label="Phone" type="tel" />
|
||||||
|
</div> */}
|
||||||
|
|
||||||
|
{/* Static select */}
|
||||||
|
{/* <RhfSelectField
|
||||||
|
name="status"
|
||||||
|
label="Status"
|
||||||
|
placeholder="Select status"
|
||||||
|
options={STATUS_OPTIONS}
|
||||||
|
/> */}
|
||||||
|
|
||||||
|
{/* Async select (fetches options from API) */}
|
||||||
|
{/* <RhfAsyncSelectField
|
||||||
|
name="category"
|
||||||
|
label="Category"
|
||||||
|
placeholder="Select category"
|
||||||
|
queryKey={["categories"]}
|
||||||
|
listFn={() => api.categories.list()}
|
||||||
|
mapOption={mapLookupOption}
|
||||||
|
{...STORE_OBJECT}
|
||||||
|
/> */}
|
||||||
|
|
||||||
|
{/* Textarea */}
|
||||||
|
{/* <RhfTextareaField name="notes" label="Notes" rows={4} /> */}
|
||||||
|
|
||||||
|
{/* Checkbox */}
|
||||||
|
{/* <RhfCheckboxField name="is_active" label="Active" /> */}
|
||||||
|
|
||||||
|
<Button type="submit" variant="default" disabled={isPending}>
|
||||||
|
{isEditing ? <Save /> : <Plus />}
|
||||||
|
{isPending
|
||||||
|
? (isEditing ? "Updating..." : "Creating...")
|
||||||
|
: (isEditing ? "Update <Resource>" : "Create <Resource>")}
|
||||||
|
</Button>
|
||||||
|
</FieldGroup>
|
||||||
|
</Rhform>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Patterns
|
||||||
|
|
||||||
|
### mapToFormValues
|
||||||
|
|
||||||
|
Transforms API response → form values. Always handle:
|
||||||
|
- Null safety: `d.field || ""`
|
||||||
|
- Relation fields: `toRelation(d.relation_id, d.relation_name)`
|
||||||
|
- Nested data: `(data as any)?.data ?? data ?? {}`
|
||||||
|
- Booleans: `d.field ?? defaultValue`
|
||||||
|
|
||||||
|
### mapFormToPayload
|
||||||
|
|
||||||
|
Transforms form values → API request body. Always handle:
|
||||||
|
- Empty strings to undefined: `values.field || undefined`
|
||||||
|
- Relation to ID: `toId(values.relation)`
|
||||||
|
- Keep required fields as-is: `values.name`
|
||||||
|
|
||||||
|
### useResourceForm
|
||||||
|
|
||||||
|
Initializes react-hook-form with Zod validation. Handles both create and edit modes:
|
||||||
|
- `resourceId` null → create mode (uses `defaultValues`)
|
||||||
|
- `resourceId` set → edit mode (fetches via `initialize`, maps with `mapToFormValues`)
|
||||||
|
|
||||||
|
### useFormMutation
|
||||||
|
|
||||||
|
Wraps `useMutation` with automatic Laravel validation error mapping to form fields. No need to manually handle `ApiError.validationErrors`.
|
||||||
|
|
||||||
|
### Toast Pattern
|
||||||
|
|
||||||
|
Always use `toast.promise()` wrapping the API call for consistent loading/success/error feedback.
|
||||||
|
|
||||||
|
## Layout Conventions
|
||||||
|
|
||||||
|
- Use `<FieldGroup>` to wrap all fields
|
||||||
|
- Use `<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">` for side-by-side fields
|
||||||
|
- Place the submit button at the bottom inside `<FieldGroup>`
|
||||||
|
- Show error alert above fields when mutation fails
|
||||||
225
.github/skills/crud-page/references/page.md
vendored
Normal file
225
.github/skills/crud-page/references/page.md
vendored
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
143
.github/skills/crud-page/references/schema.md
vendored
Normal file
143
.github/skills/crud-page/references/schema.md
vendored
Normal 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
38
.gitignore
vendored
Normal 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
|
||||||
11
.vscode/mcp.json
vendored
Normal file
11
.vscode/mcp.json
vendored
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"servers": {
|
||||||
|
"shadcn": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"shadcn@latest",
|
||||||
|
"mcp"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
Garage Management System.pdf
Normal file
BIN
Garage Management System.pdf
Normal file
Binary file not shown.
159
README.md
Normal file
159
README.md
Normal 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
1
apps/dashboard
Submodule
@ -0,0 +1 @@
|
|||||||
|
Subproject commit 078e5383bae07bf1eaddc6236f25b5b2e98c52f6
|
||||||
278
docs/dashboard/crud/data-fetching.md
Normal file
278
docs/dashboard/crud/data-fetching.md
Normal file
@ -0,0 +1,278 @@
|
|||||||
|
# Data Fetching
|
||||||
|
|
||||||
|
This document covers the full data-fetching stack: API client creation, authentication injection, query state management, and URL synchronization.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Layer Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
useAuthApi()
|
||||||
|
└─ createApi({ headers: { Authorization: "Bearer <token>" } })
|
||||||
|
└─ new CustomersClient(...) ← one per domain (CrudClient subclass)
|
||||||
|
└─ ApiClient ← openapi-fetch wrapper, type-safe from OpenAPI schema
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
useDataTableQuery({ queryKey, client, queryOptions })
|
||||||
|
└─ React Query useQuery
|
||||||
|
└─ client.list({ page, per_page, sort_by, sort_order })
|
||||||
|
└─ nuqs useQueryStates ← URL ↔ pagination & sort params
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `useAuthApi` — Authenticated API Factory
|
||||||
|
|
||||||
|
**File:** `shared/useApi.ts`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { useAuthApi } from "@/shared/useApi"
|
||||||
|
|
||||||
|
const api = useAuthApi()
|
||||||
|
// api.customers, api.vehicles, api.employees, …
|
||||||
|
```
|
||||||
|
|
||||||
|
Reads the JWT token from the `useAuthStore` Zustand store and passes it as the `Authorization: Bearer <token>` header. Called inside any component or hook that needs to make authenticated requests.
|
||||||
|
|
||||||
|
> **Note:** `createApi()` is called on every render. If performance is a concern, wrap in `useMemo` (see [Enhancement Plan](./enhancement-plan.md)).
|
||||||
|
|
||||||
|
### Server-Side Variant
|
||||||
|
|
||||||
|
For `async` server components or server actions:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { getAuthApi } from "@/shared/api"
|
||||||
|
|
||||||
|
const api = await getAuthApi() // reads token from cookies (Next.js server-side)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `CrudClient` — Generic CRUD Base Class
|
||||||
|
|
||||||
|
**File:** `packages/api/src/infra/crud-client.ts`
|
||||||
|
|
||||||
|
All domain clients extend `CrudClient`. It provides four standard operations:
|
||||||
|
|
||||||
|
| Method | HTTP | Endpoint |
|
||||||
|
|---|---|---|
|
||||||
|
| `list(query?)` | `GET` | `indexRoute` (e.g. `/api/customers`) |
|
||||||
|
| `create(payload)` | `POST` | `indexRoute` |
|
||||||
|
| `update(id, payload)` | `PUT` | `byIdRoute` (e.g. `/api/customers/{id}`) |
|
||||||
|
| `destroy(id)` | `DELETE` | `byIdRoute` |
|
||||||
|
|
||||||
|
All methods are **fully type-safe** — parameter types, request body shapes, and response types are all derived from the OpenAPI schema via `packages/api/types/index.ts`.
|
||||||
|
|
||||||
|
### Exported Type Utilities
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Extract the list response type from a client class
|
||||||
|
type CrudListResponse<C> // e.g. { data: Customer[], meta: { last_page, total, ... } }
|
||||||
|
|
||||||
|
// Extract a single item type from the list data array
|
||||||
|
type CrudListItem<C> // e.g. Customer
|
||||||
|
|
||||||
|
// Extract query params accepted by list()
|
||||||
|
type CrudListParams<C>
|
||||||
|
|
||||||
|
// Base interface: all list items have `id: number`
|
||||||
|
type BaseCrudItem = { id: number }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example: Creating a Domain Client
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// packages/api/src/clients/my-resource.ts
|
||||||
|
import { CrudClient } from "../infra/crud-client"
|
||||||
|
|
||||||
|
export const MY_ROUTES = {
|
||||||
|
INDEX: "/api/my-resources",
|
||||||
|
BY_ID: "/api/my-resources/{id}",
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export class MyResourceClient extends CrudClient<
|
||||||
|
typeof MY_ROUTES.INDEX,
|
||||||
|
typeof MY_ROUTES.BY_ID
|
||||||
|
> {
|
||||||
|
constructor(baseUrl?: string, options?: ApiClientOptions) {
|
||||||
|
super(baseUrl, options, MY_ROUTES.INDEX, MY_ROUTES.BY_ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add domain-specific endpoints here:
|
||||||
|
async listCategories() {
|
||||||
|
return this.get("/api/my-resource-categories")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then register it in `packages/api/src/api.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function createApi(options?: ApiClientOptions) {
|
||||||
|
return {
|
||||||
|
// ...
|
||||||
|
myResources: new MyResourceClient(undefined, options),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `ApiClient` — Low-Level HTTP Client
|
||||||
|
|
||||||
|
**File:** `packages/api/src/infra/client.ts`
|
||||||
|
|
||||||
|
Wraps `openapi-fetch`. All requests are typed against `paths` from `packages/api/types/index.ts`, which is generated from the OpenAPI schema.
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
Failed requests throw an `ApiError`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
class ApiError extends Error {
|
||||||
|
status: number // HTTP status code
|
||||||
|
statusText: string
|
||||||
|
endpoint: string
|
||||||
|
method: string
|
||||||
|
payload?: {
|
||||||
|
message?: string
|
||||||
|
errors?: Record<string, string[]> // Laravel validation errors
|
||||||
|
}
|
||||||
|
|
||||||
|
get validationErrors(): Record<string, string[]> | undefined
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `ApiError` in Form Context
|
||||||
|
|
||||||
|
In mutation `onError` handlers, check for validation errors and apply them to individual form fields:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
onError: (err) => {
|
||||||
|
if (err instanceof ApiError && err.validationErrors) {
|
||||||
|
Object.entries(err.validationErrors).forEach(([field, messages]) => {
|
||||||
|
form.setError(field as any, { message: messages[0] })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `useDataTableQuery` — Paginated List + URL State
|
||||||
|
|
||||||
|
**File:** `shared/data-view/table-view/use-data-table-query.ts`
|
||||||
|
|
||||||
|
Wraps React Query + `nuqs` to keep the table's pagination and sort state synchronized with the URL.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const tableQuery = useDataTableQuery({
|
||||||
|
queryKey: ["customers"], // React Query cache key prefix
|
||||||
|
client, // Any object with a .list(query?) method
|
||||||
|
queryOptions, // Optional React Query overrides (staleTime, etc.)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Returns
|
||||||
|
|
||||||
|
| Key | Description |
|
||||||
|
|---|---|
|
||||||
|
| `data` | The raw API response (`CrudListResponse<C>`) |
|
||||||
|
| `isLoading` | True during initial fetch |
|
||||||
|
| `pagination` | `{ page, pageSize, pageCount: 1, total: 0 }` — pageCount/total come from `data.meta` |
|
||||||
|
| `sorting` | `SortingState` derived from URL params |
|
||||||
|
| `params` | Raw parsed URL params (`page`, `per_page`, `sort_by`, `sort_order`) |
|
||||||
|
| `setParams` | Direct URL param setter |
|
||||||
|
| `handleChange` | Normalized event handler for `DataTable` (see below) |
|
||||||
|
| `invalidateQuery` | Busts the cache (called after mutations) |
|
||||||
|
|
||||||
|
### URL Query Parameters
|
||||||
|
|
||||||
|
| Param | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `page` | `1` | Current page (1-based) |
|
||||||
|
| `per_page` | `10` | Rows per page |
|
||||||
|
| `sort_by` | `null` | Column `accessorKey` to sort by |
|
||||||
|
| `sort_order` | `null` | `"asc"` or `"desc"` |
|
||||||
|
|
||||||
|
### `handleChange` Event Types
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Triggered by DataViewPagination (page navigation, rows per page)
|
||||||
|
{ type: "pagination", pagination: { page, pageSize, ... } }
|
||||||
|
|
||||||
|
// Triggered by ColumnHeader sort dropdown
|
||||||
|
{ type: "sorting", sorting: [{ id: "email", desc: false }] }
|
||||||
|
// → resets page to 1 automatically
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `DataTable` — Table UI
|
||||||
|
|
||||||
|
**File:** `shared/data-view/table-view/data-table.tsx`
|
||||||
|
|
||||||
|
Thin wrapper around TanStack Table v8 with manual server-side pagination and sorting:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={data}
|
||||||
|
pagination={{ page, pageSize, pageCount, total }}
|
||||||
|
sorting={sorting}
|
||||||
|
onChange={handleChange}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
While `isLoading` is `true`, the table renders `pageSize` skeleton rows instead of data.
|
||||||
|
|
||||||
|
### `ColumnHeader` — Sortable Column Header
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { ColumnHeader } from "@/shared/data-view/table-view"
|
||||||
|
|
||||||
|
{
|
||||||
|
accessorKey: "email",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Email" />,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Renders a sort dropdown (Asc / Desc / Clear) if the column `canSort`. Shows a plain `<span>` otherwise.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Auth Store
|
||||||
|
|
||||||
|
**File:** `shared/stores/auth-store.ts`
|
||||||
|
|
||||||
|
A Zustand store that holds the authenticated user state:
|
||||||
|
|
||||||
|
| Key | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `token` | `string \| undefined` | JWT access token |
|
||||||
|
| `user` | `AuthUser \| undefined` | Authenticated user profile |
|
||||||
|
| `isAuthenticated` | `boolean` | True when token + user are set |
|
||||||
|
| `login(token, user, expiresIn?)` | fn | Persists to cookie + sets store |
|
||||||
|
| `logout()` | fn | Calls API logout, clears cookie + store |
|
||||||
|
| `hydrate()` | fn | Reads cookies on app boot (call in root layout) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Type System — OpenAPI-Derived Types
|
||||||
|
|
||||||
|
The entire API type surface is generated from `packages/api/open-api/schema.yaml` via scripts in `packages/api/scripts/`. The generated output is `packages/api/types/index.ts`.
|
||||||
|
|
||||||
|
Key exported type helpers from `packages/api/src/infra/types.ts`:
|
||||||
|
|
||||||
|
| Type | Description |
|
||||||
|
|---|---|
|
||||||
|
| `ApiPath` | Union of all known API paths |
|
||||||
|
| `ApiPathByMethod<M>` | All paths that support HTTP method `M` |
|
||||||
|
| `ApiQueryParams<Path, Method>` | Query parameter shape for a given path+method |
|
||||||
|
| `ApiRequestBody<Path, Method>` | Request body shape |
|
||||||
|
| `ApiResponse<Path, Method>` | Successful response shape |
|
||||||
|
| `ApiPathParams<Path, Method>` | URL path parameters (e.g. `{ id: string }`) |
|
||||||
|
|
||||||
|
These types flow through `CrudClient` → `useDataTableQuery` → `ResourcePage` → feature page, providing end-to-end type safety without any manual typing.
|
||||||
225
docs/dashboard/crud/enhancement-plan.md
Normal file
225
docs/dashboard/crud/enhancement-plan.md
Normal file
@ -0,0 +1,225 @@
|
|||||||
|
# Enhancement Plan
|
||||||
|
|
||||||
|
This document identifies current gaps and improvement opportunities in the CRUD flow. Items are grouped by priority and annotated with implementation effort.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The current CRUD pattern is clean, composable, and type-safe. Most issues are edge cases or developer-experience refinements rather than critical bugs. The highest-priority items are the ones marked **High**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## High Priority
|
||||||
|
|
||||||
|
### 1. `useAuthApi` — Missing Memoization
|
||||||
|
|
||||||
|
**File:** `shared/useApi.ts`
|
||||||
|
|
||||||
|
**Problem:** `createApi()` is called on every render. Every component that calls `useAuthApi()` creates new `ApiClient` instances per render cycle. This can cause unintended React Query cache misses or stale closures in edge cases.
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// shared/useApi.ts
|
||||||
|
import { useMemo } from "react"
|
||||||
|
|
||||||
|
export const useAuthApi = () => {
|
||||||
|
const token = useAuthStore(s => s.token)
|
||||||
|
return useMemo(
|
||||||
|
() => createApi({ headers: token ? { Authorization: `Bearer ${token}` } : undefined }),
|
||||||
|
[token],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Effort:** XS (2 lines) — no API changes required.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Edit Mode — No Server Re-fetch on Dialog Open
|
||||||
|
|
||||||
|
**File:** `shared/hooks/use-resource-form.ts`, `modules/customers/customer-form.tsx`
|
||||||
|
|
||||||
|
**Problem:** When the "Edit" button is clicked, `useResourcePage` passes `selectedItem` (the in-memory table row) as `initialData`. The form is pre-populated from this snapshot. However:
|
||||||
|
|
||||||
|
- The snapshot may be stale if the item was modified elsewhere.
|
||||||
|
- On page refresh with `?dialog=true&resourceId=5` in the URL, `selectedItem` is `null` (not hydrated) → the form opens empty.
|
||||||
|
|
||||||
|
**Fix:** Add an `initialize` function to each feature form that fetches the full resource by ID:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// In CustomerForm (use-resource-form options)
|
||||||
|
initialize: (id) => api.customers.show(id), // requires CrudClient.show()
|
||||||
|
queryKey: [CUSTOMER_ROUTES.BY_ID, resourceId],
|
||||||
|
|
||||||
|
// In CrudClient (packages/api/src/infra/crud-client.ts)
|
||||||
|
async show(id: string) {
|
||||||
|
return this.get(this.byIdRoute, { params: { id } } as never)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Effort:** S — needs `CrudClient.show()` added and each form updated to pass `initialize`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. `FormDialog` — Single Dialog Limitation
|
||||||
|
|
||||||
|
**File:** `shared/components/form-dialog.tsx`
|
||||||
|
|
||||||
|
**Problem:** Dialog state is keyed to fixed URL params `dialog` and `resourceId`. If two independent `FormDialog` instances are on the same page (e.g., a main resource form and a nested "Add Customer" side panel), they share the same URL params and will conflict.
|
||||||
|
|
||||||
|
**Fix:** Accept a configurable `paramKey` prop:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const createFormDialogParams = (key: string) => ({
|
||||||
|
[`${key}_dialog`]: parseAsBoolean.withDefault(false),
|
||||||
|
[`${key}_resourceId`]: parseAsString,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Effort:** S — requires updating `FormDialog`, `useFormDialog`, and `useResourcePage` to accept a `paramKey`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Medium Priority
|
||||||
|
|
||||||
|
### 4. No Global Search / Filter Support
|
||||||
|
|
||||||
|
**File:** `shared/data-view/table-view/use-data-table-query.ts`, `shared/data-view/table-view/search-params.ts`
|
||||||
|
|
||||||
|
**Problem:** `dataTableSearchParams` only supports `page`, `per_page`, `sort_by`, `sort_order`. There is no standard way to add resource-specific filters (e.g., search by name, filter by status).
|
||||||
|
|
||||||
|
**Proposed:** Add an optional `filters` object to `useDataTableQuery` that maps to additional query params:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
useDataTableQuery({
|
||||||
|
queryKey: ["customers"],
|
||||||
|
client,
|
||||||
|
filters: {
|
||||||
|
search: parseAsString,
|
||||||
|
status: parseAsStringEnum(["active", "inactive"] as const),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
// → adds ?search=&status= params and includes them in client.list()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Effort:** M — requires a design decision and updates to `use-data-table-query`, `data-table.tsx`, and `resource-page`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Grid View — Not Implemented
|
||||||
|
|
||||||
|
**File:** `shared/data-view/grid-view/` (empty directory)
|
||||||
|
|
||||||
|
**Problem:** The `grid-view` folder was scaffolded but never implemented. The data-view layer is clearly designed to support multiple views (table/grid), but no toggle exists.
|
||||||
|
|
||||||
|
**Proposed:**
|
||||||
|
- Implement a `GridView` component that accepts the same `DataViewProps` as `DataTable`.
|
||||||
|
- Add a view toggle (Table | Grid) in the `ResourcePage` header or `DashboardHeader`.
|
||||||
|
- Persist the selected view in a URL param (`?view=grid`).
|
||||||
|
|
||||||
|
**Effort:** M–L depending on grid card design requirements.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. `useMutation` Error Handling — Not Reusable
|
||||||
|
|
||||||
|
**File:** `modules/customers/customer-form.tsx` (and all other feature forms)
|
||||||
|
|
||||||
|
**Problem:** The pattern of mapping `ApiError.validationErrors` to `form.setError` is duplicated in every form's `onError` handler.
|
||||||
|
|
||||||
|
**Fix:** Extract a `useFormMutation` hook:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// shared/hooks/use-form-mutation.ts
|
||||||
|
export function useFormMutation<TValues extends FieldValues, TResponse = unknown>(
|
||||||
|
form: UseFormReturn<TValues>,
|
||||||
|
options: UseMutationOptions<TResponse, Error, TValues>,
|
||||||
|
) {
|
||||||
|
return useMutation({
|
||||||
|
...options,
|
||||||
|
onError: (err, vars, ctx) => {
|
||||||
|
if (err instanceof ApiError && err.validationErrors) {
|
||||||
|
Object.entries(err.validationErrors).forEach(([field, msgs]) => {
|
||||||
|
form.setError(field as keyof TValues as any, { message: msgs[0] })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
options.onError?.(err, vars, ctx)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Effort:** XS — purely additive. Existing forms can be migrated incrementally.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. `CUSTOMER_CREATED_EVENT` — Unused Custom Event
|
||||||
|
|
||||||
|
**File:** `modules/customers/customer-form.tsx`
|
||||||
|
|
||||||
|
**Problem:** `CustomerForm` dispatches `window.dispatchEvent(new CustomEvent("customer:created"))` on success, but nothing in the codebase listens to this event. Cache invalidation is already handled via `onSuccess` → `invalidateQuery()`. The event dispatch is dead code.
|
||||||
|
|
||||||
|
**Fix:** Remove the event dispatch from `CustomerForm` (and the `CUSTOMER_CREATED_EVENT` export) unless there is a known future use case, such as notifying a sibling component outside the React tree.
|
||||||
|
|
||||||
|
**Effort:** XS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8. Pagination Meta Split — Inconsistency
|
||||||
|
|
||||||
|
**File:** `shared/data-view/table-view/use-data-table-query.ts`
|
||||||
|
|
||||||
|
**Problem:** `useDataTableQuery` returns `pagination` with `pageCount: 1, total: 0` as placeholders. The real values come from `data.meta` and are calculated inside `ResourcePage.tsx`. This means:
|
||||||
|
|
||||||
|
- `useResourcePage` consumers who render the table directly (outside `ResourcePage`) need to duplicate the `pageCount`/`total` derivation.
|
||||||
|
- The `pagination` object returned by the hook is misleading until data loads.
|
||||||
|
|
||||||
|
**Fix:** Either move the meta derivation inside `useDataTableQuery` (requiring it to accept a response shape hint), or document this as an intentional split and annotate it.
|
||||||
|
|
||||||
|
**Effort:** XS–S.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Low Priority / Nice to Have
|
||||||
|
|
||||||
|
### 9. Row Selection for Bulk Actions
|
||||||
|
|
||||||
|
There is no row selection or bulk-delete support. TanStack Table supports `rowSelection` state natively. Adding a checkbox column and a "Delete selected" toolbar would benefit resource-heavy pages.
|
||||||
|
|
||||||
|
### 10. Error Boundary Around Table and Form
|
||||||
|
|
||||||
|
If a render error occurs inside `DataTable` or a feature form, it will bubble up and crash the whole page. Wrapping with `<ErrorBoundary>` (e.g., via `react-error-boundary`) would improve resilience.
|
||||||
|
|
||||||
|
### 11. `ConfirmDialog` — Not Enforced in Layout
|
||||||
|
|
||||||
|
**Problem:** `<ConfirmDialog />` must be manually mounted in `app/(authenticated)/layout.tsx`. There is no lint rule or runtime warning if it is missing. If a developer forgets to add it to a new layout, `confirm()` calls will silently resolve to `false` (no dialog shown, deletion blocked).
|
||||||
|
|
||||||
|
**Fix:** Add a development-mode warning inside the `confirm()` function if the store's `resolve` is never set after a timeout.
|
||||||
|
|
||||||
|
### 12. Column Visibility / Hide
|
||||||
|
|
||||||
|
`ColumnHeader` has a "Hide" dropdown menu item (via `column.toggleVisibility(false)`), but there is no global "Show columns" control to restore hidden columns. Either remove the hide option or add a column visibility popup to `DataTable`.
|
||||||
|
|
||||||
|
### 13. `dataTableSearchParamsCache` — Imported but Unused in App Router
|
||||||
|
|
||||||
|
`dataTableSearchParamsCache` is exported but the pages use `"use client"` throughout. If server components are introduced for any list page, the cache needs wiring in the layout via `nuqs`'s `SearchParamsProvider`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [x] #1 — Memoize `useAuthApi`
|
||||||
|
- [x] #2 — Add `CrudClient.show()` and wire `initialize` in feature forms
|
||||||
|
- [x] #3 — Make `FormDialog` param key configurable
|
||||||
|
- [ ] #4 — Design and implement filter/search param support in `useDataTableQuery`
|
||||||
|
- [ ] #5 — Implement `GridView` and view toggle
|
||||||
|
- [x] #6 — Extract `useFormMutation` hook
|
||||||
|
- [x] #7 — Remove unused `CUSTOMER_CREATED_EVENT`
|
||||||
|
- [x] #8 — Move pagination meta derivation into `useDataTableQuery`
|
||||||
|
- [ ] #9 — Row selection + bulk actions
|
||||||
|
- [ ] #10 — Error boundaries
|
||||||
|
- [x] #11 — Dev-mode warning for missing `ConfirmDialog`
|
||||||
|
- [ ] #12 — Column visibility restore control
|
||||||
|
- [ ] #13 — Wire `SearchParamsProvider` if server components are adopted
|
||||||
308
docs/dashboard/crud/form-system.md
Normal file
308
docs/dashboard/crud/form-system.md
Normal file
@ -0,0 +1,308 @@
|
|||||||
|
# Form System
|
||||||
|
|
||||||
|
This document covers the generic form infrastructure used by all resource forms in the dashboard.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Layer Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
<CustomerForm> ← Feature-specific form component
|
||||||
|
└─ useResourceForm(...) ← State: RHF form + optional fetch for edit mode
|
||||||
|
└─ useMutation(...) ← Create or update mutation with toast
|
||||||
|
└─ <Rhform form onSubmit> ← FormProvider + <form> wrapper
|
||||||
|
└─ <RhfTextField> ← RHF-connected text input
|
||||||
|
└─ <RhfSelectField> ← RHF-connected static select
|
||||||
|
└─ <RhfAsyncSelectField> ← RHF-connected async combobox (fetches options)
|
||||||
|
└─ <Button type="submit">
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `useResourceForm` — Form Initialization Hook
|
||||||
|
|
||||||
|
**File:** `shared/hooks/use-resource-form.ts`
|
||||||
|
|
||||||
|
Manages the react-hook-form instance with Zod validation and handles pre-filling the form when editing an existing item.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const { form, isEditing, isInitializing } = useResourceForm<TFormValues, TApiData>({
|
||||||
|
schema, // Zod schema → resolver
|
||||||
|
defaultValues, // Default form values for create mode
|
||||||
|
resourceId, // null → create, "5" → edit
|
||||||
|
initialData, // Optional: pre-fetched data (e.g. from table row)
|
||||||
|
mapToFormValues, // Maps API data shape → form values shape
|
||||||
|
initialize, // Optional: fetch fn called when resourceId is set (re-fetch from server)
|
||||||
|
queryKey, // Optional: React Query key for the initialize query
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Behavior
|
||||||
|
|
||||||
|
| Scenario | Result |
|
||||||
|
|---|---|
|
||||||
|
| `resourceId` is null | `isEditing = false`, form uses `defaultValues` |
|
||||||
|
| `resourceId` set, `initialData` provided, no `initialize` | Form pre-filled from `initialData` |
|
||||||
|
| `resourceId` set, `initialize` provided | `useQuery` calls `initialize(resourceId)`; form pre-filled from response |
|
||||||
|
| `resourceId` set, both provided | `useQuery` result takes precedence over `initialData` |
|
||||||
|
|
||||||
|
### `mapToFormValues`
|
||||||
|
|
||||||
|
Transforms the API response shape into the form's internal value shape. Field names, null handling, and relation objects are all resolved here:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
function mapCustomerToFormValues(data: unknown): CustomerFormValues {
|
||||||
|
const c = (data as any)?.data ?? data ?? {}
|
||||||
|
return {
|
||||||
|
first_name: c.first_name || "",
|
||||||
|
customer_type: toRelation(c.customer_type_id, c.customer_type_name),
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `toRelation` / `toId` Helpers
|
||||||
|
|
||||||
|
**File:** `shared/lib/utils.ts`
|
||||||
|
|
||||||
|
Relation fields (foreign keys) are stored in the form as `{ value: string, label: string } | null` objects (combobox-compatible), not raw IDs.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// API data → form object
|
||||||
|
toRelation(id, name) // → { value: String(id), label: name } or null
|
||||||
|
|
||||||
|
// Form object → API payload
|
||||||
|
toId(relation) // → relation?.value ?? null
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `Rhform` — Form Provider Wrapper
|
||||||
|
|
||||||
|
**File:** `shared/components/form/rhform.tsx`
|
||||||
|
|
||||||
|
Wraps `react-hook-form`'s `FormProvider` and a `<form>` element. Avoids passing the `form` instance manually through every field.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Rhform form={form} onSubmit={handleSubmit}>
|
||||||
|
{/* children have access to form context via useFormContext */}
|
||||||
|
</Rhform>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `RhfField` — Generic RHF Controller Connector
|
||||||
|
|
||||||
|
**File:** `shared/components/form/rhf-field.tsx`
|
||||||
|
|
||||||
|
Low-level generic component that connects any field control to react-hook-form. Used internally by all `Rhf*` field wrappers. You rarely need to use this directly.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<RhfField
|
||||||
|
name="email"
|
||||||
|
label="Email"
|
||||||
|
required
|
||||||
|
component={TextInputField} // Any BaseFieldControlProps-compatible control
|
||||||
|
placeholder="john@example.com"
|
||||||
|
type="email"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `FieldShell` — Label + Error Layout
|
||||||
|
|
||||||
|
**File:** `shared/components/form/field-shell.tsx`
|
||||||
|
|
||||||
|
Renders the `FieldLabel`, `FieldDescription`, and `FieldError` around a control. Used by `RhfField` and `RhfAsyncSelectField` directly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ready-Made `Rhf*` Field Wrappers
|
||||||
|
|
||||||
|
All wrappers follow the same pattern: they accept `name`, `label`, `description`, `required`, `disabled` plus any control-specific props.
|
||||||
|
|
||||||
|
### `RhfTextField`
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<RhfTextField name="first_name" label="First Name" placeholder="John" required />
|
||||||
|
<RhfTextField name="email" label="Email" type="email" />
|
||||||
|
<RhfTextField name="phone" label="Phone" type="tel" />
|
||||||
|
```
|
||||||
|
|
||||||
|
### `RhfTextareaField`
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<RhfTextareaField name="notes" label="Notes" rows={4} />
|
||||||
|
```
|
||||||
|
|
||||||
|
### `RhfCheckboxField`
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<RhfCheckboxField name="is_active" label="Active" />
|
||||||
|
```
|
||||||
|
|
||||||
|
### `RhfSelectField` — Static Options
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const options = [
|
||||||
|
{ value: "Mr.", label: "Mr." },
|
||||||
|
{ value: "Mrs.", label: "Mrs." },
|
||||||
|
]
|
||||||
|
|
||||||
|
<RhfSelectField name="salutation" label="Salutation" options={options} />
|
||||||
|
```
|
||||||
|
|
||||||
|
### `RhfAsyncSelectField` — Remote Options (Single)
|
||||||
|
|
||||||
|
Fetches options via React Query and renders a searchable combobox.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<RhfAsyncSelectField
|
||||||
|
name="customer_type"
|
||||||
|
label="Customer Type"
|
||||||
|
placeholder="Select customer type"
|
||||||
|
queryKey={["customer-types"]}
|
||||||
|
listFn={() => api.customers.listCustomerTypes()}
|
||||||
|
mapOption={(item) => ({ value: String(item.id), label: item.name })}
|
||||||
|
getOptionValue={(o) => o} // store the full object, not just the string value
|
||||||
|
getOptionLabel={(o) => o.label}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Data source options** (choose one):
|
||||||
|
|
||||||
|
| Prop | Description |
|
||||||
|
|---|---|
|
||||||
|
| `listFn` | Calls an API method; response is unwrapped automatically via `extractItems` |
|
||||||
|
| `loadOptions` | Returns `Promise<any[]>` directly (custom logic) |
|
||||||
|
|
||||||
|
The `mapOption` prop transforms raw API items to `{ value, label }` objects.
|
||||||
|
|
||||||
|
**`staleTime`** defaults to 5 minutes. Override for highly dynamic lookups.
|
||||||
|
|
||||||
|
### `RhfAsyncMultiSelectField` — Remote Options (Multi)
|
||||||
|
|
||||||
|
Same as `RhfAsyncSelectField` but stores an array of values:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<RhfAsyncMultiSelectField
|
||||||
|
name="tags"
|
||||||
|
multiple
|
||||||
|
label="Tags"
|
||||||
|
queryKey={["tags"]}
|
||||||
|
listFn={() => api.tags.list()}
|
||||||
|
mapOption={(item) => ({ value: String(item.id), label: item.name })}
|
||||||
|
getOptionValue={(o) => o}
|
||||||
|
getOptionLabel={(o) => o.label}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Anatomy of a Feature Form
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// modules/my-feature/my-feature-form.tsx
|
||||||
|
|
||||||
|
const DEFAULT_VALUES: MyFormValues = { name: "", ... }
|
||||||
|
|
||||||
|
function mapToFormValues(data: unknown): MyFormValues { ... }
|
||||||
|
function mapFormToPayload(values: MyFormValues) { ... }
|
||||||
|
|
||||||
|
export function MyFeatureForm({ resourceId, initialData, onSuccess }: ResourceFormProps) {
|
||||||
|
const api = useAuthApi()
|
||||||
|
|
||||||
|
// 1. Form initialization
|
||||||
|
const { form, isEditing } = useResourceForm<MyFormValues>({
|
||||||
|
schema: myFormSchema,
|
||||||
|
defaultValues: DEFAULT_VALUES,
|
||||||
|
resourceId,
|
||||||
|
initialData,
|
||||||
|
mapToFormValues,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 2. Mutation
|
||||||
|
const { mutate, error, isPending } = useMutation({
|
||||||
|
mutationFn: (values: MyFormValues) => {
|
||||||
|
const payload = mapFormToPayload(values)
|
||||||
|
const promise = isEditing
|
||||||
|
? api.myResources.update(resourceId!, payload)
|
||||||
|
: api.myResources.create(payload)
|
||||||
|
toast.promise(promise, { loading: "Saving...", success: "Saved!", error: "Failed." })
|
||||||
|
return promise
|
||||||
|
},
|
||||||
|
onSuccess: () => { form.reset(); onSuccess?.() },
|
||||||
|
onError: (err) => {
|
||||||
|
if (err instanceof ApiError && err.validationErrors) {
|
||||||
|
Object.entries(err.validationErrors).forEach(([field, msgs]) => {
|
||||||
|
form.setError(field as any, { message: msgs[0] })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 3. Render
|
||||||
|
return (
|
||||||
|
<Rhform form={form} onSubmit={(v) => mutate(v)}>
|
||||||
|
{error && <Alert variant="destructive">...</Alert>}
|
||||||
|
<FieldGroup>
|
||||||
|
<RhfTextField name="name" label="Name" required />
|
||||||
|
<Button type="submit" disabled={isPending}>
|
||||||
|
{isPending ? "Saving..." : isEditing ? "Update" : "Create"}
|
||||||
|
</Button>
|
||||||
|
</FieldGroup>
|
||||||
|
</Rhform>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Zod Schema Conventions
|
||||||
|
|
||||||
|
**File:** `modules/<feature>/<feature>.schema.ts`
|
||||||
|
|
||||||
|
### Relation Fields
|
||||||
|
|
||||||
|
Relation fields (foreign-key selects) use a shared `relationFieldSchema`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
const relationFieldSchema = z
|
||||||
|
.object({ value: z.string(), label: z.string() })
|
||||||
|
.nullable()
|
||||||
|
|
||||||
|
// In the schema:
|
||||||
|
const mySchema = z.object({
|
||||||
|
category: relationFieldSchema, // → { value: "3", label: "Electronics" } | null
|
||||||
|
name: z.string().min(1, "Name is required"),
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Email Validation Pattern
|
||||||
|
|
||||||
|
Use union to allow empty strings:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
email: z.union([
|
||||||
|
z.string().email("Enter a valid email address"),
|
||||||
|
z.literal(""),
|
||||||
|
]).optional(),
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `extractItems` — Response Unwrapper
|
||||||
|
|
||||||
|
Used internally by `RhfAsyncSelectField` to normalize different API response shapes:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Handles all of:
|
||||||
|
extractItems([{ id: 1, name: "A" }]) // → same array
|
||||||
|
extractItems({ data: [{ id: 1, name: "A" }] }) // → data array
|
||||||
|
extractItems({ data: { data: [{ id: 1, name: "A" }] } }) // → nested data
|
||||||
|
```
|
||||||
|
|
||||||
|
This handles both plain arrays, standard Laravel list responses, and nested pagination wrappers.
|
||||||
128
docs/dashboard/crud/overview.md
Normal file
128
docs/dashboard/crud/overview.md
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
# CRUD Pattern — Overview
|
||||||
|
|
||||||
|
This document describes the full-stack CRUD pattern used across the dashboard. Every resource page (Customers, Vendors, Employees, etc.) is built from the same thin layer of generic, composable utilities.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Layers
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Feature Page (apps/dashboard/app/…/page.tsx) │
|
||||||
|
│ • Declares columns, title, routeKey, getClient, renderForm │
|
||||||
|
└──────────────────┬───────────────────────────────────────────────┘
|
||||||
|
│ uses
|
||||||
|
┌──────────────────▼───────────────────────────────────────────────┐
|
||||||
|
│ ResourcePage (shared/data-view/resource-page) │
|
||||||
|
│ • Combines DashboardPage + FormDialog + DataTable │
|
||||||
|
│ • Delegates state to useResourcePage │
|
||||||
|
└──────┬───────────────────────────────┬────────────────────────────┘
|
||||||
|
│ │
|
||||||
|
┌──────▼──────────────┐ ┌────────────▼───────────────────────────┐
|
||||||
|
│ useResourcePage │ │ DataTable (shared/data-view/table-view)│
|
||||||
|
│ • useDataTableQuery │ │ • TanStack Table (manual mode) │
|
||||||
|
│ • form dialog state │ │ • Pagination, sorting, skeleton │
|
||||||
|
│ • delete mutation │ │ • DataViewProvider context │
|
||||||
|
└──────┬──────────────┘ └────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌──────▼──────────────────────────────────────────────────────────┐
|
||||||
|
│ useAuthApi → createApi() → CrudClient → openapi-fetch │
|
||||||
|
│ (packages/api — fully type-safe from OpenAPI schema) │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌──────▼──────────────────────────────────────────────────────────┐
|
||||||
|
│ Feature Form (modules/<feature>/<feature>-form.tsx) │
|
||||||
|
│ • useResourceForm (react-hook-form + Zod + optional fetch) │
|
||||||
|
│ • useMutation (create / update) │
|
||||||
|
│ • RhfTextField / RhfSelectField / RhfAsyncSelectField … │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Files
|
||||||
|
|
||||||
|
| Layer | File | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| Feature page | `app/(authenticated)/sales/customers/page.tsx` | Minimal feature config |
|
||||||
|
| Generic page shell | `shared/data-view/resource-page/resource-page.tsx` | Layout + wiring |
|
||||||
|
| Page logic hook | `shared/data-view/resource-page/use-resource-page.ts` | State + mutations |
|
||||||
|
| Table | `shared/data-view/table-view/data-table.tsx` | TanStack Table UI |
|
||||||
|
| Table data hook | `shared/data-view/table-view/use-data-table-query.ts` | React Query + URL state |
|
||||||
|
| Form dialog | `shared/components/form-dialog.tsx` | URL-driven dialog trigger |
|
||||||
|
| Confirm dialog | `shared/components/confirm-dialog.tsx` | Imperative async confirm |
|
||||||
|
| Form state hook | `shared/hooks/use-resource-form.ts` | RHF + Zod + optional re-fetch |
|
||||||
|
| Form field wrappers | `shared/components/form/` | RhfTextField, RhfSelectField, … |
|
||||||
|
| API layer | `packages/api/src/infra/crud-client.ts` | Generic CRUD HTTP client |
|
||||||
|
| Auth API hook | `shared/useApi.ts` | Creates authenticated API instance |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Flow — Read (LIST)
|
||||||
|
|
||||||
|
```
|
||||||
|
URL params change (page / sort)
|
||||||
|
→ useDataTableQuery re-runs queryFn
|
||||||
|
→ client.list({ page, per_page, sort_by, sort_order })
|
||||||
|
→ CrudClient.list()
|
||||||
|
→ ApiClient.get(indexRoute, { query })
|
||||||
|
→ openapi-fetch GET /api/customers
|
||||||
|
← { data: [...], meta: { last_page, total, ... } }
|
||||||
|
→ DataTable renders rows + pagination
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Flow — Write (CREATE / UPDATE)
|
||||||
|
|
||||||
|
```
|
||||||
|
User fills form → submits
|
||||||
|
→ useMutation mutationFn
|
||||||
|
→ api.customers.create(payload) or .update(id, payload)
|
||||||
|
→ CrudClient.create() / .update()
|
||||||
|
→ ApiClient.post() / .put()
|
||||||
|
→ openapi-fetch POST|PUT /api/customers[/{id}]
|
||||||
|
← 200/201 response
|
||||||
|
→ onSuccess: invalidateQuery() → list refreshes
|
||||||
|
→ onError: ApiError.validationErrors → form.setError(field, msg)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Flow — Delete
|
||||||
|
|
||||||
|
```
|
||||||
|
User clicks Delete in actions column
|
||||||
|
→ confirm({ title, description, variant: "destructive" }) → awaits boolean
|
||||||
|
→ if confirmed: deleteItem(id)
|
||||||
|
→ useMutation → client.destroy(id)
|
||||||
|
→ ApiClient.delete()
|
||||||
|
← success
|
||||||
|
→ invalidateQuery()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Creating a New Feature Page
|
||||||
|
|
||||||
|
To add a new resource page following this pattern:
|
||||||
|
|
||||||
|
1. Create schema: `modules/<feature>/<feature>.schema.ts`
|
||||||
|
2. Create form: `modules/<feature>/<feature>-form.tsx`
|
||||||
|
3. Create page: `app/(authenticated)/<section>/<feature>/page.tsx`
|
||||||
|
|
||||||
|
The page needs only 5 props on `<ResourcePage>`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<ResourcePage<MyClient>
|
||||||
|
title="My Resource"
|
||||||
|
pageTitle="My Resources"
|
||||||
|
routeKey={MY_ROUTES.INDEX}
|
||||||
|
getClient={(api) => api.myClient}
|
||||||
|
columns={({ actionsColumn }) => [
|
||||||
|
{ accessorKey: "name", header: () => <ColumnHeader ... /> },
|
||||||
|
actionsColumn(),
|
||||||
|
]}
|
||||||
|
renderForm={({ resourceId, initialData, onSuccess }) => (
|
||||||
|
<MyForm resourceId={resourceId} initialData={initialData} onSuccess={onSuccess} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
See the [Resource Page](./resource-page.md), [Data Fetching](./data-fetching.md), and [Form System](./form-system.md) docs for details on each layer.
|
||||||
179
docs/dashboard/crud/resource-page.md
Normal file
179
docs/dashboard/crud/resource-page.md
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
# Resource Page
|
||||||
|
|
||||||
|
The `ResourcePage` component is the primary generic shell for any CRUD list page. It composes layout, table, dialog, and state management into a single, reusable component.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | Description |
|
||||||
|
|---|---|
|
||||||
|
| `shared/data-view/resource-page/resource-page.tsx` | The React component |
|
||||||
|
| `shared/data-view/resource-page/use-resource-page.ts` | The state/logic hook |
|
||||||
|
| `shared/data-view/resource-page/index.ts` | Public exports |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `<ResourcePage>` Component
|
||||||
|
|
||||||
|
### Props
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type ResourcePageProps<TClient extends ResourcePageClient> = {
|
||||||
|
// Required
|
||||||
|
title: string // Used for the "Add" button label and dialog title
|
||||||
|
routeKey: string // React Query cache key (e.g. CUSTOMER_ROUTES.INDEX)
|
||||||
|
getClient: (api: ApiInstance) => TClient // Selects the domain client from the API
|
||||||
|
columns: // Column definitions or a factory receiving helpers
|
||||||
|
| ColumnDef<ResourceItem<TClient>>[]
|
||||||
|
| ((helpers: ResourcePageColumnHelpers<TClient>) => ColumnDef<ResourceItem<TClient>>[])
|
||||||
|
renderForm: (props: ResourceFormProps<TClient>) => React.ReactNode
|
||||||
|
|
||||||
|
// Optional
|
||||||
|
pageTitle?: string // Heading text (defaults to undefined)
|
||||||
|
queryOptions?: Omit<UseQueryOptions<...>, "queryKey" | "queryFn">
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `ResourcePageColumnHelpers`
|
||||||
|
|
||||||
|
Passed to the `columns` callback, providing three pre-wired helpers:
|
||||||
|
|
||||||
|
| Helper | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `actionsColumn(options?)` | `ColumnDef` | Pre-built Edit + Delete dropdown column |
|
||||||
|
| `openEdit(row)` | `(row: TItem) => void` | Opens dialog with row pre-filled |
|
||||||
|
| `deleteItem(id)` | `(id: string) => Promise<unknown>` | Deletes with toast + confirmation |
|
||||||
|
|
||||||
|
The `actionsColumn` factory (`createActionsColumn`) can be further customized:
|
||||||
|
```ts
|
||||||
|
actionsColumn({
|
||||||
|
onEdit: (row) => customOpen(row),
|
||||||
|
onDelete: async (row) => {
|
||||||
|
// completely override delete behavior
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### `ResourceFormProps`
|
||||||
|
|
||||||
|
Passed to the `renderForm` callback:
|
||||||
|
|
||||||
|
| Prop | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `resourceId` | `string \| null` | `null` on create; the item's `id` string on edit |
|
||||||
|
| `initialData` | `TItem \| null` | The full row object on edit (from the table's in-memory state) |
|
||||||
|
| `onSuccess` | `() => void` | Call this after a successful mutation to refresh the list |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `useResourcePage` Hook
|
||||||
|
|
||||||
|
Encapsulates all state and logic. Returned by `ResourcePage` internally but also exported for use in custom page layouts.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const page = useResourcePage<MyClient>({ routeKey, getClient, queryOptions })
|
||||||
|
```
|
||||||
|
|
||||||
|
### Returns
|
||||||
|
|
||||||
|
| Key | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `data` | `CrudListResponse<TClient>` | Raw API response |
|
||||||
|
| `isLoading` | `boolean` | True while initial fetch is in progress |
|
||||||
|
| `pagination` | `DataViewPaginationState` | `{ page, pageSize, pageCount, total }` |
|
||||||
|
| `sorting` | `DataViewSorting` | Current sort state |
|
||||||
|
| `handleChange` | `(event: DataViewChangeEvent) => void` | Handles pagination and sort events |
|
||||||
|
| `invalidateQuery` | `() => void` | Busts the React Query cache for the current query key |
|
||||||
|
| `selectedItem` | `TItem \| null` | The row being edited (populated by `openEdit`) |
|
||||||
|
| `openEdit(row)` | fn | Sets `selectedItem` and opens dialog |
|
||||||
|
| `openCreate()` | fn | Clears `selectedItem` and opens dialog |
|
||||||
|
| `openDialog(id?)` | fn | Low-level dialog open (sets `?dialog=true&resourceId=id` in URL) |
|
||||||
|
| `closeDialog()` | fn | Closes dialog (removes URL params) |
|
||||||
|
| `isDialogOpen` | `boolean` | Current dialog open state |
|
||||||
|
| `dialogResourceId` | `string \| null` | Current resource ID from URL |
|
||||||
|
| `deleteItem(id)` | `(id: string) => Promise<unknown>` | Mutation that destroys a resource |
|
||||||
|
| `actionsColumn(options?)` | fn | Generates the actions `ColumnDef` |
|
||||||
|
| `client` | `TClient` | The domain API client |
|
||||||
|
| `api` | `ApiInstance` | Full authenticated API object |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `FormDialog` — URL-Driven Dialog
|
||||||
|
|
||||||
|
`FormDialog` and its companion hook `useFormDialog` manage dialog open/close state **via URL query parameters**:
|
||||||
|
|
||||||
|
| URL param | Value | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `dialog` | `true` / absent | Dialog open/closed |
|
||||||
|
| `resourceId` | string / absent | ID of the item being edited |
|
||||||
|
|
||||||
|
This means sharing or refreshing the URL with `?dialog=true&resourceId=5` will reopen the dialog on the same item (as long as `initialData` is in memory—see [Enhancement Plan](./enhancement-plan.md)).
|
||||||
|
|
||||||
|
### `useFormDialog`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const { isOpen, resourceId, open, close } = useFormDialog()
|
||||||
|
|
||||||
|
open("5") // opens dialog in edit mode
|
||||||
|
open() // opens dialog in create mode
|
||||||
|
close() // closes dialog, clears resourceId
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `ConfirmDialog` — Imperative Async Confirm
|
||||||
|
|
||||||
|
`ConfirmDialog` is a **singleton store-driven dialog** mounted once in the root layout. It exposes an imperative `confirm()` function:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { confirm } from "@/shared/components/confirm-dialog"
|
||||||
|
|
||||||
|
const ok = await confirm({
|
||||||
|
title: "Delete this item?",
|
||||||
|
description: "This action cannot be undone.",
|
||||||
|
confirmLabel: "Delete",
|
||||||
|
variant: "destructive", // shows destructive styling + trash icon
|
||||||
|
})
|
||||||
|
|
||||||
|
if (ok) { /* proceed */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Important:** `<ConfirmDialog />` must be rendered once in the root layout. If it is not mounted, `confirm()` will open a dialog that is never displayed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `createActionsColumn`
|
||||||
|
|
||||||
|
A standalone factory for generating the standard Edit + Delete column:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { createActionsColumn } from "@/shared/data-view/table-view"
|
||||||
|
|
||||||
|
createActionsColumn<MyItem>({
|
||||||
|
onEdit: (row) => openEdit(row),
|
||||||
|
onDelete: async (row) => {
|
||||||
|
const confirmed = await confirm({ ... })
|
||||||
|
if (confirmed) await deleteItem(String(row.id))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Component Tree
|
||||||
|
|
||||||
|
```
|
||||||
|
<ResourcePage>
|
||||||
|
└─ <DashboardPage header={...} title={pageTitle}>
|
||||||
|
├─ <DashboardHeader>
|
||||||
|
│ └─ <FormDialog title={title}> ← "Add Customer" button + Dialog shell
|
||||||
|
│ └─ renderForm(resourceId) ← Feature-specific form
|
||||||
|
└─ <Card>
|
||||||
|
└─ <CardContent>
|
||||||
|
└─ <DataTable columns data pagination sorting onChange isLoading>
|
||||||
|
├─ <DataViewProvider> ← Shares state via context
|
||||||
|
├─ TanStack Table (manual pagination + sorting)
|
||||||
|
├─ Skeleton rows while loading
|
||||||
|
└─ <DataViewPagination> ← Page controls + rows-per-page
|
||||||
|
```
|
||||||
261
docs/dashboard/feature-checklist.md
Normal file
261
docs/dashboard/feature-checklist.md
Normal file
@ -0,0 +1,261 @@
|
|||||||
|
# Garage Management System — Feature Implementation Checklist
|
||||||
|
|
||||||
|
> **Generated**: March 27, 2026
|
||||||
|
> **Reference**: Postman API Collection (`packages/api/postman/collection.json`)
|
||||||
|
> **Ordered by**: Dependency level (no dependencies → most complex relations)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How to Read This Checklist
|
||||||
|
|
||||||
|
- **✅ Full** = Page + Module (Form + Schema) + API Client all exist
|
||||||
|
- **🔧 API Only** = API Client exists, but no dashboard page/module yet
|
||||||
|
- **⬜ Not Started** = No implementation found
|
||||||
|
- **Depends on** = Other resources that must exist before this one (based on foreign keys in Postman collection)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Level 0 — Zero Dependencies (Standalone Reference Data)
|
||||||
|
|
||||||
|
These resources have no foreign key references. They are the foundation.
|
||||||
|
|
||||||
|
| # | Resource | Status | Implementation Details |
|
||||||
|
|---|----------|--------|----------------------|
|
||||||
|
| 1 | Auth (Login / Profile / Logout) | ✅ Full | Page: `(auth)/login` · Module: `auth/` · Client: `AuthClient` |
|
||||||
|
| 2 | Countries | 🔧 API Only | Client: `GeoClient` — used by Customer form |
|
||||||
|
| 3 | Customer Types | 🔧 API Only | Client: `CustomersClient.listCustomerTypes()` |
|
||||||
|
| 4 | Referral Sources | 🔧 API Only | Client: `ReferralSourcesClient` |
|
||||||
|
| 5 | Payment Terms | 🔧 API Only | Client: `PaymentTermsClient` |
|
||||||
|
| 6 | Payment Modes | 🔧 API Only | Client: `PaymentsClient` |
|
||||||
|
| 7 | Shop Types | ✅ Full | Page: `settings/shop-type` · Module: `settings/shop-type/` · Client: `ShopTypesClient` |
|
||||||
|
| 8 | Vehicle Body Types | 🔧 API Only | Client: `VehicleAttributesClient` — inline form in Vehicles |
|
||||||
|
| 9 | Vehicle Fuel Types | 🔧 API Only | Client: `VehicleAttributesClient` — inline form in Vehicles |
|
||||||
|
| 10 | Vehicle Transmissions | 🔧 API Only | Client: `VehicleAttributesClient` — inline form in Vehicles |
|
||||||
|
| 11 | Vehicle Colors | 🔧 API Only | Client: `VehicleAttributesClient` — inline form in Vehicles |
|
||||||
|
| 12 | Document Types | 🔧 API Only | Client: `VehicleDocumentsClient` |
|
||||||
|
| 13 | Unit Types | 🔧 API Only | Client: `InventoryClient` — inline form in Services |
|
||||||
|
| 14 | Labels | 🔧 API Only | Client: `LabelsClient` |
|
||||||
|
| 15 | Insurance Types | 🔧 API Only | Client: `InsuranceTypesClient` |
|
||||||
|
| 16 | Inspection Categories | 🔧 API Only | Client: `InspectionsClient` — inline form in Inspections |
|
||||||
|
| 17 | Check Point Labels | 🔧 API Only | Client: `InspectionsClient` |
|
||||||
|
| 18 | Quick Remarks | 🔧 API Only | Client: `EstimatesClient` |
|
||||||
|
| 19 | Quick Notes | 🔧 API Only | Client: `EstimatesClient` |
|
||||||
|
| 20 | Reasons | 🔧 API Only | Client: `LabelsClient` (or standalone) |
|
||||||
|
| 21 | Task Types | 🔧 API Only | Client: `TasksClient` |
|
||||||
|
| 22 | Task Sections | 🔧 API Only | Client: `TasksClient` |
|
||||||
|
| 23 | Invoice Labels | 🔧 API Only | Client: exists in collection |
|
||||||
|
| 24 | Holiday Years | 🔧 API Only | Client: exists in collection |
|
||||||
|
| 25 | Taxes | 🔧 API Only | Client: exists in collection |
|
||||||
|
| 26 | Departments | 🔧 API Only | Client: `DepartmentsClient` — inline form in Services |
|
||||||
|
| 27 | Labor Rates | 🔧 API Only | Client: `InventoryClient` |
|
||||||
|
| 28 | Vendors | 🔧 API Only | Client: `VendorsClient` |
|
||||||
|
| 29 | Shop Calendars | ✅ Full | Page: `productivity/shop-calendars` · Module: `shop-calendars/` · Client: `ShopCalendarsClient` |
|
||||||
|
| 30 | Shop Timings | ✅ Full | Page: `productivity/shop-timings` · Module: `shop-timings/` · Client: `ShopTimingsClient` |
|
||||||
|
| 31 | Settings | 🔧 API Only | Client: exists in collection (GET/PUT only) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Level 1 — Single-Level Dependencies
|
||||||
|
|
||||||
|
These depend only on Level 0 resources.
|
||||||
|
|
||||||
|
| # | Resource | Status | Depends On | Implementation Details |
|
||||||
|
|---|----------|--------|------------|----------------------|
|
||||||
|
| 32 | States | 🔧 API Only | Countries | Client: `GeoClient` |
|
||||||
|
| 33 | Inventory Categories | 🔧 API Only | Shop Types | Client: `InventoryClient` — inline form in Services |
|
||||||
|
| 34 | Vendor Addresses | 🔧 API Only | Vendors, Countries, States | Client: `VendorsClient.createAddress()` |
|
||||||
|
| 35 | Holidays | 🔧 API Only | Holiday Years | Client: exists in collection |
|
||||||
|
| 36 | Make and Models | 🔧 API Only | Shop Types, Body Types, Fuel Types, Transmissions | Client: exists in collection |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Level 2 — Core Business Entities
|
||||||
|
|
||||||
|
These depend on Level 0 + Level 1 resources and are used by many higher-level features.
|
||||||
|
|
||||||
|
| # | Resource | Status | Depends On | Implementation Details |
|
||||||
|
|---|----------|--------|------------|----------------------|
|
||||||
|
| 37 | Customers | ✅ Full | Customer Types, Referral Sources, Payment Terms, Countries, States | Page: `sales/customers` · Module: `customers/` · Client: `CustomersClient` |
|
||||||
|
| 38 | Vehicles | ✅ Full | Shop Types, Body Types, Fuel Types, Transmissions, Colors | Page: `sales/vehicles` · Module: `vehicles/` · Client: `VehiclesClient` · 5 inline forms |
|
||||||
|
| 39 | Expense Items | 🔧 API Only | Inventory Categories, Unit Types, Departments | Client: `ExpensesClient` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Level 3 — Operational Resources
|
||||||
|
|
||||||
|
These depend on Level 0–2 resources.
|
||||||
|
|
||||||
|
| # | Resource | Status | Depends On | Implementation Details |
|
||||||
|
|---|----------|--------|------------|----------------------|
|
||||||
|
| 40 | Employees | ✅ Full | Departments, Shop Calendars, Shop Timings | Page: `productivity/employees` · Module: `employees/` · Client: `EmployeesClient` |
|
||||||
|
| 41 | Parts | ✅ Full | Shop Types, Inventory Categories, Unit Types, Departments, Vendors | Page: `items/parts` · Module: `parts/` · Client: `PartsClient` |
|
||||||
|
| 42 | Services | ✅ Full | Shop Types, Inventory Categories, Unit Types, Departments | Page: `items/services` · Module: `services/` · Client: `ServicesClient` · 4 inline forms |
|
||||||
|
| 43 | Vehicle Documents | 🔧 API Only | Vehicles, Document Types | Client: `VehicleDocumentsClient` |
|
||||||
|
| 44 | Vehicle Mileage | 🔧 API Only | Vehicles | Client: `VehicleDocumentsClient` |
|
||||||
|
| 45 | Time Sheets | 🔧 API Only | Employees | Client: exists in collection |
|
||||||
|
| 46 | Invoice Sequences | 🔧 API Only | Departments | Client: exists in collection |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Level 4 — Composite Service Resources
|
||||||
|
|
||||||
|
These depend on Level 0–3 resources.
|
||||||
|
|
||||||
|
| # | Resource | Status | Depends On | Implementation Details |
|
||||||
|
|---|----------|--------|------------|----------------------|
|
||||||
|
| 47 | Service Groups | ✅ Full | Shop Types, Inventory Categories, Unit Types, Departments | Page: `items/service-group` · Module: `service-groups/` · Client: `ServiceGroupsClient` |
|
||||||
|
| 48 | Service Group Includes | 🔧 API Only | Service Groups | Client: part of Service Group Details |
|
||||||
|
| 49 | Service Group Services | 🔧 API Only | Service Groups, Services, Labor Rates, Taxes | Client: part of Service Group Details |
|
||||||
|
| 50 | Service Group Parts | 🔧 API Only | Service Groups, Parts, Taxes | Client: part of Service Group Details |
|
||||||
|
| 51 | Service Group Pricings | 🔧 API Only | Service Groups, Shop Types, Labor Rates, Fuel Types, Body Types | Client: part of Service Group Details |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Level 5 — Workflow & Operations
|
||||||
|
|
||||||
|
These are core garage workflow features depending on customers, vehicles, employees, etc.
|
||||||
|
|
||||||
|
| # | Resource | Status | Depends On | Implementation Details |
|
||||||
|
|---|----------|--------|------------|----------------------|
|
||||||
|
| 52 | Inspections | ✅ Full | Customers, Vehicles, Departments, Inspection Categories, Employees | Page: N/A (module exists) · Module: `inspections/` · Client: `InspectionsClient` · 1 inline form |
|
||||||
|
| 53 | Inspection Check Points | 🔧 API Only | Inspections, Check Point Labels | Client: `InspectionsClient` |
|
||||||
|
| 54 | Estimates | 🔧 API Only | Customers, Vehicles, Departments, Labels | Client: `EstimatesClient` |
|
||||||
|
| 55 | Job Cards | 🔧 API Only | Customers, Vehicles, Departments, Labels, Employees | Client: `JobCardsClient` (richest API — status workflow, remarks, attachments) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Level 6 — Financial & Scheduling
|
||||||
|
|
||||||
|
These depend on Job Cards and other Level 5 resources.
|
||||||
|
|
||||||
|
| # | Resource | Status | Depends On | Implementation Details |
|
||||||
|
|---|----------|--------|------------|----------------------|
|
||||||
|
| 56 | Appointments | 🔧 API Only | Customers, Vehicles, Departments, Job Cards, Employees, Labels | Client: `AppointmentsClient` |
|
||||||
|
| 57 | Tasks | 🔧 API Only | Task Types, Task Sections, Job Cards, Employees, Departments | Client: `TasksClient` |
|
||||||
|
| 58 | Purchase Orders | 🔧 API Only | Job Cards, Vendors, Departments, Labels, Parts | Client: `PurchaseOrdersClient` |
|
||||||
|
| 59 | Bills | 🔧 API Only | Job Cards, Vendors, Vendor Addresses, Payment Terms, Departments, Labels, Parts | Client: `ExpensesClient` |
|
||||||
|
| 60 | Expenses | 🔧 API Only | Job Cards, Expense Items, Vendors, Departments, Labels | Client: `ExpensesClient` |
|
||||||
|
| 61 | Payment Received | 🔧 API Only | Job Cards, Payment Modes, Customers | Client: `PaymentsClient` |
|
||||||
|
| 62 | Inventory Adjustments | 🔧 API Only | Parts, Job Cards, Invoices, Reasons | Client: exists in collection |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Level 7 — Invoicing & Credit System (Most Complex)
|
||||||
|
|
||||||
|
These are the most complex resources with the deepest dependency chains.
|
||||||
|
|
||||||
|
| # | Resource | Status | Depends On | Implementation Details |
|
||||||
|
|---|----------|--------|------------|----------------------|
|
||||||
|
| 63 | Invoices | 🔧 API Only | Customers, Vehicles, Departments, Invoice Sequences, Labels, Inspection Categories, Parts, Services, Expense Items, Service Groups | Client: exists in collection |
|
||||||
|
| 64 | Invoice Documents | 🔧 API Only | Invoices, Customers, Vehicles, Document Types | Client: exists in collection |
|
||||||
|
| 65 | Invoice Notes | 🔧 API Only | Invoices | Client: exists in collection |
|
||||||
|
| 66 | Credit Notes | 🔧 API Only | Customers, Parts, Services, Expenses, Inspection Categories, Labels | Client: exists in collection |
|
||||||
|
| 67 | Payment Mades | 🔧 API Only | Vendors, Employees, Bills, Expenses, Payment Modes | Client: exists in collection |
|
||||||
|
| 68 | Vendor Credits | 🔧 API Only | Vendors, Departments, Parts, Services, Expenses, Labels | Client: exists in collection |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
### Implementation Progress
|
||||||
|
|
||||||
|
| Category | Total | ✅ Full | 🔧 API Only | ⬜ Not Started |
|
||||||
|
|----------|-------|---------|-------------|----------------|
|
||||||
|
| Level 0 — Standalone | 31 | 4 | 27 | 0 |
|
||||||
|
| Level 1 — Single Dep | 5 | 0 | 5 | 0 |
|
||||||
|
| Level 2 — Core Entities | 3 | 2 | 1 | 0 |
|
||||||
|
| Level 3 — Operational | 7 | 3 | 4 | 0 |
|
||||||
|
| Level 4 — Composite | 5 | 1 | 4 | 0 |
|
||||||
|
| Level 5 — Workflows | 4 | 1 | 3 | 0 |
|
||||||
|
| Level 6 — Financial | 7 | 0 | 7 | 0 |
|
||||||
|
| Level 7 — Invoicing | 6 | 0 | 6 | 0 |
|
||||||
|
| **Total** | **68** | **11** | **57** | **0** |
|
||||||
|
|
||||||
|
### Pages with Full UI (11 total)
|
||||||
|
|
||||||
|
1. Auth (Login)
|
||||||
|
2. Shop Types (Settings)
|
||||||
|
3. Shop Calendars (Productivity)
|
||||||
|
4. Shop Timings (Productivity)
|
||||||
|
5. Customers (Sales)
|
||||||
|
6. Vehicles (Sales)
|
||||||
|
7. Employees (Productivity)
|
||||||
|
8. Parts (Items)
|
||||||
|
9. Services (Items)
|
||||||
|
10. Service Groups (Items)
|
||||||
|
11. Inspections (Module only — no page route yet)
|
||||||
|
|
||||||
|
### API Clients Without Pages — Priority Recommendations
|
||||||
|
|
||||||
|
Based on the roadmap (Phase 1 — Garage Operations), these are the highest-priority missing pages:
|
||||||
|
|
||||||
|
1. **Job Cards** — Core garage workflow, API client is the most feature-rich
|
||||||
|
2. **Estimates** — Pre-job-card workflow
|
||||||
|
3. **Appointments** — Scheduling system
|
||||||
|
4. **Inspections Page** — Module exists but no page route
|
||||||
|
5. **Departments** — Referenced by almost every form
|
||||||
|
6. **Vendors** — Needed for Parts purchasing and Bills
|
||||||
|
7. **Invoices** — Phase 2 but API is ready
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependency Graph (Simplified)
|
||||||
|
|
||||||
|
```
|
||||||
|
Level 0 (Foundation)
|
||||||
|
├── Auth, Countries, Shop Types, Customer Types, Referral Sources
|
||||||
|
├── Payment Terms, Payment Modes, Document Types, Unit Types, Labels
|
||||||
|
├── Vehicle Attributes (Body, Fuel, Transmission, Colors)
|
||||||
|
├── Inspection Categories, Check Point Labels, Insurance Types
|
||||||
|
├── Quick Remarks/Notes, Reasons, Task Types/Sections
|
||||||
|
├── Holiday Years, Taxes, Departments, Labor Rates
|
||||||
|
├── Vendors, Shop Calendars, Shop Timings, Invoice Labels, Settings
|
||||||
|
│
|
||||||
|
Level 1 (Single Dependency)
|
||||||
|
├── States → Countries
|
||||||
|
├── Inventory Categories → Shop Types
|
||||||
|
├── Vendor Addresses → Vendors + Countries + States
|
||||||
|
├── Holidays → Holiday Years
|
||||||
|
├── Make and Models → Shop Types + Vehicle Attributes
|
||||||
|
│
|
||||||
|
Level 2 (Core Entities)
|
||||||
|
├── Customers → Customer Types + Referral Sources + Payment Terms + Geo
|
||||||
|
├── Vehicles → Shop Types + Vehicle Attributes
|
||||||
|
├── Expense Items → Inventory Categories + Unit Types + Departments
|
||||||
|
│
|
||||||
|
Level 3 (Operational)
|
||||||
|
├── Employees → Departments + Shop Calendars + Shop Timings
|
||||||
|
├── Parts → Shop Types + Inventory Categories + Unit Types + Departments + Vendors
|
||||||
|
├── Services → Shop Types + Inventory Categories + Unit Types + Departments
|
||||||
|
├── Vehicle Documents → Vehicles + Document Types
|
||||||
|
├── Vehicle Mileage → Vehicles
|
||||||
|
├── Time Sheets → Employees
|
||||||
|
├── Invoice Sequences → Departments
|
||||||
|
│
|
||||||
|
Level 4 (Composite)
|
||||||
|
├── Service Groups → Shop Types + Inv. Categories + Unit Types + Departments
|
||||||
|
├── SG Includes/Services/Parts/Pricings → Service Groups + ...
|
||||||
|
│
|
||||||
|
Level 5 (Workflows)
|
||||||
|
├── Inspections → Customers + Vehicles + Departments + Insp. Categories + Employees
|
||||||
|
├── Inspection Check Points → Inspections + Check Point Labels
|
||||||
|
├── Estimates → Customers + Vehicles + Departments + Labels
|
||||||
|
├── Job Cards → Customers + Vehicles + Departments + Labels + Employees
|
||||||
|
│
|
||||||
|
Level 6 (Financial)
|
||||||
|
├── Appointments → Customers + Vehicles + Departments + Job Cards + Employees
|
||||||
|
├── Tasks → Task Types + Task Sections + Job Cards + Employees + Departments
|
||||||
|
├── Purchase Orders → Job Cards + Vendors + Departments + Labels + Parts
|
||||||
|
├── Bills → Job Cards + Vendors + Payment Terms + Departments + Labels + Parts
|
||||||
|
├── Expenses → Job Cards + Expense Items + Vendors + Departments + Labels
|
||||||
|
├── Payment Received → Job Cards + Payment Modes + Customers
|
||||||
|
├── Inventory Adjustments → Parts + Job Cards + Invoices + Reasons
|
||||||
|
│
|
||||||
|
Level 7 (Invoicing — Most Complex)
|
||||||
|
├── Invoices → Customers + Vehicles + Departments + Inv. Sequences + Labels + Parts + Services + Expenses + Service Groups
|
||||||
|
├── Invoice Documents → Invoices + Customers + Vehicles + Document Types
|
||||||
|
├── Invoice Notes → Invoices
|
||||||
|
├── Credit Notes → Customers + Parts + Services + Expenses + Insp. Categories + Labels
|
||||||
|
├── Payment Mades → Vendors + Employees + Bills + Expenses + Payment Modes
|
||||||
|
└── Vendor Credits → Vendors + Departments + Parts + Services + Expenses + Labels
|
||||||
|
```
|
||||||
22
package.json
Normal file
22
package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
28678
packages/api/open-api/schema.json
Normal file
28678
packages/api/open-api/schema.json
Normal file
File diff suppressed because it is too large
Load Diff
9625
packages/api/open-api/schema.yaml
Normal file
9625
packages/api/open-api/schema.yaml
Normal file
File diff suppressed because it is too large
Load Diff
39
packages/api/package.json
Normal file
39
packages/api/package.json
Normal 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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
20462
packages/api/postman/collection.json
Normal file
20462
packages/api/postman/collection.json
Normal file
File diff suppressed because it is too large
Load Diff
223
packages/api/scripts/generate-openapi.cjs
Normal file
223
packages/api/scripts/generate-openapi.cjs
Normal 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();
|
||||||
13
packages/api/scripts/generate-types.cjs
Normal file
13
packages/api/scripts/generate-types.cjs
Normal 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
65
packages/api/src/api.ts
Normal 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()
|
||||||
35
packages/api/src/clients/appointments.ts
Normal file
35
packages/api/src/clients/appointments.ts
Normal 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 } })
|
||||||
|
}
|
||||||
|
}
|
||||||
26
packages/api/src/clients/auth.ts
Normal file
26
packages/api/src/clients/auth.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
32
packages/api/src/clients/customers.ts
Normal file
32
packages/api/src/clients/customers.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
40
packages/api/src/clients/departments.ts
Normal file
40
packages/api/src/clients/departments.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
17
packages/api/src/clients/employees.ts
Normal file
17
packages/api/src/clients/employees.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
69
packages/api/src/clients/estimates.ts
Normal file
69
packages/api/src/clients/estimates.ts
Normal 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 } })
|
||||||
|
}
|
||||||
|
}
|
||||||
74
packages/api/src/clients/expenses.ts
Normal file
74
packages/api/src/clients/expenses.ts
Normal 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 } })
|
||||||
|
}
|
||||||
|
}
|
||||||
21
packages/api/src/clients/geo.ts
Normal file
21
packages/api/src/clients/geo.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
28
packages/api/src/clients/index.ts
Normal file
28
packages/api/src/clients/index.ts
Normal 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"
|
||||||
118
packages/api/src/clients/inspections.ts
Normal file
118
packages/api/src/clients/inspections.ts
Normal 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 } })
|
||||||
|
}
|
||||||
|
}
|
||||||
30
packages/api/src/clients/insurance-types.ts
Normal file
30
packages/api/src/clients/insurance-types.ts
Normal 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 } })
|
||||||
|
}
|
||||||
|
}
|
||||||
99
packages/api/src/clients/inventory.ts
Normal file
99
packages/api/src/clients/inventory.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
90
packages/api/src/clients/job-cards.ts
Normal file
90
packages/api/src/clients/job-cards.ts
Normal 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 } })
|
||||||
|
}
|
||||||
|
}
|
||||||
30
packages/api/src/clients/labels.ts
Normal file
30
packages/api/src/clients/labels.ts
Normal 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 } })
|
||||||
|
}
|
||||||
|
}
|
||||||
45
packages/api/src/clients/parts.ts
Normal file
45
packages/api/src/clients/parts.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
35
packages/api/src/clients/payment-terms.ts
Normal file
35
packages/api/src/clients/payment-terms.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
50
packages/api/src/clients/payments.ts
Normal file
50
packages/api/src/clients/payments.ts
Normal 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 } })
|
||||||
|
}
|
||||||
|
}
|
||||||
30
packages/api/src/clients/purchase-orders.ts
Normal file
30
packages/api/src/clients/purchase-orders.ts
Normal 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 } })
|
||||||
|
}
|
||||||
|
}
|
||||||
35
packages/api/src/clients/referral-sources.ts
Normal file
35
packages/api/src/clients/referral-sources.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
17
packages/api/src/clients/service-groups.ts
Normal file
17
packages/api/src/clients/service-groups.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
40
packages/api/src/clients/services.ts
Normal file
40
packages/api/src/clients/services.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
41
packages/api/src/clients/shop-calendars.ts
Normal file
41
packages/api/src/clients/shop-calendars.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
27
packages/api/src/clients/shop-timings.ts
Normal file
27
packages/api/src/clients/shop-timings.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
60
packages/api/src/clients/shop-types.ts
Normal file
60
packages/api/src/clients/shop-types.ts
Normal 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 } })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
99
packages/api/src/clients/tasks.ts
Normal file
99
packages/api/src/clients/tasks.ts
Normal 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 } })
|
||||||
|
}
|
||||||
|
}
|
||||||
88
packages/api/src/clients/vehicle-attributes.ts
Normal file
88
packages/api/src/clients/vehicle-attributes.ts
Normal 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 } })
|
||||||
|
}
|
||||||
|
}
|
||||||
69
packages/api/src/clients/vehicle-documents.ts
Normal file
69
packages/api/src/clients/vehicle-documents.ts
Normal 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 } })
|
||||||
|
}
|
||||||
|
}
|
||||||
55
packages/api/src/clients/vehicles.ts
Normal file
55
packages/api/src/clients/vehicles.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
45
packages/api/src/clients/vendors.ts
Normal file
45
packages/api/src/clients/vendors.ts
Normal 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 } })
|
||||||
|
}
|
||||||
|
}
|
||||||
8
packages/api/src/contracts/crud.ts
Normal file
8
packages/api/src/contracts/crud.ts
Normal 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>>
|
||||||
|
}
|
||||||
19
packages/api/src/contracts/types.ts
Normal file
19
packages/api/src/contracts/types.ts
Normal 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
13
packages/api/src/index.ts
Normal 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"
|
||||||
|
|
||||||
|
|
||||||
269
packages/api/src/infra/client.ts
Normal file
269
packages/api/src/infra/client.ts
Normal 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." },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
58
packages/api/src/infra/crud-client.ts
Normal file
58
packages/api/src/infra/crud-client.ts
Normal 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
|
||||||
22
packages/api/src/infra/index.ts
Normal file
22
packages/api/src/infra/index.ts
Normal 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"
|
||||||
6
packages/api/src/infra/token.ts
Normal file
6
packages/api/src/infra/token.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export type AuthUser = {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
[key: string]: string | number
|
||||||
|
}
|
||||||
112
packages/api/src/infra/types.ts
Normal file
112
packages/api/src/infra/types.ts
Normal 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
|
||||||
|
>
|
||||||
|
>
|
||||||
|
>
|
||||||
24
packages/api/src/server.ts
Normal file
24
packages/api/src/server.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
11
packages/api/tsconfig.json
Normal file
11
packages/api/tsconfig.json
Normal 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
22335
packages/api/types/index.ts
Normal file
File diff suppressed because it is too large
Load Diff
3
packages/eslint-config/README.md
Normal file
3
packages/eslint-config/README.md
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# `@turbo/eslint-config`
|
||||||
|
|
||||||
|
Collection of internal eslint configurations.
|
||||||
32
packages/eslint-config/base.js
Normal file
32
packages/eslint-config/base.js
Normal 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/**"],
|
||||||
|
},
|
||||||
|
];
|
||||||
57
packages/eslint-config/next.js
Normal file
57
packages/eslint-config/next.js
Normal 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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
24
packages/eslint-config/package.json
Normal file
24
packages/eslint-config/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
39
packages/eslint-config/react-internal.js
vendored
Normal file
39
packages/eslint-config/react-internal.js
vendored
Normal 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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
19
packages/typescript-config/base.json
Normal file
19
packages/typescript-config/base.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
12
packages/typescript-config/nextjs.json
Normal file
12
packages/typescript-config/nextjs.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
9
packages/typescript-config/package.json
Normal file
9
packages/typescript-config/package.json
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"name": "@repo/typescript-config",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
|
}
|
||||||
|
}
|
||||||
7
packages/typescript-config/react-library.json
Normal file
7
packages/typescript-config/react-library.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/tsconfig",
|
||||||
|
"extends": "./base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
}
|
||||||
|
}
|
||||||
4
packages/ui/eslint.config.mjs
Normal file
4
packages/ui/eslint.config.mjs
Normal 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
26
packages/ui/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
20
packages/ui/src/button.tsx
Normal file
20
packages/ui/src/button.tsx
Normal 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
27
packages/ui/src/card.tsx
Normal 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>-></span>
|
||||||
|
</h2>
|
||||||
|
<p>{children}</p>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
packages/ui/src/code.tsx
Normal file
11
packages/ui/src/code.tsx
Normal 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>;
|
||||||
|
}
|
||||||
9
packages/ui/tsconfig.json
Normal file
9
packages/ui/tsconfig.json
Normal 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
9926
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
3
pnpm-workspace.yaml
Normal file
3
pnpm-workspace.yaml
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
packages:
|
||||||
|
- "apps/*"
|
||||||
|
- "packages/*"
|
||||||
46
turbo.json
Normal file
46
turbo.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user