updates
This commit is contained in:
parent
c5f6d2f596
commit
29cc9c6f4e
17
.github/skills/crud-page/references/form.md
vendored
17
.github/skills/crud-page/references/form.md
vendored
@ -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 ──
|
||||||
|
|
||||||
|
|||||||
@ -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 }) => [
|
||||||
|
|||||||
@ -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>
|
||||||
: "—"
|
: "—"
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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 }) => [
|
||||||
|
|||||||
@ -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 }) => [
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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"),
|
||||||
|
|||||||
@ -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 ──
|
||||||
|
|
||||||
|
|||||||
@ -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 ──
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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 ──
|
||||||
|
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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]
|
||||||
|
|||||||
54
apps/dashboard/shared/components/export-data-button.tsx
Normal file
54
apps/dashboard/shared/components/export-data-button.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
61
apps/dashboard/shared/components/import-data-button.tsx
Normal file
61
apps/dashboard/shared/components/import-data-button.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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": {
|
||||||
|
|||||||
@ -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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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">) {
|
||||||
|
|||||||
@ -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)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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> {
|
||||||
|
|||||||
@ -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 }
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user