init
This commit is contained in:
commit
13b56d4960
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 '@garage/api'
|
||||||
|
import { <RESOURCE>_ROUTES } from '@garage/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 "@garage/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 '@garage/api'
|
||||||
|
import type { <Resource>Client } from '@garage/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 '@garage/api'
|
||||||
|
import type { CustomersClient } from '@garage/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 '@garage/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:
|
||||||
|
|
||||||
|
### 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)
|
||||||
39
apps/dashboard/.gitignore
vendored
Normal file
39
apps/dashboard/.gitignore
vendored
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
/cypress/videos
|
||||||
|
/cypress/screenshots
|
||||||
|
/cypress/downloads
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files
|
||||||
|
.env*.local
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
7
apps/dashboard/.prettierignore
Normal file
7
apps/dashboard/.prettierignore
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
dist/
|
||||||
|
node_modules/
|
||||||
|
.next/
|
||||||
|
.turbo/
|
||||||
|
coverage/
|
||||||
|
pnpm-lock.yaml
|
||||||
|
.pnpm-store/
|
||||||
11
apps/dashboard/.prettierrc
Normal file
11
apps/dashboard/.prettierrc
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"endOfLine": "lf",
|
||||||
|
"semi": false,
|
||||||
|
"singleQuote": false,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"printWidth": 80,
|
||||||
|
"plugins": ["prettier-plugin-tailwindcss"],
|
||||||
|
"tailwindStylesheet": "app/globals.css",
|
||||||
|
"tailwindFunctions": ["cn", "cva"]
|
||||||
|
}
|
||||||
11
apps/dashboard/.vscode/mcp.json
vendored
Normal file
11
apps/dashboard/.vscode/mcp.json
vendored
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"servers": {
|
||||||
|
"shadcn": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"shadcn@latest",
|
||||||
|
"mcp"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
21
apps/dashboard/README.md
Normal file
21
apps/dashboard/README.md
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
# Next.js template
|
||||||
|
|
||||||
|
This is a Next.js template with shadcn/ui.
|
||||||
|
|
||||||
|
## Adding components
|
||||||
|
|
||||||
|
To add components to your app, run the following command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest add button
|
||||||
|
```
|
||||||
|
|
||||||
|
This will place the ui components in the `components` directory.
|
||||||
|
|
||||||
|
## Using components
|
||||||
|
|
||||||
|
To use the components in your app, import them as follows:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
```
|
||||||
12
apps/dashboard/app/(auth)/login/page.tsx
Normal file
12
apps/dashboard/app/(auth)/login/page.tsx
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { LoginForm } from "@/modules/auth/login-form";
|
||||||
|
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
|
||||||
|
<div className="w-full max-w-sm">
|
||||||
|
<LoginForm />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
90
apps/dashboard/app/(authenticated)/items/parts/page.tsx
Normal file
90
apps/dashboard/app/(authenticated)/items/parts/page.tsx
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { ResourcePage } from "@/shared/data-view/resource-page"
|
||||||
|
import { ColumnHeader } from "@/shared/data-view/table-view"
|
||||||
|
import { PartForm } from "@/modules/parts/part-form"
|
||||||
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
|
import { PARTS_ROUTES } from "@garage/api"
|
||||||
|
import type { PartsClient } from "@garage/api"
|
||||||
|
|
||||||
|
export default function PartsPage() {
|
||||||
|
return (
|
||||||
|
<ResourcePage<PartsClient>
|
||||||
|
pageTitle="Parts"
|
||||||
|
title="Part"
|
||||||
|
routeKey={PARTS_ROUTES.INDEX}
|
||||||
|
getClient={(api) => api.parts}
|
||||||
|
columns={({ actionsColumn }) => [
|
||||||
|
{
|
||||||
|
accessorKey: "title",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Title" />,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const r = row.original as any
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">{r.title || "—"}</span>
|
||||||
|
{r.sku && (
|
||||||
|
<span className="ml-2 text-xs text-muted-foreground">{r.sku}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "part_number",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Part #" />,
|
||||||
|
cell: ({ row }) => (row.original as any).part_number || "—",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "manufactured_by",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Manufacturer" />,
|
||||||
|
cell: ({ row }) => (row.original as any).manufactured_by || "—",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "selling_price",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Sell Price" />,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const val = (row.original as any).selling_price
|
||||||
|
return val != null ? `$${Number(val).toFixed(2)}` : "—"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "purchase_price",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Cost" />,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const val = (row.original as any).purchase_price
|
||||||
|
return val != null ? `$${Number(val).toFixed(2)}` : "—"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "is_active",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Status" />,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const active = (row.original as any).is_active
|
||||||
|
return (
|
||||||
|
<Badge variant={active ? "default" : "secondary"}>
|
||||||
|
{active ? "Active" : "Inactive"}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "created_at",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Created" />,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const val = (row.original as any).created_at
|
||||||
|
return val ? new Date(val).toLocaleDateString() : "—"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
actionsColumn(),
|
||||||
|
]}
|
||||||
|
renderForm={({ resourceId, initialData, onSuccess }) => (
|
||||||
|
<PartForm
|
||||||
|
resourceId={resourceId}
|
||||||
|
initialData={initialData}
|
||||||
|
onSuccess={onSuccess}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,72 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { ResourcePage } from "@/shared/data-view/resource-page"
|
||||||
|
import { ColumnHeader } from "@/shared/data-view/table-view"
|
||||||
|
import { ServiceGroupForm } from "@/modules/service-groups/service-group-form"
|
||||||
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
|
import { SERVICE_GROUP_ROUTES } from "@garage/api"
|
||||||
|
import type { ServiceGroupsClient } from "@garage/api"
|
||||||
|
|
||||||
|
export default function ServiceGroupPage() {
|
||||||
|
return (
|
||||||
|
<ResourcePage<ServiceGroupsClient>
|
||||||
|
pageTitle="Service Groups"
|
||||||
|
title="Service Group"
|
||||||
|
routeKey={SERVICE_GROUP_ROUTES.INDEX}
|
||||||
|
getClient={(api) => api.serviceGroups}
|
||||||
|
columns={({ actionsColumn }) => [
|
||||||
|
{
|
||||||
|
accessorKey: "name",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Name" />,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const r = row.original as any
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">{r.service_name || r.name || "—"}</span>
|
||||||
|
{r.code && (
|
||||||
|
<span className="ml-2 text-xs text-muted-foreground">{r.code}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "selling_price",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Price" />,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const val = (row.original as any).selling_price
|
||||||
|
return val != null ? `$${Number(val).toFixed(2)}` : "—"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "is_active",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Status" />,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const active = (row.original as any).is_active
|
||||||
|
return (
|
||||||
|
<Badge variant={active ? "default" : "secondary"}>
|
||||||
|
{active ? "Active" : "Inactive"}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "created_at",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Created" />,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const val = (row.original as any).created_at
|
||||||
|
return val ? new Date(val).toLocaleDateString() : "—"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
actionsColumn(),
|
||||||
|
]}
|
||||||
|
renderForm={({ resourceId, initialData, onSuccess }) => (
|
||||||
|
<ServiceGroupForm
|
||||||
|
resourceId={resourceId}
|
||||||
|
initialData={initialData}
|
||||||
|
onSuccess={onSuccess}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
69
apps/dashboard/app/(authenticated)/items/services/page.tsx
Normal file
69
apps/dashboard/app/(authenticated)/items/services/page.tsx
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { ResourcePage } from "@/shared/data-view/resource-page"
|
||||||
|
import { ColumnHeader } from "@/shared/data-view/table-view"
|
||||||
|
import { ServiceForm } from "@/modules/services/service-form"
|
||||||
|
import { SERVICE_ROUTES } from "@garage/api"
|
||||||
|
import type { ServicesClient } from "@garage/api"
|
||||||
|
|
||||||
|
export default function ServicesPage() {
|
||||||
|
return (
|
||||||
|
<ResourcePage<ServicesClient>
|
||||||
|
pageTitle="Services"
|
||||||
|
title="Service"
|
||||||
|
routeKey={SERVICE_ROUTES.INDEX}
|
||||||
|
getClient={(api) => api.services}
|
||||||
|
columns={({ actionsColumn }) => [
|
||||||
|
{
|
||||||
|
accessorKey: "labor_name",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Name" />,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const r = row.original as any
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">{r.labor_name || r.name || "—"}</span>
|
||||||
|
{r.service_code && (
|
||||||
|
<span className="ml-2 text-xs text-muted-foreground">{r.service_code}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "description",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Description" />,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const val = (row.original as any).description
|
||||||
|
return val
|
||||||
|
? <span className="max-w-[200px] truncate block">{val}</span>
|
||||||
|
: "—"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "selling_price",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Price" />,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const val = (row.original as any).selling_price
|
||||||
|
return val != null ? `$${Number(val).toFixed(2)}` : "—"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "created_at",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Created" />,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const val = (row.original as any).created_at
|
||||||
|
return val ? new Date(val).toLocaleDateString() : "—"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
actionsColumn(),
|
||||||
|
]}
|
||||||
|
renderForm={({ resourceId, initialData, onSuccess }) => (
|
||||||
|
<ServiceForm
|
||||||
|
resourceId={resourceId}
|
||||||
|
initialData={initialData}
|
||||||
|
onSuccess={onSuccess}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
219
apps/dashboard/app/(authenticated)/layout.tsx
Normal file
219
apps/dashboard/app/(authenticated)/layout.tsx
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Suspense } from "react"
|
||||||
|
import type { NavGroup } from "@/base/types/navigation"
|
||||||
|
import {
|
||||||
|
AlarmClockIcon,
|
||||||
|
AwardIcon,
|
||||||
|
BanknoteArrowDownIcon,
|
||||||
|
BarChart3Icon,
|
||||||
|
BellRingIcon,
|
||||||
|
BookIcon,
|
||||||
|
BriefcaseBusinessIcon,
|
||||||
|
Building2Icon,
|
||||||
|
CalendarCheck2Icon,
|
||||||
|
CalendarDaysIcon,
|
||||||
|
LayoutDashboardIcon,
|
||||||
|
ClipboardListIcon,
|
||||||
|
UsersIcon,
|
||||||
|
CalendarIcon,
|
||||||
|
CarIcon,
|
||||||
|
ClipboardCheckIcon,
|
||||||
|
Clock3Icon,
|
||||||
|
ClockIcon,
|
||||||
|
GemIcon,
|
||||||
|
GitBranchIcon,
|
||||||
|
HandCoinsIcon,
|
||||||
|
ListIcon,
|
||||||
|
ListTodoIcon,
|
||||||
|
MegaphoneIcon,
|
||||||
|
PackageIcon,
|
||||||
|
PhoneCallIcon,
|
||||||
|
PlugZapIcon,
|
||||||
|
ReceiptIcon,
|
||||||
|
ReceiptTextIcon,
|
||||||
|
SettingsIcon,
|
||||||
|
ShoppingBasketIcon,
|
||||||
|
CircleDollarSign,
|
||||||
|
StarIcon,
|
||||||
|
StoreIcon,
|
||||||
|
TimerIcon,
|
||||||
|
UserCogIcon,
|
||||||
|
WalletIcon,
|
||||||
|
WrenchIcon,
|
||||||
|
ShoppingCartIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
import Image from "next/image"
|
||||||
|
import { DashboardLayout } from "@/base/components/layout/dashboard"
|
||||||
|
import { useAuth } from "@/shared/hooks/use-auth"
|
||||||
|
|
||||||
|
const navGroups: NavGroup[] = [
|
||||||
|
{
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: "Dashboard",
|
||||||
|
href: "/",
|
||||||
|
icon: <LayoutDashboardIcon />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Job Cards",
|
||||||
|
href: "/sales/workorder/list",
|
||||||
|
icon: <ClipboardListIcon />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Customer & Vehicles",
|
||||||
|
href: "/customer-vehicles",
|
||||||
|
icon: <UsersIcon />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Reports",
|
||||||
|
href: "/reports",
|
||||||
|
icon: <BarChart3Icon />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Management",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: "Calendars",
|
||||||
|
href: "/calendars",
|
||||||
|
icon: <CalendarIcon />,
|
||||||
|
items: [
|
||||||
|
{ title: "Work Schedule", href: "/calendar/work-schedule/list", icon: <Clock3Icon /> },
|
||||||
|
{ title: "Appointments", href: "/calendar/appointment/list", icon: <CalendarCheck2Icon /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Sales",
|
||||||
|
href: "/sales",
|
||||||
|
icon: <CircleDollarSign />,
|
||||||
|
items: [
|
||||||
|
{ title: "Customers", href: "/sales/customers", icon: <UsersIcon /> },
|
||||||
|
{ title: "Vehicles", href: "/sales/vehicles", icon: <CarIcon /> },
|
||||||
|
{ title: "Inspections", href: "/sales/inspections", icon: <ClipboardCheckIcon /> },
|
||||||
|
{ title: "Estimates", href: "/sales/estimate", icon: <ReceiptTextIcon /> },
|
||||||
|
{ title: "Job Cards", href: "/sales/workorder/list", icon: <ClipboardListIcon /> },
|
||||||
|
{ title: "Invoices", href: "/sales/invoice", icon: <ReceiptIcon /> },
|
||||||
|
{ title: "Payments Received", href: "/sales/payment-received", icon: <HandCoinsIcon /> },
|
||||||
|
{ title: "Credit Notes", href: "/sales/credit-notes", icon: <ReceiptTextIcon /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Purchases",
|
||||||
|
href: "/purchases",
|
||||||
|
icon: <ShoppingCartIcon />,
|
||||||
|
items: [
|
||||||
|
{ title: "Vendors", href: "/purchase/vendor", icon: <StoreIcon /> },
|
||||||
|
{ title: "Expenses", href: "/purchase/expense", icon: <WalletIcon /> },
|
||||||
|
{ title: "Purchase Orders", href: "/purchase/purchase-order", icon: <ShoppingBasketIcon /> },
|
||||||
|
{ title: "Bills", href: "/purchase/bill", icon: <ReceiptIcon /> },
|
||||||
|
{ title: "Payments Made", href: "/purchase/payments-made", icon: <BanknoteArrowDownIcon /> },
|
||||||
|
{ title: "Vendor Credits", href: "/purchase/vendor-credit", icon: <ReceiptTextIcon /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "CRM",
|
||||||
|
href: "/crm",
|
||||||
|
icon: <BriefcaseBusinessIcon />,
|
||||||
|
items: [
|
||||||
|
{ title: "Leads", href: "/crm/leads/list", icon: <GemIcon /> },
|
||||||
|
{ title: "Calls", href: "/crm/calls-follow-up/list", icon: <PhoneCallIcon /> },
|
||||||
|
{ title: "Tasks", href: "/crm/tasks/list", icon: <ListTodoIcon /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Marketing",
|
||||||
|
href: "/marketing",
|
||||||
|
icon: <MegaphoneIcon />,
|
||||||
|
items: [
|
||||||
|
{ title: "Service Reminders", href: "/marketing/service-reminder/list", icon: <AlarmClockIcon /> },
|
||||||
|
{ title: "Rating & Reviews", href: "/marketing/rating-review", icon: <StarIcon /> },
|
||||||
|
{ title: "Google Business Reviews", href: "/marketing/google-rating-review", icon: <AwardIcon /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Accountants",
|
||||||
|
href: "/accountants",
|
||||||
|
icon: <BookIcon />,
|
||||||
|
items: [
|
||||||
|
{ title: "Manual Journals", href: "/accountants/manual-journal", icon: <BookIcon /> },
|
||||||
|
{ title: "Chart of Accounts", href: "/accountants/chart-of-account", icon: <GitBranchIcon /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Employees",
|
||||||
|
href: "/productivity",
|
||||||
|
icon: <UserCogIcon />,
|
||||||
|
items: [
|
||||||
|
{ title: "Employees", href: "/productivity/employees", icon: <UsersIcon /> },
|
||||||
|
{ title: "Time Clocks", href: "/productivity/time-clocks", icon: <TimerIcon /> },
|
||||||
|
{ title: "Time Sheets", href: "/productivity/timesheet", icon: <ClockIcon /> },
|
||||||
|
{ title: "Payroll", href: "/productivity/payroll", icon: <WalletIcon /> },
|
||||||
|
{ title: "Payments Made", href: "/productivity/employee-payments-made", icon: <HandCoinsIcon /> },
|
||||||
|
{ title: "Shop Calendars", href: "/productivity/shop-calendars", icon: <CalendarDaysIcon /> },
|
||||||
|
{ title: "Shop Timing", href: "/productivity/shop-timings", icon: <Clock3Icon /> },
|
||||||
|
{ title: "Holidays", href: "/productivity/holidays", icon: <CalendarIcon /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Items",
|
||||||
|
href: "/items",
|
||||||
|
icon: <PackageIcon />,
|
||||||
|
items: [
|
||||||
|
{ title: "Services", href: "/items/services", icon: <WrenchIcon /> },
|
||||||
|
{ title: "Parts", href: "/items/parts", icon: <WrenchIcon /> },
|
||||||
|
{ title: "Expense Item", href: "/items/expense-item", icon: <WalletIcon /> },
|
||||||
|
{ title: "Service Group", href: "/items/service-group", icon: <PackageIcon /> },
|
||||||
|
{ title: "Inspections", href: "/items/inspection", icon: <ClipboardCheckIcon /> },
|
||||||
|
{ title: "Inventory Adjustments", href: "/items/adjustment", icon: <ListIcon /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Settings",
|
||||||
|
href: "/setting",
|
||||||
|
icon: <SettingsIcon />,
|
||||||
|
items: [
|
||||||
|
{ title: "Company", href: "/setting/company", icon: <Building2Icon /> },
|
||||||
|
{ title: "Shop Types", href: "/setting/shop-type", icon: <CarIcon /> },
|
||||||
|
{ title: "Tax & Rates", href: "/setting/tax-rates", icon: <ReceiptTextIcon /> },
|
||||||
|
{ title: "Configurations", href: "/setting/configurations/preferences/sales", icon: <SettingsIcon /> },
|
||||||
|
{ title: "Templates", href: "/setting/templates", icon: <ClipboardListIcon /> },
|
||||||
|
{ title: "Integrations", href: "/setting/integrations/providers", icon: <PlugZapIcon /> },
|
||||||
|
{ title: "Master", href: "/setting/master/body-type", icon: <ListIcon /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
function Logo() {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Image alt="Logo" src={'/assets/logo.png'} height={200} width={200}/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AuthenticatedLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
const { user } = useAuth()
|
||||||
|
|
||||||
|
const userInfo = user
|
||||||
|
? {
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
initials: user.name.charAt(0).toUpperCase(),
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardLayout navGroups={navGroups} logo={<Logo />} user={userInfo}>
|
||||||
|
<Suspense>{children}</Suspense>
|
||||||
|
</DashboardLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
14
apps/dashboard/app/(authenticated)/page.tsx
Normal file
14
apps/dashboard/app/(authenticated)/page.tsx
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { DashboardHeader } from "@/base/components/layout/dashboard";
|
||||||
|
import DashboardPage from "@/base/components/layout/dashboard/dashboard-page";
|
||||||
|
export default function page() {
|
||||||
|
return (
|
||||||
|
<DashboardPage header={<DashboardHeader />} >
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Dashboard</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Welcome to your dashboard. Select an item from the sidebar to get started.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</DashboardPage>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,65 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { ResourcePage } from "@/shared/data-view/resource-page"
|
||||||
|
import { ColumnHeader } from "@/shared/data-view/table-view"
|
||||||
|
import { EmployeeForm } from "@/modules/employees/employee-form"
|
||||||
|
import { EMPLOYEE_ROUTES } from "@garage/api"
|
||||||
|
import type { EmployeesClient } from "@garage/api"
|
||||||
|
|
||||||
|
export default function EmployeesPage() {
|
||||||
|
return (
|
||||||
|
<ResourcePage<EmployeesClient>
|
||||||
|
pageTitle="Employees"
|
||||||
|
title="Employee"
|
||||||
|
routeKey={EMPLOYEE_ROUTES.INDEX}
|
||||||
|
getClient={(api) => api.employees}
|
||||||
|
columns={({ actionsColumn }) => [
|
||||||
|
{
|
||||||
|
accessorKey: "first_name",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Name" />,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const { first_name, last_name } = row.original
|
||||||
|
return `${first_name ?? ""} ${last_name ?? ""}`.trim()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "email",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Email" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "phone",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Phone" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "position",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Position" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "department",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Department" />,
|
||||||
|
cell: ({ row }) => (row.original as any).department?.name ?? "—",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "status",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Status" />,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const status = row.original.status
|
||||||
|
return (
|
||||||
|
<span className={status === "active" ? "text-green-600" : "text-red-600"}>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
actionsColumn(),
|
||||||
|
]}
|
||||||
|
renderForm={({ resourceId, initialData, onSuccess }) => (
|
||||||
|
<EmployeeForm
|
||||||
|
resourceId={resourceId}
|
||||||
|
initialData={initialData}
|
||||||
|
onSuccess={onSuccess}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,50 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { ResourcePage } from "@/shared/data-view/resource-page"
|
||||||
|
import { ColumnHeader } from "@/shared/data-view/table-view"
|
||||||
|
import { ShopCalendarForm } from "@/modules/shop-calendars/shop-calendar-form"
|
||||||
|
import { SHOP_CALENDAR_ROUTES } from "@garage/api"
|
||||||
|
import type { ShopCalendarsClient } from "@garage/api"
|
||||||
|
import { CheckCircle2Icon } from "lucide-react"
|
||||||
|
|
||||||
|
export default function ShopCalendarsPage() {
|
||||||
|
return (
|
||||||
|
<ResourcePage<ShopCalendarsClient>
|
||||||
|
pageTitle="Shop Calendars"
|
||||||
|
title="Shop Calendar"
|
||||||
|
routeKey={SHOP_CALENDAR_ROUTES.INDEX}
|
||||||
|
getClient={(api) => api.shopCalendars}
|
||||||
|
columns={({ actionsColumn }) => [
|
||||||
|
{
|
||||||
|
accessorKey: "title",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Title" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "is_default",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Default" />,
|
||||||
|
cell: ({ row }) =>
|
||||||
|
(row.original as any).is_default ? (
|
||||||
|
<CheckCircle2Icon className="text-green-600 h-5 w-5" />
|
||||||
|
) : null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "shop_calender_days",
|
||||||
|
header: () => <span>Days</span>,
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const days = (row.original as any).shop_calender_days
|
||||||
|
return days?.length ?? 0
|
||||||
|
},
|
||||||
|
},
|
||||||
|
actionsColumn({ onEdit: undefined }),
|
||||||
|
]}
|
||||||
|
renderForm={({ resourceId, initialData, onSuccess }) => (
|
||||||
|
<ShopCalendarForm
|
||||||
|
resourceId={resourceId}
|
||||||
|
initialData={initialData}
|
||||||
|
onSuccess={onSuccess}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,57 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { ResourcePage } from "@/shared/data-view/resource-page"
|
||||||
|
import { ColumnHeader } from "@/shared/data-view/table-view"
|
||||||
|
import { ShopTimingForm } from "@/modules/shop-timings/shop-timing-form"
|
||||||
|
import { SHOP_TIMING_ROUTES } from "@garage/api"
|
||||||
|
import type { ShopTimingsClient } from "@garage/api"
|
||||||
|
import { CheckCircle2Icon } from "lucide-react"
|
||||||
|
|
||||||
|
export default function ShopTimingsPage() {
|
||||||
|
return (
|
||||||
|
<ResourcePage<ShopTimingsClient>
|
||||||
|
pageTitle="Shop Timings"
|
||||||
|
title="Shop Timing"
|
||||||
|
routeKey={SHOP_TIMING_ROUTES.INDEX}
|
||||||
|
getClient={(api) => api.shopTimings}
|
||||||
|
columns={({ actionsColumn }) => [
|
||||||
|
{
|
||||||
|
accessorKey: "title",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Title" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "in_time",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="In Time" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "out_time",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Out Time" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "full_day_hours",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Full Day Hours" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "half_day_hours",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Half Day Hours" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "is_default",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Default" />,
|
||||||
|
cell: ({ row }) =>
|
||||||
|
row.original.is_default ? (
|
||||||
|
<CheckCircle2Icon className="text-green-600 h-5 w-5" />
|
||||||
|
) : null,
|
||||||
|
},
|
||||||
|
actionsColumn(),
|
||||||
|
]}
|
||||||
|
renderForm={({ resourceId, initialData, onSuccess }) => (
|
||||||
|
<ShopTimingForm
|
||||||
|
resourceId={resourceId}
|
||||||
|
initialData={initialData}
|
||||||
|
onSuccess={onSuccess}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
54
apps/dashboard/app/(authenticated)/sales/customers/page.tsx
Normal file
54
apps/dashboard/app/(authenticated)/sales/customers/page.tsx
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
"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 '@garage/api'
|
||||||
|
import type { CustomersClient } from '@garage/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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,65 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { ResourcePage } from "@/shared/data-view/resource-page"
|
||||||
|
import { ColumnHeader } from "@/shared/data-view/table-view"
|
||||||
|
import { InspectionForm } from "@/modules/inspections/inspection-form"
|
||||||
|
import { INSPECTION_ROUTES } from "@garage/api"
|
||||||
|
import type { InspectionsClient } from "@garage/api"
|
||||||
|
|
||||||
|
export default function InspectionsPage() {
|
||||||
|
return (
|
||||||
|
<ResourcePage<InspectionsClient>
|
||||||
|
pageTitle="Inspections"
|
||||||
|
title="Inspection"
|
||||||
|
routeKey={INSPECTION_ROUTES.INDEX}
|
||||||
|
getClient={(api) => api.inspections}
|
||||||
|
columns={({ actionsColumn }) => [
|
||||||
|
{
|
||||||
|
accessorKey: "title",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Title" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "customer",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Customer" />,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const c = (row.original as any).customer
|
||||||
|
return c ? `${c.first_name ?? ""} ${c.last_name ?? ""}`.trim() : "—"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "vehicle",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Vehicle" />,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const v = (row.original as any).vehicle
|
||||||
|
return v ? `${v.make ?? ""} ${v.model ?? ""}`.trim() : "—"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "inspection_category",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Category" />,
|
||||||
|
cell: ({ row }) => (row.original as any).inspection_category?.name ?? "—",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "status",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Status" />,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const status = (row.original as any).status
|
||||||
|
return (
|
||||||
|
<span className={status === "completed" ? "text-green-600" : "text-yellow-600"}>
|
||||||
|
{status ?? "—"}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
actionsColumn(),
|
||||||
|
]}
|
||||||
|
renderForm={({ resourceId, initialData, onSuccess }) => (
|
||||||
|
<InspectionForm
|
||||||
|
resourceId={resourceId}
|
||||||
|
initialData={initialData}
|
||||||
|
onSuccess={onSuccess}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
98
apps/dashboard/app/(authenticated)/sales/vehicles/page.tsx
Normal file
98
apps/dashboard/app/(authenticated)/sales/vehicles/page.tsx
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { ResourcePage } from '@/shared/data-view/resource-page'
|
||||||
|
import { ColumnHeader } from '@/shared/data-view/table-view'
|
||||||
|
import { VehicleForm } from '@/modules/vehicles/vehicle-form'
|
||||||
|
import { VEHICLE_ROUTES } from '@garage/api'
|
||||||
|
import type { VehiclesClient } from '@garage/api'
|
||||||
|
import { CarIcon } from 'lucide-react'
|
||||||
|
|
||||||
|
export default function VehiclesPage() {
|
||||||
|
return (
|
||||||
|
<ResourcePage<VehiclesClient>
|
||||||
|
pageTitle="Vehicles"
|
||||||
|
title="Vehicle"
|
||||||
|
routeKey={VEHICLE_ROUTES.INDEX}
|
||||||
|
getClient={(api) => api.vehicles}
|
||||||
|
|
||||||
|
|
||||||
|
columns={({ actionsColumn }) => [
|
||||||
|
{
|
||||||
|
accessorKey: "name",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Vehicle" />,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const r = row.original as any
|
||||||
|
const make = r.make ?? ""
|
||||||
|
const model = r.model ?? ""
|
||||||
|
const display = r.name || `${make} ${model}`.trim() || "—"
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CarIcon className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">{display}</span>
|
||||||
|
{r.sub_model && (
|
||||||
|
<span className="ml-1 text-xs text-muted-foreground">{r.sub_model}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "year",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Year" />,
|
||||||
|
cell: ({ row }) => (row.original as any).year ?? "—",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "license_plate",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="License Plate" />,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const val = (row.original as any).license_plate
|
||||||
|
return val
|
||||||
|
? <span className="font-mono text-xs">{val}</span>
|
||||||
|
: "—"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "vin_number",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="VIN" />,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const val = (row.original as any).vin_number
|
||||||
|
return val
|
||||||
|
? <span className="max-w-30 truncate block font-mono text-xs">{val}</span>
|
||||||
|
: "—"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "engine_size",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Engine" />,
|
||||||
|
cell: ({ row }) => (row.original as any).engine_size ?? "—",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "mileage",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Mileage" />,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const val = (row.original as any).mileage
|
||||||
|
return val != null ? `${Number(val).toLocaleString()} mi` : "—"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "created_at",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Created" />,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const val = (row.original as any).created_at
|
||||||
|
return val ? new Date(val).toLocaleDateString() : "—"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
actionsColumn(),
|
||||||
|
]}
|
||||||
|
renderForm={({ resourceId, initialData, onSuccess }) => (
|
||||||
|
<VehicleForm
|
||||||
|
resourceId={resourceId}
|
||||||
|
initialData={initialData}
|
||||||
|
onSuccess={onSuccess}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,54 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { ResourcePage } from "@/shared/data-view/resource-page"
|
||||||
|
import { ColumnHeader } from "@/shared/data-view/table-view"
|
||||||
|
import { ShopTypeForm } from "@/modules/settings/shop-type/shop-type-form"
|
||||||
|
import { SHOP_TYPE_ROUTES } from "@garage/api"
|
||||||
|
import type { ShopTypesClient } from "@garage/api"
|
||||||
|
import { CheckIcon, XIcon } from "lucide-react"
|
||||||
|
|
||||||
|
export default function ShopTypesPage() {
|
||||||
|
return (
|
||||||
|
<ResourcePage<ShopTypesClient>
|
||||||
|
pageTitle="Shop Types"
|
||||||
|
title="Shop Type"
|
||||||
|
routeKey={SHOP_TYPE_ROUTES.INDEX}
|
||||||
|
getClient={(api) => api.shopTypes}
|
||||||
|
columns={({ actionsColumn }) => [
|
||||||
|
{
|
||||||
|
accessorKey: "title",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Title" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "shop_type",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Type" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "note",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Note" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-muted-foreground line-clamp-1">
|
||||||
|
{(row.original as any).note ?? "—"}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "is_default",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Default" />,
|
||||||
|
cell: ({ row }) =>
|
||||||
|
(row.original as any).is_default
|
||||||
|
? <CheckIcon className="h-4 w-4 text-green-600" />
|
||||||
|
: <XIcon className="h-4 w-4 text-muted-foreground" />,
|
||||||
|
},
|
||||||
|
actionsColumn(),
|
||||||
|
]}
|
||||||
|
renderForm={({ resourceId, initialData, onSuccess }) => (
|
||||||
|
<ShopTypeForm
|
||||||
|
resourceId={resourceId}
|
||||||
|
initialData={initialData}
|
||||||
|
onSuccess={onSuccess}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
BIN
apps/dashboard/app/favicon.ico
Normal file
BIN
apps/dashboard/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
179
apps/dashboard/app/globals.css
Normal file
179
apps/dashboard/app/globals.css
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
@import "shadcn/tailwind.css";
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--font-heading: var(--font-sans);
|
||||||
|
--font-sans: var(--font-sans);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-background: var(--background);
|
||||||
|
--radius-sm: calc(var(--radius) * 0.6);
|
||||||
|
--radius-md: calc(var(--radius) * 0.8);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) * 1.4);
|
||||||
|
--radius-2xl: calc(var(--radius) * 1.8);
|
||||||
|
--radius-3xl: calc(var(--radius) * 2.2);
|
||||||
|
--radius-4xl: calc(var(--radius) * 2.6);
|
||||||
|
|
||||||
|
--shadow-glow : 0 0 10px var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: oklch(96.416% 0.00011 271.152);
|
||||||
|
--foreground: oklch(0.062 0 0);
|
||||||
|
--card: oklch(0.975 0 0);
|
||||||
|
--card-foreground: oklch(0.281 0 0);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
|
--primary: oklch(0.577 0.245 27.325);
|
||||||
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
|
--secondary: oklch(0.97 0 0);
|
||||||
|
--secondary-foreground: oklch(0.205 0 0);
|
||||||
|
--muted: oklch(0.949 0 0);
|
||||||
|
--muted-foreground: oklch(0.6 0 0);
|
||||||
|
--accent: oklch(0.97 0 0);
|
||||||
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--border: oklch(0.922 0 0);
|
||||||
|
--input: oklch(0.922 0 0);
|
||||||
|
--ring: oklch(0.708 0 0);
|
||||||
|
--chart-1: oklch(0.87 0 0);
|
||||||
|
--chart-2: oklch(0.556 0 0);
|
||||||
|
--chart-3: oklch(0.439 0 0);
|
||||||
|
--chart-4: oklch(0.371 0 0);
|
||||||
|
--chart-5: oklch(0.269 0 0);
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--sidebar: oklch(0.985 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.145 0 0);
|
||||||
|
--sidebar-primary: oklch(0.205 0 0);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.97 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: oklch(0.145 0 0);
|
||||||
|
--foreground: oklch(0.985 0 0);
|
||||||
|
--card: oklch(0.205 0 0);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: oklch(0.205 0 0);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--primary: oklch(75.417% 0.14818 18.15);
|
||||||
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
|
--secondary: oklch(0.269 0 0);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: oklch(0.269 0 0);
|
||||||
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
|
--accent: oklch(0.269 0 0);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: oklch(0.556 0 0);
|
||||||
|
--chart-1: oklch(0.87 0 0);
|
||||||
|
--chart-2: oklch(0.556 0 0);
|
||||||
|
--chart-3: oklch(0.439 0 0);
|
||||||
|
--chart-4: oklch(0.371 0 0);
|
||||||
|
--chart-5: oklch(0.269 0 0);
|
||||||
|
--sidebar: oklch(0.205 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.269 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: oklch(0.556 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
@apply font-sans;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
.dashboard-nav-item {
|
||||||
|
@apply
|
||||||
|
relative
|
||||||
|
overflow-hidden
|
||||||
|
data-active:bg-primary/10
|
||||||
|
data-active:text-primary
|
||||||
|
data-active:hover:text-primary
|
||||||
|
data-active:hover:bg-primary/15
|
||||||
|
transition-all
|
||||||
|
duration-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Accent bar — only in expanded mode */
|
||||||
|
/* .dashboard-nav-item:not([data-collapsed="true"])::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset-inline-end: 0.25rem;
|
||||||
|
height: 80%;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
z-index: 10;
|
||||||
|
box-shadow: 0 0 6px var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-nav-item:not([data-collapsed="true"])[data-active="true"]::after {
|
||||||
|
width: 0.25rem;
|
||||||
|
background-color: var(--primary);
|
||||||
|
} */
|
||||||
|
|
||||||
|
/* Collapsed mode: icon centered, no bar */
|
||||||
|
.dashboard-nav-item[data-collapsed="true"] {
|
||||||
|
@apply justify-center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-nav-sub-item {
|
||||||
|
@apply
|
||||||
|
transition-colors
|
||||||
|
duration-200
|
||||||
|
data-active:text-primary
|
||||||
|
data-active:font-medium
|
||||||
|
data-active:bg-primary/5
|
||||||
|
hover:text-primary/80;
|
||||||
|
}
|
||||||
|
}
|
||||||
40
apps/dashboard/app/layout.tsx
Normal file
40
apps/dashboard/app/layout.tsx
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { Geist_Mono, Inter } from "next/font/google"
|
||||||
|
|
||||||
|
import "./globals.css"
|
||||||
|
import { QueryProvider } from "@/shared/components/query-provider"
|
||||||
|
import { ThemeProvider } from "@/shared/components/theme-provider"
|
||||||
|
import { Toaster } from "@/shared/components/ui/sonner"
|
||||||
|
import { ConfirmDialog } from "@/shared/components/confirm-dialog"
|
||||||
|
import { NuqsAdapter } from "nuqs/adapters/next/app"
|
||||||
|
import { cn } from "@/shared/lib/utils"
|
||||||
|
|
||||||
|
const inter = Inter({ subsets: ["latin"], variable: "--font-sans" })
|
||||||
|
|
||||||
|
const fontMono = Geist_Mono({
|
||||||
|
subsets: ["latin"],
|
||||||
|
variable: "--font-mono",
|
||||||
|
})
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html
|
||||||
|
lang="en"
|
||||||
|
suppressHydrationWarning
|
||||||
|
className={cn("antialiased", fontMono.variable, "font-sans", inter.variable)}
|
||||||
|
>
|
||||||
|
<body>
|
||||||
|
<NuqsAdapter>
|
||||||
|
<ThemeProvider>
|
||||||
|
<QueryProvider>{children}</QueryProvider>
|
||||||
|
<Toaster />
|
||||||
|
<ConfirmDialog />
|
||||||
|
</ThemeProvider>
|
||||||
|
</NuqsAdapter>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
19
apps/dashboard/base/components/auth-store-initializer.tsx
Normal file
19
apps/dashboard/base/components/auth-store-initializer.tsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useRef } from "react"
|
||||||
|
import { useAuthStore } from "@/shared/stores/auth-store"
|
||||||
|
import type { AuthUser } from "@garage/api"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronously initializes the auth store from server-side token/user before
|
||||||
|
* any child component renders. This avoids the first-render race condition where
|
||||||
|
* useEffect-based hydration hasn't fired yet and API requests go out without a token.
|
||||||
|
*/
|
||||||
|
export function AuthStoreInitializer({ token, user }: { token: string; user: AuthUser }) {
|
||||||
|
const initialized = useRef(false)
|
||||||
|
if (!initialized.current) {
|
||||||
|
initialized.current = true
|
||||||
|
useAuthStore.setState({ token, user, isAuthenticated: true })
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
240
apps/dashboard/base/components/layout/dashboard/app-sidebar.tsx
Normal file
240
apps/dashboard/base/components/layout/dashboard/app-sidebar.tsx
Normal file
@ -0,0 +1,240 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import Link from "next/link"
|
||||||
|
import { usePathname } from "next/navigation"
|
||||||
|
import { ChevronRight, Circle } from "lucide-react"
|
||||||
|
|
||||||
|
import type { NavGroup, NavItem } from "@/base/types/navigation"
|
||||||
|
import { cn } from "@/shared/lib/utils"
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from "@/shared/components/ui/collapsible"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/shared/components/ui/dropdown-menu"
|
||||||
|
import {
|
||||||
|
Sidebar,
|
||||||
|
SidebarContent,
|
||||||
|
SidebarGroup,
|
||||||
|
SidebarGroupLabel,
|
||||||
|
SidebarHeader,
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
SidebarMenuSub,
|
||||||
|
SidebarMenuSubButton,
|
||||||
|
SidebarMenuSubItem,
|
||||||
|
SidebarRail,
|
||||||
|
useSidebar,
|
||||||
|
} from "@/shared/components/ui/sidebar"
|
||||||
|
|
||||||
|
type AppSidebarProps = React.ComponentProps<typeof Sidebar> & {
|
||||||
|
navGroups: NavGroup[]
|
||||||
|
logo?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppSidebar({ navGroups, logo, ...props }: AppSidebarProps) {
|
||||||
|
const { state, isMobile } = useSidebar()
|
||||||
|
const isCollapsed = state === "collapsed" && !isMobile
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sidebar collapsible="icon" {...props} className="bg-card">
|
||||||
|
{logo && (
|
||||||
|
<SidebarHeader className="flex p-4">
|
||||||
|
{logo}
|
||||||
|
</SidebarHeader>
|
||||||
|
)}
|
||||||
|
<SidebarContent className={cn("transition-[padding] duration-200", !isCollapsed && "ps-2")}>
|
||||||
|
{navGroups.map((group, groupIndex) => (
|
||||||
|
<SidebarGroup key={group.label ?? groupIndex}>
|
||||||
|
{group.label && (
|
||||||
|
<SidebarGroupLabel className="uppercase text-xs tracking-wider text-muted-foreground">
|
||||||
|
{group.label}
|
||||||
|
</SidebarGroupLabel>
|
||||||
|
)}
|
||||||
|
<SidebarMenu>
|
||||||
|
{group.items.map((item) =>
|
||||||
|
item.items && item.items.length > 0 ? (
|
||||||
|
<CollapsibleNavItem key={item.href} item={item} isCollapsed={isCollapsed} />
|
||||||
|
) : (
|
||||||
|
<SimpleNavItem key={item.href} item={item} isCollapsed={isCollapsed} />
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroup>
|
||||||
|
))}
|
||||||
|
</SidebarContent>
|
||||||
|
<SidebarRail />
|
||||||
|
</Sidebar>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SimpleNavItem({ item, isCollapsed }: { item: NavItem; isCollapsed: boolean }) {
|
||||||
|
const pathname = usePathname()
|
||||||
|
const isActive = item.isActive ?? pathname === item.href
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<SidebarMenuButton
|
||||||
|
|
||||||
|
asChild
|
||||||
|
isActive={isActive}
|
||||||
|
tooltip={item.title}
|
||||||
|
className="dashboard-nav-item"
|
||||||
|
data-collapsed={isCollapsed}
|
||||||
|
>
|
||||||
|
<Link href={item.href}>
|
||||||
|
{item.icon}
|
||||||
|
{
|
||||||
|
!isCollapsed &&
|
||||||
|
<span>{item.title}</span>
|
||||||
|
}
|
||||||
|
</Link>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapsibleNavItem({ item, isCollapsed }: { item: NavItem; isCollapsed: boolean }) {
|
||||||
|
const pathname = usePathname()
|
||||||
|
const isChildActive = item.items?.some((sub) => pathname === sub.href)
|
||||||
|
const isActive = item.isActive ?? (pathname === item.href || isChildActive === true)
|
||||||
|
|
||||||
|
// Collapsed sidebar → flyout dropdown with sub-items
|
||||||
|
if (isCollapsed) {
|
||||||
|
return (
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<SidebarMenuButton
|
||||||
|
|
||||||
|
isActive={isActive}
|
||||||
|
tooltip={item.title}
|
||||||
|
className="dashboard-nav-item"
|
||||||
|
data-collapsed={isCollapsed}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"transition-transform duration-300",
|
||||||
|
isActive && "text-primary"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
</span>
|
||||||
|
{
|
||||||
|
!isCollapsed &&
|
||||||
|
<span>{item.title}</span>
|
||||||
|
}
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
side="right"
|
||||||
|
align="start"
|
||||||
|
sideOffset={4}
|
||||||
|
className="min-w-45"
|
||||||
|
>
|
||||||
|
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
||||||
|
{item.title}
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{item.items?.map((sub) => {
|
||||||
|
const isSubActive = sub.isActive ?? pathname === sub.href
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem key={sub.href} asChild>
|
||||||
|
<Link
|
||||||
|
href={sub.href}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2",
|
||||||
|
isSubActive && "bg-primary/10 text-primary font-medium"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{sub.icon ? (
|
||||||
|
<span className={cn("shrink-0 [&>svg]:size-4", isSubActive ? "text-primary" : "text-muted-foreground/70")}>
|
||||||
|
{sub.icon}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<Circle
|
||||||
|
className={cn(
|
||||||
|
"size-1.5",
|
||||||
|
isSubActive ? "fill-primary text-primary" : "fill-muted-foreground/50 text-muted-foreground/50"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{sub.title}
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expanded sidebar → collapsible/accordion sub-menu
|
||||||
|
return (
|
||||||
|
<Collapsible asChild defaultOpen={isActive} className="group/collapsible">
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<SidebarMenuButton tooltip={item.title} isActive={isActive} className="dashboard-nav-item" data-collapsed={isCollapsed}>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"transition-transform duration-300",
|
||||||
|
isActive && "text-primary"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
|
||||||
|
<span>{item.title}</span>
|
||||||
|
|
||||||
|
<ChevronRight
|
||||||
|
className={cn(
|
||||||
|
"ms-auto size-4 shrink-0 transition-transform duration-300 ease-[cubic-bezier(0.87,0,0.13,1)]",
|
||||||
|
"group-data-[state=open]/collapsible:rotate-90"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent className="overflow-hidden py-2 data-[state=open]:animate-collapsible-down data-[state=closed]:animate-collapsible-up">
|
||||||
|
<SidebarMenuSub>
|
||||||
|
{item.items?.map((sub) => {
|
||||||
|
const isSubActive = sub.isActive ?? pathname === sub.href
|
||||||
|
return (
|
||||||
|
<SidebarMenuSubItem key={sub.href}>
|
||||||
|
<SidebarMenuSubButton asChild isActive={isSubActive} className="dashboard-nav-sub-item my-0.5">
|
||||||
|
<Link href={sub.href}>
|
||||||
|
{sub.icon ? (
|
||||||
|
<span className={cn("shrink-0 transition-colors duration-200 [&>svg]:size-4", isSubActive ? "text-primary" : "text-muted-foreground/70 group-hover/menu-sub-item:text-primary")}>
|
||||||
|
{sub.icon}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<Circle
|
||||||
|
className={cn(
|
||||||
|
"size-1.5 transition-colors duration-200",
|
||||||
|
isSubActive
|
||||||
|
? "fill-primary text-primary"
|
||||||
|
: "fill-muted-foreground/40 text-muted-foreground/40 group-hover/menu-sub-item:fill-foreground group-hover/menu-sub-item:text-primary"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span>{sub.title}</span>
|
||||||
|
</Link>
|
||||||
|
</SidebarMenuSubButton>
|
||||||
|
</SidebarMenuSubItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</SidebarMenuSub>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</Collapsible>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,210 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { useTheme } from "next-themes"
|
||||||
|
import {
|
||||||
|
BellIcon,
|
||||||
|
LogOutIcon,
|
||||||
|
MoonIcon,
|
||||||
|
SearchIcon,
|
||||||
|
SunIcon,
|
||||||
|
UserIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
import type { UserInfo } from "@/base/types/navigation"
|
||||||
|
import { useAuthStore } from "@/shared/stores/auth-store"
|
||||||
|
import { cn } from "@/shared/lib/utils"
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { SidebarTrigger } from "@/shared/components/ui/sidebar"
|
||||||
|
import {
|
||||||
|
CommandDialog,
|
||||||
|
Command,
|
||||||
|
CommandInput,
|
||||||
|
CommandList,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandItem,
|
||||||
|
} from "@/shared/components/ui/command"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/shared/components/ui/dropdown-menu"
|
||||||
|
import { Separator } from "@/shared/components/ui/separator"
|
||||||
|
|
||||||
|
type DashboardHeaderProps = {
|
||||||
|
user?: UserInfo
|
||||||
|
actions?: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardHeader({ actions, className }: DashboardHeaderProps) {
|
||||||
|
const { resolvedTheme, setTheme } = useTheme()
|
||||||
|
const [searchOpen, setSearchOpen] = useState(false)
|
||||||
|
const { logout, user } = useAuthStore((s) => s)
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const handleLogout = useCallback(async () => {
|
||||||
|
await logout()
|
||||||
|
router.push("/login")
|
||||||
|
}, [logout, router])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
||||||
|
e.preventDefault()
|
||||||
|
setSearchOpen((prev) => !prev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener("keydown", onKeyDown)
|
||||||
|
return () => window.removeEventListener("keydown", onKeyDown)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const toggleTheme = useCallback(() => {
|
||||||
|
setTheme(resolvedTheme === "dark" ? "light" : "dark")
|
||||||
|
}, [resolvedTheme, setTheme])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header
|
||||||
|
className={cn(
|
||||||
|
"sticky top-0 z-30 flex h-18 shrink-0 items-center gap-2 border-b bg-card px-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Sidebar toggle — mobile: hamburger, desktop: collapse */}
|
||||||
|
<SidebarTrigger className="-ms-2" />
|
||||||
|
<Separator orientation="vertical" />
|
||||||
|
|
||||||
|
{/* Left side — default actions */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{/* User dropdown */}
|
||||||
|
{/* {user && ( */}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" className="flex items-center gap-2 px-2">
|
||||||
|
<Avatar >
|
||||||
|
{user?.avatar && <AvatarImage src={user?.avatar as string} alt={user?.name} />}
|
||||||
|
<AvatarFallback>
|
||||||
|
{user?.initials ?? user?.name.charAt(0).toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span className="hidden text-sm font-medium md:inline-block">
|
||||||
|
{user?.name}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
|
<DropdownMenuContent align="end" className="w-56">
|
||||||
|
{/* User info header */}
|
||||||
|
<DropdownMenuLabel className="font-normal">
|
||||||
|
<div className="flex items-center gap-3 py-1">
|
||||||
|
<Avatar size="lg">
|
||||||
|
{user?.avatar && <AvatarImage src={user?.avatar as string} alt={user?.name} />}
|
||||||
|
<AvatarFallback className="text-base">
|
||||||
|
{user?.initials ?? user?.name.charAt(0).toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-medium text-foreground">{user?.name}</span>
|
||||||
|
{user?.email && (
|
||||||
|
<span className="text-xs text-muted-foreground">{user?.email}</span>
|
||||||
|
)}
|
||||||
|
{user?.role && (
|
||||||
|
<span className="mt-0.5 text-xs font-medium text-primary">{user?.role}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href="/profile">
|
||||||
|
<UserIcon />
|
||||||
|
Profile
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
|
<DropdownMenuItem variant="destructive" onSelect={handleLogout}>
|
||||||
|
<LogOutIcon />
|
||||||
|
Logout
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
{/* )} */}
|
||||||
|
|
||||||
|
|
||||||
|
{/* Search trigger */}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="hidden h-8 w-56 justify-start gap-2 text-muted-foreground md:flex"
|
||||||
|
onClick={() => setSearchOpen(true)}
|
||||||
|
>
|
||||||
|
<SearchIcon className="size-4" />
|
||||||
|
<span className="text-sm">Search…</span>
|
||||||
|
<kbd className="pointer-events-none ms-auto inline-flex h-5 select-none items-center gap-0.5 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground">
|
||||||
|
⌘K
|
||||||
|
</kbd>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Mobile search icon */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="md:hidden"
|
||||||
|
aria-label="Search"
|
||||||
|
onClick={() => setSearchOpen(true)}
|
||||||
|
>
|
||||||
|
<SearchIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Theme toggle */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label="Toggle theme"
|
||||||
|
onClick={toggleTheme}
|
||||||
|
>
|
||||||
|
<SunIcon className="size-4 rotate-0 scale-100 transition-transform dark:-rotate-90 dark:scale-0" />
|
||||||
|
<MoonIcon className="absolute size-4 rotate-90 scale-0 transition-transform dark:rotate-0 dark:scale-100" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Notifications */}
|
||||||
|
<Button variant="ghost" size="icon-sm" aria-label="Notifications">
|
||||||
|
<BellIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search command dialog */}
|
||||||
|
<CommandDialog open={searchOpen} onOpenChange={setSearchOpen}>
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="Type to search…" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>No results found.</CommandEmpty>
|
||||||
|
<CommandGroup heading="Quick Actions">
|
||||||
|
<CommandItem>Dashboard</CommandItem>
|
||||||
|
<CommandItem>Job Cards</CommandItem>
|
||||||
|
<CommandItem>Customers</CommandItem>
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</CommandDialog>
|
||||||
|
|
||||||
|
{/* Right side — custom actions */}
|
||||||
|
{actions && (
|
||||||
|
<div className="ms-auto flex items-center gap-2">{actions}</div>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,41 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import type { NavGroup, UserInfo } from "@/base/types/navigation"
|
||||||
|
import { SidebarInset, SidebarProvider } from "@/shared/components/ui/sidebar"
|
||||||
|
import { TooltipProvider } from "@/shared/components/ui/tooltip"
|
||||||
|
import { AppSidebar } from "./app-sidebar"
|
||||||
|
import { DashboardHeader } from "./dashboard-header"
|
||||||
|
|
||||||
|
type DashboardLayoutProps = {
|
||||||
|
children: React.ReactNode
|
||||||
|
/** Navigation groups rendered in the sidebar */
|
||||||
|
navGroups: NavGroup[]
|
||||||
|
/** Logo element displayed at the top of the sidebar */
|
||||||
|
logo?: React.ReactNode
|
||||||
|
/** Current user info shown in the header */
|
||||||
|
user?: UserInfo
|
||||||
|
/** Custom actions rendered in the header (e.g. session timer, clock-in button) */
|
||||||
|
headerActions?: React.ReactNode
|
||||||
|
/** Default sidebar open state */
|
||||||
|
defaultOpen?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardLayout({
|
||||||
|
children,
|
||||||
|
navGroups,
|
||||||
|
logo,
|
||||||
|
user,
|
||||||
|
headerActions,
|
||||||
|
defaultOpen = true,
|
||||||
|
}: DashboardLayoutProps) {
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<SidebarProvider defaultOpen={defaultOpen}>
|
||||||
|
<AppSidebar navGroups={navGroups} logo={logo} />
|
||||||
|
<SidebarInset>
|
||||||
|
{children}
|
||||||
|
</SidebarInset>
|
||||||
|
</SidebarProvider>
|
||||||
|
</TooltipProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
import { cn } from '@/shared/lib/utils'
|
||||||
|
import { title } from 'process'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
export default function DashboardPage({ children, header, title, fullscreen }: { children: React.ReactNode, header: React.ReactNode, title?: string, fullscreen?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className='page'>
|
||||||
|
<header>
|
||||||
|
{header}
|
||||||
|
</header>
|
||||||
|
<main className={cn('p-4 lg:p-8 w-full h-full', fullscreen && 'h-screen p-0 lg:p-0')}>
|
||||||
|
{
|
||||||
|
title &&
|
||||||
|
<h2 className='text-lg lg:text-2xl font-bold mb-4'> {title}</h2>
|
||||||
|
}
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
3
apps/dashboard/base/components/layout/dashboard/index.ts
Normal file
3
apps/dashboard/base/components/layout/dashboard/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export { DashboardLayout } from "./dashboard-layout"
|
||||||
|
export { AppSidebar } from "./app-sidebar"
|
||||||
|
export { DashboardHeader } from "./dashboard-header"
|
||||||
31
apps/dashboard/base/types/navigation.ts
Normal file
31
apps/dashboard/base/types/navigation.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { ReactNode } from "react"
|
||||||
|
|
||||||
|
|
||||||
|
export type NavItem = {
|
||||||
|
title: string
|
||||||
|
href: string
|
||||||
|
icon?: ReactNode
|
||||||
|
isActive?: boolean
|
||||||
|
badge?: string | number
|
||||||
|
items?: NavSubItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NavSubItem = {
|
||||||
|
title: string
|
||||||
|
href: string
|
||||||
|
icon?: ReactNode
|
||||||
|
isActive?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NavGroup = {
|
||||||
|
label?: string
|
||||||
|
items: NavItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UserInfo = {
|
||||||
|
name: string
|
||||||
|
email?: string
|
||||||
|
avatar?: string
|
||||||
|
initials?: string
|
||||||
|
role?: string
|
||||||
|
}
|
||||||
25
apps/dashboard/components.json
Normal file
25
apps/dashboard/components.json
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "radix-vega",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "app/globals.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"rtl": true,
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/shared/components",
|
||||||
|
"utils": "@/shared/lib/utils",
|
||||||
|
"ui": "@/shared/components/ui",
|
||||||
|
"lib": "@/shared/lib",
|
||||||
|
"hooks": "@/shared/hooks"
|
||||||
|
},
|
||||||
|
"menuColor": "default",
|
||||||
|
"menuAccent": "subtle",
|
||||||
|
"registries": {}
|
||||||
|
}
|
||||||
16
apps/dashboard/cypress.config.ts
Normal file
16
apps/dashboard/cypress.config.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { defineConfig } from "cypress"
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
e2e: {
|
||||||
|
baseUrl: "http://localhost:3000",
|
||||||
|
env: {
|
||||||
|
NEXT_PUBLIC_API_URL: "https://newgarage.yslootahtech.com"
|
||||||
|
},
|
||||||
|
specPattern: "cypress/e2e/**/*.cy.{ts,tsx}",
|
||||||
|
supportFile: "cypress/support/e2e.ts",
|
||||||
|
viewportWidth: 1280,
|
||||||
|
viewportHeight: 720,
|
||||||
|
defaultCommandTimeout: 10000,
|
||||||
|
requestTimeout: 10000,
|
||||||
|
},
|
||||||
|
})
|
||||||
@ -0,0 +1,293 @@
|
|||||||
|
describe("Customer Form – Integration Tests", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cy.login()
|
||||||
|
|
||||||
|
cy.fixture("customers").then((data) => {
|
||||||
|
cy.intercept("GET", "**/api/referral-sources", {
|
||||||
|
statusCode: 200,
|
||||||
|
body: data.referral_sources,
|
||||||
|
}).as("getReferralSources")
|
||||||
|
|
||||||
|
cy.intercept("GET", "**/api/payment-terms", {
|
||||||
|
statusCode: 200,
|
||||||
|
body: data.payment_terms,
|
||||||
|
}).as("getPaymentTerms")
|
||||||
|
|
||||||
|
cy.intercept("GET", "**/api/countries", {
|
||||||
|
statusCode: 200,
|
||||||
|
body: data.countries,
|
||||||
|
}).as("getCountries")
|
||||||
|
|
||||||
|
cy.intercept("GET", "**/api/states", {
|
||||||
|
statusCode: 200,
|
||||||
|
body: data.states,
|
||||||
|
}).as("getStates")
|
||||||
|
|
||||||
|
cy.intercept("GET", "**/api/customers*", {
|
||||||
|
statusCode: 200,
|
||||||
|
body: { success: true, data: { data: [], pagination: { total: 0 } } },
|
||||||
|
}).as("getCustomers")
|
||||||
|
})
|
||||||
|
|
||||||
|
cy.visit("/sales/customers")
|
||||||
|
cy.contains("button", "Create Customer").click()
|
||||||
|
cy.get("[role='dialog']").should("be.visible")
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Form interaction flow ──
|
||||||
|
|
||||||
|
describe("Field interactions", () => {
|
||||||
|
it("should clear a text field after typing", () => {
|
||||||
|
cy.get("[role='dialog']").within(() => {
|
||||||
|
cy.get("input[name='first_name']")
|
||||||
|
.type("John")
|
||||||
|
.should("have.value", "John")
|
||||||
|
.clear()
|
||||||
|
.should("have.value", "")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle special characters in text inputs", () => {
|
||||||
|
cy.get("[role='dialog']").within(() => {
|
||||||
|
cy.get("input[name='first_name']").type("José-María").should("have.value", "José-María")
|
||||||
|
cy.get("input[name='last_name']").type("O'Brien").should("have.value", "O'Brien")
|
||||||
|
cy.get("input[name='company_name']").type("Smith & Co.").should("have.value", "Smith & Co.")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should accept various email formats", () => {
|
||||||
|
cy.get("[role='dialog']").within(() => {
|
||||||
|
cy.get("input[name='first_name']").type("Test")
|
||||||
|
cy.get("input[name='last_name']").type("User")
|
||||||
|
|
||||||
|
// Valid email should not show error
|
||||||
|
cy.get("input[name='email']").type("user+tag@sub.domain.com")
|
||||||
|
cy.contains("button", "Create Customer").click()
|
||||||
|
cy.contains("Enter a valid email address").should("not.exist")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle phone number input", () => {
|
||||||
|
cy.get("[role='dialog']").within(() => {
|
||||||
|
cy.get("input[name='phone']")
|
||||||
|
.type("0501234567")
|
||||||
|
.should("have.value", "0501234567")
|
||||||
|
|
||||||
|
cy.get("input[name='alternate_phone']")
|
||||||
|
.type("+971501234567")
|
||||||
|
.should("have.value", "+971501234567")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Async select integration ──
|
||||||
|
|
||||||
|
describe("Async select fields", () => {
|
||||||
|
it("should show loading state while fetching referral sources", () => {
|
||||||
|
cy.intercept("GET", "**/api/referral-sources", {
|
||||||
|
statusCode: 200,
|
||||||
|
body: { success: true, data: { data: [{ id: 1, name: "Google" }] } },
|
||||||
|
delay: 2000,
|
||||||
|
}).as("slowReferralSources")
|
||||||
|
|
||||||
|
// Reload to get the delayed intercept
|
||||||
|
cy.visit("/sales/customers")
|
||||||
|
cy.contains("button", "Create Customer").click()
|
||||||
|
cy.get("[role='dialog']").should("be.visible")
|
||||||
|
|
||||||
|
cy.get("[role='dialog']").within(() => {
|
||||||
|
cy.contains("label", "Referral Source").parent().find("input").click()
|
||||||
|
})
|
||||||
|
|
||||||
|
// The component should show a loading spinner
|
||||||
|
cy.get("[role='listbox']").should("be.visible")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should filter options by text input in combobox", () => {
|
||||||
|
cy.wait("@getReferralSources")
|
||||||
|
|
||||||
|
cy.get("[role='dialog']").within(() => {
|
||||||
|
cy.contains("label", "Referral Source").parent().find("input").click().type("Goo")
|
||||||
|
})
|
||||||
|
|
||||||
|
// Should show Google, shouldn't show Friend Referral
|
||||||
|
cy.get("[role='option']").contains("Google").should("exist")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should show empty state when no options match", () => {
|
||||||
|
cy.wait("@getCountries")
|
||||||
|
|
||||||
|
cy.get("[role='dialog']").within(() => {
|
||||||
|
cy.contains("label", "Country").parent().find("input").click().type("zzzzz")
|
||||||
|
})
|
||||||
|
|
||||||
|
cy.contains("No results found").should("be.visible")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should select a payment term from the combobox", () => {
|
||||||
|
cy.wait("@getPaymentTerms")
|
||||||
|
|
||||||
|
cy.get("[role='dialog']").within(() => {
|
||||||
|
cy.contains("label", "Payment Terms").parent().find("input").click()
|
||||||
|
})
|
||||||
|
|
||||||
|
cy.get("[role='option']").contains("Net 30").click()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should select a state from the combobox", () => {
|
||||||
|
cy.wait("@getStates")
|
||||||
|
|
||||||
|
cy.get("[role='dialog']").within(() => {
|
||||||
|
cy.contains("label", "State").parent().find("input").click()
|
||||||
|
})
|
||||||
|
|
||||||
|
cy.get("[role='option']").contains("Dubai").click()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Validation edge cases ──
|
||||||
|
|
||||||
|
describe("Validation edge cases", () => {
|
||||||
|
it("should validate only on submit (not on blur)", () => {
|
||||||
|
cy.get("[role='dialog']").within(() => {
|
||||||
|
// Focus and blur first_name without typing
|
||||||
|
cy.get("input[name='first_name']").focus().blur()
|
||||||
|
|
||||||
|
// Error should NOT appear yet (react-hook-form validates on submit by default)
|
||||||
|
cy.contains("First name is required").should("not.exist")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should clear validation errors when user corrects input", () => {
|
||||||
|
cy.get("[role='dialog']").within(() => {
|
||||||
|
// Trigger validation
|
||||||
|
cy.contains("button", "Create Customer").click()
|
||||||
|
cy.contains("First name is required").should("be.visible")
|
||||||
|
|
||||||
|
// Fix the error
|
||||||
|
cy.get("input[name='first_name']").type("John")
|
||||||
|
cy.contains("First name is required").should("not.exist")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should trim whitespace-only inputs and still require first_name", () => {
|
||||||
|
cy.get("[role='dialog']").within(() => {
|
||||||
|
cy.get("input[name='first_name']").type(" ")
|
||||||
|
cy.get("input[name='last_name']").type("Doe")
|
||||||
|
cy.contains("button", "Create Customer").click()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should allow submission with only required fields", () => {
|
||||||
|
cy.fixture("customers").then((data) => {
|
||||||
|
cy.intercept("POST", "**/api/customers", {
|
||||||
|
statusCode: 201,
|
||||||
|
body: data.customer_created,
|
||||||
|
}).as("createCustomer")
|
||||||
|
})
|
||||||
|
|
||||||
|
cy.get("[role='dialog']").within(() => {
|
||||||
|
cy.get("input[name='first_name']").type("Jane")
|
||||||
|
cy.get("input[name='last_name']").type("Smith")
|
||||||
|
cy.contains("button", "Create Customer").click()
|
||||||
|
})
|
||||||
|
|
||||||
|
cy.wait("@createCustomer").its("request.body").should((body) => {
|
||||||
|
expect(body.first_name).to.eq("Jane")
|
||||||
|
expect(body.last_name).to.eq("Smith")
|
||||||
|
// Optional fields should be empty or undefined
|
||||||
|
expect(body.company_name).to.satisfy(
|
||||||
|
(v: unknown) => v === "" || v === undefined || v === null,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── API error scenarios ──
|
||||||
|
|
||||||
|
describe("API error handling", () => {
|
||||||
|
it("should handle network error gracefully", () => {
|
||||||
|
cy.intercept("POST", "**/api/customers", { forceNetworkError: true }).as(
|
||||||
|
"networkError",
|
||||||
|
)
|
||||||
|
|
||||||
|
cy.get("[role='dialog']").within(() => {
|
||||||
|
cy.get("input[name='first_name']").type("John")
|
||||||
|
cy.get("input[name='last_name']").type("Doe")
|
||||||
|
cy.contains("button", "Create Customer").click()
|
||||||
|
})
|
||||||
|
|
||||||
|
cy.wait("@networkError")
|
||||||
|
|
||||||
|
cy.get("[role='dialog']").within(() => {
|
||||||
|
cy.contains("Failed to create customer").should("be.visible")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle 500 server error", () => {
|
||||||
|
cy.intercept("POST", "**/api/customers", {
|
||||||
|
statusCode: 500,
|
||||||
|
body: { success: false, message: "Internal server error" },
|
||||||
|
}).as("serverError")
|
||||||
|
|
||||||
|
cy.get("[role='dialog']").within(() => {
|
||||||
|
cy.get("input[name='first_name']").type("John")
|
||||||
|
cy.get("input[name='last_name']").type("Doe")
|
||||||
|
cy.contains("button", "Create Customer").click()
|
||||||
|
})
|
||||||
|
|
||||||
|
cy.wait("@serverError")
|
||||||
|
|
||||||
|
cy.get("[role='dialog']").within(() => {
|
||||||
|
cy.contains("Failed to create customer").should("be.visible")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle 422 validation error from server", () => {
|
||||||
|
cy.intercept("POST", "**/api/customers", {
|
||||||
|
statusCode: 422,
|
||||||
|
body: {
|
||||||
|
success: false,
|
||||||
|
message: "The email has already been taken.",
|
||||||
|
errors: { email: ["The email has already been taken."] },
|
||||||
|
},
|
||||||
|
}).as("validationError")
|
||||||
|
|
||||||
|
cy.get("[role='dialog']").within(() => {
|
||||||
|
cy.get("input[name='first_name']").type("John")
|
||||||
|
cy.get("input[name='last_name']").type("Doe")
|
||||||
|
cy.get("input[name='email']").type("existing@example.com")
|
||||||
|
cy.contains("button", "Create Customer").click()
|
||||||
|
})
|
||||||
|
|
||||||
|
cy.wait("@validationError")
|
||||||
|
|
||||||
|
cy.get("[role='dialog']").within(() => {
|
||||||
|
cy.contains("Failed to create customer").should("be.visible")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should re-enable submit button after a failed request", () => {
|
||||||
|
cy.intercept("POST", "**/api/customers", {
|
||||||
|
statusCode: 422,
|
||||||
|
body: {
|
||||||
|
success: false,
|
||||||
|
message: "Validation failed",
|
||||||
|
errors: {},
|
||||||
|
},
|
||||||
|
}).as("failedRequest")
|
||||||
|
|
||||||
|
cy.get("[role='dialog']").within(() => {
|
||||||
|
cy.get("input[name='first_name']").type("John")
|
||||||
|
cy.get("input[name='last_name']").type("Doe")
|
||||||
|
cy.contains("button", "Create Customer").click()
|
||||||
|
})
|
||||||
|
|
||||||
|
cy.wait("@failedRequest")
|
||||||
|
|
||||||
|
cy.get("[role='dialog']").within(() => {
|
||||||
|
cy.contains("button", "Create Customer").should("not.be.disabled")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
347
apps/dashboard/cypress/e2e/customers/customer-form.cy.ts
Normal file
347
apps/dashboard/cypress/e2e/customers/customer-form.cy.ts
Normal file
@ -0,0 +1,347 @@
|
|||||||
|
describe("Customer Form", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Authenticate via API and set cookies
|
||||||
|
cy.login()
|
||||||
|
|
||||||
|
// Intercept lookup APIs with fixture data
|
||||||
|
cy.fixture("customers").then((data) => {
|
||||||
|
cy.intercept("GET", "**/api/referral-sources", {
|
||||||
|
statusCode: 200,
|
||||||
|
body: data.referral_sources,
|
||||||
|
}).as("getReferralSources")
|
||||||
|
|
||||||
|
cy.intercept("GET", "**/api/payment-terms", {
|
||||||
|
statusCode: 200,
|
||||||
|
body: data.payment_terms,
|
||||||
|
}).as("getPaymentTerms")
|
||||||
|
|
||||||
|
cy.intercept("GET", "**/api/countries", {
|
||||||
|
statusCode: 200,
|
||||||
|
body: data.countries,
|
||||||
|
}).as("getCountries")
|
||||||
|
|
||||||
|
cy.intercept("GET", "**/api/states", {
|
||||||
|
statusCode: 200,
|
||||||
|
body: data.states,
|
||||||
|
}).as("getStates")
|
||||||
|
|
||||||
|
// Intercept customer list (GET) for the data table
|
||||||
|
cy.intercept("GET", "**/api/customers*", {
|
||||||
|
statusCode: 200,
|
||||||
|
body: { success: true, data: { data: [], pagination: { total: 0 } } },
|
||||||
|
}).as("getCustomers")
|
||||||
|
})
|
||||||
|
|
||||||
|
cy.visit("/sales/customers")
|
||||||
|
})
|
||||||
|
|
||||||
|
function openCustomerDialog() {
|
||||||
|
cy.contains("button", "Create Customer").click()
|
||||||
|
cy.get("[role='dialog']").should("be.visible")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rendering ──
|
||||||
|
|
||||||
|
it("should open the create customer dialog", () => {
|
||||||
|
openCustomerDialog()
|
||||||
|
|
||||||
|
cy.get("[role='dialog']").within(() => {
|
||||||
|
cy.contains("Create Customer").should("exist")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should display all form fields", () => {
|
||||||
|
openCustomerDialog()
|
||||||
|
|
||||||
|
cy.get("[role='dialog']").within(() => {
|
||||||
|
// Text fields
|
||||||
|
cy.get("input[name='first_name']").should("exist")
|
||||||
|
cy.get("input[name='last_name']").should("exist")
|
||||||
|
cy.get("input[name='company_name']").should("exist")
|
||||||
|
cy.get("input[name='email']").should("exist")
|
||||||
|
cy.get("input[name='phone']").should("exist")
|
||||||
|
cy.get("input[name='alternate_phone']").should("exist")
|
||||||
|
cy.get("input[name='address_line_1']").should("exist")
|
||||||
|
cy.get("input[name='address_line_2']").should("exist")
|
||||||
|
cy.get("input[name='city']").should("exist")
|
||||||
|
cy.get("input[name='zip_code']").should("exist")
|
||||||
|
|
||||||
|
// Labels
|
||||||
|
cy.contains("label", "First Name").should("exist")
|
||||||
|
cy.contains("label", "Last Name").should("exist")
|
||||||
|
cy.contains("label", "Email").should("exist")
|
||||||
|
cy.contains("label", "Salutation").should("exist")
|
||||||
|
cy.contains("label", "Customer Type").should("exist")
|
||||||
|
cy.contains("label", "Referral Source").should("exist")
|
||||||
|
cy.contains("label", "Payment Terms").should("exist")
|
||||||
|
cy.contains("label", "Country").should("exist")
|
||||||
|
cy.contains("label", "State").should("exist")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Validation ──
|
||||||
|
|
||||||
|
it("should show validation errors for required fields", () => {
|
||||||
|
openCustomerDialog()
|
||||||
|
|
||||||
|
cy.get("[role='dialog']").within(() => {
|
||||||
|
cy.contains("button", "Create Customer").click()
|
||||||
|
|
||||||
|
// first_name and last_name are required
|
||||||
|
cy.contains("First name is required").should("be.visible")
|
||||||
|
cy.contains("Last name is required").should("be.visible")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should show email validation error for invalid email", () => {
|
||||||
|
openCustomerDialog()
|
||||||
|
|
||||||
|
cy.get("[role='dialog']").within(() => {
|
||||||
|
cy.get("input[name='email']").type("not-an-email")
|
||||||
|
cy.contains("button", "Create Customer").click()
|
||||||
|
|
||||||
|
cy.contains("Enter a valid email address").should("be.visible")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should not show email error when email is empty", () => {
|
||||||
|
openCustomerDialog()
|
||||||
|
|
||||||
|
cy.get("[role='dialog']").within(() => {
|
||||||
|
cy.get("input[name='first_name']").type("John")
|
||||||
|
cy.get("input[name='last_name']").type("Doe")
|
||||||
|
cy.contains("button", "Create Customer").click()
|
||||||
|
|
||||||
|
cy.contains("Enter a valid email address").should("not.exist")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Text input ──
|
||||||
|
|
||||||
|
it("should fill in text fields", () => {
|
||||||
|
openCustomerDialog()
|
||||||
|
|
||||||
|
cy.get("[role='dialog']").within(() => {
|
||||||
|
cy.get("input[name='first_name']").type("John").should("have.value", "John")
|
||||||
|
cy.get("input[name='last_name']").type("Doe").should("have.value", "Doe")
|
||||||
|
cy.get("input[name='company_name']").type("Acme Corp").should("have.value", "Acme Corp")
|
||||||
|
cy.get("input[name='email']").type("john@example.com").should("have.value", "john@example.com")
|
||||||
|
cy.get("input[name='phone']").type("0501234567").should("have.value", "0501234567")
|
||||||
|
cy.get("input[name='address_line_1']").type("123 Main St").should("have.value", "123 Main St")
|
||||||
|
cy.get("input[name='city']").type("Dubai").should("have.value", "Dubai")
|
||||||
|
cy.get("input[name='zip_code']").type("00000").should("have.value", "00000")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Select fields ──
|
||||||
|
|
||||||
|
it("should select a salutation from the dropdown", () => {
|
||||||
|
openCustomerDialog()
|
||||||
|
|
||||||
|
cy.get("[role='dialog']").within(() => {
|
||||||
|
// Click the Salutation select trigger
|
||||||
|
cy.contains("label", "Salutation")
|
||||||
|
.parent()
|
||||||
|
.find("[role='combobox'], button[data-slot='select-trigger']")
|
||||||
|
.click()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Select option from the popover (may render outside the dialog)
|
||||||
|
cy.get("[role='option'], [role='listbox'] [data-value='Mr']")
|
||||||
|
.contains("Mr")
|
||||||
|
.click()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should select a customer type", () => {
|
||||||
|
openCustomerDialog()
|
||||||
|
|
||||||
|
cy.get("[role='dialog']").within(() => {
|
||||||
|
cy.contains("label", "Customer Type")
|
||||||
|
.parent()
|
||||||
|
.find("[role='combobox'], button[data-slot='select-trigger']")
|
||||||
|
.click()
|
||||||
|
})
|
||||||
|
|
||||||
|
cy.get("[role='option']").contains("Individual").click()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Async select (Combobox) fields ──
|
||||||
|
|
||||||
|
it("should load and select a referral source", () => {
|
||||||
|
openCustomerDialog()
|
||||||
|
|
||||||
|
cy.wait("@getReferralSources")
|
||||||
|
|
||||||
|
cy.get("[role='dialog']").within(() => {
|
||||||
|
cy.contains("label", "Referral Source")
|
||||||
|
.parent()
|
||||||
|
.find("input")
|
||||||
|
.click()
|
||||||
|
.type("Google")
|
||||||
|
})
|
||||||
|
|
||||||
|
cy.get("[role='option']").contains("Google").click()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should load and select a country", () => {
|
||||||
|
openCustomerDialog()
|
||||||
|
|
||||||
|
cy.wait("@getCountries")
|
||||||
|
|
||||||
|
cy.get("[role='dialog']").within(() => {
|
||||||
|
cy.contains("label", "Country")
|
||||||
|
.parent()
|
||||||
|
.find("input")
|
||||||
|
.click()
|
||||||
|
.type("United")
|
||||||
|
})
|
||||||
|
|
||||||
|
cy.get("[role='option']").contains("United Arab Emirates").click()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Successful submission ──
|
||||||
|
|
||||||
|
it("should submit the form successfully with required fields", () => {
|
||||||
|
cy.fixture("customers").then((data) => {
|
||||||
|
cy.intercept("POST", "**/api/customers", {
|
||||||
|
statusCode: 201,
|
||||||
|
body: data.customer_created,
|
||||||
|
}).as("createCustomer")
|
||||||
|
})
|
||||||
|
|
||||||
|
openCustomerDialog()
|
||||||
|
|
||||||
|
cy.get("[role='dialog']").within(() => {
|
||||||
|
cy.get("input[name='first_name']").type("John")
|
||||||
|
cy.get("input[name='last_name']").type("Doe")
|
||||||
|
|
||||||
|
cy.contains("button", "Create Customer").click()
|
||||||
|
})
|
||||||
|
|
||||||
|
cy.wait("@createCustomer").its("request.body").should((body) => {
|
||||||
|
expect(body.first_name).to.eq("John")
|
||||||
|
expect(body.last_name).to.eq("Doe")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should submit a fully filled form", () => {
|
||||||
|
cy.fixture("customers").then((data) => {
|
||||||
|
cy.intercept("POST", "**/api/customers", {
|
||||||
|
statusCode: 201,
|
||||||
|
body: data.customer_created,
|
||||||
|
}).as("createCustomer")
|
||||||
|
})
|
||||||
|
|
||||||
|
openCustomerDialog()
|
||||||
|
|
||||||
|
// Wait for async data
|
||||||
|
cy.wait("@getReferralSources")
|
||||||
|
cy.wait("@getPaymentTerms")
|
||||||
|
cy.wait("@getCountries")
|
||||||
|
cy.wait("@getStates")
|
||||||
|
|
||||||
|
cy.get("[role='dialog']").within(() => {
|
||||||
|
// Text fields
|
||||||
|
cy.get("input[name='first_name']").type("John")
|
||||||
|
cy.get("input[name='last_name']").type("Doe")
|
||||||
|
cy.get("input[name='company_name']").type("Doe Holdings")
|
||||||
|
cy.get("input[name='email']").type("john@example.com")
|
||||||
|
cy.get("input[name='phone']").type("0501234567")
|
||||||
|
cy.get("input[name='alternate_phone']").type("0551234567")
|
||||||
|
cy.get("input[name='address_line_1']").type("Street 10")
|
||||||
|
cy.get("input[name='address_line_2']").type("Near Central Plaza")
|
||||||
|
cy.get("input[name='city']").type("Dubai")
|
||||||
|
cy.get("input[name='zip_code']").type("00000")
|
||||||
|
|
||||||
|
// Submit
|
||||||
|
cy.contains("button", "Create Customer").click()
|
||||||
|
})
|
||||||
|
|
||||||
|
cy.wait("@createCustomer").its("request.body").should((body) => {
|
||||||
|
expect(body.first_name).to.eq("John")
|
||||||
|
expect(body.last_name).to.eq("Doe")
|
||||||
|
expect(body.company_name).to.eq("Doe Holdings")
|
||||||
|
expect(body.email).to.eq("john@example.com")
|
||||||
|
expect(body.phone).to.eq("0501234567")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Error handling ──
|
||||||
|
|
||||||
|
it("should display API error on submission failure", () => {
|
||||||
|
cy.intercept("POST", "**/api/customers", {
|
||||||
|
statusCode: 422,
|
||||||
|
body: {
|
||||||
|
success: false,
|
||||||
|
message: "The given data was invalid.",
|
||||||
|
errors: { email: ["The email has already been taken."] },
|
||||||
|
},
|
||||||
|
}).as("createCustomerFail")
|
||||||
|
|
||||||
|
openCustomerDialog()
|
||||||
|
|
||||||
|
cy.get("[role='dialog']").within(() => {
|
||||||
|
cy.get("input[name='first_name']").type("John")
|
||||||
|
cy.get("input[name='last_name']").type("Doe")
|
||||||
|
cy.get("input[name='email']").type("john@example.com")
|
||||||
|
|
||||||
|
cy.contains("button", "Create Customer").click()
|
||||||
|
})
|
||||||
|
|
||||||
|
cy.wait("@createCustomerFail")
|
||||||
|
|
||||||
|
cy.get("[role='dialog']").within(() => {
|
||||||
|
cy.contains("Failed to create customer").should("be.visible")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should show loading state while submitting", () => {
|
||||||
|
cy.intercept("POST", "**/api/customers", {
|
||||||
|
statusCode: 201,
|
||||||
|
body: { success: true, data: { id: 1 } },
|
||||||
|
delay: 1000,
|
||||||
|
}).as("createCustomerSlow")
|
||||||
|
|
||||||
|
openCustomerDialog()
|
||||||
|
|
||||||
|
cy.get("[role='dialog']").within(() => {
|
||||||
|
cy.get("input[name='first_name']").type("John")
|
||||||
|
cy.get("input[name='last_name']").type("Doe")
|
||||||
|
|
||||||
|
cy.contains("button", "Create Customer").click()
|
||||||
|
|
||||||
|
// Button should show loading text and be disabled
|
||||||
|
cy.contains("button", "Creating...").should("be.visible").and("be.disabled")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Form reset after success ──
|
||||||
|
|
||||||
|
it("should reset the form after successful submission", () => {
|
||||||
|
cy.fixture("customers").then((data) => {
|
||||||
|
cy.intercept("POST", "**/api/customers", {
|
||||||
|
statusCode: 201,
|
||||||
|
body: data.customer_created,
|
||||||
|
}).as("createCustomer")
|
||||||
|
})
|
||||||
|
|
||||||
|
openCustomerDialog()
|
||||||
|
|
||||||
|
cy.get("[role='dialog']").within(() => {
|
||||||
|
cy.get("input[name='first_name']").type("John")
|
||||||
|
cy.get("input[name='last_name']").type("Doe")
|
||||||
|
|
||||||
|
cy.contains("button", "Create Customer").click()
|
||||||
|
})
|
||||||
|
|
||||||
|
cy.wait("@createCustomer")
|
||||||
|
|
||||||
|
// After success, re-open the dialog and verify fields are empty
|
||||||
|
openCustomerDialog()
|
||||||
|
|
||||||
|
cy.get("[role='dialog']").within(() => {
|
||||||
|
cy.get("input[name='first_name']").should("have.value", "")
|
||||||
|
cy.get("input[name='last_name']").should("have.value", "")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
47
apps/dashboard/cypress/fixtures/customers.json
Normal file
47
apps/dashboard/cypress/fixtures/customers.json
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"referral_sources": {
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"data": [
|
||||||
|
{ "id": 1, "name": "Google" },
|
||||||
|
{ "id": 2, "name": "Friend Referral" },
|
||||||
|
{ "id": 3, "name": "Social Media" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"payment_terms": {
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"data": [
|
||||||
|
{ "id": 1, "name": "Net 30" },
|
||||||
|
{ "id": 2, "name": "Net 60" },
|
||||||
|
{ "id": 3, "name": "Due on Receipt" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"countries": {
|
||||||
|
"success": true,
|
||||||
|
"data": [
|
||||||
|
{ "id": 1, "name": "United Arab Emirates" },
|
||||||
|
{ "id": 2, "name": "Saudi Arabia" },
|
||||||
|
{ "id": 3, "name": "United States" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"states": {
|
||||||
|
"success": true,
|
||||||
|
"data": [
|
||||||
|
{ "id": 1, "name": "Dubai" },
|
||||||
|
{ "id": 2, "name": "Abu Dhabi" },
|
||||||
|
{ "id": 3, "name": "Sharjah" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"customer_created": {
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"id": 101,
|
||||||
|
"first_name": "John",
|
||||||
|
"last_name": "Doe",
|
||||||
|
"email": "john@example.com"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
33
apps/dashboard/cypress/support/commands.ts
Normal file
33
apps/dashboard/cypress/support/commands.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
/// <reference types="cypress" />
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
namespace Cypress {
|
||||||
|
interface Chainable {
|
||||||
|
/**
|
||||||
|
* Log in via the API and set auth cookies so the app
|
||||||
|
* recognises the user as authenticated.
|
||||||
|
*/
|
||||||
|
login(email?: string, password?: string): Chainable<void>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Cypress.Commands.add("login", (email?: string, password?: string) => {
|
||||||
|
const userEmail = email ?? Cypress.env("TEST_USER_EMAIL") ?? "admin@admin.com"
|
||||||
|
const userPassword = password ?? Cypress.env("TEST_USER_PASSWORD") ?? "12345678"
|
||||||
|
|
||||||
|
cy.request({
|
||||||
|
method: "POST",
|
||||||
|
url: `${Cypress.env("API_URL") ?? "http://localhost:8000"}/api/login`,
|
||||||
|
body: { email: userEmail, password: userPassword },
|
||||||
|
}).then((response) => {
|
||||||
|
const { token, user } = response.body
|
||||||
|
|
||||||
|
cy.setCookie("auth_token", token, { path: "/" })
|
||||||
|
cy.setCookie("auth_user", encodeURIComponent(JSON.stringify(user)), {
|
||||||
|
path: "/",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
export {}
|
||||||
1
apps/dashboard/cypress/support/e2e.ts
Normal file
1
apps/dashboard/cypress/support/e2e.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
import "./commands"
|
||||||
15
apps/dashboard/cypress/tsconfig.json
Normal file
15
apps/dashboard/cypress/tsconfig.json
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["ES2017", "DOM"],
|
||||||
|
"types": ["cypress"],
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"module": "ESNext",
|
||||||
|
"strict": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["../../*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["**/*.ts", "../support/**/*.ts"]
|
||||||
|
}
|
||||||
18
apps/dashboard/eslint.config.mjs
Normal file
18
apps/dashboard/eslint.config.mjs
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
import nextTs from "eslint-config-next/typescript";
|
||||||
|
|
||||||
|
const eslintConfig = defineConfig([
|
||||||
|
...nextVitals,
|
||||||
|
...nextTs,
|
||||||
|
// Override default ignores of eslint-config-next.
|
||||||
|
globalIgnores([
|
||||||
|
// Default ignores of eslint-config-next:
|
||||||
|
".next/**",
|
||||||
|
"out/**",
|
||||||
|
"build/**",
|
||||||
|
"next-env.d.ts",
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
55
apps/dashboard/modules/auth/auth.actions.ts
Normal file
55
apps/dashboard/modules/auth/auth.actions.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
"use server"
|
||||||
|
|
||||||
|
import { cookies } from "next/headers"
|
||||||
|
import type { AuthUser } from "@garage/api"
|
||||||
|
|
||||||
|
const TOKEN_COOKIE = "auth_token"
|
||||||
|
const USER_COOKIE = "auth_user"
|
||||||
|
const DEFAULT_EXPIRES_IN = 60 * 60 * 24 * 7 // 7 days in seconds
|
||||||
|
|
||||||
|
export async function setAuthCookies(
|
||||||
|
token: string,
|
||||||
|
user: AuthUser,
|
||||||
|
expiresIn: number = DEFAULT_EXPIRES_IN,
|
||||||
|
) {
|
||||||
|
const cookieStore = await cookies()
|
||||||
|
const expires = new Date(Date.now() + expiresIn * 1000)
|
||||||
|
|
||||||
|
cookieStore.set(TOKEN_COOKIE, token, {
|
||||||
|
expires,
|
||||||
|
path: "/",
|
||||||
|
sameSite: "strict",
|
||||||
|
})
|
||||||
|
|
||||||
|
cookieStore.set(USER_COOKIE, JSON.stringify(user), {
|
||||||
|
expires,
|
||||||
|
path: "/",
|
||||||
|
sameSite: "strict",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearAuthCookies() {
|
||||||
|
const cookieStore = await cookies()
|
||||||
|
cookieStore.delete(TOKEN_COOKIE)
|
||||||
|
cookieStore.delete(USER_COOKIE)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAuthCookies(): Promise<{
|
||||||
|
token: string | undefined
|
||||||
|
user: AuthUser | undefined
|
||||||
|
}> {
|
||||||
|
const cookieStore = await cookies()
|
||||||
|
const token = cookieStore.get(TOKEN_COOKIE)?.value
|
||||||
|
const rawUser = cookieStore.get(USER_COOKIE)?.value
|
||||||
|
|
||||||
|
let user: AuthUser | undefined
|
||||||
|
if (rawUser) {
|
||||||
|
try {
|
||||||
|
user = JSON.parse(rawUser) as AuthUser
|
||||||
|
} catch {
|
||||||
|
user = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { token, user }
|
||||||
|
}
|
||||||
11
apps/dashboard/modules/auth/login-form.schema.ts
Normal file
11
apps/dashboard/modules/auth/login-form.schema.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
const loginFormSchema = z.object({
|
||||||
|
email: z.string().trim().email("Enter a valid email address"),
|
||||||
|
password: z.string().min(8, "Password must be at least 8 characters"),
|
||||||
|
})
|
||||||
|
|
||||||
|
type LoginFormValues = z.infer<typeof loginFormSchema>
|
||||||
|
|
||||||
|
export { loginFormSchema }
|
||||||
|
export type { LoginFormValues }
|
||||||
148
apps/dashboard/modules/auth/login-form.tsx
Normal file
148
apps/dashboard/modules/auth/login-form.tsx
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import { useForm } from "react-hook-form"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { api } from '@garage/api'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/shared/components/ui/card"
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
FieldDescription,
|
||||||
|
FieldError,
|
||||||
|
FieldGroup,
|
||||||
|
FieldLabel,
|
||||||
|
} from "@/shared/components/ui/field"
|
||||||
|
import { Input } from "@/shared/components/ui/input"
|
||||||
|
import { useAppStore } from "@/shared/stores/app-store"
|
||||||
|
import { useAuthStore } from "@/shared/stores/auth-store"
|
||||||
|
import { cn } from "@/shared/lib/utils"
|
||||||
|
import Image from "next/image"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
|
||||||
|
import { loginFormSchema, type LoginFormValues } from "./login-form.schema"
|
||||||
|
import { useMutation } from "@tanstack/react-query"
|
||||||
|
import { Alert, AlertTitle } from "@/shared/components/ui/alert"
|
||||||
|
import { AlertTriangle } from "lucide-react"
|
||||||
|
|
||||||
|
export function LoginForm({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
const lastLoginEmail = useAppStore((state) => state.lastLoginEmail)
|
||||||
|
const setLastLoginEmail = useAppStore((state) => state.setLastLoginEmail)
|
||||||
|
const login = useAuthStore((state) => state.login)
|
||||||
|
const router = useRouter()
|
||||||
|
const {
|
||||||
|
handleSubmit,
|
||||||
|
register,
|
||||||
|
formState: { errors, },
|
||||||
|
} = useForm<LoginFormValues>({
|
||||||
|
resolver: zodResolver(loginFormSchema),
|
||||||
|
defaultValues: process.env.NODE_ENV === "development" ? {
|
||||||
|
"email": "admin@admin.com",
|
||||||
|
"password": "12345678"
|
||||||
|
} : {
|
||||||
|
email: lastLoginEmail,
|
||||||
|
password: "",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const { mutate, error, isPending: isSubmitting } = useMutation({
|
||||||
|
mutationFn: (values: LoginFormValues) => api.auth.login(values),
|
||||||
|
onSuccess: async (data) => {
|
||||||
|
if (data.token && data.user) {
|
||||||
|
await login(data.token, data.user as Parameters<typeof login>[1])
|
||||||
|
router.push("/")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
async function onSubmit(values: LoginFormValues) {
|
||||||
|
setLastLoginEmail(values.email)
|
||||||
|
mutate(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<Image
|
||||||
|
className="mx-auto mb-8 h-20 w-48"
|
||||||
|
alt="Logo"
|
||||||
|
src="/assets/logo.png"
|
||||||
|
height={200}
|
||||||
|
width={200}
|
||||||
|
/>
|
||||||
|
<CardTitle>Login to your account</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Enter your email below to login to your account
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{error ? (
|
||||||
|
<Alert variant='destructive' className="mb-4">
|
||||||
|
<AlertTriangle className="me-2 h-4 w-4" />
|
||||||
|
<AlertTitle>Login failed</AlertTitle>
|
||||||
|
{error.message}
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} noValidate>
|
||||||
|
<FieldGroup>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="email">Email</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="m@example.com"
|
||||||
|
aria-invalid={!!errors.email}
|
||||||
|
{...register("email")}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.email]} />
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<FieldLabel htmlFor="password">Password</FieldLabel>
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
className="ml-auto inline-block text-sm underline-offset-4 hover:underline"
|
||||||
|
>
|
||||||
|
Forgot your password?
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
aria-invalid={!!errors.password}
|
||||||
|
{...register("password")}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.password]} />
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<Button type="submit" disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? "Logging in..." : "Login"}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{lastLoginEmail ? (
|
||||||
|
<FieldDescription className="text-center">
|
||||||
|
Last email used: {lastLoginEmail}
|
||||||
|
</FieldDescription>
|
||||||
|
) : null}
|
||||||
|
{/* <FieldDescription className="text-center">
|
||||||
|
Don't have an account? <a href="#">Sign up</a>
|
||||||
|
</FieldDescription> */}
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
264
apps/dashboard/modules/customers/customer-form.tsx
Normal file
264
apps/dashboard/modules/customers/customer-form.tsx
Normal file
@ -0,0 +1,264 @@
|
|||||||
|
"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,
|
||||||
|
} 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 {
|
||||||
|
customerFormSchema,
|
||||||
|
type CustomerFormValues,
|
||||||
|
} from "./customer.schema"
|
||||||
|
import { CUSTOMER_ROUTES } from "@garage/api"
|
||||||
|
|
||||||
|
// ── Constants ──
|
||||||
|
|
||||||
|
const SALUTATION_OPTIONS = [
|
||||||
|
{ value: "Mr.", label: "Mr." },
|
||||||
|
{ value: "Mrs.", label: "Mrs." },
|
||||||
|
{ value: "Ms.", label: "Ms." },
|
||||||
|
{ value: "Miss", label: "Miss" },
|
||||||
|
{ value: "Dr.", label: "Dr." },
|
||||||
|
{ value: "Prof.", label: "Prof." },
|
||||||
|
]
|
||||||
|
|
||||||
|
// ── Props ──
|
||||||
|
|
||||||
|
export type CustomerFormProps = {
|
||||||
|
resourceId?: string | null
|
||||||
|
initialData?: unknown
|
||||||
|
onSuccess?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Default values ──
|
||||||
|
|
||||||
|
const CUSTOMER_DEFAULT_VALUES: CustomerFormValues = {
|
||||||
|
customer_type: null,
|
||||||
|
referral_source: null,
|
||||||
|
payment_terms: null,
|
||||||
|
country: null,
|
||||||
|
state: null,
|
||||||
|
salutation: "",
|
||||||
|
first_name: "",
|
||||||
|
last_name: "",
|
||||||
|
company_name: "",
|
||||||
|
email: "",
|
||||||
|
phone: "",
|
||||||
|
alternate_phone: "",
|
||||||
|
address_line_1: "",
|
||||||
|
address_line_2: "",
|
||||||
|
city: "",
|
||||||
|
zip_code: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mapping helpers ──
|
||||||
|
|
||||||
|
function mapCustomerToFormValues(data: unknown): CustomerFormValues {
|
||||||
|
const c = (data as any)?.data ?? data ?? {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
customer_type: toRelation(c.customer_type_id, c.customer_type_name),
|
||||||
|
referral_source: toRelation(c.referral_source_id, c.referral_source_name),
|
||||||
|
payment_terms: toRelation(c.payment_terms_id, c.payment_terms_name),
|
||||||
|
country: toRelation(c.country_id, c.country_name),
|
||||||
|
state: toRelation(c.state_id, c.state_name),
|
||||||
|
salutation: c.salutation || "",
|
||||||
|
first_name: c.first_name || "",
|
||||||
|
last_name: c.last_name || "",
|
||||||
|
company_name: c.company_name || "",
|
||||||
|
email: c.email || "",
|
||||||
|
phone: c.phone || "",
|
||||||
|
alternate_phone: c.alternate_phone || "",
|
||||||
|
address_line_1: c.address_line_1 || "",
|
||||||
|
address_line_2: c.address_line_2 || "",
|
||||||
|
city: c.city || "",
|
||||||
|
zip_code: c.zip_code || "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapFormToPayload(values: CustomerFormValues) {
|
||||||
|
return {
|
||||||
|
customer_type_id: toId(values.customer_type),
|
||||||
|
referral_source_id: toId(values.referral_source),
|
||||||
|
payment_terms_id: toId(values.payment_terms),
|
||||||
|
country_id: toId(values.country),
|
||||||
|
state_id: toId(values.state),
|
||||||
|
salutation: values.salutation || undefined,
|
||||||
|
first_name: values.first_name,
|
||||||
|
last_name: values.last_name,
|
||||||
|
company_name: values.company_name || undefined,
|
||||||
|
email: values.email || undefined,
|
||||||
|
phone: values.phone || undefined,
|
||||||
|
alternate_phone: values.alternate_phone || undefined,
|
||||||
|
address_line_1: values.address_line_1 || undefined,
|
||||||
|
address_line_2: values.address_line_2 || undefined,
|
||||||
|
city: values.city || undefined,
|
||||||
|
zip_code: values.zip_code || undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 CustomerForm({ resourceId, initialData, onSuccess }: CustomerFormProps) {
|
||||||
|
const api = useAuthApi()
|
||||||
|
|
||||||
|
const { form, isEditing } = useResourceForm<CustomerFormValues, any>({
|
||||||
|
schema: customerFormSchema,
|
||||||
|
defaultValues: CUSTOMER_DEFAULT_VALUES,
|
||||||
|
resourceId,
|
||||||
|
initialData,
|
||||||
|
initialize: (id) => api.customers.show(id),
|
||||||
|
queryKey: [CUSTOMER_ROUTES.BY_ID, resourceId],
|
||||||
|
mapToFormValues: mapCustomerToFormValues,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { mutate, error, isPending } = useFormMutation(form, {
|
||||||
|
mutationFn: (values: CustomerFormValues) => {
|
||||||
|
const payload = mapFormToPayload(values)
|
||||||
|
const promise = isEditing && resourceId
|
||||||
|
? api.customers.update(resourceId, payload)
|
||||||
|
: api.customers.create(payload)
|
||||||
|
toast.promise(promise, {
|
||||||
|
loading: isEditing ? "Updating customer..." : "Creating customer...",
|
||||||
|
success: isEditing ? "Customer updated successfully" : "Customer created successfully",
|
||||||
|
error: isEditing ? "Failed to update customer" : "Failed to create customer",
|
||||||
|
})
|
||||||
|
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 customer" : "Failed to create customer"}</AlertTitle>
|
||||||
|
{error.message}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FieldGroup>
|
||||||
|
|
||||||
|
{/* Basic Info */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<RhfSelectField
|
||||||
|
name="salutation"
|
||||||
|
label="Salutation"
|
||||||
|
placeholder="Select salutation"
|
||||||
|
options={SALUTATION_OPTIONS}
|
||||||
|
/>
|
||||||
|
<RhfAsyncSelectField
|
||||||
|
name="customer_type"
|
||||||
|
label="Customer Type"
|
||||||
|
placeholder="Select customer type"
|
||||||
|
queryKey={[CUSTOMER_ROUTES.CUSTOMER_TYPES]}
|
||||||
|
listFn={() => api.customers.listCustomerTypes()}
|
||||||
|
mapOption={mapLookupOption}
|
||||||
|
{...STORE_OBJECT}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
{/* Name */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<RhfTextField name="first_name" label="First Name" placeholder="John" required />
|
||||||
|
<RhfTextField name="last_name" label="Last Name" placeholder="Doe" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RhfTextField name="company_name" label="Company Name" placeholder="Doe Holdings" />
|
||||||
|
|
||||||
|
{/* Contact */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<RhfTextField name="email" label="Email" placeholder="john@example.com" type="email" />
|
||||||
|
<RhfTextField name="phone" label="Phone" placeholder="0501234567" type="tel" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RhfTextField name="alternate_phone" label="Alternate Phone" placeholder="0551234567" type="tel" />
|
||||||
|
|
||||||
|
{/* Relations */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<RhfAsyncSelectField
|
||||||
|
name="referral_source"
|
||||||
|
label="Referral Source"
|
||||||
|
placeholder="Select referral source"
|
||||||
|
queryKey={["referral-sources"]}
|
||||||
|
listFn={() => api.referralSources.list()}
|
||||||
|
mapOption={mapLookupOption}
|
||||||
|
{...STORE_OBJECT}
|
||||||
|
/>
|
||||||
|
<RhfAsyncSelectField
|
||||||
|
name="payment_terms"
|
||||||
|
label="Payment Terms"
|
||||||
|
placeholder="Select payment terms"
|
||||||
|
queryKey={["payment-terms"]}
|
||||||
|
listFn={() => api.paymentTerms.list()}
|
||||||
|
mapOption={mapLookupOption}
|
||||||
|
{...STORE_OBJECT}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Address */}
|
||||||
|
<RhfTextField name="address_line_1" label="Address Line 1" placeholder="Street 10" />
|
||||||
|
<RhfTextField name="address_line_2" label="Address Line 2" placeholder="Near Central Plaza" />
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<RhfAsyncSelectField
|
||||||
|
name="country"
|
||||||
|
label="Country"
|
||||||
|
placeholder="Select country"
|
||||||
|
queryKey={["countries"]}
|
||||||
|
listFn={() => api.geo.countries()}
|
||||||
|
mapOption={mapLookupOption}
|
||||||
|
{...STORE_OBJECT}
|
||||||
|
/>
|
||||||
|
<RhfAsyncSelectField
|
||||||
|
name="state"
|
||||||
|
label="State"
|
||||||
|
placeholder="Select state"
|
||||||
|
queryKey={["states"]}
|
||||||
|
listFn={() => api.geo.states()}
|
||||||
|
mapOption={mapLookupOption}
|
||||||
|
{...STORE_OBJECT}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<RhfTextField name="city" label="City" placeholder="Dubai" />
|
||||||
|
<RhfTextField name="zip_code" label="Zip Code" placeholder="00000" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" variant="default" disabled={isPending}>
|
||||||
|
{isEditing ? <Save /> : <Plus />}
|
||||||
|
{isPending
|
||||||
|
? (isEditing ? "Updating..." : "Creating...")
|
||||||
|
: (isEditing ? "Update Customer" : "Create Customer")}
|
||||||
|
</Button>
|
||||||
|
</FieldGroup>
|
||||||
|
</Rhform>
|
||||||
|
)
|
||||||
|
}
|
||||||
44
apps/dashboard/modules/customers/customer.schema.ts
Normal file
44
apps/dashboard/modules/customers/customer.schema.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reusable schema for relation/lookup fields stored as `{ value, label }` objects.
|
||||||
|
* Use `.nullable()` when the field is optional but explicitly clearable.
|
||||||
|
*/
|
||||||
|
const relationFieldSchema = z
|
||||||
|
.object({ value: z.string(), label: z.string() })
|
||||||
|
.nullable()
|
||||||
|
|
||||||
|
type RelationField = z.infer<typeof relationFieldSchema>
|
||||||
|
|
||||||
|
const customerFormSchema = z.object({
|
||||||
|
// ── Relations (stored as objects, mapped to IDs on submit) ──
|
||||||
|
customer_type: relationFieldSchema,
|
||||||
|
referral_source: relationFieldSchema,
|
||||||
|
payment_terms: relationFieldSchema,
|
||||||
|
country: relationFieldSchema,
|
||||||
|
state: relationFieldSchema,
|
||||||
|
|
||||||
|
// ── Basic info ──
|
||||||
|
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(),
|
||||||
|
|
||||||
|
// ── Contact ──
|
||||||
|
email: z
|
||||||
|
.union([z.string().email("Enter a valid email address"), z.literal("")])
|
||||||
|
.optional(),
|
||||||
|
phone: z.string().optional(),
|
||||||
|
alternate_phone: z.string().optional(),
|
||||||
|
|
||||||
|
// ── Address ──
|
||||||
|
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 }
|
||||||
236
apps/dashboard/modules/employees/employee-form.tsx
Normal file
236
apps/dashboard/modules/employees/employee-form.tsx
Normal file
@ -0,0 +1,236 @@
|
|||||||
|
"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,
|
||||||
|
RhfCheckboxField,
|
||||||
|
} 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 {
|
||||||
|
employeeFormSchema,
|
||||||
|
type EmployeeFormValues,
|
||||||
|
} from "./employee.schema"
|
||||||
|
import { EMPLOYEE_ROUTES, DEPARTMENT_ROUTES, SHOP_TIMING_ROUTES, SHOP_CALENDAR_ROUTES } from "@garage/api"
|
||||||
|
|
||||||
|
// ── Constants ──
|
||||||
|
|
||||||
|
const STATUS_OPTIONS = [
|
||||||
|
{ value: "active", label: "Active" },
|
||||||
|
{ value: "inactive", label: "Inactive" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const TYPE_OPTIONS = [
|
||||||
|
{ value: "employee", label: "Employee" },
|
||||||
|
{ value: "contractor", label: "Contractor" },
|
||||||
|
]
|
||||||
|
|
||||||
|
// ── Props ──
|
||||||
|
|
||||||
|
export type EmployeeFormProps = {
|
||||||
|
resourceId?: string | null
|
||||||
|
initialData?: unknown
|
||||||
|
onSuccess?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Default values ──
|
||||||
|
|
||||||
|
const DEFAULT_VALUES: EmployeeFormValues = {
|
||||||
|
department: null,
|
||||||
|
shop_calender: null,
|
||||||
|
shop_timing: null,
|
||||||
|
first_name: "",
|
||||||
|
last_name: "",
|
||||||
|
email: "",
|
||||||
|
phone: "",
|
||||||
|
position: "",
|
||||||
|
status: "active",
|
||||||
|
type: "employee",
|
||||||
|
track_attendance: true,
|
||||||
|
notify_owner_when_punch_in_out: false,
|
||||||
|
geo_fence_radius: 100,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mapping helpers ──
|
||||||
|
|
||||||
|
function mapToFormValues(data: unknown): EmployeeFormValues {
|
||||||
|
const d = (data as any)?.data ?? data ?? {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
department: toRelation(d.department_id, d.department?.name),
|
||||||
|
shop_calender: toRelation(d.shop_calender_id, d.shop_calender?.title),
|
||||||
|
shop_timing: toRelation(d.shop_timing_id, d.shop_timing?.title),
|
||||||
|
first_name: d.first_name || "",
|
||||||
|
last_name: d.last_name || "",
|
||||||
|
email: d.email || "",
|
||||||
|
phone: d.phone || "",
|
||||||
|
position: d.position || "",
|
||||||
|
status: d.status || "active",
|
||||||
|
type: d.type || "employee",
|
||||||
|
track_attendance: d.track_attendance ?? true,
|
||||||
|
notify_owner_when_punch_in_out: d.notify_owner_when_punch_in_out ?? false,
|
||||||
|
geo_fence_radius: d.geo_fence_radius ?? 100,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapFormToPayload(values: EmployeeFormValues) {
|
||||||
|
return {
|
||||||
|
department_id: toId(values.department),
|
||||||
|
shop_calender_id: toId(values.shop_calender),
|
||||||
|
shop_timing_id: toId(values.shop_timing),
|
||||||
|
first_name: values.first_name,
|
||||||
|
last_name: values.last_name,
|
||||||
|
email: values.email || undefined,
|
||||||
|
phone: values.phone || undefined,
|
||||||
|
position: values.position || undefined,
|
||||||
|
status: values.status || undefined,
|
||||||
|
type: values.type || undefined,
|
||||||
|
track_attendance: values.track_attendance,
|
||||||
|
notify_owner_when_punch_in_out: values.notify_owner_when_punch_in_out,
|
||||||
|
geo_fence_radius: values.geo_fence_radius,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shared mapOption for async selects ──
|
||||||
|
|
||||||
|
const mapLookupOption = (item: any) => ({
|
||||||
|
value: String(item.id),
|
||||||
|
label: item.name ?? item.title ?? String(item.id),
|
||||||
|
})
|
||||||
|
|
||||||
|
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
|
||||||
|
|
||||||
|
// ── Component ──
|
||||||
|
|
||||||
|
export function EmployeeForm({ resourceId, initialData, onSuccess }: EmployeeFormProps) {
|
||||||
|
const api = useAuthApi()
|
||||||
|
|
||||||
|
const { form, isEditing } = useResourceForm<EmployeeFormValues, any>({
|
||||||
|
schema: employeeFormSchema,
|
||||||
|
defaultValues: DEFAULT_VALUES,
|
||||||
|
resourceId,
|
||||||
|
initialData,
|
||||||
|
initialize: (id) => api.employees.show(id),
|
||||||
|
queryKey: [EMPLOYEE_ROUTES.BY_ID, resourceId],
|
||||||
|
mapToFormValues: mapToFormValues,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { mutate, error, isPending } = useFormMutation(form, {
|
||||||
|
mutationFn: (values: EmployeeFormValues) => {
|
||||||
|
const payload = mapFormToPayload(values)
|
||||||
|
const promise = isEditing && resourceId
|
||||||
|
? api.employees.update(resourceId, payload)
|
||||||
|
: api.employees.create(payload)
|
||||||
|
toast.promise(promise, {
|
||||||
|
loading: isEditing ? "Updating employee..." : "Creating employee...",
|
||||||
|
success: isEditing ? "Employee updated successfully" : "Employee created successfully",
|
||||||
|
error: isEditing ? "Failed to update employee" : "Failed to create employee",
|
||||||
|
})
|
||||||
|
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 employee" : "Failed to create employee"}
|
||||||
|
</AlertTitle>
|
||||||
|
{error.message}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FieldGroup>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<RhfTextField name="first_name" label="First Name" placeholder="Jane" required />
|
||||||
|
<RhfTextField name="last_name" label="Last Name" placeholder="Smith" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<RhfTextField name="email" label="Email" placeholder="jane@example.com" type="email" />
|
||||||
|
<RhfTextField name="phone" label="Phone" placeholder="0501234567" type="tel" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<RhfTextField name="position" label="Position" placeholder="Technician" />
|
||||||
|
<RhfAsyncSelectField
|
||||||
|
name="department"
|
||||||
|
label="Department"
|
||||||
|
placeholder="Select department"
|
||||||
|
queryKey={[DEPARTMENT_ROUTES.INDEX]}
|
||||||
|
listFn={() => api.departments.list()}
|
||||||
|
mapOption={mapLookupOption}
|
||||||
|
{...STORE_OBJECT}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<RhfSelectField
|
||||||
|
name="status"
|
||||||
|
label="Status"
|
||||||
|
placeholder="Select status"
|
||||||
|
options={STATUS_OPTIONS}
|
||||||
|
/>
|
||||||
|
<RhfSelectField
|
||||||
|
name="type"
|
||||||
|
label="Type"
|
||||||
|
placeholder="Select type"
|
||||||
|
options={TYPE_OPTIONS}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<RhfAsyncSelectField
|
||||||
|
name="shop_calender"
|
||||||
|
label="Shop Calendar"
|
||||||
|
placeholder="Select calendar"
|
||||||
|
queryKey={[SHOP_CALENDAR_ROUTES.INDEX]}
|
||||||
|
listFn={() => api.shopCalendars.list()}
|
||||||
|
mapOption={mapLookupOption}
|
||||||
|
{...STORE_OBJECT}
|
||||||
|
/>
|
||||||
|
<RhfAsyncSelectField
|
||||||
|
name="shop_timing"
|
||||||
|
label="Shop Timing"
|
||||||
|
placeholder="Select timing"
|
||||||
|
queryKey={[SHOP_TIMING_ROUTES.INDEX]}
|
||||||
|
listFn={() => api.shopTimings.list()}
|
||||||
|
mapOption={mapLookupOption}
|
||||||
|
{...STORE_OBJECT}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RhfTextField name="geo_fence_radius" label="Geo Fence Radius (m)" placeholder="100" type="number" />
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<RhfCheckboxField name="track_attendance" label="Track Attendance" />
|
||||||
|
<RhfCheckboxField name="notify_owner_when_punch_in_out" label="Notify Owner on Punch In/Out" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" variant="default" disabled={isPending}>
|
||||||
|
{isEditing ? <Save /> : <Plus />}
|
||||||
|
{isPending
|
||||||
|
? (isEditing ? "Updating..." : "Creating...")
|
||||||
|
: (isEditing ? "Update Employee" : "Create Employee")}
|
||||||
|
</Button>
|
||||||
|
</FieldGroup>
|
||||||
|
</Rhform>
|
||||||
|
)
|
||||||
|
}
|
||||||
32
apps/dashboard/modules/employees/employee.schema.ts
Normal file
32
apps/dashboard/modules/employees/employee.schema.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
const relationFieldSchema = z
|
||||||
|
.object({ value: z.string(), label: z.string() })
|
||||||
|
.nullable()
|
||||||
|
|
||||||
|
const STATUS_OPTIONS = ["active", "inactive"] as const
|
||||||
|
const TYPE_OPTIONS = ["employee", "contractor"] as const
|
||||||
|
|
||||||
|
const employeeFormSchema = z.object({
|
||||||
|
department: relationFieldSchema,
|
||||||
|
shop_calender: relationFieldSchema,
|
||||||
|
shop_timing: relationFieldSchema,
|
||||||
|
first_name: z.string().min(1, "First name is required"),
|
||||||
|
last_name: z.string().min(1, "Last name is required"),
|
||||||
|
email: z.union([
|
||||||
|
z.string().email("Enter a valid email address"),
|
||||||
|
z.literal(""),
|
||||||
|
]).optional(),
|
||||||
|
phone: z.string().optional(),
|
||||||
|
position: z.string().optional(),
|
||||||
|
status: z.string().optional(),
|
||||||
|
type: z.string().optional(),
|
||||||
|
track_attendance: z.boolean(),
|
||||||
|
notify_owner_when_punch_in_out: z.boolean(),
|
||||||
|
geo_fence_radius: z.coerce.number().min(0).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
type EmployeeFormValues = z.infer<typeof employeeFormSchema>
|
||||||
|
|
||||||
|
export { employeeFormSchema, relationFieldSchema, STATUS_OPTIONS, TYPE_OPTIONS }
|
||||||
|
export type { EmployeeFormValues }
|
||||||
@ -0,0 +1,57 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { z } from "zod"
|
||||||
|
import { useForm } from "react-hook-form"
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import { Plus } from "lucide-react"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { FieldGroup } from "@/shared/components/ui/field"
|
||||||
|
import { Rhform, RhfTextField, type InlineCreateFormProps } from "@/shared/components/form"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { useAuthApi } from "@/shared/useApi"
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
inspection_name: z.string().min(1, "Name is required"),
|
||||||
|
})
|
||||||
|
|
||||||
|
type FormValues = z.infer<typeof schema>
|
||||||
|
|
||||||
|
export function InspectionCategoryInlineForm({ onSuccess }: InlineCreateFormProps) {
|
||||||
|
const api = useAuthApi()
|
||||||
|
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
defaultValues: { inspection_name: "" },
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSubmit = async (values: FormValues) => {
|
||||||
|
try {
|
||||||
|
const result = await api.inspections.createCategory({
|
||||||
|
inspection_name: values.inspection_name,
|
||||||
|
})
|
||||||
|
toast.success("Inspection category created")
|
||||||
|
form.reset()
|
||||||
|
const item = (result as any)?.data ?? result
|
||||||
|
onSuccess({ value: String(item.id), label: item.name ?? values.inspection_name })
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to create inspection category")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Rhform form={form} onSubmit={handleSubmit}>
|
||||||
|
<FieldGroup>
|
||||||
|
<RhfTextField
|
||||||
|
name="inspection_name"
|
||||||
|
label="Name"
|
||||||
|
placeholder="e.g. Brake Check"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||||
|
<Plus />
|
||||||
|
{form.formState.isSubmitting ? "Creating..." : "Create Category"}
|
||||||
|
</Button>
|
||||||
|
</FieldGroup>
|
||||||
|
</Rhform>
|
||||||
|
)
|
||||||
|
}
|
||||||
231
apps/dashboard/modules/inspections/inspection-form.tsx
Normal file
231
apps/dashboard/modules/inspections/inspection-form.tsx
Normal file
@ -0,0 +1,231 @@
|
|||||||
|
"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,
|
||||||
|
RhfAsyncSelectField,
|
||||||
|
} 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 { InspectionCategoryInlineForm } from "./inline-forms/inspection-category-inline-form"
|
||||||
|
import { DepartmentInlineForm } from "@/modules/services/inline-forms/department-inline-form"
|
||||||
|
|
||||||
|
import {
|
||||||
|
inspectionFormSchema,
|
||||||
|
type InspectionFormValues,
|
||||||
|
} from "./inspection.schema"
|
||||||
|
import {
|
||||||
|
INSPECTION_ROUTES,
|
||||||
|
CUSTOMER_ROUTES,
|
||||||
|
VEHICLE_ROUTES,
|
||||||
|
DEPARTMENT_ROUTES,
|
||||||
|
EMPLOYEE_ROUTES,
|
||||||
|
} from "@garage/api"
|
||||||
|
|
||||||
|
// ── Props ──
|
||||||
|
|
||||||
|
export type InspectionFormProps = {
|
||||||
|
resourceId?: string | null
|
||||||
|
initialData?: unknown
|
||||||
|
onSuccess?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Default values ──
|
||||||
|
|
||||||
|
const DEFAULT_VALUES: InspectionFormValues = {
|
||||||
|
customer: null,
|
||||||
|
vehicle: null,
|
||||||
|
department: null,
|
||||||
|
inspection_category: null,
|
||||||
|
employee: null,
|
||||||
|
title: "",
|
||||||
|
order_number: "",
|
||||||
|
date: "",
|
||||||
|
time: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mapping helpers ──
|
||||||
|
|
||||||
|
function mapToFormValues(data: unknown): InspectionFormValues {
|
||||||
|
const d = (data as any)?.data ?? data ?? {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
customer: toRelation(d.customer_id, d.customer?.first_name ? `${d.customer.first_name} ${d.customer.last_name ?? ""}`.trim() : undefined),
|
||||||
|
vehicle: toRelation(d.vehicle_id, d.vehicle?.make ? `${d.vehicle.make} ${d.vehicle.model ?? ""}`.trim() : undefined),
|
||||||
|
department: toRelation(d.department_id, d.department?.name),
|
||||||
|
inspection_category: toRelation(d.inspection_category_id, d.inspection_category?.name),
|
||||||
|
employee: toRelation(d.employee_id, d.employee?.first_name ? `${d.employee.first_name} ${d.employee.last_name ?? ""}`.trim() : undefined),
|
||||||
|
title: d.title ?? "",
|
||||||
|
order_number: d.order_number ?? "",
|
||||||
|
date: d.date ?? "",
|
||||||
|
time: d.time ?? "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapFormToPayload(values: InspectionFormValues) {
|
||||||
|
return {
|
||||||
|
customer_id: toId(values.customer),
|
||||||
|
vehicle_id: toId(values.vehicle),
|
||||||
|
department_id: toId(values.department),
|
||||||
|
inspection_category_id: toId(values.inspection_category),
|
||||||
|
employee_id: toId(values.employee),
|
||||||
|
title: values.title,
|
||||||
|
order_number: values.order_number || undefined,
|
||||||
|
date: values.date || undefined,
|
||||||
|
time: values.time || undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shared mapOption for async selects ──
|
||||||
|
|
||||||
|
const mapLookupOption = (item: any) => ({
|
||||||
|
value: String(item.id),
|
||||||
|
label: item.name ?? item.title ?? String(item.id),
|
||||||
|
})
|
||||||
|
|
||||||
|
const mapCustomerOption = (item: any) => ({
|
||||||
|
value: String(item.id),
|
||||||
|
label: `${item.first_name ?? ""} ${item.last_name ?? ""}`.trim() || String(item.id),
|
||||||
|
})
|
||||||
|
|
||||||
|
const mapVehicleOption = (item: any) => ({
|
||||||
|
value: String(item.id),
|
||||||
|
label: `${item.make ?? ""} ${item.model ?? ""} ${item.year ?? ""}`.trim() || String(item.id),
|
||||||
|
})
|
||||||
|
|
||||||
|
const mapEmployeeOption = (item: any) => ({
|
||||||
|
value: String(item.id),
|
||||||
|
label: `${item.first_name ?? ""} ${item.last_name ?? ""}`.trim() || String(item.id),
|
||||||
|
})
|
||||||
|
|
||||||
|
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
|
||||||
|
|
||||||
|
// ── Component ──
|
||||||
|
|
||||||
|
export function InspectionForm({ resourceId, initialData, onSuccess }: InspectionFormProps) {
|
||||||
|
const api = useAuthApi()
|
||||||
|
|
||||||
|
const { form, isEditing } = useResourceForm<InspectionFormValues, any>({
|
||||||
|
schema: inspectionFormSchema,
|
||||||
|
defaultValues: DEFAULT_VALUES,
|
||||||
|
resourceId,
|
||||||
|
initialData,
|
||||||
|
queryKey: [INSPECTION_ROUTES.BY_ID, resourceId],
|
||||||
|
mapToFormValues: mapToFormValues,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { mutate, error, isPending } = useFormMutation(form, {
|
||||||
|
mutationFn: (values: InspectionFormValues) => {
|
||||||
|
const payload = mapFormToPayload(values)
|
||||||
|
const promise = isEditing && resourceId
|
||||||
|
? api.inspections.update(resourceId, payload)
|
||||||
|
: api.inspections.create(payload)
|
||||||
|
toast.promise(promise, {
|
||||||
|
loading: isEditing ? "Updating inspection..." : "Creating inspection...",
|
||||||
|
success: isEditing ? "Inspection updated successfully" : "Inspection created successfully",
|
||||||
|
error: isEditing ? "Failed to update inspection" : "Failed to create inspection",
|
||||||
|
})
|
||||||
|
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 inspection" : "Failed to create inspection"}
|
||||||
|
</AlertTitle>
|
||||||
|
{error.message}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FieldGroup>
|
||||||
|
<RhfTextField name="title" label="Title" placeholder="e.g. Pre-purchase" required />
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<RhfAsyncSelectField
|
||||||
|
name="customer"
|
||||||
|
label="Customer"
|
||||||
|
placeholder="Select customer"
|
||||||
|
queryKey={[CUSTOMER_ROUTES.INDEX]}
|
||||||
|
listFn={() => api.customers.list()}
|
||||||
|
mapOption={mapCustomerOption}
|
||||||
|
{...STORE_OBJECT}
|
||||||
|
/>
|
||||||
|
<RhfAsyncSelectField
|
||||||
|
name="vehicle"
|
||||||
|
label="Vehicle"
|
||||||
|
placeholder="Select vehicle"
|
||||||
|
queryKey={[VEHICLE_ROUTES.INDEX]}
|
||||||
|
listFn={() => api.vehicles.list()}
|
||||||
|
mapOption={mapVehicleOption}
|
||||||
|
{...STORE_OBJECT}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<RhfAsyncSelectField
|
||||||
|
name="department"
|
||||||
|
label="Department"
|
||||||
|
placeholder="Select department"
|
||||||
|
queryKey={[DEPARTMENT_ROUTES.INDEX]}
|
||||||
|
listFn={() => api.departments.list()}
|
||||||
|
mapOption={mapLookupOption}
|
||||||
|
createForm={(props) => <DepartmentInlineForm {...props} />}
|
||||||
|
createLabel="Department"
|
||||||
|
{...STORE_OBJECT}
|
||||||
|
/>
|
||||||
|
<RhfAsyncSelectField
|
||||||
|
name="inspection_category"
|
||||||
|
label="Inspection Category"
|
||||||
|
placeholder="Select category"
|
||||||
|
queryKey={[INSPECTION_ROUTES.CATEGORIES]}
|
||||||
|
listFn={() => api.inspections.listCategories()}
|
||||||
|
mapOption={mapLookupOption}
|
||||||
|
createForm={(props) => <InspectionCategoryInlineForm {...props} />}
|
||||||
|
createLabel="Inspection Category"
|
||||||
|
{...STORE_OBJECT}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RhfAsyncSelectField
|
||||||
|
name="employee"
|
||||||
|
label="Employee"
|
||||||
|
placeholder="Select employee"
|
||||||
|
queryKey={[EMPLOYEE_ROUTES.INDEX]}
|
||||||
|
listFn={() => api.employees.list()}
|
||||||
|
mapOption={mapEmployeeOption}
|
||||||
|
{...STORE_OBJECT}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<RhfTextField name="order_number" label="Order Number" placeholder="e.g. ORD-001" />
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<RhfTextField name="date" label="Date" placeholder="YYYY-MM-DD" type="date" />
|
||||||
|
<RhfTextField name="time" label="Time" placeholder="HH:MM:SS" type="time" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" variant="default" disabled={isPending}>
|
||||||
|
{isEditing ? <Save /> : <Plus />}
|
||||||
|
{isPending
|
||||||
|
? (isEditing ? "Updating..." : "Creating...")
|
||||||
|
: (isEditing ? "Update Inspection" : "Create Inspection")}
|
||||||
|
</Button>
|
||||||
|
</FieldGroup>
|
||||||
|
</Rhform>
|
||||||
|
)
|
||||||
|
}
|
||||||
22
apps/dashboard/modules/inspections/inspection.schema.ts
Normal file
22
apps/dashboard/modules/inspections/inspection.schema.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
const relationFieldSchema = z
|
||||||
|
.object({ value: z.string(), label: z.string() })
|
||||||
|
.nullable()
|
||||||
|
|
||||||
|
const inspectionFormSchema = z.object({
|
||||||
|
customer: relationFieldSchema,
|
||||||
|
vehicle: relationFieldSchema,
|
||||||
|
department: relationFieldSchema,
|
||||||
|
inspection_category: relationFieldSchema,
|
||||||
|
employee: relationFieldSchema,
|
||||||
|
title: z.string().min(1, "Title is required"),
|
||||||
|
order_number: z.string().optional(),
|
||||||
|
date: z.string().optional(),
|
||||||
|
time: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
type InspectionFormValues = z.infer<typeof inspectionFormSchema>
|
||||||
|
|
||||||
|
export { inspectionFormSchema, relationFieldSchema }
|
||||||
|
export type { InspectionFormValues }
|
||||||
242
apps/dashboard/modules/parts/part-form.tsx
Normal file
242
apps/dashboard/modules/parts/part-form.tsx
Normal file
@ -0,0 +1,242 @@
|
|||||||
|
"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,
|
||||||
|
RhfTextareaField,
|
||||||
|
RhfAsyncSelectField,
|
||||||
|
} from "@/shared/components/form"
|
||||||
|
import { ShopTypeInlineForm } from "@/modules/vehicles/inline-forms/shop-type-inline-form"
|
||||||
|
import { InventoryCategoryInlineForm } from "@/modules/services/inline-forms/inventory-category-inline-form"
|
||||||
|
import { UnitTypeInlineForm } from "@/modules/services/inline-forms/unit-type-inline-form"
|
||||||
|
import { DepartmentInlineForm } from "@/modules/services/inline-forms/department-inline-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 { toId } from "@/shared/lib/utils"
|
||||||
|
|
||||||
|
import { partFormSchema, type PartFormValues } from "./part.schema"
|
||||||
|
import { PARTS_ROUTES } from "@garage/api"
|
||||||
|
|
||||||
|
// ── Props ──
|
||||||
|
|
||||||
|
export type PartFormProps = {
|
||||||
|
resourceId?: string | null
|
||||||
|
initialData?: unknown
|
||||||
|
onSuccess?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Default values ──
|
||||||
|
|
||||||
|
const DEFAULT_VALUES: PartFormValues = {
|
||||||
|
shop_type: null,
|
||||||
|
category: null,
|
||||||
|
unit_type: null,
|
||||||
|
department: null,
|
||||||
|
title: "",
|
||||||
|
sku: "",
|
||||||
|
description: "",
|
||||||
|
selling_price: undefined,
|
||||||
|
purchase_price: undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mapping helpers ──
|
||||||
|
|
||||||
|
const mapLookupOption = (item: any) => ({
|
||||||
|
value: String(item.id),
|
||||||
|
label: item.name ?? item.title ?? String(item.id),
|
||||||
|
})
|
||||||
|
|
||||||
|
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
|
||||||
|
|
||||||
|
function mapToFormValues(data: unknown): PartFormValues {
|
||||||
|
const d = (data as any)?.data ?? data ?? {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
shop_type: null,
|
||||||
|
category: null,
|
||||||
|
unit_type: null,
|
||||||
|
department: null,
|
||||||
|
title: d.title ?? d.name ?? "",
|
||||||
|
sku: d.sku ?? "",
|
||||||
|
description: d.description ?? "",
|
||||||
|
selling_price: d.selling_price ?? undefined,
|
||||||
|
purchase_price: d.purchase_price ?? undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapCreatePayload(values: PartFormValues) {
|
||||||
|
return {
|
||||||
|
shop_type_id: toId(values.shop_type),
|
||||||
|
category_id: toId(values.category),
|
||||||
|
unit_type_id: toId(values.unit_type),
|
||||||
|
department_id: toId(values.department),
|
||||||
|
title: values.title,
|
||||||
|
sku: values.sku || undefined,
|
||||||
|
description: values.description || undefined,
|
||||||
|
selling_price: values.selling_price,
|
||||||
|
purchase_price: values.purchase_price,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapUpdatePayload(values: PartFormValues) {
|
||||||
|
return {
|
||||||
|
title: values.title,
|
||||||
|
selling_price: values.selling_price,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component ──
|
||||||
|
|
||||||
|
export function PartForm({ resourceId, initialData, onSuccess }: PartFormProps) {
|
||||||
|
const api = useAuthApi()
|
||||||
|
|
||||||
|
const { form, isEditing } = useResourceForm<PartFormValues, any>({
|
||||||
|
schema: partFormSchema,
|
||||||
|
defaultValues: DEFAULT_VALUES,
|
||||||
|
resourceId,
|
||||||
|
initialData,
|
||||||
|
mapToFormValues,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { mutate, error, isPending } = useFormMutation(form, {
|
||||||
|
mutationFn: (values: PartFormValues) => {
|
||||||
|
const promise = isEditing && resourceId
|
||||||
|
? api.parts.update(resourceId, mapUpdatePayload(values))
|
||||||
|
: api.parts.create(mapCreatePayload(values))
|
||||||
|
toast.promise(promise, {
|
||||||
|
loading: isEditing ? "Updating part..." : "Creating part...",
|
||||||
|
success: isEditing ? "Part updated successfully" : "Part created successfully",
|
||||||
|
error: isEditing ? "Failed to update part" : "Failed to create part",
|
||||||
|
})
|
||||||
|
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 part" : "Failed to create part"}
|
||||||
|
</AlertTitle>
|
||||||
|
{error.message}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FieldGroup>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<RhfTextField
|
||||||
|
name="title"
|
||||||
|
label="Title"
|
||||||
|
placeholder="e.g. Brake Pad"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<RhfTextField
|
||||||
|
name="sku"
|
||||||
|
label="SKU"
|
||||||
|
placeholder="e.g. BP-001"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isEditing && (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<RhfAsyncSelectField
|
||||||
|
name="shop_type"
|
||||||
|
label="Shop Type"
|
||||||
|
placeholder="Select shop type"
|
||||||
|
queryKey={["shop-types"]}
|
||||||
|
listFn={() => api.shopTypes.list()}
|
||||||
|
mapOption={mapLookupOption}
|
||||||
|
createForm={(props) => <ShopTypeInlineForm {...props} />}
|
||||||
|
createLabel="Shop Type"
|
||||||
|
{...STORE_OBJECT}
|
||||||
|
/>
|
||||||
|
<RhfAsyncSelectField
|
||||||
|
name="category"
|
||||||
|
label="Category"
|
||||||
|
placeholder="Select category"
|
||||||
|
queryKey={["inventory-categories"]}
|
||||||
|
listFn={() => api.inventory.listCategories()}
|
||||||
|
mapOption={mapLookupOption}
|
||||||
|
createForm={(props) => <InventoryCategoryInlineForm {...props} />}
|
||||||
|
createLabel="Category"
|
||||||
|
{...STORE_OBJECT}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<RhfAsyncSelectField
|
||||||
|
name="unit_type"
|
||||||
|
label="Unit Type"
|
||||||
|
placeholder="Select unit type"
|
||||||
|
queryKey={["unit-types"]}
|
||||||
|
listFn={() => api.inventory.listUnitTypes()}
|
||||||
|
mapOption={mapLookupOption}
|
||||||
|
createForm={(props) => <UnitTypeInlineForm {...props} />}
|
||||||
|
createLabel="Unit Type"
|
||||||
|
{...STORE_OBJECT}
|
||||||
|
/>
|
||||||
|
<RhfAsyncSelectField
|
||||||
|
name="department"
|
||||||
|
label="Department"
|
||||||
|
placeholder="Select department"
|
||||||
|
queryKey={["departments"]}
|
||||||
|
listFn={() => api.departments.list()}
|
||||||
|
mapOption={(item: any) => ({ value: String(item.id), label: item.name ?? String(item.id) })}
|
||||||
|
createForm={(props) => <DepartmentInlineForm {...props} />}
|
||||||
|
createLabel="Department"
|
||||||
|
{...STORE_OBJECT}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<RhfTextField
|
||||||
|
name="selling_price"
|
||||||
|
label="Selling Price"
|
||||||
|
placeholder="0.00"
|
||||||
|
type="number"
|
||||||
|
/>
|
||||||
|
{!isEditing && (
|
||||||
|
<RhfTextField
|
||||||
|
name="purchase_price"
|
||||||
|
label="Purchase Price"
|
||||||
|
placeholder="0.00"
|
||||||
|
type="number"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RhfTextareaField
|
||||||
|
name="description"
|
||||||
|
label="Description"
|
||||||
|
placeholder="Optional description"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</FieldGroup>
|
||||||
|
|
||||||
|
<div className="mt-4 flex justify-end">
|
||||||
|
<Button type="submit" disabled={isPending}>
|
||||||
|
{isEditing ? <Save className="me-2 h-4 w-4" /> : <Plus className="me-2 h-4 w-4" />}
|
||||||
|
{isPending
|
||||||
|
? isEditing ? "Updating..." : "Creating..."
|
||||||
|
: isEditing ? "Update Part" : "Create Part"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Rhform>
|
||||||
|
)
|
||||||
|
}
|
||||||
19
apps/dashboard/modules/parts/part.schema.ts
Normal file
19
apps/dashboard/modules/parts/part.schema.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
export const relationFieldSchema = z
|
||||||
|
.object({ value: z.string(), label: z.string() })
|
||||||
|
.nullable()
|
||||||
|
|
||||||
|
export const partFormSchema = z.object({
|
||||||
|
shop_type: relationFieldSchema,
|
||||||
|
category: relationFieldSchema,
|
||||||
|
unit_type: relationFieldSchema,
|
||||||
|
department: relationFieldSchema,
|
||||||
|
title: z.string().min(1, "Title is required"),
|
||||||
|
sku: z.string().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
selling_price: z.coerce.number().min(0).optional(),
|
||||||
|
purchase_price: z.coerce.number().min(0).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type PartFormValues = z.infer<typeof partFormSchema>
|
||||||
274
apps/dashboard/modules/service-groups/service-group-form.tsx
Normal file
274
apps/dashboard/modules/service-groups/service-group-form.tsx
Normal file
@ -0,0 +1,274 @@
|
|||||||
|
"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,
|
||||||
|
RhfTextareaField,
|
||||||
|
RhfAsyncSelectField,
|
||||||
|
RhfCheckboxField,
|
||||||
|
} from "@/shared/components/form"
|
||||||
|
import { ShopTypeInlineForm } from "@/modules/vehicles/inline-forms/shop-type-inline-form"
|
||||||
|
import { InventoryCategoryInlineForm } from "@/modules/services/inline-forms/inventory-category-inline-form"
|
||||||
|
import { UnitTypeInlineForm } from "@/modules/services/inline-forms/unit-type-inline-form"
|
||||||
|
import { DepartmentInlineForm } from "@/modules/services/inline-forms/department-inline-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 { toId } from "@/shared/lib/utils"
|
||||||
|
|
||||||
|
import { serviceGroupFormSchema, type ServiceGroupFormValues } from "./service-group.schema"
|
||||||
|
import { SERVICE_GROUP_ROUTES } from "@garage/api"
|
||||||
|
|
||||||
|
// ── Props ──
|
||||||
|
|
||||||
|
export type ServiceGroupFormProps = {
|
||||||
|
resourceId?: string | null
|
||||||
|
initialData?: unknown
|
||||||
|
onSuccess?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Default values ──
|
||||||
|
|
||||||
|
const DEFAULT_VALUES: ServiceGroupFormValues = {
|
||||||
|
shop_type: null,
|
||||||
|
inventory_category: null,
|
||||||
|
unit_type: null,
|
||||||
|
department: null,
|
||||||
|
service_name: "",
|
||||||
|
code: "",
|
||||||
|
service_description: "",
|
||||||
|
selling_price: undefined,
|
||||||
|
selling_chart_of_account: "",
|
||||||
|
show_as_lump_sum: false,
|
||||||
|
mark_as_recommended: false,
|
||||||
|
set_packaged_pricing: false,
|
||||||
|
is_active: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mapping helpers ──
|
||||||
|
|
||||||
|
const mapLookupOption = (item: any) => ({
|
||||||
|
value: String(item.id),
|
||||||
|
label: item.name ?? item.title ?? String(item.id),
|
||||||
|
})
|
||||||
|
|
||||||
|
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
|
||||||
|
|
||||||
|
function mapToFormValues(data: unknown): ServiceGroupFormValues {
|
||||||
|
const d = (data as any)?.data ?? data ?? {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
shop_type: null,
|
||||||
|
inventory_category: null,
|
||||||
|
unit_type: null,
|
||||||
|
department: null,
|
||||||
|
service_name: d.service_name ?? d.name ?? "",
|
||||||
|
code: d.code ?? "",
|
||||||
|
service_description: d.service_description ?? "",
|
||||||
|
selling_price: d.selling_price ?? undefined,
|
||||||
|
selling_chart_of_account: d.selling_chart_of_account ?? "",
|
||||||
|
show_as_lump_sum: d.show_as_lump_sum ?? false,
|
||||||
|
mark_as_recommended: d.mark_as_recommended ?? false,
|
||||||
|
set_packaged_pricing: d.set_packaged_pricing ?? false,
|
||||||
|
is_active: d.is_active ?? true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapCreatePayload(values: ServiceGroupFormValues) {
|
||||||
|
return {
|
||||||
|
service_name: values.service_name,
|
||||||
|
shop_type_id: toId(values.shop_type),
|
||||||
|
code: values.code || undefined,
|
||||||
|
inventory_category_id: toId(values.inventory_category),
|
||||||
|
unit_type_id: toId(values.unit_type),
|
||||||
|
department_id: toId(values.department),
|
||||||
|
service_description: values.service_description || undefined,
|
||||||
|
show_as_lump_sum: values.show_as_lump_sum,
|
||||||
|
mark_as_recommended: values.mark_as_recommended,
|
||||||
|
set_packaged_pricing: values.set_packaged_pricing,
|
||||||
|
selling_price: values.selling_price,
|
||||||
|
selling_chart_of_account: values.selling_chart_of_account || undefined,
|
||||||
|
is_active: values.is_active,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapUpdatePayload(values: ServiceGroupFormValues) {
|
||||||
|
return {
|
||||||
|
service_name: values.service_name,
|
||||||
|
selling_price: values.selling_price,
|
||||||
|
is_active: values.is_active,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component ──
|
||||||
|
|
||||||
|
export function ServiceGroupForm({ resourceId, initialData, onSuccess }: ServiceGroupFormProps) {
|
||||||
|
const api = useAuthApi()
|
||||||
|
|
||||||
|
const { form, isEditing } = useResourceForm<ServiceGroupFormValues, any>({
|
||||||
|
schema: serviceGroupFormSchema,
|
||||||
|
defaultValues: DEFAULT_VALUES,
|
||||||
|
resourceId,
|
||||||
|
initialData,
|
||||||
|
mapToFormValues,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { mutate, error, isPending } = useFormMutation(form, {
|
||||||
|
mutationFn: (values: ServiceGroupFormValues) => {
|
||||||
|
const promise = isEditing && resourceId
|
||||||
|
? api.serviceGroups.update(resourceId, mapUpdatePayload(values))
|
||||||
|
: api.serviceGroups.create(mapCreatePayload(values))
|
||||||
|
toast.promise(promise, {
|
||||||
|
loading: isEditing ? "Updating service group..." : "Creating service group...",
|
||||||
|
success: isEditing ? "Service group updated" : "Service group created",
|
||||||
|
error: isEditing ? "Failed to update service group" : "Failed to create service group",
|
||||||
|
})
|
||||||
|
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 service group" : "Failed to create service group"}
|
||||||
|
</AlertTitle>
|
||||||
|
{error.message}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FieldGroup>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<RhfTextField
|
||||||
|
name="service_name"
|
||||||
|
label="Service Name"
|
||||||
|
placeholder="e.g. Engine Service Group"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<RhfTextField
|
||||||
|
name="code"
|
||||||
|
label="Code"
|
||||||
|
placeholder="e.g. SG-001"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isEditing && (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<RhfAsyncSelectField
|
||||||
|
name="shop_type"
|
||||||
|
label="Shop Type"
|
||||||
|
placeholder="Select shop type"
|
||||||
|
queryKey={["shop-types"]}
|
||||||
|
listFn={() => api.shopTypes.list()}
|
||||||
|
mapOption={mapLookupOption}
|
||||||
|
createForm={(props) => <ShopTypeInlineForm {...props} />}
|
||||||
|
createLabel="Shop Type"
|
||||||
|
{...STORE_OBJECT}
|
||||||
|
/>
|
||||||
|
<RhfAsyncSelectField
|
||||||
|
name="inventory_category"
|
||||||
|
label="Category"
|
||||||
|
placeholder="Select category"
|
||||||
|
queryKey={["inventory-categories"]}
|
||||||
|
listFn={() => api.inventory.listCategories()}
|
||||||
|
mapOption={mapLookupOption}
|
||||||
|
createForm={(props) => <InventoryCategoryInlineForm {...props} />}
|
||||||
|
createLabel="Category"
|
||||||
|
{...STORE_OBJECT}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<RhfAsyncSelectField
|
||||||
|
name="unit_type"
|
||||||
|
label="Unit Type"
|
||||||
|
placeholder="Select unit type"
|
||||||
|
queryKey={["unit-types"]}
|
||||||
|
listFn={() => api.inventory.listUnitTypes()}
|
||||||
|
mapOption={mapLookupOption}
|
||||||
|
createForm={(props) => <UnitTypeInlineForm {...props} />}
|
||||||
|
createLabel="Unit Type"
|
||||||
|
{...STORE_OBJECT}
|
||||||
|
/>
|
||||||
|
<RhfAsyncSelectField
|
||||||
|
name="department"
|
||||||
|
label="Department"
|
||||||
|
placeholder="Select department"
|
||||||
|
queryKey={["departments"]}
|
||||||
|
listFn={() => api.departments.list()}
|
||||||
|
mapOption={(item: any) => ({ value: String(item.id), label: item.name ?? String(item.id) })}
|
||||||
|
createForm={(props) => <DepartmentInlineForm {...props} />}
|
||||||
|
createLabel="Department"
|
||||||
|
{...STORE_OBJECT}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<RhfTextField
|
||||||
|
name="selling_price"
|
||||||
|
label="Selling Price"
|
||||||
|
placeholder="0.00"
|
||||||
|
type="number"
|
||||||
|
/>
|
||||||
|
<RhfTextField
|
||||||
|
name="selling_chart_of_account"
|
||||||
|
label="Selling Chart of Account"
|
||||||
|
placeholder="e.g. 4000"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RhfTextareaField
|
||||||
|
name="service_description"
|
||||||
|
label="Description"
|
||||||
|
placeholder="Optional description"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<RhfCheckboxField
|
||||||
|
name="show_as_lump_sum"
|
||||||
|
label="Show as Lump Sum"
|
||||||
|
/>
|
||||||
|
<RhfCheckboxField
|
||||||
|
name="mark_as_recommended"
|
||||||
|
label="Recommended"
|
||||||
|
/>
|
||||||
|
<RhfCheckboxField
|
||||||
|
name="set_packaged_pricing"
|
||||||
|
label="Set Packaged Pricing"
|
||||||
|
/>
|
||||||
|
<RhfCheckboxField
|
||||||
|
name="is_active"
|
||||||
|
label="Active"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</FieldGroup>
|
||||||
|
|
||||||
|
<div className="mt-4 flex justify-end">
|
||||||
|
<Button type="submit" disabled={isPending}>
|
||||||
|
{isEditing ? <Save className="me-2 h-4 w-4" /> : <Plus className="me-2 h-4 w-4" />}
|
||||||
|
{isPending
|
||||||
|
? isEditing ? "Updating..." : "Creating..."
|
||||||
|
: isEditing ? "Update Service Group" : "Create Service Group"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Rhform>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
export const relationFieldSchema = z
|
||||||
|
.object({ value: z.string(), label: z.string() })
|
||||||
|
.nullable()
|
||||||
|
|
||||||
|
export const serviceGroupFormSchema = z.object({
|
||||||
|
shop_type: relationFieldSchema,
|
||||||
|
inventory_category: relationFieldSchema,
|
||||||
|
unit_type: relationFieldSchema,
|
||||||
|
department: relationFieldSchema,
|
||||||
|
service_name: z.string().min(1, "Service name is required"),
|
||||||
|
code: z.string().optional(),
|
||||||
|
service_description: z.string().optional(),
|
||||||
|
selling_price: z.coerce.number().min(0).optional(),
|
||||||
|
selling_chart_of_account: z.string().optional(),
|
||||||
|
show_as_lump_sum: z.boolean().optional(),
|
||||||
|
mark_as_recommended: z.boolean().optional(),
|
||||||
|
set_packaged_pricing: z.boolean().optional(),
|
||||||
|
is_active: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type ServiceGroupFormValues = z.infer<typeof serviceGroupFormSchema>
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
export const DEPARTMENT_ASSIGNMENT_TYPE_OPTIONS = [
|
||||||
|
{ value: "none", label: "None" },
|
||||||
|
{ value: "bays", label: "Bays" },
|
||||||
|
{ value: "outsourced", label: "Outsourced" },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export type DepartmentAssignmentType = typeof DEPARTMENT_ASSIGNMENT_TYPE_OPTIONS[number]["value"]
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
// Renamed to inventory-category-inline-form.tsx
|
||||||
|
export { InventoryCategoryInlineForm as CategoryInlineForm } from "./inventory-category-inline-form"
|
||||||
@ -0,0 +1,66 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { z } from "zod"
|
||||||
|
import { useForm } from "react-hook-form"
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import { Plus } from "lucide-react"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { FieldGroup } from "@/shared/components/ui/field"
|
||||||
|
import { Rhform, RhfTextField, RhfSelectField, type InlineCreateFormProps } from "@/shared/components/form"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { useAuthApi } from "@/shared/useApi"
|
||||||
|
import { DEPARTMENT_ASSIGNMENT_TYPE_OPTIONS } from "../department-assignment-types"
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
name: z.string().min(1, "Name is required"),
|
||||||
|
assignment_type: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
type FormValues = z.infer<typeof schema>
|
||||||
|
|
||||||
|
export function DepartmentInlineForm({ onSuccess }: InlineCreateFormProps) {
|
||||||
|
const api = useAuthApi()
|
||||||
|
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
defaultValues: { name: "", assignment_type: "none" },
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSubmit = async (values: FormValues) => {
|
||||||
|
try {
|
||||||
|
const result = await api.departments.create({
|
||||||
|
name: values.name,
|
||||||
|
assignment_type: values.assignment_type || undefined,
|
||||||
|
})
|
||||||
|
toast.success("Department created")
|
||||||
|
form.reset()
|
||||||
|
const item = (result as any)?.data ?? result
|
||||||
|
onSuccess({ value: String(item.id), label: item.name ?? String(item.id) })
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to create department")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Rhform form={form} onSubmit={handleSubmit}>
|
||||||
|
<FieldGroup>
|
||||||
|
<RhfTextField
|
||||||
|
name="name"
|
||||||
|
label="Name"
|
||||||
|
placeholder="e.g. Mechanical"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<RhfSelectField
|
||||||
|
name="assignment_type"
|
||||||
|
label="Assignment Type"
|
||||||
|
placeholder="Select assignment type"
|
||||||
|
options={[...DEPARTMENT_ASSIGNMENT_TYPE_OPTIONS]}
|
||||||
|
/>
|
||||||
|
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||||
|
<Plus />
|
||||||
|
{form.formState.isSubmitting ? "Creating..." : "Create Department"}
|
||||||
|
</Button>
|
||||||
|
</FieldGroup>
|
||||||
|
</Rhform>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,78 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { z } from "zod"
|
||||||
|
import { useForm } from "react-hook-form"
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import { Plus } from "lucide-react"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { FieldGroup } from "@/shared/components/ui/field"
|
||||||
|
import { Rhform, RhfTextField, RhfAsyncSelectField, type InlineCreateFormProps } from "@/shared/components/form"
|
||||||
|
import { ShopTypeInlineForm } from "@/modules/vehicles/inline-forms/shop-type-inline-form"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { useAuthApi } from "@/shared/useApi"
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
title: z.string().min(1, "Title is required"),
|
||||||
|
shop_type: z.object({ value: z.string(), label: z.string() }).nullable(),
|
||||||
|
})
|
||||||
|
|
||||||
|
type FormValues = z.infer<typeof schema>
|
||||||
|
|
||||||
|
const mapLookupOption = (item: any) => ({
|
||||||
|
value: String(item.id),
|
||||||
|
label: item.name ?? item.title ?? String(item.id),
|
||||||
|
})
|
||||||
|
|
||||||
|
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
|
||||||
|
|
||||||
|
export function InventoryCategoryInlineForm({ onSuccess }: InlineCreateFormProps) {
|
||||||
|
const api = useAuthApi()
|
||||||
|
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
defaultValues: { title: "", shop_type: null },
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSubmit = async (values: FormValues) => {
|
||||||
|
try {
|
||||||
|
const result = await api.inventory.createCategory({
|
||||||
|
title: values.title,
|
||||||
|
shop_type_id: values.shop_type ? Number(values.shop_type.value) : undefined,
|
||||||
|
})
|
||||||
|
toast.success("Category created")
|
||||||
|
form.reset()
|
||||||
|
const item = (result as any)?.data ?? result
|
||||||
|
onSuccess({ value: String(item.id), label: item.title ?? item.name ?? String(item.id) })
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to create category")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Rhform form={form} onSubmit={handleSubmit}>
|
||||||
|
<FieldGroup>
|
||||||
|
<RhfTextField
|
||||||
|
name="title"
|
||||||
|
label="Title"
|
||||||
|
placeholder="e.g. Parts"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<RhfAsyncSelectField
|
||||||
|
name="shop_type"
|
||||||
|
label="Shop Type"
|
||||||
|
placeholder="Select shop type"
|
||||||
|
queryKey={["shop-types"]}
|
||||||
|
listFn={() => api.shopTypes.list()}
|
||||||
|
mapOption={mapLookupOption}
|
||||||
|
createForm={(props) => <ShopTypeInlineForm {...props} />}
|
||||||
|
createLabel="Shop Type"
|
||||||
|
{...STORE_OBJECT}
|
||||||
|
/>
|
||||||
|
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||||
|
<Plus />
|
||||||
|
{form.formState.isSubmitting ? "Creating..." : "Create Category"}
|
||||||
|
</Button>
|
||||||
|
</FieldGroup>
|
||||||
|
</Rhform>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,55 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { z } from "zod"
|
||||||
|
import { useForm } from "react-hook-form"
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import { Plus } from "lucide-react"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { FieldGroup } from "@/shared/components/ui/field"
|
||||||
|
import { Rhform, RhfTextField, type InlineCreateFormProps } from "@/shared/components/form"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { useAuthApi } from "@/shared/useApi"
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
title: z.string().min(1, "Title is required"),
|
||||||
|
})
|
||||||
|
|
||||||
|
type FormValues = z.infer<typeof schema>
|
||||||
|
|
||||||
|
export function UnitTypeInlineForm({ onSuccess }: InlineCreateFormProps) {
|
||||||
|
const api = useAuthApi()
|
||||||
|
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
defaultValues: { title: "" },
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSubmit = async (values: FormValues) => {
|
||||||
|
try {
|
||||||
|
const result = await api.inventory.createUnitType({ title: values.title })
|
||||||
|
toast.success("Unit type created")
|
||||||
|
form.reset()
|
||||||
|
const item = (result as any)?.data ?? result
|
||||||
|
onSuccess({ value: String(item.id), label: item.title ?? item.name ?? String(item.id) })
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to create unit type")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Rhform form={form} onSubmit={handleSubmit}>
|
||||||
|
<FieldGroup>
|
||||||
|
<RhfTextField
|
||||||
|
name="title"
|
||||||
|
label="Title"
|
||||||
|
placeholder="e.g. Hour"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||||
|
<Plus />
|
||||||
|
{form.formState.isSubmitting ? "Creating..." : "Create Unit Type"}
|
||||||
|
</Button>
|
||||||
|
</FieldGroup>
|
||||||
|
</Rhform>
|
||||||
|
)
|
||||||
|
}
|
||||||
238
apps/dashboard/modules/services/service-form.tsx
Normal file
238
apps/dashboard/modules/services/service-form.tsx
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
"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,
|
||||||
|
RhfTextareaField,
|
||||||
|
RhfAsyncSelectField,
|
||||||
|
} from "@/shared/components/form"
|
||||||
|
import { ShopTypeInlineForm } from "@/modules/vehicles/inline-forms/shop-type-inline-form"
|
||||||
|
import { InventoryCategoryInlineForm } from "./inline-forms/inventory-category-inline-form"
|
||||||
|
import { UnitTypeInlineForm } from "./inline-forms/unit-type-inline-form"
|
||||||
|
import { DepartmentInlineForm } from "./inline-forms/department-inline-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 { toId } from "@/shared/lib/utils"
|
||||||
|
|
||||||
|
import { serviceFormSchema, type ServiceFormValues } from "./service.schema"
|
||||||
|
import { SERVICE_ROUTES } from "@garage/api"
|
||||||
|
|
||||||
|
// ── Props ──
|
||||||
|
|
||||||
|
export type ServiceFormProps = {
|
||||||
|
resourceId?: string | null
|
||||||
|
initialData?: unknown
|
||||||
|
onSuccess?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Default values ──
|
||||||
|
|
||||||
|
const DEFAULT_VALUES: ServiceFormValues = {
|
||||||
|
shop_type: null,
|
||||||
|
category: null,
|
||||||
|
unit_type: null,
|
||||||
|
department: null,
|
||||||
|
labor_name: "",
|
||||||
|
service_code: "",
|
||||||
|
labor_matrix: "",
|
||||||
|
description: "",
|
||||||
|
selling_price: undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mapping helpers ──
|
||||||
|
|
||||||
|
const mapLookupOption = (item: any) => ({
|
||||||
|
value: String(item.id),
|
||||||
|
label: item.name ?? item.title ?? String(item.id),
|
||||||
|
})
|
||||||
|
|
||||||
|
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
|
||||||
|
|
||||||
|
function mapToFormValues(data: unknown): ServiceFormValues {
|
||||||
|
const d = (data as any)?.data ?? data ?? {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
shop_type: null,
|
||||||
|
category: null,
|
||||||
|
unit_type: null,
|
||||||
|
department: null,
|
||||||
|
labor_name: d.name || d.labor_name || "",
|
||||||
|
service_code: d.service_code || "",
|
||||||
|
labor_matrix: d.labor_matrix || "",
|
||||||
|
description: d.description || "",
|
||||||
|
selling_price: d.selling_price ?? undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapCreatePayload(values: ServiceFormValues) {
|
||||||
|
return {
|
||||||
|
shop_type_id: toId(values.shop_type),
|
||||||
|
category_id: toId(values.category),
|
||||||
|
unit_type_id: toId(values.unit_type),
|
||||||
|
department_id: toId(values.department),
|
||||||
|
labor_name: values.labor_name,
|
||||||
|
service_code: values.service_code || undefined,
|
||||||
|
labor_matrix: values.labor_matrix || undefined,
|
||||||
|
description: values.description || undefined,
|
||||||
|
selling_price: values.selling_price,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapUpdatePayload(values: ServiceFormValues) {
|
||||||
|
return {
|
||||||
|
labor_name: values.labor_name,
|
||||||
|
selling_price: values.selling_price,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component ──
|
||||||
|
|
||||||
|
export function ServiceForm({ resourceId, initialData, onSuccess }: ServiceFormProps) {
|
||||||
|
const api = useAuthApi()
|
||||||
|
|
||||||
|
const { form, isEditing } = useResourceForm<ServiceFormValues, any>({
|
||||||
|
schema: serviceFormSchema,
|
||||||
|
defaultValues: DEFAULT_VALUES,
|
||||||
|
resourceId,
|
||||||
|
initialData,
|
||||||
|
mapToFormValues,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { mutate, error, isPending } = useFormMutation(form, {
|
||||||
|
mutationFn: (values: ServiceFormValues) => {
|
||||||
|
const promise = isEditing && resourceId
|
||||||
|
? api.services.update(resourceId, mapUpdatePayload(values))
|
||||||
|
: api.services.create(mapCreatePayload(values))
|
||||||
|
toast.promise(promise, {
|
||||||
|
loading: isEditing ? "Updating service..." : "Creating service...",
|
||||||
|
success: isEditing ? "Service updated successfully" : "Service created successfully",
|
||||||
|
error: isEditing ? "Failed to update service" : "Failed to create service",
|
||||||
|
})
|
||||||
|
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 service" : "Failed to create service"}
|
||||||
|
</AlertTitle>
|
||||||
|
{error.message}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FieldGroup>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<RhfTextField
|
||||||
|
name="labor_name"
|
||||||
|
label="Labor Name"
|
||||||
|
placeholder="e.g. Oil Change"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<RhfTextField
|
||||||
|
name="service_code"
|
||||||
|
label="Service Code"
|
||||||
|
placeholder="e.g. SVC-001"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isEditing && (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<RhfAsyncSelectField
|
||||||
|
name="shop_type"
|
||||||
|
label="Shop Type"
|
||||||
|
placeholder="Select shop type"
|
||||||
|
queryKey={["shop-types"]}
|
||||||
|
listFn={() => api.shopTypes.list()}
|
||||||
|
mapOption={mapLookupOption}
|
||||||
|
createForm={(props) => <ShopTypeInlineForm {...props} />}
|
||||||
|
createLabel="Shop Type"
|
||||||
|
{...STORE_OBJECT}
|
||||||
|
/>
|
||||||
|
<RhfAsyncSelectField
|
||||||
|
name="category"
|
||||||
|
label="Category"
|
||||||
|
placeholder="Select category"
|
||||||
|
queryKey={["inventory-categories"]}
|
||||||
|
listFn={() => api.inventory.listCategories()}
|
||||||
|
mapOption={mapLookupOption}
|
||||||
|
createForm={(props) => <InventoryCategoryInlineForm {...props} />}
|
||||||
|
createLabel="Category"
|
||||||
|
{...STORE_OBJECT}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<RhfAsyncSelectField
|
||||||
|
name="unit_type"
|
||||||
|
label="Unit Type"
|
||||||
|
placeholder="Select unit type"
|
||||||
|
queryKey={["unit-types"]}
|
||||||
|
listFn={() => api.inventory.listUnitTypes()}
|
||||||
|
mapOption={mapLookupOption}
|
||||||
|
createForm={(props) => <UnitTypeInlineForm {...props} />}
|
||||||
|
createLabel="Unit Type"
|
||||||
|
{...STORE_OBJECT}
|
||||||
|
/>
|
||||||
|
<RhfAsyncSelectField
|
||||||
|
name="department"
|
||||||
|
label="Department"
|
||||||
|
placeholder="Select department"
|
||||||
|
queryKey={["departments"]}
|
||||||
|
listFn={() => api.departments.list()}
|
||||||
|
mapOption={(item: any) => ({ value: String(item.id), label: item.name ?? String(item.id) })}
|
||||||
|
createForm={(props) => <DepartmentInlineForm {...props} />}
|
||||||
|
createLabel="Department"
|
||||||
|
{...STORE_OBJECT}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RhfTextField
|
||||||
|
name="labor_matrix"
|
||||||
|
label="Labor Matrix"
|
||||||
|
placeholder="e.g. Standard"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<RhfTextField
|
||||||
|
name="selling_price"
|
||||||
|
label="Selling Price"
|
||||||
|
type="number"
|
||||||
|
placeholder="e.g. 75"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RhfTextareaField
|
||||||
|
name="description"
|
||||||
|
label="Description"
|
||||||
|
placeholder="Describe the service..."
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button type="submit" variant="default" disabled={isPending}>
|
||||||
|
{isEditing ? <Save /> : <Plus />}
|
||||||
|
{isPending
|
||||||
|
? (isEditing ? "Updating..." : "Creating...")
|
||||||
|
: (isEditing ? "Update Service" : "Create Service")}
|
||||||
|
</Button>
|
||||||
|
</FieldGroup>
|
||||||
|
</Rhform>
|
||||||
|
)
|
||||||
|
}
|
||||||
19
apps/dashboard/modules/services/service.schema.ts
Normal file
19
apps/dashboard/modules/services/service.schema.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
export const relationFieldSchema = z
|
||||||
|
.object({ value: z.string(), label: z.string() })
|
||||||
|
.nullable()
|
||||||
|
|
||||||
|
export const serviceFormSchema = z.object({
|
||||||
|
shop_type: relationFieldSchema,
|
||||||
|
category: relationFieldSchema,
|
||||||
|
unit_type: relationFieldSchema,
|
||||||
|
department: relationFieldSchema,
|
||||||
|
labor_name: z.string().min(1, "Labor name is required"),
|
||||||
|
service_code: z.string().optional(),
|
||||||
|
labor_matrix: z.string().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
selling_price: z.coerce.number().min(0).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type ServiceFormValues = z.infer<typeof serviceFormSchema>
|
||||||
157
apps/dashboard/modules/settings/shop-type/shop-type-form.tsx
Normal file
157
apps/dashboard/modules/settings/shop-type/shop-type-form.tsx
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
"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,
|
||||||
|
RhfTextareaField,
|
||||||
|
RhfCheckboxField,
|
||||||
|
RhfFileField,
|
||||||
|
} 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 { shopTypeFormSchema, type ShopTypeFormValues } from "./shop-type.schema"
|
||||||
|
import { SHOP_TYPE_ROUTES } from "@garage/api"
|
||||||
|
|
||||||
|
// ── Props ──
|
||||||
|
|
||||||
|
export type ShopTypeFormProps = {
|
||||||
|
resourceId?: string | null
|
||||||
|
initialData?: unknown
|
||||||
|
onSuccess?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Default values ──
|
||||||
|
|
||||||
|
const DEFAULT_VALUES: ShopTypeFormValues = {
|
||||||
|
title: "",
|
||||||
|
shop_type: "",
|
||||||
|
note: "",
|
||||||
|
is_default: false,
|
||||||
|
inspection: null,
|
||||||
|
image: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mapping helpers ──
|
||||||
|
|
||||||
|
function mapToFormValues(data: unknown): ShopTypeFormValues {
|
||||||
|
const d = (data as any)?.data ?? data ?? {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: d.title || "",
|
||||||
|
shop_type: d.shop_type || "",
|
||||||
|
note: d.note || "",
|
||||||
|
is_default: d.is_default ?? false,
|
||||||
|
// File fields cannot be pre-filled from URL strings
|
||||||
|
inspection: null,
|
||||||
|
image: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapFormToPayload(values: ShopTypeFormValues) {
|
||||||
|
return {
|
||||||
|
title: values.title,
|
||||||
|
shop_type: values.shop_type || undefined,
|
||||||
|
note: values.note || undefined,
|
||||||
|
is_default: values.is_default,
|
||||||
|
inspection: values.inspection ?? undefined,
|
||||||
|
image: values.image ?? undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component ──
|
||||||
|
|
||||||
|
export function ShopTypeForm({ resourceId, initialData, onSuccess }: ShopTypeFormProps) {
|
||||||
|
const api = useAuthApi()
|
||||||
|
|
||||||
|
const { form, isEditing } = useResourceForm<ShopTypeFormValues, any>({
|
||||||
|
schema: shopTypeFormSchema,
|
||||||
|
defaultValues: DEFAULT_VALUES,
|
||||||
|
resourceId,
|
||||||
|
initialData,
|
||||||
|
mapToFormValues,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { mutate, error, isPending } = useFormMutation(form, {
|
||||||
|
mutationFn: (values: ShopTypeFormValues) => {
|
||||||
|
const payload = mapFormToPayload(values)
|
||||||
|
const promise = isEditing && resourceId
|
||||||
|
? api.shopTypes.update(resourceId, payload)
|
||||||
|
: api.shopTypes.create({ ...payload, title: values.title })
|
||||||
|
toast.promise(promise, {
|
||||||
|
loading: isEditing ? "Updating shop type..." : "Creating shop type...",
|
||||||
|
success: isEditing ? "Shop type updated successfully" : "Shop type created successfully",
|
||||||
|
error: isEditing ? "Failed to update shop type" : "Failed to create shop type",
|
||||||
|
})
|
||||||
|
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 shop type" : "Failed to create shop type"}
|
||||||
|
</AlertTitle>
|
||||||
|
{error.message}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FieldGroup>
|
||||||
|
<RhfTextField
|
||||||
|
name="title"
|
||||||
|
label="Title"
|
||||||
|
placeholder="e.g. Main Workshop"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<RhfTextField
|
||||||
|
name="shop_type"
|
||||||
|
label="Type"
|
||||||
|
placeholder="e.g. Car, Truck"
|
||||||
|
/>
|
||||||
|
<RhfTextareaField
|
||||||
|
name="note"
|
||||||
|
label="Note"
|
||||||
|
placeholder="Optional description"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
<RhfCheckboxField
|
||||||
|
name="is_default"
|
||||||
|
label="Set as default"
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<RhfFileField
|
||||||
|
name="inspection"
|
||||||
|
label="Inspection Template"
|
||||||
|
accept=".pdf,.doc,.docx"
|
||||||
|
/>
|
||||||
|
<RhfFileField
|
||||||
|
name="image"
|
||||||
|
label="Image"
|
||||||
|
accept="image/*"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" variant="default" disabled={isPending}>
|
||||||
|
{isEditing ? <Save /> : <Plus />}
|
||||||
|
{isPending
|
||||||
|
? (isEditing ? "Updating..." : "Creating...")
|
||||||
|
: (isEditing ? "Update Shop Type" : "Create Shop Type")}
|
||||||
|
</Button>
|
||||||
|
</FieldGroup>
|
||||||
|
</Rhform>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
export const shopTypeFormSchema = z.object({
|
||||||
|
title: z.string().min(1, "Title is required"),
|
||||||
|
shop_type: z.string().optional(),
|
||||||
|
note: z.string().optional(),
|
||||||
|
is_default: z.boolean().optional(),
|
||||||
|
inspection: z.any().optional(),
|
||||||
|
image: z.any().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type ShopTypeFormValues = {
|
||||||
|
title: string
|
||||||
|
shop_type?: string
|
||||||
|
note?: string
|
||||||
|
is_default?: boolean
|
||||||
|
inspection?: File | null
|
||||||
|
image?: File | null
|
||||||
|
}
|
||||||
99
apps/dashboard/modules/shop-calendars/shop-calendar-form.tsx
Normal file
99
apps/dashboard/modules/shop-calendars/shop-calendar-form.tsx
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { AlertTriangle, Plus } 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,
|
||||||
|
RhfCheckboxField,
|
||||||
|
} 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 {
|
||||||
|
shopCalendarFormSchema,
|
||||||
|
type ShopCalendarFormValues,
|
||||||
|
} from "./shop-calendar.schema"
|
||||||
|
import { SHOP_CALENDAR_ROUTES } from "@garage/api"
|
||||||
|
|
||||||
|
// ── Props ──
|
||||||
|
|
||||||
|
export type ShopCalendarFormProps = {
|
||||||
|
resourceId?: string | null
|
||||||
|
initialData?: unknown
|
||||||
|
onSuccess?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Default values ──
|
||||||
|
|
||||||
|
const DEFAULT_VALUES: ShopCalendarFormValues = {
|
||||||
|
title: "",
|
||||||
|
is_default: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component ──
|
||||||
|
|
||||||
|
export function ShopCalendarForm({ resourceId, onSuccess }: ShopCalendarFormProps) {
|
||||||
|
const api = useAuthApi()
|
||||||
|
|
||||||
|
const { form } = useResourceForm<ShopCalendarFormValues, any>({
|
||||||
|
schema: shopCalendarFormSchema,
|
||||||
|
defaultValues: DEFAULT_VALUES,
|
||||||
|
resourceId: null,
|
||||||
|
queryKey: [SHOP_CALENDAR_ROUTES.INDEX],
|
||||||
|
mapToFormValues: (data: unknown) => {
|
||||||
|
const d = (data as any)?.data ?? data ?? {}
|
||||||
|
return {
|
||||||
|
title: d.title ?? "",
|
||||||
|
is_default: d.is_default ?? false,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const { mutate, error, isPending } = useFormMutation(form, {
|
||||||
|
mutationFn: (values: ShopCalendarFormValues) => {
|
||||||
|
const payload = {
|
||||||
|
title: values.title,
|
||||||
|
is_default: values.is_default,
|
||||||
|
}
|
||||||
|
const promise = api.shopCalendars.create(payload)
|
||||||
|
toast.promise(promise, {
|
||||||
|
loading: "Creating shop calendar...",
|
||||||
|
success: "Shop calendar created successfully",
|
||||||
|
error: "Failed to create shop calendar",
|
||||||
|
})
|
||||||
|
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>Failed to create shop calendar</AlertTitle>
|
||||||
|
{error.message}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FieldGroup>
|
||||||
|
<RhfTextField name="title" label="Title" placeholder="Enter calendar title" required />
|
||||||
|
<RhfCheckboxField name="is_default" label="Set as default" />
|
||||||
|
|
||||||
|
<Button type="submit" variant="default" disabled={isPending}>
|
||||||
|
<Plus />
|
||||||
|
{isPending ? "Creating..." : "Create Shop Calendar"}
|
||||||
|
</Button>
|
||||||
|
</FieldGroup>
|
||||||
|
</Rhform>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
const shopCalendarFormSchema = z.object({
|
||||||
|
title: z.string().min(1, "Title is required"),
|
||||||
|
is_default: z.boolean(),
|
||||||
|
})
|
||||||
|
|
||||||
|
type ShopCalendarFormValues = z.infer<typeof shopCalendarFormSchema>
|
||||||
|
|
||||||
|
export { shopCalendarFormSchema }
|
||||||
|
export type { ShopCalendarFormValues }
|
||||||
161
apps/dashboard/modules/shop-timings/shop-timing-form.tsx
Normal file
161
apps/dashboard/modules/shop-timings/shop-timing-form.tsx
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
"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,
|
||||||
|
RhfCheckboxField,
|
||||||
|
} 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 {
|
||||||
|
shopTimingFormSchema,
|
||||||
|
type ShopTimingFormValues,
|
||||||
|
} from "./shop-timing.schema"
|
||||||
|
import { SHOP_TIMING_ROUTES } from "@garage/api"
|
||||||
|
|
||||||
|
// ── Props ──
|
||||||
|
|
||||||
|
export type ShopTimingFormProps = {
|
||||||
|
resourceId?: string | null
|
||||||
|
initialData?: unknown
|
||||||
|
onSuccess?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Default values ──
|
||||||
|
|
||||||
|
const DEFAULT_VALUES: ShopTimingFormValues = {
|
||||||
|
title: "",
|
||||||
|
in_time: "",
|
||||||
|
out_time: "",
|
||||||
|
full_day_hours: "",
|
||||||
|
half_day_hours: "",
|
||||||
|
punch_in: "",
|
||||||
|
punch_out: "",
|
||||||
|
before_time: "",
|
||||||
|
after_time: "",
|
||||||
|
is_default: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mapping helpers ──
|
||||||
|
|
||||||
|
function mapToFormValues(data: unknown): ShopTimingFormValues {
|
||||||
|
const d = (data as any)?.data ?? data ?? {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: d.title ?? "",
|
||||||
|
in_time: d.in_time ?? "",
|
||||||
|
out_time: d.out_time ?? "",
|
||||||
|
full_day_hours: d.full_day_hours ?? "",
|
||||||
|
half_day_hours: d.half_day_hours ?? "",
|
||||||
|
punch_in: d.punch_in ?? "",
|
||||||
|
punch_out: d.punch_out ?? "",
|
||||||
|
before_time: d.before_time ?? "",
|
||||||
|
after_time: d.after_time ?? "",
|
||||||
|
is_default: d.is_default ?? false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapFormToPayload(values: ShopTimingFormValues) {
|
||||||
|
return {
|
||||||
|
title: values.title,
|
||||||
|
in_time: values.in_time,
|
||||||
|
out_time: values.out_time,
|
||||||
|
full_day_hours: values.full_day_hours || undefined,
|
||||||
|
half_day_hours: values.half_day_hours || undefined,
|
||||||
|
punch_in: values.punch_in || undefined,
|
||||||
|
punch_out: values.punch_out || undefined,
|
||||||
|
before_time: values.before_time || undefined,
|
||||||
|
after_time: values.after_time || undefined,
|
||||||
|
is_default: values.is_default,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component ──
|
||||||
|
|
||||||
|
export function ShopTimingForm({ resourceId, initialData, onSuccess }: ShopTimingFormProps) {
|
||||||
|
const api = useAuthApi()
|
||||||
|
|
||||||
|
const { form, isEditing } = useResourceForm<ShopTimingFormValues, any>({
|
||||||
|
schema: shopTimingFormSchema,
|
||||||
|
defaultValues: DEFAULT_VALUES,
|
||||||
|
resourceId,
|
||||||
|
initialData,
|
||||||
|
initialize: (id) => api.shopTimings.show(id),
|
||||||
|
queryKey: [SHOP_TIMING_ROUTES.BY_ID, resourceId],
|
||||||
|
mapToFormValues: mapToFormValues,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { mutate, error, isPending } = useFormMutation(form, {
|
||||||
|
mutationFn: (values: ShopTimingFormValues) => {
|
||||||
|
const payload = mapFormToPayload(values)
|
||||||
|
const promise = isEditing && resourceId
|
||||||
|
? api.shopTimings.update(resourceId, payload)
|
||||||
|
: api.shopTimings.create(payload)
|
||||||
|
toast.promise(promise, {
|
||||||
|
loading: isEditing ? "Updating shop timing..." : "Creating shop timing...",
|
||||||
|
success: isEditing ? "Shop timing updated successfully" : "Shop timing created successfully",
|
||||||
|
error: isEditing ? "Failed to update shop timing" : "Failed to create shop timing",
|
||||||
|
})
|
||||||
|
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 shop timing" : "Failed to create shop timing"}
|
||||||
|
</AlertTitle>
|
||||||
|
{error.message}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FieldGroup>
|
||||||
|
<RhfTextField name="title" label="Title" placeholder="Enter title" required />
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<RhfTextField name="in_time" label="In Time" placeholder="HH:MM:SS" required />
|
||||||
|
<RhfTextField name="out_time" label="Out Time" placeholder="HH:MM:SS" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<RhfTextField name="full_day_hours" label="Full Day Hours" placeholder="HH:MM:SS" />
|
||||||
|
<RhfTextField name="half_day_hours" label="Half Day Hours" placeholder="HH:MM:SS" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<RhfTextField name="punch_in" label="Punch In" placeholder="HH:MM:SS" />
|
||||||
|
<RhfTextField name="punch_out" label="Punch Out" placeholder="HH:MM:SS" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<RhfTextField name="before_time" label="Before Time" placeholder="HH:MM:SS" />
|
||||||
|
<RhfTextField name="after_time" label="After Time" placeholder="HH:MM:SS" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RhfCheckboxField name="is_default" label="Set as default" />
|
||||||
|
|
||||||
|
<Button type="submit" variant="default" disabled={isPending}>
|
||||||
|
{isEditing ? <Save /> : <Plus />}
|
||||||
|
{isPending
|
||||||
|
? (isEditing ? "Updating..." : "Creating...")
|
||||||
|
: (isEditing ? "Update Shop Timing" : "Create Shop Timing")}
|
||||||
|
</Button>
|
||||||
|
</FieldGroup>
|
||||||
|
</Rhform>
|
||||||
|
)
|
||||||
|
}
|
||||||
19
apps/dashboard/modules/shop-timings/shop-timing.schema.ts
Normal file
19
apps/dashboard/modules/shop-timings/shop-timing.schema.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
const shopTimingFormSchema = z.object({
|
||||||
|
title: z.string().min(1, "Title is required"),
|
||||||
|
in_time: z.string().min(1, "In time is required"),
|
||||||
|
out_time: z.string().min(1, "Out time is required"),
|
||||||
|
full_day_hours: z.string().optional(),
|
||||||
|
half_day_hours: z.string().optional(),
|
||||||
|
punch_in: z.string().optional(),
|
||||||
|
punch_out: z.string().optional(),
|
||||||
|
before_time: z.string().optional(),
|
||||||
|
after_time: z.string().optional(),
|
||||||
|
is_default: z.boolean().default(false),
|
||||||
|
})
|
||||||
|
|
||||||
|
type ShopTimingFormValues = z.infer<typeof shopTimingFormSchema>
|
||||||
|
|
||||||
|
export { shopTimingFormSchema }
|
||||||
|
export type { ShopTimingFormValues }
|
||||||
@ -0,0 +1,55 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { z } from "zod"
|
||||||
|
import { useForm } from "react-hook-form"
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import { Plus } from "lucide-react"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { FieldGroup } from "@/shared/components/ui/field"
|
||||||
|
import { Rhform, RhfTextField, type InlineCreateFormProps } from "@/shared/components/form"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { useAuthApi } from "@/shared/useApi"
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
title: z.string().min(1, "Title is required"),
|
||||||
|
})
|
||||||
|
|
||||||
|
type FormValues = z.infer<typeof schema>
|
||||||
|
|
||||||
|
export function BodyTypeInlineForm({ onSuccess }: InlineCreateFormProps) {
|
||||||
|
const api = useAuthApi()
|
||||||
|
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
defaultValues: { title: "" },
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSubmit = async (values: FormValues) => {
|
||||||
|
try {
|
||||||
|
const result = await api.vehicleAttributes.createBodyType({ title: values.title })
|
||||||
|
toast.success("Body type created")
|
||||||
|
form.reset()
|
||||||
|
const item = (result as any)?.data ?? result
|
||||||
|
onSuccess({ value: String(item.id), label: item.title ?? String(item.id) })
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to create body type")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Rhform form={form} onSubmit={handleSubmit}>
|
||||||
|
<FieldGroup>
|
||||||
|
<RhfTextField
|
||||||
|
name="title"
|
||||||
|
label="Title"
|
||||||
|
placeholder="e.g. Sedan"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||||
|
<Plus />
|
||||||
|
{form.formState.isSubmitting ? "Creating..." : "Create Body Type"}
|
||||||
|
</Button>
|
||||||
|
</FieldGroup>
|
||||||
|
</Rhform>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,55 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { z } from "zod"
|
||||||
|
import { useForm } from "react-hook-form"
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import { Plus } from "lucide-react"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { FieldGroup } from "@/shared/components/ui/field"
|
||||||
|
import { Rhform, RhfTextField, type InlineCreateFormProps } from "@/shared/components/form"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { useAuthApi } from "@/shared/useApi"
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
title: z.string().min(1, "Title is required"),
|
||||||
|
})
|
||||||
|
|
||||||
|
type FormValues = z.infer<typeof schema>
|
||||||
|
|
||||||
|
export function ColorInlineForm({ onSuccess }: InlineCreateFormProps) {
|
||||||
|
const api = useAuthApi()
|
||||||
|
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
defaultValues: { title: "" },
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSubmit = async (values: FormValues) => {
|
||||||
|
try {
|
||||||
|
const result = await api.vehicleAttributes.createColor({ title: values.title })
|
||||||
|
toast.success("Color created")
|
||||||
|
form.reset()
|
||||||
|
const item = (result as any)?.data ?? result
|
||||||
|
onSuccess({ value: String(item.id), label: item.title ?? String(item.id) })
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to create color")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Rhform form={form} onSubmit={handleSubmit}>
|
||||||
|
<FieldGroup>
|
||||||
|
<RhfTextField
|
||||||
|
name="title"
|
||||||
|
label="Title"
|
||||||
|
placeholder="e.g. Black"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||||
|
<Plus />
|
||||||
|
{form.formState.isSubmitting ? "Creating..." : "Create Color"}
|
||||||
|
</Button>
|
||||||
|
</FieldGroup>
|
||||||
|
</Rhform>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,55 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { z } from "zod"
|
||||||
|
import { useForm } from "react-hook-form"
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import { Plus } from "lucide-react"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { FieldGroup } from "@/shared/components/ui/field"
|
||||||
|
import { Rhform, RhfTextField, type InlineCreateFormProps } from "@/shared/components/form"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { useAuthApi } from "@/shared/useApi"
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
title: z.string().min(1, "Title is required"),
|
||||||
|
})
|
||||||
|
|
||||||
|
type FormValues = z.infer<typeof schema>
|
||||||
|
|
||||||
|
export function FuelTypeInlineForm({ onSuccess }: InlineCreateFormProps) {
|
||||||
|
const api = useAuthApi()
|
||||||
|
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
defaultValues: { title: "" },
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSubmit = async (values: FormValues) => {
|
||||||
|
try {
|
||||||
|
const result = await api.vehicleAttributes.createFuelType({ title: values.title })
|
||||||
|
toast.success("Fuel type created")
|
||||||
|
form.reset()
|
||||||
|
const item = (result as any)?.data ?? result
|
||||||
|
onSuccess({ value: String(item.id), label: item.title ?? String(item.id) })
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to create fuel type")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Rhform form={form} onSubmit={handleSubmit}>
|
||||||
|
<FieldGroup>
|
||||||
|
<RhfTextField
|
||||||
|
name="title"
|
||||||
|
label="Title"
|
||||||
|
placeholder="e.g. Gasoline"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||||
|
<Plus />
|
||||||
|
{form.formState.isSubmitting ? "Creating..." : "Create Fuel Type"}
|
||||||
|
</Button>
|
||||||
|
</FieldGroup>
|
||||||
|
</Rhform>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,114 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { z } from "zod"
|
||||||
|
import { useForm } from "react-hook-form"
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import { Plus } from "lucide-react"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { FieldGroup } from "@/shared/components/ui/field"
|
||||||
|
import {
|
||||||
|
Rhform,
|
||||||
|
RhfTextField,
|
||||||
|
RhfTextareaField,
|
||||||
|
RhfCheckboxField,
|
||||||
|
RhfFileField,
|
||||||
|
type InlineCreateFormProps,
|
||||||
|
} from "@/shared/components/form"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { useAuthApi } from "@/shared/useApi"
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
title: z.string().min(1, "Title is required"),
|
||||||
|
shop_type: z.string().optional(),
|
||||||
|
note: z.string().optional(),
|
||||||
|
is_default: z.boolean().optional(),
|
||||||
|
inspection: z.any().optional(),
|
||||||
|
image: z.any().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
type FormValues = {
|
||||||
|
title: string
|
||||||
|
shop_type?: string
|
||||||
|
note?: string
|
||||||
|
is_default?: boolean
|
||||||
|
inspection?: File | null
|
||||||
|
image?: File | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ShopTypeInlineForm({ onSuccess }: InlineCreateFormProps) {
|
||||||
|
const api = useAuthApi()
|
||||||
|
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
defaultValues: {
|
||||||
|
title: "",
|
||||||
|
shop_type: "",
|
||||||
|
note: "",
|
||||||
|
is_default: false,
|
||||||
|
inspection: null,
|
||||||
|
image: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSubmit = async (values: FormValues) => {
|
||||||
|
try {
|
||||||
|
const result = await api.shopTypes.create({
|
||||||
|
title: values.title,
|
||||||
|
shop_type: values.shop_type || undefined,
|
||||||
|
note: values.note || undefined,
|
||||||
|
is_default: values.is_default,
|
||||||
|
inspection: values.inspection ?? undefined,
|
||||||
|
image: values.image ?? undefined,
|
||||||
|
})
|
||||||
|
toast.success("Shop type created")
|
||||||
|
form.reset()
|
||||||
|
const item = (result as any)?.data ?? result
|
||||||
|
onSuccess({ value: String(item.id), label: item.title ?? String(item.id) })
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to create shop type")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Rhform form={form} onSubmit={handleSubmit}>
|
||||||
|
<FieldGroup>
|
||||||
|
<RhfTextField
|
||||||
|
name="title"
|
||||||
|
label="Title"
|
||||||
|
placeholder="e.g. Main Workshop"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<RhfTextField
|
||||||
|
name="shop_type"
|
||||||
|
label="Type"
|
||||||
|
placeholder="e.g. Car, Truck"
|
||||||
|
/>
|
||||||
|
<RhfTextareaField
|
||||||
|
name="note"
|
||||||
|
label="Note"
|
||||||
|
placeholder="Optional description"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
<RhfCheckboxField
|
||||||
|
name="is_default"
|
||||||
|
label="Set as default"
|
||||||
|
/>
|
||||||
|
<RhfFileField
|
||||||
|
name="inspection"
|
||||||
|
label="Inspection Template"
|
||||||
|
accept=".pdf,.doc,.docx"
|
||||||
|
/>
|
||||||
|
<RhfFileField
|
||||||
|
name="image"
|
||||||
|
label="Image"
|
||||||
|
accept="image/*"
|
||||||
|
/>
|
||||||
|
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||||
|
<Plus />
|
||||||
|
{form.formState.isSubmitting ? "Creating..." : "Create Shop Type"}
|
||||||
|
</Button>
|
||||||
|
</FieldGroup>
|
||||||
|
</Rhform>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,55 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { z } from "zod"
|
||||||
|
import { useForm } from "react-hook-form"
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import { Plus } from "lucide-react"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { FieldGroup } from "@/shared/components/ui/field"
|
||||||
|
import { Rhform, RhfTextField, type InlineCreateFormProps } from "@/shared/components/form"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { useAuthApi } from "@/shared/useApi"
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
title: z.string().min(1, "Title is required"),
|
||||||
|
})
|
||||||
|
|
||||||
|
type FormValues = z.infer<typeof schema>
|
||||||
|
|
||||||
|
export function TransmissionInlineForm({ onSuccess }: InlineCreateFormProps) {
|
||||||
|
const api = useAuthApi()
|
||||||
|
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
defaultValues: { title: "" },
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSubmit = async (values: FormValues) => {
|
||||||
|
try {
|
||||||
|
const result = await api.vehicleAttributes.createTransmission({ title: values.title })
|
||||||
|
toast.success("Transmission created")
|
||||||
|
form.reset()
|
||||||
|
const item = (result as any)?.data ?? result
|
||||||
|
onSuccess({ value: String(item.id), label: item.title ?? String(item.id) })
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to create transmission")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Rhform form={form} onSubmit={handleSubmit}>
|
||||||
|
<FieldGroup>
|
||||||
|
<RhfTextField
|
||||||
|
name="title"
|
||||||
|
label="Title"
|
||||||
|
placeholder="e.g. Automatic"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||||
|
<Plus />
|
||||||
|
{form.formState.isSubmitting ? "Creating..." : "Create Transmission"}
|
||||||
|
</Button>
|
||||||
|
</FieldGroup>
|
||||||
|
</Rhform>
|
||||||
|
)
|
||||||
|
}
|
||||||
267
apps/dashboard/modules/vehicles/vehicle-form.tsx
Normal file
267
apps/dashboard/modules/vehicles/vehicle-form.tsx
Normal file
@ -0,0 +1,267 @@
|
|||||||
|
"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,
|
||||||
|
RhfTextareaField,
|
||||||
|
RhfAsyncSelectField,
|
||||||
|
} from "@/shared/components/form"
|
||||||
|
import { ShopTypeInlineForm } from "./inline-forms/shop-type-inline-form"
|
||||||
|
import { BodyTypeInlineForm } from "./inline-forms/body-type-inline-form"
|
||||||
|
import { FuelTypeInlineForm } from "./inline-forms/fuel-type-inline-form"
|
||||||
|
import { TransmissionInlineForm } from "./inline-forms/transmission-inline-form"
|
||||||
|
import { ColorInlineForm } from "./inline-forms/color-inline-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 { vehicleFormSchema, type VehicleFormValues } from "./vehicle.schema"
|
||||||
|
import { VEHICLE_ROUTES } from "@garage/api"
|
||||||
|
|
||||||
|
// ── Props ──
|
||||||
|
|
||||||
|
export type VehicleFormProps = {
|
||||||
|
resourceId?: string | null
|
||||||
|
initialData?: unknown
|
||||||
|
onSuccess?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Default values ──
|
||||||
|
|
||||||
|
const DEFAULT_VALUES: VehicleFormValues = {
|
||||||
|
shop_type: null,
|
||||||
|
vehicle_body_type: null,
|
||||||
|
vehicle_fuel_type: null,
|
||||||
|
vehicle_transmission: null,
|
||||||
|
vehicle_color: null,
|
||||||
|
make: "",
|
||||||
|
model: "",
|
||||||
|
year: "",
|
||||||
|
sub_model: "",
|
||||||
|
license_plate: "",
|
||||||
|
vin_number: "",
|
||||||
|
engine_size: "",
|
||||||
|
drivetrain: "",
|
||||||
|
mileage: "",
|
||||||
|
owners_number: "",
|
||||||
|
note: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mapping helpers ──
|
||||||
|
|
||||||
|
const mapLookupOption = (item: any) => ({
|
||||||
|
value: String(item.id),
|
||||||
|
label: item.name ?? item.title ?? String(item.id),
|
||||||
|
})
|
||||||
|
|
||||||
|
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
|
||||||
|
|
||||||
|
function mapToFormValues(data: unknown): VehicleFormValues {
|
||||||
|
const d = (data as any)?.data ?? data ?? {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
shop_type: toRelation(d.shop_type_id, d.shop_type?.title),
|
||||||
|
vehicle_body_type: toRelation(d.vehicle_body_type_id, d.vehicle_body_type?.title),
|
||||||
|
vehicle_fuel_type: toRelation(d.vehicle_fuel_type_id, d.vehicle_fuel_type?.title),
|
||||||
|
vehicle_transmission: toRelation(d.vehicle_transmission_id, d.vehicle_transmission?.title),
|
||||||
|
vehicle_color: toRelation(d.vehicle_color_id, d.vehicle_color?.title),
|
||||||
|
make: d.make || "",
|
||||||
|
model: d.model || "",
|
||||||
|
year: d.year || "",
|
||||||
|
sub_model: d.sub_model || "",
|
||||||
|
license_plate: d.license_plate || "",
|
||||||
|
vin_number: d.vin_number || "",
|
||||||
|
engine_size: d.engine_size || "",
|
||||||
|
drivetrain: d.drivetrain || "",
|
||||||
|
mileage: d.mileage || "",
|
||||||
|
owners_number: d.owners_number || "",
|
||||||
|
note: d.note || "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapCreatePayload(values: VehicleFormValues) {
|
||||||
|
return {
|
||||||
|
shop_type_id: toId(values.shop_type),
|
||||||
|
vehicle_body_type_id: toId(values.vehicle_body_type),
|
||||||
|
vehicle_fuel_type_id: toId(values.vehicle_fuel_type),
|
||||||
|
vehicle_transmission_id: toId(values.vehicle_transmission),
|
||||||
|
vehicle_color_id: toId(values.vehicle_color),
|
||||||
|
make: values.make,
|
||||||
|
model: values.model,
|
||||||
|
year: values.year,
|
||||||
|
sub_model: values.sub_model || undefined,
|
||||||
|
license_plate: values.license_plate || undefined,
|
||||||
|
vin_number: values.vin_number || undefined,
|
||||||
|
engine_size: values.engine_size || undefined,
|
||||||
|
drivetrain: values.drivetrain || undefined,
|
||||||
|
mileage: values.mileage || undefined,
|
||||||
|
owners_number: values.owners_number || undefined,
|
||||||
|
note: values.note || undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapUpdatePayload(values: VehicleFormValues) {
|
||||||
|
return {
|
||||||
|
mileage: values.mileage || undefined,
|
||||||
|
license_plate: values.license_plate || undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component ──
|
||||||
|
|
||||||
|
export function VehicleForm({ resourceId, initialData, onSuccess }: VehicleFormProps) {
|
||||||
|
const api = useAuthApi()
|
||||||
|
|
||||||
|
const { form, isEditing } = useResourceForm<VehicleFormValues, any>({
|
||||||
|
schema: vehicleFormSchema,
|
||||||
|
defaultValues: DEFAULT_VALUES,
|
||||||
|
resourceId,
|
||||||
|
initialData,
|
||||||
|
mapToFormValues,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { mutate, error, isPending } = useFormMutation(form, {
|
||||||
|
mutationFn: (values: VehicleFormValues) => {
|
||||||
|
const promise = isEditing && resourceId
|
||||||
|
? api.vehicles.update(resourceId, mapUpdatePayload(values))
|
||||||
|
: api.vehicles.create(mapCreatePayload(values))
|
||||||
|
toast.promise(promise, {
|
||||||
|
loading: isEditing ? "Updating vehicle..." : "Creating vehicle...",
|
||||||
|
success: isEditing ? "Vehicle updated successfully" : "Vehicle created successfully",
|
||||||
|
error: isEditing ? "Failed to update vehicle" : "Failed to create vehicle",
|
||||||
|
})
|
||||||
|
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 vehicle" : "Failed to create vehicle"}
|
||||||
|
</AlertTitle>
|
||||||
|
{error.message}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FieldGroup>
|
||||||
|
{!isEditing && (
|
||||||
|
<>
|
||||||
|
{/* Vehicle identity */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
|
<RhfTextField name="make" label="Make" placeholder="e.g. Toyota" required />
|
||||||
|
<RhfTextField name="model" label="Model" placeholder="e.g. Camry" required />
|
||||||
|
<RhfTextField name="year" label="Year" placeholder="e.g. 2024" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RhfTextField name="sub_model" label="Sub Model" placeholder="e.g. LE" />
|
||||||
|
|
||||||
|
{/* Associations */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<RhfAsyncSelectField
|
||||||
|
name="shop_type"
|
||||||
|
label="Shop Type"
|
||||||
|
placeholder="Select shop type"
|
||||||
|
queryKey={["shop-types"]}
|
||||||
|
listFn={() => api.shopTypes.list()}
|
||||||
|
mapOption={mapLookupOption}
|
||||||
|
createForm={(props) => <ShopTypeInlineForm {...props} />}
|
||||||
|
createLabel="Shop Type"
|
||||||
|
{...STORE_OBJECT}
|
||||||
|
/>
|
||||||
|
<RhfAsyncSelectField
|
||||||
|
name="vehicle_body_type"
|
||||||
|
label="Body Type"
|
||||||
|
placeholder="Select body type"
|
||||||
|
queryKey={["vehicle-body-types"]}
|
||||||
|
listFn={() => api.vehicleAttributes.listBodyTypes()}
|
||||||
|
mapOption={mapLookupOption}
|
||||||
|
createForm={(props) => <BodyTypeInlineForm {...props} />}
|
||||||
|
createLabel="Body Type"
|
||||||
|
{...STORE_OBJECT}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<RhfAsyncSelectField
|
||||||
|
name="vehicle_fuel_type"
|
||||||
|
label="Fuel Type"
|
||||||
|
placeholder="Select fuel type"
|
||||||
|
queryKey={["vehicle-fuel-types"]}
|
||||||
|
listFn={() => api.vehicleAttributes.listFuelTypes()}
|
||||||
|
mapOption={mapLookupOption}
|
||||||
|
createForm={(props) => <FuelTypeInlineForm {...props} />}
|
||||||
|
createLabel="Fuel Type"
|
||||||
|
{...STORE_OBJECT}
|
||||||
|
/>
|
||||||
|
<RhfAsyncSelectField
|
||||||
|
name="vehicle_transmission"
|
||||||
|
label="Transmission"
|
||||||
|
placeholder="Select transmission"
|
||||||
|
queryKey={["vehicle-transmissions"]}
|
||||||
|
listFn={() => api.vehicleAttributes.listTransmissions()}
|
||||||
|
mapOption={mapLookupOption}
|
||||||
|
createForm={(props) => <TransmissionInlineForm {...props} />}
|
||||||
|
createLabel="Transmission"
|
||||||
|
{...STORE_OBJECT}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<RhfAsyncSelectField
|
||||||
|
name="vehicle_color"
|
||||||
|
label="Color"
|
||||||
|
placeholder="Select color"
|
||||||
|
queryKey={["vehicle-colors"]}
|
||||||
|
listFn={() => api.vehicleAttributes.listColors()}
|
||||||
|
mapOption={mapLookupOption}
|
||||||
|
createForm={(props) => <ColorInlineForm {...props} />}
|
||||||
|
createLabel="Color"
|
||||||
|
{...STORE_OBJECT}
|
||||||
|
/>
|
||||||
|
<RhfTextField name="vin_number" label="VIN Number" placeholder="e.g. 1HGBH41JXMN109186" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Technical specs */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<RhfTextField name="engine_size" label="Engine Size" placeholder="e.g. 2.5L" />
|
||||||
|
<RhfTextField name="drivetrain" label="Drivetrain" placeholder="e.g. FWD" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RhfTextField name="owners_number" label="Number of Owners" placeholder="e.g. 1" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Editable in both create and update */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<RhfTextField name="license_plate" label="License Plate" placeholder="e.g. ABC-123" />
|
||||||
|
<RhfTextField name="mileage" label="Mileage" placeholder="e.g. 10000" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isEditing && (
|
||||||
|
<RhfTextareaField name="note" label="Notes" rows={3} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button type="submit" variant="default" disabled={isPending}>
|
||||||
|
{isEditing ? <Save /> : <Plus />}
|
||||||
|
{isPending
|
||||||
|
? (isEditing ? "Updating..." : "Creating...")
|
||||||
|
: (isEditing ? "Update Vehicle" : "Create Vehicle")}
|
||||||
|
</Button>
|
||||||
|
</FieldGroup>
|
||||||
|
</Rhform>
|
||||||
|
)
|
||||||
|
}
|
||||||
35
apps/dashboard/modules/vehicles/vehicle.schema.ts
Normal file
35
apps/dashboard/modules/vehicles/vehicle.schema.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
export const relationFieldSchema = z
|
||||||
|
.object({ value: z.string(), label: z.string() })
|
||||||
|
.nullable()
|
||||||
|
|
||||||
|
export const vehicleFormSchema = z.object({
|
||||||
|
// ── Relations ──
|
||||||
|
shop_type: relationFieldSchema,
|
||||||
|
vehicle_body_type: relationFieldSchema,
|
||||||
|
vehicle_fuel_type: relationFieldSchema,
|
||||||
|
vehicle_transmission: relationFieldSchema,
|
||||||
|
vehicle_color: relationFieldSchema,
|
||||||
|
|
||||||
|
// ── Vehicle identity ──
|
||||||
|
make: z.string().optional(),
|
||||||
|
model: z.string().optional(),
|
||||||
|
year: z.string().optional(),
|
||||||
|
sub_model: z.string().optional(),
|
||||||
|
|
||||||
|
// ── License & identifiers ──
|
||||||
|
license_plate: z.string().optional(),
|
||||||
|
vin_number: z.string().optional(),
|
||||||
|
|
||||||
|
// ── Technical specs ──
|
||||||
|
engine_size: z.string().optional(),
|
||||||
|
drivetrain: z.string().optional(),
|
||||||
|
mileage: z.string().optional(),
|
||||||
|
owners_number: z.string().optional(),
|
||||||
|
|
||||||
|
// ── Notes ──
|
||||||
|
note: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type VehicleFormValues = z.infer<typeof vehicleFormSchema>
|
||||||
4
apps/dashboard/next.config.mjs
Normal file
4
apps/dashboard/next.config.mjs
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {}
|
||||||
|
|
||||||
|
export default nextConfig
|
||||||
65
apps/dashboard/package.json
Normal file
65
apps/dashboard/package.json
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
{
|
||||||
|
"name": "@garage/dashboard",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"predev": "pnpm --filter @garage/api run generate",
|
||||||
|
"dev": "next dev --turbopack",
|
||||||
|
"prebuild": "pnpm --filter @garage/api run generate",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "eslint",
|
||||||
|
"format": "prettier --write \"**/*.{ts,tsx}\"",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"cypress:open": "cypress open",
|
||||||
|
"cypress:run": "cypress run",
|
||||||
|
"test:e2e": "cypress run"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@base-ui/react": "^1.3.0",
|
||||||
|
"@hookform/resolvers": "^5.2.2",
|
||||||
|
"@garage/api": "workspace:*",
|
||||||
|
"@tanstack/react-query": "^5.95.2",
|
||||||
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"embla-carousel-react": "^8.6.0",
|
||||||
|
"input-otp": "^1.4.2",
|
||||||
|
"lucide-react": "^0.577.0",
|
||||||
|
"next": "16.1.7",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
|
"nuqs": "^2.8.9",
|
||||||
|
"radix-ui": "^1.4.3",
|
||||||
|
"react": "^19.2.4",
|
||||||
|
"react-day-picker": "^9.14.0",
|
||||||
|
"react-dom": "^19.2.4",
|
||||||
|
"react-hook-form": "^7.72.0",
|
||||||
|
"react-resizable-panels": "^4.7.5",
|
||||||
|
"recharts": "3.8.0",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
|
"tailwind-merge": "^3.5.0",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"vaul": "^1.1.2",
|
||||||
|
"zod": "^4.3.6",
|
||||||
|
"zustand": "^5.0.12"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/eslintrc": "^3",
|
||||||
|
"@tailwindcss/postcss": "^4.2.1",
|
||||||
|
"@types/node": "^25.5.0",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"cypress": "^15.13.0",
|
||||||
|
"eslint": "^9.39.4",
|
||||||
|
"eslint-config-next": "16.1.7",
|
||||||
|
"postcss": "^8",
|
||||||
|
"prettier": "^3.8.1",
|
||||||
|
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||||
|
"shadcn": "^4.1.0",
|
||||||
|
"tailwindcss": "^4.2.1",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
8
apps/dashboard/postcss.config.mjs
Normal file
8
apps/dashboard/postcss.config.mjs
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
/** @type {import('postcss-load-config').Config} */
|
||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default config
|
||||||
0
apps/dashboard/public/.gitkeep
Normal file
0
apps/dashboard/public/.gitkeep
Normal file
BIN
apps/dashboard/public/assets/logo.png
Normal file
BIN
apps/dashboard/public/assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
12
apps/dashboard/shared/api.ts
Normal file
12
apps/dashboard/shared/api.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { createApi, } from "@garage/api";
|
||||||
|
import { useAuthStore } from "./stores/auth-store";
|
||||||
|
import { getAuthCookies } from "@/modules/auth/auth.actions";
|
||||||
|
|
||||||
|
export const getAuthApi = async () => {
|
||||||
|
const { token } = await getAuthCookies();
|
||||||
|
console.log(`Auth Token: ${token}`);
|
||||||
|
const api = createApi({ headers: token ? { Authorization: `Bearer ${token}` } : undefined });
|
||||||
|
return api;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
0
apps/dashboard/shared/components/.gitkeep
Normal file
0
apps/dashboard/shared/components/.gitkeep
Normal file
120
apps/dashboard/shared/components/confirm-dialog.tsx
Normal file
120
apps/dashboard/shared/components/confirm-dialog.tsx
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { create } from "zustand"
|
||||||
|
import { Trash2 } from "lucide-react"
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogMedia,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/shared/components/ui/alert-dialog"
|
||||||
|
|
||||||
|
// ── Types ──
|
||||||
|
|
||||||
|
export type ConfirmOptions = {
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
confirmLabel?: string
|
||||||
|
cancelLabel?: string
|
||||||
|
variant?: "destructive" | "default"
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConfirmStore = {
|
||||||
|
open: boolean
|
||||||
|
options: ConfirmOptions
|
||||||
|
resolve: ((value: boolean) => void) | null
|
||||||
|
_show: (options: ConfirmOptions) => Promise<boolean>
|
||||||
|
_close: (confirmed: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Store ──
|
||||||
|
|
||||||
|
const useConfirmStore = create<ConfirmStore>((set, get) => ({
|
||||||
|
open: false,
|
||||||
|
options: {},
|
||||||
|
resolve: null,
|
||||||
|
_show: (options) =>
|
||||||
|
new Promise<boolean>((resolve) => {
|
||||||
|
set({ open: true, options, resolve })
|
||||||
|
}),
|
||||||
|
_close: (confirmed) => {
|
||||||
|
const { resolve } = get()
|
||||||
|
resolve?.(confirmed)
|
||||||
|
set({ open: false, resolve: null })
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ── Imperative API (usage: `await confirm({ ... })`) ──
|
||||||
|
|
||||||
|
export function confirm(options: ConfirmOptions = {}) {
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
const state = useConfirmStore.getState()
|
||||||
|
if (state.open) {
|
||||||
|
console.warn("[ConfirmDialog] A confirm dialog is already open. Nested confirms are not supported.")
|
||||||
|
}
|
||||||
|
// Detect missing <ConfirmDialog /> mount: if `resolve` is never set after a tick, the dialog component is not mounted.
|
||||||
|
const result = state._show(options)
|
||||||
|
setTimeout(() => {
|
||||||
|
const current = useConfirmStore.getState()
|
||||||
|
if (current.open && current.resolve === null) {
|
||||||
|
console.warn(
|
||||||
|
"[ConfirmDialog] confirm() was called but <ConfirmDialog /> does not appear to be mounted. " +
|
||||||
|
"Make sure <ConfirmDialog /> is rendered in your root layout.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}, 100)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
return useConfirmStore.getState()._show(options)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Dialog component (mount once in the root layout) ──
|
||||||
|
|
||||||
|
export function ConfirmDialog() {
|
||||||
|
const { open, options, _close } = useConfirmStore()
|
||||||
|
|
||||||
|
const isDestructive = options.variant === "destructive"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertDialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(v) => {
|
||||||
|
if (!v) _close(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertDialogContent size="sm">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
{isDestructive && (
|
||||||
|
<AlertDialogMedia>
|
||||||
|
<Trash2 className="text-destructive" />
|
||||||
|
</AlertDialogMedia>
|
||||||
|
)}
|
||||||
|
<AlertDialogTitle>
|
||||||
|
{options.title ?? "Are you sure?"}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
{options.description && (
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{options.description}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
)}
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel onClick={() => _close(false)}>
|
||||||
|
{options.cancelLabel ?? "Cancel"}
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
variant={isDestructive ? "destructive" : "default"}
|
||||||
|
onClick={() => _close(true)}
|
||||||
|
>
|
||||||
|
{options.confirmLabel ?? "Confirm"}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
87
apps/dashboard/shared/components/form-dialog.tsx
Normal file
87
apps/dashboard/shared/components/form-dialog.tsx
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { useQueryStates, parseAsBoolean, parseAsString } from 'nuqs'
|
||||||
|
import { Button } from '@/shared/components/ui/button'
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/shared/components/ui/dialog'
|
||||||
|
import { ScrollArea } from '@/shared/components/ui/scroll-area'
|
||||||
|
import { Plus } from 'lucide-react'
|
||||||
|
|
||||||
|
export const formDialogParams = {
|
||||||
|
dialog: parseAsBoolean.withDefault(false),
|
||||||
|
resourceId: parseAsString,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFormDialog(paramKey?: string) {
|
||||||
|
// Default (no paramKey) uses the standard `dialog` and `resourceId` params
|
||||||
|
const defaultState = useQueryStates(formDialogParams)
|
||||||
|
|
||||||
|
// When a paramKey is provided, use prefixed params to avoid URL collisions
|
||||||
|
const prefixedState = useQueryStates({
|
||||||
|
[`${paramKey ?? "_"}_dialog`]: parseAsBoolean.withDefault(false),
|
||||||
|
[`${paramKey ?? "_"}_resourceId`]: parseAsString,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (paramKey) {
|
||||||
|
const [params, setParams] = prefixedState
|
||||||
|
const dialogKey = `${paramKey}_dialog`
|
||||||
|
const resourceIdKey = `${paramKey}_resourceId`
|
||||||
|
|
||||||
|
const open = (resourceId?: string) => {
|
||||||
|
setParams({ [dialogKey]: true, [resourceIdKey]: resourceId ?? null })
|
||||||
|
}
|
||||||
|
const close = () => {
|
||||||
|
setParams({ [dialogKey]: false, [resourceIdKey]: null })
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isOpen: (params as Record<string, unknown>)[dialogKey] as boolean,
|
||||||
|
resourceId: (params as Record<string, unknown>)[resourceIdKey] as string | null,
|
||||||
|
open,
|
||||||
|
close,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [params, setParams] = defaultState
|
||||||
|
|
||||||
|
const open = (resourceId?: string) => {
|
||||||
|
setParams({ dialog: true, resourceId: resourceId ?? null })
|
||||||
|
}
|
||||||
|
const close = () => {
|
||||||
|
setParams({ dialog: false, resourceId: null })
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isOpen: params.dialog,
|
||||||
|
resourceId: params.resourceId,
|
||||||
|
open,
|
||||||
|
close,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FormDialog(props: {
|
||||||
|
children: (resourceId: string | null) => React.ReactNode
|
||||||
|
title: string
|
||||||
|
paramKey?: string
|
||||||
|
}) {
|
||||||
|
const { isOpen, resourceId, open, close } = useFormDialog(props.paramKey)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button size='lg' onClick={() => open()}>
|
||||||
|
<Plus />
|
||||||
|
{props.title}
|
||||||
|
</Button>
|
||||||
|
<Dialog open={isOpen} onOpenChange={(v) => { if (!v) close() }}>
|
||||||
|
<DialogContent className='min-w-xl'>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className='text-2xl font-bold'>
|
||||||
|
{props.title}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<ScrollArea className='max-h-[80vh] px-4'>
|
||||||
|
{props.children(resourceId)}
|
||||||
|
</ScrollArea>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,160 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useRef } from "react"
|
||||||
|
import type { AsyncOption, BaseFieldControlProps } from "../types"
|
||||||
|
import { Loader2 } from "lucide-react"
|
||||||
|
import {
|
||||||
|
Combobox,
|
||||||
|
ComboboxInput,
|
||||||
|
ComboboxContent,
|
||||||
|
ComboboxList,
|
||||||
|
ComboboxItem,
|
||||||
|
ComboboxEmpty,
|
||||||
|
} from "@/shared/components/ui/combobox"
|
||||||
|
|
||||||
|
const defaultGetOptionValue = (opt: any) => opt.value
|
||||||
|
const defaultGetOptionLabel = (opt: any) => opt.label
|
||||||
|
function defaultGetOptionKey(opt: any): string {
|
||||||
|
const v = defaultGetOptionValue(opt)
|
||||||
|
if (typeof v === "string" || typeof v === "number") return String(v)
|
||||||
|
return String(opt.id ?? JSON.stringify(v))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Single-select ──
|
||||||
|
|
||||||
|
export type AsyncSelectFieldProps<TOption = AsyncOption> = BaseFieldControlProps<any> & {
|
||||||
|
options: TOption[]
|
||||||
|
loading?: boolean
|
||||||
|
onInputValueChange?: (value: string) => void
|
||||||
|
placeholder?: string
|
||||||
|
getOptionValue?: (option: TOption) => any
|
||||||
|
getOptionLabel?: (option: TOption) => string
|
||||||
|
getOptionKey?: (option: TOption) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AsyncSelectField<TOption = AsyncOption>({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onBlur,
|
||||||
|
disabled,
|
||||||
|
invalid,
|
||||||
|
options,
|
||||||
|
loading,
|
||||||
|
onInputValueChange,
|
||||||
|
placeholder = "Search...",
|
||||||
|
getOptionValue = defaultGetOptionValue,
|
||||||
|
getOptionLabel = defaultGetOptionLabel,
|
||||||
|
getOptionKey = defaultGetOptionKey,
|
||||||
|
}: AsyncSelectFieldProps<TOption>) {
|
||||||
|
const anchorRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={anchorRef}>
|
||||||
|
<Combobox
|
||||||
|
value={value}
|
||||||
|
onValueChange={(val) => onChange(val)}
|
||||||
|
disabled={disabled}
|
||||||
|
onInputValueChange={(val, { reason }) => {
|
||||||
|
if (reason === "input-change") {
|
||||||
|
onInputValueChange?.(val)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ComboboxInput
|
||||||
|
placeholder={placeholder}
|
||||||
|
showClear={!!value}
|
||||||
|
onBlur={onBlur}
|
||||||
|
aria-invalid={invalid || undefined}
|
||||||
|
/>
|
||||||
|
<ComboboxContent anchor={anchorRef}>
|
||||||
|
<ComboboxList>
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center justify-center py-4">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!loading &&
|
||||||
|
options.map((opt) => (
|
||||||
|
<ComboboxItem key={getOptionKey(opt)} value={getOptionValue(opt)}>
|
||||||
|
{getOptionLabel(opt)}
|
||||||
|
</ComboboxItem>
|
||||||
|
))}
|
||||||
|
{!loading && options.length === 0 && (
|
||||||
|
<ComboboxEmpty>No results found</ComboboxEmpty>
|
||||||
|
)}
|
||||||
|
</ComboboxList>
|
||||||
|
</ComboboxContent>
|
||||||
|
</Combobox>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Multi-select ──
|
||||||
|
|
||||||
|
export type AsyncMultiSelectFieldProps<TOption = AsyncOption> = BaseFieldControlProps<any[]> & {
|
||||||
|
options: TOption[]
|
||||||
|
loading?: boolean
|
||||||
|
onInputValueChange?: (value: string) => void
|
||||||
|
placeholder?: string
|
||||||
|
getOptionValue?: (option: TOption) => any
|
||||||
|
getOptionLabel?: (option: TOption) => string
|
||||||
|
getOptionKey?: (option: TOption) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AsyncMultiSelectField<TOption = AsyncOption>({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onBlur,
|
||||||
|
disabled,
|
||||||
|
invalid,
|
||||||
|
options,
|
||||||
|
loading,
|
||||||
|
onInputValueChange,
|
||||||
|
placeholder = "Search...",
|
||||||
|
getOptionValue = defaultGetOptionValue,
|
||||||
|
getOptionLabel = defaultGetOptionLabel,
|
||||||
|
getOptionKey = defaultGetOptionKey,
|
||||||
|
}: AsyncMultiSelectFieldProps<TOption>) {
|
||||||
|
const anchorRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={anchorRef}>
|
||||||
|
<Combobox
|
||||||
|
multiple
|
||||||
|
value={value ?? []}
|
||||||
|
onValueChange={(val) => onChange(val as any[])}
|
||||||
|
disabled={disabled}
|
||||||
|
onInputValueChange={(val, { reason }) => {
|
||||||
|
if (reason === "input-change") {
|
||||||
|
onInputValueChange?.(val)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ComboboxInput
|
||||||
|
placeholder={placeholder}
|
||||||
|
showClear={value && value.length > 0}
|
||||||
|
onBlur={onBlur}
|
||||||
|
aria-invalid={invalid || undefined}
|
||||||
|
/>
|
||||||
|
<ComboboxContent anchor={anchorRef}>
|
||||||
|
<ComboboxList>
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center justify-center py-4">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!loading &&
|
||||||
|
options.map((opt) => (
|
||||||
|
<ComboboxItem key={getOptionKey(opt)} value={getOptionValue(opt)}>
|
||||||
|
{getOptionLabel(opt)}
|
||||||
|
</ComboboxItem>
|
||||||
|
))}
|
||||||
|
{!loading && options.length === 0 && (
|
||||||
|
<ComboboxEmpty>No results found</ComboboxEmpty>
|
||||||
|
)}
|
||||||
|
</ComboboxList>
|
||||||
|
</ComboboxContent>
|
||||||
|
</Combobox>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import type { BaseFieldControlProps } from "../types"
|
||||||
|
import { Switch } from "@/shared/components/ui/switch"
|
||||||
|
|
||||||
|
export type CheckboxFieldProps = BaseFieldControlProps<boolean> & {
|
||||||
|
label?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CheckboxField({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onBlur,
|
||||||
|
name,
|
||||||
|
disabled,
|
||||||
|
invalid,
|
||||||
|
}: CheckboxFieldProps) {
|
||||||
|
return (
|
||||||
|
<Switch
|
||||||
|
checked={value}
|
||||||
|
onCheckedChange={(checked) => onChange(checked === true)}
|
||||||
|
onBlur={onBlur}
|
||||||
|
name={name}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-invalid={invalid || undefined}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
import type { BaseFieldControlProps } from "../types"
|
||||||
|
import { Input } from "@/shared/components/ui/input"
|
||||||
|
|
||||||
|
export type FileInputFieldProps = BaseFieldControlProps<File | null> & {
|
||||||
|
accept?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FileInputField({
|
||||||
|
// value intentionally unused — file inputs cannot be controlled
|
||||||
|
onBlur,
|
||||||
|
name,
|
||||||
|
disabled,
|
||||||
|
invalid,
|
||||||
|
accept,
|
||||||
|
onChange,
|
||||||
|
}: FileInputFieldProps) {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
type="file"
|
||||||
|
accept={accept}
|
||||||
|
onBlur={onBlur}
|
||||||
|
name={name}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-invalid={invalid || undefined}
|
||||||
|
onChange={(e) => onChange(e.target.files?.[0] ?? null)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import type { BaseFieldControlProps, SelectOption } from "../types"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/shared/components/ui/select"
|
||||||
|
|
||||||
|
export type SelectFieldProps = BaseFieldControlProps<string> & {
|
||||||
|
placeholder?: string
|
||||||
|
options: SelectOption[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SelectField({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onBlur,
|
||||||
|
name,
|
||||||
|
disabled,
|
||||||
|
invalid,
|
||||||
|
placeholder,
|
||||||
|
options,
|
||||||
|
}: SelectFieldProps) {
|
||||||
|
return (
|
||||||
|
<Select value={value} onValueChange={onChange} disabled={disabled} name={name}>
|
||||||
|
<SelectTrigger
|
||||||
|
className="w-full"
|
||||||
|
aria-invalid={invalid || undefined}
|
||||||
|
onBlur={onBlur}
|
||||||
|
>
|
||||||
|
<SelectValue placeholder={placeholder} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{options.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
import type { BaseFieldControlProps } from "../types"
|
||||||
|
import { Input } from "@/shared/components/ui/input"
|
||||||
|
|
||||||
|
export type TextInputFieldProps = BaseFieldControlProps<string> & {
|
||||||
|
placeholder?: string
|
||||||
|
type?: React.HTMLInputTypeAttribute
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TextInputField({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onBlur,
|
||||||
|
name,
|
||||||
|
disabled,
|
||||||
|
invalid,
|
||||||
|
placeholder,
|
||||||
|
type = "text",
|
||||||
|
}: TextInputFieldProps) {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
onBlur={onBlur}
|
||||||
|
name={name}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-invalid={invalid || undefined}
|
||||||
|
placeholder={placeholder}
|
||||||
|
type={type}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
import type { BaseFieldControlProps } from "../types"
|
||||||
|
import { Textarea } from "@/shared/components/ui/textarea"
|
||||||
|
|
||||||
|
export type TextareaFieldProps = BaseFieldControlProps<string> & {
|
||||||
|
placeholder?: string
|
||||||
|
rows?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TextareaField({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onBlur,
|
||||||
|
name,
|
||||||
|
disabled,
|
||||||
|
invalid,
|
||||||
|
placeholder,
|
||||||
|
rows,
|
||||||
|
}: TextareaFieldProps) {
|
||||||
|
return (
|
||||||
|
<Textarea
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
onBlur={onBlur}
|
||||||
|
name={name}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-invalid={invalid || undefined}
|
||||||
|
placeholder={placeholder}
|
||||||
|
rows={rows}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
29
apps/dashboard/shared/components/form/field-shell.tsx
Normal file
29
apps/dashboard/shared/components/form/field-shell.tsx
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import type { FieldShellProps } from "./types"
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
FieldLabel,
|
||||||
|
FieldError,
|
||||||
|
FieldDescription,
|
||||||
|
} from "@/shared/components/ui/field"
|
||||||
|
|
||||||
|
export function FieldShell({
|
||||||
|
label,
|
||||||
|
error,
|
||||||
|
description,
|
||||||
|
required,
|
||||||
|
children,
|
||||||
|
}: FieldShellProps) {
|
||||||
|
return (
|
||||||
|
<Field data-invalid={!!error || undefined}>
|
||||||
|
{label && (
|
||||||
|
<FieldLabel>
|
||||||
|
{label}
|
||||||
|
{required && <span className="text-destructive">*</span>}
|
||||||
|
</FieldLabel>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
{description && <FieldDescription>{description}</FieldDescription>}
|
||||||
|
{error && <FieldError>{error}</FieldError>}
|
||||||
|
</Field>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,278 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import type { FieldValues, FieldPath } from "react-hook-form"
|
||||||
|
import {
|
||||||
|
useFormContext,
|
||||||
|
useController,
|
||||||
|
} from "react-hook-form"
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||||
|
import { FieldShell } from "../field-shell"
|
||||||
|
import {
|
||||||
|
AsyncSelectField,
|
||||||
|
AsyncMultiSelectField,
|
||||||
|
type AsyncSelectFieldProps,
|
||||||
|
type AsyncMultiSelectFieldProps,
|
||||||
|
} from "../controls/async-select-field"
|
||||||
|
import { Field, FieldLabel, FieldError, FieldDescription } from "@/shared/components/ui/field"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/shared/components/ui/dialog"
|
||||||
|
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||||
|
import { PlusIcon } from "lucide-react"
|
||||||
|
|
||||||
|
// ── Inline create types ──
|
||||||
|
|
||||||
|
export type InlineCreateFormProps = {
|
||||||
|
onSuccess: (newItem?: { value: string; label: string }) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InlineCreateConfig = {
|
||||||
|
createForm: (props: InlineCreateFormProps) => React.ReactNode
|
||||||
|
createLabel?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractItems(response: unknown): any[] {
|
||||||
|
if (Array.isArray(response)) return response
|
||||||
|
if (response && typeof response === "object") {
|
||||||
|
const obj = response as Record<string, unknown>
|
||||||
|
if (obj.data && typeof obj.data === "object" && !Array.isArray(obj.data)) {
|
||||||
|
const nested = obj.data as Record<string, unknown>
|
||||||
|
if (Array.isArray(nested.data)) return nested.data
|
||||||
|
}
|
||||||
|
if (Array.isArray(obj.data)) return obj.data
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Props forwarded to the underlying control ──
|
||||||
|
|
||||||
|
type AsyncSelectControlProps = Omit<
|
||||||
|
AsyncSelectFieldProps,
|
||||||
|
keyof import("../types").BaseFieldControlProps<any> | "options" | "loading" | "onInputValueChange"
|
||||||
|
>
|
||||||
|
|
||||||
|
type AsyncMultiSelectControlProps = Omit<
|
||||||
|
AsyncMultiSelectFieldProps,
|
||||||
|
keyof import("../types").BaseFieldControlProps<any> | "options" | "loading" | "onInputValueChange"
|
||||||
|
>
|
||||||
|
|
||||||
|
// ── Shared base props ──
|
||||||
|
|
||||||
|
type BaseRhfAsyncFieldProps = {
|
||||||
|
label?: string
|
||||||
|
description?: string
|
||||||
|
required?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
queryKey: string[]
|
||||||
|
staleTime?: number
|
||||||
|
} & Partial<InlineCreateConfig>
|
||||||
|
|
||||||
|
type WithLoadOptions = {
|
||||||
|
loadOptions: () => Promise<any[]>
|
||||||
|
listFn?: never
|
||||||
|
mapOption?: never
|
||||||
|
}
|
||||||
|
|
||||||
|
type WithCrudClient<TItem> = {
|
||||||
|
loadOptions?: never
|
||||||
|
listFn: () => Promise<any>
|
||||||
|
mapOption?: (item: TItem) => any
|
||||||
|
}
|
||||||
|
|
||||||
|
type DataSource<TItem = unknown> = WithLoadOptions | WithCrudClient<TItem>
|
||||||
|
|
||||||
|
function useAsyncOptions<TItem>(
|
||||||
|
queryKey: string[],
|
||||||
|
source: DataSource<TItem>,
|
||||||
|
staleTime?: number,
|
||||||
|
) {
|
||||||
|
return useQuery<any[]>({
|
||||||
|
queryKey,
|
||||||
|
queryFn: async () => {
|
||||||
|
if ("loadOptions" in source && source.loadOptions) {
|
||||||
|
return source.loadOptions()
|
||||||
|
}
|
||||||
|
if ("listFn" in source && source.listFn) {
|
||||||
|
const response = await source.listFn()
|
||||||
|
const items = extractItems(response)
|
||||||
|
return source.mapOption ? items.map(source.mapOption) : items
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
},
|
||||||
|
staleTime: staleTime ?? 5 * 60 * 1000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Single-select wrapper ──
|
||||||
|
|
||||||
|
type RhfAsyncSelectFieldProps<
|
||||||
|
TValues extends FieldValues,
|
||||||
|
TName extends FieldPath<TValues>,
|
||||||
|
TItem = unknown,
|
||||||
|
> = {
|
||||||
|
name: TName
|
||||||
|
multiple?: false
|
||||||
|
} & BaseRhfAsyncFieldProps & DataSource<TItem> & AsyncSelectControlProps
|
||||||
|
|
||||||
|
export function RhfAsyncSelectField<
|
||||||
|
TValues extends FieldValues,
|
||||||
|
TName extends FieldPath<TValues>,
|
||||||
|
TItem = unknown,
|
||||||
|
>(props: RhfAsyncSelectFieldProps<TValues, TName, TItem>) {
|
||||||
|
const {
|
||||||
|
name, label, description, required, disabled,
|
||||||
|
queryKey, staleTime,
|
||||||
|
loadOptions, listFn, mapOption,
|
||||||
|
createForm, createLabel,
|
||||||
|
...controlProps
|
||||||
|
} = props
|
||||||
|
|
||||||
|
const source = { loadOptions, listFn, mapOption } as DataSource<TItem>
|
||||||
|
|
||||||
|
const { control } = useFormContext<TValues>()
|
||||||
|
const { field, fieldState: { error } } = useController({ name, control, disabled })
|
||||||
|
const { data: options = [], isLoading } = useAsyncOptions(queryKey, source, staleTime)
|
||||||
|
const [inputValue, setInputValue] = useState("")
|
||||||
|
const [isCreateOpen, setIsCreateOpen] = useState(false)
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const getLabel = controlProps.getOptionLabel ?? ((o: any) => o.label)
|
||||||
|
|
||||||
|
const filtered = inputValue
|
||||||
|
? options.filter((o) => String(getLabel(o)).toLowerCase().includes(inputValue.toLowerCase()))
|
||||||
|
: options
|
||||||
|
|
||||||
|
const handleCreateSuccess = (newItem?: { value: string; label: string }) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey })
|
||||||
|
if (newItem) {
|
||||||
|
field.onChange(newItem)
|
||||||
|
}
|
||||||
|
setIsCreateOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// When a createForm is provided, render a custom label row with the "+" button
|
||||||
|
if (createForm) {
|
||||||
|
return (
|
||||||
|
<Field data-invalid={!!error || undefined}>
|
||||||
|
{label && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<FieldLabel>
|
||||||
|
{label}
|
||||||
|
{required && <span className="text-destructive ms-0.5">*</span>}
|
||||||
|
</FieldLabel>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-5 w-5"
|
||||||
|
onClick={() => setIsCreateOpen(true)}
|
||||||
|
title={`Add new ${createLabel ?? label}`}
|
||||||
|
>
|
||||||
|
<PlusIcon className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<AsyncSelectField
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
onBlur={field.onBlur}
|
||||||
|
disabled={field.disabled}
|
||||||
|
invalid={!!error}
|
||||||
|
options={filtered}
|
||||||
|
loading={isLoading}
|
||||||
|
onInputValueChange={setInputValue}
|
||||||
|
{...controlProps}
|
||||||
|
/>
|
||||||
|
{description && <FieldDescription>{description}</FieldDescription>}
|
||||||
|
{error && <FieldError>{error.message}</FieldError>}
|
||||||
|
|
||||||
|
<Dialog open={isCreateOpen} onOpenChange={(v) => { if (!v) setIsCreateOpen(false) }}>
|
||||||
|
<DialogContent className="min-w-xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-2xl font-bold">
|
||||||
|
Add {createLabel ?? label}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<ScrollArea className="max-h-[80vh] px-4">
|
||||||
|
{createForm({ onSuccess: handleCreateSuccess })}
|
||||||
|
</ScrollArea>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</Field>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FieldShell label={label} error={error?.message} description={description} required={required}>
|
||||||
|
<AsyncSelectField
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
onBlur={field.onBlur}
|
||||||
|
disabled={field.disabled}
|
||||||
|
invalid={!!error}
|
||||||
|
options={filtered}
|
||||||
|
loading={isLoading}
|
||||||
|
onInputValueChange={setInputValue}
|
||||||
|
{...controlProps}
|
||||||
|
/>
|
||||||
|
</FieldShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Multi-select wrapper ──
|
||||||
|
|
||||||
|
type RhfAsyncMultiSelectFieldProps<
|
||||||
|
TValues extends FieldValues,
|
||||||
|
TName extends FieldPath<TValues>,
|
||||||
|
TItem = unknown,
|
||||||
|
> = {
|
||||||
|
name: TName
|
||||||
|
multiple: true
|
||||||
|
} & BaseRhfAsyncFieldProps & DataSource<TItem> & AsyncMultiSelectControlProps
|
||||||
|
|
||||||
|
export function RhfAsyncMultiSelectField<
|
||||||
|
TValues extends FieldValues,
|
||||||
|
TName extends FieldPath<TValues>,
|
||||||
|
TItem = unknown,
|
||||||
|
>(props: RhfAsyncMultiSelectFieldProps<TValues, TName, TItem>) {
|
||||||
|
const {
|
||||||
|
name, label, description, required, disabled,
|
||||||
|
queryKey, staleTime,
|
||||||
|
loadOptions, listFn, mapOption,
|
||||||
|
...controlProps
|
||||||
|
} = props
|
||||||
|
|
||||||
|
const source = { loadOptions, listFn, mapOption } as DataSource<TItem>
|
||||||
|
|
||||||
|
const { control } = useFormContext<TValues>()
|
||||||
|
const { field, fieldState: { error } } = useController({ name, control, disabled })
|
||||||
|
const { data: options = [], isLoading } = useAsyncOptions(queryKey, source, staleTime)
|
||||||
|
const [inputValue, setInputValue] = useState("")
|
||||||
|
|
||||||
|
const getLabel = controlProps.getOptionLabel ?? ((o: any) => o.label)
|
||||||
|
|
||||||
|
const filtered = inputValue
|
||||||
|
? options.filter((o) => String(getLabel(o)).toLowerCase().includes(inputValue.toLowerCase()))
|
||||||
|
: options
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FieldShell label={label} error={error?.message} description={description} required={required}>
|
||||||
|
<AsyncMultiSelectField
|
||||||
|
value={field.value ?? []}
|
||||||
|
onChange={field.onChange}
|
||||||
|
onBlur={field.onBlur}
|
||||||
|
disabled={field.disabled}
|
||||||
|
invalid={!!error}
|
||||||
|
options={filtered}
|
||||||
|
loading={isLoading}
|
||||||
|
onInputValueChange={setInputValue}
|
||||||
|
{...controlProps}
|
||||||
|
/>
|
||||||
|
</FieldShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,62 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import type { FieldValues, FieldPath } from "react-hook-form"
|
||||||
|
import { useFormContext, useController } from "react-hook-form"
|
||||||
|
import { CheckboxField, type CheckboxFieldProps } from "../controls/checkbox-field"
|
||||||
|
import type { BaseFieldControlProps } from "../types"
|
||||||
|
import { FieldError } from "@/shared/components/ui/field"
|
||||||
|
|
||||||
|
type RhfCheckboxFieldProps<
|
||||||
|
TValues extends FieldValues,
|
||||||
|
TName extends FieldPath<TValues>,
|
||||||
|
> = {
|
||||||
|
name: TName
|
||||||
|
label?: string
|
||||||
|
description?: string
|
||||||
|
required?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
} & Omit<CheckboxFieldProps, keyof BaseFieldControlProps<boolean>>
|
||||||
|
|
||||||
|
export function RhfCheckboxField<
|
||||||
|
TValues extends FieldValues,
|
||||||
|
TName extends FieldPath<TValues>,
|
||||||
|
>({
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
required,
|
||||||
|
disabled,
|
||||||
|
...controlProps
|
||||||
|
}: RhfCheckboxFieldProps<TValues, TName>) {
|
||||||
|
const { control } = useFormContext<TValues>()
|
||||||
|
const {
|
||||||
|
field,
|
||||||
|
fieldState: { error },
|
||||||
|
} = useController({ name, control, disabled })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-4 rounded-lg border p-4">
|
||||||
|
<div className="flex-1 space-y-0.5">
|
||||||
|
{label && (
|
||||||
|
<p className="text-sm font-medium leading-none">
|
||||||
|
{label}
|
||||||
|
{required && <span className="text-destructive"> *</span>}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{description && (
|
||||||
|
<p className="text-sm text-muted-foreground">{description}</p>
|
||||||
|
)}
|
||||||
|
{error && <FieldError>{error.message}</FieldError>}
|
||||||
|
</div>
|
||||||
|
<CheckboxField
|
||||||
|
{...(controlProps as any)}
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
onBlur={field.onBlur}
|
||||||
|
name={field.name}
|
||||||
|
disabled={field.disabled}
|
||||||
|
invalid={!!error}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import type { FieldValues, FieldPath } from "react-hook-form"
|
||||||
|
import { RhfField } from "../rhf-field"
|
||||||
|
import { FileInputField, type FileInputFieldProps } from "../controls/file-input-field"
|
||||||
|
import type { BaseFieldControlProps } from "../types"
|
||||||
|
|
||||||
|
type RhfFileFieldProps<
|
||||||
|
TValues extends FieldValues,
|
||||||
|
TName extends FieldPath<TValues>,
|
||||||
|
> = {
|
||||||
|
name: TName
|
||||||
|
label?: string
|
||||||
|
description?: string
|
||||||
|
required?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
} & Omit<FileInputFieldProps, keyof BaseFieldControlProps<File | null>>
|
||||||
|
|
||||||
|
export function RhfFileField<
|
||||||
|
TValues extends FieldValues,
|
||||||
|
TName extends FieldPath<TValues>,
|
||||||
|
>(props: RhfFileFieldProps<TValues, TName>) {
|
||||||
|
return <RhfField {...props} component={FileInputField} />
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user