diff --git a/.github/skills/crud-page/references/form.md b/.github/skills/crud-page/references/form.md index c6df8ba..18725d0 100644 --- a/.github/skills/crud-page/references/form.md +++ b/.github/skills/crud-page/references/form.md @@ -37,11 +37,18 @@ import { _ROUTES } from "@garage/api" // ── Constants ── -// Static select options (if needed): -// const STATUS_OPTIONS = [ -// { value: "active", label: "Active" }, -// { value: "inactive", label: "Inactive" }, -// ] +// ALWAYS derive select options from API enums imported from "@garage/api". +// NEVER hardcode enum values inline — they must stay in sync with the backend. +// +// Pattern: import the enum array, then .map() to produce { value, label } pairs. +// +// const STATUS_OPTIONS = SomeStatus.map((v) => ({ +// value: v, +// label: v.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()), +// })) +// +// If no matching enum exists in packages/api/src/contracts/enums.ts, add it there first, +// then import and use it here. // ── Props ── diff --git a/apps/dashboard/app/(authenticated)/items/parts/page.tsx b/apps/dashboard/app/(authenticated)/items/parts/page.tsx index 76a5bd0..054d1cc 100644 --- a/apps/dashboard/app/(authenticated)/items/parts/page.tsx +++ b/apps/dashboard/app/(authenticated)/items/parts/page.tsx @@ -3,12 +3,17 @@ import { ResourcePage } from "@/shared/data-view/resource-page" import { ColumnHeader } from "@/shared/data-view/table-view" import FormDialog from "@/shared/components/form-dialog" +import { ImportDataButton } from "@/shared/components/import-data-button" +import { ExportDataButton } from "@/shared/components/export-data-button" import { PartForm } from "@/modules/parts/part-form" import { Badge } from "@/shared/components/ui/badge" +import { useAuthApi } from "@/shared/useApi" import { PARTS_ROUTES } from "@garage/api" import type { PartsClient } from "@garage/api" export default function PartsPage() { + const api = useAuthApi() + return ( pageTitle="Parts" @@ -16,15 +21,25 @@ export default function PartsPage() { getClient={(api) => api.parts} headerProps={({ selectedItem, invalidateQuery }) => ({ actions: ( - - {(resourceId) => ( - - )} - +
+ api.parts.importData(file)} + onSuccess={invalidateQuery} + /> + api.parts.exportData(filters)} + fileName="parts" + /> + + {(resourceId) => ( + + )} + +
), })} columns={({ actionsColumn }) => [ diff --git a/apps/dashboard/app/(authenticated)/items/services/page.tsx b/apps/dashboard/app/(authenticated)/items/services/page.tsx index 703e02d..ecf4ebc 100644 --- a/apps/dashboard/app/(authenticated)/items/services/page.tsx +++ b/apps/dashboard/app/(authenticated)/items/services/page.tsx @@ -3,11 +3,16 @@ import { ResourcePage } from "@/shared/data-view/resource-page" import { ColumnHeader } from "@/shared/data-view/table-view" import FormDialog from "@/shared/components/form-dialog" +import { ImportDataButton } from "@/shared/components/import-data-button" +import { ExportDataButton } from "@/shared/components/export-data-button" +import { useAuthApi } from "@/shared/useApi" import { ServiceForm } from "@/modules/services/service-form" import { SERVICE_ROUTES } from "@garage/api" import type { ServicesClient } from "@garage/api" export default function ServicesPage() { + const api = useAuthApi() + return ( pageTitle="Services" @@ -15,15 +20,25 @@ export default function ServicesPage() { getClient={(api) => api.services} headerProps={({ selectedItem, invalidateQuery }) => ({ actions: ( - - {(resourceId) => ( - - )} - +
+ api.services.importData(file)} + onSuccess={invalidateQuery} + /> + api.services.exportData(filters)} + fileName="services" + /> + + {(resourceId) => ( + + )} + +
), })} columns={({ actionsColumn }) => [ @@ -48,7 +63,7 @@ export default function ServicesPage() { cell: ({ row }) => { const val = (row.original as any).description return val - ? {val} + ? {val} : "—" }, }, diff --git a/apps/dashboard/app/(authenticated)/sales/customers/page.tsx b/apps/dashboard/app/(authenticated)/sales/customers/page.tsx index 0781856..8c853e9 100644 --- a/apps/dashboard/app/(authenticated)/sales/customers/page.tsx +++ b/apps/dashboard/app/(authenticated)/sales/customers/page.tsx @@ -4,6 +4,9 @@ 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 { ImportDataButton } from '@/shared/components/import-data-button' +import { ExportDataButton } from '@/shared/components/export-data-button' +import { useAuthApi } from '@/shared/useApi' import { CustomerForm } from '@/modules/customers/customer-form' import { CUSTOMER_ROUTES } from '@garage/api' import type { CustomersClient } from '@garage/api' @@ -11,6 +14,7 @@ import { Building2Icon, UserIcon } from 'lucide-react' export default function CustomersPage() { const router = useRouter() + const api = useAuthApi() return ( pageTitle='Customers' @@ -19,15 +23,25 @@ export default function CustomersPage() { onRowClick={(row) => router.push(`/sales/customers/${(row as any).id}`)} headerProps={({ selectedItem, invalidateQuery }) => ({ actions: ( - - {(resourceId) => ( - - )} - +
+ api.customers.importData(file)} + onSuccess={invalidateQuery} + /> + api.customers.exportData(filters)} + fileName='customers' + /> + + {(resourceId) => ( + + )} + +
), })} columns={({ actionsColumn }) => [ diff --git a/apps/dashboard/app/(authenticated)/sales/vehicles/page.tsx b/apps/dashboard/app/(authenticated)/sales/vehicles/page.tsx index 5c7a8d1..0182a0f 100644 --- a/apps/dashboard/app/(authenticated)/sales/vehicles/page.tsx +++ b/apps/dashboard/app/(authenticated)/sales/vehicles/page.tsx @@ -4,6 +4,9 @@ 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 { ImportDataButton } from '@/shared/components/import-data-button' +import { ExportDataButton } from '@/shared/components/export-data-button' +import { useAuthApi } from '@/shared/useApi' import { VehicleForm } from '@/modules/vehicles/vehicle-form' import { VEHICLE_ROUTES } from '@garage/api' import type { VehiclesClient } from '@garage/api' @@ -11,6 +14,7 @@ import { CarIcon } from 'lucide-react' export default function VehiclesPage() { const router = useRouter() + const api = useAuthApi() return ( pageTitle="Vehicles" @@ -19,15 +23,25 @@ export default function VehiclesPage() { onRowClick={(row) => router.push(`/sales/vehicles/${(row as any).id}`)} headerProps={({ selectedItem, invalidateQuery }) => ({ actions: ( - - {(resourceId) => ( - - )} - +
+ api.vehicles.importData(file)} + onSuccess={invalidateQuery} + /> + api.vehicles.exportData(filters)} + fileName='vehicles' + /> + + {(resourceId) => ( + + )} + +
), })} columns={({ actionsColumn }) => [ diff --git a/apps/dashboard/app/layout.tsx b/apps/dashboard/app/layout.tsx index 326b949..19e7703 100644 --- a/apps/dashboard/app/layout.tsx +++ b/apps/dashboard/app/layout.tsx @@ -1,15 +1,21 @@ import { Geist_Mono, Inter } from "next/font/google" -import "./globals.css" + import { QueryProvider } from "@/shared/components/query-provider" import { ThemeProvider } from "@/shared/components/theme-provider" import { Toaster } from "@/shared/components/ui/sonner" import { ConfirmDialog } from "@/shared/components/confirm-dialog" import { NuqsAdapter } from "nuqs/adapters/next/app" import { cn } from "@/shared/lib/utils" +import './globals.css' const inter = Inter({ subsets: ["latin"], variable: "--font-sans" }) +export const metadata = { + title: "Garage ERP Dashboard", + description: "Manage your garage with ease using Garage ERP Dashboard.", +} + const fontMono = Geist_Mono({ subsets: ["latin"], variable: "--font-mono", diff --git a/apps/dashboard/modules/appointments/appointment.schema.ts b/apps/dashboard/modules/appointments/appointment.schema.ts index fd82a01..1cf6dc5 100644 --- a/apps/dashboard/modules/appointments/appointment.schema.ts +++ b/apps/dashboard/modules/appointments/appointment.schema.ts @@ -1,16 +1,14 @@ import { z } from "zod" +import { AppointmentStatus } from "@garage/api" const relationFieldSchema = z .object({ value: z.string(), label: z.string() }) .nullable() -const APPOINTMENT_STATUS_OPTIONS = [ - { value: "requested", label: "Requested" }, - { value: "confirmed", label: "Confirmed" }, - { value: "in_progress", label: "In Progress" }, - { value: "completed", label: "Completed" }, - { value: "cancelled", label: "Cancelled" }, -] +const APPOINTMENT_STATUS_OPTIONS = AppointmentStatus.map((v) => ({ + value: v, + label: v.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()), +})) const appointmentFormSchema = z.object({ title: z.string().min(1, "Title is required"), diff --git a/apps/dashboard/modules/credit-notes/credit-note-form.tsx b/apps/dashboard/modules/credit-notes/credit-note-form.tsx index a724fe5..33cfd72 100644 --- a/apps/dashboard/modules/credit-notes/credit-note-form.tsx +++ b/apps/dashboard/modules/credit-notes/credit-note-form.tsx @@ -22,7 +22,7 @@ import { creditNoteFormSchema, type CreditNoteFormValues, } from "./credit-note.schema" -import { CREDIT_NOTE_ROUTES, DEPARTMENT_ROUTES } from "@garage/api" +import { CREDIT_NOTE_ROUTES, DEPARTMENT_ROUTES, CreditNoteStatus } from "@garage/api" import { RhfCustomerSelectField } from "@/modules/customers/rhf-customer-select-field" // ── Shared mapOption for async selects ── @@ -36,12 +36,10 @@ const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) = // ── Constants ── -const STATUS_OPTIONS = [ - { value: "draft", label: "Draft" }, - { value: "open", label: "Open" }, - { value: "applied", label: "Applied" }, - { value: "void", label: "Void" }, -] +const STATUS_OPTIONS = CreditNoteStatus.map((v) => ({ + value: v, + label: v.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()), +})) // ── Props ── diff --git a/apps/dashboard/modules/employees/employee-form.tsx b/apps/dashboard/modules/employees/employee-form.tsx index 404a7de..40309c6 100644 --- a/apps/dashboard/modules/employees/employee-form.tsx +++ b/apps/dashboard/modules/employees/employee-form.tsx @@ -22,7 +22,7 @@ import { employeeFormSchema, type EmployeeFormValues, } from "./employee.schema" -import { EMPLOYEE_ROUTES, DEPARTMENT_ROUTES, SHOP_TIMING_ROUTES, SHOP_CALENDAR_ROUTES } from "@garage/api" +import { EMPLOYEE_ROUTES, DEPARTMENT_ROUTES, SHOP_TIMING_ROUTES, SHOP_CALENDAR_ROUTES, EmployeeType } from "@garage/api" // ── Constants ── @@ -31,10 +31,10 @@ const STATUS_OPTIONS = [ { value: "inactive", label: "Inactive" }, ] -const TYPE_OPTIONS = [ - { value: "employee", label: "Employee" }, - { value: "contractor", label: "Contractor" }, -] +const TYPE_OPTIONS = EmployeeType.map((v) => ({ + value: v, + label: v.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()), +})) // ── Props ── diff --git a/apps/dashboard/modules/employees/employee.schema.ts b/apps/dashboard/modules/employees/employee.schema.ts index 6a0f374..aab21e6 100644 --- a/apps/dashboard/modules/employees/employee.schema.ts +++ b/apps/dashboard/modules/employees/employee.schema.ts @@ -1,11 +1,12 @@ import { z } from "zod" +import { EmployeeType } from "@garage/api" const relationFieldSchema = z .object({ value: z.string(), label: z.string() }) .nullable() const STATUS_OPTIONS = ["active", "inactive"] as const -const TYPE_OPTIONS = ["employee", "contractor"] as const +const TYPE_OPTIONS = EmployeeType const employeeFormSchema = z.object({ department: relationFieldSchema, diff --git a/apps/dashboard/modules/expenses/expense-form.tsx b/apps/dashboard/modules/expenses/expense-form.tsx index ab4b190..abe5e9d 100644 --- a/apps/dashboard/modules/expenses/expense-form.tsx +++ b/apps/dashboard/modules/expenses/expense-form.tsx @@ -22,15 +22,15 @@ import { expenseFormSchema, type ExpenseFormValues, } 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, ExpenseStatus } from "@garage/api" import { getFullName } from "@/shared/utils/getFullName" // ── Constants ── -const STATUS_OPTIONS = [ - { value: "open", label: "Open" }, - { value: "paid", label: "Paid" }, -] +const STATUS_OPTIONS = ExpenseStatus.map((v) => ({ + value: v, + label: v.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()), +})) // ── Props ── diff --git a/apps/dashboard/modules/invoices/invoice-form.tsx b/apps/dashboard/modules/invoices/invoice-form.tsx index 4b5099b..0270ab8 100644 --- a/apps/dashboard/modules/invoices/invoice-form.tsx +++ b/apps/dashboard/modules/invoices/invoice-form.tsx @@ -11,6 +11,7 @@ import { RhfSelectField, RhfTextareaField, RhfAsyncSelectField, + RhfAutoGenerateField, } from "@/shared/components/form" import { toast } from "sonner" import { useAuthApi } from "@/shared/useApi" @@ -22,19 +23,16 @@ import { invoiceFormSchema, type InvoiceFormValues, } from "./invoice.schema" -import { INVOICE_ROUTES, DEPARTMENT_ROUTES } from "@garage/api" +import { INVOICE_ROUTES, DEPARTMENT_ROUTES, InvoiceStatus } from "@garage/api" import { RhfCustomerSelectField } from "@/modules/customers/rhf-customer-select-field" import { RhfVehicleSelectField } from "@/modules/vehicles/rhf-vehicle-select-field" // ── Constants ── -const STATUS_OPTIONS = [ - { value: "draft", label: "Draft" }, - { value: "open", label: "Open" }, - { value: "paid", label: "Paid" }, - { value: "overdue", label: "Overdue" }, - { value: "void", label: "Void" }, -] +const STATUS_OPTIONS = InvoiceStatus.map((v) => ({ + value: v, + label: v.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()), +})) // ── Props ── @@ -148,7 +146,7 @@ export function InvoiceForm({ resourceId, initialData, onSuccess }: InvoiceFormP
- + | null onSuccess?: () => void } @@ -150,59 +150,59 @@ export function PartForm({ resourceId, initialData, onSuccess }: PartFormProps) />
- {!isEditing && ( - <> -
- api.shopTypes.list()} - mapOption={mapLookupOption} - createForm={(props) => } - createLabel="Shop Type" - {...STORE_OBJECT} - /> - api.inventory.listCategories()} - mapOption={mapLookupOption} - createForm={(props) => } - createLabel="Category" - {...STORE_OBJECT} - /> -
-
- api.inventory.listUnitTypes()} - mapOption={mapLookupOption} - createForm={(props) => } - createLabel="Unit Type" - {...STORE_OBJECT} - /> - api.departments.list()} - mapOption={(item: any) => ({ value: String(item.id), label: item.name ?? String(item.id) })} - createForm={(props) => } - createLabel="Department" - {...STORE_OBJECT} - /> -
- - )} + <> +
+ api.shopTypes.list()} + mapOption={mapLookupOption} + createForm={(props) => } + createLabel="Shop Type" + {...STORE_OBJECT} + /> + api.inventory.listCategories()} + mapOption={mapLookupOption} + createForm={(props) => } + createLabel="Category" + {...STORE_OBJECT} + /> +
+ +
+ api.inventory.listUnitTypes()} + mapOption={mapLookupOption} + createForm={(props) => } + createLabel="Unit Type" + {...STORE_OBJECT} + /> + api.departments.list()} + mapOption={(item: any) => ({ value: String(item.id), label: item.name ?? String(item.id) })} + createForm={(props) => } + createLabel="Department" + {...STORE_OBJECT} + /> +
+ +
- {!isEditing && ( - - )} + + +
({ + value: v, + label: v.charAt(0).toUpperCase() + v.slice(1), +})) + +export type DepartmentAssignmentType = (typeof AssignmentType)[number] diff --git a/apps/dashboard/shared/components/export-data-button.tsx b/apps/dashboard/shared/components/export-data-button.tsx new file mode 100644 index 0000000..e5a6692 --- /dev/null +++ b/apps/dashboard/shared/components/export-data-button.tsx @@ -0,0 +1,54 @@ +"use client" + +import { useState } from "react" +import { Button } from "@/shared/components/ui/button" +import { Download, Loader2 } from "lucide-react" +import { toast } from "sonner" + +type ExportDataButtonProps = { + onExport: (filters?: Record) => Promise + fileName?: string + filters?: Record + label?: string +} + +export function ExportDataButton({ + onExport, + fileName = "export", + filters, + label = "Export", +}: ExportDataButtonProps) { + const [isPending, setIsPending] = useState(false) + + const handleExport = async () => { + setIsPending(true) + try { + const blob = await onExport(filters) + const url = URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + a.download = `${fileName}.xlsx` + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) + toast.success("Data exported successfully") + } catch (err: any) { + toast.error(err?.message ?? "Failed to export data") + } finally { + setIsPending(false) + } + } + + return ( + + ) +} diff --git a/apps/dashboard/shared/components/import-data-button.tsx b/apps/dashboard/shared/components/import-data-button.tsx new file mode 100644 index 0000000..6a3e81b --- /dev/null +++ b/apps/dashboard/shared/components/import-data-button.tsx @@ -0,0 +1,61 @@ +"use client" + +import React, { useRef, useState } from "react" +import { Button } from "@/shared/components/ui/button" +import { Upload, Loader2 } from "lucide-react" +import { toast } from "sonner" + +type ImportDataButtonProps = { + onImport: (file: File) => Promise<{ message?: string }> + onSuccess?: () => void + accept?: string + label?: string +} + +export function ImportDataButton({ + onImport, + onSuccess, + accept = ".xlsx,.xls,.csv", + label = "Import", +}: ImportDataButtonProps) { + const inputRef = useRef(null) + const [isPending, setIsPending] = useState(false) + + const handleFileChange = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file) return + + setIsPending(true) + try { + const result = await onImport(file) + toast.success(result.message ?? "Data imported successfully") + onSuccess?.() + } catch (err: any) { + toast.error(err?.message ?? "Failed to import data") + } finally { + setIsPending(false) + if (inputRef.current) inputRef.current.value = "" + } + } + + return ( + <> + + + + ) +} diff --git a/packages/api/open-api/schema.json b/packages/api/open-api/schema.json index 529acf6..a46ae2a 100644 --- a/packages/api/open-api/schema.json +++ b/packages/api/open-api/schema.json @@ -30221,7 +30221,7 @@ } } }, - "/api/import-parts": { + "/api/parts/import": { "post": { "tags": [ "Import Parts" @@ -30264,7 +30264,7 @@ } } }, - "/api/export-parts": { + "/api/parts/export": { "post": { "tags": [ "Export Parts" @@ -30343,12 +30343,12 @@ } } }, - "/api/toggle-part-status": { + "/api/parts/toggle-status": { "post": { "tags": [ "Toggle Part Status" ], - "summary": "POST /api/toggle-part-status", + "summary": "POST /api/parts/toggle-status", "requestBody": { "required": true, "content": { diff --git a/packages/api/postman/collection.json b/packages/api/postman/collection.json index 53a9fbc..367a4ce 100644 --- a/packages/api/postman/collection.json +++ b/packages/api/postman/collection.json @@ -1,6 +1,6 @@ { "info": { - "_postman_id": "bf46649e-48e0-49d5-aa87-1790dcbc3c24", + "_postman_id": "a879ecf0-769b-47c2-86ec-dcc11bf1973d", "name": "Reparee Collection", "description": "Auto-generated from OpenAPI spec. Import storage/app/openapi-default.json for the full schema.", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", @@ -16122,7 +16122,7 @@ "job-cards" ] }, - "description": "Paginated list. Eager loads: labels, customer, vehicle, department, documents, attachmentFiles, serviceWriter, salesPerson, primaryTechnician. withCount: purchase_orders_count, bills_count, expenses_count, tasks_count, appointments_count, inspections_count, services_count, parts_count, expense_items_count (line-level rows on the job card). Response: `data` and `meta` only. Nested employees may include full columns in production; example uses a shortened shape. Query: status, customer_id, vehicle_id, per_page, sort_by, sort_order, start_date, end_date, service_writer_id, department_id, has_start_or_delivery_date, has_start_date, has_delivery_date, has_both_dates." + "description": "Paginated list. Eager loads: labels, customer, vehicle, department, documents, attachmentFiles, serviceWriter, salesPerson, primaryTechnician. withCount: purchase_orders_count, bills_count, expenses_count, tasks_count, appointments_count, inspections_count, services_count, parts_count, expense_items_count (line-level rows on the job card). Response: `data` and `meta` only. Nested employees may include full columns in production; example uses a shortened shape. Query: sort_by, sort_order, status, customer_id, insurer_id, sales_person_id, primary_technician_id, service_writer_id, label_ids, delivery_date, convert_invoice, check_in_date, check_out_date, vehicle_id, start_date, end_date, department_id, search, has_start_or_delivery_date, has_start_date, has_delivery_date, has_both_dates, per_page." }, "response": [ { @@ -22023,13 +22023,14 @@ ] }, "url": { - "raw": "{{base_url}}/api/import-parts", + "raw": "{{base_url}}/api/parts/import", "host": [ "{{base_url}}" ], "path": [ "api", - "import-parts" + "parts", + "import" ] } }, @@ -22071,13 +22072,14 @@ ] }, "url": { - "raw": "{{base_url}}/api/import-parts", + "raw": "{{base_url}}/api/parts/import", "host": [ "{{base_url}}" ], "path": [ "api", - "import-parts" + "parts", + "import" ] } }, @@ -22134,13 +22136,14 @@ } }, "url": { - "raw": "{{base_url}}/api/export-parts", + "raw": "{{base_url}}/api/parts/export", "host": [ "{{base_url}}" ], "path": [ "api", - "export-parts" + "parts", + "export" ] } }, @@ -22179,13 +22182,14 @@ } }, "url": { - "raw": "{{base_url}}/api/export-parts", + "raw": "{{base_url}}/api/parts/export", "host": [ "{{base_url}}" ], "path": [ "api", - "export-parts" + "parts", + "export" ] } }, @@ -22209,7 +22213,7 @@ "name": "Toggle Part Status", "item": [ { - "name": "POST /api/toggle-part-status", + "name": "POST /api/parts/toggle-status", "request": { "auth": { "type": "bearer", @@ -22242,13 +22246,14 @@ } }, "url": { - "raw": "{{base_url}}/api/toggle-part-status", + "raw": "{{base_url}}/api/parts/toggle-status", "host": [ "{{base_url}}" ], "path": [ "api", - "toggle-part-status" + "parts", + "toggle-status" ] } }, @@ -22287,13 +22292,14 @@ } }, "url": { - "raw": "{{base_url}}/api/toggle-part-status", + "raw": "{{base_url}}/api/parts/toggle-status", "host": [ "{{base_url}}" ], "path": [ "api", - "toggle-part-status" + "parts", + "toggle-status" ] } }, diff --git a/packages/api/src/clients/parts.ts b/packages/api/src/clients/parts.ts index 490cc13..cac2bc6 100644 --- a/packages/api/src/clients/parts.ts +++ b/packages/api/src/clients/parts.ts @@ -5,8 +5,8 @@ import type { ApiPath, ApiRequestBody } from "../infra/types" export const PARTS_ROUTES = { INDEX: "/api/parts", BY_ID: "/api/parts/{id}", - IMPORT: "/api/import-parts", - EXPORT: "/api/export-parts", + IMPORT: "/api/parts/import", + EXPORT: "/api/parts/export", TOGGLE_STATUS: "/api/toggle-part-status", } as const satisfies Record @@ -15,15 +15,15 @@ export class PartsClient extends CrudClient< typeof PARTS_ROUTES.BY_ID > { constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) { - super(baseUrl, defaultOptions, PARTS_ROUTES.INDEX, PARTS_ROUTES.BY_ID) - } - - async import(payload: ApiRequestBody) { - return this.post(PARTS_ROUTES.IMPORT, payload) - } - - async export(payload: ApiRequestBody) { - return this.post(PARTS_ROUTES.EXPORT, payload) + super( + baseUrl, + defaultOptions, + PARTS_ROUTES.INDEX, + PARTS_ROUTES.BY_ID, + PARTS_ROUTES.IMPORT, + PARTS_ROUTES.EXPORT, + "POST", + ) } async toggleStatus(payload: ApiRequestBody) { diff --git a/packages/api/src/clients/services.ts b/packages/api/src/clients/services.ts index ff3ace8..2fb1d6b 100644 --- a/packages/api/src/clients/services.ts +++ b/packages/api/src/clients/services.ts @@ -1,6 +1,6 @@ import { CrudClient } from "../infra/crud-client" import type { ApiClientOptions } from "../infra/client" -import type { ApiPath, ApiRequestBody } from "../infra/types" +import type { ApiPath } from "../infra/types" export const SERVICE_ROUTES = { INDEX: "/api/services", @@ -14,14 +14,14 @@ export class ServicesClient extends CrudClient< typeof SERVICE_ROUTES.BY_ID > { constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) { - super(baseUrl, defaultOptions, SERVICE_ROUTES.INDEX, SERVICE_ROUTES.BY_ID) - } - - async import(payload: ApiRequestBody) { - return this.post(SERVICE_ROUTES.IMPORT, payload) - } - - async export(payload: ApiRequestBody) { - return this.post(SERVICE_ROUTES.EXPORT, payload) + super( + baseUrl, + defaultOptions, + SERVICE_ROUTES.INDEX, + SERVICE_ROUTES.BY_ID, + SERVICE_ROUTES.IMPORT, + SERVICE_ROUTES.EXPORT, + "POST", + ) } } diff --git a/packages/api/src/infra/client.ts b/packages/api/src/infra/client.ts index d6e656f..c6fba47 100644 --- a/packages/api/src/infra/client.ts +++ b/packages/api/src/infra/client.ts @@ -190,6 +190,28 @@ export class ApiClient { return data } + protected async fetchBlob(endpoint: string, options?: { method?: "GET" | "POST"; body?: unknown }): Promise { + const url = `${this.normalizeBaseUrl(this.baseUrl)}${endpoint}` + const headers = new Headers(this.defaultOptions.headers as Record) + const method = options?.method ?? "POST" + + const init: RequestInit = { method, headers } + if (options?.body) { + headers.set("Content-Type", "application/json") + init.body = JSON.stringify(options.body) + } + + const response = await fetch(url, init) + + if (!response.ok) { + const text = await response.text() + const data = text ? JSON.parse(text) : null + throw new ApiError(response.status, response.statusText, endpoint, method.toLowerCase(), data) + } + + return response.blob() + } + private toFetchOptions( options: ApiRequestOptions, ): Record { diff --git a/packages/api/src/infra/crud-client.ts b/packages/api/src/infra/crud-client.ts index 5845c7a..991007d 100644 --- a/packages/api/src/infra/crud-client.ts +++ b/packages/api/src/infra/crud-client.ts @@ -6,6 +6,8 @@ export const DEFAULT_PER_PAGE = 10 type CrudIndexRoute = ApiPathByMethod<"get"> & ApiPathByMethod<"post"> type CrudByIdRoute = ApiPathByMethod<"put"> & ApiPathByMethod<"delete"> +type CrudImportRoute = ApiPathByMethod<"post"> +type CrudExportRoute = ApiPathByMethod<"get"> | ApiPathByMethod<"post"> export type CrudTypeOverrides = { listResponse?: unknown @@ -29,7 +31,10 @@ export abstract class CrudClient< constructor(baseUrl?: string, defaultOptions?: ApiClientOptions, public indexRoute?: IndexRoute, - public byIdRoute?: ByIdRoute) { + public byIdRoute?: ByIdRoute, + public importRoute?: CrudImportRoute, + public exportRoute?: CrudExportRoute, + public exportMethod: "GET" | "POST" = "GET") { super(baseUrl, defaultOptions) @@ -54,6 +59,21 @@ export abstract class CrudClient< async destroy(id: string): Promise>> { return this.delete(this.byIdRoute as ByIdRoute, { params: { id } } as never) as never } + + async importData(file: File): Promise<{ message?: string }> { + const formData = new FormData() + formData.append("file", file) + const route = (this.importRoute ?? `${this.indexRoute}/import`) as string + return this.postFormData(route, formData) + } + + async exportData(filters?: Record): Promise { + const route = (this.exportRoute ?? `${this.indexRoute}/export`) as string + if (this.exportMethod === "POST") { + return this.fetchBlob(route, { method: "POST", body: filters ?? {} }) + } + return this.fetchBlob(route) + } } export type BaseCrudItem = { id: number } diff --git a/packages/api/types/index.ts b/packages/api/types/index.ts index f8e9ee1..d710f9e 100644 --- a/packages/api/types/index.ts +++ b/packages/api/types/index.ts @@ -20275,7 +20275,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/import-parts": { + "/api/parts/import": { parameters: { query?: never; header?: never; @@ -20325,7 +20325,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/export-parts": { + "/api/parts/export": { parameters: { query?: never; header?: never; @@ -20393,7 +20393,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/toggle-part-status": { + "/api/parts/toggle-status": { parameters: { query?: never; header?: never; @@ -20402,7 +20402,7 @@ export interface paths { }; get?: never; put?: never; - /** POST /api/toggle-part-status */ + /** POST /api/parts/toggle-status */ post: { parameters: { query?: never;