updates
This commit is contained in:
parent
24a44481a0
commit
11db1e6941
304
.github/skills/crud-dialog/SKILL.md
vendored
Normal file
304
.github/skills/crud-dialog/SKILL.md
vendored
Normal file
@ -0,0 +1,304 @@
|
|||||||
|
---
|
||||||
|
name: crud-dialog
|
||||||
|
description: "Create CRUD dialogs for managing lookup/reference resources inline (inside a dialog) rather than a full page. Use when: adding a config/manage button next to a select field, building an inline CRUD for a simple lookup entity (e.g. insurance types, payment terms, categories), embedding list+create+edit+delete inside a modal. Uses the shared CrudDialog component and useCrudDialog hook."
|
||||||
|
---
|
||||||
|
|
||||||
|
# CRUD Dialog Generator
|
||||||
|
|
||||||
|
Create fully functional CRUD dialogs that embed list + create + edit + delete inside a modal dialog. This is the in-dialog counterpart of the page-level `ResourcePage` pattern. Ideal for managing simple lookup/reference entities without navigating away from the current form.
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
|
||||||
|
- User wants a config/settings button next to a select field to manage its options
|
||||||
|
- User wants to manage a simple lookup entity (e.g. insurance types, categories, tags) inline
|
||||||
|
- The resource is simple enough that a full page is overkill
|
||||||
|
- User says "CRUD in a dialog", "manage inside a modal", "config button", "inline CRUD"
|
||||||
|
|
||||||
|
## When NOT to Use
|
||||||
|
|
||||||
|
- The resource is complex with many fields, relations, or tabs → use **crud-page** skill instead
|
||||||
|
- The resource already has a dedicated page → link to it instead
|
||||||
|
- Only creation is needed (no listing/editing) → use `RhfAsyncSelectField` with `createForm` prop instead
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The CRUD Dialog system has two layers:
|
||||||
|
|
||||||
|
| Layer | File | Purpose |
|
||||||
|
|-------|------|---------|
|
||||||
|
| **Hook** | `shared/components/crud-dialog/use-crud-dialog.ts` | Local state for pagination, sorting, form open/close, delete confirmation. No URL pollution. |
|
||||||
|
| **Component** | `shared/components/crud-dialog/crud-dialog.tsx` | Renders trigger button → Dialog with DataTable (list view) ↔ Form (create/edit view). Uses `useCrudDialog` internally. |
|
||||||
|
|
||||||
|
### Key Differences from ResourcePage
|
||||||
|
|
||||||
|
| Aspect | ResourcePage | CrudDialog |
|
||||||
|
|--------|-------------|------------|
|
||||||
|
| Renders in | Full page | Dialog modal |
|
||||||
|
| Pagination state | URL query params (`nuqs`) | Local `useState` (no URL pollution) |
|
||||||
|
| Form rendering | `FormDialog` component | Inline view swap (list ↔ form) |
|
||||||
|
| Trigger | Page navigation | Button click (settings icon by default) |
|
||||||
|
| Use case | Primary resource management | Lookup/reference entity management |
|
||||||
|
|
||||||
|
## Procedure
|
||||||
|
|
||||||
|
### Step 1: Ensure API Client Exists
|
||||||
|
|
||||||
|
The resource needs a client with `list`, `create`, `update`, `destroy` methods. Check `packages/api/src/clients/`. If missing, create one following the **crud-page** skill's Step 2.
|
||||||
|
|
||||||
|
### Step 2: Create the Resource Form
|
||||||
|
|
||||||
|
Create a simple form component for the resource. This is a lightweight form (no `useResourceForm` needed for simple entities).
|
||||||
|
|
||||||
|
**File**: `apps/dashboard/modules/<parent-module>/<resource>-form.tsx`
|
||||||
|
|
||||||
|
**Template**:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { z } from "zod"
|
||||||
|
import { useForm } from "react-hook-form"
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import { Plus, Save } from "lucide-react"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { FieldGroup } from "@/shared/components/ui/field"
|
||||||
|
import { Rhform, RhfTextField } from "@/shared/components/form"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { useAuthApi } from "@/shared/useApi"
|
||||||
|
import { useEffect } from "react"
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
name: z.string().min(1, "Name is required"),
|
||||||
|
// Add more fields as needed
|
||||||
|
})
|
||||||
|
|
||||||
|
type FormValues = z.infer<typeof schema>
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
resourceId?: string | null
|
||||||
|
initialData?: any
|
||||||
|
onSuccess?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function <Resource>Form({ resourceId, initialData, onSuccess }: Props) {
|
||||||
|
const api = useAuthApi()
|
||||||
|
const isEditing = !!resourceId
|
||||||
|
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
defaultValues: { name: "" },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Pre-fill when editing
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialData) {
|
||||||
|
const d = initialData?.data ?? initialData
|
||||||
|
form.reset({ name: d.name ?? "" })
|
||||||
|
}
|
||||||
|
}, [initialData, form])
|
||||||
|
|
||||||
|
const handleSubmit = async (values: FormValues) => {
|
||||||
|
try {
|
||||||
|
const promise = isEditing
|
||||||
|
? api.<resource>.update(resourceId!, { title: values.name } as any)
|
||||||
|
: api.<resource>.create({ title: values.name } as any)
|
||||||
|
|
||||||
|
toast.promise(promise, {
|
||||||
|
loading: isEditing ? "Updating..." : "Creating...",
|
||||||
|
success: isEditing ? "Updated successfully" : "Created successfully",
|
||||||
|
error: isEditing ? "Failed to update" : "Failed to create",
|
||||||
|
})
|
||||||
|
|
||||||
|
await promise
|
||||||
|
form.reset()
|
||||||
|
onSuccess?.()
|
||||||
|
} catch {
|
||||||
|
// toast already shown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Rhform form={form} onSubmit={handleSubmit}>
|
||||||
|
<FieldGroup>
|
||||||
|
<RhfTextField name="name" label="Name" placeholder="e.g. My Item" required />
|
||||||
|
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||||
|
{isEditing ? <Save className="h-4 w-4" /> : <Plus className="h-4 w-4" />}
|
||||||
|
{form.formState.isSubmitting
|
||||||
|
? (isEditing ? "Updating..." : "Creating...")
|
||||||
|
: (isEditing ? "Update" : "Create")}
|
||||||
|
</Button>
|
||||||
|
</FieldGroup>
|
||||||
|
</Rhform>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Create the CrudDialog Instance
|
||||||
|
|
||||||
|
Wire the form into a `CrudDialog` component.
|
||||||
|
|
||||||
|
**File**: `apps/dashboard/modules/<parent-module>/<resource>-crud-dialog.tsx`
|
||||||
|
|
||||||
|
**Template**:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { CrudDialog } from "@/shared/components/crud-dialog"
|
||||||
|
import { ColumnHeader } from "@/shared/data-view/table-view"
|
||||||
|
import { useAuthApi } from "@/shared/useApi"
|
||||||
|
import { <RESOURCE>_ROUTES } from "@garage/api"
|
||||||
|
import { <Resource>Form } from "./<resource>-form"
|
||||||
|
|
||||||
|
export function <Resource>CrudDialog() {
|
||||||
|
const api = useAuthApi()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CrudDialog
|
||||||
|
title="<Resource Label>"
|
||||||
|
queryKey={[<RESOURCE>_ROUTES.INDEX]}
|
||||||
|
getClient={() => api.<resource>}
|
||||||
|
resourceLabel="<resource label>"
|
||||||
|
columns={() => [
|
||||||
|
{
|
||||||
|
accessorKey: "name",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Name" />,
|
||||||
|
},
|
||||||
|
// Add more columns as needed
|
||||||
|
]}
|
||||||
|
renderForm={({ resourceId, initialData, onSuccess }) => (
|
||||||
|
<<Resource>Form
|
||||||
|
resourceId={resourceId}
|
||||||
|
initialData={initialData}
|
||||||
|
onSuccess={onSuccess}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Wire into the Parent Form
|
||||||
|
|
||||||
|
Place the CrudDialog trigger next to the corresponding select field. The pattern is to add a custom label row with the config button:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { <Resource>CrudDialog } from "./<resource>-crud-dialog"
|
||||||
|
|
||||||
|
// Inside the form JSX:
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<span className="text-sm font-medium"><Field Label></span>
|
||||||
|
<<Resource>CrudDialog />
|
||||||
|
</div>
|
||||||
|
<RhfAsyncSelectField
|
||||||
|
name="<field_name>"
|
||||||
|
label=""
|
||||||
|
placeholder="Select..."
|
||||||
|
queryKey={[<RESOURCE>_ROUTES.INDEX]}
|
||||||
|
listFn={() => api.<resource>.list()}
|
||||||
|
mapOption={mapLookupOption}
|
||||||
|
{...STORE_OBJECT}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key**: Set `label=""` on the `RhfAsyncSelectField` since the label is rendered manually above with the config button.
|
||||||
|
|
||||||
|
## CrudDialog Props Reference
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type CrudDialogProps<TClient> = {
|
||||||
|
/** Dialog title shown in the header */
|
||||||
|
title: string
|
||||||
|
/** React Query cache key */
|
||||||
|
queryKey: string[]
|
||||||
|
/** Function returning the API client instance */
|
||||||
|
getClient: () => TClient
|
||||||
|
/** Human-readable name for toast messages (e.g. "insurance type") */
|
||||||
|
resourceLabel?: string
|
||||||
|
/** Table columns definition */
|
||||||
|
columns: (helpers: {
|
||||||
|
openEdit: (row: any) => void
|
||||||
|
handleDelete: (row: any) => Promise<void>
|
||||||
|
}) => ColumnDef<any>[]
|
||||||
|
/** Render create/edit form */
|
||||||
|
renderForm: (props: {
|
||||||
|
resourceId: string | null
|
||||||
|
initialData: any
|
||||||
|
onSuccess: () => void
|
||||||
|
}) => React.ReactNode
|
||||||
|
/** Optional custom trigger element (defaults to Settings2 icon) */
|
||||||
|
trigger?: React.ReactNode
|
||||||
|
/** CSS class for the default trigger button */
|
||||||
|
triggerClassName?: string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## useCrudDialog Hook API
|
||||||
|
|
||||||
|
For advanced use cases where you need more control, use the hook directly:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const crud = useCrudDialog({
|
||||||
|
queryKey: [ROUTES.INDEX],
|
||||||
|
getClient: () => api.resource,
|
||||||
|
resourceLabel: "item",
|
||||||
|
})
|
||||||
|
|
||||||
|
// Returns:
|
||||||
|
crud.items // Current page data
|
||||||
|
crud.isLoading // Query loading state
|
||||||
|
crud.pagination // { page, pageSize, pageCount, total }
|
||||||
|
crud.sorting // SortingState
|
||||||
|
crud.handleChange // DataViewChangeEvent handler
|
||||||
|
crud.isFormOpen // Whether form view is active
|
||||||
|
crud.editingId // ID being edited (null for create)
|
||||||
|
crud.editingItem // Full item data being edited
|
||||||
|
crud.openCreate() // Switch to create form
|
||||||
|
crud.openEdit(row) // Switch to edit form
|
||||||
|
crud.closeForm() // Back to list view
|
||||||
|
crud.handleDelete(row) // Confirm + delete
|
||||||
|
crud.handleFormSuccess() // Invalidate + close form
|
||||||
|
crud.invalidateQuery() // Refresh list data
|
||||||
|
```
|
||||||
|
|
||||||
|
## Real Example: Insurance Type
|
||||||
|
|
||||||
|
See the implementation in `apps/dashboard/modules/job-cards/`:
|
||||||
|
|
||||||
|
- [insurance-type-form.tsx](../../apps/dashboard/modules/job-cards/insurance-type-form.tsx) — Simple form with one "name" field
|
||||||
|
- [insurance-type-crud-dialog.tsx](../../apps/dashboard/modules/job-cards/insurance-type-crud-dialog.tsx) — CrudDialog wiring
|
||||||
|
- [job-card-form.tsx](../../apps/dashboard/modules/job-cards/job-card-form.tsx) — Usage next to the insurance type select field
|
||||||
|
|
||||||
|
## Naming Conventions
|
||||||
|
|
||||||
|
| Item | Pattern | Example |
|
||||||
|
|------|---------|---------|
|
||||||
|
| Form file | `modules/<parent>/<resource>-form.tsx` | `job-cards/insurance-type-form.tsx` |
|
||||||
|
| CrudDialog file | `modules/<parent>/<resource>-crud-dialog.tsx` | `job-cards/insurance-type-crud-dialog.tsx` |
|
||||||
|
| Form component | `<Resource>Form` | `InsuranceTypeForm` |
|
||||||
|
| CrudDialog component | `<Resource>CrudDialog` | `InsuranceTypeCrudDialog` |
|
||||||
|
|
||||||
|
## Imports Cheat Sheet
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// CrudDialog component
|
||||||
|
import { CrudDialog } from "@/shared/components/crud-dialog"
|
||||||
|
|
||||||
|
// Table column header
|
||||||
|
import { ColumnHeader } from "@/shared/data-view/table-view"
|
||||||
|
|
||||||
|
// API
|
||||||
|
import { useAuthApi } from "@/shared/useApi"
|
||||||
|
import { <RESOURCE>_ROUTES } from "@garage/api"
|
||||||
|
|
||||||
|
// Form components (for the resource form)
|
||||||
|
import { Rhform, RhfTextField } from "@/shared/components/form"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { FieldGroup } from "@/shared/components/ui/field"
|
||||||
|
import { z } from "zod"
|
||||||
|
import { useForm } from "react-hook-form"
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
```
|
||||||
73
.github/skills/shared-formatters/SKILL.md
vendored
Normal file
73
.github/skills/shared-formatters/SKILL.md
vendored
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
---
|
||||||
|
name: shared-formatters
|
||||||
|
description: "Use the central shared/utils/formatters.ts file for all display formatting in the dashboard. Use when: formatting dates, times, numbers, currencies, or enum strings in table cells, detail pages, or any UI display. Avoid inline formatting logic like `toLocaleDateString()`, `.toLocaleString()`, or manual string splits. Import from @/shared/utils/formatters."
|
||||||
|
---
|
||||||
|
|
||||||
|
# Shared Formatters
|
||||||
|
|
||||||
|
Use this skill whenever work involves displaying dates, times, numbers, currencies, or enum strings in any UI component.
|
||||||
|
|
||||||
|
## Source of Truth
|
||||||
|
|
||||||
|
All shared display formatters live in:
|
||||||
|
|
||||||
|
```
|
||||||
|
apps/dashboard/shared/utils/formatters.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Import path: `@/shared/utils/formatters`
|
||||||
|
|
||||||
|
## Available Formatters
|
||||||
|
|
||||||
|
| Function | Input | Output example |
|
||||||
|
|---|---|---|
|
||||||
|
| `formatDate(value)` | string \| Date \| null | `"Jan 6, 2026"` |
|
||||||
|
| `formatDateTime(value)` | string \| Date \| null | `"Jan 6, 2026, 2:30 PM"` |
|
||||||
|
| `formatDateShort(value)` | string \| Date \| null | `"04/06/2026"` |
|
||||||
|
| `formatTime(value)` | string \| Date \| null | `"2:30 PM"` |
|
||||||
|
| `formatEnum(value)` | string \| null | `"In Progress"` |
|
||||||
|
| `formatNumber(value)` | number \| string \| null | `"150,000"` |
|
||||||
|
| `formatCurrency(value, currency?, locale?)` | number \| string \| null | `"$1,500.00"` |
|
||||||
|
|
||||||
|
All functions return `"—"` for null/undefined/invalid input — never return an empty string or throw.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
1. **Never inline formatting.** Do not use `new Date(x).toLocaleDateString()`, `Number(x).toLocaleString()`, or manual `split("_")` chains in components or pages. Use the shared formatters instead.
|
||||||
|
|
||||||
|
2. **Add before duplicating.** Check `formatters.ts` for an existing formatter before writing a new one. If a new formatter is needed, add it to `formatters.ts` — not inline.
|
||||||
|
|
||||||
|
3. **Keep all formatters in one file.** Do not create separate formatter files per module. All display formatting stays in `shared/utils/formatters.ts`.
|
||||||
|
|
||||||
|
4. **Consistent null handling.** Every formatter accepts `null | undefined` and returns `"—"`. Never require callers to guard against null before calling.
|
||||||
|
|
||||||
|
5. **Use `formatEnum` for status/type fields.** Any snake_case or underscore-separated enum value displayed as text must go through `formatEnum`.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. Identify a display value needing formatting in a component.
|
||||||
|
2. Check `formatters.ts` for an existing matching formatter.
|
||||||
|
3. If found, import and use it.
|
||||||
|
4. If missing, add to `formatters.ts` following the null-safety pattern, then import.
|
||||||
|
5. Remove any inline formatting that the shared formatter now replaces.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { formatDate, formatEnum, formatNumber, formatCurrency } from "@/shared/utils/formatters"
|
||||||
|
|
||||||
|
// Table cell — date
|
||||||
|
cell: ({ row }) => formatDate(row.original.created_at)
|
||||||
|
|
||||||
|
// Table cell — enum status
|
||||||
|
cell: ({ row }) => <Badge>{formatEnum(row.original.status)}</Badge>
|
||||||
|
|
||||||
|
// Table cell — number
|
||||||
|
cell: ({ row }) => formatNumber(row.original.km_in)
|
||||||
|
|
||||||
|
// Table cell — currency
|
||||||
|
cell: ({ row }) => formatCurrency(row.original.total_amount)
|
||||||
|
|
||||||
|
// Detail page field
|
||||||
|
<p>{formatDateTime(jobCard.updated_at)}</p>
|
||||||
|
```
|
||||||
@ -12,7 +12,7 @@ import { redirect } from "next/navigation"
|
|||||||
function Logo() {
|
function Logo() {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Image alt="Logo" src={'/assets/logo.png'} height={200} width={200} />
|
<Image alt="Logo" src={'/assets/logo.png'} height={100} width={100} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { DashboardContent } from "@/modules/home/dashboard-content";
|
|||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
return (
|
return (
|
||||||
<DashboardPage header={<DashboardHeader />} title="Dashboard">
|
<DashboardPage headerProps={{title: "Dashboard"}} >
|
||||||
<DashboardContent />
|
<DashboardContent />
|
||||||
</DashboardPage>
|
</DashboardPage>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -0,0 +1,57 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { ResourcePage } from "@/shared/data-view/resource-page"
|
||||||
|
import { ColumnHeader } from "@/shared/data-view/table-view"
|
||||||
|
import FormDialog from "@/shared/components/form-dialog"
|
||||||
|
import { TaskForm } from "@/modules/tasks/task-form"
|
||||||
|
import { TASK_ROUTES } from "@garage/api"
|
||||||
|
import type { TasksClient } from "@garage/api"
|
||||||
|
|
||||||
|
export default function TasksPage() {
|
||||||
|
return (
|
||||||
|
<ResourcePage<TasksClient>
|
||||||
|
routeKey={TASK_ROUTES.INDEX}
|
||||||
|
getClient={(api) => api.tasks}
|
||||||
|
headerProps={({ selectedItem, invalidateQuery }) => ({
|
||||||
|
title: "Tasks",
|
||||||
|
actions: (
|
||||||
|
<FormDialog title="Task">
|
||||||
|
{(resourceId) => (
|
||||||
|
<TaskForm
|
||||||
|
resourceId={resourceId}
|
||||||
|
initialData={selectedItem}
|
||||||
|
onSuccess={invalidateQuery}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</FormDialog>
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
columns={({ actionsColumn }) => [
|
||||||
|
{
|
||||||
|
accessorKey: "task_number",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Task #" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "subject",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Subject" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "priority",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Priority" />,
|
||||||
|
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "status",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Status" />,
|
||||||
|
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "due_date",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Due Date" />,
|
||||||
|
|
||||||
|
},
|
||||||
|
actionsColumn(),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -35,13 +35,13 @@ export default function JobCardAppointmentsPage({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ResourcePage<AppointmentsClient>
|
<ResourcePage<AppointmentsClient>
|
||||||
pageTitle="Appointments"
|
|
||||||
routeKey={APPOINTMENT_ROUTES.INDEX}
|
routeKey={APPOINTMENT_ROUTES.INDEX}
|
||||||
getClient={(api) => api.appointments}
|
getClient={(api) => api.appointments}
|
||||||
extraParams={{ job_card_id: jobCardId }}
|
extraParams={{ job_card_id: jobCardId }}
|
||||||
header={null}
|
header={null}
|
||||||
onRowClick={(row) => router.push(`/calendar/appointment/${(row as any).id}`)}
|
onRowClick={(row) => router.push(`/calendar/appointment/${(row as any).id}`)}
|
||||||
toolbar={({ invalidateQuery, selectedItem, closeDialog }) => (
|
tableHeader={({ invalidateQuery, selectedItem, closeDialog }) => (
|
||||||
|
<div className="flex justify-end">
|
||||||
<FormDialog title="Appointment">
|
<FormDialog title="Appointment">
|
||||||
{(resourceId) => (
|
{(resourceId) => (
|
||||||
<AppointmentForm
|
<AppointmentForm
|
||||||
@ -51,6 +51,7 @@ export default function JobCardAppointmentsPage({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</FormDialog>
|
</FormDialog>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
columns={({ actionsColumn }) => [
|
columns={({ actionsColumn }) => [
|
||||||
{
|
{
|
||||||
|
|||||||
@ -85,8 +85,9 @@ export default function JobCardAttachmentsPage() {
|
|||||||
return (
|
return (
|
||||||
<DashboardPage
|
<DashboardPage
|
||||||
header={null}
|
header={null}
|
||||||
toolbar={
|
>
|
||||||
<div className="flex items-center justify-end">
|
|
||||||
|
<div className="flex items-center justify-end mb-4">
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
@ -101,11 +102,7 @@ export default function JobCardAttachmentsPage() {
|
|||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
{isUploading ? "Uploading..." : "Upload Attachment"}
|
{isUploading ? "Uploading..." : "Upload Attachment"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>}
|
</div>
|
||||||
title="Attachments"
|
|
||||||
>
|
|
||||||
|
|
||||||
|
|
||||||
{attachments?.length === 0 ? (
|
{attachments?.length === 0 ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="py-8 text-center text-muted-foreground">
|
<CardContent className="py-8 text-center text-muted-foreground">
|
||||||
@ -114,6 +111,7 @@ export default function JobCardAttachmentsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
|
||||||
{attachments?.map((attachment) => {
|
{attachments?.map((attachment) => {
|
||||||
const Icon = getFileIcon(attachment.attachment_path)
|
const Icon = getFileIcon(attachment.attachment_path)
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -24,12 +24,13 @@ export default function JobCardBillsPage({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ResourcePage<BillsClient>
|
<ResourcePage<BillsClient>
|
||||||
pageTitle="Bills"
|
|
||||||
routeKey={BILL_ROUTES.INDEX}
|
routeKey={BILL_ROUTES.INDEX}
|
||||||
getClient={(api) => api.bills}
|
getClient={(api) => api.bills}
|
||||||
extraParams={{ job_card_id: jobCardId }}
|
extraParams={{ job_card_id: jobCardId }}
|
||||||
header={null}
|
header={null}
|
||||||
toolbar={({ invalidateQuery, selectedItem, closeDialog }) => (
|
tableHeader={({ invalidateQuery, selectedItem, closeDialog }) => (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
|
||||||
<FormDialog title="Bill">
|
<FormDialog title="Bill">
|
||||||
{(resourceId) => (
|
{(resourceId) => (
|
||||||
<BillForm
|
<BillForm
|
||||||
@ -39,6 +40,7 @@ export default function JobCardBillsPage({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</FormDialog>
|
</FormDialog>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
columns={({ actionsColumn }) => [
|
columns={({ actionsColumn }) => [
|
||||||
{
|
{
|
||||||
|
|||||||
@ -1,153 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useParams } from "next/navigation"
|
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
|
||||||
import { type ColumnDef } from "@tanstack/react-table"
|
|
||||||
import { useState } from "react"
|
|
||||||
import { Plus, Trash2 } from "lucide-react"
|
|
||||||
import { toast } from "sonner"
|
|
||||||
|
|
||||||
import { useAuthApi } from "@/shared/useApi"
|
|
||||||
import { DataTable, ColumnHeader } from "@/shared/data-view/table-view"
|
|
||||||
import { confirm } from "@/shared/components/confirm-dialog"
|
|
||||||
import { Button } from "@/shared/components/ui/button"
|
|
||||||
import { Card, CardContent } from "@/shared/components/ui/card"
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/shared/components/ui/dialog"
|
|
||||||
import { JobCardRemarkForm } from "@/modules/job-cards/job-card-remark-form"
|
|
||||||
import DashboardPage from "@/base/components/layout/dashboard/dashboard-page"
|
|
||||||
|
|
||||||
type CustomerRemark = {
|
|
||||||
id: number
|
|
||||||
job_card_id?: number
|
|
||||||
remark?: string
|
|
||||||
created_at: string
|
|
||||||
updated_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CustomerRemarksPage() {
|
|
||||||
const { id: jobCardId } = useParams<{ id: string }>()
|
|
||||||
const api = useAuthApi()
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
const [dialogOpen, setDialogOpen] = useState(false)
|
|
||||||
|
|
||||||
const queryKey = ["job-card-remarks", jobCardId]
|
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
|
||||||
queryKey,
|
|
||||||
queryFn: async () => {
|
|
||||||
// const result = await api.jobCards.
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
|
||||||
mutationFn: () => api.jobCards.deleteCustomerRemark(jobCardId),
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success("Customer remark deleted successfully.")
|
|
||||||
queryClient.invalidateQueries({ queryKey })
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast.error("Failed to delete customer remark.")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleDelete = async (remark: CustomerRemark) => {
|
|
||||||
const confirmed = await confirm({
|
|
||||||
title: "Delete Customer Remark",
|
|
||||||
description: "Are you sure you want to delete this remark?",
|
|
||||||
confirmLabel: "Delete",
|
|
||||||
variant: "destructive",
|
|
||||||
})
|
|
||||||
if (confirmed) {
|
|
||||||
deleteMutation.mutate()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const columns: ColumnDef<CustomerRemark>[] = [
|
|
||||||
{
|
|
||||||
accessorKey: "remark",
|
|
||||||
header: ({ column }) => <ColumnHeader column={column} title="Remark" />,
|
|
||||||
cell: ({ getValue }) => {
|
|
||||||
const val = getValue<string>()
|
|
||||||
return val || "—"
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "created_at",
|
|
||||||
header: ({ column }) => <ColumnHeader column={column} title="Created" />,
|
|
||||||
cell: ({ getValue }) => {
|
|
||||||
const val = getValue<string>()
|
|
||||||
return val ? new Date(val).toLocaleDateString() : "—"
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "actions",
|
|
||||||
header: () => <span className="sr-only">Actions</span>,
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
onClick={() => handleDelete(row.original)}
|
|
||||||
title="Delete remark"
|
|
||||||
>
|
|
||||||
<Trash2 className="size-4 text-destructive" />
|
|
||||||
</Button>
|
|
||||||
),
|
|
||||||
enableSorting: false,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const remarks = Array.isArray(data) ? data : []
|
|
||||||
|
|
||||||
const pagination = {
|
|
||||||
page: 1,
|
|
||||||
pageSize: 100,
|
|
||||||
pageCount: 1,
|
|
||||||
total: remarks.length,
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DashboardPage header={null} title="Customer Remarks" toolbar={ <div className="flex items-center justify-end">
|
|
||||||
<Button onClick={() => setDialogOpen(true)}>
|
|
||||||
<Plus className="size-4" />
|
|
||||||
Add Customer Remark
|
|
||||||
</Button>
|
|
||||||
</div>}>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardContent>
|
|
||||||
<DataTable
|
|
||||||
columns={columns}
|
|
||||||
data={remarks}
|
|
||||||
pagination={pagination}
|
|
||||||
isLoading={isLoading}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Add Customer Remark</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<JobCardRemarkForm
|
|
||||||
jobCardId={jobCardId}
|
|
||||||
onSuccess={() => {
|
|
||||||
setDialogOpen(false)
|
|
||||||
queryClient.invalidateQueries({ queryKey })
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
</DashboardPage>
|
|
||||||
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -24,12 +24,13 @@ export default function JobCardExpensesPage({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ResourcePage<ExpensesClient>
|
<ResourcePage<ExpensesClient>
|
||||||
pageTitle="Expenses"
|
|
||||||
routeKey={EXPENSE_ROUTES.INDEX}
|
routeKey={EXPENSE_ROUTES.INDEX}
|
||||||
getClient={(api) => api.expenses}
|
getClient={(api) => api.expenses}
|
||||||
extraParams={{ job_card_id: jobCardId }}
|
extraParams={{ job_card_id: jobCardId }}
|
||||||
header={null}
|
header={null}
|
||||||
toolbar={({ invalidateQuery, selectedItem, closeDialog }) => (
|
tableHeader={({ invalidateQuery, selectedItem, closeDialog }) => (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
|
||||||
<FormDialog title="Expense">
|
<FormDialog title="Expense">
|
||||||
{(resourceId) => (
|
{(resourceId) => (
|
||||||
<ExpenseForm
|
<ExpenseForm
|
||||||
@ -39,6 +40,7 @@ export default function JobCardExpensesPage({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</FormDialog>
|
</FormDialog>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
columns={({ actionsColumn }) => [
|
columns={({ actionsColumn }) => [
|
||||||
{
|
{
|
||||||
|
|||||||
@ -19,7 +19,7 @@ export default async function JobCardDetailLayout(props: { params: Promise<{ id:
|
|||||||
const docs = jobCard?.documents
|
const docs = jobCard?.documents
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<JobCardProvider jobCard={{ ...jobCard, label: title }}>
|
<JobCardProvider jobCard={{ ...jobCard }}>
|
||||||
<DashboardDetailsPage
|
<DashboardDetailsPage
|
||||||
className='p-0 lg:p-0'
|
className='p-0 lg:p-0'
|
||||||
title={title}
|
title={title}
|
||||||
@ -35,14 +35,6 @@ export default async function JobCardDetailLayout(props: { params: Promise<{ id:
|
|||||||
href: `/sales/job-cards/${id}`,
|
href: `/sales/job-cards/${id}`,
|
||||||
label: 'Details'
|
label: 'Details'
|
||||||
},
|
},
|
||||||
{
|
|
||||||
href: `/sales/job-cards/${id}/customer-remarks`,
|
|
||||||
label: 'Customer Remarks'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: `/sales/job-cards/${id}/shop-recommendations`,
|
|
||||||
label: 'Shop Recommendations'
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
href: `/sales/job-cards/${id}/attachments`,
|
href: `/sales/job-cards/${id}/attachments`,
|
||||||
label: `Attachments (${docs?.length || 0})`
|
label: `Attachments (${docs?.length || 0})`
|
||||||
@ -67,6 +59,10 @@ export default async function JobCardDetailLayout(props: { params: Promise<{ id:
|
|||||||
href: `/sales/job-cards/${id}/tasks`,
|
href: `/sales/job-cards/${id}/tasks`,
|
||||||
label: `Tasks (${jobCard?.tasks_count || 0})`
|
label: `Tasks (${jobCard?.tasks_count || 0})`
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: `/sales/job-cards/${id}/parts`,
|
||||||
|
label: `Parts (${jobCard?.parts_count || 0})`
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{props.children}
|
{props.children}
|
||||||
|
|||||||
@ -0,0 +1,196 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { use, useState } from "react"
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||||
|
import { useAuthApi } from "@/shared/useApi"
|
||||||
|
import { ColumnHeader, DataTable } from "@/shared/data-view/table-view"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/shared/components/ui/dialog"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/shared/components/ui/dropdown-menu"
|
||||||
|
import { confirm } from "@/shared/components/confirm-dialog"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { Ellipsis, Plus } from "lucide-react"
|
||||||
|
import type { ColumnDef } from "@tanstack/react-table"
|
||||||
|
import { JobCardPartForm } from "@/modules/job-cards/job-card-part-form"
|
||||||
|
import { formatDate } from "@/shared/utils/formatters"
|
||||||
|
|
||||||
|
export default function JobCardPartsPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>
|
||||||
|
}) {
|
||||||
|
const { id: jobCardId } = use(params)
|
||||||
|
const api = useAuthApi()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const queryKey = ["job-card-parts", jobCardId]
|
||||||
|
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
|
const [editItem, setEditItem] = useState<any | null>(null)
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey,
|
||||||
|
queryFn: () => api.jobCards.getParts(jobCardId),
|
||||||
|
})
|
||||||
|
|
||||||
|
const rows = (data as any)?.data ?? []
|
||||||
|
|
||||||
|
const invalidate = () => queryClient.invalidateQueries({ queryKey })
|
||||||
|
|
||||||
|
async function handleDelete(row: any) {
|
||||||
|
const confirmed = await confirm({
|
||||||
|
title: "Delete this part?",
|
||||||
|
description: `Remove part "${row.part?.title ?? "this part"}" from the job card?`,
|
||||||
|
})
|
||||||
|
if (!confirmed) return
|
||||||
|
const promise = api.jobCards.deletePart(jobCardId, row.id)
|
||||||
|
toast.promise(promise, {
|
||||||
|
loading: "Deleting...",
|
||||||
|
success: "Part deleted",
|
||||||
|
error: "Failed to delete part",
|
||||||
|
})
|
||||||
|
await promise
|
||||||
|
invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: ColumnDef<any>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: "part.title",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Part" />,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const part = row.original.part
|
||||||
|
return part ? (
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">{part.title}</span>
|
||||||
|
{part.sku && (
|
||||||
|
<span className="ml-2 text-xs text-muted-foreground">{part.sku}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : "—"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "quantity",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Qty" />,
|
||||||
|
cell: ({ row }) => row.original.quantity ?? "—",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "rate",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Rate" />,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const val = row.original.rate
|
||||||
|
return val != null ? `$${Number(val).toFixed(2)}` : "—"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "tax",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Tax" />,
|
||||||
|
cell: ({ row }) => row.original.tax || "—",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "department.name",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Department" />,
|
||||||
|
cell: ({ row }) => row.original.department?.name || "—",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "description",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Description" />,
|
||||||
|
cell: ({ row }) => row.original.description || "—",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "created_at",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Added" />,
|
||||||
|
cell: ({ row }) => formatDate(row.original.created_at),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<Ellipsis className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
setEditItem(row.original)
|
||||||
|
setDialogOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-destructive"
|
||||||
|
onClick={() => handleDelete(row.original)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 p-4">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Dialog
|
||||||
|
open={dialogOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setDialogOpen(open)
|
||||||
|
if (!open) setEditItem(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button onClick={() => setEditItem(null)}>
|
||||||
|
<Plus className="me-2 h-4 w-4" />
|
||||||
|
Add Part
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editItem ? "Edit Part" : "Add Part"}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<JobCardPartForm
|
||||||
|
jobCardId={jobCardId}
|
||||||
|
jobCardPartId={editItem?.id ?? null}
|
||||||
|
initialData={editItem}
|
||||||
|
onSuccess={() => {
|
||||||
|
setDialogOpen(false)
|
||||||
|
setEditItem(null)
|
||||||
|
invalidate()
|
||||||
|
}}
|
||||||
|
onCancel={() => {
|
||||||
|
setDialogOpen(false)
|
||||||
|
setEditItem(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={rows}
|
||||||
|
pagination={{
|
||||||
|
page: 1,
|
||||||
|
pageSize: rows.length || 15,
|
||||||
|
pageCount: 1,
|
||||||
|
total: rows.length,
|
||||||
|
}}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -23,12 +23,13 @@ export default function JobCardPurchaseOrdersPage({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ResourcePage<PurchaseOrdersClient>
|
<ResourcePage<PurchaseOrdersClient>
|
||||||
pageTitle="Purchase Orders"
|
|
||||||
routeKey={PURCHASE_ORDER_ROUTES.INDEX}
|
routeKey={PURCHASE_ORDER_ROUTES.INDEX}
|
||||||
getClient={(api) => api.purchaseOrders}
|
getClient={(api) => api.purchaseOrders}
|
||||||
extraParams={{ job_card_id: jobCardId }}
|
extraParams={{ job_card_id: jobCardId }}
|
||||||
header={null}
|
header={null}
|
||||||
toolbar={({ invalidateQuery, selectedItem, closeDialog }) => (
|
tableHeader={({ invalidateQuery, selectedItem, closeDialog }) => (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
|
||||||
<FormDialog title="Purchase Order">
|
<FormDialog title="Purchase Order">
|
||||||
{(resourceId) => (
|
{(resourceId) => (
|
||||||
<PurchaseOrderForm
|
<PurchaseOrderForm
|
||||||
@ -38,6 +39,8 @@ export default function JobCardPurchaseOrdersPage({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</FormDialog>
|
</FormDialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
)}
|
)}
|
||||||
columns={({ actionsColumn }) => [
|
columns={({ actionsColumn }) => [
|
||||||
{
|
{
|
||||||
|
|||||||
@ -1,157 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useParams } from "next/navigation"
|
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
|
||||||
import { type ColumnDef } from "@tanstack/react-table"
|
|
||||||
import { useState } from "react"
|
|
||||||
import { Plus, Trash2 } from "lucide-react"
|
|
||||||
import { toast } from "sonner"
|
|
||||||
|
|
||||||
import { useAuthApi } from "@/shared/useApi"
|
|
||||||
import { DataTable, ColumnHeader } from "@/shared/data-view/table-view"
|
|
||||||
import { confirm } from "@/shared/components/confirm-dialog"
|
|
||||||
import { Button } from "@/shared/components/ui/button"
|
|
||||||
import { Card, CardContent } from "@/shared/components/ui/card"
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/shared/components/ui/dialog"
|
|
||||||
import { JobCardRecommendationForm } from "@/modules/job-cards/job-card-recommendation-form"
|
|
||||||
import DashboardPage from "@/base/components/layout/dashboard/dashboard-page"
|
|
||||||
|
|
||||||
type ShopRecommendation = {
|
|
||||||
id: number
|
|
||||||
job_card_id?: number
|
|
||||||
recommendation?: string
|
|
||||||
created_at: string
|
|
||||||
updated_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ShopRecommendationsPage() {
|
|
||||||
const { id: jobCardId } = useParams<{ id: string }>()
|
|
||||||
const api = useAuthApi()
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
const [dialogOpen, setDialogOpen] = useState(false)
|
|
||||||
|
|
||||||
const queryKey = ["job-card-recommendations", jobCardId]
|
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
|
||||||
queryKey,
|
|
||||||
queryFn: async () => {
|
|
||||||
const result = await api.jobCards.show(jobCardId)
|
|
||||||
const d = (result as any)?.data ?? result
|
|
||||||
return d?.shop_recommendations ?? []
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
|
||||||
mutationFn: () => api.jobCards.deleteShopRecommendation(jobCardId),
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success("Shop recommendation deleted successfully.")
|
|
||||||
queryClient.invalidateQueries({ queryKey })
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast.error("Failed to delete shop recommendation.")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleDelete = async (rec: ShopRecommendation) => {
|
|
||||||
const confirmed = await confirm({
|
|
||||||
title: "Delete Shop Recommendation",
|
|
||||||
description: "Are you sure you want to delete this recommendation?",
|
|
||||||
confirmLabel: "Delete",
|
|
||||||
variant: "destructive",
|
|
||||||
})
|
|
||||||
if (confirmed) {
|
|
||||||
deleteMutation.mutate()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const columns: ColumnDef<ShopRecommendation>[] = [
|
|
||||||
{
|
|
||||||
accessorKey: "recommendation",
|
|
||||||
header: ({ column }) => <ColumnHeader column={column} title="Recommendation" />,
|
|
||||||
cell: ({ getValue }) => {
|
|
||||||
const val = getValue<string>()
|
|
||||||
return val || "—"
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "created_at",
|
|
||||||
header: ({ column }) => <ColumnHeader column={column} title="Created" />,
|
|
||||||
cell: ({ getValue }) => {
|
|
||||||
const val = getValue<string>()
|
|
||||||
return val ? new Date(val).toLocaleDateString() : "—"
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "actions",
|
|
||||||
header: () => <span className="sr-only">Actions</span>,
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
onClick={() => handleDelete(row.original)}
|
|
||||||
title="Delete recommendation"
|
|
||||||
>
|
|
||||||
<Trash2 className="size-4 text-destructive" />
|
|
||||||
</Button>
|
|
||||||
),
|
|
||||||
enableSorting: false,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const recommendations = Array.isArray(data) ? data : []
|
|
||||||
|
|
||||||
const pagination = {
|
|
||||||
page: 1,
|
|
||||||
pageSize: 100,
|
|
||||||
pageCount: 1,
|
|
||||||
total: recommendations.length,
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DashboardPage
|
|
||||||
header={null}
|
|
||||||
title="Shop Recommendations"
|
|
||||||
toolbar={
|
|
||||||
<Button onClick={() => setDialogOpen(true)}>
|
|
||||||
<Plus className="size-4 me-2" />
|
|
||||||
Add Recommendation
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardContent>
|
|
||||||
<DataTable
|
|
||||||
columns={columns}
|
|
||||||
data={recommendations}
|
|
||||||
pagination={pagination}
|
|
||||||
sorting={[]}
|
|
||||||
onChange={() => {}}
|
|
||||||
isLoading={isLoading}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Add Shop Recommendation</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<JobCardRecommendationForm
|
|
||||||
jobCardId={jobCardId}
|
|
||||||
onSuccess={() => {
|
|
||||||
setDialogOpen(false)
|
|
||||||
queryClient.invalidateQueries({ queryKey })
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</DashboardPage>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -3,9 +3,13 @@
|
|||||||
import { use } from "react"
|
import { use } from "react"
|
||||||
import { ResourcePage } from "@/shared/data-view/resource-page"
|
import { ResourcePage } from "@/shared/data-view/resource-page"
|
||||||
import { ColumnHeader } from "@/shared/data-view/table-view"
|
import { ColumnHeader } from "@/shared/data-view/table-view"
|
||||||
|
import FormDialog from "@/shared/components/form-dialog"
|
||||||
|
import { TaskForm } from "@/modules/tasks/task-form"
|
||||||
import { TASK_ROUTES } from "@garage/api"
|
import { TASK_ROUTES } from "@garage/api"
|
||||||
import type { TasksClient } from "@garage/api"
|
import type { TasksClient } from "@garage/api"
|
||||||
import { Badge } from "@/shared/components/ui/badge"
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
|
import { useJobCard } from "@/modules/job-cards/job-card-context"
|
||||||
|
import { formatDate, formatEnum } from "@/shared/utils/formatters"
|
||||||
|
|
||||||
export default function JobCardTasksPage({
|
export default function JobCardTasksPage({
|
||||||
params,
|
params,
|
||||||
@ -13,54 +17,58 @@ export default function JobCardTasksPage({
|
|||||||
params: Promise<{ id: string }>
|
params: Promise<{ id: string }>
|
||||||
}) {
|
}) {
|
||||||
const { id: jobCardId } = use(params)
|
const { id: jobCardId } = use(params)
|
||||||
|
const jobCard = useJobCard()
|
||||||
|
|
||||||
|
const defaultJobCard = jobCard
|
||||||
|
? { value: String((jobCard as any).id), label: (jobCard as any).title || `Job Card` }
|
||||||
|
: null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResourcePage<TasksClient>
|
<ResourcePage<TasksClient>
|
||||||
pageTitle="Tasks"
|
routeKey={TASK_ROUTES.INDEX}
|
||||||
routeKey={TASK_ROUTES.TASKS}
|
|
||||||
getClient={(api) => api.tasks}
|
getClient={(api) => api.tasks}
|
||||||
extraParams={{ job_card_id: jobCardId }}
|
extraParams={{ job_card_id: jobCardId }}
|
||||||
header={null}
|
header={null}
|
||||||
columns={() => [
|
tableHeader={({ invalidateQuery, selectedItem, closeDialog }) => (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<FormDialog title="Task">
|
||||||
|
{(resourceId) => (
|
||||||
|
<TaskForm
|
||||||
|
resourceId={resourceId}
|
||||||
|
initialData={selectedItem ?? { job_card: defaultJobCard }}
|
||||||
|
onSuccess={() => { closeDialog(); invalidateQuery() }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</FormDialog>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
columns={({ actionsColumn }) => [
|
||||||
{
|
{
|
||||||
accessorKey: "title",
|
accessorKey: "task_number",
|
||||||
header: ({ column }) => <ColumnHeader column={column} title="Task" />,
|
header: ({ column }) => <ColumnHeader column={column} title="Task #" />,
|
||||||
cell: ({ row }) => (row.original as any).title || "—",
|
cell: ({ row }) => (row.original as any).task_number || "—",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "task_type_name",
|
accessorKey: "subject",
|
||||||
header: ({ column }) => <ColumnHeader column={column} title="Type" />,
|
header: ({ column }) => <ColumnHeader column={column} title="Subject" />,
|
||||||
cell: ({ row }) => (row.original as any).task_type_name || "—",
|
cell: ({ row }) => (row.original as any).subject || "—",
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "section_name",
|
|
||||||
header: ({ column }) => <ColumnHeader column={column} title="Section" />,
|
|
||||||
cell: ({ row }) => (row.original as any).section_name || "—",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "due_date",
|
accessorKey: "due_date",
|
||||||
header: ({ column }) => <ColumnHeader column={column} title="Due Date" />,
|
header: ({ column }) => <ColumnHeader column={column} title="Due Date" />,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => formatDate((row.original as any).due_date),
|
||||||
const val = (row.original as any).due_date
|
|
||||||
return val ? new Date(val).toLocaleDateString() : "—"
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "priority",
|
accessorKey: "priority",
|
||||||
header: ({ column }) => <ColumnHeader column={column} title="Priority" />,
|
header: ({ column }) => <ColumnHeader column={column} title="Priority" />,
|
||||||
cell: ({ row }) => {
|
|
||||||
const value = (row.original as any).priority
|
|
||||||
return value ? <Badge variant="outline">{value}</Badge> : "—"
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "status",
|
accessorKey: "status",
|
||||||
header: ({ column }) => <ColumnHeader column={column} title="Status" />,
|
header: ({ column }) => <ColumnHeader column={column} title="Status" />,
|
||||||
cell: ({ row }) => {
|
|
||||||
const value = (row.original as any).status
|
|
||||||
return value ? <Badge>{value}</Badge> : "—"
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
actionsColumn(),
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import { Badge } from '@/shared/components/ui/badge'
|
|||||||
import { Input } from '@/shared/components/ui/input'
|
import { Input } from '@/shared/components/ui/input'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
|
import { formatDate, formatEnum, formatNumber } from '@/shared/utils/formatters'
|
||||||
|
|
||||||
type JobCardItem = {
|
type JobCardItem = {
|
||||||
id: number
|
id: number
|
||||||
@ -31,19 +32,11 @@ const statusColorMap: Record<string, string> = {
|
|||||||
cancelled: "destructive",
|
cancelled: "destructive",
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatStatus = (status?: string) => {
|
|
||||||
if (!status) return "—"
|
|
||||||
return status
|
|
||||||
.split("_")
|
|
||||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
||||||
.join(" ")
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function JobCardsPage() {
|
export default function JobCardsPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [searchInput, setSearchInput] = useState("")
|
const [searchInput, setSearchInput] = useState("")
|
||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
const [statusFilter, setStatusFilter] = useState<string>("all")
|
const [statusFilter, setStatusFilter] = useState<string>("check_in")
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => setSearch(searchInput), 400)
|
const timer = setTimeout(() => setSearch(searchInput), 400)
|
||||||
@ -56,14 +49,15 @@ export default function JobCardsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ResourcePage<JobCardsClient>
|
<ResourcePage<JobCardsClient>
|
||||||
pageTitle="Job Cards"
|
|
||||||
routeKey={JOB_CARD_ROUTES.INDEX}
|
routeKey={JOB_CARD_ROUTES.INDEX}
|
||||||
getClient={(api) => api.jobCards}
|
getClient={(api) => api.jobCards}
|
||||||
extraParams={extraParams}
|
extraParams={extraParams}
|
||||||
onRowClick={(row) => router.push(`/sales/job-cards/${row.id}`)}
|
onRowClick={(row) => router.push(`/sales/job-cards/${row.id}`)}
|
||||||
headerProps={({ selectedItem, invalidateQuery }) => ({
|
headerProps={({ selectedItem, invalidateQuery }) => ({
|
||||||
|
title: "Job Cards",
|
||||||
actions: (
|
actions: (
|
||||||
<FormDialog title="Job Card">
|
<FormDialog classNames={{ dialogContent: 'min-w-6xl' }} title="Job Card" >
|
||||||
{(resourceId) => (
|
{(resourceId) => (
|
||||||
<JobCardForm
|
<JobCardForm
|
||||||
resourceId={resourceId}
|
resourceId={resourceId}
|
||||||
@ -88,28 +82,26 @@ export default function JobCardsPage() {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
accessorKey: "status",
|
accessorKey: "order_number",
|
||||||
header: ({ column }) => <ColumnHeader column={column} title="Status" />,
|
header: ({ column }) => <ColumnHeader column={column} title="Order Number" />,
|
||||||
cell: ({ row }) => {
|
|
||||||
const item = row.original as unknown as JobCardItem
|
|
||||||
return (
|
|
||||||
<Badge variant={statusColorMap[item.status ?? ""] as any ?? "outline"}>
|
|
||||||
{formatStatus(item.status)}
|
|
||||||
</Badge>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "check_in_date",
|
accessorKey: "check_in_date",
|
||||||
header: ({ column }) => <ColumnHeader column={column} title="Check-in Date" />,
|
header: ({ column }) => <ColumnHeader column={column} title="Check-in Date" />,
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const val = getValue<string>()
|
||||||
|
return formatDate(val)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "km_in",
|
accessorKey: "vehicle_id",
|
||||||
header: ({ column }) => <ColumnHeader column={column} title="KM In" />,
|
header: ({ column }) => <ColumnHeader column={column} title="KM In" />,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const item = row.original as unknown as JobCardItem
|
const item = row.original as unknown as JobCardItem
|
||||||
return item.km_in ? Number(item.km_in).toLocaleString() : "—"
|
return item.km_in ? formatNumber(item.km_in) : "—"
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -117,14 +109,36 @@ export default function JobCardsPage() {
|
|||||||
header: ({ column }) => <ColumnHeader column={column} title="Created" />,
|
header: ({ column }) => <ColumnHeader column={column} title="Created" />,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const item = row.original as unknown as JobCardItem
|
const item = row.original as unknown as JobCardItem
|
||||||
return item.created_at ? new Date(item.created_at).toLocaleDateString() : "—"
|
return formatDate(item.created_at)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "status",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Status" />,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const item = row.original as unknown as JobCardItem
|
||||||
|
return (
|
||||||
|
<Badge variant={statusColorMap[item.status ?? ""] as any ?? "outline"}>
|
||||||
|
{formatEnum(item.status)}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
actionsColumn(),
|
actionsColumn(),
|
||||||
]}
|
]}
|
||||||
toolbar={
|
|
||||||
<div className="flex gap-3 w-full">
|
|
||||||
|
|
||||||
|
tableHeader={
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<Tabs value={statusFilter} onValueChange={setStatusFilter}>
|
||||||
|
<TabsList variant="line">
|
||||||
|
<TabsTrigger value="all" >All</TabsTrigger>
|
||||||
|
{JobCardStatus.map((status) => (
|
||||||
|
<TabsTrigger key={status} value={status}>
|
||||||
|
{formatEnum(status)}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
<div className="relative w-64">
|
<div className="relative w-64">
|
||||||
<SearchIcon className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
<SearchIcon className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
@ -136,18 +150,6 @@ export default function JobCardsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
tableHeader={
|
|
||||||
<Tabs value={statusFilter} onValueChange={setStatusFilter}>
|
|
||||||
<TabsList variant="default">
|
|
||||||
<TabsTrigger value="all" className='data-[state=active]:bg-primary/10 data-[state=active]:text-primary'>All</TabsTrigger>
|
|
||||||
{JobCardStatus.map((status) => (
|
|
||||||
<TabsTrigger className='data-[state=active]:bg-primary/10 data-[state=active]:text-primary ' key={status} value={status}>
|
|
||||||
{formatStatus(status)}
|
|
||||||
</TabsTrigger>
|
|
||||||
))}
|
|
||||||
</TabsList>
|
|
||||||
</Tabs>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { ResourcePage } from "@/shared/data-view/resource-page"
|
|||||||
import { ColumnHeader } from "@/shared/data-view/table-view"
|
import { ColumnHeader } from "@/shared/data-view/table-view"
|
||||||
import FormDialog from "@/shared/components/form-dialog"
|
import FormDialog from "@/shared/components/form-dialog"
|
||||||
import { PaymentReceivedForm } from "@/modules/payment-received/payment-received-form"
|
import { PaymentReceivedForm } from "@/modules/payment-received/payment-received-form"
|
||||||
import { PAYMENT_ROUTES } from "@garage/api"
|
import { PAYMENT_RECEIVED_ROUTES } from "@garage/api"
|
||||||
import {
|
import {
|
||||||
BadgeDollarSignIcon,
|
BadgeDollarSignIcon,
|
||||||
CalendarIcon,
|
CalendarIcon,
|
||||||
@ -31,11 +31,10 @@ type PaymentReceivedItem = {
|
|||||||
export default function PaymentReceivedPage() {
|
export default function PaymentReceivedPage() {
|
||||||
return (
|
return (
|
||||||
<ResourcePage<{ list(query?: any): Promise<any>; destroy(id: string): Promise<any> }>
|
<ResourcePage<{ list(query?: any): Promise<any>; destroy(id: string): Promise<any> }>
|
||||||
pageTitle="Payments Received"
|
routeKey={PAYMENT_RECEIVED_ROUTES.INDEX}
|
||||||
routeKey={PAYMENT_ROUTES.RECEIVED}
|
|
||||||
getClient={(api) => ({
|
getClient={(api) => ({
|
||||||
list: (query?: any) => api.payments.listReceived(query),
|
list: (query?: any) => api.paymentReceived.list(query),
|
||||||
destroy: (id: string) => api.payments.destroyReceived(id),
|
destroy: (id: string) => api.paymentReceived.destroy(id),
|
||||||
})}
|
})}
|
||||||
headerProps={({ invalidateQuery }) => ({
|
headerProps={({ invalidateQuery }) => ({
|
||||||
actions: (
|
actions: (
|
||||||
|
|||||||
@ -1,12 +1,55 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { StarIcon, StarOffIcon } from "lucide-react"
|
||||||
|
|
||||||
import { DepartmentForm } from "@/modules/settings/departments/department-form"
|
import { DepartmentForm } from "@/modules/settings/departments/department-form"
|
||||||
import { ResourcePage } from "@/shared/data-view/resource-page"
|
import { ResourcePage } from "@/shared/data-view/resource-page"
|
||||||
import { ColumnHeader } from "@/shared/data-view/table-view"
|
import { ColumnHeader } from "@/shared/data-view/table-view"
|
||||||
import FormDialog from "@/shared/components/form-dialog"
|
import FormDialog from "@/shared/components/form-dialog"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { useAuthApi } from "@/shared/useApi"
|
||||||
import { DEPARTMENT_ROUTES } from "@garage/api"
|
import { DEPARTMENT_ROUTES } from "@garage/api"
|
||||||
import type { DepartmentsClient } from "@garage/api"
|
import type { DepartmentsClient } from "@garage/api"
|
||||||
|
|
||||||
|
function FavoriteCell({ row }: { row: any }) {
|
||||||
|
const api = useAuthApi()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const isFavorite: boolean = row.is_favorite ?? false
|
||||||
|
|
||||||
|
const { mutate, isPending } = useMutation({
|
||||||
|
mutationFn: () => {
|
||||||
|
const promise = isFavorite
|
||||||
|
? api.departments.removeFavorite({ id: row.id })
|
||||||
|
: api.departments.setFavorite({ id: row.id })
|
||||||
|
toast.promise(promise, {
|
||||||
|
loading: isFavorite ? "Removing from favourites..." : "Setting as favourite...",
|
||||||
|
success: isFavorite ? "Removed from favourites" : "Set as favourite",
|
||||||
|
error: isFavorite ? "Failed to remove favourite" : "Failed to set favourite",
|
||||||
|
})
|
||||||
|
return promise
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: [DEPARTMENT_ROUTES.INDEX] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={() => mutate()}
|
||||||
|
title={isFavorite ? "Remove from favourites" : "Set as favourite"}
|
||||||
|
>
|
||||||
|
{isFavorite
|
||||||
|
? <StarIcon className="h-4 w-4 text-yellow-500 fill-yellow-400" />
|
||||||
|
: <StarOffIcon className="h-4 w-4 text-muted-foreground" />}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function DepartmentsPage() {
|
export default function DepartmentsPage() {
|
||||||
return (
|
return (
|
||||||
<ResourcePage<DepartmentsClient>
|
<ResourcePage<DepartmentsClient>
|
||||||
@ -36,6 +79,12 @@ export default function DepartmentsPage() {
|
|||||||
header: ({ column }) => <ColumnHeader column={column} title="Assignment Type" />,
|
header: ({ column }) => <ColumnHeader column={column} title="Assignment Type" />,
|
||||||
cell: ({ row }) => (row.original as any).assignment_type ?? "none",
|
cell: ({ row }) => (row.original as any).assignment_type ?? "none",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "favourite",
|
||||||
|
header: () => <span className="sr-only">Favourite</span>,
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) => <FavoriteCell row={row.original} />,
|
||||||
|
},
|
||||||
actionsColumn(),
|
actionsColumn(),
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,11 +1,15 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import { useCallback } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { usePathname } from "next/navigation"
|
import { usePathname, useRouter } from "next/navigation"
|
||||||
import { ChevronRight, Circle } from "lucide-react"
|
import { ChevronRight, Circle, LogOutIcon, UserIcon } from "lucide-react"
|
||||||
|
|
||||||
import type { NavGroup, NavItem } from "@/base/types/navigation"
|
import type { NavGroup, NavItem } from "@/base/types/navigation"
|
||||||
|
import type { UserInfo } from "@/base/types/navigation"
|
||||||
|
import { useAuthStore } from "@/shared/stores/auth-store"
|
||||||
import { cn } from "@/shared/lib/utils"
|
import { cn } from "@/shared/lib/utils"
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
|
||||||
import {
|
import {
|
||||||
Collapsible,
|
Collapsible,
|
||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
@ -22,6 +26,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
|
SidebarFooter,
|
||||||
SidebarGroup,
|
SidebarGroup,
|
||||||
SidebarGroupLabel,
|
SidebarGroupLabel,
|
||||||
SidebarHeader,
|
SidebarHeader,
|
||||||
@ -38,11 +43,19 @@ import {
|
|||||||
type AppSidebarProps = React.ComponentProps<typeof Sidebar> & {
|
type AppSidebarProps = React.ComponentProps<typeof Sidebar> & {
|
||||||
navGroups: NavGroup[]
|
navGroups: NavGroup[]
|
||||||
logo?: React.ReactNode
|
logo?: React.ReactNode
|
||||||
|
user?: UserInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppSidebar({ navGroups, logo, ...props }: AppSidebarProps) {
|
export function AppSidebar({ navGroups, logo, user, ...props }: AppSidebarProps) {
|
||||||
const { state, isMobile } = useSidebar()
|
const { state, isMobile } = useSidebar()
|
||||||
const isCollapsed = state === "collapsed" && !isMobile
|
const isCollapsed = state === "collapsed" && !isMobile
|
||||||
|
const { logout } = useAuthStore((s) => s)
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const handleLogout = useCallback(async () => {
|
||||||
|
await logout()
|
||||||
|
router.push("/login")
|
||||||
|
}, [logout, router])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar collapsible="icon" {...props} className="bg-card">
|
<Sidebar collapsible="icon" {...props} className="bg-card">
|
||||||
@ -71,6 +84,58 @@ export function AppSidebar({ navGroups, logo, ...props }: AppSidebarProps) {
|
|||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
))}
|
))}
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
|
<SidebarFooter className="p-2">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<SidebarMenuButton
|
||||||
|
size="lg"
|
||||||
|
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||||
|
>
|
||||||
|
<Avatar className="size-8 rounded-lg">
|
||||||
|
{user?.avatar && <AvatarImage src={user.avatar as string} alt={user.name} />}
|
||||||
|
<AvatarFallback className="rounded-lg">
|
||||||
|
{user?.initials ?? user?.name?.charAt(0).toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
{!isCollapsed && (
|
||||||
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||||
|
<span className="truncate font-medium">{user?.name}</span>
|
||||||
|
{user?.email && (
|
||||||
|
<span className="truncate text-xs text-muted-foreground">{user.email}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
side="top"
|
||||||
|
align="start"
|
||||||
|
sideOffset={4}
|
||||||
|
className="w-56"
|
||||||
|
>
|
||||||
|
<DropdownMenuLabel className="font-normal">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-medium">{user?.name}</span>
|
||||||
|
{user?.email && (
|
||||||
|
<span className="text-xs text-muted-foreground">{user.email}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href="/profile">
|
||||||
|
<UserIcon />
|
||||||
|
Profile
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem variant="destructive" onSelect={handleLogout}>
|
||||||
|
<LogOutIcon />
|
||||||
|
Logout
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</SidebarFooter>
|
||||||
<SidebarRail />
|
<SidebarRail />
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,22 +1,15 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react"
|
import { useCallback, useEffect, useState } from "react"
|
||||||
import Link from "next/link"
|
|
||||||
import { useRouter } from "next/navigation"
|
|
||||||
import { useTheme } from "next-themes"
|
import { useTheme } from "next-themes"
|
||||||
import {
|
import {
|
||||||
BellIcon,
|
BellIcon,
|
||||||
LogOutIcon,
|
|
||||||
MoonIcon,
|
MoonIcon,
|
||||||
SearchIcon,
|
SearchIcon,
|
||||||
SunIcon,
|
SunIcon,
|
||||||
UserIcon,
|
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
import type { UserInfo } from "@/base/types/navigation"
|
|
||||||
import { useAuthStore } from "@/shared/stores/auth-store"
|
|
||||||
import { cn } from "@/shared/lib/utils"
|
import { cn } from "@/shared/lib/utils"
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
|
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { SidebarTrigger } from "@/shared/components/ui/sidebar"
|
import { SidebarTrigger } from "@/shared/components/ui/sidebar"
|
||||||
import {
|
import {
|
||||||
@ -28,33 +21,17 @@ import {
|
|||||||
CommandGroup,
|
CommandGroup,
|
||||||
CommandItem,
|
CommandItem,
|
||||||
} from "@/shared/components/ui/command"
|
} 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"
|
import { Separator } from "@/shared/components/ui/separator"
|
||||||
|
|
||||||
export type DashboardHeaderProps = {
|
export type DashboardHeaderProps = {
|
||||||
user?: UserInfo
|
|
||||||
actions?: React.ReactNode
|
actions?: React.ReactNode
|
||||||
className?: string
|
className?: string
|
||||||
|
title?:string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DashboardHeader({ actions, className }: DashboardHeaderProps) {
|
export function DashboardHeader({ actions, className, title }: DashboardHeaderProps) {
|
||||||
const { resolvedTheme, setTheme } = useTheme()
|
const { resolvedTheme, setTheme } = useTheme()
|
||||||
const [searchOpen, setSearchOpen] = useState(false)
|
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(() => {
|
useEffect(() => {
|
||||||
function onKeyDown(e: KeyboardEvent) {
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
@ -78,75 +55,16 @@ export function DashboardHeader({ actions, className }: DashboardHeaderProps) {
|
|||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
{title && <h1 className="text-lg font-semibold">{title}</h1>}
|
||||||
|
|
||||||
{/* Sidebar toggle — mobile: hamburger, desktop: collapse */}
|
{/* Sidebar toggle — mobile: hamburger, desktop: collapse */}
|
||||||
<SidebarTrigger className="-ms-2" />
|
<SidebarTrigger className="-ms-2 md:hidden" />
|
||||||
<Separator orientation="vertical" />
|
<Separator orientation="vertical" className="md:hidden" />
|
||||||
|
|
||||||
{/* Left side — default actions */}
|
{/* Left side — default actions */}
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{/* User dropdown */}
|
|
||||||
{/* {user && ( */}
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="ghost" className="flex items-center gap-2 px-2">
|
|
||||||
<Avatar >
|
|
||||||
{user?.avatar && <AvatarImage src={user?.avatar as string} alt={user?.name} />}
|
|
||||||
<AvatarFallback>
|
|
||||||
{user?.initials ?? user?.name.charAt(0).toUpperCase()}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<span className="hidden text-sm font-medium md:inline-block">
|
|
||||||
{user?.name}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
|
|
||||||
<DropdownMenuContent align="end" className="w-56">
|
|
||||||
{/* User info header */}
|
|
||||||
<DropdownMenuLabel className="font-normal">
|
|
||||||
<div className="flex items-center gap-3 py-1">
|
|
||||||
<Avatar size="lg">
|
|
||||||
{user?.avatar && <AvatarImage src={user?.avatar as string} alt={user?.name} />}
|
|
||||||
<AvatarFallback className="text-base">
|
|
||||||
{user?.initials ?? user?.name.charAt(0).toUpperCase()}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-sm font-medium text-foreground">{user?.name}</span>
|
|
||||||
{user?.email && (
|
|
||||||
<span className="text-xs text-muted-foreground">{user?.email}</span>
|
|
||||||
)}
|
|
||||||
{user?.role && (
|
|
||||||
<span className="mt-0.5 text-xs font-medium text-primary">{user?.role}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
<DropdownMenuItem asChild>
|
|
||||||
<Link href="/profile">
|
|
||||||
<UserIcon />
|
|
||||||
Profile
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
|
|
||||||
<DropdownMenuItem variant="destructive" onSelect={handleLogout}>
|
|
||||||
<LogOutIcon />
|
|
||||||
Logout
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
{/* )} */}
|
|
||||||
|
|
||||||
|
|
||||||
{/* Search trigger */}
|
{/* Search trigger */}
|
||||||
<Button
|
{/* <Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="hidden h-8 w-56 justify-start gap-2 text-muted-foreground md:flex"
|
className="hidden h-8 w-56 justify-start gap-2 text-muted-foreground md:flex"
|
||||||
onClick={() => setSearchOpen(true)}
|
onClick={() => setSearchOpen(true)}
|
||||||
@ -156,7 +74,7 @@ export function DashboardHeader({ actions, className }: DashboardHeaderProps) {
|
|||||||
<kbd className="pointer-events-none ms-auto inline-flex h-5 select-none items-center gap-0.5 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground">
|
<kbd className="pointer-events-none ms-auto inline-flex h-5 select-none items-center gap-0.5 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground">
|
||||||
⌘K
|
⌘K
|
||||||
</kbd>
|
</kbd>
|
||||||
</Button>
|
</Button> */}
|
||||||
|
|
||||||
{/* Mobile search icon */}
|
{/* Mobile search icon */}
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -31,11 +31,11 @@ export function DashboardLayout({
|
|||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<SidebarProvider defaultOpen={defaultOpen}>
|
<SidebarProvider defaultOpen={defaultOpen}>
|
||||||
<AppSidebar navGroups={navGroups} logo={logo} />
|
<AppSidebar navGroups={navGroups} logo={logo} user={user} />
|
||||||
<SidebarInset>
|
|
||||||
<div>
|
<SidebarInset className="min-w-0">
|
||||||
|
|
||||||
{children}
|
{children}
|
||||||
</div>
|
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|||||||
@ -6,39 +6,25 @@ type DashboardPageProps = {
|
|||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
header?: React.ReactNode | null
|
header?: React.ReactNode | null
|
||||||
headerProps?: DashboardHeaderProps
|
headerProps?: DashboardHeaderProps
|
||||||
toolbar?: React.ReactNode
|
|
||||||
title?: string
|
|
||||||
fullscreen?: boolean
|
fullscreen?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DashboardPage({ children, header, headerProps, title, fullscreen, toolbar }: DashboardPageProps) {
|
export default function DashboardPage({ children, header, headerProps, fullscreen, }: DashboardPageProps) {
|
||||||
const resolvedHeader = header !== undefined
|
const resolvedHeader = header !== undefined
|
||||||
? header
|
? header
|
||||||
: <DashboardHeader {...headerProps} />
|
: <DashboardHeader {...headerProps} />
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='page'>
|
<div className={cn("flex flex-col gap-4", fullscreen && "gap-0")}>
|
||||||
{resolvedHeader !== null && (
|
{resolvedHeader !== null && (
|
||||||
<header>
|
<header>
|
||||||
{resolvedHeader}
|
{resolvedHeader}
|
||||||
</header>
|
</header>
|
||||||
)}
|
)}
|
||||||
<main className={cn('p-4 w-full h-full ', fullscreen && 'h-screen p-0 lg:p-0')}>
|
<main >
|
||||||
{(title || toolbar) && <div className='flex items-center justify-between gap-4 mb-4'>
|
<div className={cn(fullscreen ? "w-full px-0" : "p-4")}>
|
||||||
|
|
||||||
{
|
|
||||||
title &&
|
|
||||||
<h2 className='text-lg lg:text-2xl font-bold '> {title}</h2>
|
|
||||||
}
|
|
||||||
{
|
|
||||||
toolbar &&
|
|
||||||
<div className=''>
|
|
||||||
{toolbar}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
</div>}
|
|
||||||
{children}
|
{children}
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -149,6 +149,7 @@ export const navGroups: NavGroup[] = [
|
|||||||
{ title: "Shop Calendars", href: "/productivity/shop-calendars", icon: <CalendarDaysIcon /> },
|
{ title: "Shop Calendars", href: "/productivity/shop-calendars", icon: <CalendarDaysIcon /> },
|
||||||
{ title: "Shop Timing", href: "/productivity/shop-timings", icon: <Clock3Icon /> },
|
{ title: "Shop Timing", href: "/productivity/shop-timings", icon: <Clock3Icon /> },
|
||||||
{ title: "Holidays", href: "/productivity/holidays", icon: <CalendarIcon /> },
|
{ title: "Holidays", href: "/productivity/holidays", icon: <CalendarIcon /> },
|
||||||
|
{ title: "Tasks", href: "/productivity/tasks", icon: <ListTodoIcon /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -70,6 +70,8 @@ export type RhfCustomerSelectFieldProps<
|
|||||||
required?: boolean
|
required?: boolean
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
|
/** Filter customers by customer_type.name (case-insensitive). */
|
||||||
|
customerType?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Component ──
|
// ── Component ──
|
||||||
@ -84,6 +86,7 @@ export function RhfCustomerSelectField<
|
|||||||
required,
|
required,
|
||||||
disabled,
|
disabled,
|
||||||
placeholder = "Search by name, company, or phone...",
|
placeholder = "Search by name, company, or phone...",
|
||||||
|
customerType,
|
||||||
}: RhfCustomerSelectFieldProps<TValues, TName>) {
|
}: RhfCustomerSelectFieldProps<TValues, TName>) {
|
||||||
const api = useAuthApi()
|
const api = useAuthApi()
|
||||||
const anchorRef = useRef<HTMLDivElement>(null)
|
const anchorRef = useRef<HTMLDivElement>(null)
|
||||||
@ -96,10 +99,16 @@ export function RhfCustomerSelectField<
|
|||||||
} = useController({ name, control, disabled })
|
} = useController({ name, control, disabled })
|
||||||
|
|
||||||
const { data: options = [], isLoading } = useQuery<CustomerOption[]>({
|
const { data: options = [], isLoading } = useQuery<CustomerOption[]>({
|
||||||
queryKey: [CUSTOMER_ROUTES.INDEX, "customer-select"],
|
queryKey: [CUSTOMER_ROUTES.INDEX, "customer-select", customerType ?? "all"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await api.customers.list()
|
const res = await api.customers.list()
|
||||||
return extractItems(res).map(buildCustomerOption)
|
const items = extractItems(res)
|
||||||
|
const filtered = customerType
|
||||||
|
? items.filter((item: any) =>
|
||||||
|
item.customer_type?.name?.toLowerCase() === customerType.toLowerCase(),
|
||||||
|
)
|
||||||
|
: items
|
||||||
|
return filtered.map(buildCustomerOption)
|
||||||
},
|
},
|
||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
})
|
})
|
||||||
|
|||||||
566
apps/dashboard/modules/estimates/rhf-customer-remarks-field.tsx
Normal file
566
apps/dashboard/modules/estimates/rhf-customer-remarks-field.tsx
Normal file
@ -0,0 +1,566 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import {
|
||||||
|
useFormContext,
|
||||||
|
useController,
|
||||||
|
type FieldValues,
|
||||||
|
type FieldPath,
|
||||||
|
} from "react-hook-form"
|
||||||
|
import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query"
|
||||||
|
import {
|
||||||
|
Check,
|
||||||
|
ChevronFirst,
|
||||||
|
ChevronLast,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Edit2,
|
||||||
|
MoreHorizontal,
|
||||||
|
Plus,
|
||||||
|
RefreshCcw,
|
||||||
|
Search,
|
||||||
|
Trash2,
|
||||||
|
X,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
import { useAuthApi } from "@/shared/useApi"
|
||||||
|
import { QUICK_REMARK_ROUTES } from "@garage/api"
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
} from "@/shared/components/ui/sheet"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { Input } from "@/shared/components/ui/input"
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
FieldLabel,
|
||||||
|
FieldError,
|
||||||
|
} from "@/shared/components/ui/field"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/shared/components/ui/dropdown-menu"
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/shared/components/ui/tooltip"
|
||||||
|
import { cn } from "@/shared/lib/utils"
|
||||||
|
|
||||||
|
// ── Types ──
|
||||||
|
|
||||||
|
type QuickRemark = {
|
||||||
|
id: number
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type QuickRemarksPage = {
|
||||||
|
data: QuickRemark[]
|
||||||
|
meta: {
|
||||||
|
current_page: number
|
||||||
|
last_page: number
|
||||||
|
per_page: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type RhfCustomerRemarksFieldProps<
|
||||||
|
TValues extends FieldValues,
|
||||||
|
TName extends FieldPath<TValues>,
|
||||||
|
> = {
|
||||||
|
name: TName
|
||||||
|
label?: string
|
||||||
|
description?: string
|
||||||
|
required?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
placeholder?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ──
|
||||||
|
|
||||||
|
function extractPage(response: unknown): QuickRemarksPage {
|
||||||
|
const r = response as any
|
||||||
|
return {
|
||||||
|
data: Array.isArray(r?.data?.data)
|
||||||
|
? r.data.data
|
||||||
|
: Array.isArray(r?.data)
|
||||||
|
? r.data
|
||||||
|
: [],
|
||||||
|
meta: r?.data?.meta ?? r?.meta ?? {
|
||||||
|
current_page: 1,
|
||||||
|
last_page: 1,
|
||||||
|
per_page: 15,
|
||||||
|
total: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── QuickRemarksSheet ──
|
||||||
|
|
||||||
|
function QuickRemarksSheet({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
selected,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (v: boolean) => void
|
||||||
|
selected: string[]
|
||||||
|
onToggle: (description: string) => void
|
||||||
|
}) {
|
||||||
|
const api = useAuthApi()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [search, setSearch] = useState("")
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
const [newDescription, setNewDescription] = useState("")
|
||||||
|
const [editingId, setEditingId] = useState<number | null>(null)
|
||||||
|
const [editingText, setEditingText] = useState("")
|
||||||
|
|
||||||
|
const queryKey = [QUICK_REMARK_ROUTES.INDEX, { page, search }]
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery<QuickRemarksPage>({
|
||||||
|
queryKey,
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await api.quickRemarks.list({
|
||||||
|
page,
|
||||||
|
...(search ? { search } : {}),
|
||||||
|
})
|
||||||
|
return extractPage(res)
|
||||||
|
},
|
||||||
|
enabled: open,
|
||||||
|
staleTime: 30_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const remarks = data?.data ?? []
|
||||||
|
const meta = data?.meta
|
||||||
|
|
||||||
|
const invalidate = () =>
|
||||||
|
queryClient.invalidateQueries({ queryKey: [QUICK_REMARK_ROUTES.INDEX] })
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (description: string) =>
|
||||||
|
api.quickRemarks.create({ description }),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidate()
|
||||||
|
setCreating(false)
|
||||||
|
setNewDescription("")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ id, description }: { id: number; description: string }) =>
|
||||||
|
api.quickRemarks.update(String(id), { description }),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidate()
|
||||||
|
setEditingId(null)
|
||||||
|
setEditingText("")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: number) =>
|
||||||
|
api.quickRemarks.destroy(String(id)),
|
||||||
|
onSuccess: () => invalidate(),
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleCreate() {
|
||||||
|
const text = newDescription.trim()
|
||||||
|
if (!text) return
|
||||||
|
createMutation.mutate(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUpdate(id: number) {
|
||||||
|
const text = editingText.trim()
|
||||||
|
if (!text) return
|
||||||
|
updateMutation.mutate({ id, description: text })
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(remark: QuickRemark) {
|
||||||
|
setEditingId(remark.id)
|
||||||
|
setEditingText(remark.description)
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalPages = meta?.last_page ?? 1
|
||||||
|
const from = meta ? (meta.current_page - 1) * meta.per_page + 1 : 0
|
||||||
|
const to = meta ? Math.min(meta.current_page * meta.per_page, meta.total) : 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
|
<SheetContent side="right" className="flex w-105 flex-col gap-0 p-0 sm:w-120">
|
||||||
|
{/* Header */}
|
||||||
|
<SheetHeader className="flex flex-row items-center justify-between border-b px-4 py-3">
|
||||||
|
<SheetTitle className="text-base font-semibold">Quick Remarks</SheetTitle>
|
||||||
|
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="border-b px-3 py-2 flex gap-1 items-center">
|
||||||
|
<div className="relative grow">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearch(e.target.value)
|
||||||
|
setPage(1)
|
||||||
|
}}
|
||||||
|
className="pl-9 h-9"
|
||||||
|
/>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setCreating(true)
|
||||||
|
setEditingId(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Inline create form */}
|
||||||
|
{creating && (
|
||||||
|
<div className="border-b px-3 py-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
placeholder="New quick remark..."
|
||||||
|
value={newDescription}
|
||||||
|
onChange={(e) => setNewDescription(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") { e.preventDefault(); handleCreate() }
|
||||||
|
if (e.key === "Escape") { setCreating(false); setNewDescription("") }
|
||||||
|
}}
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
disabled={createMutation.isPending || !newDescription.trim()}
|
||||||
|
onClick={handleCreate}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => { setCreating(false); setNewDescription("") }}
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* List */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
) : remarks.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
|
||||||
|
No quick remarks found.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
remarks.map((remark) => {
|
||||||
|
const isSelected = selected.includes(remark.description)
|
||||||
|
const isEditing = editingId === remark.id
|
||||||
|
|
||||||
|
if (isEditing) {
|
||||||
|
return (
|
||||||
|
<div key={remark.id} className="flex items-center gap-2 border-b px-4 py-2">
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
value={editingText}
|
||||||
|
onChange={(e) => setEditingText(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") { e.preventDefault(); handleUpdate(remark.id) }
|
||||||
|
if (e.key === "Escape") { setEditingId(null); setEditingText("") }
|
||||||
|
}}
|
||||||
|
className="h-8 flex-1 text-sm"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
disabled={updateMutation.isPending || !editingText.trim()}
|
||||||
|
onClick={() => handleUpdate(remark.id)}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => { setEditingId(null); setEditingText("") }}
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={remark.id}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-pointer items-center justify-between border-b px-4 py-3 transition-colors hover:bg-muted/50",
|
||||||
|
isSelected && "bg-primary/5",
|
||||||
|
)}
|
||||||
|
onClick={() => onToggle(remark.description)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault()
|
||||||
|
onToggle(remark.description)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||||
|
<div className={cn(
|
||||||
|
"flex h-4 w-4 shrink-0 items-center justify-center",
|
||||||
|
)}>
|
||||||
|
{isSelected && (
|
||||||
|
<Check className="h-4 w-4 text-primary" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="truncate text-sm">{remark.description}</span>
|
||||||
|
</div>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 shrink-0"
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Actions</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
startEdit(remark)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Edit2 className="mr-2 h-3.5 w-3.5" />
|
||||||
|
Edit
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
deleteMutation.mutate(remark.id)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-3.5 w-3.5" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{meta && meta.total > 0 && (
|
||||||
|
<div className="flex items-center justify-between border-t px-4 py-2 text-sm text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
{from}–{to} of {meta.total}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => setPage(1)}
|
||||||
|
>
|
||||||
|
<ChevronFirst className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => setPage((p) => p - 1)}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() => setPage(totalPages)}
|
||||||
|
>
|
||||||
|
<ChevronLast className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main Component ──
|
||||||
|
|
||||||
|
export function RhfCustomerRemarksField<
|
||||||
|
TValues extends FieldValues,
|
||||||
|
TName extends FieldPath<TValues>,
|
||||||
|
>({
|
||||||
|
name,
|
||||||
|
label = "Customer Remark",
|
||||||
|
description,
|
||||||
|
required,
|
||||||
|
disabled,
|
||||||
|
placeholder = "Enter remark...",
|
||||||
|
}: RhfCustomerRemarksFieldProps<TValues, TName>) {
|
||||||
|
const { control } = useFormContext<TValues>()
|
||||||
|
const { field, fieldState: { error } } = useController({ name, control, disabled })
|
||||||
|
|
||||||
|
const [sheetOpen, setSheetOpen] = useState(false)
|
||||||
|
|
||||||
|
const remarks: string[] = Array.isArray(field.value) ? field.value : []
|
||||||
|
|
||||||
|
function updateAt(index: number, value: string) {
|
||||||
|
const next = [...remarks]
|
||||||
|
next[index] = value
|
||||||
|
field.onChange(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLine() {
|
||||||
|
field.onChange([...remarks, ""])
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeLine(index: number) {
|
||||||
|
field.onChange(remarks.filter((_, i) => i !== index))
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleQuickRemark(description: string) {
|
||||||
|
const exists = remarks.includes(description)
|
||||||
|
if (exists) {
|
||||||
|
field.onChange(remarks.filter((r) => r !== description))
|
||||||
|
} else {
|
||||||
|
field.onChange([...remarks, description])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Field data-invalid={!!error || undefined}>
|
||||||
|
{label && (
|
||||||
|
<FieldLabel>
|
||||||
|
{label}
|
||||||
|
{required && (
|
||||||
|
<span className="text-destructive ms-0.5">*</span>
|
||||||
|
)}
|
||||||
|
</FieldLabel>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Repeater rows */}
|
||||||
|
<div className="flex flex-col rounded-md border divide-y overflow-hidden">
|
||||||
|
{remarks.map((remark, index) => (
|
||||||
|
<div key={index} className="flex items-center gap-2 px-3 py-2 bg-background">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={remark}
|
||||||
|
onChange={(e) => updateAt(index, e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
disabled={disabled}
|
||||||
|
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => removeLine(index)}
|
||||||
|
className="shrink-0 rounded-full p-0.5 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-40"
|
||||||
|
aria-label="Remove remark"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{remarks.length === 0 && (
|
||||||
|
<div className="px-3 py-2 text-sm text-muted-foreground italic">
|
||||||
|
No remarks added yet.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer row */}
|
||||||
|
<div className="flex items-center justify-between px-3 py-2 bg-muted/30">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={addLine}
|
||||||
|
className="h-7 gap-1.5 text-xs text-primary hover:text-primary"
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
New Line
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="default"
|
||||||
|
size="icon"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => setSheetOpen(true)}
|
||||||
|
className="h-8 w-8 rounded-full"
|
||||||
|
aria-label="Pick from Quick Remarks"
|
||||||
|
>
|
||||||
|
<RefreshCcw className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="left">Quick Remarks</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <FieldError>{error.message}</FieldError>}
|
||||||
|
{description && !error && (
|
||||||
|
<p className="text-xs text-muted-foreground">{description}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<QuickRemarksSheet
|
||||||
|
open={sheetOpen}
|
||||||
|
onOpenChange={setSheetOpen}
|
||||||
|
selected={remarks}
|
||||||
|
onToggle={toggleQuickRemark}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)
|
||||||
|
}
|
||||||
548
apps/dashboard/modules/estimates/rhf-quick-notes-field.tsx
Normal file
548
apps/dashboard/modules/estimates/rhf-quick-notes-field.tsx
Normal file
@ -0,0 +1,548 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import {
|
||||||
|
useFormContext,
|
||||||
|
useController,
|
||||||
|
type FieldValues,
|
||||||
|
type FieldPath,
|
||||||
|
} from "react-hook-form"
|
||||||
|
import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query"
|
||||||
|
import {
|
||||||
|
Check,
|
||||||
|
ChevronFirst,
|
||||||
|
ChevronLast,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Edit2,
|
||||||
|
MoreHorizontal,
|
||||||
|
Plus,
|
||||||
|
RefreshCcw,
|
||||||
|
Search,
|
||||||
|
Trash2,
|
||||||
|
X,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
import { useAuthApi } from "@/shared/useApi"
|
||||||
|
import { QUICK_NOTE_ROUTES } from "@garage/api"
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
} from "@/shared/components/ui/sheet"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { Input } from "@/shared/components/ui/input"
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
FieldLabel,
|
||||||
|
FieldError,
|
||||||
|
} from "@/shared/components/ui/field"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/shared/components/ui/dropdown-menu"
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/shared/components/ui/tooltip"
|
||||||
|
import { cn } from "@/shared/lib/utils"
|
||||||
|
|
||||||
|
type QuickNote = {
|
||||||
|
id: number
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type QuickNotesPage = {
|
||||||
|
data: QuickNote[]
|
||||||
|
meta: {
|
||||||
|
current_page: number
|
||||||
|
last_page: number
|
||||||
|
per_page: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type RhfQuickNotesFieldProps<
|
||||||
|
TValues extends FieldValues,
|
||||||
|
TName extends FieldPath<TValues>,
|
||||||
|
> = {
|
||||||
|
name: TName
|
||||||
|
label?: string
|
||||||
|
description?: string
|
||||||
|
required?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
placeholder?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractPage(response: unknown): QuickNotesPage {
|
||||||
|
const r = response as any
|
||||||
|
return {
|
||||||
|
data: Array.isArray(r?.data?.data)
|
||||||
|
? r.data.data
|
||||||
|
: Array.isArray(r?.data)
|
||||||
|
? r.data
|
||||||
|
: [],
|
||||||
|
meta: r?.data?.meta ?? r?.meta ?? {
|
||||||
|
current_page: 1,
|
||||||
|
last_page: 1,
|
||||||
|
per_page: 15,
|
||||||
|
total: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function QuickNotesSheet({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
selected,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (v: boolean) => void
|
||||||
|
selected: string[]
|
||||||
|
onToggle: (description: string) => void
|
||||||
|
}) {
|
||||||
|
const api = useAuthApi()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [search, setSearch] = useState("")
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
const [newDescription, setNewDescription] = useState("")
|
||||||
|
const [editingId, setEditingId] = useState<number | null>(null)
|
||||||
|
const [editingText, setEditingText] = useState("")
|
||||||
|
|
||||||
|
const queryKey = [QUICK_NOTE_ROUTES.INDEX, { page, search }]
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery<QuickNotesPage>({
|
||||||
|
queryKey,
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await api.quickNotes.list({
|
||||||
|
page,
|
||||||
|
...(search ? { search } : {}),
|
||||||
|
})
|
||||||
|
return extractPage(res)
|
||||||
|
},
|
||||||
|
enabled: open,
|
||||||
|
staleTime: 30_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const notes = data?.data ?? []
|
||||||
|
const meta = data?.meta
|
||||||
|
|
||||||
|
const invalidate = () =>
|
||||||
|
queryClient.invalidateQueries({ queryKey: [QUICK_NOTE_ROUTES.INDEX] })
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (description: string) =>
|
||||||
|
api.quickNotes.create({ description }),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidate()
|
||||||
|
setCreating(false)
|
||||||
|
setNewDescription("")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ id, description }: { id: number; description: string }) =>
|
||||||
|
api.quickNotes.update(String(id), { description }),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidate()
|
||||||
|
setEditingId(null)
|
||||||
|
setEditingText("")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: number) =>
|
||||||
|
api.quickNotes.destroy(String(id)),
|
||||||
|
onSuccess: () => invalidate(),
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleCreate() {
|
||||||
|
const text = newDescription.trim()
|
||||||
|
if (!text) return
|
||||||
|
createMutation.mutate(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUpdate(id: number) {
|
||||||
|
const text = editingText.trim()
|
||||||
|
if (!text) return
|
||||||
|
updateMutation.mutate({ id, description: text })
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(note: QuickNote) {
|
||||||
|
setEditingId(note.id)
|
||||||
|
setEditingText(note.description)
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalPages = meta?.last_page ?? 1
|
||||||
|
const from = meta ? (meta.current_page - 1) * meta.per_page + 1 : 0
|
||||||
|
const to = meta ? Math.min(meta.current_page * meta.per_page, meta.total) : 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
|
<SheetContent side="right" className="flex w-105 flex-col gap-0 p-0 sm:w-120">
|
||||||
|
<SheetHeader className="flex flex-row items-center justify-between border-b px-4 py-3">
|
||||||
|
<SheetTitle className="text-base font-semibold">Quick Notes</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<div className="border-b px-3 py-2 flex gap-1 items-center">
|
||||||
|
<div className="relative grow">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearch(e.target.value)
|
||||||
|
setPage(1)
|
||||||
|
}}
|
||||||
|
className="pl-9 h-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setCreating(true)
|
||||||
|
setEditingId(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{creating && (
|
||||||
|
<div className="border-b px-3 py-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
placeholder="New quick note..."
|
||||||
|
value={newDescription}
|
||||||
|
onChange={(e) => setNewDescription(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") { e.preventDefault(); handleCreate() }
|
||||||
|
if (e.key === "Escape") { setCreating(false); setNewDescription("") }
|
||||||
|
}}
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
disabled={createMutation.isPending || !newDescription.trim()}
|
||||||
|
onClick={handleCreate}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => { setCreating(false); setNewDescription("") }}
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
) : notes.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
|
||||||
|
No quick notes found.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
notes.map((note) => {
|
||||||
|
const isSelected = selected.includes(note.description)
|
||||||
|
const isEditing = editingId === note.id
|
||||||
|
|
||||||
|
if (isEditing) {
|
||||||
|
return (
|
||||||
|
<div key={note.id} className="flex items-center gap-2 border-b px-4 py-2">
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
value={editingText}
|
||||||
|
onChange={(e) => setEditingText(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") { e.preventDefault(); handleUpdate(note.id) }
|
||||||
|
if (e.key === "Escape") { setEditingId(null); setEditingText("") }
|
||||||
|
}}
|
||||||
|
className="h-8 flex-1 text-sm"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
disabled={updateMutation.isPending || !editingText.trim()}
|
||||||
|
onClick={() => handleUpdate(note.id)}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => { setEditingId(null); setEditingText("") }}
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={note.id}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-pointer items-center justify-between border-b px-4 py-3 transition-colors hover:bg-muted/50",
|
||||||
|
isSelected && "bg-primary/5",
|
||||||
|
)}
|
||||||
|
onClick={() => onToggle(note.description)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault()
|
||||||
|
onToggle(note.description)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||||
|
<div className={cn(
|
||||||
|
"flex h-4 w-4 shrink-0 items-center justify-center",
|
||||||
|
)}>
|
||||||
|
{isSelected && (
|
||||||
|
<Check className="h-4 w-4 text-primary" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="truncate text-sm">{note.description}</span>
|
||||||
|
</div>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 shrink-0"
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Actions</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
startEdit(note)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Edit2 className="mr-2 h-3.5 w-3.5" />
|
||||||
|
Edit
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
deleteMutation.mutate(note.id)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-3.5 w-3.5" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{meta && meta.total > 0 && (
|
||||||
|
<div className="flex items-center justify-between border-t px-4 py-2 text-sm text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
{from}-{to} of {meta.total}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => setPage(1)}
|
||||||
|
>
|
||||||
|
<ChevronFirst className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => setPage((p) => p - 1)}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() => setPage(totalPages)}
|
||||||
|
>
|
||||||
|
<ChevronLast className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RhfQuickNotesField<
|
||||||
|
TValues extends FieldValues,
|
||||||
|
TName extends FieldPath<TValues>,
|
||||||
|
>({
|
||||||
|
name,
|
||||||
|
label = "Quick Notes",
|
||||||
|
description,
|
||||||
|
required,
|
||||||
|
disabled,
|
||||||
|
placeholder = "Enter note...",
|
||||||
|
}: RhfQuickNotesFieldProps<TValues, TName>) {
|
||||||
|
const { control } = useFormContext<TValues>()
|
||||||
|
const { field, fieldState: { error } } = useController({ name, control, disabled })
|
||||||
|
|
||||||
|
const [sheetOpen, setSheetOpen] = useState(false)
|
||||||
|
|
||||||
|
const notes: string[] = Array.isArray(field.value) ? field.value : []
|
||||||
|
|
||||||
|
function updateAt(index: number, value: string) {
|
||||||
|
const next = [...notes]
|
||||||
|
next[index] = value
|
||||||
|
field.onChange(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLine() {
|
||||||
|
field.onChange([...notes, ""])
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeLine(index: number) {
|
||||||
|
field.onChange(notes.filter((_, i) => i !== index))
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleQuickNote(description: string) {
|
||||||
|
const exists = notes.includes(description)
|
||||||
|
if (exists) {
|
||||||
|
field.onChange(notes.filter((r) => r !== description))
|
||||||
|
} else {
|
||||||
|
field.onChange([...notes, description])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Field data-invalid={!!error || undefined}>
|
||||||
|
{label && (
|
||||||
|
<FieldLabel>
|
||||||
|
{label}
|
||||||
|
{required && (
|
||||||
|
<span className="text-destructive ms-0.5">*</span>
|
||||||
|
)}
|
||||||
|
</FieldLabel>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col rounded-md border divide-y overflow-hidden">
|
||||||
|
{notes.map((note, index) => (
|
||||||
|
<div key={index} className="flex items-center gap-2 px-3 py-2 bg-background">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={note}
|
||||||
|
onChange={(e) => updateAt(index, e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
disabled={disabled}
|
||||||
|
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => removeLine(index)}
|
||||||
|
className="shrink-0 rounded-full p-0.5 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-40"
|
||||||
|
aria-label="Remove note"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{notes.length === 0 && (
|
||||||
|
<div className="px-3 py-2 text-sm text-muted-foreground italic">
|
||||||
|
No notes added yet.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between px-3 py-2 bg-muted/30">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={addLine}
|
||||||
|
className="h-7 gap-1.5 text-xs text-primary hover:text-primary"
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
New Line
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="default"
|
||||||
|
size="icon"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => setSheetOpen(true)}
|
||||||
|
className="h-8 w-8 rounded-full"
|
||||||
|
aria-label="Pick from Quick Notes"
|
||||||
|
>
|
||||||
|
<RefreshCcw className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="left">Quick Notes</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <FieldError>{error.message}</FieldError>}
|
||||||
|
{description && !error && (
|
||||||
|
<p className="text-xs text-muted-foreground">{description}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<QuickNotesSheet
|
||||||
|
open={sheetOpen}
|
||||||
|
onOpenChange={setSheetOpen}
|
||||||
|
selected={notes}
|
||||||
|
onToggle={toggleQuickNote}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,548 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import {
|
||||||
|
useFormContext,
|
||||||
|
useController,
|
||||||
|
type FieldValues,
|
||||||
|
type FieldPath,
|
||||||
|
} from "react-hook-form"
|
||||||
|
import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query"
|
||||||
|
import {
|
||||||
|
Check,
|
||||||
|
ChevronFirst,
|
||||||
|
ChevronLast,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Edit2,
|
||||||
|
MoreHorizontal,
|
||||||
|
Plus,
|
||||||
|
RefreshCcw,
|
||||||
|
Search,
|
||||||
|
Trash2,
|
||||||
|
X,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
import { useAuthApi } from "@/shared/useApi"
|
||||||
|
import { SHOP_RECOMMENDATION_ROUTES } from "@garage/api"
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
} from "@/shared/components/ui/sheet"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { Input } from "@/shared/components/ui/input"
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
FieldLabel,
|
||||||
|
FieldError,
|
||||||
|
} from "@/shared/components/ui/field"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/shared/components/ui/dropdown-menu"
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/shared/components/ui/tooltip"
|
||||||
|
import { cn } from "@/shared/lib/utils"
|
||||||
|
|
||||||
|
type ShopRecommendation = {
|
||||||
|
id: number
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ShopRecommendationsPage = {
|
||||||
|
data: ShopRecommendation[]
|
||||||
|
meta: {
|
||||||
|
current_page: number
|
||||||
|
last_page: number
|
||||||
|
per_page: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type RhfShopRecommendationsFieldProps<
|
||||||
|
TValues extends FieldValues,
|
||||||
|
TName extends FieldPath<TValues>,
|
||||||
|
> = {
|
||||||
|
name: TName
|
||||||
|
label?: string
|
||||||
|
description?: string
|
||||||
|
required?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
placeholder?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractPage(response: unknown): ShopRecommendationsPage {
|
||||||
|
const r = response as any
|
||||||
|
return {
|
||||||
|
data: Array.isArray(r?.data?.data)
|
||||||
|
? r.data.data
|
||||||
|
: Array.isArray(r?.data)
|
||||||
|
? r.data
|
||||||
|
: [],
|
||||||
|
meta: r?.data?.meta ?? r?.meta ?? {
|
||||||
|
current_page: 1,
|
||||||
|
last_page: 1,
|
||||||
|
per_page: 15,
|
||||||
|
total: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ShopRecommendationsSheet({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
selected,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (v: boolean) => void
|
||||||
|
selected: string[]
|
||||||
|
onToggle: (description: string) => void
|
||||||
|
}) {
|
||||||
|
const api = useAuthApi()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [search, setSearch] = useState("")
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
const [newDescription, setNewDescription] = useState("")
|
||||||
|
const [editingId, setEditingId] = useState<number | null>(null)
|
||||||
|
const [editingText, setEditingText] = useState("")
|
||||||
|
|
||||||
|
const queryKey = [SHOP_RECOMMENDATION_ROUTES.INDEX, { page, search }]
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery<ShopRecommendationsPage>({
|
||||||
|
queryKey,
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await api.shopRecommendations.list({
|
||||||
|
page,
|
||||||
|
...(search ? { search } : {}),
|
||||||
|
})
|
||||||
|
return extractPage(res)
|
||||||
|
},
|
||||||
|
enabled: open,
|
||||||
|
staleTime: 30_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const recommendations = data?.data ?? []
|
||||||
|
const meta = data?.meta
|
||||||
|
|
||||||
|
const invalidate = () =>
|
||||||
|
queryClient.invalidateQueries({ queryKey: [SHOP_RECOMMENDATION_ROUTES.INDEX] })
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (description: string) =>
|
||||||
|
api.shopRecommendations.create({ description }),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidate()
|
||||||
|
setCreating(false)
|
||||||
|
setNewDescription("")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ id, description }: { id: number; description: string }) =>
|
||||||
|
api.shopRecommendations.update(String(id), { description }),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidate()
|
||||||
|
setEditingId(null)
|
||||||
|
setEditingText("")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: number) =>
|
||||||
|
api.shopRecommendations.destroy(String(id)),
|
||||||
|
onSuccess: () => invalidate(),
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleCreate() {
|
||||||
|
const text = newDescription.trim()
|
||||||
|
if (!text) return
|
||||||
|
createMutation.mutate(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUpdate(id: number) {
|
||||||
|
const text = editingText.trim()
|
||||||
|
if (!text) return
|
||||||
|
updateMutation.mutate({ id, description: text })
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(recommendation: ShopRecommendation) {
|
||||||
|
setEditingId(recommendation.id)
|
||||||
|
setEditingText(recommendation.description)
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalPages = meta?.last_page ?? 1
|
||||||
|
const from = meta ? (meta.current_page - 1) * meta.per_page + 1 : 0
|
||||||
|
const to = meta ? Math.min(meta.current_page * meta.per_page, meta.total) : 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
|
<SheetContent side="right" className="flex w-105 flex-col gap-0 p-0 sm:w-120">
|
||||||
|
<SheetHeader className="flex flex-row items-center justify-between border-b px-4 py-3">
|
||||||
|
<SheetTitle className="text-base font-semibold">Shop Recommendations</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<div className="border-b px-3 py-2 flex gap-1 items-center">
|
||||||
|
<div className="relative grow">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearch(e.target.value)
|
||||||
|
setPage(1)
|
||||||
|
}}
|
||||||
|
className="pl-9 h-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setCreating(true)
|
||||||
|
setEditingId(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{creating && (
|
||||||
|
<div className="border-b px-3 py-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
placeholder="New shop recommendation..."
|
||||||
|
value={newDescription}
|
||||||
|
onChange={(e) => setNewDescription(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") { e.preventDefault(); handleCreate() }
|
||||||
|
if (e.key === "Escape") { setCreating(false); setNewDescription("") }
|
||||||
|
}}
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
disabled={createMutation.isPending || !newDescription.trim()}
|
||||||
|
onClick={handleCreate}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => { setCreating(false); setNewDescription("") }}
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
) : recommendations.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
|
||||||
|
No shop recommendations found.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
recommendations.map((recommendation) => {
|
||||||
|
const isSelected = selected.includes(recommendation.description)
|
||||||
|
const isEditing = editingId === recommendation.id
|
||||||
|
|
||||||
|
if (isEditing) {
|
||||||
|
return (
|
||||||
|
<div key={recommendation.id} className="flex items-center gap-2 border-b px-4 py-2">
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
value={editingText}
|
||||||
|
onChange={(e) => setEditingText(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") { e.preventDefault(); handleUpdate(recommendation.id) }
|
||||||
|
if (e.key === "Escape") { setEditingId(null); setEditingText("") }
|
||||||
|
}}
|
||||||
|
className="h-8 flex-1 text-sm"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
disabled={updateMutation.isPending || !editingText.trim()}
|
||||||
|
onClick={() => handleUpdate(recommendation.id)}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => { setEditingId(null); setEditingText("") }}
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={recommendation.id}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-pointer items-center justify-between border-b px-4 py-3 transition-colors hover:bg-muted/50",
|
||||||
|
isSelected && "bg-primary/5",
|
||||||
|
)}
|
||||||
|
onClick={() => onToggle(recommendation.description)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault()
|
||||||
|
onToggle(recommendation.description)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||||
|
<div className={cn(
|
||||||
|
"flex h-4 w-4 shrink-0 items-center justify-center",
|
||||||
|
)}>
|
||||||
|
{isSelected && (
|
||||||
|
<Check className="h-4 w-4 text-primary" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="truncate text-sm">{recommendation.description}</span>
|
||||||
|
</div>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 shrink-0"
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Actions</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
startEdit(recommendation)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Edit2 className="mr-2 h-3.5 w-3.5" />
|
||||||
|
Edit
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
deleteMutation.mutate(recommendation.id)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-3.5 w-3.5" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{meta && meta.total > 0 && (
|
||||||
|
<div className="flex items-center justify-between border-t px-4 py-2 text-sm text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
{from}-{to} of {meta.total}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => setPage(1)}
|
||||||
|
>
|
||||||
|
<ChevronFirst className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => setPage((p) => p - 1)}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() => setPage(totalPages)}
|
||||||
|
>
|
||||||
|
<ChevronLast className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RhfShopRecommendationsField<
|
||||||
|
TValues extends FieldValues,
|
||||||
|
TName extends FieldPath<TValues>,
|
||||||
|
>({
|
||||||
|
name,
|
||||||
|
label = "Shop Recommendations",
|
||||||
|
description,
|
||||||
|
required,
|
||||||
|
disabled,
|
||||||
|
placeholder = "Enter recommendation...",
|
||||||
|
}: RhfShopRecommendationsFieldProps<TValues, TName>) {
|
||||||
|
const { control } = useFormContext<TValues>()
|
||||||
|
const { field, fieldState: { error } } = useController({ name, control, disabled })
|
||||||
|
|
||||||
|
const [sheetOpen, setSheetOpen] = useState(false)
|
||||||
|
|
||||||
|
const recommendations: string[] = Array.isArray(field.value) ? field.value : []
|
||||||
|
|
||||||
|
function updateAt(index: number, value: string) {
|
||||||
|
const next = [...recommendations]
|
||||||
|
next[index] = value
|
||||||
|
field.onChange(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLine() {
|
||||||
|
field.onChange([...recommendations, ""])
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeLine(index: number) {
|
||||||
|
field.onChange(recommendations.filter((_, i) => i !== index))
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleRecommendation(description: string) {
|
||||||
|
const exists = recommendations.includes(description)
|
||||||
|
if (exists) {
|
||||||
|
field.onChange(recommendations.filter((r) => r !== description))
|
||||||
|
} else {
|
||||||
|
field.onChange([...recommendations, description])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Field data-invalid={!!error || undefined}>
|
||||||
|
{label && (
|
||||||
|
<FieldLabel>
|
||||||
|
{label}
|
||||||
|
{required && (
|
||||||
|
<span className="text-destructive ms-0.5">*</span>
|
||||||
|
)}
|
||||||
|
</FieldLabel>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col rounded-md border divide-y overflow-hidden">
|
||||||
|
{recommendations.map((recommendation, index) => (
|
||||||
|
<div key={index} className="flex items-center gap-2 px-3 py-2 bg-background">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={recommendation}
|
||||||
|
onChange={(e) => updateAt(index, e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
disabled={disabled}
|
||||||
|
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => removeLine(index)}
|
||||||
|
className="shrink-0 rounded-full p-0.5 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-40"
|
||||||
|
aria-label="Remove recommendation"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{recommendations.length === 0 && (
|
||||||
|
<div className="px-3 py-2 text-sm text-muted-foreground italic">
|
||||||
|
No recommendations added yet.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between px-3 py-2 bg-muted/30">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={addLine}
|
||||||
|
className="h-7 gap-1.5 text-xs text-primary hover:text-primary"
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
New Line
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="default"
|
||||||
|
size="icon"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => setSheetOpen(true)}
|
||||||
|
className="h-8 w-8 rounded-full"
|
||||||
|
aria-label="Pick from Shop Recommendations"
|
||||||
|
>
|
||||||
|
<RefreshCcw className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="left">Shop Recommendations</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <FieldError>{error.message}</FieldError>}
|
||||||
|
{description && !error && (
|
||||||
|
<p className="text-xs text-muted-foreground">{description}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ShopRecommendationsSheet
|
||||||
|
open={sheetOpen}
|
||||||
|
onOpenChange={setSheetOpen}
|
||||||
|
selected={recommendations}
|
||||||
|
onToggle={toggleRecommendation}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -23,6 +23,7 @@ import {
|
|||||||
type ExpenseFormValues,
|
type ExpenseFormValues,
|
||||||
} from "./expense.schema"
|
} from "./expense.schema"
|
||||||
import { EXPENSE_ROUTES, JOB_CARD_ROUTES, VENDOR_ROUTES, DEPARTMENT_ROUTES } from "@garage/api"
|
import { EXPENSE_ROUTES, JOB_CARD_ROUTES, VENDOR_ROUTES, DEPARTMENT_ROUTES } from "@garage/api"
|
||||||
|
import { getFullName } from "@/shared/utils/getFullName"
|
||||||
|
|
||||||
// ── Constants ──
|
// ── Constants ──
|
||||||
|
|
||||||
@ -158,7 +159,7 @@ export function ExpenseForm({ resourceId, initialData, onSuccess }: ExpenseFormP
|
|||||||
placeholder="Select vendor"
|
placeholder="Select vendor"
|
||||||
queryKey={[VENDOR_ROUTES.INDEX]}
|
queryKey={[VENDOR_ROUTES.INDEX]}
|
||||||
listFn={() => api.vendors.list()}
|
listFn={() => api.vendors.list()}
|
||||||
mapOption={mapLookupOption}
|
mapOption={(op: any) => ({ value: String(op.id), label: getFullName(op)})}
|
||||||
{...STORE_OBJECT}
|
{...STORE_OBJECT}
|
||||||
/>
|
/>
|
||||||
<RhfAsyncSelectField
|
<RhfAsyncSelectField
|
||||||
|
|||||||
@ -0,0 +1,33 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { CrudDialog } from "@/shared/components/crud-dialog"
|
||||||
|
import { ColumnHeader } from "@/shared/data-view/table-view"
|
||||||
|
import { useAuthApi } from "@/shared/useApi"
|
||||||
|
import { INSURANCE_TYPE_ROUTES } from "@garage/api"
|
||||||
|
import { InsuranceTypeForm } from "./insurance-type-form"
|
||||||
|
|
||||||
|
export function InsuranceTypeCrudDialog() {
|
||||||
|
const api = useAuthApi()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CrudDialog
|
||||||
|
title="Insurance Type"
|
||||||
|
queryKey={[INSURANCE_TYPE_ROUTES.INDEX]}
|
||||||
|
getClient={() => api.insuranceTypes}
|
||||||
|
resourceLabel="insurance type"
|
||||||
|
columns={() => [
|
||||||
|
{
|
||||||
|
accessorKey: "name",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Name" />,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
renderForm={({ resourceId, initialData, onSuccess }) => (
|
||||||
|
<InsuranceTypeForm
|
||||||
|
resourceId={resourceId}
|
||||||
|
initialData={initialData}
|
||||||
|
onSuccess={onSuccess}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
87
apps/dashboard/modules/job-cards/insurance-type-form.tsx
Normal file
87
apps/dashboard/modules/job-cards/insurance-type-form.tsx
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { z } from "zod"
|
||||||
|
import { useForm } from "react-hook-form"
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import { Plus, Save } from "lucide-react"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { FieldGroup } from "@/shared/components/ui/field"
|
||||||
|
import { Rhform, RhfTextField } from "@/shared/components/form"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { useAuthApi } from "@/shared/useApi"
|
||||||
|
import { useEffect } from "react"
|
||||||
|
|
||||||
|
// ── Schema ──
|
||||||
|
|
||||||
|
const insuranceTypeSchema = z.object({
|
||||||
|
name: z.string().min(1, "Name is required"),
|
||||||
|
})
|
||||||
|
|
||||||
|
type InsuranceTypeFormValues = z.infer<typeof insuranceTypeSchema>
|
||||||
|
|
||||||
|
// ── Props ──
|
||||||
|
|
||||||
|
type InsuranceTypeFormProps = {
|
||||||
|
resourceId?: string | null
|
||||||
|
initialData?: any
|
||||||
|
onSuccess?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component ──
|
||||||
|
|
||||||
|
export function InsuranceTypeForm({ resourceId, initialData, onSuccess }: InsuranceTypeFormProps) {
|
||||||
|
const api = useAuthApi()
|
||||||
|
const isEditing = !!resourceId
|
||||||
|
|
||||||
|
const form = useForm<InsuranceTypeFormValues>({
|
||||||
|
resolver: zodResolver(insuranceTypeSchema),
|
||||||
|
defaultValues: { name: "" },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Pre-fill when editing
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialData) {
|
||||||
|
const d = initialData?.data ?? initialData
|
||||||
|
form.reset({ name: d.name ?? "" })
|
||||||
|
}
|
||||||
|
}, [initialData, form])
|
||||||
|
|
||||||
|
const handleSubmit = async (values: InsuranceTypeFormValues) => {
|
||||||
|
try {
|
||||||
|
const promise = isEditing
|
||||||
|
? api.insuranceTypes.update(resourceId!, { title: values.name } as any)
|
||||||
|
: api.insuranceTypes.create({ title: values.name } as any)
|
||||||
|
|
||||||
|
toast.promise(promise, {
|
||||||
|
loading: isEditing ? "Updating..." : "Creating...",
|
||||||
|
success: isEditing ? "Updated successfully" : "Created successfully",
|
||||||
|
error: isEditing ? "Failed to update" : "Failed to create",
|
||||||
|
})
|
||||||
|
|
||||||
|
await promise
|
||||||
|
form.reset()
|
||||||
|
onSuccess?.()
|
||||||
|
} catch {
|
||||||
|
// toast already shown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Rhform form={form} onSubmit={handleSubmit}>
|
||||||
|
<FieldGroup>
|
||||||
|
<RhfTextField
|
||||||
|
name="name"
|
||||||
|
label="Name"
|
||||||
|
placeholder="e.g. Comprehensive"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||||
|
{isEditing ? <Save className="h-4 w-4" /> : <Plus className="h-4 w-4" />}
|
||||||
|
{form.formState.isSubmitting
|
||||||
|
? (isEditing ? "Updating..." : "Creating...")
|
||||||
|
: (isEditing ? "Update" : "Create")}
|
||||||
|
</Button>
|
||||||
|
</FieldGroup>
|
||||||
|
</Rhform>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -27,6 +27,7 @@ type JobCardActionsProps = {
|
|||||||
orderDate?: string | null
|
orderDate?: string | null
|
||||||
serviceWriterName?: string | null
|
serviceWriterName?: string | null
|
||||||
salesPersonName?: string | null
|
salesPersonName?: string | null
|
||||||
|
primaryTechnicianName?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Informative Action Card ──
|
// ── Informative Action Card ──
|
||||||
@ -104,13 +105,13 @@ function EmployeePickerDialog({
|
|||||||
|
|
||||||
// ── Main Component ──
|
// ── Main Component ──
|
||||||
|
|
||||||
export function JobCardActions({ jobCardId, orderDate, serviceWriterName, salesPersonName }: JobCardActionsProps) {
|
export function JobCardActions({ jobCardId, orderDate, serviceWriterName, salesPersonName, primaryTechnicianName }: JobCardActionsProps) {
|
||||||
const api = useAuthApi()
|
const api = useAuthApi()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [datePickerOpen, setDatePickerOpen] = useState(false)
|
const [datePickerOpen, setDatePickerOpen] = useState(false)
|
||||||
const [serviceWriterDialogOpen, setServiceWriterDialogOpen] = useState(false)
|
const [serviceWriterDialogOpen, setServiceWriterDialogOpen] = useState(false)
|
||||||
const [salesPersonDialogOpen, setSalesPersonDialogOpen] = useState(false)
|
const [salesPersonDialogOpen, setSalesPersonDialogOpen] = useState(false)
|
||||||
|
const [primaryTechnicianDialogOpen, setPrimaryTechnicianDialogOpen] = useState(false)
|
||||||
|
|
||||||
const changeDateMutation = useMutation({
|
const changeDateMutation = useMutation({
|
||||||
mutationFn: (date: Date) => {
|
mutationFn: (date: Date) => {
|
||||||
@ -161,6 +162,8 @@ export function JobCardActions({ jobCardId, orderDate, serviceWriterName, salesP
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-stretch gap-2">
|
<div className="flex flex-wrap items-stretch gap-2">
|
||||||
{/* Check-in Date Action Card */}
|
{/* Check-in Date Action Card */}
|
||||||
@ -213,6 +216,19 @@ export function JobCardActions({ jobCardId, orderDate, serviceWriterName, salesP
|
|||||||
isPending={changeSalesPersonMutation.isPending}
|
isPending={changeSalesPersonMutation.isPending}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
{/* Primary Technician Action Card */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-lg"
|
||||||
|
onClick={() => setPrimaryTechnicianDialogOpen(true)}
|
||||||
|
>
|
||||||
|
<ActionCard
|
||||||
|
icon={UserCheck}
|
||||||
|
label="Primary Technician"
|
||||||
|
value={primaryTechnicianName ?? null}
|
||||||
|
isPending={false}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* Edit / Delete Dropdown */}
|
{/* Edit / Delete Dropdown */}
|
||||||
|
|
||||||
@ -234,6 +250,24 @@ export function JobCardActions({ jobCardId, orderDate, serviceWriterName, salesP
|
|||||||
isPending={changeSalesPersonMutation.isPending}
|
isPending={changeSalesPersonMutation.isPending}
|
||||||
onSelect={(id) => changeSalesPersonMutation.mutate(id)}
|
onSelect={(id) => changeSalesPersonMutation.mutate(id)}
|
||||||
/>
|
/>
|
||||||
|
<EmployeePickerDialog
|
||||||
|
open={salesPersonDialogOpen}
|
||||||
|
onOpenChange={setSalesPersonDialogOpen}
|
||||||
|
title="Change Sales Person"
|
||||||
|
description="Search and select an employee to assign as sales person."
|
||||||
|
isPending={changeSalesPersonMutation.isPending}
|
||||||
|
onSelect={(id) => changeSalesPersonMutation.mutate(id)}
|
||||||
|
/>
|
||||||
|
<EmployeePickerDialog
|
||||||
|
open={primaryTechnicianDialogOpen}
|
||||||
|
onOpenChange={setPrimaryTechnicianDialogOpen}
|
||||||
|
title="Change Primary Technician"
|
||||||
|
description="Search and select an employee to assign as primary technician."
|
||||||
|
isPending={false}
|
||||||
|
onSelect={()=>{}}
|
||||||
|
// onSelect={(id) => changePrimaryTechnicianMutation.mutate(id)}
|
||||||
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,16 +2,16 @@
|
|||||||
|
|
||||||
import { createContext, useContext, useState, useCallback } from "react"
|
import { createContext, useContext, useState, useCallback } from "react"
|
||||||
import type { JobCardStatus } from "./job-card.schema"
|
import type { JobCardStatus } from "./job-card.schema"
|
||||||
import { JobCardResponseData } from "@garage/api"
|
import { JobCardShowData } from "../../../../packages/api/src/clients/job-cards"
|
||||||
|
|
||||||
|
|
||||||
const JobCardContext = createContext<JobCardResponseData | null>(null)
|
const JobCardContext = createContext<JobCardShowData | null>(null)
|
||||||
|
|
||||||
export function JobCardProvider({
|
export function JobCardProvider({
|
||||||
jobCard,
|
jobCard,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
jobCard: JobCardResponseData
|
jobCard: JobCardShowData
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
const [status, setStatusState] = useState<JobCardStatus>(jobCard.status as JobCardStatus)
|
const [status, setStatusState] = useState<JobCardStatus>(jobCard.status as JobCardStatus)
|
||||||
@ -21,7 +21,7 @@ export function JobCardProvider({
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<JobCardContext.Provider value={{ ...jobCard, status, setStatus } as JobCardResponseData}>
|
<JobCardContext.Provider value={{ ...jobCard, status, setStatus } as JobCardShowData}>
|
||||||
{children}
|
{children}
|
||||||
</JobCardContext.Provider>
|
</JobCardContext.Provider>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -4,14 +4,16 @@ import { AlertTriangle, Plus, Save } from "lucide-react"
|
|||||||
|
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { Alert, AlertTitle } from "@/shared/components/ui/alert"
|
import { Alert, AlertTitle } from "@/shared/components/ui/alert"
|
||||||
import { FieldGroup } from "@/shared/components/ui/field"
|
|
||||||
import {
|
import {
|
||||||
Rhform,
|
Rhform,
|
||||||
RhfTextField,
|
RhfTextField,
|
||||||
|
RhfTextareaField,
|
||||||
|
RhfCheckboxField,
|
||||||
RhfSelectField,
|
RhfSelectField,
|
||||||
RhfAsyncSelectField,
|
RhfAsyncSelectField,
|
||||||
RhfDateField,
|
RhfDateField,
|
||||||
RhfTimeField,
|
RhfTimeField,
|
||||||
|
RhfAutoGenerateField,
|
||||||
} from "@/shared/components/form"
|
} from "@/shared/components/form"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { useAuthApi } from "@/shared/useApi"
|
import { useAuthApi } from "@/shared/useApi"
|
||||||
@ -22,16 +24,15 @@ import { toRelation, toId } from "@/shared/lib/utils"
|
|||||||
import {
|
import {
|
||||||
jobCardFormSchema,
|
jobCardFormSchema,
|
||||||
type JobCardFormValues,
|
type JobCardFormValues,
|
||||||
TAX_INCLUSIVE_OPTIONS,
|
|
||||||
DISCOUNT_TYPE_OPTIONS,
|
|
||||||
DISCOUNT_AT_OPTIONS,
|
|
||||||
ESTIMATE_TO_OPTIONS,
|
|
||||||
FUEL_LEVEL_OPTIONS,
|
FUEL_LEVEL_OPTIONS,
|
||||||
JOB_CARD_STATUS_OPTIONS,
|
JOB_CARD_STATUS_OPTIONS,
|
||||||
} from "./job-card.schema"
|
} from "./job-card.schema"
|
||||||
import { JOB_CARD_ROUTES, EMPLOYEE_ROUTES, DEPARTMENT_ROUTES } from "@garage/api"
|
import { JOB_CARD_ROUTES, EMPLOYEE_ROUTES, DEPARTMENT_ROUTES, INSURANCE_TYPE_ROUTES } from "@garage/api"
|
||||||
import { RhfCustomerSelectField } from "@/modules/customers/rhf-customer-select-field"
|
import { RhfCustomerSelectField } from "@/modules/customers/rhf-customer-select-field"
|
||||||
import { RhfVehicleSelectField } from "@/modules/vehicles/rhf-vehicle-select-field"
|
import { RhfVehicleSelectField } from "@/modules/vehicles/rhf-vehicle-select-field"
|
||||||
|
import { RhfLabelPickerField } from "@/modules/labels/rhf-label-picker-field"
|
||||||
|
import { RhfCustomerRemarksField } from "@/modules/estimates/rhf-customer-remarks-field"
|
||||||
|
import { InsuranceTypeCrudDialog } from "./insurance-type-crud-dialog"
|
||||||
|
|
||||||
// ── Props ──
|
// ── Props ──
|
||||||
|
|
||||||
@ -49,15 +50,32 @@ const DEFAULT_VALUES: JobCardFormValues = {
|
|||||||
vehicle: null,
|
vehicle: null,
|
||||||
department: null,
|
department: null,
|
||||||
service_writer: null,
|
service_writer: null,
|
||||||
|
primary_technician: null,
|
||||||
|
sales_person: null,
|
||||||
|
insurance_type: null,
|
||||||
|
insurer: null,
|
||||||
|
order_number: "",
|
||||||
|
estimate_number: "",
|
||||||
status: "check_in",
|
status: "check_in",
|
||||||
estimate_to: "Customer",
|
estimate_to: "Customer",
|
||||||
tax_inclusive: "Tax Inclusive",
|
tax_inclusive: "Tax Inclusive",
|
||||||
discount_type: "no",
|
discount_type: "no",
|
||||||
discount_at: "inclusive_of_tax",
|
discount_at: "inclusive_of_tax",
|
||||||
// check_in_date: "",
|
order_date: new Date().toISOString().split("T")[0],
|
||||||
// check_in_time: (() => { const n = new Date(); return `${String(n.getHours()).padStart(2,"0")}:${String(n.getMinutes()).padStart(2,"0")}:${String(n.getSeconds()).padStart(2,"0")}` })(),
|
check_in_date: "",
|
||||||
|
check_in_time: "",
|
||||||
|
start_date: "",
|
||||||
|
start_time: "",
|
||||||
|
delivery_date: "",
|
||||||
|
delivery_time: "",
|
||||||
km_in: "",
|
km_in: "",
|
||||||
fuel_level: "",
|
fuel_level: "",
|
||||||
|
has_insurance: false,
|
||||||
|
enable_parts_issuing: false,
|
||||||
|
enable_digital_authorisation: false,
|
||||||
|
footer: "",
|
||||||
|
customer_remarks: [],
|
||||||
|
labels: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Mapping helpers ──
|
// ── Mapping helpers ──
|
||||||
@ -65,21 +83,56 @@ const DEFAULT_VALUES: JobCardFormValues = {
|
|||||||
function mapToFormValues(data: unknown): JobCardFormValues {
|
function mapToFormValues(data: unknown): JobCardFormValues {
|
||||||
const d = (data as any)?.data ?? data ?? {}
|
const d = (data as any)?.data ?? data ?? {}
|
||||||
|
|
||||||
|
const mapTime = (v: unknown): string => {
|
||||||
|
if (!v || typeof v !== "string") return ""
|
||||||
|
if (v.includes("T")) return v.split("T")[1]?.slice(0, 8) ?? ""
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
const mapDate = (v: unknown): string => {
|
||||||
|
if (!v || typeof v !== "string") return ""
|
||||||
|
return v.split("T")[0]
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: d.title || "",
|
title: d.title || "",
|
||||||
customer: toRelation(d.customer_id, d.customer ? `${d.customer.first_name} ${d.customer.last_name}` : undefined),
|
customer: toRelation(d.customer_id, d.customer ? `${d.customer.first_name} ${d.customer.last_name}` : undefined),
|
||||||
vehicle: toRelation(d.vehicle_id, d.vehicle ? `${d.vehicle.make} ${d.vehicle.model}` : undefined),
|
vehicle: toRelation(d.vehicle_id, d.vehicle ? (d.vehicle.plate_number ?? `${d.vehicle.make ?? ""} ${d.vehicle.model ?? ""}`.trim()) : undefined),
|
||||||
department: toRelation(d.department_id, d.department?.name),
|
department: toRelation(d.department_id, d.department?.name),
|
||||||
service_writer: toRelation(d.service_writer_id, d.service_writer ? `${d.service_writer.first_name} ${d.service_writer.last_name}` : undefined),
|
service_writer: toRelation(d.service_writer_id, d.service_writer ? `${d.service_writer.first_name} ${d.service_writer.last_name}` : undefined),
|
||||||
|
primary_technician: toRelation(d.primary_technician_id, d.primary_technician ? `${d.primary_technician.first_name} ${d.primary_technician.last_name}` : undefined),
|
||||||
|
sales_person: toRelation(d.sales_person_id, d.sales_person ? `${d.sales_person.first_name} ${d.sales_person.last_name}` : undefined),
|
||||||
|
insurance_type: toRelation(d.insurance_type_id, d.insurance_type?.name),
|
||||||
|
insurer: toRelation(d.insurer_id, d.insurer ? `${d.insurer.first_name} ${d.insurer.last_name}`.trim() || d.insurer.company_name : undefined),
|
||||||
|
order_number: d.order_number || "",
|
||||||
|
estimate_number: d.estimate_number || "",
|
||||||
status: d.status || "draft",
|
status: d.status || "draft",
|
||||||
estimate_to: d.estimate_to || "Customer",
|
estimate_to: d.estimate_to || "Customer",
|
||||||
tax_inclusive: d.tax_inclusive || "Tax Inclusive",
|
tax_inclusive: d.tax_inclusive || "Tax Inclusive",
|
||||||
discount_type: d.discount_type || "no",
|
discount_type: d.discount_type || "no",
|
||||||
discount_at: d.discount_at || "inclusive_of_tax",
|
discount_at: d.discount_at || "inclusive_of_tax",
|
||||||
check_in_date: d.check_in_date ? d.check_in_date.split("T")[0] : "",
|
order_date: mapDate(d.order_date),
|
||||||
check_in_time: d.check_in_time ? d.check_in_time.split("T")[0] : "",
|
check_in_date: mapDate(d.check_in_date),
|
||||||
|
check_in_time: mapTime(d.check_in_time),
|
||||||
|
start_date: mapDate(d.start_date),
|
||||||
|
start_time: mapTime(d.start_time),
|
||||||
|
delivery_date: mapDate(d.delivery_date),
|
||||||
|
delivery_time: mapTime(d.delivery_time),
|
||||||
km_in: d.km_in != null ? String(d.km_in) : "",
|
km_in: d.km_in != null ? String(d.km_in) : "",
|
||||||
fuel_level: d.fuel_level || "",
|
fuel_level: d.fuel_level || "",
|
||||||
|
has_insurance: d.has_insurance ?? false,
|
||||||
|
enable_parts_issuing: d.enable_parts_issuing ?? false,
|
||||||
|
enable_digital_authorisation: d.enable_digital_authorisation ?? false,
|
||||||
|
footer: d.footer || "",
|
||||||
|
customer_remarks: Array.isArray(d.customer_remarks)
|
||||||
|
? d.customer_remarks.map((r: any) =>
|
||||||
|
typeof r === "string" ? r : (r?.remark ?? "")
|
||||||
|
).filter(Boolean)
|
||||||
|
: [],
|
||||||
|
labels: (d.labels ?? []).map((l: any) => ({
|
||||||
|
id: l.id,
|
||||||
|
title: l.title,
|
||||||
|
color_code: l.color_code,
|
||||||
|
})),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -90,15 +143,32 @@ function mapFormToPayload(values: JobCardFormValues) {
|
|||||||
vehicle_id: toId(values.vehicle),
|
vehicle_id: toId(values.vehicle),
|
||||||
department_id: toId(values.department),
|
department_id: toId(values.department),
|
||||||
service_writer_id: toId(values.service_writer),
|
service_writer_id: toId(values.service_writer),
|
||||||
|
primary_technician_id: toId(values.primary_technician),
|
||||||
|
sales_person_id: toId(values.sales_person),
|
||||||
|
insurance_type_id: values.insurance_type ? String(toId(values.insurance_type)) : null,
|
||||||
|
insurer_id: values.insurer ? String(toId(values.insurer)) : null,
|
||||||
|
estimate_number: values.estimate_number || undefined,
|
||||||
|
order_number: values.order_number || undefined,
|
||||||
status: values.status || undefined,
|
status: values.status || undefined,
|
||||||
estimate_to: values.estimate_to || undefined,
|
estimate_to: values.estimate_to || undefined,
|
||||||
tax_inclusive: values.tax_inclusive || undefined,
|
tax_inclusive: values.tax_inclusive || undefined,
|
||||||
discount_type: values.discount_type || undefined,
|
discount_type: values.discount_type || undefined,
|
||||||
discount_at: values.discount_at || undefined,
|
discount_at: values.discount_at || undefined,
|
||||||
|
order_date: values.order_date || undefined,
|
||||||
check_in_date: values.check_in_date || undefined,
|
check_in_date: values.check_in_date || undefined,
|
||||||
check_in_time: values.check_in_time || undefined,
|
check_in_time: values.check_in_time || undefined,
|
||||||
|
start_date: values.start_date || undefined,
|
||||||
|
start_time: values.start_time || undefined,
|
||||||
|
delivery_date: values.delivery_date || undefined,
|
||||||
|
delivery_time: values.delivery_time || undefined,
|
||||||
km_in: values.km_in ? Number(values.km_in) : undefined,
|
km_in: values.km_in ? Number(values.km_in) : undefined,
|
||||||
fuel_level: values.fuel_level || undefined,
|
fuel_level: values.fuel_level || undefined,
|
||||||
|
has_insurance: values.has_insurance,
|
||||||
|
enable_parts_issuing: values.enable_parts_issuing,
|
||||||
|
enable_digital_authorisation: values.enable_digital_authorisation,
|
||||||
|
footer: values.footer || undefined,
|
||||||
|
customer_remarks: values.customer_remarks?.filter(Boolean) ?? [],
|
||||||
|
label_ids: values.labels?.map((l) => l.id),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -130,6 +200,10 @@ export function JobCardForm({ resourceId, initialData, onSuccess }: JobCardFormP
|
|||||||
mapToFormValues,
|
mapToFormValues,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const hasInsurance = form.watch("has_insurance")
|
||||||
|
const status = form.watch("status")
|
||||||
|
const isCheckIn = status === "check_in"
|
||||||
|
|
||||||
const { mutate, error, isPending } = useFormMutation(form, {
|
const { mutate, error, isPending } = useFormMutation(form, {
|
||||||
mutationFn: (values: JobCardFormValues) => {
|
mutationFn: (values: JobCardFormValues) => {
|
||||||
const payload = mapFormToPayload(values)
|
const payload = mapFormToPayload(values)
|
||||||
@ -161,7 +235,11 @@ export function JobCardForm({ resourceId, initialData, onSuccess }: JobCardFormP
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<FieldGroup>
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_360px]">
|
||||||
|
|
||||||
|
{/* ── Left column ── */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<RhfLabelPickerField name="labels" label="Labels" />
|
||||||
<RhfTextField name="title" label="Title" placeholder="Job Card 001" required />
|
<RhfTextField name="title" label="Title" placeholder="Job Card 001" required />
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
@ -169,31 +247,41 @@ export function JobCardForm({ resourceId, initialData, onSuccess }: JobCardFormP
|
|||||||
<RhfVehicleSelectField name="vehicle" />
|
<RhfVehicleSelectField name="vehicle" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<RhfCheckboxField name="has_insurance" label="Has Insurance Work?" />
|
||||||
|
{hasInsurance && (
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
{/* <RhfSelectField
|
<RhfCustomerSelectField
|
||||||
name="status"
|
name="insurer"
|
||||||
label="Status"
|
label="Insurer"
|
||||||
placeholder="Select status"
|
placeholder="Search insurer..."
|
||||||
options={JOB_CARD_STATUS_OPTIONS}
|
customerType="Insurer"
|
||||||
/> */}
|
|
||||||
<RhfSelectField
|
|
||||||
name="estimate_to"
|
|
||||||
label="Estimate To"
|
|
||||||
placeholder="Select estimate to"
|
|
||||||
options={ESTIMATE_TO_OPTIONS}
|
|
||||||
/>
|
/>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<span className="text-sm font-medium">Insurance Type</span>
|
||||||
|
<InsuranceTypeCrudDialog />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
||||||
<RhfAsyncSelectField
|
<RhfAsyncSelectField
|
||||||
name="department"
|
name="insurance_type"
|
||||||
label="Department"
|
label=""
|
||||||
placeholder="Select department"
|
placeholder="Select insurance type"
|
||||||
queryKey={[DEPARTMENT_ROUTES.INDEX]}
|
queryKey={[INSURANCE_TYPE_ROUTES.INDEX]}
|
||||||
listFn={() => api.departments.list()}
|
listFn={() => api.insuranceTypes.list()}
|
||||||
mapOption={mapLookupOption}
|
mapOption={mapLookupOption}
|
||||||
{...STORE_OBJECT}
|
{...STORE_OBJECT}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
|
||||||
|
<RhfDateField name="order_date" label="Order Date" />
|
||||||
|
|
||||||
|
<RhfAutoGenerateField autoFetch name="order_number" label="Order#" placeholder="ORD-001" table="job_cards" />
|
||||||
|
<RhfAutoGenerateField autoFetch name="estimate_number" label="Estimate#" placeholder="EST-001" table="estimates" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<RhfAsyncSelectField
|
<RhfAsyncSelectField
|
||||||
name="service_writer"
|
name="service_writer"
|
||||||
label="Service Writer"
|
label="Service Writer"
|
||||||
@ -203,51 +291,88 @@ export function JobCardForm({ resourceId, initialData, onSuccess }: JobCardFormP
|
|||||||
mapOption={mapEmployeeOption}
|
mapOption={mapEmployeeOption}
|
||||||
{...STORE_OBJECT}
|
{...STORE_OBJECT}
|
||||||
/>
|
/>
|
||||||
|
<RhfAsyncSelectField
|
||||||
|
name="primary_technician"
|
||||||
|
label="Primary Technician"
|
||||||
|
placeholder="Select technician"
|
||||||
|
queryKey={[EMPLOYEE_ROUTES.INDEX]}
|
||||||
|
listFn={() => api.employees.list()}
|
||||||
|
mapOption={mapEmployeeOption}
|
||||||
|
{...STORE_OBJECT}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
||||||
<RhfDateField name="check_in_date" label="Check-in Date" />
|
{/* ── Check-in Details (shown when status is check_in) ── */}
|
||||||
<RhfTimeField name="check_in_time" label="Check-in Time" withSeconds />
|
{isCheckIn && (
|
||||||
</div> */}
|
<div className="space-y-4 rounded-lg border p-4">
|
||||||
|
<p className="text-sm font-semibold">Check In Details</p>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<RhfTextField name="km_in" label="KM In" placeholder="50000" type="number" />
|
<RhfDateField name="check_in_date" label="Check In Date" />
|
||||||
|
<RhfTimeField name="check_in_time" label="Check In Time" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||||
|
<RhfTextField name="km_in" label="KMs IN" placeholder="0" type="number" />
|
||||||
<RhfSelectField
|
<RhfSelectField
|
||||||
name="fuel_level"
|
name="fuel_level"
|
||||||
label="Fuel Level"
|
label="Fuel Level"
|
||||||
placeholder="Select fuel level"
|
placeholder="Select"
|
||||||
options={FUEL_LEVEL_OPTIONS}
|
options={FUEL_LEVEL_OPTIONS}
|
||||||
/>
|
/>
|
||||||
|
<RhfDateField name="start_date" label="Start Date" />
|
||||||
|
<RhfTimeField name="start_time" label="Start Time" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-[1fr_1fr_1fr]">
|
||||||
<RhfSelectField
|
<RhfAsyncSelectField
|
||||||
name="tax_inclusive"
|
name="department"
|
||||||
label="Tax"
|
label="Department"
|
||||||
placeholder="Select tax type"
|
placeholder="Select department"
|
||||||
options={TAX_INCLUSIVE_OPTIONS}
|
queryKey={[DEPARTMENT_ROUTES.INDEX]}
|
||||||
/>
|
listFn={() => api.departments.list()}
|
||||||
<RhfSelectField
|
mapOption={mapLookupOption}
|
||||||
name="discount_type"
|
{...STORE_OBJECT}
|
||||||
label="Discount Type"
|
|
||||||
placeholder="Select discount type"
|
|
||||||
options={DISCOUNT_TYPE_OPTIONS}
|
|
||||||
/>
|
|
||||||
<RhfSelectField
|
|
||||||
name="discount_at"
|
|
||||||
label="Discount At"
|
|
||||||
placeholder="Select discount at"
|
|
||||||
options={DISCOUNT_AT_OPTIONS}
|
|
||||||
/>
|
/>
|
||||||
|
<RhfDateField name="delivery_date" label="Delivery Date" />
|
||||||
|
<RhfTimeField name="delivery_time" label="Delivery Time" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Right column ── */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<RhfSelectField
|
||||||
|
name="status"
|
||||||
|
label="Status"
|
||||||
|
placeholder="Select status"
|
||||||
|
options={JOB_CARD_STATUS_OPTIONS}
|
||||||
|
/>
|
||||||
|
<RhfCustomerRemarksField name="customer_remarks" />
|
||||||
|
<RhfTextareaField name="footer" label="Estimate Footer" placeholder="Thank you for your business." rows={6} />
|
||||||
|
<RhfCheckboxField
|
||||||
|
name="enable_parts_issuing"
|
||||||
|
label="Enable Parts Issuing"
|
||||||
|
description="When off, parts will be auto issued upon authorization. When on, manually request, reserve, and issue parts."
|
||||||
|
/>
|
||||||
|
<RhfCheckboxField
|
||||||
|
name="enable_digital_authorisation"
|
||||||
|
label="Enable Digital Authorisation"
|
||||||
|
description="When off, items will be auto authorised. When on, customer approval is required before proceeding."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
<Button type="submit" variant="default" disabled={isPending}>
|
<Button type="submit" variant="default" disabled={isPending}>
|
||||||
{isEditing ? <Save /> : <Plus />}
|
{isEditing ? <Save /> : <Plus />}
|
||||||
{isPending
|
{isPending
|
||||||
? (isEditing ? "Updating..." : "Creating...")
|
? (isEditing ? "Updating..." : "Creating...")
|
||||||
: (isEditing ? "Update Job Card" : "Create Job Card")}
|
: (isEditing ? "Update Job Card" : "Create Job Card")}
|
||||||
</Button>
|
</Button>
|
||||||
</FieldGroup>
|
</div>
|
||||||
</Rhform>
|
</Rhform>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import {
|
|||||||
Hash,
|
Hash,
|
||||||
Users,
|
Users,
|
||||||
Car,
|
Car,
|
||||||
Building2,
|
|
||||||
Gauge,
|
Gauge,
|
||||||
Clock,
|
Clock,
|
||||||
UserCheck,
|
UserCheck,
|
||||||
@ -21,15 +20,19 @@ import {
|
|||||||
import { Badge } from "@/shared/components/ui/badge"
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
import { Separator } from "@/shared/components/ui/separator"
|
import { Separator } from "@/shared/components/ui/separator"
|
||||||
import { JobCardActions } from "./job-card-actions"
|
import { JobCardActions } from "./job-card-actions"
|
||||||
|
import { JobCardRemarksList } from "./job-card-remarks-list"
|
||||||
|
import { JobCardRecommendationsList } from "./job-card-recommendations-list"
|
||||||
import { getVehicleLabel } from "../vehicles/utils/getVehicleLabel"
|
import { getVehicleLabel } from "../vehicles/utils/getVehicleLabel"
|
||||||
import { JobCardResponseData } from "@garage/api"
|
|
||||||
import { getFullName } from "@/shared/utils/getFullName"
|
import { getFullName } from "@/shared/utils/getFullName"
|
||||||
|
import { CrudShowResponse, JobCardsClient, PAYMENT_RECEIVED_ROUTES, PaymentReceivedClient } from "@garage/api"
|
||||||
|
import { ResourcePage } from "@/shared/data-view/resource-page"
|
||||||
|
import PaymentReceivedPage from "@/app/(authenticated)/sales/payment-received/page"
|
||||||
|
import JobCardPaymentsReceived from "./job-card-payments-received"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
type JobCardGeneralInfoProps = {
|
type JobCard = NonNullable<CrudShowResponse<JobCardsClient>['data']>
|
||||||
jobCard: JobCardResponseData
|
|
||||||
}
|
|
||||||
|
|
||||||
function InfoItem({
|
function InfoItem({
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
@ -64,7 +67,7 @@ const statusColorMap: Record<string, string> = {
|
|||||||
cancelled: "destructive",
|
cancelled: "destructive",
|
||||||
}
|
}
|
||||||
|
|
||||||
export function JobCardGeneralInfo({ jobCard }: { jobCard: JobCardResponseData }) {
|
export function JobCardGeneralInfo({ jobCard }: { jobCard: JobCard }) {
|
||||||
const formatStatus = (status?: string) => {
|
const formatStatus = (status?: string) => {
|
||||||
if (!status) return null
|
if (!status) return null
|
||||||
return status
|
return status
|
||||||
@ -73,16 +76,22 @@ export function JobCardGeneralInfo({ jobCard }: { jobCard: JobCardResponseData }
|
|||||||
.join(" ")
|
.join(" ")
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(jobCard)
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
{jobCard.service_writer?.first_name}
|
|
||||||
<JobCardActions
|
<JobCardActions
|
||||||
jobCardId={String(jobCard.id)}
|
jobCardId={String(jobCard)}
|
||||||
orderDate={jobCard.order_date ?? null}
|
orderDate={jobCard.order_date ?? null}
|
||||||
serviceWriterName={jobCard.service_writer?.first_name}
|
serviceWriterName={jobCard.service_writer?.first_name}
|
||||||
salesPersonName={jobCard.sales_person?.first_name}
|
salesPersonName={jobCard.sales_person?.first_name}
|
||||||
|
primaryTechnicianName={jobCard.primary_technician?.first_name}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@ -141,18 +150,18 @@ export function JobCardGeneralInfo({ jobCard }: { jobCard: JobCardResponseData }
|
|||||||
<InfoItem
|
<InfoItem
|
||||||
icon={Users}
|
icon={Users}
|
||||||
label="Customer"
|
label="Customer"
|
||||||
value={getFullName(jobCard.customer)}
|
value={getFullName(jobCard?.customer)}
|
||||||
/>
|
/>
|
||||||
<InfoItem
|
<InfoItem
|
||||||
icon={Car}
|
icon={Car}
|
||||||
label="Vehicle"
|
label="Vehicle"
|
||||||
value={getVehicleLabel(jobCard.vehicle as any)}
|
value={getVehicleLabel(jobCard?.vehicle as any)}
|
||||||
/>
|
/>
|
||||||
<InfoItem
|
{/* <InfoItem
|
||||||
icon={Building2}
|
icon={Building2}
|
||||||
label="Department"
|
label="Department"
|
||||||
value={jobCard.department as any}
|
value={jobCard?.department?.name as any}
|
||||||
/>
|
/> */}
|
||||||
<InfoItem
|
<InfoItem
|
||||||
icon={Briefcase}
|
icon={Briefcase}
|
||||||
label="Sales Person"
|
label="Sales Person"
|
||||||
@ -161,7 +170,7 @@ export function JobCardGeneralInfo({ jobCard }: { jobCard: JobCardResponseData }
|
|||||||
<InfoItem
|
<InfoItem
|
||||||
icon={UserCheck}
|
icon={UserCheck}
|
||||||
label="Service Writer"
|
label="Service Writer"
|
||||||
value={getFullName(jobCard.service_writer as any)}
|
value={getFullName(jobCard?.service_writer as any)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@ -231,6 +240,20 @@ export function JobCardGeneralInfo({ jobCard }: { jobCard: JobCardResponseData }
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<JobCardPaymentsReceived />
|
||||||
|
|
||||||
|
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
<JobCardRemarksList
|
||||||
|
jobCardId={String(jobCard.id)}
|
||||||
|
remarks={(jobCard as any).customer_remarks ?? []}
|
||||||
|
/>
|
||||||
|
<JobCardRecommendationsList
|
||||||
|
jobCardId={String(jobCard.id)}
|
||||||
|
recommendations={(jobCard as any).shop_recommendations ?? []}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
244
apps/dashboard/modules/job-cards/job-card-part-form.tsx
Normal file
244
apps/dashboard/modules/job-cards/job-card-part-form.tsx
Normal file
@ -0,0 +1,244 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import React from "react"
|
||||||
|
import { AlertTriangle, Plus, Save } from "lucide-react"
|
||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
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 { DepartmentInlineForm } from "@/modules/services/inline-forms/department-inline-form"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { useAuthApi } from "@/shared/useApi"
|
||||||
|
import { useForm } from "react-hook-form"
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
|
||||||
|
// ── Schema ──
|
||||||
|
|
||||||
|
const jobCardPartFormSchema = z.object({
|
||||||
|
part: z
|
||||||
|
.object({ value: z.string(), label: z.string() })
|
||||||
|
.nullable(),
|
||||||
|
department: z
|
||||||
|
.object({ value: z.string(), label: z.string() })
|
||||||
|
.nullable()
|
||||||
|
.optional(),
|
||||||
|
quantity: z.coerce.number().min(1, "Quantity is required"),
|
||||||
|
rate: z.coerce.number().min(0, "Rate is required"),
|
||||||
|
tax: z.string().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
type JobCardPartFormValues = z.infer<typeof jobCardPartFormSchema>
|
||||||
|
|
||||||
|
// ── Props ──
|
||||||
|
|
||||||
|
export type JobCardPartFormProps = {
|
||||||
|
jobCardId: string
|
||||||
|
jobCardPartId?: number | null
|
||||||
|
initialData?: unknown
|
||||||
|
onSuccess?: () => void
|
||||||
|
onCancel?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_VALUES: JobCardPartFormValues = {
|
||||||
|
part: null,
|
||||||
|
department: null,
|
||||||
|
quantity: 1,
|
||||||
|
rate: 0,
|
||||||
|
tax: "",
|
||||||
|
description: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
|
||||||
|
|
||||||
|
function mapToFormValues(data: unknown): JobCardPartFormValues {
|
||||||
|
const d = (data as any) ?? {}
|
||||||
|
return {
|
||||||
|
part: d.part
|
||||||
|
? { value: String(d.part.id), label: d.part.title ?? String(d.part.id) }
|
||||||
|
: null,
|
||||||
|
department: d.department
|
||||||
|
? { value: String(d.department.id), label: d.department.name ?? String(d.department.id) }
|
||||||
|
: null,
|
||||||
|
quantity: d.quantity ?? 1,
|
||||||
|
rate: d.rate != null ? Number(d.rate) : 0,
|
||||||
|
tax: d.tax ?? "",
|
||||||
|
description: d.description ?? "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component ──
|
||||||
|
|
||||||
|
export function JobCardPartForm({
|
||||||
|
jobCardId,
|
||||||
|
jobCardPartId,
|
||||||
|
initialData,
|
||||||
|
onSuccess,
|
||||||
|
onCancel,
|
||||||
|
}: JobCardPartFormProps) {
|
||||||
|
const api = useAuthApi()
|
||||||
|
const isEditing = !!jobCardPartId
|
||||||
|
|
||||||
|
const form = useForm<JobCardPartFormValues>({
|
||||||
|
resolver: zodResolver(jobCardPartFormSchema) as any,
|
||||||
|
defaultValues: initialData
|
||||||
|
? mapToFormValues(initialData)
|
||||||
|
: DEFAULT_VALUES,
|
||||||
|
})
|
||||||
|
|
||||||
|
const [error, setError] = React.useState<string | null>(null)
|
||||||
|
const [isPending, setIsPending] = React.useState(false)
|
||||||
|
|
||||||
|
async function handleSubmit(values: JobCardPartFormValues) {
|
||||||
|
setError(null)
|
||||||
|
setIsPending(true)
|
||||||
|
try {
|
||||||
|
if (isEditing && jobCardPartId) {
|
||||||
|
await toast.promise(
|
||||||
|
api.jobCards.updatePart(jobCardId, {
|
||||||
|
job_card_part_id: jobCardPartId,
|
||||||
|
quantity: values.quantity,
|
||||||
|
rate: values.rate,
|
||||||
|
description: values.description || undefined,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
loading: "Updating part...",
|
||||||
|
success: "Part updated successfully",
|
||||||
|
error: "Failed to update part",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
await toast.promise(
|
||||||
|
api.jobCards.addPart(jobCardId, {
|
||||||
|
part_id: values.part ? Number(values.part.value) : undefined,
|
||||||
|
department_id: values.department ? Number(values.department.value) : undefined,
|
||||||
|
quantity: values.quantity,
|
||||||
|
rate: values.rate,
|
||||||
|
tax: values.tax || undefined,
|
||||||
|
description: values.description || undefined,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
loading: "Adding part...",
|
||||||
|
success: "Part added successfully",
|
||||||
|
error: "Failed to add part",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
form.reset()
|
||||||
|
onSuccess?.()
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err?.message ?? "An unexpected error occurred")
|
||||||
|
} finally {
|
||||||
|
setIsPending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Rhform form={form} onSubmit={handleSubmit}>
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive" className="mb-4">
|
||||||
|
<AlertTriangle className="me-2 h-4 w-4" />
|
||||||
|
<AlertTitle>
|
||||||
|
{isEditing ? "Failed to update part" : "Failed to add part"}
|
||||||
|
</AlertTitle>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FieldGroup>
|
||||||
|
{!isEditing && (
|
||||||
|
<RhfAsyncSelectField
|
||||||
|
name="part"
|
||||||
|
label="Part"
|
||||||
|
placeholder="Select part"
|
||||||
|
required
|
||||||
|
queryKey={["parts"]}
|
||||||
|
listFn={() => api.parts.list()}
|
||||||
|
mapOption={(item: any) => ({
|
||||||
|
value: String(item.id),
|
||||||
|
label: item.title ?? String(item.id),
|
||||||
|
})}
|
||||||
|
{...STORE_OBJECT}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<RhfTextField
|
||||||
|
name="quantity"
|
||||||
|
label="Quantity"
|
||||||
|
type="number"
|
||||||
|
placeholder="1"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<RhfTextField
|
||||||
|
name="rate"
|
||||||
|
label="Rate"
|
||||||
|
type="number"
|
||||||
|
placeholder="0.00"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isEditing && (
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<RhfAsyncSelectField
|
||||||
|
name="department"
|
||||||
|
label="Department"
|
||||||
|
placeholder="Select department"
|
||||||
|
queryKey={["departments"]}
|
||||||
|
listFn={() => api.departments.list()}
|
||||||
|
mapOption={(item: any) => ({
|
||||||
|
value: String(item.id),
|
||||||
|
label: item.name ?? String(item.id),
|
||||||
|
})}
|
||||||
|
createForm={(props) => <DepartmentInlineForm {...props} />}
|
||||||
|
createLabel="Department"
|
||||||
|
{...STORE_OBJECT}
|
||||||
|
/>
|
||||||
|
<RhfTextField
|
||||||
|
name="tax"
|
||||||
|
label="Tax"
|
||||||
|
placeholder="e.g. 5%"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<RhfTextareaField
|
||||||
|
name="description"
|
||||||
|
label="Description"
|
||||||
|
placeholder="Optional notes"
|
||||||
|
/>
|
||||||
|
</FieldGroup>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
{onCancel && (
|
||||||
|
<Button type="button" variant="outline" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button type="submit" disabled={isPending}>
|
||||||
|
{isPending ? (
|
||||||
|
isEditing ? "Saving..." : "Adding..."
|
||||||
|
) : isEditing ? (
|
||||||
|
<>
|
||||||
|
<Save className="me-2 h-4 w-4" />
|
||||||
|
Save Changes
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Plus className="me-2 h-4 w-4" />
|
||||||
|
Add Part
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Rhform>
|
||||||
|
)
|
||||||
|
}
|
||||||
134
apps/dashboard/modules/job-cards/job-card-payments-received.tsx
Normal file
134
apps/dashboard/modules/job-cards/job-card-payments-received.tsx
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { CrudResource } from "@/shared/data-view/resource-page"
|
||||||
|
import { ColumnHeader } from "@/shared/data-view/table-view"
|
||||||
|
import FormDialog from "@/shared/components/form-dialog"
|
||||||
|
import { PaymentReceivedForm } from "@/modules/payment-received/payment-received-form"
|
||||||
|
import { PAYMENT_RECEIVED_ROUTES, PaymentReceivedClient } from "@garage/api"
|
||||||
|
import {
|
||||||
|
BadgeDollarSignIcon,
|
||||||
|
CalendarIcon,
|
||||||
|
ChevronDown,
|
||||||
|
CreditCardIcon,
|
||||||
|
HashIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/shared/components/ui/collapsible"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { useJobCard } from "./job-card-context"
|
||||||
|
import { formatDate, formatCurrency } from "@/shared/utils/formatters"
|
||||||
|
|
||||||
|
export default function JobCardPaymentsReceived() {
|
||||||
|
const jobCard = useJobCard()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Collapsible defaultOpen={false} className="group/collapsible">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-sm font-semibold">Payments Received</CardTitle>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7">
|
||||||
|
<ChevronDown className="h-4 w-4 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-180" />
|
||||||
|
</Button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
<CrudResource<PaymentReceivedClient>
|
||||||
|
extraParams={{ job_card_id: jobCard?.id }}
|
||||||
|
routeKey={PAYMENT_RECEIVED_ROUTES.INDEX}
|
||||||
|
getClient={(api) => api.paymentReceived}
|
||||||
|
tableHeader={({ invalidateQuery }) =>
|
||||||
|
<div className="p-2">
|
||||||
|
<FormDialog title="Record Payment">
|
||||||
|
{(resourceId) => (
|
||||||
|
<PaymentReceivedForm
|
||||||
|
resourceId={resourceId}
|
||||||
|
defaultJobCard={{ id: jobCard?.id, title: jobCard?.title }}
|
||||||
|
onSuccess={invalidateQuery}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</FormDialog>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
columns={({ actionsColumn }) => [
|
||||||
|
{
|
||||||
|
accessorKey: "payment_number",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Payment #" />,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const item = row.original
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<HashIcon className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="font-medium">{item.payment_number || "—"}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "amount_received",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Amount" />,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const item = row.original
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<BadgeDollarSignIcon className="h-4 w-4 text-emerald-600" />
|
||||||
|
<span className="font-semibold text-emerald-700 dark:text-emerald-400">
|
||||||
|
{formatCurrency(item.amount_received)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "payment_mode_name",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Payment Mode" />,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const item = row.original as any
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CreditCardIcon className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="capitalize">{item.payment_mode_name || "—"}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "payment_date",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Date" />,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const item = row.original
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CalendarIcon className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span>{formatDate(item.payment_date)}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "note",
|
||||||
|
header: () => <span>Note</span>,
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const item = row.original
|
||||||
|
const note = item.note
|
||||||
|
if (!note) return <span className="text-muted-foreground">—</span>
|
||||||
|
return (
|
||||||
|
<span className="max-w-50 truncate block" title={note}>
|
||||||
|
{note}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
actionsColumn(),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Card>
|
||||||
|
</Collapsible>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,128 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { useMutation } from "@tanstack/react-query"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { Plus, Trash2, Lightbulb } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { useAuthApi } from "@/shared/useApi"
|
||||||
|
import { confirm } from "@/shared/components/confirm-dialog"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/shared/components/ui/card"
|
||||||
|
import { Textarea } from "@/shared/components/ui/textarea"
|
||||||
|
|
||||||
|
type ShopRecommendation = {
|
||||||
|
id: number
|
||||||
|
recommendation: string
|
||||||
|
created_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
jobCardId: string
|
||||||
|
recommendations: ShopRecommendation[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function JobCardRecommendationsList({ jobCardId, recommendations }: Props) {
|
||||||
|
const api = useAuthApi()
|
||||||
|
const router = useRouter()
|
||||||
|
const [newRecommendation, setNewRecommendation] = useState("")
|
||||||
|
|
||||||
|
const addMutation = useMutation({
|
||||||
|
mutationFn: (recommendation: string) =>
|
||||||
|
api.jobCards.addShopRecommendation(jobCardId, { recommendation }),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Shop recommendation added")
|
||||||
|
setNewRecommendation("")
|
||||||
|
router.refresh()
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error("Failed to add shop recommendation")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (recommendationId: number) =>
|
||||||
|
api.jobCards.deleteShopRecommendation(jobCardId, recommendationId),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Shop recommendation deleted")
|
||||||
|
router.refresh()
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error("Failed to delete shop recommendation")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
const trimmed = newRecommendation.trim()
|
||||||
|
if (!trimmed) return
|
||||||
|
addMutation.mutate(trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (recommendation: ShopRecommendation) => {
|
||||||
|
const confirmed = await confirm({
|
||||||
|
title: "Delete Shop Recommendation",
|
||||||
|
description: "Are you sure you want to delete this recommendation?",
|
||||||
|
confirmLabel: "Delete",
|
||||||
|
variant: "destructive",
|
||||||
|
})
|
||||||
|
if (confirmed) {
|
||||||
|
deleteMutation.mutate(recommendation.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Lightbulb className="size-4" />
|
||||||
|
Shop Recommendations
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-4">
|
||||||
|
{recommendations.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">No shop recommendations yet.</p>
|
||||||
|
)}
|
||||||
|
{recommendations.map((rec) => (
|
||||||
|
<div
|
||||||
|
key={rec.id}
|
||||||
|
className="flex items-start justify-between gap-3 rounded-lg border px-4 py-3"
|
||||||
|
>
|
||||||
|
<p className="text-sm whitespace-pre-wrap">{rec.recommendation}</p>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="shrink-0 text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={() => handleDelete(rec)}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Textarea
|
||||||
|
placeholder="Add a shop recommendation..."
|
||||||
|
value={newRecommendation}
|
||||||
|
onChange={(e) => setNewRecommendation(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="self-end"
|
||||||
|
onClick={handleAdd}
|
||||||
|
disabled={addMutation.isPending || !newRecommendation.trim()}
|
||||||
|
>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
{addMutation.isPending ? "Adding..." : "Add Recommendation"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
128
apps/dashboard/modules/job-cards/job-card-remarks-list.tsx
Normal file
128
apps/dashboard/modules/job-cards/job-card-remarks-list.tsx
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { useMutation } from "@tanstack/react-query"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { Plus, Trash2, MessageSquare } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { useAuthApi } from "@/shared/useApi"
|
||||||
|
import { confirm } from "@/shared/components/confirm-dialog"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/shared/components/ui/card"
|
||||||
|
import { Textarea } from "@/shared/components/ui/textarea"
|
||||||
|
|
||||||
|
type CustomerRemark = {
|
||||||
|
id: number
|
||||||
|
remark: string
|
||||||
|
created_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
jobCardId: string
|
||||||
|
remarks: CustomerRemark[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function JobCardRemarksList({ jobCardId, remarks }: Props) {
|
||||||
|
const api = useAuthApi()
|
||||||
|
const router = useRouter()
|
||||||
|
const [newRemark, setNewRemark] = useState("")
|
||||||
|
|
||||||
|
const addMutation = useMutation({
|
||||||
|
mutationFn: (remark: string) =>
|
||||||
|
api.jobCards.addCustomerRemark(jobCardId, { remark }),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Customer remark added")
|
||||||
|
setNewRemark("")
|
||||||
|
router.refresh()
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error("Failed to add customer remark")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (remarkId: number) =>
|
||||||
|
api.jobCards.deleteCustomerRemark(jobCardId, remarkId),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Customer remark deleted")
|
||||||
|
router.refresh()
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error("Failed to delete customer remark")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
const trimmed = newRemark.trim()
|
||||||
|
if (!trimmed) return
|
||||||
|
addMutation.mutate(trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (remark: CustomerRemark) => {
|
||||||
|
const confirmed = await confirm({
|
||||||
|
title: "Delete Customer Remark",
|
||||||
|
description: "Are you sure you want to delete this remark?",
|
||||||
|
confirmLabel: "Delete",
|
||||||
|
variant: "destructive",
|
||||||
|
})
|
||||||
|
if (confirmed) {
|
||||||
|
deleteMutation.mutate(remark.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<MessageSquare className="size-4" />
|
||||||
|
Customer Remarks
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-4">
|
||||||
|
{remarks.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">No customer remarks yet.</p>
|
||||||
|
)}
|
||||||
|
{remarks.map((remark) => (
|
||||||
|
<div
|
||||||
|
key={remark.id}
|
||||||
|
className="flex items-start justify-between gap-3 rounded-lg border px-4 py-3"
|
||||||
|
>
|
||||||
|
<p className="text-sm whitespace-pre-wrap">{remark.remark}</p>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="shrink-0 text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={() => handleDelete(remark)}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Textarea
|
||||||
|
placeholder="Add a customer remark..."
|
||||||
|
value={newRemark}
|
||||||
|
onChange={(e) => setNewRemark(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="self-end"
|
||||||
|
onClick={handleAdd}
|
||||||
|
disabled={addMutation.isPending || !newRemark.trim()}
|
||||||
|
>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
{addMutation.isPending ? "Adding..." : "Add Remark"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -55,19 +55,56 @@ const jobCardFormSchema = z.object({
|
|||||||
vehicle: relationFieldSchema,
|
vehicle: relationFieldSchema,
|
||||||
department: relationFieldSchema,
|
department: relationFieldSchema,
|
||||||
service_writer: relationFieldSchema,
|
service_writer: relationFieldSchema,
|
||||||
|
primary_technician: relationFieldSchema,
|
||||||
|
sales_person: relationFieldSchema,
|
||||||
|
insurance_type: relationFieldSchema,
|
||||||
|
insurer: relationFieldSchema,
|
||||||
|
|
||||||
// ── Settings ──
|
// ── Numbers & identifiers ──
|
||||||
|
order_number: z.string().optional(),
|
||||||
|
estimate_number: z.string().optional(),
|
||||||
|
|
||||||
|
// ── Status & settings ──
|
||||||
status: z.string().optional(),
|
status: z.string().optional(),
|
||||||
estimate_to: z.string().optional(),
|
estimate_to: z.string().optional(),
|
||||||
tax_inclusive: z.string().optional(),
|
tax_inclusive: z.string().optional(),
|
||||||
discount_type: z.string().optional(),
|
discount_type: z.string().optional(),
|
||||||
discount_at: z.string().optional(),
|
discount_at: z.string().optional(),
|
||||||
|
|
||||||
// ── Check-in details ──
|
// ── Dates & times ──
|
||||||
|
order_date: z.string().optional(),
|
||||||
check_in_date: z.string().optional(),
|
check_in_date: z.string().optional(),
|
||||||
check_in_time: z.string().optional(),
|
check_in_time: z.string().optional(),
|
||||||
|
start_date: z.string().optional(),
|
||||||
|
start_time: z.string().optional(),
|
||||||
|
delivery_date: z.string().optional(),
|
||||||
|
delivery_time: z.string().optional(),
|
||||||
|
|
||||||
|
// ── Vehicle state ──
|
||||||
km_in: z.string().optional(),
|
km_in: z.string().optional(),
|
||||||
fuel_level: z.string().optional(),
|
fuel_level: z.string().optional(),
|
||||||
|
|
||||||
|
// ── Boolean options ──
|
||||||
|
has_insurance: z.boolean().optional(),
|
||||||
|
enable_parts_issuing: z.boolean().optional(),
|
||||||
|
enable_digital_authorisation: z.boolean().optional(),
|
||||||
|
|
||||||
|
// ── Notes ──
|
||||||
|
footer: z.string().optional(),
|
||||||
|
|
||||||
|
// ── Customer Remarks ──
|
||||||
|
customer_remarks: z.array(z.string()).optional(),
|
||||||
|
|
||||||
|
// ── Labels ──
|
||||||
|
labels: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
id: z.number(),
|
||||||
|
title: z.string(),
|
||||||
|
color_code: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
type JobCardFormValues = z.infer<typeof jobCardFormSchema>
|
type JobCardFormValues = z.infer<typeof jobCardFormSchema>
|
||||||
@ -80,7 +117,6 @@ export {
|
|||||||
DISCOUNT_AT_OPTIONS,
|
DISCOUNT_AT_OPTIONS,
|
||||||
ESTIMATE_TO_OPTIONS,
|
ESTIMATE_TO_OPTIONS,
|
||||||
FUEL_LEVEL_OPTIONS,
|
FUEL_LEVEL_OPTIONS,
|
||||||
JOB_CARD_STATUS_OPTIONS,
|
|
||||||
}
|
}
|
||||||
// Backward-compat alias used by job-card-status-stepper
|
// Backward-compat alias used by job-card-status-stepper
|
||||||
export const JOB_CARD_STATUSES = JOB_CARD_STATUS_OPTIONS
|
export const JOB_CARD_STATUSES = JOB_CARD_STATUS_OPTIONS
|
||||||
|
|||||||
337
apps/dashboard/modules/labels/rhf-label-picker-field.tsx
Normal file
337
apps/dashboard/modules/labels/rhf-label-picker-field.tsx
Normal file
@ -0,0 +1,337 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import {
|
||||||
|
useFormContext,
|
||||||
|
useController,
|
||||||
|
type FieldValues,
|
||||||
|
type FieldPath,
|
||||||
|
} from "react-hook-form"
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||||
|
import { Check, PlusIcon, X } from "lucide-react"
|
||||||
|
|
||||||
|
import { useAuthApi } from "@/shared/useApi"
|
||||||
|
import { LABEL_ROUTES } from "@garage/api"
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/components/ui/popover"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { Input } from "@/shared/components/ui/input"
|
||||||
|
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
FieldLabel,
|
||||||
|
FieldError,
|
||||||
|
FieldDescription,
|
||||||
|
} from "@/shared/components/ui/field"
|
||||||
|
import { cn } from "@/shared/lib/utils"
|
||||||
|
|
||||||
|
// ── Types ──
|
||||||
|
|
||||||
|
export type LabelItem = {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
color_code: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type RhfLabelPickerFieldProps<
|
||||||
|
TValues extends FieldValues,
|
||||||
|
TName extends FieldPath<TValues>,
|
||||||
|
> = {
|
||||||
|
name: TName
|
||||||
|
label?: string
|
||||||
|
description?: string
|
||||||
|
required?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
placeholder?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ──
|
||||||
|
|
||||||
|
function extractLabels(response: unknown): LabelItem[] {
|
||||||
|
if (Array.isArray(response)) return response
|
||||||
|
const obj = response as any
|
||||||
|
if (Array.isArray(obj?.data?.data)) return obj.data.data
|
||||||
|
if (Array.isArray(obj?.data)) return obj.data
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component ──
|
||||||
|
|
||||||
|
export function RhfLabelPickerField<
|
||||||
|
TValues extends FieldValues,
|
||||||
|
TName extends FieldPath<TValues>,
|
||||||
|
>({
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
required,
|
||||||
|
disabled,
|
||||||
|
placeholder = "Select labels...",
|
||||||
|
}: RhfLabelPickerFieldProps<TValues, TName>) {
|
||||||
|
const api = useAuthApi()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const { control } = useFormContext<TValues>()
|
||||||
|
const { field, fieldState: { error } } = useController({ name, control, disabled })
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [search, setSearch] = useState("")
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
const [newTitle, setNewTitle] = useState("")
|
||||||
|
const [newColor, setNewColor] = useState("#6366f1")
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
|
||||||
|
const { data: allLabels = [] } = useQuery<LabelItem[]>({
|
||||||
|
queryKey: [LABEL_ROUTES.INDEX],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await api.labels.list()
|
||||||
|
return extractLabels(res)
|
||||||
|
},
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const selected: LabelItem[] = field.value ?? []
|
||||||
|
|
||||||
|
const filtered = search
|
||||||
|
? allLabels.filter((l) =>
|
||||||
|
l.title.toLowerCase().includes(search.toLowerCase()),
|
||||||
|
)
|
||||||
|
: allLabels
|
||||||
|
|
||||||
|
function toggle(lbl: LabelItem) {
|
||||||
|
const isSelected = selected.some((s) => s.id === lbl.id)
|
||||||
|
if (isSelected) {
|
||||||
|
field.onChange(selected.filter((s) => s.id !== lbl.id))
|
||||||
|
} else {
|
||||||
|
field.onChange([...selected, lbl])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(id: number, e: React.MouseEvent) {
|
||||||
|
e.stopPropagation()
|
||||||
|
field.onChange(selected.filter((s) => s.id !== id))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreate() {
|
||||||
|
if (!newTitle.trim()) return
|
||||||
|
setIsSubmitting(true)
|
||||||
|
try {
|
||||||
|
const res = await api.labels.create({
|
||||||
|
title: newTitle.trim(),
|
||||||
|
color_code: newColor,
|
||||||
|
}) as any
|
||||||
|
const created = res?.data ?? res
|
||||||
|
queryClient.invalidateQueries({ queryKey: [LABEL_ROUTES.INDEX] })
|
||||||
|
if (created?.id) {
|
||||||
|
field.onChange([
|
||||||
|
...selected,
|
||||||
|
{
|
||||||
|
id: created.id,
|
||||||
|
title: created.title ?? newTitle.trim(),
|
||||||
|
color_code: created.color_code ?? newColor,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
setNewTitle("")
|
||||||
|
setNewColor("#6366f1")
|
||||||
|
setCreating(false)
|
||||||
|
} catch {
|
||||||
|
// silent – toast handled upstream if desired
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Field data-invalid={!!error || undefined}>
|
||||||
|
{label && (
|
||||||
|
<FieldLabel>
|
||||||
|
{label}
|
||||||
|
{required && (
|
||||||
|
<span className="text-destructive ms-0.5">*</span>
|
||||||
|
)}
|
||||||
|
</FieldLabel>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<div
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault()
|
||||||
|
setOpen((v) => !v)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-9 w-full cursor-pointer flex-wrap items-center gap-1.5 rounded-md border border-input bg-background px-3 py-1.5 text-sm transition-colors hover:border-ring focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||||
|
disabled && "cursor-not-allowed opacity-50",
|
||||||
|
error && "border-destructive",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{selected.length === 0 && (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{placeholder}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{selected.map((s) => (
|
||||||
|
<span
|
||||||
|
key={s.id}
|
||||||
|
className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium"
|
||||||
|
style={{
|
||||||
|
backgroundColor: s.color_code + "28",
|
||||||
|
borderColor: s.color_code + "80",
|
||||||
|
color: s.color_code,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="h-2 w-2 shrink-0 rounded-full"
|
||||||
|
style={{ backgroundColor: s.color_code }}
|
||||||
|
/>
|
||||||
|
{s.title}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ml-0.5 rounded-full opacity-70 hover:opacity-100"
|
||||||
|
onClick={(e) => remove(s.id, e)}
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-label={`Remove ${s.title}`}
|
||||||
|
>
|
||||||
|
<X className="h-2.5 w-2.5" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</PopoverTrigger>
|
||||||
|
|
||||||
|
<PopoverContent
|
||||||
|
className="flex w-64 flex-col overflow-hidden p-0"
|
||||||
|
align="start"
|
||||||
|
style={{
|
||||||
|
maxHeight:
|
||||||
|
"var(--radix-popover-content-available-height, 320px)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Search */}
|
||||||
|
<div className="shrink-0 p-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Search labels..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="h-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* List */}
|
||||||
|
<ScrollArea className="min-h-0 flex-1">
|
||||||
|
<div className="px-1 pb-1">
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<p className="py-4 text-center text-xs text-muted-foreground">
|
||||||
|
No labels found
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{filtered.map((lbl) => {
|
||||||
|
const isSelected = selected.some(
|
||||||
|
(s) => s.id === lbl.id,
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={lbl.id}
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-accent"
|
||||||
|
onClick={() => toggle(lbl)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="h-3 w-3 shrink-0 rounded-full"
|
||||||
|
style={{
|
||||||
|
backgroundColor: lbl.color_code,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="flex-1 text-start">
|
||||||
|
{lbl.title}
|
||||||
|
</span>
|
||||||
|
{isSelected && (
|
||||||
|
<Check className="h-3.5 w-3.5 text-primary" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
{/* Footer: create */}
|
||||||
|
<div className="shrink-0 border-t p-2">
|
||||||
|
{creating ? (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Label name"
|
||||||
|
value={newTitle}
|
||||||
|
onChange={(e) => setNewTitle(e.target.value)}
|
||||||
|
className="h-8"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault()
|
||||||
|
handleCreate()
|
||||||
|
}
|
||||||
|
if (e.key === "Escape") setCreating(false)
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={newColor}
|
||||||
|
onChange={(e) =>
|
||||||
|
setNewColor(e.target.value)
|
||||||
|
}
|
||||||
|
className="h-8 w-8 cursor-pointer rounded border p-0.5"
|
||||||
|
title="Pick a color"
|
||||||
|
/>
|
||||||
|
<span className="flex-1 text-xs text-muted-foreground">
|
||||||
|
Color
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
onClick={handleCreate}
|
||||||
|
disabled={
|
||||||
|
isSubmitting || !newTitle.trim()
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isSubmitting ? "..." : "Create"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
onClick={() => setCreating(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 w-full justify-start gap-1.5 text-xs"
|
||||||
|
onClick={() => setCreating(true)}
|
||||||
|
>
|
||||||
|
<PlusIcon className="h-3.5 w-3.5" />
|
||||||
|
Create label
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
{description && (
|
||||||
|
<FieldDescription>{description}</FieldDescription>
|
||||||
|
)}
|
||||||
|
{error && <FieldError>{error.message}</FieldError>}
|
||||||
|
</Field>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -24,7 +24,7 @@ import {
|
|||||||
} from "./payment-made.schema"
|
} from "./payment-made.schema"
|
||||||
import {
|
import {
|
||||||
PAYMENT_MADE_ROUTES,
|
PAYMENT_MADE_ROUTES,
|
||||||
PAYMENT_ROUTES,
|
PAYMENT_MODE_ROUTES,
|
||||||
VENDOR_ROUTES,
|
VENDOR_ROUTES,
|
||||||
EMPLOYEE_ROUTES,
|
EMPLOYEE_ROUTES,
|
||||||
PaymentFor,
|
PaymentFor,
|
||||||
@ -222,8 +222,8 @@ export function PaymentMadeForm({ resourceId, initialData, onSuccess }: PaymentM
|
|||||||
name="payment_mode"
|
name="payment_mode"
|
||||||
label="Payment Mode"
|
label="Payment Mode"
|
||||||
placeholder="Select payment mode"
|
placeholder="Select payment mode"
|
||||||
queryKey={[PAYMENT_ROUTES.MODES]}
|
queryKey={[PAYMENT_MODE_ROUTES.INDEX]}
|
||||||
listFn={() => api.payments.listModes()}
|
listFn={() => api.paymentModes.list()}
|
||||||
mapOption={mapLookupOption}
|
mapOption={mapLookupOption}
|
||||||
{...STORE_OBJECT}
|
{...STORE_OBJECT}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo } from "react"
|
||||||
import { AlertTriangle, Plus, Save } from "lucide-react"
|
import { AlertTriangle, Plus, Save } from "lucide-react"
|
||||||
|
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
@ -21,7 +22,7 @@ import {
|
|||||||
paymentReceivedFormSchema,
|
paymentReceivedFormSchema,
|
||||||
type PaymentReceivedFormValues,
|
type PaymentReceivedFormValues,
|
||||||
} from "./payment-received.schema"
|
} from "./payment-received.schema"
|
||||||
import { PAYMENT_ROUTES, CUSTOMER_ROUTES, JOB_CARD_ROUTES } from "@garage/api"
|
import { PAYMENT_MODE_ROUTES, CUSTOMER_ROUTES, JOB_CARD_ROUTES } from "@garage/api"
|
||||||
|
|
||||||
// ── Props ──
|
// ── Props ──
|
||||||
|
|
||||||
@ -29,6 +30,7 @@ export type PaymentReceivedFormProps = {
|
|||||||
resourceId?: string | null
|
resourceId?: string | null
|
||||||
initialData?: unknown
|
initialData?: unknown
|
||||||
onSuccess?: () => void
|
onSuccess?: () => void
|
||||||
|
defaultJobCard?: { id?: number | null; title?: string | null } | null
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Default values ──
|
// ── Default values ──
|
||||||
@ -82,14 +84,24 @@ const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) =
|
|||||||
|
|
||||||
// ── Component ──
|
// ── Component ──
|
||||||
|
|
||||||
export function PaymentReceivedForm({ resourceId, initialData, onSuccess }: PaymentReceivedFormProps) {
|
export function PaymentReceivedForm({ resourceId, initialData, onSuccess, defaultJobCard }: PaymentReceivedFormProps) {
|
||||||
const api = useAuthApi()
|
const api = useAuthApi()
|
||||||
|
|
||||||
|
const resolvedInitialData = useMemo(() => {
|
||||||
|
if (!resourceId && defaultJobCard?.id != null) {
|
||||||
|
return {
|
||||||
|
...(initialData as any),
|
||||||
|
job_card: toRelation(defaultJobCard.id, defaultJobCard.title ?? undefined),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return initialData
|
||||||
|
}, [resourceId, defaultJobCard, initialData])
|
||||||
|
|
||||||
const { form, isEditing } = useResourceForm<PaymentReceivedFormValues, any>({
|
const { form, isEditing } = useResourceForm<PaymentReceivedFormValues, any>({
|
||||||
schema: paymentReceivedFormSchema,
|
schema: paymentReceivedFormSchema,
|
||||||
defaultValues: DEFAULT_VALUES,
|
defaultValues: DEFAULT_VALUES,
|
||||||
resourceId,
|
resourceId,
|
||||||
initialData,
|
initialData: resolvedInitialData,
|
||||||
mapToFormValues,
|
mapToFormValues,
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -97,8 +109,8 @@ export function PaymentReceivedForm({ resourceId, initialData, onSuccess }: Paym
|
|||||||
mutationFn: (values: PaymentReceivedFormValues) => {
|
mutationFn: (values: PaymentReceivedFormValues) => {
|
||||||
const payload = mapFormToPayload(values)
|
const payload = mapFormToPayload(values)
|
||||||
const promise = isEditing && resourceId
|
const promise = isEditing && resourceId
|
||||||
? api.payments.updateReceived(resourceId, payload as any)
|
? api.paymentReceived.update(resourceId, payload as any)
|
||||||
: api.payments.createReceived(payload as any)
|
: api.paymentReceived.create(payload as any)
|
||||||
toast.promise(promise, {
|
toast.promise(promise, {
|
||||||
loading: isEditing ? "Updating payment..." : "Recording payment...",
|
loading: isEditing ? "Updating payment..." : "Recording payment...",
|
||||||
success: isEditing ? "Payment updated successfully" : "Payment recorded successfully",
|
success: isEditing ? "Payment updated successfully" : "Payment recorded successfully",
|
||||||
@ -148,7 +160,7 @@ export function PaymentReceivedForm({ resourceId, initialData, onSuccess }: Paym
|
|||||||
listFn={() => api.jobCards.list()}
|
listFn={() => api.jobCards.list()}
|
||||||
mapOption={(item: any) => ({
|
mapOption={(item: any) => ({
|
||||||
value: String(item.id),
|
value: String(item.id),
|
||||||
label: item.job_card_number || item.name || `#${item.id}`,
|
label: item.title,
|
||||||
})}
|
})}
|
||||||
{...STORE_OBJECT}
|
{...STORE_OBJECT}
|
||||||
/>
|
/>
|
||||||
@ -166,8 +178,8 @@ export function PaymentReceivedForm({ resourceId, initialData, onSuccess }: Paym
|
|||||||
name="payment_mode"
|
name="payment_mode"
|
||||||
label="Payment Mode"
|
label="Payment Mode"
|
||||||
placeholder="Select payment mode"
|
placeholder="Select payment mode"
|
||||||
queryKey={[PAYMENT_ROUTES.MODES]}
|
queryKey={[PAYMENT_MODE_ROUTES.INDEX]}
|
||||||
listFn={() => api.payments.listModes()}
|
listFn={() => api.paymentModes.list()}
|
||||||
mapOption={mapLookupOption}
|
mapOption={mapLookupOption}
|
||||||
{...STORE_OBJECT}
|
{...STORE_OBJECT}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import {
|
|||||||
RhfTextField,
|
RhfTextField,
|
||||||
RhfTextareaField,
|
RhfTextareaField,
|
||||||
RhfAsyncSelectField,
|
RhfAsyncSelectField,
|
||||||
|
RhfDateField,
|
||||||
} from "@/shared/components/form"
|
} from "@/shared/components/form"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { useAuthApi } from "@/shared/useApi"
|
import { useAuthApi } from "@/shared/useApi"
|
||||||
@ -21,7 +22,8 @@ import {
|
|||||||
purchaseOrderFormSchema,
|
purchaseOrderFormSchema,
|
||||||
type PurchaseOrderFormValues,
|
type PurchaseOrderFormValues,
|
||||||
} from "./purchase-order.schema"
|
} from "./purchase-order.schema"
|
||||||
import { PURCHASE_ORDER_ROUTES, VENDOR_ROUTES, JOB_CARD_ROUTES, DEPARTMENT_ROUTES } from "@garage/api"
|
import { VENDOR_ROUTES, JOB_CARD_ROUTES, DEPARTMENT_ROUTES } from "@garage/api"
|
||||||
|
import { getFullName } from "@/shared/utils/getFullName"
|
||||||
|
|
||||||
// ── Props ──
|
// ── Props ──
|
||||||
|
|
||||||
@ -38,7 +40,7 @@ const DEFAULT_VALUES: PurchaseOrderFormValues = {
|
|||||||
job_card: null,
|
job_card: null,
|
||||||
department: null,
|
department: null,
|
||||||
title: "",
|
title: "",
|
||||||
order_date: "",
|
order_date: new Date().toISOString().split("T")[0],
|
||||||
delivery_date: "",
|
delivery_date: "",
|
||||||
notes: "",
|
notes: "",
|
||||||
}
|
}
|
||||||
@ -128,8 +130,8 @@ export function PurchaseOrderForm({ resourceId, initialData, onSuccess }: Purcha
|
|||||||
<RhfTextField name="title" label="Title" placeholder="Enter purchase order title" required />
|
<RhfTextField name="title" label="Title" placeholder="Enter purchase order title" required />
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<RhfTextField name="order_date" label="Order Date" placeholder="YYYY-MM-DD" type="date" />
|
<RhfDateField name="order_date" label="Order Date" />
|
||||||
<RhfTextField name="delivery_date" label="Delivery Date" placeholder="YYYY-MM-DD" type="date" />
|
<RhfDateField name="delivery_date" label="Delivery Date" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
@ -139,7 +141,7 @@ export function PurchaseOrderForm({ resourceId, initialData, onSuccess }: Purcha
|
|||||||
placeholder="Select vendor"
|
placeholder="Select vendor"
|
||||||
queryKey={[VENDOR_ROUTES.INDEX]}
|
queryKey={[VENDOR_ROUTES.INDEX]}
|
||||||
listFn={() => api.vendors.list()}
|
listFn={() => api.vendors.list()}
|
||||||
mapOption={mapLookupOption}
|
mapOption={(op: any) => ({ label: getFullName(op as any), value: String(op.id) })}
|
||||||
{...STORE_OBJECT}
|
{...STORE_OBJECT}
|
||||||
/>
|
/>
|
||||||
<RhfAsyncSelectField
|
<RhfAsyncSelectField
|
||||||
@ -161,7 +163,7 @@ export function PurchaseOrderForm({ resourceId, initialData, onSuccess }: Purcha
|
|||||||
listFn={() => api.jobCards.list()}
|
listFn={() => api.jobCards.list()}
|
||||||
mapOption={(item: any) => ({
|
mapOption={(item: any) => ({
|
||||||
value: String(item.id),
|
value: String(item.id),
|
||||||
label: item.job_card_number || item.name || `#${item.id}`,
|
label: item.title || `#${item.id}`,
|
||||||
})}
|
})}
|
||||||
{...STORE_OBJECT}
|
{...STORE_OBJECT}
|
||||||
/>
|
/>
|
||||||
|
|||||||
266
apps/dashboard/modules/tasks/task-form.tsx
Normal file
266
apps/dashboard/modules/tasks/task-form.tsx
Normal file
@ -0,0 +1,266 @@
|
|||||||
|
"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,
|
||||||
|
RhfSelectField,
|
||||||
|
RhfAsyncSelectField,
|
||||||
|
RhfDateField,
|
||||||
|
} 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 { taskFormSchema, type TaskFormValues } from "./task.schema"
|
||||||
|
import { TASK_ROUTES, TASK_TYPE_ROUTES, TASK_SECTION_ROUTES, EMPLOYEE_ROUTES, DEPARTMENT_ROUTES, JOB_CARD_ROUTES } from "@garage/api"
|
||||||
|
import { TaskStatus, TaskPriority } from "@garage/api"
|
||||||
|
import { TaskTypeCrudDialog } from "./task-type-crud-dialog"
|
||||||
|
import { TaskSectionCrudDialog } from "./task-section-crud-dialog"
|
||||||
|
|
||||||
|
// ── Constants ──
|
||||||
|
|
||||||
|
const STATUS_OPTIONS = TaskStatus.map((s) => ({
|
||||||
|
value: s,
|
||||||
|
label: s.charAt(0).toUpperCase() + s.slice(1),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const PRIORITY_OPTIONS = TaskPriority.map((p,i) => ({
|
||||||
|
value: i,
|
||||||
|
label: p.charAt(0).toUpperCase() + p.slice(1),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
|
||||||
|
|
||||||
|
// ── Props ──
|
||||||
|
|
||||||
|
export type TaskFormProps = {
|
||||||
|
resourceId?: string | null
|
||||||
|
initialData?: unknown
|
||||||
|
onSuccess?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Default values ──
|
||||||
|
|
||||||
|
const DEFAULT_VALUES: TaskFormValues = {
|
||||||
|
subject: "",
|
||||||
|
description: "",
|
||||||
|
task_type: null,
|
||||||
|
task_section: null,
|
||||||
|
owner: null,
|
||||||
|
department: null,
|
||||||
|
priority: "medium",
|
||||||
|
due_date: "",
|
||||||
|
status: "pending",
|
||||||
|
job_card: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mapping helpers ──
|
||||||
|
|
||||||
|
function mapToFormValues(data: unknown): TaskFormValues {
|
||||||
|
const d = (data as any)?.data ?? data ?? {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
subject: d.subject || "",
|
||||||
|
description: d.description || "",
|
||||||
|
task_type: toRelation(d.task_type_id, d.task_type?.title),
|
||||||
|
task_section: toRelation(d.task_section_id, d.task_section?.title),
|
||||||
|
owner: toRelation(
|
||||||
|
d.owner_id,
|
||||||
|
d.owner ? `${d.owner.first_name ?? ""} ${d.owner.last_name ?? ""}`.trim() : undefined,
|
||||||
|
),
|
||||||
|
department: toRelation(d.department_id, d.department?.name),
|
||||||
|
priority: d.priority || "medium",
|
||||||
|
due_date: d.due_date ? d.due_date.split("T")[0] : "",
|
||||||
|
status: d.status || "pending",
|
||||||
|
job_card: toRelation(d.job_card_id, d.job_card?.title),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapFormToPayload(values: TaskFormValues) {
|
||||||
|
return {
|
||||||
|
subject: values.subject,
|
||||||
|
description: values.description || undefined,
|
||||||
|
task_type_id: toId(values.task_type),
|
||||||
|
task_section_id: toId(values.task_section),
|
||||||
|
owner_id: toId(values.owner),
|
||||||
|
department_id: toId(values.department),
|
||||||
|
priority: values.priority || undefined,
|
||||||
|
due_date: values.due_date || undefined,
|
||||||
|
status: values.status || undefined,
|
||||||
|
job_card_id: toId(values.job_card),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component ──
|
||||||
|
|
||||||
|
export function TaskForm({ resourceId, initialData, onSuccess }: TaskFormProps) {
|
||||||
|
const api = useAuthApi()
|
||||||
|
|
||||||
|
const { form, isEditing } = useResourceForm<TaskFormValues, any>({
|
||||||
|
schema: taskFormSchema,
|
||||||
|
defaultValues: DEFAULT_VALUES,
|
||||||
|
resourceId,
|
||||||
|
initialData,
|
||||||
|
mapToFormValues,
|
||||||
|
queryKey: [TASK_ROUTES.BY_ID, resourceId],
|
||||||
|
})
|
||||||
|
|
||||||
|
const { mutate, error, isPending } = useFormMutation(form, {
|
||||||
|
mutationFn: (values: TaskFormValues) => {
|
||||||
|
const payload = mapFormToPayload(values)
|
||||||
|
const promise = isEditing && resourceId
|
||||||
|
? api.tasks.update(resourceId, payload as any)
|
||||||
|
: api.tasks.create(payload as any)
|
||||||
|
toast.promise(promise, {
|
||||||
|
loading: isEditing ? "Updating task..." : "Creating task...",
|
||||||
|
success: isEditing ? "Task updated successfully" : "Task created successfully",
|
||||||
|
error: isEditing ? "Failed to update task" : "Failed to create task",
|
||||||
|
})
|
||||||
|
return promise
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
form.reset()
|
||||||
|
onSuccess?.()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Rhform form={form} onSubmit={(values) => mutate(values)} className="space-y-6">
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
<AlertTitle>
|
||||||
|
{isEditing ? "Failed to update task" : "Failed to create task"}
|
||||||
|
</AlertTitle>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FieldGroup>
|
||||||
|
<RhfTextField
|
||||||
|
name="subject"
|
||||||
|
label="Subject"
|
||||||
|
placeholder="e.g. Inspect brake pads"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<RhfTextareaField
|
||||||
|
name="description"
|
||||||
|
label="Description"
|
||||||
|
placeholder="Optional description..."
|
||||||
|
/>
|
||||||
|
</FieldGroup>
|
||||||
|
|
||||||
|
<FieldGroup>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{/* Task Type with inline CRUD */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<span className="text-sm font-medium">Task Type</span>
|
||||||
|
<TaskTypeCrudDialog />
|
||||||
|
</div>
|
||||||
|
<RhfAsyncSelectField
|
||||||
|
name="task_type"
|
||||||
|
label=""
|
||||||
|
placeholder="Select task type..."
|
||||||
|
queryKey={[TASK_TYPE_ROUTES.INDEX]}
|
||||||
|
listFn={() => api.taskTypes.list()}
|
||||||
|
mapOption={(item: any) => ({ value: String(item.id), label: item.title })}
|
||||||
|
{...STORE_OBJECT}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Task Section with inline CRUD */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<span className="text-sm font-medium">Task Section</span>
|
||||||
|
<TaskSectionCrudDialog />
|
||||||
|
</div>
|
||||||
|
<RhfAsyncSelectField
|
||||||
|
name="task_section"
|
||||||
|
label=""
|
||||||
|
placeholder="Select task section..."
|
||||||
|
queryKey={[TASK_SECTION_ROUTES.INDEX]}
|
||||||
|
listFn={() => api.taskSections.list()}
|
||||||
|
mapOption={(item: any) => ({ value: String(item.id), label: item.title })}
|
||||||
|
{...STORE_OBJECT}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FieldGroup>
|
||||||
|
|
||||||
|
<FieldGroup>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<RhfAsyncSelectField
|
||||||
|
name="owner"
|
||||||
|
label="Assigned To"
|
||||||
|
placeholder="Select employee..."
|
||||||
|
queryKey={[EMPLOYEE_ROUTES.INDEX]}
|
||||||
|
listFn={() => api.employees.list()}
|
||||||
|
mapOption={(item: any) => ({
|
||||||
|
value: String(item.id),
|
||||||
|
label: `${item.first_name ?? ""} ${item.last_name ?? ""}`.trim(),
|
||||||
|
})}
|
||||||
|
{...STORE_OBJECT}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<RhfAsyncSelectField
|
||||||
|
name="department"
|
||||||
|
label="Department"
|
||||||
|
placeholder="Select department..."
|
||||||
|
queryKey={[DEPARTMENT_ROUTES.INDEX]}
|
||||||
|
listFn={() => api.departments.list()}
|
||||||
|
mapOption={(item: any) => ({ value: String(item.id), label: item.name })}
|
||||||
|
{...STORE_OBJECT}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<RhfSelectField
|
||||||
|
name="priority"
|
||||||
|
label="Priority"
|
||||||
|
placeholder="Select priority..."
|
||||||
|
options={PRIORITY_OPTIONS as any}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<RhfSelectField
|
||||||
|
name="status"
|
||||||
|
label="Status"
|
||||||
|
placeholder="Select status..."
|
||||||
|
options={STATUS_OPTIONS}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<RhfDateField name="due_date" label="Due Date" />
|
||||||
|
|
||||||
|
<RhfAsyncSelectField
|
||||||
|
name="job_card"
|
||||||
|
label="Job Card"
|
||||||
|
placeholder="Select job card..."
|
||||||
|
queryKey={[JOB_CARD_ROUTES.INDEX]}
|
||||||
|
listFn={() => api.jobCards.list()}
|
||||||
|
mapOption={(item: any) => ({
|
||||||
|
value: String(item.id),
|
||||||
|
label: item.title || `#${item.id}`,
|
||||||
|
})}
|
||||||
|
{...STORE_OBJECT}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</FieldGroup>
|
||||||
|
|
||||||
|
<Button type="submit" disabled={isPending} className="w-full">
|
||||||
|
{isEditing ? <Save className="h-4 w-4" /> : <Plus className="h-4 w-4" />}
|
||||||
|
{isPending
|
||||||
|
? isEditing ? "Updating..." : "Creating..."
|
||||||
|
: isEditing ? "Update Task" : "Add Task"}
|
||||||
|
</Button>
|
||||||
|
</Rhform>
|
||||||
|
)
|
||||||
|
}
|
||||||
43
apps/dashboard/modules/tasks/task-section-crud-dialog.tsx
Normal file
43
apps/dashboard/modules/tasks/task-section-crud-dialog.tsx
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { CrudDialog } from "@/shared/components/crud-dialog"
|
||||||
|
import { ColumnHeader } from "@/shared/data-view/table-view"
|
||||||
|
import { useAuthApi } from "@/shared/useApi"
|
||||||
|
import { TASK_SECTION_ROUTES } from "@garage/api"
|
||||||
|
import { TaskSectionForm } from "./task-section-form"
|
||||||
|
|
||||||
|
export function TaskSectionCrudDialog() {
|
||||||
|
const api = useAuthApi()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CrudDialog
|
||||||
|
title="Task Section"
|
||||||
|
queryKey={[TASK_SECTION_ROUTES.INDEX]}
|
||||||
|
getClient={() => api.taskSections}
|
||||||
|
resourceLabel="task section"
|
||||||
|
columns={() => [
|
||||||
|
{
|
||||||
|
accessorKey: "title",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Title" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "arrangement",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Order" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "is_default",
|
||||||
|
header: () => <span>Default</span>,
|
||||||
|
cell: ({ row }) => ((row.original as any).is_default ? "Yes" : "No"),
|
||||||
|
enableSorting: false,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
renderForm={({ resourceId, initialData, onSuccess }) => (
|
||||||
|
<TaskSectionForm
|
||||||
|
resourceId={resourceId}
|
||||||
|
initialData={initialData}
|
||||||
|
onSuccess={onSuccess}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
107
apps/dashboard/modules/tasks/task-section-form.tsx
Normal file
107
apps/dashboard/modules/tasks/task-section-form.tsx
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { z } from "zod"
|
||||||
|
import { useForm } from "react-hook-form"
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import { Plus, Save } from "lucide-react"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
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 { useEffect } from "react"
|
||||||
|
|
||||||
|
// ── Schema ──
|
||||||
|
|
||||||
|
const taskSectionSchema = z.object({
|
||||||
|
title: z.string().min(1, "Title is required"),
|
||||||
|
arrangement: z.string().optional(),
|
||||||
|
is_default: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
type TaskSectionFormValues = z.infer<typeof taskSectionSchema>
|
||||||
|
|
||||||
|
// ── Props ──
|
||||||
|
|
||||||
|
type TaskSectionFormProps = {
|
||||||
|
resourceId?: string | null
|
||||||
|
initialData?: any
|
||||||
|
onSuccess?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component ──
|
||||||
|
|
||||||
|
export function TaskSectionForm({ resourceId, initialData, onSuccess }: TaskSectionFormProps) {
|
||||||
|
const api = useAuthApi()
|
||||||
|
const isEditing = !!resourceId
|
||||||
|
|
||||||
|
const form = useForm<TaskSectionFormValues>({
|
||||||
|
resolver: zodResolver(taskSectionSchema),
|
||||||
|
defaultValues: { title: "", arrangement: "", is_default: false },
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialData) {
|
||||||
|
const d = initialData?.data ?? initialData
|
||||||
|
form.reset({
|
||||||
|
title: d.title ?? "",
|
||||||
|
arrangement: d.arrangement != null ? String(d.arrangement) : "",
|
||||||
|
is_default: d.is_default ?? false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [initialData, form])
|
||||||
|
|
||||||
|
const handleSubmit = async (values: TaskSectionFormValues) => {
|
||||||
|
try {
|
||||||
|
const promise = isEditing
|
||||||
|
? api.taskSections.update(resourceId!, {
|
||||||
|
title: values.title,
|
||||||
|
arrangement: values.arrangement ? Number(values.arrangement) : undefined,
|
||||||
|
is_default: values.is_default,
|
||||||
|
})
|
||||||
|
: api.taskSections.create({
|
||||||
|
title: values.title,
|
||||||
|
arrangement: values.arrangement ? Number(values.arrangement) : undefined,
|
||||||
|
is_default: values.is_default,
|
||||||
|
})
|
||||||
|
|
||||||
|
toast.promise(promise, {
|
||||||
|
loading: isEditing ? "Updating..." : "Creating...",
|
||||||
|
success: isEditing ? "Updated successfully" : "Created successfully",
|
||||||
|
error: isEditing ? "Failed to update" : "Failed to create",
|
||||||
|
})
|
||||||
|
|
||||||
|
await promise
|
||||||
|
form.reset()
|
||||||
|
onSuccess?.()
|
||||||
|
} catch {
|
||||||
|
// toast already shown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Rhform form={form} onSubmit={handleSubmit}>
|
||||||
|
<FieldGroup>
|
||||||
|
<RhfTextField
|
||||||
|
name="title"
|
||||||
|
label="Title"
|
||||||
|
placeholder="e.g. Pre-Work"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<RhfTextField
|
||||||
|
name="arrangement"
|
||||||
|
label="Arrangement Order"
|
||||||
|
placeholder="e.g. 1"
|
||||||
|
type="number"
|
||||||
|
/>
|
||||||
|
<RhfCheckboxField name="is_default" label="Set as default" />
|
||||||
|
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||||
|
{isEditing ? <Save className="h-4 w-4" /> : <Plus className="h-4 w-4" />}
|
||||||
|
{form.formState.isSubmitting
|
||||||
|
? isEditing ? "Updating..." : "Creating..."
|
||||||
|
: isEditing ? "Update" : "Create"}
|
||||||
|
</Button>
|
||||||
|
</FieldGroup>
|
||||||
|
</Rhform>
|
||||||
|
)
|
||||||
|
}
|
||||||
39
apps/dashboard/modules/tasks/task-type-crud-dialog.tsx
Normal file
39
apps/dashboard/modules/tasks/task-type-crud-dialog.tsx
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { CrudDialog } from "@/shared/components/crud-dialog"
|
||||||
|
import { ColumnHeader } from "@/shared/data-view/table-view"
|
||||||
|
import { useAuthApi } from "@/shared/useApi"
|
||||||
|
import { TASK_TYPE_ROUTES } from "@garage/api"
|
||||||
|
import { TaskTypeForm } from "./task-type-form"
|
||||||
|
|
||||||
|
export function TaskTypeCrudDialog() {
|
||||||
|
const api = useAuthApi()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CrudDialog
|
||||||
|
title="Task Type"
|
||||||
|
queryKey={[TASK_TYPE_ROUTES.INDEX]}
|
||||||
|
getClient={() => api.taskTypes}
|
||||||
|
resourceLabel="task type"
|
||||||
|
columns={() => [
|
||||||
|
{
|
||||||
|
accessorKey: "title",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Title" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "is_default",
|
||||||
|
header: () => <span>Default</span>,
|
||||||
|
cell: ({ row }) => ((row.original as any).is_default ? "Yes" : "No"),
|
||||||
|
enableSorting: false,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
renderForm={({ resourceId, initialData, onSuccess }) => (
|
||||||
|
<TaskTypeForm
|
||||||
|
resourceId={resourceId}
|
||||||
|
initialData={initialData}
|
||||||
|
onSuccess={onSuccess}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
91
apps/dashboard/modules/tasks/task-type-form.tsx
Normal file
91
apps/dashboard/modules/tasks/task-type-form.tsx
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { z } from "zod"
|
||||||
|
import { useForm } from "react-hook-form"
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import { Plus, Save } from "lucide-react"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
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 { useEffect } from "react"
|
||||||
|
|
||||||
|
// ── Schema ──
|
||||||
|
|
||||||
|
const taskTypeSchema = z.object({
|
||||||
|
title: z.string().min(1, "Title is required"),
|
||||||
|
is_default: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
type TaskTypeFormValues = z.infer<typeof taskTypeSchema>
|
||||||
|
|
||||||
|
// ── Props ──
|
||||||
|
|
||||||
|
type TaskTypeFormProps = {
|
||||||
|
resourceId?: string | null
|
||||||
|
initialData?: any
|
||||||
|
onSuccess?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component ──
|
||||||
|
|
||||||
|
export function TaskTypeForm({ resourceId, initialData, onSuccess }: TaskTypeFormProps) {
|
||||||
|
const api = useAuthApi()
|
||||||
|
const isEditing = !!resourceId
|
||||||
|
|
||||||
|
const form = useForm<TaskTypeFormValues>({
|
||||||
|
resolver: zodResolver(taskTypeSchema),
|
||||||
|
defaultValues: { title: "", is_default: false },
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialData) {
|
||||||
|
const d = initialData?.data ?? initialData
|
||||||
|
form.reset({
|
||||||
|
title: d.title ?? "",
|
||||||
|
is_default: d.is_default ?? false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [initialData, form])
|
||||||
|
|
||||||
|
const handleSubmit = async (values: TaskTypeFormValues) => {
|
||||||
|
try {
|
||||||
|
const promise = isEditing
|
||||||
|
? api.taskTypes.update(resourceId!, values)
|
||||||
|
: api.taskTypes.create(values)
|
||||||
|
|
||||||
|
toast.promise(promise, {
|
||||||
|
loading: isEditing ? "Updating..." : "Creating...",
|
||||||
|
success: isEditing ? "Updated successfully" : "Created successfully",
|
||||||
|
error: isEditing ? "Failed to update" : "Failed to create",
|
||||||
|
})
|
||||||
|
|
||||||
|
await promise
|
||||||
|
form.reset()
|
||||||
|
onSuccess?.()
|
||||||
|
} catch {
|
||||||
|
// toast already shown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Rhform form={form} onSubmit={handleSubmit}>
|
||||||
|
<FieldGroup>
|
||||||
|
<RhfTextField
|
||||||
|
name="title"
|
||||||
|
label="Title"
|
||||||
|
placeholder="e.g. Maintenance"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<RhfCheckboxField name="is_default" label="Set as default" />
|
||||||
|
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||||
|
{isEditing ? <Save className="h-4 w-4" /> : <Plus className="h-4 w-4" />}
|
||||||
|
{form.formState.isSubmitting
|
||||||
|
? isEditing ? "Updating..." : "Creating..."
|
||||||
|
: isEditing ? "Update" : "Create"}
|
||||||
|
</Button>
|
||||||
|
</FieldGroup>
|
||||||
|
</Rhform>
|
||||||
|
)
|
||||||
|
}
|
||||||
20
apps/dashboard/modules/tasks/task.schema.ts
Normal file
20
apps/dashboard/modules/tasks/task.schema.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
export const relationFieldSchema = z
|
||||||
|
.object({ value: z.string(), label: z.string() })
|
||||||
|
.nullable()
|
||||||
|
|
||||||
|
export const taskFormSchema = z.object({
|
||||||
|
subject: z.string().min(1, "Subject is required"),
|
||||||
|
description: z.string().optional(),
|
||||||
|
task_type: relationFieldSchema,
|
||||||
|
task_section: relationFieldSchema,
|
||||||
|
owner: relationFieldSchema,
|
||||||
|
department: relationFieldSchema,
|
||||||
|
priority: z.string().optional(),
|
||||||
|
due_date: z.string().optional(),
|
||||||
|
status: z.string().optional(),
|
||||||
|
job_card: relationFieldSchema,
|
||||||
|
})
|
||||||
|
|
||||||
|
export type TaskFormValues = z.infer<typeof taskFormSchema>
|
||||||
149
apps/dashboard/shared/components/crud-dialog/crud-dialog.tsx
Normal file
149
apps/dashboard/shared/components/crud-dialog/crud-dialog.tsx
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import React, { useState } from "react"
|
||||||
|
import type { ColumnDef } from "@tanstack/react-table"
|
||||||
|
import { Settings2, Plus, ArrowLeft } from "lucide-react"
|
||||||
|
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 { DataTable } from "@/shared/data-view/table-view"
|
||||||
|
import { createActionsColumn } from "@/shared/data-view/table-view"
|
||||||
|
import { useCrudDialog, type CrudDialogClient, type UseCrudDialogOptions } from "./use-crud-dialog"
|
||||||
|
|
||||||
|
// ── Types ──
|
||||||
|
|
||||||
|
export type CrudDialogProps<TClient extends CrudDialogClient> = UseCrudDialogOptions<TClient> & {
|
||||||
|
/** Dialog title shown in the header */
|
||||||
|
title: string
|
||||||
|
/** Table columns (receives `openEdit` and `handleDelete` helpers) */
|
||||||
|
columns: (helpers: {
|
||||||
|
openEdit: (row: any) => void
|
||||||
|
handleDelete: (row: any) => Promise<void>
|
||||||
|
}) => ColumnDef<any>[]
|
||||||
|
/** Render the create/edit form */
|
||||||
|
renderForm: (props: {
|
||||||
|
resourceId: string | null
|
||||||
|
initialData: any
|
||||||
|
onSuccess: () => void
|
||||||
|
}) => React.ReactNode
|
||||||
|
/** Optional trigger button; defaults to a settings icon button */
|
||||||
|
trigger?: React.ReactNode
|
||||||
|
/** Custom trigger class */
|
||||||
|
triggerClassName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component ──
|
||||||
|
|
||||||
|
export function CrudDialog<TClient extends CrudDialogClient>({
|
||||||
|
title,
|
||||||
|
columns: columnsFn,
|
||||||
|
renderForm,
|
||||||
|
trigger,
|
||||||
|
triggerClassName,
|
||||||
|
...hookOptions
|
||||||
|
}: CrudDialogProps<TClient>) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
|
||||||
|
const crud = useCrudDialog(hookOptions)
|
||||||
|
|
||||||
|
const columns = columnsFn({
|
||||||
|
openEdit: crud.openEdit,
|
||||||
|
handleDelete: crud.handleDelete,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add actions column
|
||||||
|
const allColumns: ColumnDef<any>[] = [
|
||||||
|
...columns,
|
||||||
|
createActionsColumn({
|
||||||
|
onEdit: crud.openEdit,
|
||||||
|
onDelete: crud.handleDelete,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setIsOpen(false)
|
||||||
|
crud.closeForm()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{trigger ? (
|
||||||
|
<div onClick={() => setIsOpen(true)}>{trigger}</div>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className={triggerClassName ?? "h-5 w-5"}
|
||||||
|
onClick={() => setIsOpen(true)}
|
||||||
|
title={`Manage ${title}`}
|
||||||
|
>
|
||||||
|
<Settings2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Dialog open={isOpen} onOpenChange={(v) => { if (!v) handleClose() }}>
|
||||||
|
<DialogContent className="min-w-2xl max-w-3xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{crud.isFormOpen && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={crud.closeForm}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<DialogTitle className="text-xl font-bold">
|
||||||
|
{crud.isFormOpen
|
||||||
|
? (crud.editingId ? `Edit ${title}` : `New ${title}`)
|
||||||
|
: title}
|
||||||
|
</DialogTitle>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<ScrollArea className="max-h-[75vh]">
|
||||||
|
{crud.isFormOpen ? (
|
||||||
|
<div className="px-1">
|
||||||
|
{renderForm({
|
||||||
|
resourceId: crud.editingId,
|
||||||
|
initialData: crud.editingItem,
|
||||||
|
onSuccess: crud.handleFormSuccess,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3 px-1">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
onClick={crud.openCreate}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Add {title}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<DataTable
|
||||||
|
columns={allColumns}
|
||||||
|
data={crud.items}
|
||||||
|
pagination={crud.pagination}
|
||||||
|
sorting={crud.sorting}
|
||||||
|
onChange={crud.handleChange}
|
||||||
|
isLoading={crud.isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
2
apps/dashboard/shared/components/crud-dialog/index.ts
Normal file
2
apps/dashboard/shared/components/crud-dialog/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { CrudDialog, type CrudDialogProps } from "./crud-dialog"
|
||||||
|
export { useCrudDialog, type CrudDialogClient, type UseCrudDialogOptions } from "./use-crud-dialog"
|
||||||
154
apps/dashboard/shared/components/crud-dialog/use-crud-dialog.ts
Normal file
154
apps/dashboard/shared/components/crud-dialog/use-crud-dialog.ts
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { confirm } from "@/shared/components/confirm-dialog"
|
||||||
|
import type { DataViewChangeEvent, DataViewPaginationState, DataViewSorting } from "@/shared/data-view/table-view"
|
||||||
|
|
||||||
|
// ── Types ──
|
||||||
|
|
||||||
|
export type CrudDialogClient = {
|
||||||
|
list(query?: any): Promise<any>
|
||||||
|
create(payload: any): Promise<any>
|
||||||
|
update(id: string, payload: any): Promise<any>
|
||||||
|
destroy(id: string): Promise<any>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UseCrudDialogOptions<TClient extends CrudDialogClient> = {
|
||||||
|
queryKey: string[]
|
||||||
|
getClient: () => TClient
|
||||||
|
resourceLabel?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hook ──
|
||||||
|
|
||||||
|
export function useCrudDialog<TClient extends CrudDialogClient>({
|
||||||
|
queryKey,
|
||||||
|
getClient,
|
||||||
|
resourceLabel = "item",
|
||||||
|
}: UseCrudDialogOptions<TClient>) {
|
||||||
|
const client = getClient()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
// ── Local pagination state (no URL pollution) ──
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [pageSize, setPageSize] = useState(10)
|
||||||
|
const [sortBy, setSortBy] = useState<string | null>(null)
|
||||||
|
const [sortOrder, setSortOrder] = useState<"asc" | "desc" | null>(null)
|
||||||
|
|
||||||
|
// ── Form dialog state ──
|
||||||
|
const [isFormOpen, setIsFormOpen] = useState(false)
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
|
const [editingItem, setEditingItem] = useState<any>(null)
|
||||||
|
|
||||||
|
const fullQueryKey = [...queryKey, { page, pageSize, sortBy, sortOrder }]
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: fullQueryKey,
|
||||||
|
queryFn: () => {
|
||||||
|
const params: Record<string, unknown> = { page, per_page: pageSize }
|
||||||
|
if (sortBy) params.sort_by = sortBy
|
||||||
|
if (sortOrder) params.sort_order = sortOrder
|
||||||
|
return client.list(params)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const responseData = (data as any)?.data ?? []
|
||||||
|
const items = Array.isArray(responseData) ? responseData : []
|
||||||
|
const meta = (data as any)?.meta
|
||||||
|
|
||||||
|
const pagination: DataViewPaginationState = {
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
pageCount: meta?.last_page ?? 1,
|
||||||
|
total: meta?.total ?? 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorting: DataViewSorting = sortBy
|
||||||
|
? [{ id: sortBy, desc: sortOrder === "desc" }]
|
||||||
|
: []
|
||||||
|
|
||||||
|
const handleChange = (event: DataViewChangeEvent) => {
|
||||||
|
switch (event.type) {
|
||||||
|
case "pagination":
|
||||||
|
setPage(event.pagination.page)
|
||||||
|
setPageSize(event.pagination.pageSize)
|
||||||
|
break
|
||||||
|
case "sorting": {
|
||||||
|
const sort = event.sorting[0]
|
||||||
|
setSortBy(sort?.id ?? null)
|
||||||
|
setSortOrder(sort ? (sort.desc ? "desc" : "asc") : null)
|
||||||
|
setPage(1)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const invalidateQuery = () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { mutateAsync: deleteItem } = useMutation({
|
||||||
|
mutationFn: (id: string) => {
|
||||||
|
const promise = client.destroy(id)
|
||||||
|
toast.promise(promise, {
|
||||||
|
loading: `Deleting ${resourceLabel}...`,
|
||||||
|
success: `${resourceLabel} deleted`,
|
||||||
|
error: `Failed to delete ${resourceLabel}`,
|
||||||
|
})
|
||||||
|
return promise
|
||||||
|
},
|
||||||
|
onSuccess: invalidateQuery,
|
||||||
|
})
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setEditingId(null)
|
||||||
|
setEditingItem(null)
|
||||||
|
setIsFormOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openEdit = (row: any) => {
|
||||||
|
setEditingId(String(row.id))
|
||||||
|
setEditingItem(row)
|
||||||
|
setIsFormOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeForm = () => {
|
||||||
|
setIsFormOpen(false)
|
||||||
|
setEditingId(null)
|
||||||
|
setEditingItem(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (row: any) => {
|
||||||
|
const confirmed = await confirm({
|
||||||
|
title: `Delete this ${resourceLabel}?`,
|
||||||
|
description: "This action cannot be undone.",
|
||||||
|
confirmLabel: "Delete",
|
||||||
|
variant: "destructive",
|
||||||
|
})
|
||||||
|
if (confirmed) await deleteItem(String(row.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFormSuccess = () => {
|
||||||
|
invalidateQuery()
|
||||||
|
closeForm()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
isLoading,
|
||||||
|
pagination,
|
||||||
|
sorting,
|
||||||
|
handleChange,
|
||||||
|
isFormOpen,
|
||||||
|
editingId,
|
||||||
|
editingItem,
|
||||||
|
openCreate,
|
||||||
|
openEdit,
|
||||||
|
closeForm,
|
||||||
|
handleDelete,
|
||||||
|
handleFormSuccess,
|
||||||
|
invalidateQuery,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@ import { Button } from '@/shared/components/ui/button'
|
|||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/shared/components/ui/dialog'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/shared/components/ui/dialog'
|
||||||
import { ScrollArea } from '@/shared/components/ui/scroll-area'
|
import { ScrollArea } from '@/shared/components/ui/scroll-area'
|
||||||
import { Plus } from 'lucide-react'
|
import { Plus } from 'lucide-react'
|
||||||
|
import { cn } from '../lib/utils'
|
||||||
|
|
||||||
export const formDialogParams = {
|
export const formDialogParams = {
|
||||||
dialog: parseAsBoolean.withDefault(false),
|
dialog: parseAsBoolean.withDefault(false),
|
||||||
@ -61,23 +62,29 @@ export default function FormDialog(props: {
|
|||||||
children: (resourceId: string | null) => React.ReactNode
|
children: (resourceId: string | null) => React.ReactNode
|
||||||
title: string
|
title: string
|
||||||
paramKey?: string
|
paramKey?: string
|
||||||
|
classNames?: {
|
||||||
|
trigger?: string
|
||||||
|
dialogContent?: string
|
||||||
|
scrollArea?: string
|
||||||
|
}
|
||||||
|
|
||||||
}) {
|
}) {
|
||||||
const { isOpen, resourceId, open, close } = useFormDialog(props.paramKey)
|
const { isOpen, resourceId, open, close } = useFormDialog(props.paramKey)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button size='lg' onClick={() => open()}>
|
<Button size='sm' className={cn(props.classNames?.trigger)} onClick={() => open()}>
|
||||||
<Plus />
|
<Plus />
|
||||||
{props.title}
|
{props.title}
|
||||||
</Button>
|
</Button>
|
||||||
<Dialog open={isOpen} onOpenChange={(v) => { if (!v) close() }}>
|
<Dialog open={isOpen} onOpenChange={(v) => { if (!v) close() }}>
|
||||||
<DialogContent className='min-w-xl'>
|
<DialogContent className={`min-w-xl ${cn(props.classNames?.dialogContent)}`}>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className='text-2xl font-bold'>
|
<DialogTitle className='text-2xl font-bold'>
|
||||||
{props.title}
|
{props.title}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<ScrollArea className='max-h-[80vh] px-4'>
|
<ScrollArea className={`max-h-[80vh] px-4 ${cn(props.classNames?.scrollArea)}`}>
|
||||||
{props.children(resourceId)}
|
{props.children(resourceId)}
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@ -54,6 +54,12 @@ export function AsyncSelectField<TOption = AsyncOption>({
|
|||||||
value={value}
|
value={value}
|
||||||
onValueChange={(val) => onChange(val)}
|
onValueChange={(val) => onChange(val)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
isItemEqualToValue={(item, val) => {
|
||||||
|
if (item !== null && typeof item === "object" && "value" in (item as object)) {
|
||||||
|
return (item as any).value === (val as any)?.value
|
||||||
|
}
|
||||||
|
return item === val
|
||||||
|
}}
|
||||||
onInputValueChange={(val, { reason }) => {
|
onInputValueChange={(val, { reason }) => {
|
||||||
if (reason === "input-change") {
|
if (reason === "input-change") {
|
||||||
onInputValueChange?.(val)
|
onInputValueChange?.(val)
|
||||||
|
|||||||
@ -0,0 +1,100 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { useFormContext, type FieldValues, type FieldPath } from "react-hook-form"
|
||||||
|
import { useQuery } from "@tanstack/react-query"
|
||||||
|
import { RefreshCw } from "lucide-react"
|
||||||
|
|
||||||
|
import { useAuthApi } from "@/shared/useApi"
|
||||||
|
import { AUTO_GENERATE_ROUTES } from "@garage/api"
|
||||||
|
import { FieldShell } from "../field-shell"
|
||||||
|
import { Input } from "@/shared/components/ui/input"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
|
||||||
|
type RhfAutoGenerateFieldProps<
|
||||||
|
TValues extends FieldValues,
|
||||||
|
TName extends FieldPath<TValues>,
|
||||||
|
> = {
|
||||||
|
name: TName
|
||||||
|
label?: string
|
||||||
|
description?: string
|
||||||
|
required?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
placeholder?: string
|
||||||
|
table: string
|
||||||
|
/** When true, fetches the next code immediately on mount */
|
||||||
|
autoFetch?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RhfAutoGenerateField<
|
||||||
|
TValues extends FieldValues,
|
||||||
|
TName extends FieldPath<TValues>,
|
||||||
|
>({
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
required,
|
||||||
|
disabled,
|
||||||
|
placeholder,
|
||||||
|
table,
|
||||||
|
autoFetch = false,
|
||||||
|
}: RhfAutoGenerateFieldProps<TValues, TName>) {
|
||||||
|
const api = useAuthApi()
|
||||||
|
const { setValue, watch, formState: { errors } } = useFormContext<TValues>()
|
||||||
|
const value = watch(name)
|
||||||
|
const error = errors[name]
|
||||||
|
|
||||||
|
const [enabled, setEnabled] = useState(autoFetch)
|
||||||
|
|
||||||
|
const { isFetching } = useQuery({
|
||||||
|
queryKey: [AUTO_GENERATE_ROUTES.BY_TABLE, table],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await api.autoGenerate.generate(table)
|
||||||
|
const generated = (res as any)?.data
|
||||||
|
if (generated) {
|
||||||
|
setValue(name, generated as any, { shouldValidate: true })
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
},
|
||||||
|
enabled,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
staleTime: 0,
|
||||||
|
gcTime: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleGenerate = () => {
|
||||||
|
setEnabled(false)
|
||||||
|
// Reset and re-enable to trigger a fresh fetch
|
||||||
|
setTimeout(() => setEnabled(true), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FieldShell
|
||||||
|
label={label}
|
||||||
|
error={error?.message as string | undefined}
|
||||||
|
description={description}
|
||||||
|
required={required}
|
||||||
|
>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={value ?? ""}
|
||||||
|
onChange={(e) => setValue(name, e.target.value as any, { shouldValidate: true })}
|
||||||
|
name={name}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-invalid={!!error || undefined}
|
||||||
|
placeholder={placeholder}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
disabled={disabled || isFetching}
|
||||||
|
onClick={handleGenerate}
|
||||||
|
title="Auto-generate"
|
||||||
|
>
|
||||||
|
<RefreshCw className={isFetching ? "animate-spin" : ""} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</FieldShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -39,3 +39,4 @@ export { RhfAsyncSelectField, RhfAsyncMultiSelectField, type InlineCreateFormPro
|
|||||||
export { SimpleTitleForm, type SimpleTitleFormProps } from "./fields/simple-title-form"
|
export { SimpleTitleForm, type SimpleTitleFormProps } from "./fields/simple-title-form"
|
||||||
export { RhfDateField } from "./fields/rhf-date-field"
|
export { RhfDateField } from "./fields/rhf-date-field"
|
||||||
export { RhfTimeField } from "./fields/rhf-time-field"
|
export { RhfTimeField } from "./fields/rhf-time-field"
|
||||||
|
export { RhfAutoGenerateField } from "./fields/rhf-auto-generate-field"
|
||||||
|
|||||||
@ -137,7 +137,7 @@ function SidebarProvider({
|
|||||||
} as React.CSSProperties
|
} as React.CSSProperties
|
||||||
}
|
}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
|
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-card",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@ -168,7 +168,7 @@ function Sidebar({
|
|||||||
<div
|
<div
|
||||||
data-slot="sidebar"
|
data-slot="sidebar"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
|
"flex h-full w-(--sidebar-width) flex-col bg-card text-sidebar-foreground",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@ -186,7 +186,7 @@ function Sidebar({
|
|||||||
data-sidebar="sidebar"
|
data-sidebar="sidebar"
|
||||||
data-slot="sidebar"
|
data-slot="sidebar"
|
||||||
data-mobile="true"
|
data-mobile="true"
|
||||||
className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
|
className="w-(--sidebar-width) bg-card p-0 text-sidebar-foreground [&>button]:hidden"
|
||||||
style={
|
style={
|
||||||
{
|
{
|
||||||
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
||||||
@ -241,7 +241,7 @@ function Sidebar({
|
|||||||
<div
|
<div
|
||||||
data-sidebar="sidebar"
|
data-sidebar="sidebar"
|
||||||
data-slot="sidebar-inner"
|
data-slot="sidebar-inner"
|
||||||
className="flex size-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-sidebar-border"
|
className="flex size-full flex-col bg-card group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-sidebar-border"
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
@ -288,10 +288,10 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
|||||||
onClick={toggleSidebar}
|
onClick={toggleSidebar}
|
||||||
title="Toggle Sidebar"
|
title="Toggle Sidebar"
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
|
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-card-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
|
||||||
"in-data-[side=left]:cursor-w-resize rtl:in-data-[side=left]:cursor-e-resize in-data-[side=right]:cursor-e-resize rtl:in-data-[side=right]:cursor-w-resize",
|
"in-data-[side=left]:cursor-w-resize rtl:in-data-[side=left]:cursor-e-resize in-data-[side=right]:cursor-e-resize rtl:in-data-[side=right]:cursor-w-resize",
|
||||||
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize rtl:[[data-side=left][data-state=collapsed]_&]:cursor-w-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize rtl:[[data-side=right][data-state=collapsed]_&]:cursor-e-resize",
|
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize rtl:[[data-side=left][data-state=collapsed]_&]:cursor-w-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize rtl:[[data-side=right][data-state=collapsed]_&]:cursor-e-resize",
|
||||||
"group-data-[collapsible=offcanvas]:translate-x-0 rtl:group-data-[collapsible=offcanvas]:-translate-x-0 group-data-[collapsible=offcanvas]:after:start-full hover:group-data-[collapsible=offcanvas]:bg-sidebar",
|
"group-data-[collapsible=offcanvas]:translate-x-0 rtl:group-data-[collapsible=offcanvas]:-translate-x-0 group-data-[collapsible=offcanvas]:after:start-full hover:group-data-[collapsible=offcanvas]:bg-card",
|
||||||
"[[data-side=left][data-collapsible=offcanvas]_&]:-end-2",
|
"[[data-side=left][data-collapsible=offcanvas]_&]:-end-2",
|
||||||
"[[data-side=right][data-collapsible=offcanvas]_&]:-start-2",
|
"[[data-side=right][data-collapsible=offcanvas]_&]:-start-2",
|
||||||
className
|
className
|
||||||
@ -358,7 +358,7 @@ function SidebarSeparator({
|
|||||||
<Separator
|
<Separator
|
||||||
data-slot="sidebar-separator"
|
data-slot="sidebar-separator"
|
||||||
data-sidebar="separator"
|
data-sidebar="separator"
|
||||||
className={cn("mx-2 w-auto bg-sidebar-border", className)}
|
className={cn("mx-2 w-auto bg-card-border", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@ -421,7 +421,7 @@ function SidebarGroupAction({
|
|||||||
data-slot="sidebar-group-action"
|
data-slot="sidebar-group-action"
|
||||||
data-sidebar="group-action"
|
data-sidebar="group-action"
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute top-3.5 end-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
|
"absolute top-3.5 end-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-card-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@ -466,13 +466,13 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sidebarMenuButtonVariants = cva(
|
const sidebarMenuButtonVariants = cva(
|
||||||
"peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-start text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pe-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
|
"peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-start text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pe-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-card-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-card-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-card-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-card-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
default: "hover:bg-card-accent hover:text-sidebar-accent-foreground",
|
||||||
outline:
|
outline:
|
||||||
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
|
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-card-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: "h-8 text-sm " ,
|
default: "h-8 text-sm " ,
|
||||||
@ -553,7 +553,7 @@ function SidebarMenuAction({
|
|||||||
data-slot="sidebar-menu-action"
|
data-slot="sidebar-menu-action"
|
||||||
data-sidebar="menu-action"
|
data-sidebar="menu-action"
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute top-1.5 end-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
|
"absolute top-1.5 end-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-card-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
showOnHover &&
|
showOnHover &&
|
||||||
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0",
|
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0",
|
||||||
className
|
className
|
||||||
@ -666,7 +666,7 @@ function SidebarMenuSubButton({
|
|||||||
data-size={size}
|
data-size={size}
|
||||||
data-active={isActive}
|
data-active={isActive}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-7 min-w-0 -translate-x-px rtl:translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
|
"flex h-7 min-w-0 -translate-x-px rtl:translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-card-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-card-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-card-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@ -0,0 +1,95 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import React from "react"
|
||||||
|
import { DataTable, type ActionsColumnOptions } from "@/shared/data-view/table-view"
|
||||||
|
import { useResourcePage, type UseResourcePageOptions, type ResourceItem, type ResourcePageClient } from "./use-resource-page"
|
||||||
|
import type { ColumnDef } from "@tanstack/react-table"
|
||||||
|
|
||||||
|
export type CrudResourceColumnHelpers<TClient extends ResourcePageClient> = {
|
||||||
|
actionsColumn: (options?: Partial<ActionsColumnOptions<ResourceItem<TClient>>>) => ColumnDef<ResourceItem<TClient>, unknown>
|
||||||
|
openEdit: (row: ResourceItem<TClient>) => void
|
||||||
|
deleteItem: (id: string) => Promise<unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CrudResourceContext<TClient extends ResourcePageClient> = {
|
||||||
|
selectedItem: ResourceItem<TClient> | null
|
||||||
|
isDialogOpen: boolean
|
||||||
|
dialogResourceId: string | null
|
||||||
|
isLoading: boolean
|
||||||
|
data: ResourceItem<TClient>[]
|
||||||
|
openCreate: () => void
|
||||||
|
openEdit: (row: ResourceItem<TClient>) => void
|
||||||
|
closeDialog: () => void
|
||||||
|
deleteItem: (id: string) => Promise<unknown>
|
||||||
|
invalidateQuery: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReactNodeOrRender<TClient extends ResourcePageClient> =
|
||||||
|
| React.ReactNode
|
||||||
|
| ((context: CrudResourceContext<TClient>) => React.ReactNode)
|
||||||
|
|
||||||
|
export type CrudResourceProps<TClient extends ResourcePageClient> = UseResourcePageOptions<TClient> & {
|
||||||
|
columns: ColumnDef<ResourceItem<TClient>>[] | ((helpers: CrudResourceColumnHelpers<TClient>) => ColumnDef<ResourceItem<TClient>>[])
|
||||||
|
onRowClick?: (row: ResourceItem<TClient>) => void
|
||||||
|
tableHeader?: ReactNodeOrRender<TClient>
|
||||||
|
render?: (table: React.ReactElement, context: CrudResourceContext<TClient>) => React.ReactElement
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CrudResource<TClient extends ResourcePageClient>({
|
||||||
|
columns: columnsProp,
|
||||||
|
routeKey,
|
||||||
|
getClient,
|
||||||
|
queryOptions,
|
||||||
|
paramKey,
|
||||||
|
extraParams,
|
||||||
|
onRowClick,
|
||||||
|
tableHeader,
|
||||||
|
render,
|
||||||
|
}: CrudResourceProps<TClient>) {
|
||||||
|
type TItem = ResourceItem<TClient>
|
||||||
|
|
||||||
|
const page = useResourcePage<TClient>({ routeKey, getClient, queryOptions, paramKey, extraParams })
|
||||||
|
|
||||||
|
const columns = typeof columnsProp === "function"
|
||||||
|
? columnsProp({
|
||||||
|
actionsColumn: page.actionsColumn,
|
||||||
|
openEdit: page.openEdit,
|
||||||
|
deleteItem: page.deleteItem,
|
||||||
|
})
|
||||||
|
: columnsProp
|
||||||
|
|
||||||
|
type ListResponse = { data?: TItem[] }
|
||||||
|
const responseData = page.data as ListResponse | undefined
|
||||||
|
const items = (responseData?.data ?? []) as TItem[]
|
||||||
|
|
||||||
|
const context: CrudResourceContext<TClient> = {
|
||||||
|
selectedItem: page.selectedItem,
|
||||||
|
isDialogOpen: page.isDialogOpen,
|
||||||
|
dialogResourceId: page.dialogResourceId,
|
||||||
|
isLoading: page.isLoading,
|
||||||
|
data: items,
|
||||||
|
openCreate: page.openCreate,
|
||||||
|
openEdit: page.openEdit,
|
||||||
|
closeDialog: page.closeDialog,
|
||||||
|
deleteItem: page.deleteItem,
|
||||||
|
invalidateQuery: () => page.invalidateQuery(),
|
||||||
|
}
|
||||||
|
|
||||||
|
const table = (
|
||||||
|
<>
|
||||||
|
{tableHeader && (typeof tableHeader === "function" ? tableHeader(context) : tableHeader)}
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={items}
|
||||||
|
pagination={page.pagination}
|
||||||
|
sorting={page.sorting}
|
||||||
|
onChange={page.handleChange}
|
||||||
|
isLoading={page.isLoading}
|
||||||
|
onRowClick={onRowClick}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (render) return render(table, context)
|
||||||
|
return table
|
||||||
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
export { useResourcePage } from "./use-resource-page"
|
export { useResourcePage } from "./use-resource-page"
|
||||||
export { ResourcePage } from "./resource-page"
|
export { ResourcePage } from "./resource-page"
|
||||||
|
export { CrudResource } from "./crud-resource"
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
ResourcePageClient,
|
ResourcePageClient,
|
||||||
@ -14,3 +15,9 @@ export type {
|
|||||||
ResourcePageContext,
|
ResourcePageContext,
|
||||||
ResourcePageProps,
|
ResourcePageProps,
|
||||||
} from "./resource-page"
|
} from "./resource-page"
|
||||||
|
|
||||||
|
export type {
|
||||||
|
CrudResourceColumnHelpers,
|
||||||
|
CrudResourceContext,
|
||||||
|
CrudResourceProps,
|
||||||
|
} from "./crud-resource"
|
||||||
|
|||||||
@ -4,9 +4,12 @@ import React from "react"
|
|||||||
import DashboardPage from "@/base/components/layout/dashboard/dashboard-page"
|
import DashboardPage from "@/base/components/layout/dashboard/dashboard-page"
|
||||||
import type { DashboardHeaderProps } from "@/base/components/layout/dashboard"
|
import type { DashboardHeaderProps } from "@/base/components/layout/dashboard"
|
||||||
import { Card, CardContent } from "@/shared/components/ui/card"
|
import { Card, CardContent } from "@/shared/components/ui/card"
|
||||||
import { DataTable, type ActionsColumnOptions } from "@/shared/data-view/table-view"
|
import type { ResourcePageClient, ResourceItem } from "./use-resource-page"
|
||||||
import { useResourcePage, type UseResourcePageOptions, type ResourceItem, type ResourcePageClient } from "./use-resource-page"
|
import { CrudResource, type CrudResourceContext, type CrudResourceColumnHelpers, type CrudResourceProps } from "./crud-resource"
|
||||||
import type { ColumnDef } from "@tanstack/react-table"
|
|
||||||
|
// Re-exported for backward compatibility
|
||||||
|
export type ResourcePageColumnHelpers<TClient extends ResourcePageClient> = CrudResourceColumnHelpers<TClient>
|
||||||
|
export type ResourcePageContext<TClient extends ResourcePageClient> = CrudResourceContext<TClient>
|
||||||
|
|
||||||
export type ResourceFormProps<TClient extends ResourcePageClient> = {
|
export type ResourceFormProps<TClient extends ResourcePageClient> = {
|
||||||
resourceId: string | null
|
resourceId: string | null
|
||||||
@ -19,114 +22,47 @@ export type ResourcePageHeaderHelpers<TClient extends ResourcePageClient> = {
|
|||||||
invalidateQuery: () => void
|
invalidateQuery: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ResourcePageColumnHelpers<TClient extends ResourcePageClient> = {
|
|
||||||
actionsColumn: (options?: Partial<ActionsColumnOptions<ResourceItem<TClient>>>) => ColumnDef<ResourceItem<TClient>, unknown>
|
|
||||||
openEdit: (row: ResourceItem<TClient>) => void
|
|
||||||
deleteItem: (id: string) => Promise<unknown>
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ResourcePageContext<TClient extends ResourcePageClient> = {
|
|
||||||
selectedItem: ResourceItem<TClient> | null
|
|
||||||
isDialogOpen: boolean
|
|
||||||
dialogResourceId: string | null
|
|
||||||
isLoading: boolean
|
|
||||||
data: ResourceItem<TClient>[]
|
|
||||||
openCreate: () => void
|
|
||||||
openEdit: (row: ResourceItem<TClient>) => void
|
|
||||||
closeDialog: () => void
|
|
||||||
deleteItem: (id: string) => Promise<unknown>
|
|
||||||
invalidateQuery: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
type ReactNodeOrRender<TClient extends ResourcePageClient> =
|
type ReactNodeOrRender<TClient extends ResourcePageClient> =
|
||||||
| React.ReactNode
|
| React.ReactNode
|
||||||
| ((context: ResourcePageContext<TClient>) => React.ReactNode)
|
| ((context: ResourcePageContext<TClient>) => React.ReactNode)
|
||||||
|
|
||||||
export type ResourcePageProps<TClient extends ResourcePageClient> = UseResourcePageOptions<TClient> & {
|
export type ResourcePageProps<TClient extends ResourcePageClient> = Omit<CrudResourceProps<TClient>, "render"> & {
|
||||||
columns: ColumnDef<ResourceItem<TClient>>[] | ((helpers: ResourcePageColumnHelpers<TClient>) => ColumnDef<ResourceItem<TClient>>[])
|
|
||||||
headerProps?: DashboardHeaderProps | ((helpers: ResourcePageHeaderHelpers<TClient>) => DashboardHeaderProps)
|
headerProps?: DashboardHeaderProps | ((helpers: ResourcePageHeaderHelpers<TClient>) => DashboardHeaderProps)
|
||||||
header?: ReactNodeOrRender<TClient> | null
|
header?: ReactNodeOrRender<TClient> | null
|
||||||
pageTitle?: string
|
|
||||||
paramKey?: string
|
|
||||||
onRowClick?: (row: ResourceItem<TClient>) => void
|
|
||||||
toolbar?: ReactNodeOrRender<TClient>
|
|
||||||
tableHeader?: ReactNodeOrRender<TClient>
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ResourcePage<TClient extends ResourcePageClient>({
|
export function ResourcePage<TClient extends ResourcePageClient>({
|
||||||
columns: columnsProp,
|
|
||||||
headerProps: headerPropsProp,
|
headerProps: headerPropsProp,
|
||||||
header,
|
header,
|
||||||
pageTitle,
|
...crudResourceProps
|
||||||
routeKey,
|
|
||||||
getClient,
|
|
||||||
queryOptions,
|
|
||||||
paramKey,
|
|
||||||
onRowClick,
|
|
||||||
toolbar,
|
|
||||||
tableHeader,
|
|
||||||
extraParams,
|
|
||||||
}: ResourcePageProps<TClient>) {
|
}: ResourcePageProps<TClient>) {
|
||||||
type TItem = ResourceItem<TClient>
|
return (
|
||||||
const page = useResourcePage<TClient>({ routeKey, getClient, queryOptions, paramKey, extraParams })
|
<CrudResource<TClient>
|
||||||
|
{...crudResourceProps}
|
||||||
const columns = typeof columnsProp === "function"
|
render={(table, context) => {
|
||||||
? columnsProp({
|
|
||||||
actionsColumn: page.actionsColumn,
|
|
||||||
openEdit: page.openEdit,
|
|
||||||
deleteItem: page.deleteItem,
|
|
||||||
})
|
|
||||||
: columnsProp
|
|
||||||
|
|
||||||
type ListResponse = { data?: TItem[] }
|
|
||||||
const responseData = page.data as ListResponse | undefined
|
|
||||||
const items = (responseData?.data ?? []) as TItem[]
|
|
||||||
|
|
||||||
const context: ResourcePageContext<TClient> = {
|
|
||||||
selectedItem: page.selectedItem,
|
|
||||||
isDialogOpen: page.isDialogOpen,
|
|
||||||
dialogResourceId: page.dialogResourceId,
|
|
||||||
isLoading: page.isLoading,
|
|
||||||
data: items,
|
|
||||||
openCreate: page.openCreate,
|
|
||||||
openEdit: page.openEdit,
|
|
||||||
closeDialog: page.closeDialog,
|
|
||||||
deleteItem: page.deleteItem,
|
|
||||||
invalidateQuery: () => page.invalidateQuery(),
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolvedHeaderProps = typeof headerPropsProp === "function"
|
const resolvedHeaderProps = typeof headerPropsProp === "function"
|
||||||
? headerPropsProp({
|
? headerPropsProp({
|
||||||
selectedItem: page.selectedItem,
|
selectedItem: context.selectedItem,
|
||||||
invalidateQuery: () => page.invalidateQuery(),
|
invalidateQuery: context.invalidateQuery,
|
||||||
})
|
})
|
||||||
: headerPropsProp
|
: headerPropsProp
|
||||||
|
|
||||||
const resolvedHeader = typeof header === "function" ? header(context) : header
|
const resolvedHeader = typeof header === "function" ? header(context) : header
|
||||||
const resolvedToolbar = typeof toolbar === "function" ? toolbar(context) : toolbar
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardPage
|
<DashboardPage
|
||||||
header={resolvedHeader}
|
header={resolvedHeader}
|
||||||
headerProps={resolvedHeaderProps}
|
headerProps={resolvedHeaderProps}
|
||||||
title={pageTitle}
|
fullscreen
|
||||||
toolbar={resolvedToolbar}
|
|
||||||
>
|
>
|
||||||
<Card>
|
<Card className="rounded-none">
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{tableHeader && (typeof tableHeader === "function" ? tableHeader(context) : tableHeader)}
|
{table}
|
||||||
<DataTable
|
|
||||||
columns={columns}
|
|
||||||
data={items}
|
|
||||||
pagination={page.pagination}
|
|
||||||
sorting={page.sorting}
|
|
||||||
onChange={page.handleChange}
|
|
||||||
isLoading={page.isLoading}
|
|
||||||
onRowClick={onRowClick}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</DashboardPage>
|
</DashboardPage>
|
||||||
)
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
82
apps/dashboard/shared/utils/formatters.ts
Normal file
82
apps/dashboard/shared/utils/formatters.ts
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* Format a date string or Date to a long readable date: "Jan 6, 2026"
|
||||||
|
*/
|
||||||
|
export function formatDate(value?: string | Date | null): string {
|
||||||
|
if (!value) return "—"
|
||||||
|
const date = value instanceof Date ? value : new Date(value)
|
||||||
|
if (isNaN(date.getTime())) return "—"
|
||||||
|
return date.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a date string or Date to date + time: "Jan 6, 2026, 2:30 PM"
|
||||||
|
*/
|
||||||
|
export function formatDateTime(value?: string | Date | null): string {
|
||||||
|
if (!value) return "—"
|
||||||
|
const date = value instanceof Date ? value : new Date(value)
|
||||||
|
if (isNaN(date.getTime())) return "—"
|
||||||
|
return date.toLocaleDateString(undefined, {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a date string or Date to a short numeric date: "04/06/2026"
|
||||||
|
*/
|
||||||
|
export function formatDateShort(value?: string | Date | null): string {
|
||||||
|
if (!value) return "—"
|
||||||
|
const date = value instanceof Date ? value : new Date(value)
|
||||||
|
if (isNaN(date.getTime())) return "—"
|
||||||
|
return date.toLocaleDateString(undefined, { year: "numeric", month: "2-digit", day: "2-digit" })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a time string or Date to a readable time: "2:30 PM"
|
||||||
|
*/
|
||||||
|
export function formatTime(value?: string | Date | null): string {
|
||||||
|
if (!value) return "—"
|
||||||
|
const date = value instanceof Date ? value : new Date(value)
|
||||||
|
if (isNaN(date.getTime())) return "—"
|
||||||
|
return date.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a snake_case or underscore-separated string to Title Case words.
|
||||||
|
* e.g. "in_progress" → "In Progress"
|
||||||
|
*/
|
||||||
|
export function formatEnum(value?: string | null): string {
|
||||||
|
if (!value) return "—"
|
||||||
|
return value
|
||||||
|
.split("_")
|
||||||
|
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||||
|
.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a number with locale-aware thousand separators.
|
||||||
|
* e.g. 150000 → "150,000"
|
||||||
|
*/
|
||||||
|
export function formatNumber(value?: number | string | null): string {
|
||||||
|
if (value == null || value === "") return "—"
|
||||||
|
const num = typeof value === "string" ? Number(value) : value
|
||||||
|
if (isNaN(num)) return "—"
|
||||||
|
return num.toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a numeric value as currency: "$1,500.00"
|
||||||
|
*/
|
||||||
|
export function formatCurrency(
|
||||||
|
value?: number | string | null,
|
||||||
|
currency = "USD",
|
||||||
|
locale?: string,
|
||||||
|
): string {
|
||||||
|
if (value == null || value === "") return "—"
|
||||||
|
const num = typeof value === "string" ? Number(value) : value
|
||||||
|
if (isNaN(num)) return "—"
|
||||||
|
return new Intl.NumberFormat(locale, { style: "currency", currency }).format(num)
|
||||||
|
}
|
||||||
@ -1,5 +1,5 @@
|
|||||||
export const getFullName = <T extends { first_name?: string | undefined; last_name?: string | undefined }>(user: T) => {
|
export const getFullName = <T extends { first_name?: string | undefined; last_name?: string | undefined }>(user?: T) => {
|
||||||
const firstName = user.first_name ?? ""
|
const firstName = user?.first_name ?? ""
|
||||||
const lastName = user.last_name ?? ""
|
const lastName = user?.last_name ?? ""
|
||||||
return `${firstName} ${lastName}`.trim()
|
return `${firstName} ${lastName}`.trim()
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@ -16,14 +16,20 @@ import { InspectionsClient } from "./clients/inspections"
|
|||||||
import { LabelsClient } from "./clients/labels"
|
import { LabelsClient } from "./clients/labels"
|
||||||
import { InsuranceTypesClient } from "./clients/insurance-types"
|
import { InsuranceTypesClient } from "./clients/insurance-types"
|
||||||
import { EstimatesClient } from "./clients/estimates"
|
import { EstimatesClient } from "./clients/estimates"
|
||||||
|
import { QuickRemarksClient } from "./clients/quick-remarks"
|
||||||
|
import { QuickNotesClient } from "./clients/quick-notes"
|
||||||
|
import { ShopRecommendationsClient } from "./clients/shop-recommendations"
|
||||||
import { JobCardsClient } from "./clients/job-cards"
|
import { JobCardsClient } from "./clients/job-cards"
|
||||||
import { PaymentsClient } from "./clients/payments"
|
import { PaymentModesClient } from "./clients/payment-modes"
|
||||||
|
import { PaymentReceivedClient } from "./clients/payment-received"
|
||||||
import { PartsClient } from "./clients/parts"
|
import { PartsClient } from "./clients/parts"
|
||||||
import { PurchaseOrdersClient } from "./clients/purchase-orders"
|
import { PurchaseOrdersClient } from "./clients/purchase-orders"
|
||||||
import { ServicesClient } from "./clients/services"
|
import { ServicesClient } from "./clients/services"
|
||||||
import { ServiceGroupsClient } from "./clients/service-groups"
|
import { ServiceGroupsClient } from "./clients/service-groups"
|
||||||
import { ExpensesClient } from "./clients/expenses"
|
import { ExpensesClient } from "./clients/expenses"
|
||||||
import { TasksClient } from "./clients/tasks"
|
import { TasksClient } from "./clients/tasks"
|
||||||
|
import { TaskTypesClient } from "./clients/task-types"
|
||||||
|
import { TaskSectionsClient } from "./clients/task-sections"
|
||||||
import { AppointmentsClient } from "./clients/appointments"
|
import { AppointmentsClient } from "./clients/appointments"
|
||||||
import { ShopTimingsClient } from "./clients/shop-timings"
|
import { ShopTimingsClient } from "./clients/shop-timings"
|
||||||
import { ShopCalendarsClient } from "./clients/shop-calendars"
|
import { ShopCalendarsClient } from "./clients/shop-calendars"
|
||||||
@ -47,6 +53,7 @@ import { ServiceGroupServicesClient } from "./clients/service-group-services"
|
|||||||
import { ServiceGroupPartsClient } from "./clients/service-group-parts"
|
import { ServiceGroupPartsClient } from "./clients/service-group-parts"
|
||||||
import { SettingsClient } from "./clients/settings"
|
import { SettingsClient } from "./clients/settings"
|
||||||
import { ConfigurationsClient } from "./clients/configurations"
|
import { ConfigurationsClient } from "./clients/configurations"
|
||||||
|
import { AutoGenerateClient } from "./clients/auto-generate"
|
||||||
|
|
||||||
export function createApi(options?: ApiClientOptions) {
|
export function createApi(options?: ApiClientOptions) {
|
||||||
return {
|
return {
|
||||||
@ -67,14 +74,20 @@ export function createApi(options?: ApiClientOptions) {
|
|||||||
labels: new LabelsClient(undefined, options),
|
labels: new LabelsClient(undefined, options),
|
||||||
insuranceTypes: new InsuranceTypesClient(undefined, options),
|
insuranceTypes: new InsuranceTypesClient(undefined, options),
|
||||||
estimates: new EstimatesClient(undefined, options),
|
estimates: new EstimatesClient(undefined, options),
|
||||||
|
quickRemarks: new QuickRemarksClient(undefined, options),
|
||||||
|
quickNotes: new QuickNotesClient(undefined, options),
|
||||||
|
shopRecommendations: new ShopRecommendationsClient(undefined, options),
|
||||||
jobCards: new JobCardsClient(undefined, options),
|
jobCards: new JobCardsClient(undefined, options),
|
||||||
payments: new PaymentsClient(undefined, options),
|
paymentModes: new PaymentModesClient(undefined, options),
|
||||||
|
paymentReceived: new PaymentReceivedClient(undefined, options),
|
||||||
parts: new PartsClient(undefined, options),
|
parts: new PartsClient(undefined, options),
|
||||||
purchaseOrders: new PurchaseOrdersClient(undefined, options),
|
purchaseOrders: new PurchaseOrdersClient(undefined, options),
|
||||||
services: new ServicesClient(undefined, options),
|
services: new ServicesClient(undefined, options),
|
||||||
serviceGroups: new ServiceGroupsClient(undefined, options),
|
serviceGroups: new ServiceGroupsClient(undefined, options),
|
||||||
expenses: new ExpensesClient(undefined, options),
|
expenses: new ExpensesClient(undefined, options),
|
||||||
tasks: new TasksClient(undefined, options),
|
tasks: new TasksClient(undefined, options),
|
||||||
|
taskTypes: new TaskTypesClient(undefined, options),
|
||||||
|
taskSections: new TaskSectionsClient(undefined, options),
|
||||||
appointments: new AppointmentsClient(undefined, options),
|
appointments: new AppointmentsClient(undefined, options),
|
||||||
shopTimings: new ShopTimingsClient(undefined, options),
|
shopTimings: new ShopTimingsClient(undefined, options),
|
||||||
shopCalendars: new ShopCalendarsClient(undefined, options),
|
shopCalendars: new ShopCalendarsClient(undefined, options),
|
||||||
@ -98,6 +111,7 @@ export function createApi(options?: ApiClientOptions) {
|
|||||||
serviceGroupParts: new ServiceGroupPartsClient(undefined, options),
|
serviceGroupParts: new ServiceGroupPartsClient(undefined, options),
|
||||||
settings: new SettingsClient(undefined, options),
|
settings: new SettingsClient(undefined, options),
|
||||||
configurations: new ConfigurationsClient(undefined, options),
|
configurations: new ConfigurationsClient(undefined, options),
|
||||||
|
autoGenerate: new AutoGenerateClient(undefined, options),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { ApiClient, type ApiClientOptions } from "../infra/client"
|
import { CrudClient } from "../infra/crud-client"
|
||||||
|
import type { ApiClientOptions } from "../infra/client"
|
||||||
import type { ApiPath, ApiRequestBody } from "../infra/types"
|
import type { ApiPath, ApiRequestBody } from "../infra/types"
|
||||||
import type { ApiListQueryParams } from "../contracts/types"
|
|
||||||
|
|
||||||
export const APPOINTMENT_ROUTES = {
|
export const APPOINTMENT_ROUTES = {
|
||||||
INDEX: "/api/appointments",
|
INDEX: "/api/appointments",
|
||||||
@ -9,25 +9,12 @@ export const APPOINTMENT_ROUTES = {
|
|||||||
CHANGE_STATUS: "/api/appointments/{id}/change-status",
|
CHANGE_STATUS: "/api/appointments/{id}/change-status",
|
||||||
} as const satisfies Record<string, ApiPath>
|
} as const satisfies Record<string, ApiPath>
|
||||||
|
|
||||||
export class AppointmentsClient extends ApiClient {
|
export class AppointmentsClient extends CrudClient<
|
||||||
|
typeof APPOINTMENT_ROUTES.INDEX,
|
||||||
|
typeof APPOINTMENT_ROUTES.BY_ID
|
||||||
|
> {
|
||||||
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
|
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
|
||||||
super(baseUrl, defaultOptions)
|
super(baseUrl, defaultOptions, APPOINTMENT_ROUTES.INDEX, APPOINTMENT_ROUTES.BY_ID)
|
||||||
}
|
|
||||||
|
|
||||||
async list(query?: ApiListQueryParams) {
|
|
||||||
return this.get(APPOINTMENT_ROUTES.INDEX, query ? { query } as never : undefined)
|
|
||||||
}
|
|
||||||
|
|
||||||
async create(payload: ApiRequestBody<typeof APPOINTMENT_ROUTES.INDEX, "post">) {
|
|
||||||
return this.post(APPOINTMENT_ROUTES.INDEX, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
async update(id: string, payload: ApiRequestBody<typeof APPOINTMENT_ROUTES.BY_ID, "put">) {
|
|
||||||
return this.put(APPOINTMENT_ROUTES.BY_ID, payload, { params: { id } })
|
|
||||||
}
|
|
||||||
|
|
||||||
async destroy(id: string) {
|
|
||||||
return this.delete(APPOINTMENT_ROUTES.BY_ID, { params: { id } })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async unlinkJobCard(id: string, payload: ApiRequestBody<typeof APPOINTMENT_ROUTES.UNLINK_JOB_CARD, "post">) {
|
async unlinkJobCard(id: string, payload: ApiRequestBody<typeof APPOINTMENT_ROUTES.UNLINK_JOB_CARD, "post">) {
|
||||||
|
|||||||
14
packages/api/src/clients/auto-generate.ts
Normal file
14
packages/api/src/clients/auto-generate.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import type { ApiPath } from "../infra/types"
|
||||||
|
import { ApiClient } from "../infra/client"
|
||||||
|
|
||||||
|
export const AUTO_GENERATE_ROUTES = {
|
||||||
|
BY_TABLE: "/api/auto-generate/{table}",
|
||||||
|
} as const satisfies Record<string, ApiPath>
|
||||||
|
|
||||||
|
export class AutoGenerateClient extends ApiClient {
|
||||||
|
async generate(table: string) {
|
||||||
|
return this.get(AUTO_GENERATE_ROUTES.BY_TABLE, {
|
||||||
|
params: { table },
|
||||||
|
} as never)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { ApiClient, type ApiClientOptions } from "../infra/client"
|
import { CrudClient } from "../infra/crud-client"
|
||||||
|
import type { ApiClientOptions } from "../infra/client"
|
||||||
import type { ApiPath, ApiRequestBody } from "../infra/types"
|
import type { ApiPath, ApiRequestBody } from "../infra/types"
|
||||||
import type { ApiListQueryParams } from "../contracts/types"
|
|
||||||
|
|
||||||
export const DEPARTMENT_ROUTES = {
|
export const DEPARTMENT_ROUTES = {
|
||||||
INDEX: "/api/departments",
|
INDEX: "/api/departments",
|
||||||
@ -9,25 +9,12 @@ export const DEPARTMENT_ROUTES = {
|
|||||||
REMOVE_FAVORITE: "/api/remove-favorite-department",
|
REMOVE_FAVORITE: "/api/remove-favorite-department",
|
||||||
} as const satisfies Record<string, ApiPath>
|
} as const satisfies Record<string, ApiPath>
|
||||||
|
|
||||||
export class DepartmentsClient extends ApiClient {
|
export class DepartmentsClient extends CrudClient<
|
||||||
|
typeof DEPARTMENT_ROUTES.INDEX,
|
||||||
|
typeof DEPARTMENT_ROUTES.BY_ID
|
||||||
|
> {
|
||||||
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
|
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
|
||||||
super(baseUrl, defaultOptions)
|
super(baseUrl, defaultOptions, DEPARTMENT_ROUTES.INDEX, DEPARTMENT_ROUTES.BY_ID)
|
||||||
}
|
|
||||||
|
|
||||||
async list(query?: ApiListQueryParams) {
|
|
||||||
return this.get(DEPARTMENT_ROUTES.INDEX, query ? { query } as never : undefined)
|
|
||||||
}
|
|
||||||
|
|
||||||
async create(payload: ApiRequestBody<typeof DEPARTMENT_ROUTES.INDEX, "post">) {
|
|
||||||
return this.post(DEPARTMENT_ROUTES.INDEX, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
async update(id: string, payload: ApiRequestBody<typeof DEPARTMENT_ROUTES.BY_ID, "put">) {
|
|
||||||
return this.put(DEPARTMENT_ROUTES.BY_ID, payload, { params: { id } })
|
|
||||||
}
|
|
||||||
|
|
||||||
async destroy(id: string) {
|
|
||||||
return this.delete(DEPARTMENT_ROUTES.BY_ID, { params: { id } })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async setFavorite(payload: ApiRequestBody<typeof DEPARTMENT_ROUTES.SET_FAVORITE, "post">) {
|
async setFavorite(payload: ApiRequestBody<typeof DEPARTMENT_ROUTES.SET_FAVORITE, "post">) {
|
||||||
|
|||||||
@ -1,24 +1,18 @@
|
|||||||
import { ApiClient, type ApiClientOptions } from "../infra/client"
|
import { CrudClient } from "../infra/crud-client"
|
||||||
import type { ApiPath, ApiRequestBody } from "../infra/types"
|
import type { ApiClientOptions } from "../infra/client"
|
||||||
import type { ApiListQueryParams } from "../contracts/types"
|
import type { ApiPath } from "../infra/types"
|
||||||
|
|
||||||
export const ESTIMATE_ROUTES = {
|
export const ESTIMATE_ROUTES = {
|
||||||
INDEX: "/api/estimates",
|
INDEX: "/api/estimates",
|
||||||
BY_ID: "/api/estimates/{id}",
|
BY_ID: "/api/estimates/{id}",
|
||||||
QUICK_REMARKS: "/api/quick-remark",
|
|
||||||
QUICK_REMARK_BY_ID: "/api/quick-remark/{id}",
|
|
||||||
QUICK_NOTES: "/api/quick-notes",
|
|
||||||
QUICK_NOTE_BY_ID: "/api/quick-notes/{id}",
|
|
||||||
} as const satisfies Record<string, ApiPath>
|
} as const satisfies Record<string, ApiPath>
|
||||||
|
|
||||||
export class EstimatesClient extends ApiClient {
|
export class EstimatesClient extends CrudClient<
|
||||||
|
typeof ESTIMATE_ROUTES.INDEX,
|
||||||
|
typeof ESTIMATE_ROUTES.BY_ID
|
||||||
|
> {
|
||||||
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
|
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
|
||||||
super(baseUrl, defaultOptions)
|
super(baseUrl, defaultOptions, ESTIMATE_ROUTES.INDEX, ESTIMATE_ROUTES.BY_ID)
|
||||||
}
|
|
||||||
|
|
||||||
// ── Estimates ──
|
|
||||||
async list(query?: ApiListQueryParams) {
|
|
||||||
return this.get(ESTIMATE_ROUTES.INDEX, query ? { query } as never : undefined)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: GET /api/estimates/{id} is not in the OpenAPI schema.
|
// Note: GET /api/estimates/{id} is not in the OpenAPI schema.
|
||||||
@ -27,50 +21,4 @@ export class EstimatesClient extends ApiClient {
|
|||||||
const data = await this.get(ESTIMATE_ROUTES.INDEX, { query: { id } } as never)
|
const data = await this.get(ESTIMATE_ROUTES.INDEX, { query: { id } } as never)
|
||||||
return {...data, data: (data as any)?.data?.[0] ?? null }
|
return {...data, data: (data as any)?.data?.[0] ?? null }
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(payload: ApiRequestBody<typeof ESTIMATE_ROUTES.INDEX, "post">) {
|
|
||||||
return this.post(ESTIMATE_ROUTES.INDEX, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
async update(id: string, payload: ApiRequestBody<typeof ESTIMATE_ROUTES.BY_ID, "put">) {
|
|
||||||
return this.put(ESTIMATE_ROUTES.BY_ID, payload, { params: { id } })
|
|
||||||
}
|
|
||||||
|
|
||||||
async destroy(id: string) {
|
|
||||||
return this.delete(ESTIMATE_ROUTES.BY_ID, { params: { id } })
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Quick Remarks ──
|
|
||||||
async listQuickRemarks(query?: ApiListQueryParams) {
|
|
||||||
return this.get(ESTIMATE_ROUTES.QUICK_REMARKS, query ? { query } as never : undefined)
|
|
||||||
}
|
|
||||||
|
|
||||||
async createQuickRemark(payload: ApiRequestBody<typeof ESTIMATE_ROUTES.QUICK_REMARKS, "post">) {
|
|
||||||
return this.post(ESTIMATE_ROUTES.QUICK_REMARKS, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateQuickRemark(id: string, payload: ApiRequestBody<typeof ESTIMATE_ROUTES.QUICK_REMARK_BY_ID, "put">) {
|
|
||||||
return this.put(ESTIMATE_ROUTES.QUICK_REMARK_BY_ID, payload, { params: { id } })
|
|
||||||
}
|
|
||||||
|
|
||||||
async destroyQuickRemark(id: string) {
|
|
||||||
return this.delete(ESTIMATE_ROUTES.QUICK_REMARK_BY_ID, { params: { id } })
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Quick Notes ──
|
|
||||||
async listQuickNotes(query?: ApiListQueryParams) {
|
|
||||||
return this.get(ESTIMATE_ROUTES.QUICK_NOTES, query ? { query } as never : undefined)
|
|
||||||
}
|
|
||||||
|
|
||||||
async createQuickNote(payload: ApiRequestBody<typeof ESTIMATE_ROUTES.QUICK_NOTES, "post">) {
|
|
||||||
return this.post(ESTIMATE_ROUTES.QUICK_NOTES, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateQuickNote(id: string, payload: ApiRequestBody<typeof ESTIMATE_ROUTES.QUICK_NOTE_BY_ID, "put">) {
|
|
||||||
return this.put(ESTIMATE_ROUTES.QUICK_NOTE_BY_ID, payload, { params: { id } })
|
|
||||||
}
|
|
||||||
|
|
||||||
async destroyQuickNote(id: string) {
|
|
||||||
return this.delete(ESTIMATE_ROUTES.QUICK_NOTE_BY_ID, { params: { id } })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { ApiClient, type ApiClientOptions } from "../infra/client"
|
import { CrudClient } from "../infra/crud-client"
|
||||||
|
import type { ApiClientOptions } from "../infra/client"
|
||||||
import type { ApiPath, ApiRequestBody } from "../infra/types"
|
import type { ApiPath, ApiRequestBody } from "../infra/types"
|
||||||
import type { ApiListQueryParams } from "../contracts/types"
|
import type { ApiListQueryParams } from "../contracts/types"
|
||||||
|
|
||||||
@ -14,9 +15,12 @@ export const EXPENSE_ROUTES = {
|
|||||||
EXPENSE_BY_ID: "/api/expenses/{id}",
|
EXPENSE_BY_ID: "/api/expenses/{id}",
|
||||||
} as const satisfies Record<string, ApiPath>
|
} as const satisfies Record<string, ApiPath>
|
||||||
|
|
||||||
export class ExpensesClient extends ApiClient {
|
export class ExpensesClient extends CrudClient<
|
||||||
|
typeof EXPENSE_ROUTES.EXPENSES,
|
||||||
|
typeof EXPENSE_ROUTES.EXPENSE_BY_ID
|
||||||
|
> {
|
||||||
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
|
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
|
||||||
super(baseUrl, defaultOptions)
|
super(baseUrl, defaultOptions, EXPENSE_ROUTES.EXPENSES, EXPENSE_ROUTES.EXPENSE_BY_ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Expense Items ──
|
// ── Expense Items ──
|
||||||
@ -59,35 +63,18 @@ export class ExpensesClient extends ApiClient {
|
|||||||
|
|
||||||
// ── Expenses ──
|
// ── Expenses ──
|
||||||
async listExpenses(query?: ApiListQueryParams) {
|
async listExpenses(query?: ApiListQueryParams) {
|
||||||
return this.get(EXPENSE_ROUTES.EXPENSES, query ? { query } as never : undefined)
|
return this.list(query)
|
||||||
}
|
}
|
||||||
|
|
||||||
async createExpense(payload: ApiRequestBody<typeof EXPENSE_ROUTES.EXPENSES, "post">) {
|
async createExpense(payload: ApiRequestBody<typeof EXPENSE_ROUTES.EXPENSES, "post">) {
|
||||||
return this.post(EXPENSE_ROUTES.EXPENSES, payload)
|
return this.create(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateExpense(id: string, payload: ApiRequestBody<typeof EXPENSE_ROUTES.EXPENSE_BY_ID, "put">) {
|
async updateExpense(id: string, payload: ApiRequestBody<typeof EXPENSE_ROUTES.EXPENSE_BY_ID, "put">) {
|
||||||
return this.put(EXPENSE_ROUTES.EXPENSE_BY_ID, payload, { params: { id } })
|
return this.update(id, payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
async destroyExpense(id: string) {
|
async destroyExpense(id: string) {
|
||||||
return this.delete(EXPENSE_ROUTES.EXPENSE_BY_ID, { params: { id } })
|
return this.destroy(id)
|
||||||
}
|
|
||||||
|
|
||||||
// ── Standard CRUD aliases (for ResourcePage compatibility) ──
|
|
||||||
async list(query?: ApiListQueryParams) {
|
|
||||||
return this.listExpenses(query)
|
|
||||||
}
|
|
||||||
|
|
||||||
async create(payload: ApiRequestBody<typeof EXPENSE_ROUTES.EXPENSES, "post">) {
|
|
||||||
return this.createExpense(payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
async update(id: string, payload: ApiRequestBody<typeof EXPENSE_ROUTES.EXPENSE_BY_ID, "put">) {
|
|
||||||
return this.updateExpense(id, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
async destroy(id: string) {
|
|
||||||
return this.destroyExpense(id)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,14 +15,20 @@ export { InspectionsClient, INSPECTION_ROUTES } from "./inspections"
|
|||||||
export { LabelsClient, LABEL_ROUTES } from "./labels"
|
export { LabelsClient, LABEL_ROUTES } from "./labels"
|
||||||
export { InsuranceTypesClient, INSURANCE_TYPE_ROUTES } from "./insurance-types"
|
export { InsuranceTypesClient, INSURANCE_TYPE_ROUTES } from "./insurance-types"
|
||||||
export { EstimatesClient, ESTIMATE_ROUTES } from "./estimates"
|
export { EstimatesClient, ESTIMATE_ROUTES } from "./estimates"
|
||||||
|
export { QuickRemarksClient, QUICK_REMARK_ROUTES } from "./quick-remarks"
|
||||||
|
export { QuickNotesClient, QUICK_NOTE_ROUTES } from "./quick-notes"
|
||||||
|
export { ShopRecommendationsClient, SHOP_RECOMMENDATION_ROUTES } from "./shop-recommendations"
|
||||||
export { JobCardsClient, JOB_CARD_ROUTES } from "./job-cards"
|
export { JobCardsClient, JOB_CARD_ROUTES } from "./job-cards"
|
||||||
export { PaymentsClient, PAYMENT_ROUTES } from "./payments"
|
export { PaymentModesClient, PAYMENT_MODE_ROUTES } from "./payment-modes"
|
||||||
|
export { PaymentReceivedClient, PAYMENT_RECEIVED_ROUTES } from "./payment-received"
|
||||||
export { PartsClient, PARTS_ROUTES } from "./parts"
|
export { PartsClient, PARTS_ROUTES } from "./parts"
|
||||||
export { PurchaseOrdersClient, PURCHASE_ORDER_ROUTES } from "./purchase-orders"
|
export { PurchaseOrdersClient, PURCHASE_ORDER_ROUTES } from "./purchase-orders"
|
||||||
export { ServicesClient, SERVICE_ROUTES } from "./services"
|
export { ServicesClient, SERVICE_ROUTES } from "./services"
|
||||||
export { ServiceGroupsClient, SERVICE_GROUP_ROUTES } from "./service-groups"
|
export { ServiceGroupsClient, SERVICE_GROUP_ROUTES } from "./service-groups"
|
||||||
export { ExpensesClient, EXPENSE_ROUTES } from "./expenses"
|
export { ExpensesClient, EXPENSE_ROUTES } from "./expenses"
|
||||||
export { TasksClient, TASK_ROUTES } from "./tasks"
|
export { TasksClient, TASK_ROUTES } from "./tasks"
|
||||||
|
export { TaskTypesClient, TASK_TYPE_ROUTES } from "./task-types"
|
||||||
|
export { TaskSectionsClient, TASK_SECTION_ROUTES } from "./task-sections"
|
||||||
export { AppointmentsClient, APPOINTMENT_ROUTES } from "./appointments"
|
export { AppointmentsClient, APPOINTMENT_ROUTES } from "./appointments"
|
||||||
export { ShopTimingsClient, SHOP_TIMING_ROUTES } from "./shop-timings"
|
export { ShopTimingsClient, SHOP_TIMING_ROUTES } from "./shop-timings"
|
||||||
export { ShopCalendarsClient, SHOP_CALENDAR_ROUTES } from "./shop-calendars"
|
export { ShopCalendarsClient, SHOP_CALENDAR_ROUTES } from "./shop-calendars"
|
||||||
@ -46,3 +52,4 @@ export { ServiceGroupServicesClient, SERVICE_GROUP_SERVICE_ROUTES } from "./serv
|
|||||||
export { ServiceGroupPartsClient, SERVICE_GROUP_PART_ROUTES } from "./service-group-parts"
|
export { ServiceGroupPartsClient, SERVICE_GROUP_PART_ROUTES } from "./service-group-parts"
|
||||||
export { SettingsClient, SETTINGS_ROUTES } from "./settings"
|
export { SettingsClient, SETTINGS_ROUTES } from "./settings"
|
||||||
export { ConfigurationsClient, CONFIGURATION_ROUTES } from "./configurations"
|
export { ConfigurationsClient, CONFIGURATION_ROUTES } from "./configurations"
|
||||||
|
export { AutoGenerateClient, AUTO_GENERATE_ROUTES } from "./auto-generate"
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { ApiClient, type ApiClientOptions } from "../infra/client"
|
import { CrudClient } from "../infra/crud-client"
|
||||||
|
import type { ApiClientOptions } from "../infra/client"
|
||||||
import type { ApiPath, ApiRequestBody } from "../infra/types"
|
import type { ApiPath, ApiRequestBody } from "../infra/types"
|
||||||
import type { ApiListQueryParams } from "../contracts/types"
|
import type { ApiListQueryParams } from "../contracts/types"
|
||||||
|
|
||||||
@ -19,9 +20,12 @@ export const INSPECTION_ROUTES = {
|
|||||||
CHECKPOINT_MEDIA: "/api/inspection-check-points/{id}/media",
|
CHECKPOINT_MEDIA: "/api/inspection-check-points/{id}/media",
|
||||||
} as const satisfies Record<string, ApiPath>
|
} as const satisfies Record<string, ApiPath>
|
||||||
|
|
||||||
export class InspectionsClient extends ApiClient {
|
export class InspectionsClient extends CrudClient<
|
||||||
|
typeof INSPECTION_ROUTES.INDEX,
|
||||||
|
typeof INSPECTION_ROUTES.BY_ID
|
||||||
|
> {
|
||||||
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
|
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
|
||||||
super(baseUrl, defaultOptions)
|
super(baseUrl, defaultOptions, INSPECTION_ROUTES.INDEX, INSPECTION_ROUTES.BY_ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Categories ──
|
// ── Categories ──
|
||||||
@ -41,28 +45,11 @@ export class InspectionsClient extends ApiClient {
|
|||||||
return this.delete(INSPECTION_ROUTES.CATEGORY_BY_ID, { params: { id } })
|
return this.delete(INSPECTION_ROUTES.CATEGORY_BY_ID, { params: { id } })
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Inspections ──
|
|
||||||
async list(query?: ApiListQueryParams) {
|
|
||||||
return this.get(INSPECTION_ROUTES.INDEX, query ? { query } as never : undefined)
|
|
||||||
}
|
|
||||||
|
|
||||||
async getById(id: string) {
|
async getById(id: string) {
|
||||||
const res = await this.list({ query: { id } } as never)
|
const res = await super.list({ query: { id } } as never)
|
||||||
return {...res, data: res.data[0] }
|
return {...res, data: res.data[0] }
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(payload: ApiRequestBody<typeof INSPECTION_ROUTES.INDEX, "post">) {
|
|
||||||
return this.post(INSPECTION_ROUTES.INDEX, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
async update(id: string, payload: ApiRequestBody<typeof INSPECTION_ROUTES.BY_ID, "put">) {
|
|
||||||
return this.put(INSPECTION_ROUTES.BY_ID, payload, { params: { id } })
|
|
||||||
}
|
|
||||||
|
|
||||||
async destroy(id: string) {
|
|
||||||
return this.delete(INSPECTION_ROUTES.BY_ID, { params: { id } })
|
|
||||||
}
|
|
||||||
|
|
||||||
async changeStatus(payload: ApiRequestBody<typeof INSPECTION_ROUTES.CHANGE_STATUS, "post">) {
|
async changeStatus(payload: ApiRequestBody<typeof INSPECTION_ROUTES.CHANGE_STATUS, "post">) {
|
||||||
return this.post(INSPECTION_ROUTES.CHANGE_STATUS, payload)
|
return this.post(INSPECTION_ROUTES.CHANGE_STATUS, payload)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,30 +1,17 @@
|
|||||||
import { ApiClient, type ApiClientOptions } from "../infra/client"
|
import { CrudClient } from "../infra/crud-client"
|
||||||
import type { ApiPath, ApiRequestBody } from "../infra/types"
|
import type { ApiClientOptions } from "../infra/client"
|
||||||
import type { ApiListQueryParams } from "../contracts/types"
|
import type { ApiPath } from "../infra/types"
|
||||||
|
|
||||||
export const INSURANCE_TYPE_ROUTES = {
|
export const INSURANCE_TYPE_ROUTES = {
|
||||||
INDEX: "/api/insurance-types",
|
INDEX: "/api/insurance-types",
|
||||||
BY_ID: "/api/insurance-types/{id}",
|
BY_ID: "/api/insurance-types/{id}",
|
||||||
} as const satisfies Record<string, ApiPath>
|
} as const satisfies Record<string, ApiPath>
|
||||||
|
|
||||||
export class InsuranceTypesClient extends ApiClient {
|
export class InsuranceTypesClient extends CrudClient<
|
||||||
|
typeof INSURANCE_TYPE_ROUTES.INDEX,
|
||||||
|
typeof INSURANCE_TYPE_ROUTES.BY_ID
|
||||||
|
> {
|
||||||
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
|
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
|
||||||
super(baseUrl, defaultOptions)
|
super(baseUrl, defaultOptions, INSURANCE_TYPE_ROUTES.INDEX, INSURANCE_TYPE_ROUTES.BY_ID)
|
||||||
}
|
|
||||||
|
|
||||||
async list(query?: ApiListQueryParams) {
|
|
||||||
return this.get(INSURANCE_TYPE_ROUTES.INDEX, query ? { query } as never : undefined)
|
|
||||||
}
|
|
||||||
|
|
||||||
async create(payload: ApiRequestBody<typeof INSURANCE_TYPE_ROUTES.INDEX, "post">) {
|
|
||||||
return this.post(INSURANCE_TYPE_ROUTES.INDEX, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
async update(id: string, payload: ApiRequestBody<typeof INSURANCE_TYPE_ROUTES.BY_ID, "put">) {
|
|
||||||
return this.put(INSURANCE_TYPE_ROUTES.BY_ID, payload, { params: { id } })
|
|
||||||
}
|
|
||||||
|
|
||||||
async destroy(id: string) {
|
|
||||||
return this.delete(INSURANCE_TYPE_ROUTES.BY_ID, { params: { id } })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { CrudClient } from "../infra/crud-client"
|
import { CrudClient } from "../infra/crud-client"
|
||||||
import { ApiClient, type ApiClientOptions } from "../infra/client"
|
import { type ApiClientOptions } from "../infra/client"
|
||||||
import type { ApiPath, ApiRequestBody } from "../infra/types"
|
import type { ApiPath, ApiRequestBody } from "../infra/types"
|
||||||
import type { ApiListQueryParams } from "../contracts/types"
|
import type { ApiListQueryParams } from "../contracts/types"
|
||||||
|
|
||||||
@ -16,30 +16,12 @@ export const INVOICE_ROUTES = {
|
|||||||
LABEL_BY_ID: "/api/invoice-labels/{id}",
|
LABEL_BY_ID: "/api/invoice-labels/{id}",
|
||||||
} as const satisfies Record<string, ApiPath>
|
} as const satisfies Record<string, ApiPath>
|
||||||
|
|
||||||
export class InvoicesClient extends ApiClient {
|
export class InvoicesClient extends CrudClient<
|
||||||
|
typeof INVOICE_ROUTES.INDEX,
|
||||||
|
typeof INVOICE_ROUTES.BY_ID
|
||||||
|
> {
|
||||||
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
|
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
|
||||||
super(baseUrl, defaultOptions)
|
super(baseUrl, defaultOptions, INVOICE_ROUTES.INDEX, INVOICE_ROUTES.BY_ID)
|
||||||
}
|
|
||||||
|
|
||||||
// ── Invoices ──
|
|
||||||
async list(query?: ApiListQueryParams) {
|
|
||||||
return this.get(INVOICE_ROUTES.INDEX, query ? { query } as never : undefined)
|
|
||||||
}
|
|
||||||
|
|
||||||
async show(id: string) {
|
|
||||||
return this.get(INVOICE_ROUTES.BY_ID, { params: { id } } as never)
|
|
||||||
}
|
|
||||||
|
|
||||||
async create(payload: ApiRequestBody<typeof INVOICE_ROUTES.INDEX, "post">) {
|
|
||||||
return this.post(INVOICE_ROUTES.INDEX, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
async update(id: string, payload: ApiRequestBody<typeof INVOICE_ROUTES.BY_ID, "put">) {
|
|
||||||
return this.put(INVOICE_ROUTES.BY_ID, payload, { params: { id } })
|
|
||||||
}
|
|
||||||
|
|
||||||
async destroy(id: string) {
|
|
||||||
return this.delete(INVOICE_ROUTES.BY_ID, { params: { id } })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Attachments ──
|
// ── Attachments ──
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { ApiClient, type ApiClientOptions } from "../infra/client"
|
import { CrudClient } from "../infra/crud-client"
|
||||||
import type { ApiPath, ApiRequestBody } from "../infra/types"
|
import type { ApiClientOptions } from "../infra/client"
|
||||||
import type { ApiListQueryParams } from "../contracts/types"
|
import type { ApiOperationResponse, ApiPath, ApiRequestBody, ApiResponse } from "../infra/types"
|
||||||
|
import { ApiBaseResponse } from "src/contracts/types"
|
||||||
|
|
||||||
export const JOB_CARD_ROUTES = {
|
export const JOB_CARD_ROUTES = {
|
||||||
INDEX: "/api/job-cards",
|
INDEX: "/api/job-cards",
|
||||||
@ -17,32 +18,31 @@ export const JOB_CARD_ROUTES = {
|
|||||||
DELETE_ATTACHMENT: "/api/job-cards/{id}/delete-attachment",
|
DELETE_ATTACHMENT: "/api/job-cards/{id}/delete-attachment",
|
||||||
CHANGE_SERVICE_WRITER: "/api/job-cards/{id}/change-service-writer-id",
|
CHANGE_SERVICE_WRITER: "/api/job-cards/{id}/change-service-writer-id",
|
||||||
CHANGE_SALES_PERSON: "/api/job-cards/{id}/change-sales-person-id",
|
CHANGE_SALES_PERSON: "/api/job-cards/{id}/change-sales-person-id",
|
||||||
|
GET_PARTS: "/api/job-cards/{id}/get-parts",
|
||||||
|
ADD_PART: "/api/job-cards/{id}/add-part",
|
||||||
|
UPDATE_PART: "/api/job-cards/{id}/update-part",
|
||||||
|
DELETE_PART: "/api/job-cards/{id}/delete-part",
|
||||||
} as const satisfies Record<string, ApiPath>
|
} as const satisfies Record<string, ApiPath>
|
||||||
|
|
||||||
export class JobCardsClient extends ApiClient {
|
|
||||||
|
export type JobCardShowData = ApiResponse<typeof JOB_CARD_ROUTES.BY_ID, "get">['data'] & {
|
||||||
|
purchase_orders_count: number
|
||||||
|
bills_count: number
|
||||||
|
expenses_count: number
|
||||||
|
tasks_count: number
|
||||||
|
appointments_count: number
|
||||||
|
parts_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export class JobCardsClient extends CrudClient<
|
||||||
|
typeof JOB_CARD_ROUTES.INDEX,
|
||||||
|
typeof JOB_CARD_ROUTES.BY_ID,
|
||||||
|
{
|
||||||
|
showResponse: ApiBaseResponse<JobCardShowData>
|
||||||
|
}
|
||||||
|
> {
|
||||||
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
|
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
|
||||||
super(baseUrl, defaultOptions)
|
super(baseUrl, defaultOptions, JOB_CARD_ROUTES.INDEX, JOB_CARD_ROUTES.BY_ID)
|
||||||
}
|
|
||||||
|
|
||||||
async list(query?: ApiListQueryParams) {
|
|
||||||
return this.get(JOB_CARD_ROUTES.INDEX, query ? { query } as never : undefined)
|
|
||||||
}
|
|
||||||
|
|
||||||
async show(id: string) {
|
|
||||||
const res = await this.get(JOB_CARD_ROUTES.INDEX, { params: { id }, query: { id } } as never)
|
|
||||||
return { ...res, data: res.data?.[0] ?? null, }
|
|
||||||
}
|
|
||||||
|
|
||||||
async create(payload: ApiRequestBody<typeof JOB_CARD_ROUTES.INDEX, "post">) {
|
|
||||||
return this.post(JOB_CARD_ROUTES.INDEX, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
async update(id: string, payload: ApiRequestBody<typeof JOB_CARD_ROUTES.BY_ID, "put">) {
|
|
||||||
return this.put(JOB_CARD_ROUTES.BY_ID, payload, { params: { id } })
|
|
||||||
}
|
|
||||||
|
|
||||||
async destroy(id: string) {
|
|
||||||
return this.delete(JOB_CARD_ROUTES.BY_ID, { params: { id } })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async changeDate(id: string, payload: ApiRequestBody<typeof JOB_CARD_ROUTES.CHANGE_DATE, "post">) {
|
async changeDate(id: string, payload: ApiRequestBody<typeof JOB_CARD_ROUTES.CHANGE_DATE, "post">) {
|
||||||
@ -61,8 +61,8 @@ export class JobCardsClient extends ApiClient {
|
|||||||
return this.post(JOB_CARD_ROUTES.EDIT_CUSTOMER_REMARK, payload, { params: { id } })
|
return this.post(JOB_CARD_ROUTES.EDIT_CUSTOMER_REMARK, payload, { params: { id } })
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteCustomerRemark(id: string) {
|
async deleteCustomerRemark(id: string, customerRemarkId: number) {
|
||||||
return this.delete(JOB_CARD_ROUTES.DELETE_CUSTOMER_REMARK, { params: { id } })
|
return this.delete(JOB_CARD_ROUTES.DELETE_CUSTOMER_REMARK, { params: { id }, body: { customer_remark_id: customerRemarkId } } as never)
|
||||||
}
|
}
|
||||||
|
|
||||||
async addShopRecommendation(id: string, payload: ApiRequestBody<typeof JOB_CARD_ROUTES.ADD_SHOP_RECOMMENDATION, "post">) {
|
async addShopRecommendation(id: string, payload: ApiRequestBody<typeof JOB_CARD_ROUTES.ADD_SHOP_RECOMMENDATION, "post">) {
|
||||||
@ -73,8 +73,8 @@ export class JobCardsClient extends ApiClient {
|
|||||||
return this.post(JOB_CARD_ROUTES.EDIT_SHOP_RECOMMENDATION, payload, { params: { id } })
|
return this.post(JOB_CARD_ROUTES.EDIT_SHOP_RECOMMENDATION, payload, { params: { id } })
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteShopRecommendation(id: string) {
|
async deleteShopRecommendation(id: string, shopRecommendationId: number) {
|
||||||
return this.delete(JOB_CARD_ROUTES.DELETE_SHOP_RECOMMENDATION, { params: { id } })
|
return this.delete(JOB_CARD_ROUTES.DELETE_SHOP_RECOMMENDATION, { params: { id }, body: { shop_recommendation_id: shopRecommendationId } } as never)
|
||||||
}
|
}
|
||||||
|
|
||||||
async addAttachment(id: string, files: File[]) {
|
async addAttachment(id: string, files: File[]) {
|
||||||
@ -96,4 +96,20 @@ export class JobCardsClient extends ApiClient {
|
|||||||
async changeSalesPerson(id: string, payload: ApiRequestBody<typeof JOB_CARD_ROUTES.CHANGE_SALES_PERSON, "post">) {
|
async changeSalesPerson(id: string, payload: ApiRequestBody<typeof JOB_CARD_ROUTES.CHANGE_SALES_PERSON, "post">) {
|
||||||
return this.post(JOB_CARD_ROUTES.CHANGE_SALES_PERSON, payload, { params: { id } })
|
return this.post(JOB_CARD_ROUTES.CHANGE_SALES_PERSON, payload, { params: { id } })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getParts(id: string, params?: Record<string, unknown>) {
|
||||||
|
return this.get(JOB_CARD_ROUTES.GET_PARTS, { params: { id }, query: params as any })
|
||||||
|
}
|
||||||
|
|
||||||
|
async addPart(id: string, payload: ApiRequestBody<typeof JOB_CARD_ROUTES.ADD_PART, "post">) {
|
||||||
|
return this.post(JOB_CARD_ROUTES.ADD_PART, payload, { params: { id } })
|
||||||
|
}
|
||||||
|
|
||||||
|
async updatePart(id: string, payload: ApiRequestBody<typeof JOB_CARD_ROUTES.UPDATE_PART, "put">) {
|
||||||
|
return this.put(JOB_CARD_ROUTES.UPDATE_PART, payload, { params: { id } })
|
||||||
|
}
|
||||||
|
|
||||||
|
async deletePart(id: string, jobCardPartId: number) {
|
||||||
|
return this.delete(JOB_CARD_ROUTES.DELETE_PART, { params: { id }, body: { job_card_part_id: jobCardPartId } } as never)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,30 +1,17 @@
|
|||||||
import { ApiClient, type ApiClientOptions } from "../infra/client"
|
import { CrudClient } from "../infra/crud-client"
|
||||||
import type { ApiPath, ApiRequestBody } from "../infra/types"
|
import type { ApiClientOptions } from "../infra/client"
|
||||||
import type { ApiListQueryParams } from "../contracts/types"
|
import type { ApiPath } from "../infra/types"
|
||||||
|
|
||||||
export const LABEL_ROUTES = {
|
export const LABEL_ROUTES = {
|
||||||
INDEX: "/api/labels",
|
INDEX: "/api/labels",
|
||||||
BY_ID: "/api/labels/{id}",
|
BY_ID: "/api/labels/{id}",
|
||||||
} as const satisfies Record<string, ApiPath>
|
} as const satisfies Record<string, ApiPath>
|
||||||
|
|
||||||
export class LabelsClient extends ApiClient {
|
export class LabelsClient extends CrudClient<
|
||||||
|
typeof LABEL_ROUTES.INDEX,
|
||||||
|
typeof LABEL_ROUTES.BY_ID
|
||||||
|
> {
|
||||||
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
|
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
|
||||||
super(baseUrl, defaultOptions)
|
super(baseUrl, defaultOptions, LABEL_ROUTES.INDEX, LABEL_ROUTES.BY_ID)
|
||||||
}
|
|
||||||
|
|
||||||
async list(query?: ApiListQueryParams) {
|
|
||||||
return this.get(LABEL_ROUTES.INDEX, query ? { query } as never : undefined)
|
|
||||||
}
|
|
||||||
|
|
||||||
async create(payload: ApiRequestBody<typeof LABEL_ROUTES.INDEX, "post">) {
|
|
||||||
return this.post(LABEL_ROUTES.INDEX, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
async update(id: string, payload: ApiRequestBody<typeof LABEL_ROUTES.BY_ID, "put">) {
|
|
||||||
return this.put(LABEL_ROUTES.BY_ID, payload, { params: { id } })
|
|
||||||
}
|
|
||||||
|
|
||||||
async destroy(id: string) {
|
|
||||||
return this.delete(LABEL_ROUTES.BY_ID, { params: { id } })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { ApiClient, type ApiClientOptions } from "../infra/client"
|
import { CrudClient } from "../infra/crud-client"
|
||||||
|
import type { ApiClientOptions } from "../infra/client"
|
||||||
import type { ApiPath, ApiRequestBody } from "../infra/types"
|
import type { ApiPath, ApiRequestBody } from "../infra/types"
|
||||||
import type { ApiListQueryParams } from "../contracts/types"
|
|
||||||
|
|
||||||
export const PARTS_ROUTES = {
|
export const PARTS_ROUTES = {
|
||||||
INDEX: "/api/parts",
|
INDEX: "/api/parts",
|
||||||
@ -10,25 +10,12 @@ export const PARTS_ROUTES = {
|
|||||||
TOGGLE_STATUS: "/api/toggle-part-status",
|
TOGGLE_STATUS: "/api/toggle-part-status",
|
||||||
} as const satisfies Record<string, ApiPath>
|
} as const satisfies Record<string, ApiPath>
|
||||||
|
|
||||||
export class PartsClient extends ApiClient {
|
export class PartsClient extends CrudClient<
|
||||||
|
typeof PARTS_ROUTES.INDEX,
|
||||||
|
typeof PARTS_ROUTES.BY_ID
|
||||||
|
> {
|
||||||
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
|
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
|
||||||
super(baseUrl, defaultOptions)
|
super(baseUrl, defaultOptions, PARTS_ROUTES.INDEX, PARTS_ROUTES.BY_ID)
|
||||||
}
|
|
||||||
|
|
||||||
async list(query?: ApiListQueryParams) {
|
|
||||||
return this.get(PARTS_ROUTES.INDEX, query ? { query } as never : undefined)
|
|
||||||
}
|
|
||||||
|
|
||||||
async create(payload: ApiRequestBody<typeof PARTS_ROUTES.INDEX, "post">) {
|
|
||||||
return this.post(PARTS_ROUTES.INDEX, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
async update(id: string, payload: ApiRequestBody<typeof PARTS_ROUTES.BY_ID, "put">) {
|
|
||||||
return this.put(PARTS_ROUTES.BY_ID, payload, { params: { id } })
|
|
||||||
}
|
|
||||||
|
|
||||||
async destroy(id: string) {
|
|
||||||
return this.delete(PARTS_ROUTES.BY_ID, { params: { id } })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async import(payload: ApiRequestBody<typeof PARTS_ROUTES.IMPORT, "post">) {
|
async import(payload: ApiRequestBody<typeof PARTS_ROUTES.IMPORT, "post">) {
|
||||||
|
|||||||
17
packages/api/src/clients/payment-modes.ts
Normal file
17
packages/api/src/clients/payment-modes.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { CrudClient } from "../infra/crud-client"
|
||||||
|
import type { ApiClientOptions } from "../infra/client"
|
||||||
|
import type { ApiPath } from "../infra/types"
|
||||||
|
|
||||||
|
export const PAYMENT_MODE_ROUTES = {
|
||||||
|
INDEX: "/api/payment-mode",
|
||||||
|
BY_ID: "/api/payment-mode/{id}",
|
||||||
|
} as const satisfies Record<string, ApiPath>
|
||||||
|
|
||||||
|
export class PaymentModesClient extends CrudClient<
|
||||||
|
typeof PAYMENT_MODE_ROUTES.INDEX,
|
||||||
|
typeof PAYMENT_MODE_ROUTES.BY_ID
|
||||||
|
> {
|
||||||
|
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
|
||||||
|
super(baseUrl, defaultOptions, PAYMENT_MODE_ROUTES.INDEX, PAYMENT_MODE_ROUTES.BY_ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
17
packages/api/src/clients/payment-received.ts
Normal file
17
packages/api/src/clients/payment-received.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { CrudClient } from "../infra/crud-client"
|
||||||
|
import type { ApiClientOptions } from "../infra/client"
|
||||||
|
import type { ApiPath } from "../infra/types"
|
||||||
|
|
||||||
|
export const PAYMENT_RECEIVED_ROUTES = {
|
||||||
|
INDEX: "/api/payment-recieved",
|
||||||
|
BY_ID: "/api/payment-recieved/{id}",
|
||||||
|
} as const satisfies Record<string, ApiPath>
|
||||||
|
|
||||||
|
export class PaymentReceivedClient extends CrudClient<
|
||||||
|
typeof PAYMENT_RECEIVED_ROUTES.INDEX,
|
||||||
|
typeof PAYMENT_RECEIVED_ROUTES.BY_ID
|
||||||
|
> {
|
||||||
|
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
|
||||||
|
super(baseUrl, defaultOptions, PAYMENT_RECEIVED_ROUTES.INDEX, PAYMENT_RECEIVED_ROUTES.BY_ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { ApiClient, type ApiClientOptions } from "../infra/client"
|
import { CrudClient } from "../infra/crud-client"
|
||||||
|
import type { ApiClientOptions } from "../infra/client"
|
||||||
import type { ApiPath, ApiRequestBody } from "../infra/types"
|
import type { ApiPath, ApiRequestBody } from "../infra/types"
|
||||||
import type { ApiListQueryParams } from "../contracts/types"
|
|
||||||
|
|
||||||
export const PAYMENT_TERM_ROUTES = {
|
export const PAYMENT_TERM_ROUTES = {
|
||||||
INDEX: "/api/payment-terms",
|
INDEX: "/api/payment-terms",
|
||||||
@ -8,25 +8,12 @@ export const PAYMENT_TERM_ROUTES = {
|
|||||||
SET_DEFAULT: "/api/set-default-payment-term",
|
SET_DEFAULT: "/api/set-default-payment-term",
|
||||||
} as const satisfies Record<string, ApiPath>
|
} as const satisfies Record<string, ApiPath>
|
||||||
|
|
||||||
export class PaymentTermsClient extends ApiClient {
|
export class PaymentTermsClient extends CrudClient<
|
||||||
|
typeof PAYMENT_TERM_ROUTES.INDEX,
|
||||||
|
typeof PAYMENT_TERM_ROUTES.BY_ID
|
||||||
|
> {
|
||||||
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
|
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
|
||||||
super(baseUrl, defaultOptions)
|
super(baseUrl, defaultOptions, PAYMENT_TERM_ROUTES.INDEX, PAYMENT_TERM_ROUTES.BY_ID)
|
||||||
}
|
|
||||||
|
|
||||||
async list(query?: ApiListQueryParams) {
|
|
||||||
return this.get(PAYMENT_TERM_ROUTES.INDEX, query ? { query } as never : undefined)
|
|
||||||
}
|
|
||||||
|
|
||||||
async create(payload: ApiRequestBody<typeof PAYMENT_TERM_ROUTES.INDEX, "post">) {
|
|
||||||
return this.post(PAYMENT_TERM_ROUTES.INDEX, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
async update(id: string, payload: ApiRequestBody<typeof PAYMENT_TERM_ROUTES.BY_ID, "put">) {
|
|
||||||
return this.put(PAYMENT_TERM_ROUTES.BY_ID, payload, { params: { id } })
|
|
||||||
}
|
|
||||||
|
|
||||||
async destroy(id: string) {
|
|
||||||
return this.delete(PAYMENT_TERM_ROUTES.BY_ID, { params: { id } })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async setDefault(payload: ApiRequestBody<typeof PAYMENT_TERM_ROUTES.SET_DEFAULT, "post">) {
|
async setDefault(payload: ApiRequestBody<typeof PAYMENT_TERM_ROUTES.SET_DEFAULT, "post">) {
|
||||||
|
|||||||
@ -1,50 +1,3 @@
|
|||||||
import { ApiClient, type ApiClientOptions } from "../infra/client"
|
// Re-exports for backward compatibility
|
||||||
import type { ApiPath, ApiRequestBody } from "../infra/types"
|
export { PaymentModesClient, PAYMENT_MODE_ROUTES } from "./payment-modes"
|
||||||
import type { ApiListQueryParams } from "../contracts/types"
|
export { PaymentReceivedClient, PAYMENT_RECEIVED_ROUTES } from "./payment-received"
|
||||||
|
|
||||||
export const PAYMENT_ROUTES = {
|
|
||||||
MODES: "/api/payment-mode",
|
|
||||||
MODE_BY_ID: "/api/payment-mode/{id}",
|
|
||||||
RECEIVED: "/api/payment-recieved",
|
|
||||||
RECEIVED_BY_ID: "/api/payment-recieved/{id}",
|
|
||||||
} as const satisfies Record<string, ApiPath>
|
|
||||||
|
|
||||||
export class PaymentsClient extends ApiClient {
|
|
||||||
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
|
|
||||||
super(baseUrl, defaultOptions)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Payment Modes ──
|
|
||||||
async listModes(query?: ApiListQueryParams) {
|
|
||||||
return this.get(PAYMENT_ROUTES.MODES, query ? { query } as never : undefined)
|
|
||||||
}
|
|
||||||
|
|
||||||
async createMode(payload: ApiRequestBody<typeof PAYMENT_ROUTES.MODES, "post">) {
|
|
||||||
return this.post(PAYMENT_ROUTES.MODES, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateMode(id: string, payload: ApiRequestBody<typeof PAYMENT_ROUTES.MODE_BY_ID, "put">) {
|
|
||||||
return this.put(PAYMENT_ROUTES.MODE_BY_ID, payload, { params: { id } })
|
|
||||||
}
|
|
||||||
|
|
||||||
async destroyMode(id: string) {
|
|
||||||
return this.delete(PAYMENT_ROUTES.MODE_BY_ID, { params: { id } })
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Payment Received ──
|
|
||||||
async listReceived(query?: ApiListQueryParams) {
|
|
||||||
return this.get(PAYMENT_ROUTES.RECEIVED, query ? { query } as never : undefined)
|
|
||||||
}
|
|
||||||
|
|
||||||
async createReceived(payload: ApiRequestBody<typeof PAYMENT_ROUTES.RECEIVED, "post">) {
|
|
||||||
return this.post(PAYMENT_ROUTES.RECEIVED, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateReceived(id: string, payload: ApiRequestBody<typeof PAYMENT_ROUTES.RECEIVED_BY_ID, "post">) {
|
|
||||||
return this.post(PAYMENT_ROUTES.RECEIVED_BY_ID, payload, { params: { id } })
|
|
||||||
}
|
|
||||||
|
|
||||||
async destroyReceived(id: string) {
|
|
||||||
return this.delete(PAYMENT_ROUTES.RECEIVED_BY_ID, { params: { id } })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
17
packages/api/src/clients/quick-notes.ts
Normal file
17
packages/api/src/clients/quick-notes.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { CrudClient } from "../infra/crud-client"
|
||||||
|
import type { ApiClientOptions } from "../infra/client"
|
||||||
|
import type { ApiPath } from "../infra/types"
|
||||||
|
|
||||||
|
export const QUICK_NOTE_ROUTES = {
|
||||||
|
INDEX: "/api/quick-notes",
|
||||||
|
BY_ID: "/api/quick-notes/{id}",
|
||||||
|
} as const satisfies Record<string, ApiPath>
|
||||||
|
|
||||||
|
export class QuickNotesClient extends CrudClient<
|
||||||
|
typeof QUICK_NOTE_ROUTES.INDEX,
|
||||||
|
typeof QUICK_NOTE_ROUTES.BY_ID
|
||||||
|
> {
|
||||||
|
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
|
||||||
|
super(baseUrl, defaultOptions, QUICK_NOTE_ROUTES.INDEX, QUICK_NOTE_ROUTES.BY_ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
17
packages/api/src/clients/quick-remarks.ts
Normal file
17
packages/api/src/clients/quick-remarks.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { CrudClient } from "../infra/crud-client"
|
||||||
|
import type { ApiClientOptions } from "../infra/client"
|
||||||
|
import type { ApiPath } from "../infra/types"
|
||||||
|
|
||||||
|
export const QUICK_REMARK_ROUTES = {
|
||||||
|
INDEX: "/api/quick-remark",
|
||||||
|
BY_ID: "/api/quick-remark/{id}",
|
||||||
|
} as const satisfies Record<string, ApiPath>
|
||||||
|
|
||||||
|
export class QuickRemarksClient extends CrudClient<
|
||||||
|
typeof QUICK_REMARK_ROUTES.INDEX,
|
||||||
|
typeof QUICK_REMARK_ROUTES.BY_ID
|
||||||
|
> {
|
||||||
|
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
|
||||||
|
super(baseUrl, defaultOptions, QUICK_REMARK_ROUTES.INDEX, QUICK_REMARK_ROUTES.BY_ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { ApiClient, type ApiClientOptions } from "../infra/client"
|
import { CrudClient } from "../infra/crud-client"
|
||||||
|
import type { ApiClientOptions } from "../infra/client"
|
||||||
import type { ApiPath, ApiRequestBody } from "../infra/types"
|
import type { ApiPath, ApiRequestBody } from "../infra/types"
|
||||||
import type { ApiListQueryParams } from "../contracts/types"
|
|
||||||
|
|
||||||
export const REFERRAL_SOURCE_ROUTES = {
|
export const REFERRAL_SOURCE_ROUTES = {
|
||||||
INDEX: "/api/referral-sources",
|
INDEX: "/api/referral-sources",
|
||||||
@ -8,25 +8,12 @@ export const REFERRAL_SOURCE_ROUTES = {
|
|||||||
SET_DEFAULT: "/api/set-default-referral-source",
|
SET_DEFAULT: "/api/set-default-referral-source",
|
||||||
} as const satisfies Record<string, ApiPath>
|
} as const satisfies Record<string, ApiPath>
|
||||||
|
|
||||||
export class ReferralSourcesClient extends ApiClient {
|
export class ReferralSourcesClient extends CrudClient<
|
||||||
|
typeof REFERRAL_SOURCE_ROUTES.INDEX,
|
||||||
|
typeof REFERRAL_SOURCE_ROUTES.BY_ID
|
||||||
|
> {
|
||||||
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
|
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
|
||||||
super(baseUrl, defaultOptions)
|
super(baseUrl, defaultOptions, REFERRAL_SOURCE_ROUTES.INDEX, REFERRAL_SOURCE_ROUTES.BY_ID)
|
||||||
}
|
|
||||||
|
|
||||||
async list(query?: ApiListQueryParams) {
|
|
||||||
return this.get(REFERRAL_SOURCE_ROUTES.INDEX, query ? { query } as never : undefined)
|
|
||||||
}
|
|
||||||
|
|
||||||
async create(payload: ApiRequestBody<typeof REFERRAL_SOURCE_ROUTES.INDEX, "post">) {
|
|
||||||
return this.post(REFERRAL_SOURCE_ROUTES.INDEX, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
async update(id: string, payload: ApiRequestBody<typeof REFERRAL_SOURCE_ROUTES.BY_ID, "put">) {
|
|
||||||
return this.put(REFERRAL_SOURCE_ROUTES.BY_ID, payload, { params: { id } })
|
|
||||||
}
|
|
||||||
|
|
||||||
async destroy(id: string) {
|
|
||||||
return this.delete(REFERRAL_SOURCE_ROUTES.BY_ID, { params: { id } })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async setDefault(payload: ApiRequestBody<typeof REFERRAL_SOURCE_ROUTES.SET_DEFAULT, "post">) {
|
async setDefault(payload: ApiRequestBody<typeof REFERRAL_SOURCE_ROUTES.SET_DEFAULT, "post">) {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { ApiClient, type ApiClientOptions } from "../infra/client"
|
import { CrudClient } from "../infra/crud-client"
|
||||||
|
import type { ApiClientOptions } from "../infra/client"
|
||||||
import type { ApiPath, ApiRequestBody } from "../infra/types"
|
import type { ApiPath, ApiRequestBody } from "../infra/types"
|
||||||
import type { ApiListQueryParams } from "../contracts/types"
|
|
||||||
|
|
||||||
export const SERVICE_ROUTES = {
|
export const SERVICE_ROUTES = {
|
||||||
INDEX: "/api/services",
|
INDEX: "/api/services",
|
||||||
@ -9,25 +9,12 @@ export const SERVICE_ROUTES = {
|
|||||||
EXPORT: "/api/export-services",
|
EXPORT: "/api/export-services",
|
||||||
} as const satisfies Record<string, ApiPath>
|
} as const satisfies Record<string, ApiPath>
|
||||||
|
|
||||||
export class ServicesClient extends ApiClient {
|
export class ServicesClient extends CrudClient<
|
||||||
|
typeof SERVICE_ROUTES.INDEX,
|
||||||
|
typeof SERVICE_ROUTES.BY_ID
|
||||||
|
> {
|
||||||
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
|
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
|
||||||
super(baseUrl, defaultOptions)
|
super(baseUrl, defaultOptions, SERVICE_ROUTES.INDEX, SERVICE_ROUTES.BY_ID)
|
||||||
}
|
|
||||||
|
|
||||||
async list(query?: ApiListQueryParams) {
|
|
||||||
return this.get(SERVICE_ROUTES.INDEX, query ? { query } as never : undefined)
|
|
||||||
}
|
|
||||||
|
|
||||||
async create(payload: ApiRequestBody<typeof SERVICE_ROUTES.INDEX, "post">) {
|
|
||||||
return this.post(SERVICE_ROUTES.INDEX, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
async update(id: string, payload: ApiRequestBody<typeof SERVICE_ROUTES.BY_ID, "put">) {
|
|
||||||
return this.put(SERVICE_ROUTES.BY_ID, payload, { params: { id } })
|
|
||||||
}
|
|
||||||
|
|
||||||
async destroy(id: string) {
|
|
||||||
return this.delete(SERVICE_ROUTES.BY_ID, { params: { id } })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async import(payload: ApiRequestBody<typeof SERVICE_ROUTES.IMPORT, "post">) {
|
async import(payload: ApiRequestBody<typeof SERVICE_ROUTES.IMPORT, "post">) {
|
||||||
|
|||||||
17
packages/api/src/clients/shop-recommendations.ts
Normal file
17
packages/api/src/clients/shop-recommendations.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { CrudClient } from "../infra/crud-client"
|
||||||
|
import type { ApiClientOptions } from "../infra/client"
|
||||||
|
import type { ApiPath } from "../infra/types"
|
||||||
|
|
||||||
|
export const SHOP_RECOMMENDATION_ROUTES = {
|
||||||
|
INDEX: "/api/shop-recommendations",
|
||||||
|
BY_ID: "/api/shop-recommendations/{id}",
|
||||||
|
} as const satisfies Record<string, ApiPath>
|
||||||
|
|
||||||
|
export class ShopRecommendationsClient extends CrudClient<
|
||||||
|
typeof SHOP_RECOMMENDATION_ROUTES.INDEX,
|
||||||
|
typeof SHOP_RECOMMENDATION_ROUTES.BY_ID
|
||||||
|
> {
|
||||||
|
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
|
||||||
|
super(baseUrl, defaultOptions, SHOP_RECOMMENDATION_ROUTES.INDEX, SHOP_RECOMMENDATION_ROUTES.BY_ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { ApiClient, type ApiClientOptions } from "../infra/client"
|
import { CrudClient } from "../infra/crud-client"
|
||||||
|
import type { ApiClientOptions } from "../infra/client"
|
||||||
import type { ApiPath } from "../infra/types"
|
import type { ApiPath } from "../infra/types"
|
||||||
import type { ApiListQueryParams } from "../contracts/types"
|
|
||||||
|
|
||||||
export const SHOP_TYPE_ROUTES = {
|
export const SHOP_TYPE_ROUTES = {
|
||||||
INDEX: "/api/shop-types",
|
INDEX: "/api/shop-types",
|
||||||
@ -31,13 +31,12 @@ function buildShopTypeFormData(payload: ShopTypeCreatePayload | ShopTypeUpdatePa
|
|||||||
return fd
|
return fd
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ShopTypesClient extends ApiClient {
|
export class ShopTypesClient extends CrudClient<
|
||||||
|
typeof SHOP_TYPE_ROUTES.INDEX,
|
||||||
|
typeof SHOP_TYPE_ROUTES.BY_ID
|
||||||
|
> {
|
||||||
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
|
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
|
||||||
super(baseUrl, defaultOptions)
|
super(baseUrl, defaultOptions, SHOP_TYPE_ROUTES.INDEX, SHOP_TYPE_ROUTES.BY_ID)
|
||||||
}
|
|
||||||
|
|
||||||
async list(query?: ApiListQueryParams) {
|
|
||||||
return this.get(SHOP_TYPE_ROUTES.INDEX, query ? { query } as never : undefined)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(payload: ShopTypeCreatePayload) {
|
async create(payload: ShopTypeCreatePayload) {
|
||||||
@ -52,9 +51,6 @@ export class ShopTypesClient extends ApiClient {
|
|||||||
return this.postFormData(url, fd)
|
return this.postFormData(url, fd)
|
||||||
}
|
}
|
||||||
|
|
||||||
async destroy(id: string) {
|
|
||||||
return this.delete(SHOP_TYPE_ROUTES.BY_ID, { params: { id } })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
32
packages/api/src/clients/task-sections.ts
Normal file
32
packages/api/src/clients/task-sections.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { CrudClient } from "../infra/crud-client"
|
||||||
|
import type { ApiClientOptions } from "../infra/client"
|
||||||
|
import type { ApiPath, ApiRequestBody } from "../infra/types"
|
||||||
|
|
||||||
|
export const TASK_SECTION_ROUTES = {
|
||||||
|
INDEX: "/api/task-sections",
|
||||||
|
BY_ID: "/api/task-sections/{id}",
|
||||||
|
SET_DEFAULT: "/api/set-default-task-section",
|
||||||
|
REMOVE_DEFAULT: "/api/remove-default-task-section",
|
||||||
|
CHANGE_ARRANGEMENT: "/api/change-task-section-arrangement",
|
||||||
|
} as const satisfies Record<string, ApiPath>
|
||||||
|
|
||||||
|
export class TaskSectionsClient extends CrudClient<
|
||||||
|
typeof TASK_SECTION_ROUTES.INDEX,
|
||||||
|
typeof TASK_SECTION_ROUTES.BY_ID
|
||||||
|
> {
|
||||||
|
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
|
||||||
|
super(baseUrl, defaultOptions, TASK_SECTION_ROUTES.INDEX, TASK_SECTION_ROUTES.BY_ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
async setDefault(payload: ApiRequestBody<typeof TASK_SECTION_ROUTES.SET_DEFAULT, "post">) {
|
||||||
|
return this.post(TASK_SECTION_ROUTES.SET_DEFAULT, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeDefault(payload: ApiRequestBody<typeof TASK_SECTION_ROUTES.REMOVE_DEFAULT, "post">) {
|
||||||
|
return this.post(TASK_SECTION_ROUTES.REMOVE_DEFAULT, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
async changeSectionArrangement(payload: ApiRequestBody<typeof TASK_SECTION_ROUTES.CHANGE_ARRANGEMENT, "post">) {
|
||||||
|
return this.post(TASK_SECTION_ROUTES.CHANGE_ARRANGEMENT, payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
27
packages/api/src/clients/task-types.ts
Normal file
27
packages/api/src/clients/task-types.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { CrudClient } from "../infra/crud-client"
|
||||||
|
import type { ApiClientOptions } from "../infra/client"
|
||||||
|
import type { ApiPath, ApiRequestBody } from "../infra/types"
|
||||||
|
|
||||||
|
export const TASK_TYPE_ROUTES = {
|
||||||
|
INDEX: "/api/task-types",
|
||||||
|
BY_ID: "/api/task-types/{id}",
|
||||||
|
SET_DEFAULT: "/api/set-default-task-type",
|
||||||
|
REMOVE_DEFAULT: "/api/remove-default-task-type",
|
||||||
|
} as const satisfies Record<string, ApiPath>
|
||||||
|
|
||||||
|
export class TaskTypesClient extends CrudClient<
|
||||||
|
typeof TASK_TYPE_ROUTES.INDEX,
|
||||||
|
typeof TASK_TYPE_ROUTES.BY_ID
|
||||||
|
> {
|
||||||
|
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
|
||||||
|
super(baseUrl, defaultOptions, TASK_TYPE_ROUTES.INDEX, TASK_TYPE_ROUTES.BY_ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
async setDefault(payload: ApiRequestBody<typeof TASK_TYPE_ROUTES.SET_DEFAULT, "post">) {
|
||||||
|
return this.post(TASK_TYPE_ROUTES.SET_DEFAULT, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeDefault(payload: ApiRequestBody<typeof TASK_TYPE_ROUTES.REMOVE_DEFAULT, "post">) {
|
||||||
|
return this.post(TASK_TYPE_ROUTES.REMOVE_DEFAULT, payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,96 +1,19 @@
|
|||||||
import { ApiClient, type ApiClientOptions } from "../infra/client"
|
import { CrudClient } from "../infra/crud-client"
|
||||||
|
import type { ApiClientOptions } from "../infra/client"
|
||||||
import type { ApiPath, ApiRequestBody } from "../infra/types"
|
import type { ApiPath, ApiRequestBody } from "../infra/types"
|
||||||
import type { ApiListQueryParams } from "../contracts/types"
|
|
||||||
|
|
||||||
export const TASK_ROUTES = {
|
export const TASK_ROUTES = {
|
||||||
TYPES: "/api/task-types",
|
INDEX: "/api/tasks",
|
||||||
TYPE_BY_ID: "/api/task-types/{id}",
|
BY_ID: "/api/tasks/{id}",
|
||||||
SET_DEFAULT_TYPE: "/api/set-default-task-type",
|
|
||||||
REMOVE_DEFAULT_TYPE: "/api/remove-default-task-type",
|
|
||||||
SECTIONS: "/api/task-sections",
|
|
||||||
SECTION_BY_ID: "/api/task-sections/{id}",
|
|
||||||
SET_DEFAULT_SECTION: "/api/set-default-task-section",
|
|
||||||
REMOVE_DEFAULT_SECTION: "/api/remove-default-task-section",
|
|
||||||
CHANGE_SECTION_ARRANGEMENT: "/api/change-task-section-arrangement",
|
|
||||||
TASKS: "/api/tasks",
|
|
||||||
TASK_BY_ID: "/api/tasks/{id}",
|
|
||||||
COMPLETE: "/api/tasks/{id}/complete",
|
COMPLETE: "/api/tasks/{id}/complete",
|
||||||
} as const satisfies Record<string, ApiPath>
|
} as const satisfies Record<string, ApiPath>
|
||||||
|
|
||||||
export class TasksClient extends ApiClient {
|
export class TasksClient extends CrudClient<
|
||||||
|
typeof TASK_ROUTES.INDEX,
|
||||||
|
typeof TASK_ROUTES.BY_ID
|
||||||
|
> {
|
||||||
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
|
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
|
||||||
super(baseUrl, defaultOptions)
|
super(baseUrl, defaultOptions, TASK_ROUTES.INDEX, TASK_ROUTES.BY_ID)
|
||||||
}
|
|
||||||
|
|
||||||
// ── Task Types ──
|
|
||||||
async listTypes(query?: ApiListQueryParams) {
|
|
||||||
return this.get(TASK_ROUTES.TYPES, query ? { query } as never : undefined)
|
|
||||||
}
|
|
||||||
|
|
||||||
async createType(payload: ApiRequestBody<typeof TASK_ROUTES.TYPES, "post">) {
|
|
||||||
return this.post(TASK_ROUTES.TYPES, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateType(id: string, payload: ApiRequestBody<typeof TASK_ROUTES.TYPE_BY_ID, "put">) {
|
|
||||||
return this.put(TASK_ROUTES.TYPE_BY_ID, payload, { params: { id } })
|
|
||||||
}
|
|
||||||
|
|
||||||
async destroyType(id: string) {
|
|
||||||
return this.delete(TASK_ROUTES.TYPE_BY_ID, { params: { id } })
|
|
||||||
}
|
|
||||||
|
|
||||||
async setDefaultType(payload: ApiRequestBody<typeof TASK_ROUTES.SET_DEFAULT_TYPE, "post">) {
|
|
||||||
return this.post(TASK_ROUTES.SET_DEFAULT_TYPE, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
async removeDefaultType(payload: ApiRequestBody<typeof TASK_ROUTES.REMOVE_DEFAULT_TYPE, "post">) {
|
|
||||||
return this.post(TASK_ROUTES.REMOVE_DEFAULT_TYPE, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Task Sections ──
|
|
||||||
async listSections(query?: ApiListQueryParams) {
|
|
||||||
return this.get(TASK_ROUTES.SECTIONS, query ? { query } as never : undefined)
|
|
||||||
}
|
|
||||||
|
|
||||||
async createSection(payload: ApiRequestBody<typeof TASK_ROUTES.SECTIONS, "post">) {
|
|
||||||
return this.post(TASK_ROUTES.SECTIONS, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateSection(id: string, payload: ApiRequestBody<typeof TASK_ROUTES.SECTION_BY_ID, "put">) {
|
|
||||||
return this.put(TASK_ROUTES.SECTION_BY_ID, payload, { params: { id } })
|
|
||||||
}
|
|
||||||
|
|
||||||
async destroySection(id: string) {
|
|
||||||
return this.delete(TASK_ROUTES.SECTION_BY_ID, { params: { id } })
|
|
||||||
}
|
|
||||||
|
|
||||||
async setDefaultSection(payload: ApiRequestBody<typeof TASK_ROUTES.SET_DEFAULT_SECTION, "post">) {
|
|
||||||
return this.post(TASK_ROUTES.SET_DEFAULT_SECTION, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
async removeDefaultSection(payload: ApiRequestBody<typeof TASK_ROUTES.REMOVE_DEFAULT_SECTION, "post">) {
|
|
||||||
return this.post(TASK_ROUTES.REMOVE_DEFAULT_SECTION, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
async changeSectionArrangement(payload: ApiRequestBody<typeof TASK_ROUTES.CHANGE_SECTION_ARRANGEMENT, "post">) {
|
|
||||||
return this.post(TASK_ROUTES.CHANGE_SECTION_ARRANGEMENT, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Tasks ──
|
|
||||||
async list(query?: ApiListQueryParams) {
|
|
||||||
return this.get(TASK_ROUTES.TASKS, query ? { query } as never : undefined)
|
|
||||||
}
|
|
||||||
|
|
||||||
async create(payload: ApiRequestBody<typeof TASK_ROUTES.TASKS, "post">) {
|
|
||||||
return this.post(TASK_ROUTES.TASKS, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
async update(id: string, payload: ApiRequestBody<typeof TASK_ROUTES.TASK_BY_ID, "put">) {
|
|
||||||
return this.put(TASK_ROUTES.TASK_BY_ID, payload, { params: { id } })
|
|
||||||
}
|
|
||||||
|
|
||||||
async destroy(id: string) {
|
|
||||||
return this.delete(TASK_ROUTES.TASK_BY_ID, { params: { id } })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async complete(id: string, payload: ApiRequestBody<typeof TASK_ROUTES.COMPLETE, "post">) {
|
async complete(id: string, payload: ApiRequestBody<typeof TASK_ROUTES.COMPLETE, "post">) {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { ApiClient, type ApiClientOptions } from "../infra/client"
|
import { CrudClient } from "../infra/crud-client"
|
||||||
|
import type { ApiClientOptions } from "../infra/client"
|
||||||
import type { ApiPath, ApiRequestBody } from "../infra/types"
|
import type { ApiPath, ApiRequestBody } from "../infra/types"
|
||||||
import type { ApiListQueryParams } from "../contracts/types"
|
|
||||||
|
|
||||||
export const TAX_ROUTES = {
|
export const TAX_ROUTES = {
|
||||||
INDEX: "/api/taxes",
|
INDEX: "/api/taxes",
|
||||||
@ -9,25 +9,12 @@ export const TAX_ROUTES = {
|
|||||||
REMOVE_DEFAULT: "/api/remove-default-tax",
|
REMOVE_DEFAULT: "/api/remove-default-tax",
|
||||||
} as const satisfies Record<string, ApiPath>
|
} as const satisfies Record<string, ApiPath>
|
||||||
|
|
||||||
export class TaxesClient extends ApiClient {
|
export class TaxesClient extends CrudClient<
|
||||||
|
typeof TAX_ROUTES.INDEX,
|
||||||
|
typeof TAX_ROUTES.BY_ID
|
||||||
|
> {
|
||||||
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
|
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
|
||||||
super(baseUrl, defaultOptions)
|
super(baseUrl, defaultOptions, TAX_ROUTES.INDEX, TAX_ROUTES.BY_ID)
|
||||||
}
|
|
||||||
|
|
||||||
async list(query?: ApiListQueryParams) {
|
|
||||||
return this.get(TAX_ROUTES.INDEX, query ? { query } as never : undefined)
|
|
||||||
}
|
|
||||||
|
|
||||||
async create(payload: ApiRequestBody<typeof TAX_ROUTES.INDEX, "post">) {
|
|
||||||
return this.post(TAX_ROUTES.INDEX, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
async update(id: string, payload: ApiRequestBody<typeof TAX_ROUTES.BY_ID, "put">) {
|
|
||||||
return this.put(TAX_ROUTES.BY_ID, payload, { params: { id } })
|
|
||||||
}
|
|
||||||
|
|
||||||
async destroy(id: string) {
|
|
||||||
return this.delete(TAX_ROUTES.BY_ID, { params: { id } })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async setDefault(payload: ApiRequestBody<typeof TAX_ROUTES.SET_DEFAULT, "post">) {
|
async setDefault(payload: ApiRequestBody<typeof TAX_ROUTES.SET_DEFAULT, "post">) {
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { ApiClient, type ApiClientOptions } from "../infra/client"
|
import { CrudClient } from "../infra/crud-client"
|
||||||
|
import type { ApiClientOptions } from "../infra/client"
|
||||||
import type { ApiPath, ApiRequestBody } from "../infra/types"
|
import type { ApiPath, ApiRequestBody } from "../infra/types"
|
||||||
import type { ApiListQueryParams } from "../contracts/types"
|
import type { ApiListQueryParams } from "../contracts/types"
|
||||||
|
|
||||||
@ -25,13 +26,12 @@ function buildVehicleFormData(payload: Record<string, any>): FormData {
|
|||||||
return fd
|
return fd
|
||||||
}
|
}
|
||||||
|
|
||||||
export class VehiclesClient extends ApiClient {
|
export class VehiclesClient extends CrudClient<
|
||||||
|
typeof VEHICLE_ROUTES.INDEX,
|
||||||
|
typeof VEHICLE_ROUTES.BY_ID
|
||||||
|
> {
|
||||||
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
|
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
|
||||||
super(baseUrl, defaultOptions)
|
super(baseUrl, defaultOptions, VEHICLE_ROUTES.INDEX, VEHICLE_ROUTES.BY_ID)
|
||||||
}
|
|
||||||
|
|
||||||
async list(query?: ApiListQueryParams) {
|
|
||||||
return this.get(VEHICLE_ROUTES.INDEX, query ? { query } as never : undefined)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -53,10 +53,6 @@ export class VehiclesClient extends ApiClient {
|
|||||||
return this.postFormData(url, fd)
|
return this.postFormData(url, fd)
|
||||||
}
|
}
|
||||||
|
|
||||||
async destroy(id: string) {
|
|
||||||
return this.delete(VEHICLE_ROUTES.BY_ID, { params: { id } })
|
|
||||||
}
|
|
||||||
|
|
||||||
async export() {
|
async export() {
|
||||||
return this.get(VEHICLE_ROUTES.EXPORT)
|
return this.get(VEHICLE_ROUTES.EXPORT)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { ApiClient, type ApiClientOptions } from "../infra/client"
|
import { CrudClient } from "../infra/crud-client"
|
||||||
|
import type { ApiClientOptions } from "../infra/client"
|
||||||
import type { ApiPath, ApiRequestBody } from "../infra/types"
|
import type { ApiPath, ApiRequestBody } from "../infra/types"
|
||||||
import type { ApiListQueryParams } from "../contracts/types"
|
|
||||||
|
|
||||||
export const VENDOR_ROUTES = {
|
export const VENDOR_ROUTES = {
|
||||||
INDEX: "/api/vendors",
|
INDEX: "/api/vendors",
|
||||||
@ -10,25 +10,12 @@ export const VENDOR_ROUTES = {
|
|||||||
ADDRESS_BY_ID: "/api/vendor-address/{id}",
|
ADDRESS_BY_ID: "/api/vendor-address/{id}",
|
||||||
} as const satisfies Record<string, ApiPath>
|
} as const satisfies Record<string, ApiPath>
|
||||||
|
|
||||||
export class VendorsClient extends ApiClient {
|
export class VendorsClient extends CrudClient<
|
||||||
|
typeof VENDOR_ROUTES.INDEX,
|
||||||
|
typeof VENDOR_ROUTES.BY_ID
|
||||||
|
> {
|
||||||
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
|
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
|
||||||
super(baseUrl, defaultOptions)
|
super(baseUrl, defaultOptions, VENDOR_ROUTES.INDEX, VENDOR_ROUTES.BY_ID)
|
||||||
}
|
|
||||||
|
|
||||||
async list(query?: ApiListQueryParams) {
|
|
||||||
return this.get(VENDOR_ROUTES.INDEX, query ? { query } as never : undefined)
|
|
||||||
}
|
|
||||||
|
|
||||||
async create(payload: ApiRequestBody<typeof VENDOR_ROUTES.INDEX, "post">) {
|
|
||||||
return this.post(VENDOR_ROUTES.INDEX, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
async update(id: string, payload: ApiRequestBody<typeof VENDOR_ROUTES.BY_ID, "put">) {
|
|
||||||
return this.put(VENDOR_ROUTES.BY_ID, payload, { params: { id } })
|
|
||||||
}
|
|
||||||
|
|
||||||
async destroy(id: string) {
|
|
||||||
return this.delete(VENDOR_ROUTES.BY_ID, { params: { id } })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async toggleStatus(payload: ApiRequestBody<typeof VENDOR_ROUTES.TOGGLE_STATUS, "post">) {
|
async toggleStatus(payload: ApiRequestBody<typeof VENDOR_ROUTES.TOGGLE_STATUS, "post">) {
|
||||||
|
|||||||
@ -59,6 +59,9 @@ export type ExpenseStatus = (typeof ExpenseStatus)[number];
|
|||||||
export const TaskStatus = ['pending', 'completed'] as const;
|
export const TaskStatus = ['pending', 'completed'] as const;
|
||||||
export type TaskStatus = (typeof TaskStatus)[number];
|
export type TaskStatus = (typeof TaskStatus)[number];
|
||||||
|
|
||||||
|
export const TaskPriority = ['low', 'medium', 'high'] as const;
|
||||||
|
export type TaskPriority = (typeof TaskPriority)[number];
|
||||||
|
|
||||||
// 📅 Appointments
|
// 📅 Appointments
|
||||||
export const AppointmentStatus = ['requested', 'confirmed', 'no_show', 'cancelled', 'completed'] as const;
|
export const AppointmentStatus = ['requested', 'confirmed', 'no_show', 'cancelled', 'completed'] as const;
|
||||||
export type AppointmentStatus = (typeof AppointmentStatus)[number];
|
export type AppointmentStatus = (typeof AppointmentStatus)[number];
|
||||||
|
|||||||
@ -10,5 +10,3 @@ export * from "./clients/index"
|
|||||||
|
|
||||||
// ── Factory ──
|
// ── Factory ──
|
||||||
export { createApi, api } from "./api"
|
export { createApi, api } from "./api"
|
||||||
|
|
||||||
export * from "./types"
|
|
||||||
@ -7,9 +7,22 @@ export const DEFAULT_PER_PAGE = 10
|
|||||||
type CrudIndexRoute = ApiPathByMethod<"get"> & ApiPathByMethod<"post">
|
type CrudIndexRoute = ApiPathByMethod<"get"> & ApiPathByMethod<"post">
|
||||||
type CrudByIdRoute = ApiPathByMethod<"put"> & ApiPathByMethod<"delete">
|
type CrudByIdRoute = ApiPathByMethod<"put"> & ApiPathByMethod<"delete">
|
||||||
|
|
||||||
|
export type CrudTypeOverrides = {
|
||||||
|
listResponse?: unknown
|
||||||
|
showResponse?: unknown
|
||||||
|
createPayload?: unknown
|
||||||
|
createResponse?: unknown
|
||||||
|
updatePayload?: unknown
|
||||||
|
updateResponse?: unknown
|
||||||
|
destroyResponse?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
type Resolve<T, K extends string, Default> = T extends Record<K, infer V> ? V : Default
|
||||||
|
|
||||||
export abstract class CrudClient<
|
export abstract class CrudClient<
|
||||||
IndexRoute extends CrudIndexRoute,
|
IndexRoute extends CrudIndexRoute,
|
||||||
ByIdRoute extends CrudByIdRoute,
|
ByIdRoute extends CrudByIdRoute,
|
||||||
|
Overrides extends CrudTypeOverrides = {},
|
||||||
> extends ApiClient {
|
> extends ApiClient {
|
||||||
|
|
||||||
|
|
||||||
@ -22,24 +35,24 @@ export abstract class CrudClient<
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async list(query?: ApiListQueryParams): Promise<ApiResponse<IndexRoute, "get">> {
|
async list(query?: ApiListQueryParams): Promise<Resolve<Overrides, 'listResponse', ApiResponse<IndexRoute, "get">>> {
|
||||||
return this.get(this.indexRoute as IndexRoute, query ? { query } as never : undefined)
|
return this.get(this.indexRoute as IndexRoute, query ? { query } as never : undefined) as never
|
||||||
}
|
}
|
||||||
|
|
||||||
async show(id: string) {
|
async show(id: string): Promise<Resolve<Overrides, 'showResponse', ApiResponse<ByIdRoute & ApiPathByMethod<"get">, "get">>> {
|
||||||
return this.get(this.byIdRoute as ByIdRoute & ApiPathByMethod<"get">, { params: { id } } as never)
|
return this.get(this.byIdRoute as ByIdRoute & ApiPathByMethod<"get">, { params: { id } } as never) as never
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(payload: ApiRequestBody<IndexRoute, "post">) {
|
async create(payload: Resolve<Overrides, 'createPayload', ApiRequestBody<IndexRoute, "post">>): Promise<Resolve<Overrides, 'createResponse', ApiResponse<IndexRoute, "post">>> {
|
||||||
return this.post<IndexRoute>(this.indexRoute as IndexRoute, payload as never)
|
return this.post<IndexRoute>(this.indexRoute as IndexRoute, payload as never) as never
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(id: string, payload: ApiRequestBody<ByIdRoute, "put">) {
|
async update(id: string, payload: Resolve<Overrides, 'updatePayload', ApiRequestBody<ByIdRoute, "put">>): Promise<Resolve<Overrides, 'updateResponse', ApiResponse<ByIdRoute, "put">>> {
|
||||||
return this.put<ByIdRoute>(this.byIdRoute as ByIdRoute, payload as never, { params: { id } } as never)
|
return this.put<ByIdRoute>(this.byIdRoute as ByIdRoute, payload as never, { params: { id } } as never) as never
|
||||||
}
|
}
|
||||||
|
|
||||||
async destroy(id: string) {
|
async destroy(id: string): Promise<Resolve<Overrides, 'destroyResponse', ApiResponse<ByIdRoute, "delete">>> {
|
||||||
return this.delete<ByIdRoute>(this.byIdRoute as ByIdRoute, { params: { id } } as never)
|
return this.delete<ByIdRoute>(this.byIdRoute as ByIdRoute, { params: { id } } as never) as never
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -110,3 +110,6 @@ export type ApiOperationResponse<OperationId extends ApiOperationId> =
|
|||||||
>
|
>
|
||||||
>
|
>
|
||||||
>
|
>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1 +0,0 @@
|
|||||||
export * from './job-cards'
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
import { JOB_CARD_ROUTES } from "../clients";
|
|
||||||
import { ApiResponse } from "../infra";
|
|
||||||
|
|
||||||
export type JobCardResponseData = NonNullable<ApiResponse<typeof JOB_CARD_ROUTES['INDEX'], 'get'>['data']>[number]
|
|
||||||
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user