commit 13b56d4960fcdb1f3f8f9b4319a6b17c2e9eaa94 Author: Mohammad Khyata Date: Fri Mar 27 16:20:46 2026 +0300 init diff --git a/.github/skills/crud-page/SKILL.md b/.github/skills/crud-page/SKILL.md new file mode 100644 index 0000000..db65136 --- /dev/null +++ b/.github/skills/crud-page/SKILL.md @@ -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/.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/"` for INDEX, `"/api//{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//.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//-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)/
//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 `` +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/.ts` | `job-cards.ts` | +| Client class | `Client` | `JobCardsClient` | +| Routes const | `_ROUTES` | `JOB_CARD_ROUTES` | +| Schema file | `modules//.schema.ts` | `job-card.schema.ts` | +| Form file | `modules//-form.tsx` | `job-card-form.tsx` | +| Page file | `app/(authenticated)/
//page.tsx` | `sales/job-cards/page.tsx` | +| Zod schema | `FormSchema` | `jobCardFormSchema` | +| Form values type | `FormValues` | `JobCardFormValues` | +| Form component | `Form` | `JobCardForm` | +| Page component | `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 `` (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 } + + 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 { Client } from '@garage/api' +import { _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 diff --git a/.github/skills/crud-page/references/api-client.md b/.github/skills/crud-page/references/api-client.md new file mode 100644 index 0000000..ebf921c --- /dev/null +++ b/.github/skills/crud-page/references/api-client.md @@ -0,0 +1,140 @@ +# API Client Reference + +## File Location + +`packages/api/src/clients/.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 _ROUTES = { + INDEX: "/api/", + BY_ID: "/api//{id}", + // Add extra routes as needed: + // EXPORT: "/api//export", + // IMPORT: "/api//import", + // RELATED: "/api/", +} as const satisfies Record + +export class Client extends CrudClient< + typeof _ROUTES.INDEX, + typeof _ROUTES.BY_ID +> { + constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) { + super(baseUrl, defaultOptions, _ROUTES.INDEX, _ROUTES.BY_ID) + } + + // Add domain-specific methods: + // async listCategories() { + // return this.get(_ROUTES.RELATED) + // } + // + // async export() { + // return this.get(_ROUTES.EXPORT) + // } +} +``` + +### CrudClient Gives You For Free + +| Method | HTTP | Description | +|---|---|---| +| `list(query?)` | `GET /api/` | Paginated list with query params | +| `show(id)` | `GET /api//{id}` | Single item fetch | +| `create(payload)` | `POST /api/` | Create new item | +| `update(id, payload)` | `PUT /api//{id}` | Update existing item | +| `destroy(id)` | `DELETE /api//{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 _ROUTES = { + INDEX: "/api/", + BY_ID: "/api//{id}", +} as const satisfies Record + +export class Client extends CrudClient< + typeof _ROUTES.INDEX, + typeof _ROUTES.BY_ID +> { + constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) { + super(baseUrl, defaultOptions, _ROUTES.INDEX, _ROUTES.BY_ID) + } +} +``` + +## Registration + +After creating the client, register it in two files: + +### 1. `packages/api/src/clients/index.ts` + +```ts +export { Client, _ROUTES } from "./" +``` + +### 2. `packages/api/src/api.ts` + +Add the import at the top: +```ts +import { Client } from "./clients/" +``` + +Add to the `createApi()` return object: +```ts +export function createApi(options?: ApiClientOptions) { + return { + // ...existing clients... + : new 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 + +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) { + return this.post(CUSTOMER_ROUTES.IMPORT, payload) + } +} +``` diff --git a/.github/skills/crud-page/references/form.md b/.github/skills/crud-page/references/form.md new file mode 100644 index 0000000..c6df8ba --- /dev/null +++ b/.github/skills/crud-page/references/form.md @@ -0,0 +1,234 @@ +# Form Reference + +## File Location + +`apps/dashboard/modules//-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 { + FormSchema, + type FormValues, +} from "./.schema" +import { _ROUTES } from "@garage/api" + +// ── Constants ── + +// Static select options (if needed): +// const STATUS_OPTIONS = [ +// { value: "active", label: "Active" }, +// { value: "inactive", label: "Inactive" }, +// ] + +// ── Props ── + +export type FormProps = { + resourceId?: string | null + initialData?: unknown + onSuccess?: () => void +} + +// ── Default values ── + +const DEFAULT_VALUES: 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): 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: 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 Form({ resourceId, initialData, onSuccess }: FormProps) { + const api = useAuthApi() + + const { form, isEditing } = useResourceForm<FormValues, any>({ + schema: FormSchema, + defaultValues: DEFAULT_VALUES, + resourceId, + initialData, + initialize: (id) => api..show(id), + queryKey: [_ROUTES.BY_ID, resourceId], + mapToFormValues: mapToFormValues, + }) + + const { mutate, error, isPending } = useFormMutation(form, { + mutationFn: (values: FormValues) => { + const payload = mapFormToPayload(values) + const promise = isEditing && resourceId + ? api..update(resourceId, payload) + : api..create(payload) + toast.promise(promise, { + loading: isEditing ? "Updating ..." : "Creating ...", + success: isEditing ? " updated successfully" : " created successfully", + error: isEditing ? "Failed to update " : "Failed to create ", + }) + return promise + }, + onSuccess: () => { + form.reset() + onSuccess?.() + }, + }) + + return ( + mutate(values)}> + {error && ( + + + + {isEditing ? "Failed to update " : "Failed to create "} + + {error.message} + + )} + + + {/* Text fields */} + {/* */} + + {/* Grid layout for side-by-side fields */} + {/*
+ + +
*/} + + {/* Static select */} + {/* */} + + {/* Async select (fetches options from API) */} + {/* api.categories.list()} + mapOption={mapLookupOption} + {...STORE_OBJECT} + /> */} + + {/* Textarea */} + {/* */} + + {/* Checkbox */} + {/* */} + + +
+
+ ) +} +``` + +## 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 `` to wrap all fields +- Use `
` for side-by-side fields +- Place the submit button at the bottom inside `` +- Show error alert above fields when mutation fails diff --git a/.github/skills/crud-page/references/page.md b/.github/skills/crud-page/references/page.md new file mode 100644 index 0000000..66a95ff --- /dev/null +++ b/.github/skills/crud-page/references/page.md @@ -0,0 +1,225 @@ +# Page Reference + +## File Location + +`apps/dashboard/app/(authenticated)/
//page.tsx` + +Where `
` is the navigation section (e.g. `sales`, `inventory`, `hr`) and `` 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 { Form } from '@/modules//-form' +import { _ROUTES } from '@garage/api' +import type { Client } from '@garage/api' + +export default function Page() { + return ( + Client> + pageTitle="" + title="" + routeKey={_ROUTES.INDEX} + getClient={(api) => api.} + columns={({ actionsColumn }) => [ + { + accessorKey: "", + header: ({ column }) => , + }, + { + accessorKey: "", + header: ({ column }) => , + }, + // Add more columns as needed... + actionsColumn(), + ]} + renderForm={({ resourceId, initialData, onSuccess }) => ( + <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 }) => , +}, +``` + +### Custom cell renderer +```tsx +{ + accessorKey: "status", + header: ({ column }) => , + cell: ({ row }) => ( + + {row.original.status} + + ), +}, +``` + +### Column with icon +```tsx +{ + accessorKey: "name", + header: ({ column }) => , + cell: ({ row }) => ( +
+ + {row.original.name} +
+ ), +}, +``` + +### Non-sortable column +```tsx +{ + accessorKey: "notes", + header: () => Notes, + 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 ( + + pageTitle='Customers' + title="Customer" + routeKey={CUSTOMER_ROUTES.INDEX} + getClient={(api) => api.customers} + columns={({ actionsColumn }) => [ + { + accessorKey: "first_name", + header: ({ column }) => , + 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 ( +
+ {isCompany + ? + : } + {name} +
+ ) + }, + }, + { + accessorKey: "email", + header: ({ column }) => , + }, + { + accessorKey: "phone", + header: ({ column }) => , + }, + actionsColumn(), + ]} + renderForm={({ resourceId, initialData, 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 { _ROUTES } from '@garage/api' +import type { ColumnDef } from '@tanstack/react-table' + +const columns: ColumnDef[] = [ + { + accessorKey: "name", + header: ({ column }) => , + }, + // ... more columns +] + +export default function Page() { + const api = useAuthApi() + + const { data, isLoading, pagination, sorting, handleChange } = useDataTableQuery({ + queryKey: [_ROUTES.INDEX], + client: api., + }) + + const response = data as any + + return ( + }> + + + ) +} +``` diff --git a/.github/skills/crud-page/references/schema.md b/.github/skills/crud-page/references/schema.md new file mode 100644 index 0000000..5830ced --- /dev/null +++ b/.github/skills/crud-page/references/schema.md @@ -0,0 +1,143 @@ +# Schema Reference + +## File Location + +`apps/dashboard/modules//.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 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 FormValues = z.inferFormSchema> + +export { FormSchema, relationFieldSchema } +export type { 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 + +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 + +export { customerFormSchema, relationFieldSchema } +export type { CustomerFormValues, RelationField } +``` diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..96fab4f --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..e69de29 diff --git a/.vscode/mcp.json b/.vscode/mcp.json new file mode 100644 index 0000000..6716ff9 --- /dev/null +++ b/.vscode/mcp.json @@ -0,0 +1,11 @@ +{ + "servers": { + "shadcn": { + "command": "npx", + "args": [ + "shadcn@latest", + "mcp" + ] + } + } +} diff --git a/Garage Management System.pdf b/Garage Management System.pdf new file mode 100644 index 0000000..d91fe0a Binary files /dev/null and b/Garage Management System.pdf differ diff --git a/README.md b/README.md new file mode 100644 index 0000000..53a70b2 --- /dev/null +++ b/README.md @@ -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) diff --git a/apps/dashboard/.gitignore b/apps/dashboard/.gitignore new file mode 100644 index 0000000..8655a4c --- /dev/null +++ b/apps/dashboard/.gitignore @@ -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 diff --git a/apps/dashboard/.prettierignore b/apps/dashboard/.prettierignore new file mode 100644 index 0000000..461b008 --- /dev/null +++ b/apps/dashboard/.prettierignore @@ -0,0 +1,7 @@ +dist/ +node_modules/ +.next/ +.turbo/ +coverage/ +pnpm-lock.yaml +.pnpm-store/ \ No newline at end of file diff --git a/apps/dashboard/.prettierrc b/apps/dashboard/.prettierrc new file mode 100644 index 0000000..a8a2054 --- /dev/null +++ b/apps/dashboard/.prettierrc @@ -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"] +} diff --git a/apps/dashboard/.vscode/mcp.json b/apps/dashboard/.vscode/mcp.json new file mode 100644 index 0000000..6716ff9 --- /dev/null +++ b/apps/dashboard/.vscode/mcp.json @@ -0,0 +1,11 @@ +{ + "servers": { + "shadcn": { + "command": "npx", + "args": [ + "shadcn@latest", + "mcp" + ] + } + } +} diff --git a/apps/dashboard/README.md b/apps/dashboard/README.md new file mode 100644 index 0000000..1e66186 --- /dev/null +++ b/apps/dashboard/README.md @@ -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"; +``` diff --git a/apps/dashboard/app/(auth)/login/page.tsx b/apps/dashboard/app/(auth)/login/page.tsx new file mode 100644 index 0000000..1817e2f --- /dev/null +++ b/apps/dashboard/app/(auth)/login/page.tsx @@ -0,0 +1,12 @@ +import { LoginForm } from "@/modules/auth/login-form"; + + +export default function Page() { + return ( +
+
+ +
+
+ ) +} diff --git a/apps/dashboard/app/(authenticated)/items/parts/page.tsx b/apps/dashboard/app/(authenticated)/items/parts/page.tsx new file mode 100644 index 0000000..c3bdaf4 --- /dev/null +++ b/apps/dashboard/app/(authenticated)/items/parts/page.tsx @@ -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 ( + + pageTitle="Parts" + title="Part" + routeKey={PARTS_ROUTES.INDEX} + getClient={(api) => api.parts} + columns={({ actionsColumn }) => [ + { + accessorKey: "title", + header: ({ column }) => , + cell: ({ row }) => { + const r = row.original as any + return ( +
+ {r.title || "—"} + {r.sku && ( + {r.sku} + )} +
+ ) + }, + }, + { + accessorKey: "part_number", + header: ({ column }) => , + cell: ({ row }) => (row.original as any).part_number || "—", + }, + { + accessorKey: "manufactured_by", + header: ({ column }) => , + cell: ({ row }) => (row.original as any).manufactured_by || "—", + }, + { + accessorKey: "selling_price", + header: ({ column }) => , + cell: ({ row }) => { + const val = (row.original as any).selling_price + return val != null ? `$${Number(val).toFixed(2)}` : "—" + }, + }, + { + accessorKey: "purchase_price", + header: ({ column }) => , + cell: ({ row }) => { + const val = (row.original as any).purchase_price + return val != null ? `$${Number(val).toFixed(2)}` : "—" + }, + }, + { + accessorKey: "is_active", + header: ({ column }) => , + cell: ({ row }) => { + const active = (row.original as any).is_active + return ( + + {active ? "Active" : "Inactive"} + + ) + }, + }, + { + accessorKey: "created_at", + header: ({ column }) => , + cell: ({ row }) => { + const val = (row.original as any).created_at + return val ? new Date(val).toLocaleDateString() : "—" + }, + }, + actionsColumn(), + ]} + renderForm={({ resourceId, initialData, onSuccess }) => ( + + )} + /> + ) +} diff --git a/apps/dashboard/app/(authenticated)/items/service-group/page.tsx b/apps/dashboard/app/(authenticated)/items/service-group/page.tsx new file mode 100644 index 0000000..1a5bdf4 --- /dev/null +++ b/apps/dashboard/app/(authenticated)/items/service-group/page.tsx @@ -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 ( + + pageTitle="Service Groups" + title="Service Group" + routeKey={SERVICE_GROUP_ROUTES.INDEX} + getClient={(api) => api.serviceGroups} + columns={({ actionsColumn }) => [ + { + accessorKey: "name", + header: ({ column }) => , + cell: ({ row }) => { + const r = row.original as any + return ( +
+ {r.service_name || r.name || "—"} + {r.code && ( + {r.code} + )} +
+ ) + }, + }, + { + accessorKey: "selling_price", + header: ({ column }) => , + cell: ({ row }) => { + const val = (row.original as any).selling_price + return val != null ? `$${Number(val).toFixed(2)}` : "—" + }, + }, + { + accessorKey: "is_active", + header: ({ column }) => , + cell: ({ row }) => { + const active = (row.original as any).is_active + return ( + + {active ? "Active" : "Inactive"} + + ) + }, + }, + { + accessorKey: "created_at", + header: ({ column }) => , + cell: ({ row }) => { + const val = (row.original as any).created_at + return val ? new Date(val).toLocaleDateString() : "—" + }, + }, + actionsColumn(), + ]} + renderForm={({ resourceId, initialData, onSuccess }) => ( + + )} + /> + ) +} diff --git a/apps/dashboard/app/(authenticated)/items/services/page.tsx b/apps/dashboard/app/(authenticated)/items/services/page.tsx new file mode 100644 index 0000000..0ccc839 --- /dev/null +++ b/apps/dashboard/app/(authenticated)/items/services/page.tsx @@ -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 ( + + pageTitle="Services" + title="Service" + routeKey={SERVICE_ROUTES.INDEX} + getClient={(api) => api.services} + columns={({ actionsColumn }) => [ + { + accessorKey: "labor_name", + header: ({ column }) => , + cell: ({ row }) => { + const r = row.original as any + return ( +
+ {r.labor_name || r.name || "—"} + {r.service_code && ( + {r.service_code} + )} +
+ ) + }, + }, + { + accessorKey: "description", + header: ({ column }) => , + cell: ({ row }) => { + const val = (row.original as any).description + return val + ? {val} + : "—" + }, + }, + { + accessorKey: "selling_price", + header: ({ column }) => , + cell: ({ row }) => { + const val = (row.original as any).selling_price + return val != null ? `$${Number(val).toFixed(2)}` : "—" + }, + }, + { + accessorKey: "created_at", + header: ({ column }) => , + cell: ({ row }) => { + const val = (row.original as any).created_at + return val ? new Date(val).toLocaleDateString() : "—" + }, + }, + actionsColumn(), + ]} + renderForm={({ resourceId, initialData, onSuccess }) => ( + + )} + /> + ) +} diff --git a/apps/dashboard/app/(authenticated)/layout.tsx b/apps/dashboard/app/(authenticated)/layout.tsx new file mode 100644 index 0000000..bce734b --- /dev/null +++ b/apps/dashboard/app/(authenticated)/layout.tsx @@ -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: , + }, + { + title: "Job Cards", + href: "/sales/workorder/list", + icon: , + }, + { + title: "Customer & Vehicles", + href: "/customer-vehicles", + icon: , + }, + { + title: "Reports", + href: "/reports", + icon: , + }, + ], + }, + { + label: "Management", + items: [ + { + title: "Calendars", + href: "/calendars", + icon: , + items: [ + { title: "Work Schedule", href: "/calendar/work-schedule/list", icon: }, + { title: "Appointments", href: "/calendar/appointment/list", icon: }, + ], + }, + { + title: "Sales", + href: "/sales", + icon: , + items: [ + { title: "Customers", href: "/sales/customers", icon: }, + { title: "Vehicles", href: "/sales/vehicles", icon: }, + { title: "Inspections", href: "/sales/inspections", icon: }, + { title: "Estimates", href: "/sales/estimate", icon: }, + { title: "Job Cards", href: "/sales/workorder/list", icon: }, + { title: "Invoices", href: "/sales/invoice", icon: }, + { title: "Payments Received", href: "/sales/payment-received", icon: }, + { title: "Credit Notes", href: "/sales/credit-notes", icon: }, + ], + }, + { + title: "Purchases", + href: "/purchases", + icon: , + items: [ + { title: "Vendors", href: "/purchase/vendor", icon: }, + { title: "Expenses", href: "/purchase/expense", icon: }, + { title: "Purchase Orders", href: "/purchase/purchase-order", icon: }, + { title: "Bills", href: "/purchase/bill", icon: }, + { title: "Payments Made", href: "/purchase/payments-made", icon: }, + { title: "Vendor Credits", href: "/purchase/vendor-credit", icon: }, + ], + }, + { + title: "CRM", + href: "/crm", + icon: , + items: [ + { title: "Leads", href: "/crm/leads/list", icon: }, + { title: "Calls", href: "/crm/calls-follow-up/list", icon: }, + { title: "Tasks", href: "/crm/tasks/list", icon: }, + ], + }, + { + title: "Marketing", + href: "/marketing", + icon: , + items: [ + { title: "Service Reminders", href: "/marketing/service-reminder/list", icon: }, + { title: "Rating & Reviews", href: "/marketing/rating-review", icon: }, + { title: "Google Business Reviews", href: "/marketing/google-rating-review", icon: }, + ], + }, + { + title: "Accountants", + href: "/accountants", + icon: , + items: [ + { title: "Manual Journals", href: "/accountants/manual-journal", icon: }, + { title: "Chart of Accounts", href: "/accountants/chart-of-account", icon: }, + ], + }, + { + title: "Employees", + href: "/productivity", + icon: , + items: [ + { title: "Employees", href: "/productivity/employees", icon: }, + { title: "Time Clocks", href: "/productivity/time-clocks", icon: }, + { title: "Time Sheets", href: "/productivity/timesheet", icon: }, + { title: "Payroll", href: "/productivity/payroll", icon: }, + { title: "Payments Made", href: "/productivity/employee-payments-made", icon: }, + { title: "Shop Calendars", href: "/productivity/shop-calendars", icon: }, + { title: "Shop Timing", href: "/productivity/shop-timings", icon: }, + { title: "Holidays", href: "/productivity/holidays", icon: }, + ], + }, + { + title: "Items", + href: "/items", + icon: , + items: [ + { title: "Services", href: "/items/services", icon: }, + { title: "Parts", href: "/items/parts", icon: }, + { title: "Expense Item", href: "/items/expense-item", icon: }, + { title: "Service Group", href: "/items/service-group", icon: }, + { title: "Inspections", href: "/items/inspection", icon: }, + { title: "Inventory Adjustments", href: "/items/adjustment", icon: }, + ], + }, + { + title: "Settings", + href: "/setting", + icon: , + items: [ + { title: "Company", href: "/setting/company", icon: }, + { title: "Shop Types", href: "/setting/shop-type", icon: }, + { title: "Tax & Rates", href: "/setting/tax-rates", icon: }, + { title: "Configurations", href: "/setting/configurations/preferences/sales", icon: }, + { title: "Templates", href: "/setting/templates", icon: }, + { title: "Integrations", href: "/setting/integrations/providers", icon: }, + { title: "Master", href: "/setting/master/body-type", icon: }, + ], + }, + ], + }, +] + +function Logo() { + return ( +
+ Logo +
+ ) +} + +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 ( + } user={userInfo}> + {children} + + ) +} + diff --git a/apps/dashboard/app/(authenticated)/page.tsx b/apps/dashboard/app/(authenticated)/page.tsx new file mode 100644 index 0000000..69396b0 --- /dev/null +++ b/apps/dashboard/app/(authenticated)/page.tsx @@ -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 ( + } > +
+

