From 24a44481a07d208d8393342ef8742602550e50a6 Mon Sep 17 00:00:00 2001 From: Mohammad Khyata Date: Mon, 6 Apr 2026 02:32:47 +0300 Subject: [PATCH] updates --- .github/skills/api-enums-reference/SKILL.md | 76 + .github/skills/date-time-pickers/SKILL.md | 86 + .../calendar/appointment/[id]/layout.tsx | 29 + .../calendar/appointment/[id]/page.tsx | 49 + .../calendar/appointment/list/page.tsx | 112 + .../app/(authenticated)/calendars/page.tsx | 0 .../(authenticated)/items/adjustment/page.tsx | 292 + .../productivity/employees/[id]/layout.tsx | 39 + .../productivity/employees/[id]/page.tsx | 19 + .../employees/[id]/permissions/page.tsx | 10 + .../productivity/employees/page.tsx | 3 + .../(authenticated)/purchase/bill/page.tsx | 73 + .../purchase/payments-made/page.tsx | 377 + .../purchase/purchase-order/page.tsx | 72 + .../purchase/vendor-credit/page.tsx | 65 + .../sales/customers/[id]/layout.tsx | 40 + .../sales/customers/[id]/notes/page.tsx | 206 + .../sales/customers/[id]/page.tsx | 19 + .../sales/customers/[id]/vehicles/page.tsx | 72 + .../(authenticated)/sales/customers/page.tsx | 3 + .../sales/estimates/[id]/layout.tsx | 41 + .../sales/estimates/[id]/page.tsx | 21 + .../(authenticated)/sales/estimates/page.tsx | 40 +- .../inspections/[id]/checkpoints/page.tsx | 589 + .../sales/inspections/[id]/edit/page.tsx | 42 + .../sales/inspections/[id]/layout.tsx | 42 + .../sales/inspections/[id]/page.tsx | 19 + .../sales/inspections/page.tsx | 4 + .../job-cards/[id]/appointments/page.tsx | 100 + .../sales/job-cards/[id]/attachments/page.tsx | 83 +- .../sales/job-cards/[id]/bills/page.tsx | 86 + .../job-cards/[id]/customer-remarks/page.tsx | 75 +- .../sales/job-cards/[id]/expenses/page.tsx | 90 + .../sales/job-cards/[id]/layout.tsx | 44 +- .../job-cards/[id]/purchase-orders/page.tsx | 85 + .../[id]/shop-recommendations/page.tsx | 21 +- .../sales/job-cards/[id]/tasks/page.tsx | 67 + .../(authenticated)/sales/job-cards/page.tsx | 46 +- .../sales/vehicles/[id]/estimates/page.tsx | 16 +- .../(authenticated)/settings/company/page.tsx | 17 + .../preferences/general/page.tsx | 7 + .../configurations/preferences/layout.tsx | 42 + .../preferences/purchases/page.tsx | 7 + .../configurations/preferences/sales/page.tsx | 7 + .../settings/departments/page.tsx | 43 + .../settings/insurance-types/page.tsx | 39 + .../settings/tax-rates/page.tsx | 59 +- .../dashboard-details-page-layout.tsx | 4 +- apps/dashboard/config/navGroups.tsx | 3 + .../appointments/appointment-actions.tsx | 124 + .../appointments/appointment-context.tsx | 28 + .../modules/appointments/appointment-form.tsx | 208 + .../appointments/appointment-general-info.tsx | 166 + .../appointments/appointment.schema.ts | 33 + apps/dashboard/modules/bills/bill-form.tsx | 197 + apps/dashboard/modules/bills/bill.schema.ts | 22 + .../modules/customers/customer-actions.tsx | 65 + .../modules/customers/customer-context.tsx | 28 + .../customers/customer-general-info.tsx | 167 + .../customers/rhf-customer-select-field.tsx | 196 + .../modules/employees/employee-actions.tsx | 70 + .../modules/employees/employee-combobox.tsx | 178 + .../modules/employees/employee-context.tsx | 28 + .../employees/employee-general-info.tsx | 159 + .../employees/employee-permissions-form.tsx | 292 + .../employees/rhf-employee-select-field.tsx | 60 + .../modules/estimates/estimate-actions.tsx | 75 + .../modules/estimates/estimate-context.tsx | 28 + .../modules/estimates/estimate-form.tsx | 34 +- .../estimates/estimate-general-info.tsx | 197 + .../inspections/inspection-actions.tsx | 88 + .../inspections/inspection-context.tsx | 28 + .../modules/inspections/inspection-form.tsx | 53 +- .../inspections/inspection-general-info.tsx | 215 + .../inventory-adjustment-form.tsx | 227 + .../inventory-adjustment.schema.ts | 23 + .../modules/invoices/invoice-form.tsx | 35 +- .../modules/job-cards/job-card-actions.tsx | 222 +- .../modules/job-cards/job-card-context.tsx | 19 +- .../modules/job-cards/job-card-dropdown.tsx | 56 + .../modules/job-cards/job-card-form.tsx | 104 +- .../job-cards/job-card-general-info.tsx | 61 +- .../job-cards/job-card-status-stepper.tsx | 2 +- .../modules/job-cards/job-card.schema.ts | 79 +- .../payment-mades/payment-made-form.tsx | 254 + .../payment-mades/payment-made.schema.ts | 26 + .../purchase-orders/purchase-order-form.tsx | 189 + .../purchase-orders/purchase-order.schema.ts | 23 + .../settings/company/settings-form.tsx | 319 + .../settings/company/settings.schema.ts | 30 + .../configurations/configurations.schema.ts | 24 + .../general-preferences-form.tsx | 102 + .../configurations/purchase-config-form.tsx | 83 + .../configurations/sales-config-form.tsx | 83 + .../settings/departments/department-form.tsx | 112 + .../settings/departments/department.schema.ts | 8 + .../insurance-types/insurance-type-form.tsx | 102 + .../insurance-types/insurance-type.schema.ts | 7 + .../vehicles/rhf-vehicle-select-field.tsx | 209 + .../modules/vehicles/utils/getVehicleLabel.ts | 5 + .../vendor-credits/vendor-credit-form.tsx | 188 + .../vendor-credits/vendor-credit.schema.ts | 21 + apps/dashboard/next.config.mjs | 14 +- .../form/controls/date-picker-field.tsx | 61 + .../form/controls/text-input-field.tsx | 3 + .../form/controls/time-picker-field.tsx | 123 + .../components/form/fields/rhf-date-field.tsx | 24 + .../components/form/fields/rhf-time-field.tsx | 24 + .../dashboard/shared/components/form/index.ts | 4 + .../dashboard/shared/components/ui/avatar.tsx | 68 +- .../shared/components/ui/combobox.tsx | 4 +- .../data-view/resource-page/resource-page.tsx | 5 +- .../data-view/table-view/data-table.tsx | 4 +- .../table-view/data-view-context.tsx | 2 +- .../shared/data-view/table-view/types.ts | 2 +- apps/dashboard/shared/utils/getFullName.ts | 5 + docs/dashboard/feature-checklist.md | 227 +- packages/api/open-api/schema.json | 41796 +++++++---- packages/api/postman/collection.json | 57503 ++++++++++------ packages/api/src/api.ts | 32 + packages/api/src/clients/appointments.ts | 5 + packages/api/src/clients/bills.ts | 17 + packages/api/src/clients/configurations.ts | 26 + packages/api/src/clients/credit-notes.ts | 42 + packages/api/src/clients/customers.ts | 22 +- packages/api/src/clients/employees.ts | 11 +- packages/api/src/clients/estimates.ts | 7 + packages/api/src/clients/holidays.ts | 17 + packages/api/src/clients/index.ts | 18 +- packages/api/src/clients/inspections.ts | 34 +- .../api/src/clients/inventory-adjustments.ts | 31 + packages/api/src/clients/invoice-sequences.ts | 17 + packages/api/src/clients/make-and-models.ts | 17 + packages/api/src/clients/payment-mades.ts | 27 + packages/api/src/clients/purchase-orders.ts | 29 +- packages/api/src/clients/reasons.ts | 17 + .../api/src/clients/service-group-includes.ts | 22 + .../api/src/clients/service-group-parts.ts | 17 + .../api/src/clients/service-group-pricings.ts | 17 + .../api/src/clients/service-group-services.ts | 17 + packages/api/src/clients/service-groups.ts | 19 +- packages/api/src/clients/settings.ts | 20 + packages/api/src/clients/time-sheets.ts | 27 + packages/api/src/clients/vehicles.ts | 2 + packages/api/src/clients/vendor-credits.ts | 42 + packages/api/src/contracts/enums.ts | 115 + packages/api/src/index.ts | 3 +- packages/api/src/infra/crud-client.ts | 24 +- packages/api/src/types/index.ts | 1 + packages/api/src/types/job-cards.ts | 4 + packages/api/src/types/labels.ts | 0 packages/api/types/index.ts | 20950 ++++-- 152 files changed, 85913 insertions(+), 44338 deletions(-) create mode 100644 .github/skills/api-enums-reference/SKILL.md create mode 100644 .github/skills/date-time-pickers/SKILL.md create mode 100644 apps/dashboard/app/(authenticated)/calendar/appointment/[id]/layout.tsx create mode 100644 apps/dashboard/app/(authenticated)/calendar/appointment/[id]/page.tsx create mode 100644 apps/dashboard/app/(authenticated)/calendar/appointment/list/page.tsx create mode 100644 apps/dashboard/app/(authenticated)/calendars/page.tsx create mode 100644 apps/dashboard/app/(authenticated)/items/adjustment/page.tsx create mode 100644 apps/dashboard/app/(authenticated)/productivity/employees/[id]/layout.tsx create mode 100644 apps/dashboard/app/(authenticated)/productivity/employees/[id]/page.tsx create mode 100644 apps/dashboard/app/(authenticated)/productivity/employees/[id]/permissions/page.tsx create mode 100644 apps/dashboard/app/(authenticated)/purchase/bill/page.tsx create mode 100644 apps/dashboard/app/(authenticated)/purchase/payments-made/page.tsx create mode 100644 apps/dashboard/app/(authenticated)/purchase/purchase-order/page.tsx create mode 100644 apps/dashboard/app/(authenticated)/purchase/vendor-credit/page.tsx create mode 100644 apps/dashboard/app/(authenticated)/sales/customers/[id]/layout.tsx create mode 100644 apps/dashboard/app/(authenticated)/sales/customers/[id]/notes/page.tsx create mode 100644 apps/dashboard/app/(authenticated)/sales/customers/[id]/page.tsx create mode 100644 apps/dashboard/app/(authenticated)/sales/customers/[id]/vehicles/page.tsx create mode 100644 apps/dashboard/app/(authenticated)/sales/estimates/[id]/layout.tsx create mode 100644 apps/dashboard/app/(authenticated)/sales/estimates/[id]/page.tsx create mode 100644 apps/dashboard/app/(authenticated)/sales/inspections/[id]/checkpoints/page.tsx create mode 100644 apps/dashboard/app/(authenticated)/sales/inspections/[id]/edit/page.tsx create mode 100644 apps/dashboard/app/(authenticated)/sales/inspections/[id]/layout.tsx create mode 100644 apps/dashboard/app/(authenticated)/sales/inspections/[id]/page.tsx create mode 100644 apps/dashboard/app/(authenticated)/sales/job-cards/[id]/appointments/page.tsx create mode 100644 apps/dashboard/app/(authenticated)/sales/job-cards/[id]/bills/page.tsx create mode 100644 apps/dashboard/app/(authenticated)/sales/job-cards/[id]/expenses/page.tsx create mode 100644 apps/dashboard/app/(authenticated)/sales/job-cards/[id]/purchase-orders/page.tsx create mode 100644 apps/dashboard/app/(authenticated)/sales/job-cards/[id]/tasks/page.tsx create mode 100644 apps/dashboard/app/(authenticated)/settings/company/page.tsx create mode 100644 apps/dashboard/app/(authenticated)/settings/configurations/preferences/general/page.tsx create mode 100644 apps/dashboard/app/(authenticated)/settings/configurations/preferences/layout.tsx create mode 100644 apps/dashboard/app/(authenticated)/settings/configurations/preferences/purchases/page.tsx create mode 100644 apps/dashboard/app/(authenticated)/settings/configurations/preferences/sales/page.tsx create mode 100644 apps/dashboard/app/(authenticated)/settings/departments/page.tsx create mode 100644 apps/dashboard/app/(authenticated)/settings/insurance-types/page.tsx create mode 100644 apps/dashboard/modules/appointments/appointment-actions.tsx create mode 100644 apps/dashboard/modules/appointments/appointment-context.tsx create mode 100644 apps/dashboard/modules/appointments/appointment-form.tsx create mode 100644 apps/dashboard/modules/appointments/appointment-general-info.tsx create mode 100644 apps/dashboard/modules/appointments/appointment.schema.ts create mode 100644 apps/dashboard/modules/bills/bill-form.tsx create mode 100644 apps/dashboard/modules/bills/bill.schema.ts create mode 100644 apps/dashboard/modules/customers/customer-actions.tsx create mode 100644 apps/dashboard/modules/customers/customer-context.tsx create mode 100644 apps/dashboard/modules/customers/customer-general-info.tsx create mode 100644 apps/dashboard/modules/customers/rhf-customer-select-field.tsx create mode 100644 apps/dashboard/modules/employees/employee-actions.tsx create mode 100644 apps/dashboard/modules/employees/employee-combobox.tsx create mode 100644 apps/dashboard/modules/employees/employee-context.tsx create mode 100644 apps/dashboard/modules/employees/employee-general-info.tsx create mode 100644 apps/dashboard/modules/employees/employee-permissions-form.tsx create mode 100644 apps/dashboard/modules/employees/rhf-employee-select-field.tsx create mode 100644 apps/dashboard/modules/estimates/estimate-actions.tsx create mode 100644 apps/dashboard/modules/estimates/estimate-context.tsx create mode 100644 apps/dashboard/modules/estimates/estimate-general-info.tsx create mode 100644 apps/dashboard/modules/inspections/inspection-actions.tsx create mode 100644 apps/dashboard/modules/inspections/inspection-context.tsx create mode 100644 apps/dashboard/modules/inspections/inspection-general-info.tsx create mode 100644 apps/dashboard/modules/inventory-adjustments/inventory-adjustment-form.tsx create mode 100644 apps/dashboard/modules/inventory-adjustments/inventory-adjustment.schema.ts create mode 100644 apps/dashboard/modules/job-cards/job-card-dropdown.tsx create mode 100644 apps/dashboard/modules/payment-mades/payment-made-form.tsx create mode 100644 apps/dashboard/modules/payment-mades/payment-made.schema.ts create mode 100644 apps/dashboard/modules/purchase-orders/purchase-order-form.tsx create mode 100644 apps/dashboard/modules/purchase-orders/purchase-order.schema.ts create mode 100644 apps/dashboard/modules/settings/company/settings-form.tsx create mode 100644 apps/dashboard/modules/settings/company/settings.schema.ts create mode 100644 apps/dashboard/modules/settings/configurations/configurations.schema.ts create mode 100644 apps/dashboard/modules/settings/configurations/general-preferences-form.tsx create mode 100644 apps/dashboard/modules/settings/configurations/purchase-config-form.tsx create mode 100644 apps/dashboard/modules/settings/configurations/sales-config-form.tsx create mode 100644 apps/dashboard/modules/settings/departments/department-form.tsx create mode 100644 apps/dashboard/modules/settings/departments/department.schema.ts create mode 100644 apps/dashboard/modules/settings/insurance-types/insurance-type-form.tsx create mode 100644 apps/dashboard/modules/settings/insurance-types/insurance-type.schema.ts create mode 100644 apps/dashboard/modules/vehicles/rhf-vehicle-select-field.tsx create mode 100644 apps/dashboard/modules/vehicles/utils/getVehicleLabel.ts create mode 100644 apps/dashboard/modules/vendor-credits/vendor-credit-form.tsx create mode 100644 apps/dashboard/modules/vendor-credits/vendor-credit.schema.ts create mode 100644 apps/dashboard/shared/components/form/controls/date-picker-field.tsx create mode 100644 apps/dashboard/shared/components/form/controls/time-picker-field.tsx create mode 100644 apps/dashboard/shared/components/form/fields/rhf-date-field.tsx create mode 100644 apps/dashboard/shared/components/form/fields/rhf-time-field.tsx create mode 100644 apps/dashboard/shared/utils/getFullName.ts create mode 100644 packages/api/src/clients/bills.ts create mode 100644 packages/api/src/clients/configurations.ts create mode 100644 packages/api/src/clients/credit-notes.ts create mode 100644 packages/api/src/clients/holidays.ts create mode 100644 packages/api/src/clients/inventory-adjustments.ts create mode 100644 packages/api/src/clients/invoice-sequences.ts create mode 100644 packages/api/src/clients/make-and-models.ts create mode 100644 packages/api/src/clients/payment-mades.ts create mode 100644 packages/api/src/clients/reasons.ts create mode 100644 packages/api/src/clients/service-group-includes.ts create mode 100644 packages/api/src/clients/service-group-parts.ts create mode 100644 packages/api/src/clients/service-group-pricings.ts create mode 100644 packages/api/src/clients/service-group-services.ts create mode 100644 packages/api/src/clients/settings.ts create mode 100644 packages/api/src/clients/time-sheets.ts create mode 100644 packages/api/src/clients/vendor-credits.ts create mode 100644 packages/api/src/contracts/enums.ts create mode 100644 packages/api/src/types/index.ts create mode 100644 packages/api/src/types/job-cards.ts create mode 100644 packages/api/src/types/labels.ts diff --git a/.github/skills/api-enums-reference/SKILL.md b/.github/skills/api-enums-reference/SKILL.md new file mode 100644 index 0000000..6d91364 --- /dev/null +++ b/.github/skills/api-enums-reference/SKILL.md @@ -0,0 +1,76 @@ +--- +name: api-enums-reference +description: "Use the central API enums file as the source of truth for enum values in this project. Use when: adding enum fields, updating enum options, creating form selects, typing status/discount/rate fields, syncing backend enum changes, or avoiding duplicated hardcoded enum literals." +--- + +# API Enums Reference + +Use this skill whenever work touches enum-like fields in API clients, schemas, forms, table filters, or page logic. + +## Source of Truth + +All shared enum values and enum union types must come from: + +- packages/api/src/contracts/enums.ts + +Do not recreate enum arrays inline when an equivalent enum already exists in this file. + +## Rules + +1. Reuse before creating. +Search and import from `@garage/api` exports (or local contracts path inside packages/api) before adding new literals. + +2. Keep runtime and type together. +For every enum, keep this pattern in `enums.ts`: + +```ts +export const ExampleStatus = ['a', 'b'] as const; +export type ExampleStatus = (typeof ExampleStatus)[number]; +``` + +3. Preserve backend values exactly. +Enum string values are case- and space-sensitive; keep exact spelling from backend migrations/spec. + +4. Avoid duplicate synonyms. +If two domains share the same canonical values, prefer reusing an existing enum unless domain separation is intentional. + +5. Update centrally first. +When backend enum options change, update `packages/api/src/contracts/enums.ts` first, then update consuming UI/API code. + +6. Prefer imports in forms and schemas. +Use central enums for select options and for typed payload/status fields instead of hardcoded string unions. + +## Workflow + +1. Identify the enum field and backend values. +2. Check `packages/api/src/contracts/enums.ts` for an existing enum. +3. If found, import and use it. +4. If missing, add a new const+type pair in `enums.ts`. +5. Update consumers to reference the central enum. +6. Verify there are no duplicated literal arrays for the same field. + +## Examples + +```ts +import { InvoiceStatus, type InvoiceStatus as InvoiceStatusType } from '@garage/api' + +const statusOptions = InvoiceStatus + +type Payload = { + status: InvoiceStatusType +} +``` + +```ts +import { DiscountType } from '@garage/api' + +const discountOptions = DiscountType.map((value) => ({ + label: value, + value, +})) +``` + +## Notes + +- If a module needs a presentation-specific label, map from the central enum value instead of changing raw enum literals. +- If backend adds/removes values, keep API and dashboard aligned in the same change set. diff --git a/.github/skills/date-time-pickers/SKILL.md b/.github/skills/date-time-pickers/SKILL.md new file mode 100644 index 0000000..b441c5c --- /dev/null +++ b/.github/skills/date-time-pickers/SKILL.md @@ -0,0 +1,86 @@ +--- +name: date-time-pickers +description: "Use RhfDateField and RhfTimeField (shadcn Calendar/Popover-based) for all date and time inputs in forms. Use when: adding date fields, adding time fields, replacing `type=\"date\"` or `type=\"time\"` RhfTextField inputs, building any form that captures a date or time value." +--- + +# Date & Time Pickers + +Always use the shadcn-based picker components for date and time fields. Never use `` or ``. + +## Components + +| Use For | Component | Import | +|---|---|---| +| Date fields (YYYY-MM-DD) | `RhfDateField` | `@/shared/components/form` | +| Time fields (HH:MM:SS) | `RhfTimeField` | `@/shared/components/form` | + +## RhfDateField + +Renders a shadcn Calendar inside a Popover. Value is a `string` in `"YYYY-MM-DD"` format. + +```tsx +import { RhfDateField } from "@/shared/components/form" + + +``` + +**Schema type**: `z.string().optional()` (stores `"YYYY-MM-DD"`) + +**Default value**: `""` for empty, or `new Date().toISOString().split("T")[0]` for today. + +**`mapToFormValues`**: `d.check_in_date ? d.check_in_date.split("T")[0] : ""` + +**`mapFormToPayload`**: `values.check_in_date || undefined` + +## RhfTimeField + +Renders an HH / MM / SS spinner inside a Popover. Value is a `string` in `"HH:MM:SS"` format. + +```tsx +import { RhfTimeField } from "@/shared/components/form" + +// With seconds (default) + + +// Without seconds + +``` + +**Props**: +- `withSeconds?: boolean` — show the SS spinner (default `true`) +- `placeholder?: string` — trigger button placeholder text + +**Schema type**: `z.string().optional()` (stores `"HH:MM:SS"` or `"HH:MM"`) + +**Default value for current time**: +```ts +(() => { + const n = new Date() + return `${String(n.getHours()).padStart(2,"0")}:${String(n.getMinutes()).padStart(2,"0")}:${String(n.getSeconds()).padStart(2,"0")}` +})() +``` + +**`mapToFormValues`** (API returns `"HH:MM"` or ISO datetime): +- If ISO: `d.check_in_time ? d.check_in_time.split("T")[1]?.slice(0, 8) ?? "" : ""` +- If plain time string: `d.check_in_time ?? ""` + +**`mapFormToPayload`**: `values.check_in_time || undefined` + +## Underlying Controls (non-RHF use) + +If you need to use the pickers outside of RHF: + +```tsx +import { DatePickerField, TimePickerField } from "@/shared/components/form" + + + +``` + +## File Locations + +- Control: `apps/dashboard/shared/components/form/controls/date-picker-field.tsx` +- Control: `apps/dashboard/shared/components/form/controls/time-picker-field.tsx` +- RHF wrapper: `apps/dashboard/shared/components/form/fields/rhf-date-field.tsx` +- RHF wrapper: `apps/dashboard/shared/components/form/fields/rhf-time-field.tsx` +- Exports: `apps/dashboard/shared/components/form/index.ts` diff --git a/apps/dashboard/app/(authenticated)/calendar/appointment/[id]/layout.tsx b/apps/dashboard/app/(authenticated)/calendar/appointment/[id]/layout.tsx new file mode 100644 index 0000000..7753921 --- /dev/null +++ b/apps/dashboard/app/(authenticated)/calendar/appointment/[id]/layout.tsx @@ -0,0 +1,29 @@ +import { DashboardDetailsPage } from "@/base/components/layout/dashboard" +import { AppointmentActions } from "@/modules/appointments/appointment-actions" +import { AppointmentProvider } from "@/modules/appointments/appointment-context" +import { CalendarCheck2 } from "lucide-react" +import React from "react" + +export default async function layout(props: { + params: Promise<{ id: string }> + children: React.ReactNode +}) { + const { id } = await props.params + + return ( + + } + title={`Appointment #${id}`} + backHref="/calendar/appointment/list" + actions={} + tabs={[ + { href: `/calendar/appointment/${id}`, label: "Details" }, + ]} + > + {props.children} + + + ) +} diff --git a/apps/dashboard/app/(authenticated)/calendar/appointment/[id]/page.tsx b/apps/dashboard/app/(authenticated)/calendar/appointment/[id]/page.tsx new file mode 100644 index 0000000..daf8104 --- /dev/null +++ b/apps/dashboard/app/(authenticated)/calendar/appointment/[id]/page.tsx @@ -0,0 +1,49 @@ +"use client" + +import { use } from "react" +import { useQuery } from "@tanstack/react-query" +import { useAuthApi } from "@/shared/useApi" +import { APPOINTMENT_ROUTES } from "@garage/api" +import { AppointmentGeneralInfo } from "@/modules/appointments/appointment-general-info" +import DashboardPage from "@/base/components/layout/dashboard/dashboard-page" +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function AppointmentDetailsPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = use(params) + const api = useAuthApi() + + const { data, isLoading } = useQuery({ + queryKey: [APPOINTMENT_ROUTES.INDEX, "detail", id], + queryFn: async () => { + const response = await api.appointments.list() + const items = (response as any)?.data ?? [] + return items.find((item: any) => String(item.id) === id) ?? null + }, + }) + + if (isLoading) { + return ( + +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+
+ ) + } + + if (!data) { + return ( + +

Appointment not found.

+
+ ) + } + + return ( + + + + ) +} diff --git a/apps/dashboard/app/(authenticated)/calendar/appointment/list/page.tsx b/apps/dashboard/app/(authenticated)/calendar/appointment/list/page.tsx new file mode 100644 index 0000000..b8cae45 --- /dev/null +++ b/apps/dashboard/app/(authenticated)/calendar/appointment/list/page.tsx @@ -0,0 +1,112 @@ +"use client" + +import { useRouter } from "next/navigation" +import { ResourcePage } from "@/shared/data-view/resource-page" +import { ColumnHeader } from "@/shared/data-view/table-view" +import FormDialog from "@/shared/components/form-dialog" +import { AppointmentForm } from "@/modules/appointments/appointment-form" +import { APPOINTMENT_ROUTES } from "@garage/api" +import type { AppointmentsClient } from "@garage/api" +import { CalendarCheck2Icon, ClipboardListIcon, ClockIcon, ExternalLinkIcon } from "lucide-react" +import { Badge } from "@/shared/components/ui/badge" +import { Button } from "@/shared/components/ui/button" + +const STATUS_COLORS: Record = { + requested: "bg-yellow-100 text-yellow-800", + confirmed: "bg-blue-100 text-blue-800", + in_progress: "bg-purple-100 text-purple-800", + completed: "bg-green-100 text-green-800", + cancelled: "bg-red-100 text-red-800", +} + +export default function AppointmentsPage() { + const router = useRouter() + + return ( + + pageTitle="Appointments" + routeKey={APPOINTMENT_ROUTES.INDEX} + getClient={(api) => api.appointments} + onRowClick={(row) => router.push(`/calendar/appointment/${(row as any).id}`)} + headerProps={({ selectedItem, invalidateQuery }) => ({ + actions: ( + + {(resourceId) => ( + + )} + + ), + })} + columns={({ actionsColumn }) => [ + { + accessorKey: "title", + header: ({ column }) => , + cell: ({ row }) => ( +
+ + {(row.original as any).title} +
+ ), + }, + { + accessorKey: "date", + header: ({ column }) => , + }, + { + accessorKey: "from_time", + header: ({ column }) => , + cell: ({ row }) => { + const r = row.original as any + return ( +
+ + {r.from_time} – {r.to_time} +
+ ) + }, + }, + { + accessorKey: "status", + header: ({ column }) => , + cell: ({ row }) => { + const status = (row.original as any).status + const colorClass = STATUS_COLORS[status] ?? "bg-gray-100 text-gray-800" + return ( + + {status?.replace("_", " ") ?? "—"} + + ) + }, + }, + { + id: "job_card", + header: ({ column }) => , + cell: ({ row }) => { + const jobCardId = (row.original as any).job_card_id + if (!jobCardId) return + return ( + + ) + }, + }, + actionsColumn(), + ]} + /> + ) +} diff --git a/apps/dashboard/app/(authenticated)/calendars/page.tsx b/apps/dashboard/app/(authenticated)/calendars/page.tsx new file mode 100644 index 0000000..e69de29 diff --git a/apps/dashboard/app/(authenticated)/items/adjustment/page.tsx b/apps/dashboard/app/(authenticated)/items/adjustment/page.tsx new file mode 100644 index 0000000..977677d --- /dev/null +++ b/apps/dashboard/app/(authenticated)/items/adjustment/page.tsx @@ -0,0 +1,292 @@ +"use client" + +import { useState, useRef } from "react" +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { Paperclip, Plus, Trash2, FileIcon, ImageIcon, FileTextIcon } from "lucide-react" +import { toast } from "sonner" + +import { ResourcePage } from "@/shared/data-view/resource-page" +import { ColumnHeader } from "@/shared/data-view/table-view" +import FormDialog from "@/shared/components/form-dialog" +import { InventoryAdjustmentForm } from "@/modules/inventory-adjustments/inventory-adjustment-form" +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 { confirm } from "@/shared/components/confirm-dialog" +import { useAuthApi } from "@/shared/useApi" +import { INVENTORY_ADJUSTMENT_ROUTES } from "@garage/api" +import type { InventoryAdjustmentsClient } from "@garage/api" + +// ── Attachment helpers ── + +type AttachmentFile = { + id: number + original_name?: string + attachment_path?: string + created_at?: string +} + +function getFileIcon(path?: string) { + if (!path) return FileIcon + const lower = path.toLowerCase() + if (/\.(jpg|jpeg|png|gif|webp|svg)$/.test(lower)) return ImageIcon + if (/\.pdf$/.test(lower)) return FileTextIcon + return FileIcon +} + +// ── Attachments Dialog ── + +function AttachmentsDialog({ + open, + adjustmentId, + adjustmentRef, + onClose, +}: { + open: boolean + adjustmentId: string + adjustmentRef: string + onClose: () => void +}) { + const api = useAuthApi() + const queryClient = useQueryClient() + const fileInputRef = useRef(null) + const [isUploading, setIsUploading] = useState(false) + const [sessionFiles, setSessionFiles] = useState([]) + + const queryKey = [INVENTORY_ADJUSTMENT_ROUTES.INDEX, adjustmentId, "attachments"] + + const deleteMutation = useMutation({ + mutationFn: (attachmentId: number) => + api.inventoryAdjustments.deleteAttachment(adjustmentId, attachmentId), + onSuccess: (_, attachmentId) => { + toast.success("Attachment deleted.") + setSessionFiles((prev) => prev.filter((f) => f.id !== attachmentId)) + queryClient.invalidateQueries({ queryKey }) + }, + onError: () => toast.error("Failed to delete attachment."), + }) + + const handleDelete = async (attachment: AttachmentFile) => { + const confirmed = await confirm({ + title: "Delete Attachment", + description: `Are you sure you want to delete "${attachment.original_name ?? "this file"}"?`, + confirmLabel: "Delete", + variant: "destructive", + }) + if (confirmed) deleteMutation.mutate(attachment.id) + } + + const handleUpload = async (e: React.ChangeEvent) => { + const files = e.target.files + if (!files || files.length === 0) return + + setIsUploading(true) + const fileArray = Array.from(files) + + try { + const result = await toast.promise( + api.inventoryAdjustments.addAttachment(adjustmentId, fileArray), + { + loading: "Uploading attachment(s)...", + success: "Attachment(s) uploaded successfully", + error: "Failed to upload attachment(s)", + }, + ) + // Track uploaded files locally for display within this session + const now = new Date().toISOString() + const uploaded: AttachmentFile[] = fileArray.map((file, i) => ({ + id: Date.now() + i, + original_name: file.name, + attachment_path: file.name, + created_at: now, + })) + setSessionFiles((prev) => [...prev, ...uploaded]) + queryClient.invalidateQueries({ queryKey }) + } finally { + setIsUploading(false) + if (fileInputRef.current) fileInputRef.current.value = "" + } + } + + const handleClose = () => { + setSessionFiles([]) + onClose() + } + + return ( + !v && handleClose()}> + + + Attachments — {adjustmentRef} + + +
+ + +
+ + {sessionFiles.length === 0 ? ( + + + No attachments uploaded in this session. Click "Upload Attachment" to add files. + + + ) : ( +
+ {sessionFiles.map((attachment) => { + const Icon = getFileIcon(attachment.attachment_path) + return ( + + +
+ +
+
+ + {attachment.original_name} + + {attachment.created_at && ( + + {new Date(attachment.created_at).toLocaleDateString()} + + )} +
+ +
+
+ ) + })} +
+ )} +
+
+ ) +} + +// ── Page ── + +export default function InventoryAdjustmentsPage() { + const [attachmentTarget, setAttachmentTarget] = useState<{ + id: string + ref: string + } | null>(null) + + return ( + <> + + pageTitle="Inventory Adjustments" + routeKey={INVENTORY_ADJUSTMENT_ROUTES.INDEX} + getClient={(api) => api.inventoryAdjustments} + headerProps={({ selectedItem, invalidateQuery }) => ({ + actions: ( + + {(resourceId) => ( + + )} + + ), + })} + columns={({ actionsColumn }) => [ + { + accessorKey: "reference_number", + header: ({ column }) => , + cell: ({ row }) => (row.original as any).reference_number || "—", + }, + { + accessorKey: "date", + header: ({ column }) => , + cell: ({ row }) => { + const val = (row.original as any).date + return val ? new Date(val).toLocaleDateString() : "—" + }, + }, + { + accessorKey: "chart_of_account", + header: ({ column }) => , + cell: ({ row }) => (row.original as any).chart_of_account || "—", + }, + { + accessorKey: "notes", + header: ({ column }) => , + cell: ({ row }) => { + const notes = (row.original as any).notes + return notes ? ( + {notes} + ) : "—" + }, + }, + { + accessorKey: "created_at", + header: ({ column }) => , + cell: ({ row }) => { + const val = (row.original as any).created_at + return val ? new Date(val).toLocaleDateString() : "—" + }, + }, + { + id: "attachments", + header: () => null, + cell: ({ row }) => { + const item = row.original as any + return ( + + ) + }, + }, + actionsColumn(), + ]} + /> + + {attachmentTarget && ( + setAttachmentTarget(null)} + /> + )} + + ) +} diff --git a/apps/dashboard/app/(authenticated)/productivity/employees/[id]/layout.tsx b/apps/dashboard/app/(authenticated)/productivity/employees/[id]/layout.tsx new file mode 100644 index 0000000..f41c04e --- /dev/null +++ b/apps/dashboard/app/(authenticated)/productivity/employees/[id]/layout.tsx @@ -0,0 +1,39 @@ +import { DashboardDetailsPage } from '@/base/components/layout/dashboard' +import { getServerApi } from '@garage/api/server' +import { EmployeeActions } from '@/modules/employees/employee-actions' +import { EmployeeProvider } from '@/modules/employees/employee-context' +import React from 'react' + +export default async function layout(props: { + params: Promise<{ id: string }> + children: React.ReactNode +}) { + const { id } = await props.params + const api = await getServerApi() + const employee = await api.employees.getById(id) + + const firstName = employee.data?.first_name || '' + const lastName = employee.data?.last_name || '' + const title = [firstName, lastName].filter(Boolean).join(' ') || 'Employee Details' + const employeeLabel = title + + return ( + <> + + } + tabs={[ + { href: `/productivity/employees/${id}`, label: 'Details' }, + { href: `/productivity/employees/${id}/permissions`, label: 'Permissions' }, + ]} + > + {props.children} + + + + ) +} diff --git a/apps/dashboard/app/(authenticated)/productivity/employees/[id]/page.tsx b/apps/dashboard/app/(authenticated)/productivity/employees/[id]/page.tsx new file mode 100644 index 0000000..15711c2 --- /dev/null +++ b/apps/dashboard/app/(authenticated)/productivity/employees/[id]/page.tsx @@ -0,0 +1,19 @@ +import { getServerApi } from '@garage/api/server' +import { EmployeeGeneralInfo } from '@/modules/employees/employee-general-info' +import DashboardPage from '@/base/components/layout/dashboard/dashboard-page' + +export default async function page(props: { params: Promise<{ id: string }> }) { + const { id } = await props.params + const api = await getServerApi() + const employee = await api.employees.getById(id) + + if (!employee.data) { + return
Employee not found.
+ } + + return ( + + + + ) +} diff --git a/apps/dashboard/app/(authenticated)/productivity/employees/[id]/permissions/page.tsx b/apps/dashboard/app/(authenticated)/productivity/employees/[id]/permissions/page.tsx new file mode 100644 index 0000000..7721774 --- /dev/null +++ b/apps/dashboard/app/(authenticated)/productivity/employees/[id]/permissions/page.tsx @@ -0,0 +1,10 @@ +"use client" + +import { use } from "react" +import { EmployeePermissionsForm } from "@/modules/employees/employee-permissions-form" + +export default function EmployeePermissionsPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = use(params) + + return +} diff --git a/apps/dashboard/app/(authenticated)/productivity/employees/page.tsx b/apps/dashboard/app/(authenticated)/productivity/employees/page.tsx index 82ae50e..22ad472 100644 --- a/apps/dashboard/app/(authenticated)/productivity/employees/page.tsx +++ b/apps/dashboard/app/(authenticated)/productivity/employees/page.tsx @@ -6,13 +6,16 @@ import FormDialog from "@/shared/components/form-dialog" import { EmployeeForm } from "@/modules/employees/employee-form" import { EMPLOYEE_ROUTES } from "@garage/api" import type { EmployeesClient } from "@garage/api" +import { useRouter } from "next/navigation" export default function EmployeesPage() { + const router = useRouter() return ( pageTitle="Employees" routeKey={EMPLOYEE_ROUTES.INDEX} getClient={(api) => api.employees} + onRowClick={(row) => router.push(`/productivity/employees/${(row as any).id}`)} headerProps={({ selectedItem, invalidateQuery }) => ({ actions: ( diff --git a/apps/dashboard/app/(authenticated)/purchase/bill/page.tsx b/apps/dashboard/app/(authenticated)/purchase/bill/page.tsx new file mode 100644 index 0000000..4204e99 --- /dev/null +++ b/apps/dashboard/app/(authenticated)/purchase/bill/page.tsx @@ -0,0 +1,73 @@ +"use client" + +import FormDialog from "@/shared/components/form-dialog" +import { Badge } from "@/shared/components/ui/badge" +import { ResourcePage } from "@/shared/data-view/resource-page" +import { ColumnHeader } from "@/shared/data-view/table-view" +import { BillForm } from "@/modules/bills/bill-form" +import { BILL_ROUTES } from "@garage/api" +import type { BillsClient } from "@garage/api" + +export default function BillsPage() { + return ( + + pageTitle="Bills" + routeKey={BILL_ROUTES.INDEX} + getClient={(api) => api.bills} + headerProps={({ selectedItem, invalidateQuery }) => ({ + actions: ( + + {(resourceId) => ( + + )} + + ), + })} + columns={({ actionsColumn }) => [ + { + accessorKey: "bill_number", + header: ({ column }) => , + cell: ({ row }) => (row.original as any).bill_number || "—", + }, + { + accessorKey: "title", + header: ({ column }) => , + }, + { + accessorKey: "vendor_name", + header: ({ column }) => , + cell: ({ row }) => (row.original as any).vendor_name || "—", + }, + { + accessorKey: "bill_date", + header: ({ column }) => , + cell: ({ row }) => { + const value = (row.original as any).bill_date + return value ? new Date(value).toLocaleDateString() : "—" + }, + }, + { + accessorKey: "bill_due_date", + header: ({ column }) => , + cell: ({ row }) => { + const value = (row.original as any).bill_due_date + return value ? new Date(value).toLocaleDateString() : "—" + }, + }, + { + accessorKey: "status", + header: ({ column }) => , + cell: ({ row }) => { + const status = (row.original as any).status + return {status || "—"} + }, + }, + actionsColumn(), + ]} + /> + ) +} diff --git a/apps/dashboard/app/(authenticated)/purchase/payments-made/page.tsx b/apps/dashboard/app/(authenticated)/purchase/payments-made/page.tsx new file mode 100644 index 0000000..0adf019 --- /dev/null +++ b/apps/dashboard/app/(authenticated)/purchase/payments-made/page.tsx @@ -0,0 +1,377 @@ +"use client" + +import { useState, useRef } from "react" +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { + Paperclip, + Plus, + Trash2, + FileIcon, + ImageIcon, + FileTextIcon, + BadgeDollarSignIcon, + CalendarIcon, + CreditCardIcon, + HashIcon, + UserIcon, + BriefcaseIcon, +} from "lucide-react" +import { toast } from "sonner" + +import { ResourcePage } from "@/shared/data-view/resource-page" +import { ColumnHeader } from "@/shared/data-view/table-view" +import FormDialog from "@/shared/components/form-dialog" +import { PaymentMadeForm } from "@/modules/payment-mades/payment-made-form" +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 { confirm } from "@/shared/components/confirm-dialog" +import { useAuthApi } from "@/shared/useApi" +import { PAYMENT_MADE_ROUTES } from "@garage/api" +import type { PaymentMadesClient } from "@garage/api" + +// ── Attachment helpers ── + +type AttachmentFile = { + id: number + original_name?: string + attachment_path?: string + created_at?: string +} + +function getFileIcon(path?: string) { + if (!path) return FileIcon + const lower = path.toLowerCase() + if (/\.(jpg|jpeg|png|gif|webp|svg)$/.test(lower)) return ImageIcon + if (/\.pdf$/.test(lower)) return FileTextIcon + return FileIcon +} + +// ── Attachments Dialog ── + +function AttachmentsDialog({ + open, + paymentId, + paymentRef, + onClose, +}: { + open: boolean + paymentId: string + paymentRef: string + onClose: () => void +}) { + const api = useAuthApi() + const queryClient = useQueryClient() + const fileInputRef = useRef(null) + const [isUploading, setIsUploading] = useState(false) + const [sessionFiles, setSessionFiles] = useState([]) + + const queryKey = [PAYMENT_MADE_ROUTES.INDEX, paymentId, "attachments"] + + const deleteMutation = useMutation({ + mutationFn: (attachmentId: number) => + api.paymentMades.deleteAttachment(paymentId, { attachment_id: attachmentId } as any), + onSuccess: (_, attachmentId) => { + toast.success("Attachment deleted.") + setSessionFiles((prev) => prev.filter((f) => f.id !== attachmentId)) + queryClient.invalidateQueries({ queryKey }) + }, + onError: () => toast.error("Failed to delete attachment."), + }) + + const handleDelete = async (attachment: AttachmentFile) => { + const confirmed = await confirm({ + title: "Delete Attachment", + description: `Are you sure you want to delete "${attachment.original_name ?? "this file"}"?`, + confirmLabel: "Delete", + variant: "destructive", + }) + if (confirmed) deleteMutation.mutate(attachment.id) + } + + const handleUpload = async (e: React.ChangeEvent) => { + const files = e.target.files + if (!files || files.length === 0) return + + setIsUploading(true) + const fileArray = Array.from(files) + + try { + const formData = new FormData() + fileArray.forEach((file) => formData.append("attachments[]", file)) + + await toast.promise( + api.paymentMades.addAttachment(paymentId, formData), + { + loading: "Uploading attachment(s)...", + success: "Attachment(s) uploaded successfully", + error: "Failed to upload attachment(s)", + }, + ) + const now = new Date().toISOString() + const uploaded: AttachmentFile[] = fileArray.map((file, i) => ({ + id: Date.now() + i, + original_name: file.name, + attachment_path: file.name, + created_at: now, + })) + setSessionFiles((prev) => [...prev, ...uploaded]) + queryClient.invalidateQueries({ queryKey }) + } finally { + setIsUploading(false) + if (fileInputRef.current) fileInputRef.current.value = "" + } + } + + const handleClose = () => { + setSessionFiles([]) + onClose() + } + + return ( + !v && handleClose()}> + + + Attachments — {paymentRef} + + +
+ + +
+ + {sessionFiles.length === 0 ? ( + + + No attachments uploaded in this session. Click "Upload Attachment" to add files. + + + ) : ( +
+ {sessionFiles.map((attachment) => { + const Icon = getFileIcon(attachment.attachment_path) + return ( + + +
+ +
+
+ + {attachment.original_name} + + {attachment.created_at && ( + + {new Date(attachment.created_at).toLocaleDateString()} + + )} +
+ +
+
+ ) + })} +
+ )} +
+
+ ) +} + +// ── Page ── + +type PaymentMadeItem = { + id: number + payment_number?: string + vendor_name?: string + employee_name?: string + payment_for?: string + payment_made?: string | number + payment_mode_name?: string + payment_date?: string + paid_through?: string + notes?: string + created_at?: string +} + +export default function PaymentsMadePage() { + const [attachmentTarget, setAttachmentTarget] = useState<{ + id: string + ref: string + } | null>(null) + + return ( + <> + + pageTitle="Payments Made" + routeKey={PAYMENT_MADE_ROUTES.INDEX} + getClient={(api) => api.paymentMades} + headerProps={({ invalidateQuery }) => ({ + actions: ( + + {(resourceId) => ( + + )} + + ), + })} + columns={({ actionsColumn }) => [ + { + accessorKey: "payment_number", + header: ({ column }) => , + cell: ({ row }) => { + const item = row.original as unknown as PaymentMadeItem + return ( +
+ + {item.payment_number || "—"} +
+ ) + }, + }, + { + accessorKey: "vendor_name", + header: ({ column }) => , + cell: ({ row }) => { + const item = row.original as unknown as PaymentMadeItem + return ( +
+ + {item.vendor_name || "—"} +
+ ) + }, + }, + { + accessorKey: "payment_for", + header: ({ column }) => , + cell: ({ row }) => { + const item = row.original as unknown as PaymentMadeItem + return ( +
+ + {item.payment_for || "—"} +
+ ) + }, + }, + { + accessorKey: "payment_made", + header: ({ column }) => , + cell: ({ row }) => { + const item = row.original as unknown as PaymentMadeItem + const amount = item.payment_made + ? Number(item.payment_made).toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }) + : "—" + return ( +
+ + + {amount} + +
+ ) + }, + }, + { + accessorKey: "payment_mode_name", + header: ({ column }) => , + cell: ({ row }) => { + const item = row.original as unknown as PaymentMadeItem + return ( +
+ + {item.payment_mode_name || "—"} +
+ ) + }, + }, + { + accessorKey: "payment_date", + header: ({ column }) => , + cell: ({ row }) => { + const item = row.original as unknown as PaymentMadeItem + const formatted = item.payment_date + ? new Date(item.payment_date).toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }) + : "—" + return ( +
+ + {formatted} +
+ ) + }, + }, + { + id: "attachments", + header: () => null, + cell: ({ row }) => { + const item = row.original as any + return ( + + ) + }, + }, + actionsColumn(), + ]} + /> + + {attachmentTarget && ( + setAttachmentTarget(null)} + /> + )} + + ) +} diff --git a/apps/dashboard/app/(authenticated)/purchase/purchase-order/page.tsx b/apps/dashboard/app/(authenticated)/purchase/purchase-order/page.tsx new file mode 100644 index 0000000..28fa0d6 --- /dev/null +++ b/apps/dashboard/app/(authenticated)/purchase/purchase-order/page.tsx @@ -0,0 +1,72 @@ +"use client" + +import { ResourcePage } from "@/shared/data-view/resource-page" +import { ColumnHeader } from "@/shared/data-view/table-view" +import FormDialog from "@/shared/components/form-dialog" +import { PurchaseOrderForm } from "@/modules/purchase-orders/purchase-order-form" +import { PURCHASE_ORDER_ROUTES } from "@garage/api" +import type { PurchaseOrdersClient } from "@garage/api" + +export default function PurchaseOrdersPage() { + return ( + + pageTitle="Purchase Orders" + routeKey={PURCHASE_ORDER_ROUTES.INDEX} + getClient={(api) => api.purchaseOrders} + headerProps={({ selectedItem, invalidateQuery }) => ({ + actions: ( + + {(resourceId) => ( + + )} + + ), + })} + columns={({ actionsColumn }) => [ + { + accessorKey: "order_number", + header: ({ column }) => , + cell: ({ row }) => (row.original as any).order_number || "—", + }, + { + accessorKey: "title", + header: ({ column }) => , + }, + { + accessorKey: "vendor_name", + header: ({ column }) => , + cell: ({ row }) => (row.original as any).vendor_name || "—", + }, + { + accessorKey: "order_date", + header: ({ column }) => , + cell: ({ row }) => { + const val = (row.original as any).order_date + return val ? new Date(val).toLocaleDateString() : "—" + }, + }, + { + accessorKey: "delivery_date", + header: ({ column }) => , + cell: ({ row }) => { + const val = (row.original as any).delivery_date + return val ? new Date(val).toLocaleDateString() : "—" + }, + }, + { + accessorKey: "created_at", + header: ({ column }) => , + cell: ({ row }) => { + const val = (row.original as any).created_at + return val ? new Date(val).toLocaleDateString() : "—" + }, + }, + actionsColumn(), + ]} + /> + ) +} diff --git a/apps/dashboard/app/(authenticated)/purchase/vendor-credit/page.tsx b/apps/dashboard/app/(authenticated)/purchase/vendor-credit/page.tsx new file mode 100644 index 0000000..748594d --- /dev/null +++ b/apps/dashboard/app/(authenticated)/purchase/vendor-credit/page.tsx @@ -0,0 +1,65 @@ +"use client" + +import FormDialog from "@/shared/components/form-dialog" +import { Badge } from "@/shared/components/ui/badge" +import { ResourcePage } from "@/shared/data-view/resource-page" +import { ColumnHeader } from "@/shared/data-view/table-view" +import { VendorCreditForm } from "@/modules/vendor-credits/vendor-credit-form" +import { VENDOR_CREDIT_ROUTES } from "@garage/api" +import type { VendorCreditsClient } from "@garage/api" + +export default function VendorCreditsPage() { + return ( + + pageTitle="Vendor Credits" + routeKey={VENDOR_CREDIT_ROUTES.INDEX} + getClient={(api) => api.vendorCredits} + headerProps={({ selectedItem, invalidateQuery }) => ({ + actions: ( + + {(resourceId) => ( + + )} + + ), + })} + columns={({ actionsColumn }) => [ + { + accessorKey: "subject", + header: ({ column }) => , + }, + { + accessorKey: "vendor_name", + header: ({ column }) => , + cell: ({ row }) => (row.original as any).vendor_name || "—", + }, + { + accessorKey: "bill_number", + header: ({ column }) => , + cell: ({ row }) => (row.original as any).bill_number || "—", + }, + { + accessorKey: "vendor_credit_date", + header: ({ column }) => , + cell: ({ row }) => { + const value = (row.original as any).vendor_credit_date + return value ? new Date(value).toLocaleDateString() : "—" + }, + }, + { + accessorKey: "status", + header: ({ column }) => , + cell: ({ row }) => { + const status = (row.original as any).status + return {status || "—"} + }, + }, + actionsColumn(), + ]} + /> + ) +} diff --git a/apps/dashboard/app/(authenticated)/sales/customers/[id]/layout.tsx b/apps/dashboard/app/(authenticated)/sales/customers/[id]/layout.tsx new file mode 100644 index 0000000..9514d23 --- /dev/null +++ b/apps/dashboard/app/(authenticated)/sales/customers/[id]/layout.tsx @@ -0,0 +1,40 @@ +import { DashboardDetailsPage } from '@/base/components/layout/dashboard' +import { getServerApi } from '@garage/api/server' +import { CustomerActions } from '@/modules/customers/customer-actions' +import { CustomerProvider } from '@/modules/customers/customer-context' +import React from 'react' + +export default async function layout(props: { + params: Promise<{ id: string }> + children: React.ReactNode +}) { + const { id } = await props.params + const api = await getServerApi() + const customer = await api.customers.getById(id) + + const firstName = customer.data?.first_name ?? '' + const lastName = customer.data?.last_name ?? '' + const fullName = [firstName, lastName].filter(Boolean).join(' ') || 'Customer Details' + const customerLabel = fullName + + return ( + <> + + } + tabs={[ + { href: `/sales/customers/${id}`, label: 'Details' }, + { href: `/sales/customers/${id}/notes`, label: 'Notes' }, + { href: `/sales/customers/${id}/vehicles`, label: 'Vehicles' }, + ]} + > + {props.children} + + + + ) +} diff --git a/apps/dashboard/app/(authenticated)/sales/customers/[id]/notes/page.tsx b/apps/dashboard/app/(authenticated)/sales/customers/[id]/notes/page.tsx new file mode 100644 index 0000000..6fd251c --- /dev/null +++ b/apps/dashboard/app/(authenticated)/sales/customers/[id]/notes/page.tsx @@ -0,0 +1,206 @@ +"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, StickyNote } from "lucide-react" +import { toast } from "sonner" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { z } from "zod" + +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 { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/shared/components/ui/dialog" +import { + Field, + FieldLabel, + FieldError, +} from "@/shared/components/ui/field" +import { Textarea } from "@/shared/components/ui/textarea" +import DashboardPage from "@/base/components/layout/dashboard/dashboard-page" + +type CustomerNote = { + id: number + note: string + created_at: string + updated_at: string +} + +const addNoteSchema = z.object({ + note: z.string().min(1, "Note content is required"), +}) +type AddNoteValues = z.infer + +export default function CustomerNotesPage() { + const { id: customerId } = useParams<{ id: string }>() + const api = useAuthApi() + const queryClient = useQueryClient() + const [dialogOpen, setDialogOpen] = useState(false) + + const queryKey = ["customer-notes", customerId] + + const { data: customerData, isLoading } = useQuery({ + queryKey, + queryFn: () => api.customers.getById(customerId), + }) + + const notes: CustomerNote[] = (customerData?.data as any)?.notes ?? [] + + const meta = (customerData as any)?.meta + const pagination = { + page: meta?.current_page ?? 1, + pageSize: meta?.per_page ?? 15, + pageCount: meta?.last_page ?? 1, + total: meta?.total ?? notes.length, + } + + const addNoteMutation = useMutation({ + mutationFn: (values: AddNoteValues) => + api.customers.addNote(customerId, { note: values.note }), + onSuccess: () => { + toast.success("Note added successfully.") + queryClient.invalidateQueries({ queryKey }) + setDialogOpen(false) + reset() + }, + onError: () => { + toast.error("Failed to add note.") + }, + }) + + const deleteNoteMutation = useMutation({ + mutationFn: (noteId: number) => api.customers.deleteNote(customerId, noteId), + onSuccess: () => { + toast.success("Note deleted successfully.") + queryClient.invalidateQueries({ queryKey }) + }, + onError: () => { + toast.error("Failed to delete note.") + }, + }) + + const { + handleSubmit, + register, + reset, + formState: { errors }, + } = useForm({ + resolver: zodResolver(addNoteSchema), + defaultValues: { note: "" }, + }) + + const handleDelete = async (note: CustomerNote) => { + const confirmed = await confirm({ + title: "Delete Note", + description: "Are you sure you want to delete this note?", + confirmLabel: "Delete", + variant: "destructive", + }) + if (confirmed) { + deleteNoteMutation.mutate(note.id) + } + } + + const columns: ColumnDef[] = [ + { + accessorKey: "note", + header: ({ column }) => , + cell: ({ getValue }) => ( + {getValue()} + ), + }, + { + accessorKey: "created_at", + header: ({ column }) => , + cell: ({ getValue }) => { + const val = getValue() + return val ? new Date(val).toLocaleDateString() : "—" + }, + }, + { + id: "actions", + header: () => Actions, + cell: ({ row }) => ( +
+ +
+ ), + }, + ] + + return ( + setDialogOpen(true)}> + + Add Note + + } + > + {}} + isLoading={isLoading} + /> + + + + + Add Note + +
addNoteMutation.mutate(values))} + className="grid gap-4" + > + + Note +