This commit is contained in:
Mohammad Khyata 2026-04-09 15:17:07 +03:00
parent c5f6d2f596
commit 29cc9c6f4e
23 changed files with 420 additions and 190 deletions

View File

@ -37,11 +37,18 @@ import { <RESOURCE>_ROUTES } from "@garage/api"
// ── Constants ── // ── Constants ──
// Static select options (if needed): // ALWAYS derive select options from API enums imported from "@garage/api".
// const STATUS_OPTIONS = [ // NEVER hardcode enum values inline — they must stay in sync with the backend.
// { value: "active", label: "Active" }, //
// { value: "inactive", label: "Inactive" }, // 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 ── // ── Props ──

View File

@ -3,12 +3,17 @@
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 { ImportDataButton } from "@/shared/components/import-data-button"
import { ExportDataButton } from "@/shared/components/export-data-button"
import { PartForm } from "@/modules/parts/part-form" import { PartForm } from "@/modules/parts/part-form"
import { Badge } from "@/shared/components/ui/badge" import { Badge } from "@/shared/components/ui/badge"
import { useAuthApi } from "@/shared/useApi"
import { PARTS_ROUTES } from "@garage/api" import { PARTS_ROUTES } from "@garage/api"
import type { PartsClient } from "@garage/api" import type { PartsClient } from "@garage/api"
export default function PartsPage() { export default function PartsPage() {
const api = useAuthApi()
return ( return (
<ResourcePage<PartsClient> <ResourcePage<PartsClient>
pageTitle="Parts" pageTitle="Parts"
@ -16,15 +21,25 @@ export default function PartsPage() {
getClient={(api) => api.parts} getClient={(api) => api.parts}
headerProps={({ selectedItem, invalidateQuery }) => ({ headerProps={({ selectedItem, invalidateQuery }) => ({
actions: ( actions: (
<FormDialog title="Part"> <div className="flex items-center gap-2">
{(resourceId) => ( <ImportDataButton
<PartForm onImport={(file) => api.parts.importData(file)}
resourceId={resourceId} onSuccess={invalidateQuery}
initialData={selectedItem} />
onSuccess={invalidateQuery} <ExportDataButton
/> onExport={(filters) => api.parts.exportData(filters)}
)} fileName="parts"
</FormDialog> />
<FormDialog title="Part">
{(resourceId) => (
<PartForm
resourceId={resourceId}
initialData={selectedItem}
onSuccess={invalidateQuery}
/>
)}
</FormDialog>
</div>
), ),
})} })}
columns={({ actionsColumn }) => [ columns={({ actionsColumn }) => [

View File

@ -3,11 +3,16 @@
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 { 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 { ServiceForm } from "@/modules/services/service-form"
import { SERVICE_ROUTES } from "@garage/api" import { SERVICE_ROUTES } from "@garage/api"
import type { ServicesClient } from "@garage/api" import type { ServicesClient } from "@garage/api"
export default function ServicesPage() { export default function ServicesPage() {
const api = useAuthApi()
return ( return (
<ResourcePage<ServicesClient> <ResourcePage<ServicesClient>
pageTitle="Services" pageTitle="Services"
@ -15,15 +20,25 @@ export default function ServicesPage() {
getClient={(api) => api.services} getClient={(api) => api.services}
headerProps={({ selectedItem, invalidateQuery }) => ({ headerProps={({ selectedItem, invalidateQuery }) => ({
actions: ( actions: (
<FormDialog title="Service"> <div className="flex items-center gap-2">
{(resourceId) => ( <ImportDataButton
<ServiceForm onImport={(file) => api.services.importData(file)}
resourceId={resourceId} onSuccess={invalidateQuery}
initialData={selectedItem} />
onSuccess={invalidateQuery} <ExportDataButton
/> onExport={(filters) => api.services.exportData(filters)}
)} fileName="services"
</FormDialog> />
<FormDialog title="Service">
{(resourceId) => (
<ServiceForm
resourceId={resourceId}
initialData={selectedItem}
onSuccess={invalidateQuery}
/>
)}
</FormDialog>
</div>
), ),
})} })}
columns={({ actionsColumn }) => [ columns={({ actionsColumn }) => [
@ -48,7 +63,7 @@ export default function ServicesPage() {
cell: ({ row }) => { cell: ({ row }) => {
const val = (row.original as any).description const val = (row.original as any).description
return val return val
? <span className="max-w-[200px] truncate block">{val}</span> ? <span className="max-w-50 truncate block">{val}</span>
: "—" : "—"
}, },
}, },

View File

@ -4,6 +4,9 @@ import { useRouter } from 'next/navigation'
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 { 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 { CustomerForm } from '@/modules/customers/customer-form'
import { CUSTOMER_ROUTES } from '@garage/api' import { CUSTOMER_ROUTES } from '@garage/api'
import type { CustomersClient } from '@garage/api' import type { CustomersClient } from '@garage/api'
@ -11,6 +14,7 @@ import { Building2Icon, UserIcon } from 'lucide-react'
export default function CustomersPage() { export default function CustomersPage() {
const router = useRouter() const router = useRouter()
const api = useAuthApi()
return ( return (
<ResourcePage<CustomersClient> <ResourcePage<CustomersClient>
pageTitle='Customers' pageTitle='Customers'
@ -19,15 +23,25 @@ export default function CustomersPage() {
onRowClick={(row) => router.push(`/sales/customers/${(row as any).id}`)} onRowClick={(row) => router.push(`/sales/customers/${(row as any).id}`)}
headerProps={({ selectedItem, invalidateQuery }) => ({ headerProps={({ selectedItem, invalidateQuery }) => ({
actions: ( actions: (
<FormDialog title="Customer"> <div className='flex items-center gap-2'>
{(resourceId) => ( <ImportDataButton
<CustomerForm onImport={(file) => api.customers.importData(file)}
resourceId={resourceId} onSuccess={invalidateQuery}
initialData={selectedItem} />
onSuccess={invalidateQuery} <ExportDataButton
/> onExport={(filters) => api.customers.exportData(filters)}
)} fileName='customers'
</FormDialog> />
<FormDialog title="Customer">
{(resourceId) => (
<CustomerForm
resourceId={resourceId}
initialData={selectedItem}
onSuccess={invalidateQuery}
/>
)}
</FormDialog>
</div>
), ),
})} })}
columns={({ actionsColumn }) => [ columns={({ actionsColumn }) => [

View File

@ -4,6 +4,9 @@ import { useRouter } from 'next/navigation'
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 { 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 { VehicleForm } from '@/modules/vehicles/vehicle-form'
import { VEHICLE_ROUTES } from '@garage/api' import { VEHICLE_ROUTES } from '@garage/api'
import type { VehiclesClient } from '@garage/api' import type { VehiclesClient } from '@garage/api'
@ -11,6 +14,7 @@ import { CarIcon } from 'lucide-react'
export default function VehiclesPage() { export default function VehiclesPage() {
const router = useRouter() const router = useRouter()
const api = useAuthApi()
return ( return (
<ResourcePage<VehiclesClient> <ResourcePage<VehiclesClient>
pageTitle="Vehicles" pageTitle="Vehicles"
@ -19,15 +23,25 @@ export default function VehiclesPage() {
onRowClick={(row) => router.push(`/sales/vehicles/${(row as any).id}`)} onRowClick={(row) => router.push(`/sales/vehicles/${(row as any).id}`)}
headerProps={({ selectedItem, invalidateQuery }) => ({ headerProps={({ selectedItem, invalidateQuery }) => ({
actions: ( actions: (
<FormDialog title="Vehicle"> <div className='flex items-center gap-2'>
{(resourceId) => ( <ImportDataButton
<VehicleForm onImport={(file) => api.vehicles.importData(file)}
resourceId={resourceId} onSuccess={invalidateQuery}
initialData={selectedItem} />
onSuccess={invalidateQuery} <ExportDataButton
/> onExport={(filters) => api.vehicles.exportData(filters)}
)} fileName='vehicles'
</FormDialog> />
<FormDialog title="Vehicle">
{(resourceId) => (
<VehicleForm
resourceId={resourceId}
initialData={selectedItem}
onSuccess={invalidateQuery}
/>
)}
</FormDialog>
</div>
), ),
})} })}
columns={({ actionsColumn }) => [ columns={({ actionsColumn }) => [

View File

@ -1,15 +1,21 @@
import { Geist_Mono, Inter } from "next/font/google" import { Geist_Mono, Inter } from "next/font/google"
import "./globals.css"
import { QueryProvider } from "@/shared/components/query-provider" import { QueryProvider } from "@/shared/components/query-provider"
import { ThemeProvider } from "@/shared/components/theme-provider" import { ThemeProvider } from "@/shared/components/theme-provider"
import { Toaster } from "@/shared/components/ui/sonner" import { Toaster } from "@/shared/components/ui/sonner"
import { ConfirmDialog } from "@/shared/components/confirm-dialog" import { ConfirmDialog } from "@/shared/components/confirm-dialog"
import { NuqsAdapter } from "nuqs/adapters/next/app" import { NuqsAdapter } from "nuqs/adapters/next/app"
import { cn } from "@/shared/lib/utils" import { cn } from "@/shared/lib/utils"
import './globals.css'
const inter = Inter({ subsets: ["latin"], variable: "--font-sans" }) 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({ const fontMono = Geist_Mono({
subsets: ["latin"], subsets: ["latin"],
variable: "--font-mono", variable: "--font-mono",

View File

@ -1,16 +1,14 @@
import { z } from "zod" import { z } from "zod"
import { AppointmentStatus } from "@garage/api"
const relationFieldSchema = z const relationFieldSchema = z
.object({ value: z.string(), label: z.string() }) .object({ value: z.string(), label: z.string() })
.nullable() .nullable()
const APPOINTMENT_STATUS_OPTIONS = [ const APPOINTMENT_STATUS_OPTIONS = AppointmentStatus.map((v) => ({
{ value: "requested", label: "Requested" }, value: v,
{ value: "confirmed", label: "Confirmed" }, label: v.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()),
{ value: "in_progress", label: "In Progress" }, }))
{ value: "completed", label: "Completed" },
{ value: "cancelled", label: "Cancelled" },
]
const appointmentFormSchema = z.object({ const appointmentFormSchema = z.object({
title: z.string().min(1, "Title is required"), title: z.string().min(1, "Title is required"),

View File

@ -22,7 +22,7 @@ import {
creditNoteFormSchema, creditNoteFormSchema,
type CreditNoteFormValues, type CreditNoteFormValues,
} from "./credit-note.schema" } 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" import { RhfCustomerSelectField } from "@/modules/customers/rhf-customer-select-field"
// ── Shared mapOption for async selects ── // ── Shared mapOption for async selects ──
@ -36,12 +36,10 @@ const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) =
// ── Constants ── // ── Constants ──
const STATUS_OPTIONS = [ const STATUS_OPTIONS = CreditNoteStatus.map((v) => ({
{ value: "draft", label: "Draft" }, value: v,
{ value: "open", label: "Open" }, label: v.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()),
{ value: "applied", label: "Applied" }, }))
{ value: "void", label: "Void" },
]
// ── Props ── // ── Props ──

View File

@ -22,7 +22,7 @@ import {
employeeFormSchema, employeeFormSchema,
type EmployeeFormValues, type EmployeeFormValues,
} from "./employee.schema" } 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 ── // ── Constants ──
@ -31,10 +31,10 @@ const STATUS_OPTIONS = [
{ value: "inactive", label: "Inactive" }, { value: "inactive", label: "Inactive" },
] ]
const TYPE_OPTIONS = [ const TYPE_OPTIONS = EmployeeType.map((v) => ({
{ value: "employee", label: "Employee" }, value: v,
{ value: "contractor", label: "Contractor" }, label: v.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()),
] }))
// ── Props ── // ── Props ──

View File

@ -1,11 +1,12 @@
import { z } from "zod" import { z } from "zod"
import { EmployeeType } from "@garage/api"
const relationFieldSchema = z const relationFieldSchema = z
.object({ value: z.string(), label: z.string() }) .object({ value: z.string(), label: z.string() })
.nullable() .nullable()
const STATUS_OPTIONS = ["active", "inactive"] as const const STATUS_OPTIONS = ["active", "inactive"] as const
const TYPE_OPTIONS = ["employee", "contractor"] as const const TYPE_OPTIONS = EmployeeType
const employeeFormSchema = z.object({ const employeeFormSchema = z.object({
department: relationFieldSchema, department: relationFieldSchema,

View File

@ -22,15 +22,15 @@ import {
expenseFormSchema, expenseFormSchema,
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, ExpenseStatus } from "@garage/api"
import { getFullName } from "@/shared/utils/getFullName" import { getFullName } from "@/shared/utils/getFullName"
// ── Constants ── // ── Constants ──
const STATUS_OPTIONS = [ const STATUS_OPTIONS = ExpenseStatus.map((v) => ({
{ value: "open", label: "Open" }, value: v,
{ value: "paid", label: "Paid" }, label: v.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()),
] }))
// ── Props ── // ── Props ──

View File

@ -11,6 +11,7 @@ import {
RhfSelectField, RhfSelectField,
RhfTextareaField, RhfTextareaField,
RhfAsyncSelectField, RhfAsyncSelectField,
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,19 +23,16 @@ import {
invoiceFormSchema, invoiceFormSchema,
type InvoiceFormValues, type InvoiceFormValues,
} from "./invoice.schema" } 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 { 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"
// ── Constants ── // ── Constants ──
const STATUS_OPTIONS = [ const STATUS_OPTIONS = InvoiceStatus.map((v) => ({
{ value: "draft", label: "Draft" }, value: v,
{ value: "open", label: "Open" }, label: v.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()),
{ value: "paid", label: "Paid" }, }))
{ value: "overdue", label: "Overdue" },
{ value: "void", label: "Void" },
]
// ── Props ── // ── Props ──
@ -148,7 +146,7 @@ export function InvoiceForm({ resourceId, initialData, onSuccess }: InvoiceFormP
<RhfTextField name="subject" label="Subject" placeholder="Invoice subject" required /> <RhfTextField name="subject" label="Subject" placeholder="Invoice subject" 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="invoice_number" label="Invoice Number" placeholder="INV-0001" /> <RhfAutoGenerateField table="invoices" name="invoice_number" label="Invoice Number" placeholder="INV-0001" />
<RhfSelectField <RhfSelectField
name="status" name="status"
label="Status" label="Status"

View File

@ -22,13 +22,13 @@ import { useFormMutation } from "@/shared/hooks/use-form-mutation"
import { toId } from "@/shared/lib/utils" import { toId } from "@/shared/lib/utils"
import { partFormSchema, type PartFormValues } from "./part.schema" import { partFormSchema, type PartFormValues } from "./part.schema"
import { PARTS_ROUTES } from "@garage/api" import { CrudListItem, PARTS_ROUTES, PartsClient } from "@garage/api"
// ── Props ── // ── Props ──
export type PartFormProps = { export type PartFormProps = {
resourceId?: string | null resourceId?: string | null
initialData?: unknown initialData?: CrudListItem<PartsClient> | null
onSuccess?: () => void onSuccess?: () => void
} }
@ -150,59 +150,59 @@ export function PartForm({ resourceId, initialData, onSuccess }: PartFormProps)
/> />
</div> </div>
{!isEditing && (
<>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfAsyncSelectField
name="shop_type"
label="Shop Type"
placeholder="Select shop type"
queryKey={["shop-types"]}
listFn={() => api.shopTypes.list()}
mapOption={mapLookupOption}
createForm={(props) => <ShopTypeInlineForm {...props} />}
createLabel="Shop Type"
{...STORE_OBJECT}
/>
<RhfAsyncSelectField
name="category"
label="Category"
placeholder="Select category"
queryKey={["inventory-categories"]}
listFn={() => api.inventory.listCategories()}
mapOption={mapLookupOption}
createForm={(props) => <InventoryCategoryInlineForm {...props} />}
createLabel="Category"
{...STORE_OBJECT}
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <>
<RhfAsyncSelectField <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
name="unit_type" <RhfAsyncSelectField
label="Unit Type" name="shop_type"
placeholder="Select unit type" label="Shop Type"
queryKey={["unit-types"]} placeholder="Select shop type"
listFn={() => api.inventory.listUnitTypes()} queryKey={["shop-types"]}
mapOption={mapLookupOption} listFn={() => api.shopTypes.list()}
createForm={(props) => <UnitTypeInlineForm {...props} />} mapOption={mapLookupOption}
createLabel="Unit Type" createForm={(props) => <ShopTypeInlineForm {...props} />}
{...STORE_OBJECT} createLabel="Shop Type"
/> {...STORE_OBJECT}
<RhfAsyncSelectField />
name="department" <RhfAsyncSelectField
label="Department" name="category"
placeholder="Select department" label="Category"
queryKey={["departments"]} placeholder="Select category"
listFn={() => api.departments.list()} queryKey={["inventory-categories"]}
mapOption={(item: any) => ({ value: String(item.id), label: item.name ?? String(item.id) })} listFn={() => api.inventory.listCategories()}
createForm={(props) => <DepartmentInlineForm {...props} />} mapOption={mapLookupOption}
createLabel="Department" createForm={(props) => <InventoryCategoryInlineForm {...props} />}
{...STORE_OBJECT} createLabel="Category"
/> {...STORE_OBJECT}
</div> />
</> </div>
)}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfAsyncSelectField
name="unit_type"
label="Unit Type"
placeholder="Select unit type"
queryKey={["unit-types"]}
listFn={() => api.inventory.listUnitTypes()}
mapOption={mapLookupOption}
createForm={(props) => <UnitTypeInlineForm {...props} />}
createLabel="Unit Type"
{...STORE_OBJECT}
/>
<RhfAsyncSelectField
name="department"
label="Department"
placeholder="Select department"
queryKey={["departments"]}
listFn={() => api.departments.list()}
mapOption={(item: any) => ({ value: String(item.id), label: item.name ?? String(item.id) })}
createForm={(props) => <DepartmentInlineForm {...props} />}
createLabel="Department"
{...STORE_OBJECT}
/>
</div>
</>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField <RhfTextField
@ -211,14 +211,14 @@ export function PartForm({ resourceId, initialData, onSuccess }: PartFormProps)
placeholder="0.00" placeholder="0.00"
type="number" type="number"
/> />
{!isEditing && (
<RhfTextField <RhfTextField
name="purchase_price" name="purchase_price"
label="Purchase Price" label="Purchase Price"
placeholder="0.00" placeholder="0.00"
type="number" type="number"
/> />
)}
</div> </div>
<RhfTextareaField <RhfTextareaField

View File

@ -1,7 +1,8 @@
export const DEPARTMENT_ASSIGNMENT_TYPE_OPTIONS = [ import { AssignmentType } from "@garage/api"
{ value: "none", label: "None" },
{ value: "bays", label: "Bays" },
{ value: "outsourced", label: "Outsourced" },
] as const
export type DepartmentAssignmentType = typeof DEPARTMENT_ASSIGNMENT_TYPE_OPTIONS[number]["value"] export const DEPARTMENT_ASSIGNMENT_TYPE_OPTIONS = AssignmentType.map((v) => ({
value: v,
label: v.charAt(0).toUpperCase() + v.slice(1),
}))
export type DepartmentAssignmentType = (typeof AssignmentType)[number]

View File

@ -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<string, unknown>) => Promise<Blob>
fileName?: string
filters?: Record<string, unknown>
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 (
<Button
size="sm"
variant="outline"
disabled={isPending}
onClick={handleExport}
>
{isPending ? <Loader2 className="animate-spin" /> : <Download />}
{label}
</Button>
)
}

View File

@ -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<HTMLInputElement>(null)
const [isPending, setIsPending] = useState(false)
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
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 (
<>
<input
ref={inputRef}
type="file"
accept={accept}
className="hidden"
onChange={handleFileChange}
/>
<Button
size="sm"
variant="outline"
disabled={isPending}
onClick={() => inputRef.current?.click()}
>
{isPending ? <Loader2 className="animate-spin" /> : <Upload />}
{label}
</Button>
</>
)
}

View File

@ -30221,7 +30221,7 @@
} }
} }
}, },
"/api/import-parts": { "/api/parts/import": {
"post": { "post": {
"tags": [ "tags": [
"Import Parts" "Import Parts"
@ -30264,7 +30264,7 @@
} }
} }
}, },
"/api/export-parts": { "/api/parts/export": {
"post": { "post": {
"tags": [ "tags": [
"Export Parts" "Export Parts"
@ -30343,12 +30343,12 @@
} }
} }
}, },
"/api/toggle-part-status": { "/api/parts/toggle-status": {
"post": { "post": {
"tags": [ "tags": [
"Toggle Part Status" "Toggle Part Status"
], ],
"summary": "POST /api/toggle-part-status", "summary": "POST /api/parts/toggle-status",
"requestBody": { "requestBody": {
"required": true, "required": true,
"content": { "content": {

View File

@ -1,6 +1,6 @@
{ {
"info": { "info": {
"_postman_id": "bf46649e-48e0-49d5-aa87-1790dcbc3c24", "_postman_id": "a879ecf0-769b-47c2-86ec-dcc11bf1973d",
"name": "Reparee Collection", "name": "Reparee Collection",
"description": "Auto-generated from OpenAPI spec. Import storage/app/openapi-default.json for the full schema.", "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", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
@ -16122,7 +16122,7 @@
"job-cards" "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": [ "response": [
{ {
@ -22023,13 +22023,14 @@
] ]
}, },
"url": { "url": {
"raw": "{{base_url}}/api/import-parts", "raw": "{{base_url}}/api/parts/import",
"host": [ "host": [
"{{base_url}}" "{{base_url}}"
], ],
"path": [ "path": [
"api", "api",
"import-parts" "parts",
"import"
] ]
} }
}, },
@ -22071,13 +22072,14 @@
] ]
}, },
"url": { "url": {
"raw": "{{base_url}}/api/import-parts", "raw": "{{base_url}}/api/parts/import",
"host": [ "host": [
"{{base_url}}" "{{base_url}}"
], ],
"path": [ "path": [
"api", "api",
"import-parts" "parts",
"import"
] ]
} }
}, },
@ -22134,13 +22136,14 @@
} }
}, },
"url": { "url": {
"raw": "{{base_url}}/api/export-parts", "raw": "{{base_url}}/api/parts/export",
"host": [ "host": [
"{{base_url}}" "{{base_url}}"
], ],
"path": [ "path": [
"api", "api",
"export-parts" "parts",
"export"
] ]
} }
}, },
@ -22179,13 +22182,14 @@
} }
}, },
"url": { "url": {
"raw": "{{base_url}}/api/export-parts", "raw": "{{base_url}}/api/parts/export",
"host": [ "host": [
"{{base_url}}" "{{base_url}}"
], ],
"path": [ "path": [
"api", "api",
"export-parts" "parts",
"export"
] ]
} }
}, },
@ -22209,7 +22213,7 @@
"name": "Toggle Part Status", "name": "Toggle Part Status",
"item": [ "item": [
{ {
"name": "POST /api/toggle-part-status", "name": "POST /api/parts/toggle-status",
"request": { "request": {
"auth": { "auth": {
"type": "bearer", "type": "bearer",
@ -22242,13 +22246,14 @@
} }
}, },
"url": { "url": {
"raw": "{{base_url}}/api/toggle-part-status", "raw": "{{base_url}}/api/parts/toggle-status",
"host": [ "host": [
"{{base_url}}" "{{base_url}}"
], ],
"path": [ "path": [
"api", "api",
"toggle-part-status" "parts",
"toggle-status"
] ]
} }
}, },
@ -22287,13 +22292,14 @@
} }
}, },
"url": { "url": {
"raw": "{{base_url}}/api/toggle-part-status", "raw": "{{base_url}}/api/parts/toggle-status",
"host": [ "host": [
"{{base_url}}" "{{base_url}}"
], ],
"path": [ "path": [
"api", "api",
"toggle-part-status" "parts",
"toggle-status"
] ]
} }
}, },

View File

@ -5,8 +5,8 @@ import type { ApiPath, ApiRequestBody } from "../infra/types"
export const PARTS_ROUTES = { export const PARTS_ROUTES = {
INDEX: "/api/parts", INDEX: "/api/parts",
BY_ID: "/api/parts/{id}", BY_ID: "/api/parts/{id}",
IMPORT: "/api/import-parts", IMPORT: "/api/parts/import",
EXPORT: "/api/export-parts", EXPORT: "/api/parts/export",
TOGGLE_STATUS: "/api/toggle-part-status", TOGGLE_STATUS: "/api/toggle-part-status",
} as const satisfies Record<string, ApiPath> } as const satisfies Record<string, ApiPath>
@ -15,15 +15,15 @@ export class PartsClient extends CrudClient<
typeof PARTS_ROUTES.BY_ID typeof PARTS_ROUTES.BY_ID
> { > {
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) { constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
super(baseUrl, defaultOptions, PARTS_ROUTES.INDEX, PARTS_ROUTES.BY_ID) super(
} baseUrl,
defaultOptions,
async import(payload: ApiRequestBody<typeof PARTS_ROUTES.IMPORT, "post">) { PARTS_ROUTES.INDEX,
return this.post(PARTS_ROUTES.IMPORT, payload) PARTS_ROUTES.BY_ID,
} PARTS_ROUTES.IMPORT,
PARTS_ROUTES.EXPORT,
async export(payload: ApiRequestBody<typeof PARTS_ROUTES.EXPORT, "post">) { "POST",
return this.post(PARTS_ROUTES.EXPORT, payload) )
} }
async toggleStatus(payload: ApiRequestBody<typeof PARTS_ROUTES.TOGGLE_STATUS, "post">) { async toggleStatus(payload: ApiRequestBody<typeof PARTS_ROUTES.TOGGLE_STATUS, "post">) {

View File

@ -1,6 +1,6 @@
import { CrudClient } from "../infra/crud-client" import { CrudClient } from "../infra/crud-client"
import type { ApiClientOptions } from "../infra/client" import type { ApiClientOptions } from "../infra/client"
import type { ApiPath, ApiRequestBody } from "../infra/types" import type { ApiPath } from "../infra/types"
export const SERVICE_ROUTES = { export const SERVICE_ROUTES = {
INDEX: "/api/services", INDEX: "/api/services",
@ -14,14 +14,14 @@ export class ServicesClient extends CrudClient<
typeof SERVICE_ROUTES.BY_ID typeof SERVICE_ROUTES.BY_ID
> { > {
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) { constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
super(baseUrl, defaultOptions, SERVICE_ROUTES.INDEX, SERVICE_ROUTES.BY_ID) super(
} baseUrl,
defaultOptions,
async import(payload: ApiRequestBody<typeof SERVICE_ROUTES.IMPORT, "post">) { SERVICE_ROUTES.INDEX,
return this.post(SERVICE_ROUTES.IMPORT, payload) SERVICE_ROUTES.BY_ID,
} SERVICE_ROUTES.IMPORT,
SERVICE_ROUTES.EXPORT,
async export(payload: ApiRequestBody<typeof SERVICE_ROUTES.EXPORT, "post">) { "POST",
return this.post(SERVICE_ROUTES.EXPORT, payload) )
} }
} }

View File

@ -190,6 +190,28 @@ export class ApiClient {
return data return data
} }
protected async fetchBlob(endpoint: string, options?: { method?: "GET" | "POST"; body?: unknown }): Promise<Blob> {
const url = `${this.normalizeBaseUrl(this.baseUrl)}${endpoint}`
const headers = new Headers(this.defaultOptions.headers as Record<string, string>)
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<Path extends ApiPath, Method extends HttpMethod>( private toFetchOptions<Path extends ApiPath, Method extends HttpMethod>(
options: ApiRequestOptions<Path, Method>, options: ApiRequestOptions<Path, Method>,
): Record<string, unknown> { ): Record<string, unknown> {

View File

@ -6,6 +6,8 @@ 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">
type CrudImportRoute = ApiPathByMethod<"post">
type CrudExportRoute = ApiPathByMethod<"get"> | ApiPathByMethod<"post">
export type CrudTypeOverrides = { export type CrudTypeOverrides = {
listResponse?: unknown listResponse?: unknown
@ -29,7 +31,10 @@ export abstract class CrudClient<
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions, constructor(baseUrl?: string, defaultOptions?: ApiClientOptions,
public indexRoute?: IndexRoute, public indexRoute?: IndexRoute,
public byIdRoute?: ByIdRoute) { public byIdRoute?: ByIdRoute,
public importRoute?: CrudImportRoute,
public exportRoute?: CrudExportRoute,
public exportMethod: "GET" | "POST" = "GET") {
super(baseUrl, defaultOptions) super(baseUrl, defaultOptions)
@ -54,6 +59,21 @@ export abstract class CrudClient<
async destroy(id: string): Promise<Resolve<Overrides, 'destroyResponse', ApiResponse<ByIdRoute, "delete">>> { async destroy(id: string): Promise<Resolve<Overrides, 'destroyResponse', ApiResponse<ByIdRoute, "delete">>> {
return this.delete<ByIdRoute>(this.byIdRoute as ByIdRoute, { params: { id } } as never) as never return this.delete<ByIdRoute>(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<string, unknown>): Promise<Blob> {
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 } export type BaseCrudItem = { id: number }

View File

@ -20275,7 +20275,7 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/import-parts": { "/api/parts/import": {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
@ -20325,7 +20325,7 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/export-parts": { "/api/parts/export": {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
@ -20393,7 +20393,7 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/toggle-part-status": { "/api/parts/toggle-status": {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
@ -20402,7 +20402,7 @@ export interface paths {
}; };
get?: never; get?: never;
put?: never; put?: never;
/** POST /api/toggle-part-status */ /** POST /api/parts/toggle-status */
post: { post: {
parameters: { parameters: {
query?: never; query?: never;