Dashboard

+

+ Welcome to your dashboard. Select an item from the sidebar to get started. +

+
+
+ ) +} diff --git a/apps/dashboard/app/(authenticated)/productivity/employees/page.tsx b/apps/dashboard/app/(authenticated)/productivity/employees/page.tsx new file mode 100644 index 0000000..2751d08 --- /dev/null +++ b/apps/dashboard/app/(authenticated)/productivity/employees/page.tsx @@ -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 ( + + pageTitle="Employees" + title="Employee" + routeKey={EMPLOYEE_ROUTES.INDEX} + getClient={(api) => api.employees} + columns={({ actionsColumn }) => [ + { + accessorKey: "first_name", + header: ({ column }) => , + cell: ({ row }) => { + const { first_name, last_name } = row.original + return `${first_name ?? ""} ${last_name ?? ""}`.trim() + }, + }, + { + accessorKey: "email", + header: ({ column }) => , + }, + { + accessorKey: "phone", + header: ({ column }) => , + }, + { + accessorKey: "position", + header: ({ column }) => , + }, + { + accessorKey: "department", + header: ({ column }) => , + cell: ({ row }) => (row.original as any).department?.name ?? "—", + }, + { + accessorKey: "status", + header: ({ column }) => , + cell: ({ row }) => { + const status = row.original.status + return ( + + {status} + + ) + }, + }, + actionsColumn(), + ]} + renderForm={({ resourceId, initialData, onSuccess }) => ( + + )} + /> + ) +} diff --git a/apps/dashboard/app/(authenticated)/productivity/shop-calendars/page.tsx b/apps/dashboard/app/(authenticated)/productivity/shop-calendars/page.tsx new file mode 100644 index 0000000..c9dcf13 --- /dev/null +++ b/apps/dashboard/app/(authenticated)/productivity/shop-calendars/page.tsx @@ -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 ( + + pageTitle="Shop Calendars" + title="Shop Calendar" + routeKey={SHOP_CALENDAR_ROUTES.INDEX} + getClient={(api) => api.shopCalendars} + columns={({ actionsColumn }) => [ + { + accessorKey: "title", + header: ({ column }) => , + }, + { + accessorKey: "is_default", + header: ({ column }) => , + cell: ({ row }) => + (row.original as any).is_default ? ( + + ) : null, + }, + { + accessorKey: "shop_calender_days", + header: () => Days, + enableSorting: false, + cell: ({ row }) => { + const days = (row.original as any).shop_calender_days + return days?.length ?? 0 + }, + }, + actionsColumn({ onEdit: undefined }), + ]} + renderForm={({ resourceId, initialData, onSuccess }) => ( + + )} + /> + ) +} diff --git a/apps/dashboard/app/(authenticated)/productivity/shop-timings/page.tsx b/apps/dashboard/app/(authenticated)/productivity/shop-timings/page.tsx new file mode 100644 index 0000000..46b7150 --- /dev/null +++ b/apps/dashboard/app/(authenticated)/productivity/shop-timings/page.tsx @@ -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 ( + + pageTitle="Shop Timings" + title="Shop Timing" + routeKey={SHOP_TIMING_ROUTES.INDEX} + getClient={(api) => api.shopTimings} + columns={({ actionsColumn }) => [ + { + accessorKey: "title", + header: ({ column }) => , + }, + { + accessorKey: "in_time", + header: ({ column }) => , + }, + { + accessorKey: "out_time", + header: ({ column }) => , + }, + { + accessorKey: "full_day_hours", + header: ({ column }) => , + }, + { + accessorKey: "half_day_hours", + header: ({ column }) => , + }, + { + accessorKey: "is_default", + header: ({ column }) => , + cell: ({ row }) => + row.original.is_default ? ( + + ) : null, + }, + actionsColumn(), + ]} + renderForm={({ resourceId, initialData, onSuccess }) => ( + + )} + /> + ) +} diff --git a/apps/dashboard/app/(authenticated)/sales/customers/page.tsx b/apps/dashboard/app/(authenticated)/sales/customers/page.tsx new file mode 100644 index 0000000..0be2ff7 --- /dev/null +++ b/apps/dashboard/app/(authenticated)/sales/customers/page.tsx @@ -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 ( + + pageTitle='Customers' + title="Customer" + routeKey={CUSTOMER_ROUTES.INDEX} + getClient={(api) => api.customers} + columns={({ actionsColumn }) => [ + + { + accessorKey: "first_name", + header: ({ column }) => , + 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 (
+ {isCompany ? : } + {name} +
+ ) + }, + }, + { + accessorKey: "email", + header: ({ column }) => , + }, + { + accessorKey: "phone", + header: ({ column }) => , + }, + actionsColumn(), + ]} + renderForm={({ resourceId, initialData, onSuccess }) => ( + + )} + /> + ) +} \ No newline at end of file diff --git a/apps/dashboard/app/(authenticated)/sales/inspections/page.tsx b/apps/dashboard/app/(authenticated)/sales/inspections/page.tsx new file mode 100644 index 0000000..58417c2 --- /dev/null +++ b/apps/dashboard/app/(authenticated)/sales/inspections/page.tsx @@ -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 ( + + pageTitle="Inspections" + title="Inspection" + routeKey={INSPECTION_ROUTES.INDEX} + getClient={(api) => api.inspections} + columns={({ actionsColumn }) => [ + { + accessorKey: "title", + header: ({ column }) => , + }, + { + accessorKey: "customer", + header: ({ column }) => , + cell: ({ row }) => { + const c = (row.original as any).customer + return c ? `${c.first_name ?? ""} ${c.last_name ?? ""}`.trim() : "—" + }, + }, + { + accessorKey: "vehicle", + header: ({ column }) => , + cell: ({ row }) => { + const v = (row.original as any).vehicle + return v ? `${v.make ?? ""} ${v.model ?? ""}`.trim() : "—" + }, + }, + { + accessorKey: "inspection_category", + header: ({ column }) => , + cell: ({ row }) => (row.original as any).inspection_category?.name ?? "—", + }, + { + accessorKey: "status", + header: ({ column }) => , + cell: ({ row }) => { + const status = (row.original as any).status + return ( + + {status ?? "—"} + + ) + }, + }, + actionsColumn(), + ]} + renderForm={({ resourceId, initialData, onSuccess }) => ( + + )} + /> + ) +} diff --git a/apps/dashboard/app/(authenticated)/sales/vehicles/page.tsx b/apps/dashboard/app/(authenticated)/sales/vehicles/page.tsx new file mode 100644 index 0000000..f310d47 --- /dev/null +++ b/apps/dashboard/app/(authenticated)/sales/vehicles/page.tsx @@ -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 ( + + pageTitle="Vehicles" + title="Vehicle" + routeKey={VEHICLE_ROUTES.INDEX} + getClient={(api) => api.vehicles} + + + columns={({ actionsColumn }) => [ + { + accessorKey: "name", + header: ({ column }) => , + cell: ({ row }) => { + const r = row.original as any + const make = r.make ?? "" + const model = r.model ?? "" + const display = r.name || `${make} ${model}`.trim() || "—" + return ( +
+ +
+ {display} + {r.sub_model && ( + {r.sub_model} + )} +
+
+ ) + }, + }, + { + accessorKey: "year", + header: ({ column }) => , + cell: ({ row }) => (row.original as any).year ?? "—", + }, + { + accessorKey: "license_plate", + header: ({ column }) => , + cell: ({ row }) => { + const val = (row.original as any).license_plate + return val + ? {val} + : "—" + }, + }, + { + accessorKey: "vin_number", + header: ({ column }) => , + cell: ({ row }) => { + const val = (row.original as any).vin_number + return val + ? {val} + : "—" + }, + }, + { + accessorKey: "engine_size", + header: ({ column }) => , + cell: ({ row }) => (row.original as any).engine_size ?? "—", + }, + { + accessorKey: "mileage", + header: ({ column }) => , + cell: ({ row }) => { + const val = (row.original as any).mileage + return val != null ? `${Number(val).toLocaleString()} mi` : "—" + }, + }, + { + accessorKey: "created_at", + header: ({ column }) => , + cell: ({ row }) => { + const val = (row.original as any).created_at + return val ? new Date(val).toLocaleDateString() : "—" + }, + }, + actionsColumn(), + ]} + renderForm={({ resourceId, initialData, onSuccess }) => ( + + )} + /> + ) +} \ No newline at end of file diff --git a/apps/dashboard/app/(authenticated)/settings/shop-type/page.tsx b/apps/dashboard/app/(authenticated)/settings/shop-type/page.tsx new file mode 100644 index 0000000..dca0f82 --- /dev/null +++ b/apps/dashboard/app/(authenticated)/settings/shop-type/page.tsx @@ -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 ( + + pageTitle="Shop Types" + title="Shop Type" + routeKey={SHOP_TYPE_ROUTES.INDEX} + getClient={(api) => api.shopTypes} + columns={({ actionsColumn }) => [ + { + accessorKey: "title", + header: ({ column }) => , + }, + { + accessorKey: "shop_type", + header: ({ column }) => , + }, + { + accessorKey: "note", + header: ({ column }) => , + cell: ({ row }) => ( + + {(row.original as any).note ?? "—"} + + ), + }, + { + accessorKey: "is_default", + header: ({ column }) => , + cell: ({ row }) => + (row.original as any).is_default + ? + : , + }, + actionsColumn(), + ]} + renderForm={({ resourceId, initialData, onSuccess }) => ( + + )} + /> + ) +} diff --git a/apps/dashboard/app/favicon.ico b/apps/dashboard/app/favicon.ico new file mode 100644 index 0000000..718d6fe Binary files /dev/null and b/apps/dashboard/app/favicon.ico differ diff --git a/apps/dashboard/app/globals.css b/apps/dashboard/app/globals.css new file mode 100644 index 0000000..d4cf055 --- /dev/null +++ b/apps/dashboard/app/globals.css @@ -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; + } +} \ No newline at end of file diff --git a/apps/dashboard/app/layout.tsx b/apps/dashboard/app/layout.tsx new file mode 100644 index 0000000..326b949 --- /dev/null +++ b/apps/dashboard/app/layout.tsx @@ -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 ( + + + + + {children} + + + + + + + ) +} diff --git a/apps/dashboard/base/components/auth-store-initializer.tsx b/apps/dashboard/base/components/auth-store-initializer.tsx new file mode 100644 index 0000000..7018b2c --- /dev/null +++ b/apps/dashboard/base/components/auth-store-initializer.tsx @@ -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 +} diff --git a/apps/dashboard/base/components/layout/dashboard/app-sidebar.tsx b/apps/dashboard/base/components/layout/dashboard/app-sidebar.tsx new file mode 100644 index 0000000..7e89e1e --- /dev/null +++ b/apps/dashboard/base/components/layout/dashboard/app-sidebar.tsx @@ -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 & { + navGroups: NavGroup[] + logo?: React.ReactNode +} + +export function AppSidebar({ navGroups, logo, ...props }: AppSidebarProps) { + const { state, isMobile } = useSidebar() + const isCollapsed = state === "collapsed" && !isMobile + + return ( + + {logo && ( + + {logo} + + )} + + {navGroups.map((group, groupIndex) => ( + + {group.label && ( + + {group.label} + + )} + + {group.items.map((item) => + item.items && item.items.length > 0 ? ( + + ) : ( + + ) + )} + + + ))} + + + + ) +} + +function SimpleNavItem({ item, isCollapsed }: { item: NavItem; isCollapsed: boolean }) { + const pathname = usePathname() + const isActive = item.isActive ?? pathname === item.href + + return ( + + + + {item.icon} + { + !isCollapsed && + {item.title} + } + + + + ) +} + +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 ( + + + + + + {item.icon} + + { + !isCollapsed && + {item.title} + } + + + + + {item.title} + + + {item.items?.map((sub) => { + const isSubActive = sub.isActive ?? pathname === sub.href + return ( + + + {sub.icon ? ( + svg]:size-4", isSubActive ? "text-primary" : "text-muted-foreground/70")}> + {sub.icon} + + ) : ( + + )} + {sub.title} + + + ) + })} + + + + ) + } + + // Expanded sidebar → collapsible/accordion sub-menu + return ( + + + + + + {item.icon} + + + + {item.title} + + + + + + + {item.items?.map((sub) => { + const isSubActive = sub.isActive ?? pathname === sub.href + return ( + + + + {sub.icon ? ( + svg]:size-4", isSubActive ? "text-primary" : "text-muted-foreground/70 group-hover/menu-sub-item:text-primary")}> + {sub.icon} + + ) : ( + + )} + {sub.title} + + + + ) + })} + + + + + ) +} diff --git a/apps/dashboard/base/components/layout/dashboard/dashboard-header.tsx b/apps/dashboard/base/components/layout/dashboard/dashboard-header.tsx new file mode 100644 index 0000000..17ab906 --- /dev/null +++ b/apps/dashboard/base/components/layout/dashboard/dashboard-header.tsx @@ -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 ( +
+ {/* Sidebar toggle — mobile: hamburger, desktop: collapse */} + + + + {/* Left side — default actions */} +
+ {/* User dropdown */} + {/* {user && ( */} + + + + + + + {/* User info header */} + +
+ + {user?.avatar && } + + {user?.initials ?? user?.name.charAt(0).toUpperCase()} + + +
+ {user?.name} + {user?.email && ( + {user?.email} + )} + {user?.role && ( + {user?.role} + )} +
+
+
+ + + + + + + + Profile + + + + + + + + + Logout + +
+
+ {/* )} */} + + + {/* Search trigger */} + + + {/* Mobile search icon */} + + + {/* Theme toggle */} + + + {/* Notifications */} + +
+ + {/* Search command dialog */} + + + + + No results found. + + Dashboard + Job Cards + Customers + + + + + + {/* Right side — custom actions */} + {actions && ( +
{actions}
+ )} +
+ ) +} diff --git a/apps/dashboard/base/components/layout/dashboard/dashboard-layout.tsx b/apps/dashboard/base/components/layout/dashboard/dashboard-layout.tsx new file mode 100644 index 0000000..4152104 --- /dev/null +++ b/apps/dashboard/base/components/layout/dashboard/dashboard-layout.tsx @@ -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 ( + + + + + {children} + + + + ) +} diff --git a/apps/dashboard/base/components/layout/dashboard/dashboard-page.tsx b/apps/dashboard/base/components/layout/dashboard/dashboard-page.tsx new file mode 100644 index 0000000..08325ae --- /dev/null +++ b/apps/dashboard/base/components/layout/dashboard/dashboard-page.tsx @@ -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 ( +
+
+ {header} +
+
+ { + title && +

{title}

+ } + {children} +
+
+ ) +} diff --git a/apps/dashboard/base/components/layout/dashboard/index.ts b/apps/dashboard/base/components/layout/dashboard/index.ts new file mode 100644 index 0000000..21d6b95 --- /dev/null +++ b/apps/dashboard/base/components/layout/dashboard/index.ts @@ -0,0 +1,3 @@ +export { DashboardLayout } from "./dashboard-layout" +export { AppSidebar } from "./app-sidebar" +export { DashboardHeader } from "./dashboard-header" diff --git a/apps/dashboard/base/types/navigation.ts b/apps/dashboard/base/types/navigation.ts new file mode 100644 index 0000000..93fd3c0 --- /dev/null +++ b/apps/dashboard/base/types/navigation.ts @@ -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 +} diff --git a/apps/dashboard/components.json b/apps/dashboard/components.json new file mode 100644 index 0000000..900b31e --- /dev/null +++ b/apps/dashboard/components.json @@ -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": {} +} diff --git a/apps/dashboard/cypress.config.ts b/apps/dashboard/cypress.config.ts new file mode 100644 index 0000000..830bcbc --- /dev/null +++ b/apps/dashboard/cypress.config.ts @@ -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, + }, +}) diff --git a/apps/dashboard/cypress/e2e/customers/customer-form-integration.cy.ts b/apps/dashboard/cypress/e2e/customers/customer-form-integration.cy.ts new file mode 100644 index 0000000..5141b99 --- /dev/null +++ b/apps/dashboard/cypress/e2e/customers/customer-form-integration.cy.ts @@ -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") + }) + }) + }) +}) diff --git a/apps/dashboard/cypress/e2e/customers/customer-form.cy.ts b/apps/dashboard/cypress/e2e/customers/customer-form.cy.ts new file mode 100644 index 0000000..7779ca5 --- /dev/null +++ b/apps/dashboard/cypress/e2e/customers/customer-form.cy.ts @@ -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", "") + }) + }) +}) diff --git a/apps/dashboard/cypress/fixtures/customers.json b/apps/dashboard/cypress/fixtures/customers.json new file mode 100644 index 0000000..b232d94 --- /dev/null +++ b/apps/dashboard/cypress/fixtures/customers.json @@ -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" + } + } +} diff --git a/apps/dashboard/cypress/support/commands.ts b/apps/dashboard/cypress/support/commands.ts new file mode 100644 index 0000000..c1dd11e --- /dev/null +++ b/apps/dashboard/cypress/support/commands.ts @@ -0,0 +1,33 @@ +/// + +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 + } + } +} + +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 {} diff --git a/apps/dashboard/cypress/support/e2e.ts b/apps/dashboard/cypress/support/e2e.ts new file mode 100644 index 0000000..b7cb303 --- /dev/null +++ b/apps/dashboard/cypress/support/e2e.ts @@ -0,0 +1 @@ +import "./commands" diff --git a/apps/dashboard/cypress/tsconfig.json b/apps/dashboard/cypress/tsconfig.json new file mode 100644 index 0000000..3b29d25 --- /dev/null +++ b/apps/dashboard/cypress/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["ES2017", "DOM"], + "types": ["cypress"], + "moduleResolution": "bundler", + "module": "ESNext", + "strict": true, + "baseUrl": ".", + "paths": { + "@/*": ["../../*"] + } + }, + "include": ["**/*.ts", "../support/**/*.ts"] +} diff --git a/apps/dashboard/eslint.config.mjs b/apps/dashboard/eslint.config.mjs new file mode 100644 index 0000000..05e726d --- /dev/null +++ b/apps/dashboard/eslint.config.mjs @@ -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; diff --git a/apps/dashboard/modules/auth/auth.actions.ts b/apps/dashboard/modules/auth/auth.actions.ts new file mode 100644 index 0000000..c842aa4 --- /dev/null +++ b/apps/dashboard/modules/auth/auth.actions.ts @@ -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 } +} diff --git a/apps/dashboard/modules/auth/login-form.schema.ts b/apps/dashboard/modules/auth/login-form.schema.ts new file mode 100644 index 0000000..dbe2b80 --- /dev/null +++ b/apps/dashboard/modules/auth/login-form.schema.ts @@ -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 + +export { loginFormSchema } +export type { LoginFormValues } \ No newline at end of file diff --git a/apps/dashboard/modules/auth/login-form.tsx b/apps/dashboard/modules/auth/login-form.tsx new file mode 100644 index 0000000..cd620da --- /dev/null +++ b/apps/dashboard/modules/auth/login-form.tsx @@ -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({ + 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[1]) + router.push("/") + } + }, + }) + + + async function onSubmit(values: LoginFormValues) { + setLastLoginEmail(values.email) + mutate(values) + } + + return ( +
+ + + Logo + Login to your account + + Enter your email below to login to your account + + + + {error ? ( + + + Login failed + {error.message} + + ) : null} + +
+ + + Email + + + + + + + + + + + + {lastLoginEmail ? ( + + Last email used: {lastLoginEmail} + + ) : null} + {/* + Don't have an account? Sign up + */} + + +
+
+
+
+ ) +} diff --git a/apps/dashboard/modules/customers/customer-form.tsx b/apps/dashboard/modules/customers/customer-form.tsx new file mode 100644 index 0000000..7072d75 --- /dev/null +++ b/apps/dashboard/modules/customers/customer-form.tsx @@ -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({ + 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 ( + mutate(values)}> + {error && ( + + + {isEditing ? "Failed to update customer" : "Failed to create customer"} + {error.message} + + )} + + + + {/* Basic Info */} +
+ + api.customers.listCustomerTypes()} + mapOption={mapLookupOption} + {...STORE_OBJECT} + /> +
+ + + {/* Name */} +
+ + +
+ + + + {/* Contact */} +
+ + +
+ + + + {/* Relations */} +
+ api.referralSources.list()} + mapOption={mapLookupOption} + {...STORE_OBJECT} + /> + api.paymentTerms.list()} + mapOption={mapLookupOption} + {...STORE_OBJECT} + /> +
+ + {/* Address */} + + + +
+ api.geo.countries()} + mapOption={mapLookupOption} + {...STORE_OBJECT} + /> + api.geo.states()} + mapOption={mapLookupOption} + {...STORE_OBJECT} + /> +
+ +
+ + +
+ + +
+
+ ) +} \ No newline at end of file diff --git a/apps/dashboard/modules/customers/customer.schema.ts b/apps/dashboard/modules/customers/customer.schema.ts new file mode 100644 index 0000000..b17b582 --- /dev/null +++ b/apps/dashboard/modules/customers/customer.schema.ts @@ -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 + +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 + +export { customerFormSchema, relationFieldSchema } +export type { CustomerFormValues, RelationField } \ No newline at end of file diff --git a/apps/dashboard/modules/employees/employee-form.tsx b/apps/dashboard/modules/employees/employee-form.tsx new file mode 100644 index 0000000..404a7de --- /dev/null +++ b/apps/dashboard/modules/employees/employee-form.tsx @@ -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({ + 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 ( + mutate(values)}> + {error && ( + + + + {isEditing ? "Failed to update employee" : "Failed to create employee"} + + {error.message} + + )} + + +
+ + +
+ +
+ + +
+ +
+ + api.departments.list()} + mapOption={mapLookupOption} + {...STORE_OBJECT} + /> +
+ +
+ + +
+ +
+ api.shopCalendars.list()} + mapOption={mapLookupOption} + {...STORE_OBJECT} + /> + api.shopTimings.list()} + mapOption={mapLookupOption} + {...STORE_OBJECT} + /> +
+ + + +
+ + +
+ + +
+
+ ) +} diff --git a/apps/dashboard/modules/employees/employee.schema.ts b/apps/dashboard/modules/employees/employee.schema.ts new file mode 100644 index 0000000..6a0f374 --- /dev/null +++ b/apps/dashboard/modules/employees/employee.schema.ts @@ -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 + +export { employeeFormSchema, relationFieldSchema, STATUS_OPTIONS, TYPE_OPTIONS } +export type { EmployeeFormValues } diff --git a/apps/dashboard/modules/inspections/inline-forms/inspection-category-inline-form.tsx b/apps/dashboard/modules/inspections/inline-forms/inspection-category-inline-form.tsx new file mode 100644 index 0000000..215f0bc --- /dev/null +++ b/apps/dashboard/modules/inspections/inline-forms/inspection-category-inline-form.tsx @@ -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 + +export function InspectionCategoryInlineForm({ onSuccess }: InlineCreateFormProps) { + const api = useAuthApi() + + const form = useForm({ + 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 ( + + + + + + + ) +} diff --git a/apps/dashboard/modules/inspections/inspection-form.tsx b/apps/dashboard/modules/inspections/inspection-form.tsx new file mode 100644 index 0000000..ce5f091 --- /dev/null +++ b/apps/dashboard/modules/inspections/inspection-form.tsx @@ -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({ + 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 ( + mutate(values)}> + {error && ( + + + + {isEditing ? "Failed to update inspection" : "Failed to create inspection"} + + {error.message} + + )} + + + + +
+ api.customers.list()} + mapOption={mapCustomerOption} + {...STORE_OBJECT} + /> + api.vehicles.list()} + mapOption={mapVehicleOption} + {...STORE_OBJECT} + /> +
+ +
+ api.departments.list()} + mapOption={mapLookupOption} + createForm={(props) => } + createLabel="Department" + {...STORE_OBJECT} + /> + api.inspections.listCategories()} + mapOption={mapLookupOption} + createForm={(props) => } + createLabel="Inspection Category" + {...STORE_OBJECT} + /> +
+ + api.employees.list()} + mapOption={mapEmployeeOption} + {...STORE_OBJECT} + /> + + + +
+ + +
+ + +
+
+ ) +} diff --git a/apps/dashboard/modules/inspections/inspection.schema.ts b/apps/dashboard/modules/inspections/inspection.schema.ts new file mode 100644 index 0000000..05cdc71 --- /dev/null +++ b/apps/dashboard/modules/inspections/inspection.schema.ts @@ -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 + +export { inspectionFormSchema, relationFieldSchema } +export type { InspectionFormValues } diff --git a/apps/dashboard/modules/parts/part-form.tsx b/apps/dashboard/modules/parts/part-form.tsx new file mode 100644 index 0000000..ede4b09 --- /dev/null +++ b/apps/dashboard/modules/parts/part-form.tsx @@ -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({ + 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 ( + mutate(values)}> + {error && ( + + + + {isEditing ? "Failed to update part" : "Failed to create part"} + + {error.message} + + )} + + +
+ + +
+ + {!isEditing && ( + <> +
+ api.shopTypes.list()} + mapOption={mapLookupOption} + createForm={(props) => } + createLabel="Shop Type" + {...STORE_OBJECT} + /> + api.inventory.listCategories()} + mapOption={mapLookupOption} + createForm={(props) => } + createLabel="Category" + {...STORE_OBJECT} + /> +
+ +
+ api.inventory.listUnitTypes()} + mapOption={mapLookupOption} + createForm={(props) => } + createLabel="Unit Type" + {...STORE_OBJECT} + /> + api.departments.list()} + mapOption={(item: any) => ({ value: String(item.id), label: item.name ?? String(item.id) })} + createForm={(props) => } + createLabel="Department" + {...STORE_OBJECT} + /> +
+ + )} + +
+ + {!isEditing && ( + + )} +
+ + +
+ +
+ +
+
+ ) +} diff --git a/apps/dashboard/modules/parts/part.schema.ts b/apps/dashboard/modules/parts/part.schema.ts new file mode 100644 index 0000000..c07cc37 --- /dev/null +++ b/apps/dashboard/modules/parts/part.schema.ts @@ -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 diff --git a/apps/dashboard/modules/service-groups/service-group-form.tsx b/apps/dashboard/modules/service-groups/service-group-form.tsx new file mode 100644 index 0000000..f43fe0f --- /dev/null +++ b/apps/dashboard/modules/service-groups/service-group-form.tsx @@ -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({ + 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 ( + mutate(values)}> + {error && ( + + + + {isEditing ? "Failed to update service group" : "Failed to create service group"} + + {error.message} + + )} + + +
+ + +
+ + {!isEditing && ( + <> +
+ api.shopTypes.list()} + mapOption={mapLookupOption} + createForm={(props) => } + createLabel="Shop Type" + {...STORE_OBJECT} + /> + api.inventory.listCategories()} + mapOption={mapLookupOption} + createForm={(props) => } + createLabel="Category" + {...STORE_OBJECT} + /> +
+ +
+ api.inventory.listUnitTypes()} + mapOption={mapLookupOption} + createForm={(props) => } + createLabel="Unit Type" + {...STORE_OBJECT} + /> + api.departments.list()} + mapOption={(item: any) => ({ value: String(item.id), label: item.name ?? String(item.id) })} + createForm={(props) => } + createLabel="Department" + {...STORE_OBJECT} + /> +
+ + )} + +
+ + +
+ + + +
+ + + + +
+ + +
+ +
+ +
+
+ ) +} diff --git a/apps/dashboard/modules/service-groups/service-group.schema.ts b/apps/dashboard/modules/service-groups/service-group.schema.ts new file mode 100644 index 0000000..01cc51c --- /dev/null +++ b/apps/dashboard/modules/service-groups/service-group.schema.ts @@ -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 diff --git a/apps/dashboard/modules/services/department-assignment-types.ts b/apps/dashboard/modules/services/department-assignment-types.ts new file mode 100644 index 0000000..44a1af1 --- /dev/null +++ b/apps/dashboard/modules/services/department-assignment-types.ts @@ -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"] diff --git a/apps/dashboard/modules/services/inline-forms/category-inline-form.tsx b/apps/dashboard/modules/services/inline-forms/category-inline-form.tsx new file mode 100644 index 0000000..82a67b6 --- /dev/null +++ b/apps/dashboard/modules/services/inline-forms/category-inline-form.tsx @@ -0,0 +1,2 @@ +// Renamed to inventory-category-inline-form.tsx +export { InventoryCategoryInlineForm as CategoryInlineForm } from "./inventory-category-inline-form" diff --git a/apps/dashboard/modules/services/inline-forms/department-inline-form.tsx b/apps/dashboard/modules/services/inline-forms/department-inline-form.tsx new file mode 100644 index 0000000..2bfff41 --- /dev/null +++ b/apps/dashboard/modules/services/inline-forms/department-inline-form.tsx @@ -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 + +export function DepartmentInlineForm({ onSuccess }: InlineCreateFormProps) { + const api = useAuthApi() + + const form = useForm({ + 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 ( + + + + + + + + ) +} diff --git a/apps/dashboard/modules/services/inline-forms/inventory-category-inline-form.tsx b/apps/dashboard/modules/services/inline-forms/inventory-category-inline-form.tsx new file mode 100644 index 0000000..5bcf2c8 --- /dev/null +++ b/apps/dashboard/modules/services/inline-forms/inventory-category-inline-form.tsx @@ -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 + +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({ + 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 ( + + + + api.shopTypes.list()} + mapOption={mapLookupOption} + createForm={(props) => } + createLabel="Shop Type" + {...STORE_OBJECT} + /> + + + + ) +} diff --git a/apps/dashboard/modules/services/inline-forms/unit-type-inline-form.tsx b/apps/dashboard/modules/services/inline-forms/unit-type-inline-form.tsx new file mode 100644 index 0000000..70c7bf0 --- /dev/null +++ b/apps/dashboard/modules/services/inline-forms/unit-type-inline-form.tsx @@ -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 + +export function UnitTypeInlineForm({ onSuccess }: InlineCreateFormProps) { + const api = useAuthApi() + + const form = useForm({ + 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 ( + + + + + + + ) +} diff --git a/apps/dashboard/modules/services/service-form.tsx b/apps/dashboard/modules/services/service-form.tsx new file mode 100644 index 0000000..044e761 --- /dev/null +++ b/apps/dashboard/modules/services/service-form.tsx @@ -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({ + 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 ( + mutate(values)}> + {error && ( + + + + {isEditing ? "Failed to update service" : "Failed to create service"} + + {error.message} + + )} + + +
+ + +
+ + {!isEditing && ( + <> +
+ api.shopTypes.list()} + mapOption={mapLookupOption} + createForm={(props) => } + createLabel="Shop Type" + {...STORE_OBJECT} + /> + api.inventory.listCategories()} + mapOption={mapLookupOption} + createForm={(props) => } + createLabel="Category" + {...STORE_OBJECT} + /> +
+ +
+ api.inventory.listUnitTypes()} + mapOption={mapLookupOption} + createForm={(props) => } + createLabel="Unit Type" + {...STORE_OBJECT} + /> + api.departments.list()} + mapOption={(item: any) => ({ value: String(item.id), label: item.name ?? String(item.id) })} + createForm={(props) => } + createLabel="Department" + {...STORE_OBJECT} + /> +
+ + + + )} + +
+ +
+ + + + +
+
+ ) +} diff --git a/apps/dashboard/modules/services/service.schema.ts b/apps/dashboard/modules/services/service.schema.ts new file mode 100644 index 0000000..116b02f --- /dev/null +++ b/apps/dashboard/modules/services/service.schema.ts @@ -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 diff --git a/apps/dashboard/modules/settings/shop-type/shop-type-form.tsx b/apps/dashboard/modules/settings/shop-type/shop-type-form.tsx new file mode 100644 index 0000000..e107164 --- /dev/null +++ b/apps/dashboard/modules/settings/shop-type/shop-type-form.tsx @@ -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({ + 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 ( + mutate(values)}> + {error && ( + + + + {isEditing ? "Failed to update shop type" : "Failed to create shop type"} + + {error.message} + + )} + + + + + + +
+ + +
+ + +
+
+ ) +} diff --git a/apps/dashboard/modules/settings/shop-type/shop-type.schema.ts b/apps/dashboard/modules/settings/shop-type/shop-type.schema.ts new file mode 100644 index 0000000..19c5690 --- /dev/null +++ b/apps/dashboard/modules/settings/shop-type/shop-type.schema.ts @@ -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 +} diff --git a/apps/dashboard/modules/shop-calendars/shop-calendar-form.tsx b/apps/dashboard/modules/shop-calendars/shop-calendar-form.tsx new file mode 100644 index 0000000..7b3634a --- /dev/null +++ b/apps/dashboard/modules/shop-calendars/shop-calendar-form.tsx @@ -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({ + 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 ( + mutate(values)}> + {error && ( + + + Failed to create shop calendar + {error.message} + + )} + + + + + + + + + ) +} diff --git a/apps/dashboard/modules/shop-calendars/shop-calendar.schema.ts b/apps/dashboard/modules/shop-calendars/shop-calendar.schema.ts new file mode 100644 index 0000000..c9bbf14 --- /dev/null +++ b/apps/dashboard/modules/shop-calendars/shop-calendar.schema.ts @@ -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 + +export { shopCalendarFormSchema } +export type { ShopCalendarFormValues } diff --git a/apps/dashboard/modules/shop-timings/shop-timing-form.tsx b/apps/dashboard/modules/shop-timings/shop-timing-form.tsx new file mode 100644 index 0000000..879b2e4 --- /dev/null +++ b/apps/dashboard/modules/shop-timings/shop-timing-form.tsx @@ -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({ + 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 ( + mutate(values)}> + {error && ( + + + + {isEditing ? "Failed to update shop timing" : "Failed to create shop timing"} + + {error.message} + + )} + + + + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + + + +
+
+ ) +} diff --git a/apps/dashboard/modules/shop-timings/shop-timing.schema.ts b/apps/dashboard/modules/shop-timings/shop-timing.schema.ts new file mode 100644 index 0000000..7d66abe --- /dev/null +++ b/apps/dashboard/modules/shop-timings/shop-timing.schema.ts @@ -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 + +export { shopTimingFormSchema } +export type { ShopTimingFormValues } diff --git a/apps/dashboard/modules/vehicles/inline-forms/body-type-inline-form.tsx b/apps/dashboard/modules/vehicles/inline-forms/body-type-inline-form.tsx new file mode 100644 index 0000000..4d0507c --- /dev/null +++ b/apps/dashboard/modules/vehicles/inline-forms/body-type-inline-form.tsx @@ -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 + +export function BodyTypeInlineForm({ onSuccess }: InlineCreateFormProps) { + const api = useAuthApi() + + const form = useForm({ + 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 ( + + + + + + + ) +} diff --git a/apps/dashboard/modules/vehicles/inline-forms/color-inline-form.tsx b/apps/dashboard/modules/vehicles/inline-forms/color-inline-form.tsx new file mode 100644 index 0000000..1e97e71 --- /dev/null +++ b/apps/dashboard/modules/vehicles/inline-forms/color-inline-form.tsx @@ -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 + +export function ColorInlineForm({ onSuccess }: InlineCreateFormProps) { + const api = useAuthApi() + + const form = useForm({ + 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 ( + + + + + + + ) +} diff --git a/apps/dashboard/modules/vehicles/inline-forms/fuel-type-inline-form.tsx b/apps/dashboard/modules/vehicles/inline-forms/fuel-type-inline-form.tsx new file mode 100644 index 0000000..0f3cc0c --- /dev/null +++ b/apps/dashboard/modules/vehicles/inline-forms/fuel-type-inline-form.tsx @@ -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 + +export function FuelTypeInlineForm({ onSuccess }: InlineCreateFormProps) { + const api = useAuthApi() + + const form = useForm({ + 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 ( + + + + + + + ) +} diff --git a/apps/dashboard/modules/vehicles/inline-forms/shop-type-inline-form.tsx b/apps/dashboard/modules/vehicles/inline-forms/shop-type-inline-form.tsx new file mode 100644 index 0000000..6cdbac4 --- /dev/null +++ b/apps/dashboard/modules/vehicles/inline-forms/shop-type-inline-form.tsx @@ -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({ + 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 ( + + + + + + + + + + + + ) +} + diff --git a/apps/dashboard/modules/vehicles/inline-forms/transmission-inline-form.tsx b/apps/dashboard/modules/vehicles/inline-forms/transmission-inline-form.tsx new file mode 100644 index 0000000..f21c98b --- /dev/null +++ b/apps/dashboard/modules/vehicles/inline-forms/transmission-inline-form.tsx @@ -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 + +export function TransmissionInlineForm({ onSuccess }: InlineCreateFormProps) { + const api = useAuthApi() + + const form = useForm({ + 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 ( + + + + + + + ) +} diff --git a/apps/dashboard/modules/vehicles/vehicle-form.tsx b/apps/dashboard/modules/vehicles/vehicle-form.tsx new file mode 100644 index 0000000..29c9a71 --- /dev/null +++ b/apps/dashboard/modules/vehicles/vehicle-form.tsx @@ -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({ + 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 ( + mutate(values)}> + {error && ( + + + + {isEditing ? "Failed to update vehicle" : "Failed to create vehicle"} + + {error.message} + + )} + + + {!isEditing && ( + <> + {/* Vehicle identity */} +
+ + + +
+ + + + {/* Associations */} +
+ api.shopTypes.list()} + mapOption={mapLookupOption} + createForm={(props) => } + createLabel="Shop Type" + {...STORE_OBJECT} + /> + api.vehicleAttributes.listBodyTypes()} + mapOption={mapLookupOption} + createForm={(props) => } + createLabel="Body Type" + {...STORE_OBJECT} + /> +
+ +
+ api.vehicleAttributes.listFuelTypes()} + mapOption={mapLookupOption} + createForm={(props) => } + createLabel="Fuel Type" + {...STORE_OBJECT} + /> + api.vehicleAttributes.listTransmissions()} + mapOption={mapLookupOption} + createForm={(props) => } + createLabel="Transmission" + {...STORE_OBJECT} + /> +
+ +
+ api.vehicleAttributes.listColors()} + mapOption={mapLookupOption} + createForm={(props) => } + createLabel="Color" + {...STORE_OBJECT} + /> + +
+ + {/* Technical specs */} +
+ + +
+ + + + )} + + {/* Editable in both create and update */} +
+ + +
+ + {!isEditing && ( + + )} + + +
+
+ ) +} diff --git a/apps/dashboard/modules/vehicles/vehicle.schema.ts b/apps/dashboard/modules/vehicles/vehicle.schema.ts new file mode 100644 index 0000000..343acda --- /dev/null +++ b/apps/dashboard/modules/vehicles/vehicle.schema.ts @@ -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 diff --git a/apps/dashboard/next.config.mjs b/apps/dashboard/next.config.mjs new file mode 100644 index 0000000..1d61478 --- /dev/null +++ b/apps/dashboard/next.config.mjs @@ -0,0 +1,4 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = {} + +export default nextConfig diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json new file mode 100644 index 0000000..23276c1 --- /dev/null +++ b/apps/dashboard/package.json @@ -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" + } +} diff --git a/apps/dashboard/postcss.config.mjs b/apps/dashboard/postcss.config.mjs new file mode 100644 index 0000000..f6c75ff --- /dev/null +++ b/apps/dashboard/postcss.config.mjs @@ -0,0 +1,8 @@ +/** @type {import('postcss-load-config').Config} */ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +} + +export default config diff --git a/apps/dashboard/public/.gitkeep b/apps/dashboard/public/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/dashboard/public/assets/logo.png b/apps/dashboard/public/assets/logo.png new file mode 100644 index 0000000..c20846b Binary files /dev/null and b/apps/dashboard/public/assets/logo.png differ diff --git a/apps/dashboard/shared/api.ts b/apps/dashboard/shared/api.ts new file mode 100644 index 0000000..fa0fdce --- /dev/null +++ b/apps/dashboard/shared/api.ts @@ -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; +} + + diff --git a/apps/dashboard/shared/components/.gitkeep b/apps/dashboard/shared/components/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/dashboard/shared/components/confirm-dialog.tsx b/apps/dashboard/shared/components/confirm-dialog.tsx new file mode 100644 index 0000000..87b04ea --- /dev/null +++ b/apps/dashboard/shared/components/confirm-dialog.tsx @@ -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 + _close: (confirmed: boolean) => void +} + +// ── Store ── + +const useConfirmStore = create((set, get) => ({ + open: false, + options: {}, + resolve: null, + _show: (options) => + new Promise((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 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 does not appear to be mounted. " + + "Make sure 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 ( + { + if (!v) _close(false) + }} + > + + + {isDestructive && ( + + + + )} + + {options.title ?? "Are you sure?"} + + {options.description && ( + + {options.description} + + )} + + + _close(false)}> + {options.cancelLabel ?? "Cancel"} + + _close(true)} + > + {options.confirmLabel ?? "Confirm"} + + + + + ) +} diff --git a/apps/dashboard/shared/components/form-dialog.tsx b/apps/dashboard/shared/components/form-dialog.tsx new file mode 100644 index 0000000..f62feb5 --- /dev/null +++ b/apps/dashboard/shared/components/form-dialog.tsx @@ -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)[dialogKey] as boolean, + resourceId: (params as Record)[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 ( + <> + + { if (!v) close() }}> + + + + {props.title} + + + + {props.children(resourceId)} + + + + + ) +} diff --git a/apps/dashboard/shared/components/form/controls/async-select-field.tsx b/apps/dashboard/shared/components/form/controls/async-select-field.tsx new file mode 100644 index 0000000..f2bf0ae --- /dev/null +++ b/apps/dashboard/shared/components/form/controls/async-select-field.tsx @@ -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 = BaseFieldControlProps & { + 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({ + value, + onChange, + onBlur, + disabled, + invalid, + options, + loading, + onInputValueChange, + placeholder = "Search...", + getOptionValue = defaultGetOptionValue, + getOptionLabel = defaultGetOptionLabel, + getOptionKey = defaultGetOptionKey, +}: AsyncSelectFieldProps) { + const anchorRef = useRef(null) + + return ( +
+ onChange(val)} + disabled={disabled} + onInputValueChange={(val, { reason }) => { + if (reason === "input-change") { + onInputValueChange?.(val) + } + }} + > + + + + {loading && ( +
+ +
+ )} + {!loading && + options.map((opt) => ( + + {getOptionLabel(opt)} + + ))} + {!loading && options.length === 0 && ( + No results found + )} +
+
+
+
+ ) +} + +// ── Multi-select ── + +export type AsyncMultiSelectFieldProps = BaseFieldControlProps & { + 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({ + value, + onChange, + onBlur, + disabled, + invalid, + options, + loading, + onInputValueChange, + placeholder = "Search...", + getOptionValue = defaultGetOptionValue, + getOptionLabel = defaultGetOptionLabel, + getOptionKey = defaultGetOptionKey, +}: AsyncMultiSelectFieldProps) { + const anchorRef = useRef(null) + + return ( +
+ onChange(val as any[])} + disabled={disabled} + onInputValueChange={(val, { reason }) => { + if (reason === "input-change") { + onInputValueChange?.(val) + } + }} + > + 0} + onBlur={onBlur} + aria-invalid={invalid || undefined} + /> + + + {loading && ( +
+ +
+ )} + {!loading && + options.map((opt) => ( + + {getOptionLabel(opt)} + + ))} + {!loading && options.length === 0 && ( + No results found + )} +
+
+
+
+ ) +} diff --git a/apps/dashboard/shared/components/form/controls/checkbox-field.tsx b/apps/dashboard/shared/components/form/controls/checkbox-field.tsx new file mode 100644 index 0000000..4756e3d --- /dev/null +++ b/apps/dashboard/shared/components/form/controls/checkbox-field.tsx @@ -0,0 +1,28 @@ +"use client" + +import type { BaseFieldControlProps } from "../types" +import { Switch } from "@/shared/components/ui/switch" + +export type CheckboxFieldProps = BaseFieldControlProps & { + label?: string +} + +export function CheckboxField({ + value, + onChange, + onBlur, + name, + disabled, + invalid, +}: CheckboxFieldProps) { + return ( + onChange(checked === true)} + onBlur={onBlur} + name={name} + disabled={disabled} + aria-invalid={invalid || undefined} + /> + ) +} diff --git a/apps/dashboard/shared/components/form/controls/file-input-field.tsx b/apps/dashboard/shared/components/form/controls/file-input-field.tsx new file mode 100644 index 0000000..e6f83db --- /dev/null +++ b/apps/dashboard/shared/components/form/controls/file-input-field.tsx @@ -0,0 +1,28 @@ +import type { BaseFieldControlProps } from "../types" +import { Input } from "@/shared/components/ui/input" + +export type FileInputFieldProps = BaseFieldControlProps & { + accept?: string +} + +export function FileInputField({ + // value intentionally unused — file inputs cannot be controlled + onBlur, + name, + disabled, + invalid, + accept, + onChange, +}: FileInputFieldProps) { + return ( + onChange(e.target.files?.[0] ?? null)} + /> + ) +} diff --git a/apps/dashboard/shared/components/form/controls/select-field.tsx b/apps/dashboard/shared/components/form/controls/select-field.tsx new file mode 100644 index 0000000..b8999ee --- /dev/null +++ b/apps/dashboard/shared/components/form/controls/select-field.tsx @@ -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 & { + placeholder?: string + options: SelectOption[] +} + +export function SelectField({ + value, + onChange, + onBlur, + name, + disabled, + invalid, + placeholder, + options, +}: SelectFieldProps) { + return ( + + ) +} diff --git a/apps/dashboard/shared/components/form/controls/text-input-field.tsx b/apps/dashboard/shared/components/form/controls/text-input-field.tsx new file mode 100644 index 0000000..371f2e2 --- /dev/null +++ b/apps/dashboard/shared/components/form/controls/text-input-field.tsx @@ -0,0 +1,31 @@ +import type { BaseFieldControlProps } from "../types" +import { Input } from "@/shared/components/ui/input" + +export type TextInputFieldProps = BaseFieldControlProps & { + placeholder?: string + type?: React.HTMLInputTypeAttribute +} + +export function TextInputField({ + value, + onChange, + onBlur, + name, + disabled, + invalid, + placeholder, + type = "text", +}: TextInputFieldProps) { + return ( + onChange(e.target.value)} + onBlur={onBlur} + name={name} + disabled={disabled} + aria-invalid={invalid || undefined} + placeholder={placeholder} + type={type} + /> + ) +} diff --git a/apps/dashboard/shared/components/form/controls/textarea-field.tsx b/apps/dashboard/shared/components/form/controls/textarea-field.tsx new file mode 100644 index 0000000..41a08b2 --- /dev/null +++ b/apps/dashboard/shared/components/form/controls/textarea-field.tsx @@ -0,0 +1,31 @@ +import type { BaseFieldControlProps } from "../types" +import { Textarea } from "@/shared/components/ui/textarea" + +export type TextareaFieldProps = BaseFieldControlProps & { + placeholder?: string + rows?: number +} + +export function TextareaField({ + value, + onChange, + onBlur, + name, + disabled, + invalid, + placeholder, + rows, +}: TextareaFieldProps) { + return ( +