From 4f0a2f790fe3a0633c5dafff7fca9de5fedcb45c Mon Sep 17 00:00:00 2001 From: humam kerdiah Date: Tue, 19 May 2026 17:56:39 +0400 Subject: [PATCH] feat: add logo field to settings schema and update settings client to handle file uploads feat: integrate dialog close context in vendor select field and CRUD dialog components feat: enhance vendor general info to format status using utility function feat: implement form dialog context for managing dialog close actions feat: add async select field dialog close context for better form handling fix: update form mutation hook to close dialog on successful submission feat: extend document print types to include expense and credit note feat: add settings update payload type to include logo and other fields feat: create employee attendance and work history pages with resource management feat: implement payment made and received detail pages with actions feat: add quick shortcuts component for easy navigation in the dashboard feat: create actions for payment made and received with print and delete options feat: implement dialog close context for better dialog management feat: add error parsing utility for improved error handling in API responses --- .../calendar/appointment/list/page.tsx | 3 +- .../employees/[id]/attendance/page.tsx | 100 +++++++++++++++ .../productivity/employees/[id]/layout.tsx | 2 + .../employees/[id]/work-history/page.tsx | 110 ++++++++++++++++ .../productivity/employees/page.tsx | 84 ++++++++++++- .../(authenticated)/purchase/bill/page.tsx | 18 ++- .../(authenticated)/purchase/expense/page.tsx | 14 ++- .../purchase/payments-made/[id]/layout.tsx | 34 +++++ .../purchase/payments-made/[id]/page.tsx | 119 ++++++++++++++++++ .../purchase/payments-made/page.tsx | 17 ++- .../purchase/purchase-order/page.tsx | 14 ++- .../purchase/vendor-credit/page.tsx | 3 +- .../sales/credit-notes/page.tsx | 16 ++- .../(authenticated)/sales/estimates/page.tsx | 14 ++- .../(authenticated)/sales/invoice/page.tsx | 14 ++- .../job-cards/[id]/appointments/page.tsx | 3 +- .../sales/job-cards/[id]/bills/page.tsx | 3 +- .../(authenticated)/sales/job-cards/page.tsx | 14 ++- .../sales/payment-received/[id]/layout.tsx | 34 +++++ .../sales/payment-received/[id]/page.tsx | 97 ++++++++++++++ .../sales/payment-received/page.tsx | 18 ++- apps/dashboard/config/navGroups.tsx | 4 +- .../cypress/fixtures/document-print.json | 14 +++ .../appointments/appointment-actions.tsx | 3 +- .../appointments/appointment-general-info.tsx | 3 +- apps/dashboard/modules/bills/bill-actions.tsx | 8 +- .../modules/bills/bill-status-badge.tsx | 8 +- .../credit-notes/credit-note-actions.tsx | 8 +- .../credit-notes/credit-note-general-info.tsx | 3 +- .../customers/rhf-customer-select-field.tsx | 18 ++- .../modules/employees/employee-combobox.tsx | 9 +- .../employees/employee-general-info.tsx | 8 +- .../modules/expenses/expense-actions.tsx | 8 +- .../modules/home/dashboard-content.tsx | 4 + .../modules/home/quick-shortcuts.tsx | 117 +++++++++++++++++ .../home/upcoming-appointments-card.tsx | 3 +- .../inspections/inspection-actions.tsx | 24 ++-- .../inspections/inspection-row-actions.tsx | 6 + .../modules/invoices/invoice-status-badge.tsx | 17 +-- .../payment-mades/payment-made-actions.tsx | 48 +++++++ .../payment-mades/payment-made-form.tsx | 3 + .../payment-received-actions.tsx | 48 +++++++ .../payment-received-form.tsx | 28 ++++- .../settings/company/settings-form.tsx | 43 ++++++- .../settings/company/settings.schema.ts | 8 ++ .../vendors/rhf-vendor-select-field.tsx | 5 +- .../modules/vendors/vendor-general-info.tsx | 5 +- .../components/crud-dialog/crud-dialog.tsx | 7 ++ .../shared/components/form-dialog.tsx | 5 +- .../form/fields/rhf-async-select-field.tsx | 5 +- .../shared/hooks/use-dialog-close.tsx | 12 ++ .../shared/hooks/use-form-mutation.ts | 13 +- packages/api/src/clients/document-print.ts | 4 +- packages/api/src/clients/settings.ts | 46 ++++++- packages/api/src/infra/index.ts | 1 + packages/api/src/infra/parse-error.ts | 35 ++++++ 56 files changed, 1220 insertions(+), 92 deletions(-) create mode 100644 apps/dashboard/app/(authenticated)/productivity/employees/[id]/attendance/page.tsx create mode 100644 apps/dashboard/app/(authenticated)/productivity/employees/[id]/work-history/page.tsx create mode 100644 apps/dashboard/app/(authenticated)/purchase/payments-made/[id]/layout.tsx create mode 100644 apps/dashboard/app/(authenticated)/purchase/payments-made/[id]/page.tsx create mode 100644 apps/dashboard/app/(authenticated)/sales/payment-received/[id]/layout.tsx create mode 100644 apps/dashboard/app/(authenticated)/sales/payment-received/[id]/page.tsx create mode 100644 apps/dashboard/cypress/fixtures/document-print.json create mode 100644 apps/dashboard/modules/home/quick-shortcuts.tsx create mode 100644 apps/dashboard/modules/payment-mades/payment-made-actions.tsx create mode 100644 apps/dashboard/modules/payment-received/payment-received-actions.tsx create mode 100644 apps/dashboard/shared/hooks/use-dialog-close.tsx create mode 100644 packages/api/src/infra/parse-error.ts diff --git a/apps/dashboard/app/(authenticated)/calendar/appointment/list/page.tsx b/apps/dashboard/app/(authenticated)/calendar/appointment/list/page.tsx index 9600a69..fc41a0d 100644 --- a/apps/dashboard/app/(authenticated)/calendar/appointment/list/page.tsx +++ b/apps/dashboard/app/(authenticated)/calendar/appointment/list/page.tsx @@ -10,6 +10,7 @@ import type { AppointmentsClient } from "@garage/api" import { CalendarCheck2Icon, ClipboardListIcon, ClockIcon } from "lucide-react" import { Badge } from "@/shared/components/ui/badge" import { RelationLink } from "@/shared/components/relation-link" +import { formatEnum } from "@/shared/utils/formatters" const STATUS_COLORS: Record = { requested: "bg-yellow-100 text-yellow-800", @@ -80,7 +81,7 @@ export default function AppointmentsPage() { const colorClass = STATUS_COLORS[status] ?? "bg-gray-100 text-gray-800" return ( - {status?.replace("_", " ") ?? "—"} + {formatEnum(status)} ) }, diff --git a/apps/dashboard/app/(authenticated)/productivity/employees/[id]/attendance/page.tsx b/apps/dashboard/app/(authenticated)/productivity/employees/[id]/attendance/page.tsx new file mode 100644 index 0000000..b9ead1e --- /dev/null +++ b/apps/dashboard/app/(authenticated)/productivity/employees/[id]/attendance/page.tsx @@ -0,0 +1,100 @@ +"use client" + +import { use } from "react" +import { ResourcePage } from "@/shared/data-view/resource-page" +import { ColumnHeader } from "@/shared/data-view/table-view" +import { Badge } from "@/shared/components/ui/badge" +import { TIME_SHEET_ROUTES } from "@garage/api" +import type { TimeSheetsClient } from "@garage/api" +import { ClockIcon } from "lucide-react" + +const ACTIVITY_VARIANT: Record = { + general: "secondary", + order: "default", + task: "outline", +} + +function formatTime(value: unknown) { + if (!value || typeof value !== "string") return "—" + return value.length >= 5 ? value.slice(0, 5) : value +} + +function formatDate(value: unknown) { + if (!value || typeof value !== "string") return "—" + const d = new Date(value) + return Number.isNaN(d.getTime()) ? value : d.toLocaleDateString() +} + +function formatCost(value: unknown) { + if (value === null || value === undefined) return "—" + const n = Number(value) + if (!Number.isFinite(n)) return "—" + return n.toFixed(2) +} + +export default function EmployeeAttendancePage({ params }: { params: Promise<{ id: string }> }) { + const { id: employeeId } = use(params) + + return ( + + pageTitle="Attendance" + routeKey={TIME_SHEET_ROUTES.INDEX} + getClient={(api) => api.timeSheets} + extraParams={{ employee_id: employeeId }} + header={null} + statusFilter={{ + statuses: ["general", "order", "task"], + paramKey: "activity_type", + allLabel: "All", + }} + columns={() => [ + { + accessorKey: "date", + header: ({ column }) => , + cell: ({ row }) => ( +
+ + {formatDate((row.original as any).date)} +
+ ), + }, + { + accessorKey: "clock_in", + header: ({ column }) => , + cell: ({ row }) => {formatTime((row.original as any).clock_in)}, + }, + { + accessorKey: "clock_out", + header: ({ column }) => , + cell: ({ row }) => {formatTime((row.original as any).clock_out)}, + }, + { + accessorKey: "duration", + header: ({ column }) => , + cell: ({ row }) => {formatTime((row.original as any).duration)}, + }, + { + accessorKey: "activity_type", + header: ({ column }) => , + cell: ({ row }) => { + const type = (row.original as any).activity_type ?? "general" + return {type} + }, + }, + { + accessorKey: "calculated_total_cost", + header: ({ column }) => , + cell: ({ row }) => formatCost((row.original as any).calculated_total_cost), + }, + { + accessorKey: "note", + header: ({ column }) => , + cell: ({ row }) => { + const v = (row.original as any).note + return v ? {v} : "—" + }, + }, + ]} + /> + ) +} diff --git a/apps/dashboard/app/(authenticated)/productivity/employees/[id]/layout.tsx b/apps/dashboard/app/(authenticated)/productivity/employees/[id]/layout.tsx index dd367f8..e23463d 100644 --- a/apps/dashboard/app/(authenticated)/productivity/employees/[id]/layout.tsx +++ b/apps/dashboard/app/(authenticated)/productivity/employees/[id]/layout.tsx @@ -28,6 +28,8 @@ export default async function layout(props: { actions={} tabs={[ { href: `/productivity/employees/${id}`, label: 'Details' }, + { href: `/productivity/employees/${id}/attendance`, label: 'Attendance' }, + { href: `/productivity/employees/${id}/work-history`, label: 'Work History' }, { href: `/productivity/employees/${id}/permissions`, label: 'Permissions' }, ]} > diff --git a/apps/dashboard/app/(authenticated)/productivity/employees/[id]/work-history/page.tsx b/apps/dashboard/app/(authenticated)/productivity/employees/[id]/work-history/page.tsx new file mode 100644 index 0000000..f943a98 --- /dev/null +++ b/apps/dashboard/app/(authenticated)/productivity/employees/[id]/work-history/page.tsx @@ -0,0 +1,110 @@ +"use client" + +import { use, useState } from "react" +import { ResourcePage } from "@/shared/data-view/resource-page" +import { ColumnHeader } from "@/shared/data-view/table-view" +import { Badge } from "@/shared/components/ui/badge" +import { Tabs, TabsList, TabsTrigger } from "@/shared/components/ui/tabs" +import { JOB_CARD_ROUTES } from "@garage/api" +import type { JobCardsClient } from "@garage/api" +import { useRouter } from "next/navigation" +import { WrenchIcon } from "lucide-react" +import { formatEnum } from "@/shared/utils/formatters" + +const ROLE_PARAM = { + technician: "primary_technician_id", + sales: "sales_person_id", + writer: "service_writer_id", +} as const + +type RoleKey = keyof typeof ROLE_PARAM + +function formatDate(value: unknown) { + if (!value || typeof value !== "string") return "—" + const d = new Date(value) + return Number.isNaN(d.getTime()) ? value : d.toLocaleDateString() +} + +function statusVariant(status: string | undefined): "default" | "secondary" | "outline" | "destructive" { + if (!status) return "secondary" + if (status === "completed" || status === "delivered") return "default" + if (status === "cancelled") return "destructive" + return "outline" +} + +export default function EmployeeWorkHistoryPage({ params }: { params: Promise<{ id: string }> }) { + const { id: employeeId } = use(params) + const router = useRouter() + const [role, setRole] = useState("technician") + const paramKey = ROLE_PARAM[role] + + return ( +
+
+ setRole(v as RoleKey)}> + + As Technician + As Sales Person + As Service Writer + + +
+ + + key={role} + pageTitle="Work History" + routeKey={JOB_CARD_ROUTES.INDEX} + searchable + searchPlaceholder="Search by job number, plate, customer..." + getClient={(api) => api.jobCards} + extraParams={{ [paramKey]: employeeId }} + header={null} + onRowClick={(row) => router.push(`/sales/job-cards/${(row as any).id}`)} + columns={() => [ + { + accessorKey: "order_number", + header: ({ column }) => , + cell: ({ row }) => ( +
+ + {(row.original as any).order_number ?? "—"} +
+ ), + }, + { + accessorKey: "customer", + header: ({ column }) => , + cell: ({ row }) => { + const c = (row.original as any).customer + if (!c) return "—" + return `${c.first_name ?? ""} ${c.last_name ?? ""}`.trim() || c.email || "—" + }, + }, + { + accessorKey: "vehicle", + header: ({ column }) => , + cell: ({ row }) => { + const v = (row.original as any).vehicle + if (!v) return "—" + const display = `${v.make ?? ""} ${v.model ?? ""}`.trim() + return display || v.license_plate || "—" + }, + }, + { + accessorKey: "status", + header: ({ column }) => , + cell: ({ row }) => { + const s = (row.original as any).status + return s ? {formatEnum(s)} : "—" + }, + }, + { + accessorKey: "created_at", + header: ({ column }) => , + cell: ({ row }) => formatDate((row.original as any).created_at), + }, + ]} + /> +
+ ) +} diff --git a/apps/dashboard/app/(authenticated)/productivity/employees/page.tsx b/apps/dashboard/app/(authenticated)/productivity/employees/page.tsx index 0e27117..1144e27 100644 --- a/apps/dashboard/app/(authenticated)/productivity/employees/page.tsx +++ b/apps/dashboard/app/(authenticated)/productivity/employees/page.tsx @@ -1,15 +1,45 @@ "use client" +import { useMemo, useState } from "react" import { ResourcePage } from "@/shared/data-view/resource-page" import { ColumnHeader } from "@/shared/data-view/table-view" import FormDialog from "@/shared/components/form-dialog" import { EmployeeForm } from "@/modules/employees/employee-form" import { EMPLOYEE_ROUTES } from "@garage/api" import type { EmployeesClient } from "@garage/api" +import { Avatar, AvatarFallback } from "@/shared/components/ui/avatar" +import { Badge } from "@/shared/components/ui/badge" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/components/ui/select" import { useRouter } from "next/navigation" +import { formatEnum } from "@/shared/utils/formatters" + +const TYPE_OPTIONS = [ + { value: "all", label: "All types" }, + { value: "employee", label: "Employee" }, + { value: "sales_person", label: "Sales Person" }, +] + +function initialsOf(first?: string | null, last?: string | null) { + const f = (first ?? "").trim()[0] ?? "" + const l = (last ?? "").trim()[0] ?? "" + return (f + l).toUpperCase() || "?" +} export default function EmployeesPage() { const router = useRouter() + const [typeFilter, setTypeFilter] = useState("all") + + const extraParams = useMemo(() => { + if (typeFilter === "all") return undefined + return { type: typeFilter } + }, [typeFilter]) + return ( pageTitle="Employees" @@ -17,8 +47,25 @@ export default function EmployeesPage() { searchable searchPlaceholder="Search employees..." statusFilter={{ statuses: ["active", "inactive"] }} + extraParams={extraParams} getClient={(api) => api.employees} onRowClick={(row) => router.push(`/productivity/employees/${(row as any).id}`)} + tableHeader={() => ( +
+ +
+ )} headerProps={({ selectedItem, invalidateQuery }) => ({ actions: ( @@ -37,8 +84,16 @@ export default function EmployeesPage() { accessorKey: "first_name", header: ({ column }) => , cell: ({ row }) => { - const { first_name, last_name } = row.original - return `${first_name ?? ""} ${last_name ?? ""}`.trim() + const r = row.original as any + const fullName = `${r.first_name ?? ""} ${r.last_name ?? ""}`.trim() || "—" + return ( +
+ + {initialsOf(r.first_name, r.last_name)} + + {fullName} +
+ ) }, }, { @@ -48,15 +103,32 @@ export default function EmployeesPage() { { accessorKey: "phone", header: ({ column }) => , + cell: ({ row }) => (row.original as any).phone ?? "—", }, { - accessorKey: "position", - header: ({ column }) => , + accessorKey: "type", + header: ({ column }) => , + cell: ({ row }) => { + const t = (row.original as any).type + if (!t) return "—" + return {t === "sales_person" ? "Sales Person" : "Employee"} + }, }, { accessorKey: "department", header: ({ column }) => , - cell: ({ row }) => (row.original as any).department?.name ?? "—", + cell: ({ row }) => { + const d = (row.original as any).department?.name + return d ? {d} : "—" + }, + }, + { + accessorKey: "role", + header: ({ column }) => , + cell: ({ row }) => { + const r = (row.original as any).role?.name + return r ? {r} : "—" + }, }, { accessorKey: "status", @@ -65,7 +137,7 @@ export default function EmployeesPage() { const status = row.original.status return ( - {status} + {formatEnum(status)} ) }, diff --git a/apps/dashboard/app/(authenticated)/purchase/bill/page.tsx b/apps/dashboard/app/(authenticated)/purchase/bill/page.tsx index cec70c0..233448a 100644 --- a/apps/dashboard/app/(authenticated)/purchase/bill/page.tsx +++ b/apps/dashboard/app/(authenticated)/purchase/bill/page.tsx @@ -8,14 +8,16 @@ import { ColumnHeader } from "@/shared/data-view/table-view" import { BillForm } from "@/modules/bills/bill-form" import { BILL_ROUTES, BillStatus } from "@garage/api" import type { BillsClient } from "@garage/api" -import { formatDate } from "@/shared/utils/formatters" +import { formatDate, formatEnum } from "@/shared/utils/formatters" import { getFullName } from "@/shared/utils/getFullName" import { Money } from "@/shared/components/money" import { RelationLink } from "@/shared/components/relation-link" -import { Building2 } from "lucide-react" +import { Building2, Printer } from "lucide-react" +import { useDocumentPrint } from "@/shared/hooks/use-document-print" export default function BillsPage() { const router = useRouter() + const { print, isPrinting } = useDocumentPrint() return ( @@ -96,12 +98,20 @@ export default function BillsPage() { const status = (row.original as any).status return ( - {status?.replace(/_/g, " ") || "—"} + {formatEnum(status)} ) }, }, - actionsColumn(), + actionsColumn({ + extraItems: (row) => [ + { + label: isPrinting ? "Printing..." : "Print", + icon: Printer, + onClick: (r) => print("bill", String(r.id), "print"), + }, + ], + }), ]} /> ) diff --git a/apps/dashboard/app/(authenticated)/purchase/expense/page.tsx b/apps/dashboard/app/(authenticated)/purchase/expense/page.tsx index e9d518a..875ecf3 100644 --- a/apps/dashboard/app/(authenticated)/purchase/expense/page.tsx +++ b/apps/dashboard/app/(authenticated)/purchase/expense/page.tsx @@ -11,11 +11,13 @@ import { useRouter } from "next/navigation" import { formatDate, formatEnum } from "@/shared/utils/formatters" import { Money } from "@/shared/components/money" import { RelationLink } from "@/shared/components/relation-link" -import { Building2 } from "lucide-react" +import { Building2, Printer } from "lucide-react" import { getFullName } from "@/shared/utils/getFullName" +import { useDocumentPrint } from "@/shared/hooks/use-document-print" export default function ExpensesPage() { const router = useRouter() + const { print, isPrinting } = useDocumentPrint() return ( pageTitle="Expenses" @@ -99,7 +101,15 @@ export default function ExpensesPage() { ) }, }, - actionsColumn(), + actionsColumn({ + extraItems: (row) => [ + { + label: isPrinting ? "Printing..." : "Print", + icon: Printer, + onClick: (r) => print("expense", String(r.id), "print"), + }, + ], + }), ]} /> ) diff --git a/apps/dashboard/app/(authenticated)/purchase/payments-made/[id]/layout.tsx b/apps/dashboard/app/(authenticated)/purchase/payments-made/[id]/layout.tsx new file mode 100644 index 0000000..0d8debc --- /dev/null +++ b/apps/dashboard/app/(authenticated)/purchase/payments-made/[id]/layout.tsx @@ -0,0 +1,34 @@ +import { DashboardDetailsPage } from '@/base/components/layout/dashboard' +import { getServerApi } from '@garage/api/server' +import { PaymentMadeActions } from '@/modules/payment-mades/payment-made-actions' +import { BanknoteIcon } from 'lucide-react' +import React from 'react' + +export default async function PaymentMadeDetailLayout(props: { + params: Promise<{ id: string }> + children: React.ReactNode +}) { + const { id } = await props.params + const api = await getServerApi() + const payment = await api.paymentMades.show(id) + const data = (payment as any)?.data ?? payment + const title = data?.payment_number || 'Payment Details' + + return ( + } + backHref="/purchase/payments-made" + actions={} + tabs={[ + { + href: `/purchase/payments-made/${id}`, + label: 'Details', + }, + ]} + > + {props.children} + + ) +} diff --git a/apps/dashboard/app/(authenticated)/purchase/payments-made/[id]/page.tsx b/apps/dashboard/app/(authenticated)/purchase/payments-made/[id]/page.tsx new file mode 100644 index 0000000..6106c62 --- /dev/null +++ b/apps/dashboard/app/(authenticated)/purchase/payments-made/[id]/page.tsx @@ -0,0 +1,119 @@ +import { getServerApi } from '@garage/api/server' +import DashboardPage from '@/base/components/layout/dashboard/dashboard-page' +import { + BadgeDollarSignIcon, + BriefcaseIcon, + Building2Icon, + CalendarIcon, + CreditCardIcon, + HashIcon, +} from 'lucide-react' + +export default async function PaymentMadeDetailPage(props: { params: Promise<{ id: string }> }) { + const { id } = await props.params + const api = await getServerApi() + const payment = await api.paymentMades.show(id) + const data = (payment as any)?.data ?? payment + + if (!data) { + return
Payment not found.
+ } + + const amount = data.payment_made != null + ? Number(data.payment_made).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + : '—' + + const paymentDate = data.payment_date + ? new Date(data.payment_date).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' }) + : '—' + + const vendorLabel = data.vendor?.company_name + ?? (data.vendor?.first_name ? `${data.vendor.first_name} ${data.vendor.last_name ?? ''}`.trim() : null) + ?? data.vendor_name + ?? (data.employee?.first_name ? `${data.employee.first_name} ${data.employee.last_name ?? ''}`.trim() : null) + ?? data.employee_name + ?? '—' + + const paymentMode = data.payment_mode?.title ?? data.payment_mode?.name ?? data.payment_mode_name ?? '—' + + return ( + +
+
+ +
+

Payment Number

+

{data.payment_number || '—'}

+
+
+ +
+ +
+

Vendor / Employee

+

{vendorLabel}

+
+
+ +
+ +
+

Payment For

+

{data.payment_for || '—'}

+
+
+ +
+ +
+

Amount

+

{amount}

+
+
+ +
+ +
+

Payment Mode

+

{paymentMode}

+
+
+ +
+ +
+

Payment Date

+

{paymentDate}

+
+
+ + {data.payment_reference && ( +
+ +
+

Reference

+

{data.payment_reference}

+
+
+ )} + + {data.paid_through && ( +
+ +
+

Paid Through

+

{data.paid_through}

+
+
+ )} + + {data.notes && ( +
+

Notes

+

{data.notes}

+
+ )} +
+
+ ) +} diff --git a/apps/dashboard/app/(authenticated)/purchase/payments-made/page.tsx b/apps/dashboard/app/(authenticated)/purchase/payments-made/page.tsx index 34b203e..08d3009 100644 --- a/apps/dashboard/app/(authenticated)/purchase/payments-made/page.tsx +++ b/apps/dashboard/app/(authenticated)/purchase/payments-made/page.tsx @@ -1,6 +1,7 @@ "use client" import { useState, useRef } from "react" +import { useRouter } from "next/navigation" import { useMutation, useQueryClient } from "@tanstack/react-query" import { Paperclip, @@ -36,7 +37,8 @@ import { getFullName } from "@/shared/utils/getFullName" import { PAYMENT_MADE_ROUTES } from "@garage/api" import type { PaymentMadesClient } from "@garage/api" import { RelationLink } from "@/shared/components/relation-link" -import { Building2 } from "lucide-react" +import { Building2, Printer } from "lucide-react" +import { useDocumentPrint } from "@/shared/hooks/use-document-print" // ── Attachment helpers ── @@ -226,6 +228,8 @@ type PaymentMadeItem = { } export default function PaymentsMadePage() { + const router = useRouter() + const { print, isPrinting } = useDocumentPrint() const [attachmentTarget, setAttachmentTarget] = useState<{ id: string ref: string @@ -239,6 +243,7 @@ export default function PaymentsMadePage() { searchable searchPlaceholder="Search payments..." getClient={(api) => api.paymentMades} + onRowClick={(row) => router.push(`/purchase/payments-made/${(row as any).id}`)} headerProps={({ selectedItem, invalidateQuery }) => ({ actions: ( @@ -393,7 +398,15 @@ export default function PaymentsMadePage() { ) }, }, - actionsColumn(), + actionsColumn({ + extraItems: (row) => [ + { + label: isPrinting ? "Printing..." : "Print", + icon: Printer, + onClick: (r) => print("payment_made", String(r.id), "print"), + }, + ], + }), ]} /> diff --git a/apps/dashboard/app/(authenticated)/purchase/purchase-order/page.tsx b/apps/dashboard/app/(authenticated)/purchase/purchase-order/page.tsx index ba89299..d996888 100644 --- a/apps/dashboard/app/(authenticated)/purchase/purchase-order/page.tsx +++ b/apps/dashboard/app/(authenticated)/purchase/purchase-order/page.tsx @@ -8,11 +8,13 @@ import { PurchaseOrderForm } from "@/modules/purchase-orders/purchase-order-form import { PURCHASE_ORDER_ROUTES } from "@garage/api" import type { PurchaseOrdersClient } from "@garage/api" import { RelationLink } from "@/shared/components/relation-link" -import { Building2 } from "lucide-react" +import { Building2, Printer } from "lucide-react" import { getFullName } from "@/shared/utils/getFullName" +import { useDocumentPrint } from "@/shared/hooks/use-document-print" export default function PurchaseOrdersPage() { const router = useRouter() + const { print, isPrinting } = useDocumentPrint() return ( @@ -83,7 +85,15 @@ export default function PurchaseOrdersPage() { return val ? new Date(val).toLocaleDateString() : "—" }, }, - actionsColumn(), + actionsColumn({ + extraItems: (row) => [ + { + label: isPrinting ? "Printing..." : "Print", + icon: Printer, + onClick: (r) => print("purchase_order", String(r.id), "print"), + }, + ], + }), ]} /> ) diff --git a/apps/dashboard/app/(authenticated)/purchase/vendor-credit/page.tsx b/apps/dashboard/app/(authenticated)/purchase/vendor-credit/page.tsx index 8165b1f..efe255d 100644 --- a/apps/dashboard/app/(authenticated)/purchase/vendor-credit/page.tsx +++ b/apps/dashboard/app/(authenticated)/purchase/vendor-credit/page.tsx @@ -10,6 +10,7 @@ import type { VendorCreditsClient } from "@garage/api" import { RelationLink } from "@/shared/components/relation-link" import { Building2, FileTextIcon } from "lucide-react" import { getFullName } from "@/shared/utils/getFullName" +import { formatEnum } from "@/shared/utils/formatters" export default function VendorCreditsPage() { return ( @@ -79,7 +80,7 @@ export default function VendorCreditsPage() { header: ({ column }) => , cell: ({ row }) => { const status = (row.original as any).status - return {status || "—"} + return {formatEnum(status)} }, }, actionsColumn(), diff --git a/apps/dashboard/app/(authenticated)/sales/credit-notes/page.tsx b/apps/dashboard/app/(authenticated)/sales/credit-notes/page.tsx index 22a8611..8f87fe7 100644 --- a/apps/dashboard/app/(authenticated)/sales/credit-notes/page.tsx +++ b/apps/dashboard/app/(authenticated)/sales/credit-notes/page.tsx @@ -7,6 +7,9 @@ import FormDialog from "@/shared/components/form-dialog" import { CreditNoteForm } from "@/modules/credit-notes/credit-note-form" import { CREDIT_NOTE_ROUTES, CreditNoteStatus } from "@garage/api" import type { CreditNotesClient } from "@garage/api" +import { formatEnum } from "@/shared/utils/formatters" +import { Printer } from "lucide-react" +import { useDocumentPrint } from "@/shared/hooks/use-document-print" type CreditNoteItem = { id: number @@ -20,6 +23,7 @@ type CreditNoteItem = { export default function CreditNotesPage() { const router = useRouter() + const { print, isPrinting } = useDocumentPrint() return ( @@ -66,7 +70,7 @@ export default function CreditNotesPage() { } return ( - {status ? status.charAt(0).toUpperCase() + status.slice(1) : "—"} + {formatEnum(status)} ) }, @@ -75,7 +79,15 @@ export default function CreditNotesPage() { accessorKey: "date", header: ({ column }) => , }, - actionsColumn(), + actionsColumn({ + extraItems: (row) => [ + { + label: isPrinting ? "Printing..." : "Print", + icon: Printer, + onClick: (r) => print("credit_note", String(r.id), "print"), + }, + ], + }), ]} /> ) diff --git a/apps/dashboard/app/(authenticated)/sales/estimates/page.tsx b/apps/dashboard/app/(authenticated)/sales/estimates/page.tsx index d1d04b8..23bdc95 100644 --- a/apps/dashboard/app/(authenticated)/sales/estimates/page.tsx +++ b/apps/dashboard/app/(authenticated)/sales/estimates/page.tsx @@ -7,7 +7,8 @@ import FormDialog from '@/shared/components/form-dialog' import { EstimateForm } from '@/modules/estimates/estimate-form' import { ESTIMATE_ROUTES } from '@garage/api' import type { EstimatesClient } from '@garage/api' -import { Car, FileTextIcon, UserIcon } from 'lucide-react' +import { Car, FileTextIcon, Printer, UserIcon } from 'lucide-react' +import { useDocumentPrint } from '@/shared/hooks/use-document-print' import Link from 'next/link' import { formatDate } from '@/shared/utils/formatters' import { getVehicleLabel } from '@/modules/vehicles/utils/getVehicleLabel' @@ -16,6 +17,7 @@ import { RelationLink } from '@/shared/components/relation-link' export default function EstimatesPage() { const router = useRouter() + const { print, isPrinting } = useDocumentPrint() return ( pageTitle="Estimates" @@ -109,7 +111,15 @@ export default function EstimatesPage() { return item.created_at ? new Date(item.created_at).toLocaleDateString() : "—" }, }, - actionsColumn(), + actionsColumn({ + extraItems: (row) => [ + { + label: isPrinting ? "Printing..." : "Print", + icon: Printer, + onClick: (r) => print("estimate", String(r.id), "print"), + }, + ], + }), ]} /> ) diff --git a/apps/dashboard/app/(authenticated)/sales/invoice/page.tsx b/apps/dashboard/app/(authenticated)/sales/invoice/page.tsx index fd06c25..ea74bf7 100644 --- a/apps/dashboard/app/(authenticated)/sales/invoice/page.tsx +++ b/apps/dashboard/app/(authenticated)/sales/invoice/page.tsx @@ -1,7 +1,8 @@ "use client" -import { Car, UserIcon } from "lucide-react" +import { Car, Printer, UserIcon } from "lucide-react" import { useRouter } from "next/navigation" +import { useDocumentPrint } from "@/shared/hooks/use-document-print" import { ResourcePage } from "@/shared/data-view/resource-page" import { ColumnHeader } from "@/shared/data-view/table-view" import FormDialog from "@/shared/components/form-dialog" @@ -81,6 +82,7 @@ function getDueMeta(dueDate?: string) { export default function InvoicesPage() { const router = useRouter() + const { print, isPrinting } = useDocumentPrint() return ( @@ -238,7 +240,15 @@ export default function InvoicesPage() { ) }, }, - actionsColumn(), + actionsColumn({ + extraItems: (row) => [ + { + label: isPrinting ? "Printing..." : "Print", + icon: Printer, + onClick: (r) => print("invoice", String(r.id), "print"), + }, + ], + }), ]} /> ) diff --git a/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/appointments/page.tsx b/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/appointments/page.tsx index f470caf..628970a 100644 --- a/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/appointments/page.tsx +++ b/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/appointments/page.tsx @@ -14,6 +14,7 @@ import { Badge } from "@/shared/components/ui/badge" import { useJobCard } from "@/modules/job-cards/job-card-context" import { getFullName } from "@/shared/utils/getFullName" import { getVehicleLabel } from "@/modules/vehicles/utils/getVehicleLabel" +import { formatEnum } from "@/shared/utils/formatters" const STATUS_COLORS: Record = { requested: "bg-yellow-100 text-yellow-800", @@ -125,7 +126,7 @@ export default function JobCardAppointmentsPage({ const colorClass = STATUS_COLORS[status] ?? "bg-gray-100 text-gray-800" return ( - {status?.replace("_", " ") ?? "—"} + {formatEnum(status)} ) }, diff --git a/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/bills/page.tsx b/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/bills/page.tsx index 090b2ef..8c90e08 100644 --- a/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/bills/page.tsx +++ b/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/bills/page.tsx @@ -12,6 +12,7 @@ import { useJobCard } from "@/modules/job-cards/job-card-context" import { RelationLink } from "@/shared/components/relation-link" import { Building2, FileTextIcon } from "lucide-react" import { getFullName } from "@/shared/utils/getFullName" +import { formatEnum } from "@/shared/utils/formatters" export default function JobCardBillsPage({ params, @@ -102,7 +103,7 @@ export default function JobCardBillsPage({ header: ({ column }) => , cell: ({ row }) => { const status = (row.original as any).status - return {status || "—"} + return {formatEnum(status)} }, }, actionsColumn(), diff --git a/apps/dashboard/app/(authenticated)/sales/job-cards/page.tsx b/apps/dashboard/app/(authenticated)/sales/job-cards/page.tsx index c51f8bc..2b9a7c8 100644 --- a/apps/dashboard/app/(authenticated)/sales/job-cards/page.tsx +++ b/apps/dashboard/app/(authenticated)/sales/job-cards/page.tsx @@ -6,7 +6,8 @@ import FormDialog from '@/shared/components/form-dialog' import { JobCardForm } from '@/modules/job-cards/job-card-form' import { JOB_CARD_ROUTES, JobCardStatus } from '@garage/api' import type { JobCardsClient } from '@garage/api' -import { ClipboardListIcon } from 'lucide-react' +import { ClipboardListIcon, Printer } from 'lucide-react' +import { useDocumentPrint } from '@/shared/hooks/use-document-print' import { Badge } from '@/shared/components/ui/badge' import { useRouter } from 'next/navigation' import { formatDate, formatEnum, formatNumber } from '@/shared/utils/formatters' @@ -35,6 +36,7 @@ const statusColorMap: Record = { export default function JobCardsPage() { const router = useRouter() const filter = useFilterParams(jobCardFilterConfig) + const { print, isPrinting } = useDocumentPrint() return ( <> @@ -117,7 +119,15 @@ export default function JobCardsPage() { ) }, }, - actionsColumn(), + actionsColumn({ + extraItems: (row) => [ + { + label: isPrinting ? "Printing..." : "Print", + icon: Printer, + onClick: (r) => print("job_card", String(r.id), "print"), + }, + ], + }), ]} /> diff --git a/apps/dashboard/app/(authenticated)/sales/payment-received/[id]/layout.tsx b/apps/dashboard/app/(authenticated)/sales/payment-received/[id]/layout.tsx new file mode 100644 index 0000000..be68dbe --- /dev/null +++ b/apps/dashboard/app/(authenticated)/sales/payment-received/[id]/layout.tsx @@ -0,0 +1,34 @@ +import { DashboardDetailsPage } from '@/base/components/layout/dashboard' +import { getServerApi } from '@garage/api/server' +import { PaymentReceivedActions } from '@/modules/payment-received/payment-received-actions' +import { BanknoteIcon } from 'lucide-react' +import React from 'react' + +export default async function PaymentReceivedDetailLayout(props: { + params: Promise<{ id: string }> + children: React.ReactNode +}) { + const { id } = await props.params + const api = await getServerApi() + const payment = await api.paymentReceived.show(id) + const data = (payment as any)?.data ?? payment + const title = data?.payment_number || 'Payment Details' + + return ( + } + backHref="/sales/payment-received" + actions={} + tabs={[ + { + href: `/sales/payment-received/${id}`, + label: 'Details', + }, + ]} + > + {props.children} + + ) +} diff --git a/apps/dashboard/app/(authenticated)/sales/payment-received/[id]/page.tsx b/apps/dashboard/app/(authenticated)/sales/payment-received/[id]/page.tsx new file mode 100644 index 0000000..fdb0925 --- /dev/null +++ b/apps/dashboard/app/(authenticated)/sales/payment-received/[id]/page.tsx @@ -0,0 +1,97 @@ +import { getServerApi } from '@garage/api/server' +import DashboardPage from '@/base/components/layout/dashboard/dashboard-page' +import { + BadgeDollarSignIcon, + CalendarIcon, + ClipboardListIcon, + CreditCardIcon, + HashIcon, + UserIcon, +} from 'lucide-react' + +export default async function PaymentReceivedDetailPage(props: { params: Promise<{ id: string }> }) { + const { id } = await props.params + const api = await getServerApi() + const payment = await api.paymentReceived.show(id) + const data = (payment as any)?.data ?? payment + + if (!data) { + return
Payment not found.
+ } + + const amount = data.amount_received != null + ? Number(data.amount_received).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + : '—' + + const paymentDate = data.payment_date + ? new Date(data.payment_date).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' }) + : '—' + + const customerName = data.customer?.first_name + ? `${data.customer.first_name} ${data.customer.last_name ?? ''}`.trim() + : data.customer?.company_name ?? data.customer_name ?? '—' + + const jobCardLabel = data.job_card?.title ?? data.job_card_name ?? '—' + const paymentMode = data.payment_mode?.title ?? data.payment_mode?.name ?? data.payment_mode_name ?? '—' + + return ( + +
+
+ +
+

Payment Number

+

{data.payment_number || '—'}

+
+
+ +
+ +
+

Customer

+

{customerName}

+
+
+ +
+ +
+

Job Card

+

{jobCardLabel}

+
+
+ +
+ +
+

Amount Received

+

{amount}

+
+
+ +
+ +
+

Payment Mode

+

{paymentMode}

+
+
+ +
+ +
+

Payment Date

+

{paymentDate}

+
+
+ + {data.note && ( +
+

Note

+

{data.note}

+
+ )} +
+
+ ) +} diff --git a/apps/dashboard/app/(authenticated)/sales/payment-received/page.tsx b/apps/dashboard/app/(authenticated)/sales/payment-received/page.tsx index 0050794..7831b06 100644 --- a/apps/dashboard/app/(authenticated)/sales/payment-received/page.tsx +++ b/apps/dashboard/app/(authenticated)/sales/payment-received/page.tsx @@ -10,9 +10,12 @@ import { CalendarIcon, CreditCardIcon, HashIcon, + Printer, UserIcon, ClipboardListIcon, } from "lucide-react" +import { useRouter } from "next/navigation" +import { useDocumentPrint } from "@/shared/hooks/use-document-print" import { getFullName } from "@/shared/utils/getFullName" import { RelationLink } from "@/shared/components/relation-link" @@ -31,6 +34,9 @@ type PaymentReceivedItem = { } export default function PaymentReceivedPage() { + const router = useRouter() + const { print, isPrinting } = useDocumentPrint() + return ( ; destroy(id: string): Promise }> routeKey={PAYMENT_RECEIVED_ROUTES.INDEX} @@ -40,6 +46,7 @@ export default function PaymentReceivedPage() { list: (query?: any) => api.paymentReceived.list(query), destroy: (id: string) => api.paymentReceived.destroy(id), })} + onRowClick={(row) => router.push(`/sales/payment-received/${(row as any).id}`)} headerProps={({ invalidateQuery }) => ({ actions: ( @@ -68,6 +75,7 @@ export default function PaymentReceivedPage() { }, { accessorKey: "customer", + header: ({ column }) => , cell: ({ row }) => { const item: any = row.original as unknown as PaymentReceivedItem @@ -165,7 +173,15 @@ export default function PaymentReceivedPage() { ) }, }, - actionsColumn(), + actionsColumn({ + extraItems: (row) => [ + { + label: isPrinting ? "Printing..." : "Print", + icon: Printer, + onClick: (r) => print("payment_received", String(r.id), "print"), + }, + ], + }), ]} /> ) diff --git a/apps/dashboard/config/navGroups.tsx b/apps/dashboard/config/navGroups.tsx index f6cb2f0..90478f3 100644 --- a/apps/dashboard/config/navGroups.tsx +++ b/apps/dashboard/config/navGroups.tsx @@ -146,8 +146,8 @@ export const navGroups: NavGroup[] = [ { title: "Time Sheets", href: "/productivity/timesheet", icon: }, // { title: "Payroll", href: "/productivity/payroll", icon: }, // { title: "Payments Made", href: "/productivity/employee-payments-made", icon: }, - // { title: "Shop Calendars", href: "/productivity/shop-calendars", icon: }, - // { title: "Shop Timing", href: "/productivity/shop-timings", icon: }, + { title: "Shop Calendars", href: "/productivity/shop-calendars", icon: }, + { title: "Shop Timing", href: "/productivity/shop-timings", icon: }, { title: "Holidays", href: "/productivity/holidays", icon: }, { title: "Tasks", href: "/productivity/tasks", icon: }, ], diff --git a/apps/dashboard/cypress/fixtures/document-print.json b/apps/dashboard/cypress/fixtures/document-print.json new file mode 100644 index 0000000..4fde63e --- /dev/null +++ b/apps/dashboard/cypress/fixtures/document-print.json @@ -0,0 +1,14 @@ +{ + "list_pages": { + "inspection": { "url": "/sales/inspections", "type": "inspection", "api": "/api/inspections" }, + "estimate": { "url": "/sales/estimates", "type": "estimate", "api": "/api/estimates" }, + "job_card": { "url": "/sales/job-cards", "type": "job_card", "api": "/api/job-cards" }, + "invoice": { "url": "/sales/invoice", "type": "invoice", "api": "/api/invoices" }, + "payment_received": { "url": "/sales/payment-received", "type": "payment_received", "api": "/api/payment-recieved" }, + "expense": { "url": "/purchase/expense", "type": "expense", "api": "/api/expenses" }, + "purchase_order": { "url": "/purchase/purchase-order", "type": "purchase_order", "api": "/api/purchase-orders" }, + "bill": { "url": "/purchase/bill", "type": "bill", "api": "/api/bills" }, + "payment_made": { "url": "/purchase/payments-made", "type": "payment_made", "api": "/api/payment-mades" }, + "credit_note": { "url": "/sales/credit-notes", "type": "credit_note", "api": "/api/credit-notes" } + } +} diff --git a/apps/dashboard/modules/appointments/appointment-actions.tsx b/apps/dashboard/modules/appointments/appointment-actions.tsx index ccca01c..bf1cc89 100644 --- a/apps/dashboard/modules/appointments/appointment-actions.tsx +++ b/apps/dashboard/modules/appointments/appointment-actions.tsx @@ -19,6 +19,7 @@ import { useQueryClient } from "@tanstack/react-query" import { APPOINTMENT_ROUTES } from "@garage/api" import { useFormDialog } from "@/shared/components/form-dialog" import { AppointmentForm } from "./appointment-form" +import { formatEnum } from "@/shared/utils/formatters" type AppointmentActionsProps = { appointmentId: string @@ -50,7 +51,7 @@ export function AppointmentActions({ appointmentId, currentStatus, jobCardId }: setIsLoading(true) try { await api.appointments.changeStatus(appointmentId, { status } as any) - toast.success(`Status updated to "${status}".`) + toast.success(`Status updated to "${formatEnum(status)}".`) queryClient.invalidateQueries({ queryKey: [APPOINTMENT_ROUTES.INDEX] }) router.refresh() } catch { diff --git a/apps/dashboard/modules/appointments/appointment-general-info.tsx b/apps/dashboard/modules/appointments/appointment-general-info.tsx index 74387aa..16ae838 100644 --- a/apps/dashboard/modules/appointments/appointment-general-info.tsx +++ b/apps/dashboard/modules/appointments/appointment-general-info.tsx @@ -15,6 +15,7 @@ import Link from "next/link" import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" import { Badge } from "@/shared/components/ui/badge" import { Button } from "@/shared/components/ui/button" +import { formatEnum } from "@/shared/utils/formatters" type AppointmentData = { id?: number @@ -89,7 +90,7 @@ export function AppointmentGeneralInfo({ appointment }: AppointmentGeneralInfoPr
{appointment.status && ( - {appointment.status.replace("_", " ")} + {formatEnum(appointment.status)} )}
diff --git a/apps/dashboard/modules/bills/bill-actions.tsx b/apps/dashboard/modules/bills/bill-actions.tsx index 150aff1..37a1a2a 100644 --- a/apps/dashboard/modules/bills/bill-actions.tsx +++ b/apps/dashboard/modules/bills/bill-actions.tsx @@ -11,11 +11,12 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/shared/components/ui/dropdown-menu" -import { Ellipsis, Pencil, Trash2, Share2 } from "lucide-react" +import { Ellipsis, Pencil, Printer, Trash2, Share2 } from "lucide-react" import { useState } from "react" import { useFormDialog } from "@/shared/components/form-dialog" import { BillForm } from "./bill-form" import { ShareDocumentDialog } from "@/shared/components/share-document-dialog" +import { useDocumentPrint } from "@/shared/hooks/use-document-print" type BillActionsProps = { billId: string @@ -25,6 +26,7 @@ export function BillActions({ billId }: BillActionsProps) { const api = useAuthApi() const router = useRouter() const editDialog = useFormDialog("bill-details-edit") + const { print, isPrinting } = useDocumentPrint() const [shareOpen, setShareOpen] = useState(false) const handleDelete = async () => { @@ -45,6 +47,10 @@ export function BillActions({ billId }: BillActionsProps) { Edit + print("bill", billId, "print")} disabled={isPrinting}> + + {isPrinting ? "Printing..." : "Print"} + setShareOpen(true)}> Share diff --git a/apps/dashboard/modules/bills/bill-status-badge.tsx b/apps/dashboard/modules/bills/bill-status-badge.tsx index c0af6ec..e455768 100644 --- a/apps/dashboard/modules/bills/bill-status-badge.tsx +++ b/apps/dashboard/modules/bills/bill-status-badge.tsx @@ -1,6 +1,6 @@ "use client" -import { BillStatus } from "@garage/api" +import { BillStatus, parseApiError } from "@garage/api" import { Badge, badgeVariants } from "@/shared/components/ui/badge" import { Select, @@ -60,7 +60,7 @@ export default function BillStatusBadge({ bill }: BillStatusBadgeProps) { toast.success("Bill status updated") router.refresh() } catch (error) { - toast.error("Failed to update bill status") + toast.error(parseApiError(error, "Failed to update bill status")) } finally { setIsLoading(false) } @@ -71,7 +71,9 @@ export default function BillStatusBadge({ bill }: BillStatusBadgeProps) { - + + {formatEnum(status)} + {BillStatus.map((s) => ( diff --git a/apps/dashboard/modules/credit-notes/credit-note-actions.tsx b/apps/dashboard/modules/credit-notes/credit-note-actions.tsx index 43b639f..e822893 100644 --- a/apps/dashboard/modules/credit-notes/credit-note-actions.tsx +++ b/apps/dashboard/modules/credit-notes/credit-note-actions.tsx @@ -11,9 +11,10 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/shared/components/ui/dropdown-menu" -import { Ellipsis, Pencil, Trash2 } from "lucide-react" +import { Ellipsis, Pencil, Printer, Trash2 } from "lucide-react" import { useFormDialog } from "@/shared/components/form-dialog" import { CreditNoteForm } from "./credit-note-form" +import { useDocumentPrint } from "@/shared/hooks/use-document-print" type CreditNoteActionsProps = { creditNoteId: string @@ -23,6 +24,7 @@ export function CreditNoteActions({ creditNoteId }: CreditNoteActionsProps) { const api = useAuthApi() const router = useRouter() const editDialog = useFormDialog("credit-note-details-edit") + const { print, isPrinting } = useDocumentPrint() const handleDelete = async () => { await api.creditNotes.destroy(creditNoteId) @@ -42,6 +44,10 @@ export function CreditNoteActions({ creditNoteId }: CreditNoteActionsProps) { Edit + print("credit_note", creditNoteId, "print")} disabled={isPrinting}> + + {isPrinting ? "Printing..." : "Print"} + Delete diff --git a/apps/dashboard/modules/credit-notes/credit-note-general-info.tsx b/apps/dashboard/modules/credit-notes/credit-note-general-info.tsx index b6b0a99..95727da 100644 --- a/apps/dashboard/modules/credit-notes/credit-note-general-info.tsx +++ b/apps/dashboard/modules/credit-notes/credit-note-general-info.tsx @@ -14,6 +14,7 @@ import { } from "@/shared/components/ui/card" import { Badge } from "@/shared/components/ui/badge" import { Separator } from "@/shared/components/ui/separator" +import { formatEnum } from "@/shared/utils/formatters" type CreditNoteData = { id?: number @@ -84,7 +85,7 @@ export function CreditNoteGeneralInfo({ creditNote }: CreditNoteGeneralInfoProps )} {creditNote.status && ( - {creditNote.status.charAt(0).toUpperCase() + creditNote.status.slice(1)} + {formatEnum(creditNote.status)} )} diff --git a/apps/dashboard/modules/customers/rhf-customer-select-field.tsx b/apps/dashboard/modules/customers/rhf-customer-select-field.tsx index d776b1c..5ce07bc 100644 --- a/apps/dashboard/modules/customers/rhf-customer-select-field.tsx +++ b/apps/dashboard/modules/customers/rhf-customer-select-field.tsx @@ -10,6 +10,7 @@ import { Field, FieldLabel, FieldError, FieldDescription } from "@/shared/compon import { Button } from "@/shared/components/ui/button" import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/shared/components/ui/dialog" import { ScrollArea } from "@/shared/components/ui/scroll-area" +import { DialogCloseContext } from "@/shared/hooks/use-dialog-close" import { Combobox, ComboboxInput, @@ -131,10 +132,17 @@ export function RhfCustomerSelectField< const handleCreateSuccess = (data?: any) => { const item = data?.data ?? data - if (item?.id) { - field.onChange(buildCustomerOption(item)) + if (!item?.id) { + setIsCreateOpen(false) + return } - queryClient.invalidateQueries({ queryKey: [CUSTOMER_ROUTES.INDEX, "customer-select", customerType ?? "all"] }) + const newOption = buildCustomerOption(item) + const key = [CUSTOMER_ROUTES.INDEX, "customer-select", customerType ?? "all"] + + queryClient.setQueryData(key, (prev = []) => + prev.some((o) => o.value === newOption.value) ? prev : [newOption, ...prev], + ) + field.onChange(newOption) setIsCreateOpen(false) } @@ -243,7 +251,9 @@ export function RhfCustomerSelectField< - + setIsCreateOpen(false)}> + + diff --git a/apps/dashboard/modules/employees/employee-combobox.tsx b/apps/dashboard/modules/employees/employee-combobox.tsx index 53e4054..6bbed41 100644 --- a/apps/dashboard/modules/employees/employee-combobox.tsx +++ b/apps/dashboard/modules/employees/employee-combobox.tsx @@ -6,6 +6,7 @@ import { Loader2 } from "lucide-react" import { useAuthApi } from "@/shared/useApi" import { EMPLOYEE_ROUTES } from "@garage/api" import { Badge } from "@/shared/components/ui/badge" +import { formatEnum } from "@/shared/utils/formatters" import { Combobox, ComboboxInput, @@ -149,16 +150,16 @@ export function EmployeeCombobox({ {opt.type && ( - {opt.type} + {formatEnum(opt.type)} )} {opt.status && ( - {opt.status} + {formatEnum(opt.status)} )} diff --git a/apps/dashboard/modules/employees/employee-general-info.tsx b/apps/dashboard/modules/employees/employee-general-info.tsx index 6e47bdb..c133d5b 100644 --- a/apps/dashboard/modules/employees/employee-general-info.tsx +++ b/apps/dashboard/modules/employees/employee-general-info.tsx @@ -18,6 +18,7 @@ import { } from "@/shared/components/ui/card" import { Badge } from "@/shared/components/ui/badge" import { Separator } from "@/shared/components/ui/separator" +import { formatEnum } from "@/shared/utils/formatters" type EmployeeData = { id?: number @@ -86,16 +87,15 @@ export function EmployeeGeneralInfo({ employee }: EmployeeGeneralInfoProps) {
{fullName || "Unknown"} {employee.type && ( - - {employee.type} + + {formatEnum(employee.type)} )} {employee.status && ( - {employee.status} + {formatEnum(employee.status)} )}
diff --git a/apps/dashboard/modules/expenses/expense-actions.tsx b/apps/dashboard/modules/expenses/expense-actions.tsx index f28414b..f1a0838 100644 --- a/apps/dashboard/modules/expenses/expense-actions.tsx +++ b/apps/dashboard/modules/expenses/expense-actions.tsx @@ -11,9 +11,10 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/shared/components/ui/dropdown-menu" -import { Ellipsis, Pencil, Trash2 } from "lucide-react" +import { Ellipsis, Pencil, Printer, Trash2 } from "lucide-react" import { useFormDialog } from "@/shared/components/form-dialog" import { ExpenseForm } from "./expense-form" +import { useDocumentPrint } from "@/shared/hooks/use-document-print" type ExpenseActionsProps = { expenseId: string @@ -23,6 +24,7 @@ export function ExpenseActions({ expenseId }: ExpenseActionsProps) { const api = useAuthApi() const router = useRouter() const editDialog = useFormDialog("expense-details-edit") + const { print, isPrinting } = useDocumentPrint() const handleDelete = async () => { await api.expenses.destroy(expenseId) @@ -42,6 +44,10 @@ export function ExpenseActions({ expenseId }: ExpenseActionsProps) { Edit
+ print("expense", expenseId, "print")} disabled={isPrinting}> + + {isPrinting ? "Printing..." : "Print"} + Delete diff --git a/apps/dashboard/modules/home/dashboard-content.tsx b/apps/dashboard/modules/home/dashboard-content.tsx index bd3ebd6..eceac76 100644 --- a/apps/dashboard/modules/home/dashboard-content.tsx +++ b/apps/dashboard/modules/home/dashboard-content.tsx @@ -14,6 +14,7 @@ import { ItemsTotalsCard } from "./items-totals-card" import { CustomersTotalsCard } from "./customers-totals-card" import { SalesPurchaseCards } from "./sales-purchase-cards" import { VehicleStatsCards } from "./vehicle-stats-cards" +import { QuickShortcuts } from "./quick-shortcuts" import { DashboardPeriods, type DashboardPeriod, type HomeDashboardQuery } from "@garage/api" const DEFAULT_PERIOD: DashboardPeriod = "this_month" @@ -66,6 +67,9 @@ export function DashboardContent() { return (
+ {/* Quick Shortcuts */} + + {/* Financial Overview */} diff --git a/apps/dashboard/modules/home/quick-shortcuts.tsx b/apps/dashboard/modules/home/quick-shortcuts.tsx new file mode 100644 index 0000000..f8ee627 --- /dev/null +++ b/apps/dashboard/modules/home/quick-shortcuts.tsx @@ -0,0 +1,117 @@ +"use client" + +import Link from "next/link" +import { + CalendarCheck2Icon, + CarIcon, + ClipboardCheckIcon, + ClipboardListIcon, + PackageIcon, + ReceiptIcon, + ReceiptTextIcon, + UsersIcon, + WrenchIcon, +} from "lucide-react" +import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" + +type Shortcut = { + label: string + href: string + icon: typeof CarIcon + color: string + bg: string +} + +const shortcuts: Shortcut[] = [ + { + label: "Job Cards", + href: "/sales/job-cards", + icon: ClipboardListIcon, + color: "text-emerald-600", + bg: "bg-emerald-500/10", + }, + { + label: "Appointments", + href: "/calendar/appointment/list", + icon: CalendarCheck2Icon, + color: "text-sky-600", + bg: "bg-sky-500/10", + }, + { + label: "New Estimate", + href: "/sales/estimates/new", + icon: ReceiptTextIcon, + color: "text-violet-600", + bg: "bg-violet-500/10", + }, + { + label: "Invoices", + href: "/sales/invoice", + icon: ReceiptIcon, + color: "text-amber-600", + bg: "bg-amber-500/10", + }, + { + label: "Inspections", + href: "/sales/inspections", + icon: ClipboardCheckIcon, + color: "text-rose-600", + bg: "bg-rose-500/10", + }, + { + label: "Customers", + href: "/sales/customers", + icon: UsersIcon, + color: "text-blue-600", + bg: "bg-blue-500/10", + }, + { + label: "Vehicles", + href: "/sales/vehicles", + icon: CarIcon, + color: "text-indigo-600", + bg: "bg-indigo-500/10", + }, + { + label: "Parts", + href: "/items/parts", + icon: PackageIcon, + color: "text-orange-600", + bg: "bg-orange-500/10", + }, + { + label: "Services", + href: "/items/services", + icon: WrenchIcon, + color: "text-teal-600", + bg: "bg-teal-500/10", + }, +] + +export function QuickShortcuts() { + return ( + + + Quick Shortcuts + + +
+ {shortcuts.map((shortcut) => ( + +
+ +
+ + {shortcut.label} + + + ))} +
+
+
+ ) +} diff --git a/apps/dashboard/modules/home/upcoming-appointments-card.tsx b/apps/dashboard/modules/home/upcoming-appointments-card.tsx index 4ab2cfa..e3f0aea 100644 --- a/apps/dashboard/modules/home/upcoming-appointments-card.tsx +++ b/apps/dashboard/modules/home/upcoming-appointments-card.tsx @@ -4,6 +4,7 @@ import { Calendar, Clock } from "lucide-react" import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" import { Badge } from "@/shared/components/ui/badge" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/components/ui/tabs" +import { formatEnum } from "@/shared/utils/formatters" import type { DashboardData } from "./use-dashboard-data" type Props = { data: DashboardData } @@ -36,7 +37,7 @@ function AppointmentRow({ appt }: { appt: AppointmentDetail }) { {appt.from_time?.slice(0, 5)} - {appt.to_time?.slice(0, 5)}
- {appt.status} + {formatEnum(appt.status)} diff --git a/apps/dashboard/modules/inspections/inspection-actions.tsx b/apps/dashboard/modules/inspections/inspection-actions.tsx index 5fc3c64..f9dc3eb 100644 --- a/apps/dashboard/modules/inspections/inspection-actions.tsx +++ b/apps/dashboard/modules/inspections/inspection-actions.tsx @@ -2,6 +2,7 @@ import { useAuthApi } from "@/shared/useApi" import { useRouter } from "next/navigation" +import { parseApiError } from "@garage/api" import { Button } from "@/shared/components/ui/button" import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/shared/components/ui/dialog" import { ScrollArea } from "@/shared/components/ui/scroll-area" @@ -50,17 +51,18 @@ export function InspectionActions({ inspectionId, status, onStatusChange }: Insp } const handleStatusChange = async (newStatus: string) => { - const promise = api.inspections.changeStatus({ - status: newStatus, - } as never) - toast.promise(promise, { - loading: "Updating status...", - success: "Status updated successfully", - error: "Failed to update status", - }) - await promise - onStatusChange?.() - router.refresh() + const loadingToast = toast.loading("Updating status...") + try { + await api.inspections.changeStatus({ + id: Number(inspectionId), + status: newStatus, + } as never) + toast.success("Status updated successfully", { id: loadingToast }) + onStatusChange?.() + router.refresh() + } catch (e) { + toast.error(parseApiError(e, "Failed to update status"), { id: loadingToast }) + } } const transition = status ? STATUS_TRANSITIONS[status] : undefined diff --git a/apps/dashboard/modules/inspections/inspection-row-actions.tsx b/apps/dashboard/modules/inspections/inspection-row-actions.tsx index 84e08ae..345bc38 100644 --- a/apps/dashboard/modules/inspections/inspection-row-actions.tsx +++ b/apps/dashboard/modules/inspections/inspection-row-actions.tsx @@ -10,6 +10,7 @@ import { MoreHorizontal, Pencil, PlayCircle, + Printer, Share2, Trash2, XCircle, @@ -28,6 +29,7 @@ import { useAuthApi } from "@/shared/useApi" import { confirm } from "@/shared/components/confirm-dialog" import { InspectionShareDialog } from "@/modules/inspections/inspection-share-dialog" import { INSPECTION_ROUTES } from "@garage/api" +import { useDocumentPrint } from "@/shared/hooks/use-document-print" type InspectionStatus = "in_progress" | "completed" | "cancelled" @@ -48,6 +50,7 @@ export function InspectionRowActions({ const router = useRouter() const api = useAuthApi() const queryClient = useQueryClient() + const { print, isPrinting } = useDocumentPrint() const [shareOpen, setShareOpen] = useState(false) const inspectionId = String(inspection.id) @@ -125,6 +128,9 @@ export function InspectionRowActions({ Edit
+ print("inspection", inspectionId, "print")} disabled={isPrinting}> + {isPrinting ? "Printing..." : "Print"} + setShareOpen(true)}> Share with customer diff --git a/apps/dashboard/modules/invoices/invoice-status-badge.tsx b/apps/dashboard/modules/invoices/invoice-status-badge.tsx index 3a78fb6..b8f971c 100644 --- a/apps/dashboard/modules/invoices/invoice-status-badge.tsx +++ b/apps/dashboard/modules/invoices/invoice-status-badge.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from "react" import { useRouter } from "next/navigation" import { toast } from "sonner" -import { InvoiceStatus } from "@garage/api" +import { InvoiceStatus, parseApiError } from "@garage/api" import { confirm } from "@/shared/components/confirm-dialog" import { badgeVariants } from "@/shared/components/ui/badge" import { @@ -71,18 +71,13 @@ export default function InvoiceStatusBadge({ invoice }: InvoiceStatusBadgeProps) setIsUpdating(true) + const loadingToast = toast.loading(`Updating invoice status to ${formatEnum(nextStatus)}...`) try { - const promise = api.invoices.update(String(invoice.id), { status: nextStatus }) - - toast.promise(promise, { - loading: `Updating invoice status to ${formatEnum(nextStatus)}...`, - success: "Invoice status updated successfully.", - error: "Failed to update invoice status.", - }) - - await promise - setStatus(nextStatus) + await api.invoices.update(String(invoice.id), { status: nextStatus }) + toast.success("Invoice status updated successfully.", { id: loadingToast }) router.refresh() + } catch (e) { + toast.error(parseApiError(e, "Failed to update invoice status."), { id: loadingToast }) } finally { setIsUpdating(false) } diff --git a/apps/dashboard/modules/payment-mades/payment-made-actions.tsx b/apps/dashboard/modules/payment-mades/payment-made-actions.tsx new file mode 100644 index 0000000..23c1c9c --- /dev/null +++ b/apps/dashboard/modules/payment-mades/payment-made-actions.tsx @@ -0,0 +1,48 @@ +"use client" + +import { useAuthApi } from "@/shared/useApi" +import { useRouter } from "next/navigation" +import { Button } from "@/shared/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/shared/components/ui/dropdown-menu" +import { Ellipsis, Printer, Trash2 } from "lucide-react" +import { useDocumentPrint } from "@/shared/hooks/use-document-print" + +type PaymentMadeActionsProps = { + paymentId: string +} + +export function PaymentMadeActions({ paymentId }: PaymentMadeActionsProps) { + const api = useAuthApi() + const router = useRouter() + const { print, isPrinting } = useDocumentPrint() + + const handleDelete = async () => { + await api.paymentMades.destroy(paymentId) + router.push("/purchase/payments-made") + } + + return ( + + + + + + print("payment_made", paymentId, "print")} disabled={isPrinting}> + + {isPrinting ? "Printing..." : "Print"} + + + + Delete + + + + ) +} diff --git a/apps/dashboard/modules/payment-mades/payment-made-form.tsx b/apps/dashboard/modules/payment-mades/payment-made-form.tsx index 0a1e4ad..bbad658 100644 --- a/apps/dashboard/modules/payment-mades/payment-made-form.tsx +++ b/apps/dashboard/modules/payment-mades/payment-made-form.tsx @@ -30,6 +30,7 @@ import { BILL_ROUTES, PAYMENT_MODE_ROUTES, EMPLOYEE_ROUTES, + PAYMENT_MADE_ROUTES, PaymentFor, } from "@garage/api" import { RhfVendorSelectField } from "@/modules/vendors/rhf-vendor-select-field" @@ -182,6 +183,8 @@ export function PaymentMadeForm({ resourceId, initialData, onSuccess, billId, ex defaultValues: DEFAULT_VALUES, resourceId, initialData: resolvedInitialData, + initialize: (id) => api.paymentMades.show(id), + queryKey: [PAYMENT_MADE_ROUTES.BY_ID, resourceId], mapToFormValues, }) diff --git a/apps/dashboard/modules/payment-received/payment-received-actions.tsx b/apps/dashboard/modules/payment-received/payment-received-actions.tsx new file mode 100644 index 0000000..0bd1d3a --- /dev/null +++ b/apps/dashboard/modules/payment-received/payment-received-actions.tsx @@ -0,0 +1,48 @@ +"use client" + +import { useAuthApi } from "@/shared/useApi" +import { useRouter } from "next/navigation" +import { Button } from "@/shared/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/shared/components/ui/dropdown-menu" +import { Ellipsis, Printer, Trash2 } from "lucide-react" +import { useDocumentPrint } from "@/shared/hooks/use-document-print" + +type PaymentReceivedActionsProps = { + paymentId: string +} + +export function PaymentReceivedActions({ paymentId }: PaymentReceivedActionsProps) { + const api = useAuthApi() + const router = useRouter() + const { print, isPrinting } = useDocumentPrint() + + const handleDelete = async () => { + await api.paymentReceived.destroy(paymentId) + router.push("/sales/payment-received") + } + + return ( + + + + + + print("payment_received", paymentId, "print")} disabled={isPrinting}> + + {isPrinting ? "Printing..." : "Print"} + + + + Delete + + + + ) +} diff --git a/apps/dashboard/modules/payment-received/payment-received-form.tsx b/apps/dashboard/modules/payment-received/payment-received-form.tsx index 6ff3da5..8d1be35 100644 --- a/apps/dashboard/modules/payment-received/payment-received-form.tsx +++ b/apps/dashboard/modules/payment-received/payment-received-form.tsx @@ -25,7 +25,7 @@ import { paymentReceivedFormSchema, type PaymentReceivedFormValues, } from "./payment-received.schema" -import { PAYMENT_MODE_ROUTES, CUSTOMER_ROUTES, JOB_CARD_ROUTES } from "@garage/api" +import { PAYMENT_MODE_ROUTES, CUSTOMER_ROUTES, JOB_CARD_ROUTES, PAYMENT_RECEIVED_ROUTES } from "@garage/api" // ── Props ── @@ -58,13 +58,29 @@ const DEFAULT_VALUES: PaymentReceivedFormValues = { function mapToFormValues(data: unknown): PaymentReceivedFormValues { const d = (data as any)?.data ?? data ?? {} + const jobCardId = d.job_card_id ?? d.job_card?.id + const jobCardLabel = d.job_card?.title ?? d.job_card_name + + const paymentModeId = d.payment_mode_id ?? d.payment_mode?.id + const paymentModeLabel = d.payment_mode?.title ?? d.payment_mode?.name ?? d.payment_mode_name + + const customerId = d.customer_id ?? d.customer?.id + const customerLabel = d.customer?.first_name + ? `${d.customer.first_name} ${d.customer.last_name ?? ""}`.trim() + : d.customer?.company_name ?? d.customer?.name ?? d.customer_name + + const rawDate = d.payment_date + const payment_date = typeof rawDate === "string" && rawDate + ? rawDate.slice(0, 10) + : new Date().toISOString().split("T")[0] + return { - job_card: toRelation(d.job_card_id, d.job_card_name), - payment_mode: toRelation(d.payment_mode_id, d.payment_mode_name), - customer: toRelation(d.customer_id, d.customer_name), + job_card: toRelation(jobCardId, jobCardLabel), + payment_mode: toRelation(paymentModeId, paymentModeLabel), + customer: toRelation(customerId, customerLabel), amount_received: d.amount_received != null ? Number(d.amount_received) : 0, payment_number: d.payment_number || "", - payment_date: d.payment_date || new Date().toISOString().split("T")[0], + payment_date, note: d.note || "", } } @@ -126,6 +142,8 @@ export function PaymentReceivedForm({ resourceId, initialData, onSuccess, defaul defaultValues: DEFAULT_VALUES, resourceId, initialData: resolvedInitialData, + initialize: (id) => api.paymentReceived.show(id), + queryKey: [PAYMENT_RECEIVED_ROUTES.BY_ID, resourceId], mapToFormValues, }) diff --git a/apps/dashboard/modules/settings/company/settings-form.tsx b/apps/dashboard/modules/settings/company/settings-form.tsx index 5bef48b..c2d5edb 100644 --- a/apps/dashboard/modules/settings/company/settings-form.tsx +++ b/apps/dashboard/modules/settings/company/settings-form.tsx @@ -17,9 +17,11 @@ import { RhfAsyncSelectField, RhfTextareaField, } from "@/shared/components/form" +import { RhfImageField } from "@/shared/components/form/fields/rhf-image-field" import { useAuthApi } from "@/shared/useApi" import { useFormMutation } from "@/shared/hooks/use-form-mutation" import { toId } from "@/shared/lib/utils" +import { CONSTANTS } from "@/config/constants" import { FirstDayOfWork } from "@garage/api" import { SETTINGS_ROUTES } from "@garage/api" @@ -58,6 +60,7 @@ const DEFAULT_VALUES: SettingsFormValues = { description: "", security: "", privacy_policy: "", + logo: null, } // ── Mapping helpers ── @@ -85,6 +88,7 @@ function mapToFormValues(data: unknown): SettingsFormValues { description: d.description ?? "", security: d.security ?? "", privacy_policy: d.privacy_policy ?? "", + logo: null, } } @@ -93,11 +97,24 @@ function mapFormToPayload(values: SettingsFormValues) { name: values.name, email: values.email || undefined, phone: values.phone || undefined, + alternative_phone: values.alternative_phone || undefined, + website: values.website || undefined, time_zone: values.time_zone || undefined, + upi_id: values.upi_id || undefined, first_day_of_work: values.first_day_of_work || undefined, + latitude: values.latitude || undefined, + longitude: values.longitude || undefined, + bank_details: values.bank_details || undefined, first_address_line: values.first_address_line || undefined, + second_address_line: values.second_address_line || undefined, country_id: toId(values.country), + state_id: toId(values.state), city: values.city || undefined, + zip_code: values.zip_code || undefined, + description: values.description || undefined, + security: values.security || undefined, + privacy_policy: values.privacy_policy || undefined, + logo: values.logo instanceof File ? values.logo : undefined, } } @@ -116,6 +133,14 @@ export function SettingsForm() { queryFn: () => api.settings.fetch(), }) + const existingLogoPath: string | null = (() => { + const raw = (data as any)?.data + const record = Array.isArray(raw) ? raw[0] : raw + const logo = record?.logo + if (!logo || typeof logo !== "string") return null + return logo.startsWith("http") ? logo : CONSTANTS.getAssetUrl(`storage/${logo.replace(/^\/+/, "")}`) + })() + useEffect(() => { if (!data) return const raw = (data as any)?.data @@ -286,6 +311,20 @@ export function SettingsForm() { {/* Sidebar - 4/12 */}
+ {/* Logo Section */} +
+

Workshop Logo

+

+ Shown on printed invoices, estimates, job cards and other documents. +

+ +
+ {/* Location & Time Section */}

Location & Time

@@ -340,8 +379,8 @@ export function SettingsForm() {
diff --git a/apps/dashboard/modules/settings/company/settings.schema.ts b/apps/dashboard/modules/settings/company/settings.schema.ts index 7133000..2b42684 100644 --- a/apps/dashboard/modules/settings/company/settings.schema.ts +++ b/apps/dashboard/modules/settings/company/settings.schema.ts @@ -51,6 +51,14 @@ export const settingsFormSchema = z.object({ description: z.string().optional(), security: z.string().optional(), privacy_policy: z.string().optional(), + logo: z + .any() + .nullable() + .optional() + .refine( + (v) => v == null || v instanceof File, + "Logo must be an image file", + ), }) export type SettingsFormValues = z.infer diff --git a/apps/dashboard/modules/vendors/rhf-vendor-select-field.tsx b/apps/dashboard/modules/vendors/rhf-vendor-select-field.tsx index 7ea6a14..bd8b16d 100644 --- a/apps/dashboard/modules/vendors/rhf-vendor-select-field.tsx +++ b/apps/dashboard/modules/vendors/rhf-vendor-select-field.tsx @@ -10,6 +10,7 @@ import { Field, FieldLabel, FieldError, FieldDescription } from "@/shared/compon import { Button } from "@/shared/components/ui/button" import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/shared/components/ui/dialog" import { ScrollArea } from "@/shared/components/ui/scroll-area" +import { DialogCloseContext } from "@/shared/hooks/use-dialog-close" import { Combobox, ComboboxInput, @@ -228,7 +229,9 @@ export function RhfVendorSelectField< Add Vendor - + setIsCreateOpen(false)}> + + diff --git a/apps/dashboard/modules/vendors/vendor-general-info.tsx b/apps/dashboard/modules/vendors/vendor-general-info.tsx index 14997e9..d9640df 100644 --- a/apps/dashboard/modules/vendors/vendor-general-info.tsx +++ b/apps/dashboard/modules/vendors/vendor-general-info.tsx @@ -16,6 +16,7 @@ import { import { Badge } from "@/shared/components/ui/badge" import { Separator } from "@/shared/components/ui/separator" import { Money } from "@/shared/components/money" +import { formatEnum } from "@/shared/utils/formatters" type VendorData = { id?: number @@ -82,8 +83,8 @@ export function VendorGeneralInfo({ vendor }: VendorGeneralInfoProps) {
{vendor.company_name || fullName || "Unknown vendor"} {vendor.status && ( - - {vendor.status} + + {formatEnum(vendor.status)} )}
diff --git a/apps/dashboard/shared/components/crud-dialog/crud-dialog.tsx b/apps/dashboard/shared/components/crud-dialog/crud-dialog.tsx index b07f52c..fa3b428 100644 --- a/apps/dashboard/shared/components/crud-dialog/crud-dialog.tsx +++ b/apps/dashboard/shared/components/crud-dialog/crud-dialog.tsx @@ -13,6 +13,7 @@ import { import { ScrollArea } from "@/shared/components/ui/scroll-area" import { DataTable } from "@/shared/data-view/table-view" import { createActionsColumn } from "@/shared/data-view/table-view" +import { DialogCloseContext } from "@/shared/hooks/use-dialog-close" import { useCrudDialog, type CrudDialogClient, type UseCrudDialogOptions } from "./use-crud-dialog" // ── Types ── @@ -89,6 +90,11 @@ export function CrudDialog({ { if (!v) handleClose() }}> + {/* Inner forms manage their own close via `onSuccess` → */} + {/* `crud.handleFormSuccess`. Shadow the parent dialog's */} + {/* close so a CrudDialog opened from inside a FormDialog */} + {/* doesn't dismiss the outer dialog on every save. */} +
{crud.isFormOpen && ( @@ -142,6 +148,7 @@ export function CrudDialog({
)} +
diff --git a/apps/dashboard/shared/components/form-dialog.tsx b/apps/dashboard/shared/components/form-dialog.tsx index c796f9b..e566e9f 100644 --- a/apps/dashboard/shared/components/form-dialog.tsx +++ b/apps/dashboard/shared/components/form-dialog.tsx @@ -5,6 +5,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/shared/compo import { ScrollArea } from '@/shared/components/ui/scroll-area' import { Plus } from 'lucide-react' import { cn } from '../lib/utils' +import { DialogCloseContext } from '@/shared/hooks/use-dialog-close' export const formDialogParams = { dialog: parseAsBoolean.withDefault(false), @@ -87,7 +88,9 @@ export default function FormDialog(props: { - {props.children(resourceId, { open, close , isOpen})} + + {props.children(resourceId, { open, close, isOpen })} + diff --git a/apps/dashboard/shared/components/form/fields/rhf-async-select-field.tsx b/apps/dashboard/shared/components/form/fields/rhf-async-select-field.tsx index 7465857..097bbd5 100644 --- a/apps/dashboard/shared/components/form/fields/rhf-async-select-field.tsx +++ b/apps/dashboard/shared/components/form/fields/rhf-async-select-field.tsx @@ -24,6 +24,7 @@ import { } from "@/shared/components/ui/dialog" import { ScrollArea } from "@/shared/components/ui/scroll-area" import { PlusIcon } from "lucide-react" +import { DialogCloseContext } from "@/shared/hooks/use-dialog-close" // ── Inline create types ── @@ -199,7 +200,9 @@ export function RhfAsyncSelectField< - {createForm({ onSuccess: handleCreateSuccess })} + setIsCreateOpen(false)}> + {createForm({ onSuccess: handleCreateSuccess })} + diff --git a/apps/dashboard/shared/hooks/use-dialog-close.tsx b/apps/dashboard/shared/hooks/use-dialog-close.tsx new file mode 100644 index 0000000..73aa078 --- /dev/null +++ b/apps/dashboard/shared/hooks/use-dialog-close.tsx @@ -0,0 +1,12 @@ +"use client" + +import { createContext, useContext } from "react" + +// Closes the nearest dialog/sheet wrapper. `null` means the form is not +// inside a dialog that should auto-close (or a parent has explicitly +// shadowed the context, e.g. CrudDialog). +export const DialogCloseContext = createContext<(() => void) | null>(null) + +export function useDialogClose(): (() => void) | null { + return useContext(DialogCloseContext) +} diff --git a/apps/dashboard/shared/hooks/use-form-mutation.ts b/apps/dashboard/shared/hooks/use-form-mutation.ts index fbab5fb..831149d 100644 --- a/apps/dashboard/shared/hooks/use-form-mutation.ts +++ b/apps/dashboard/shared/hooks/use-form-mutation.ts @@ -3,14 +3,25 @@ import { useMutation, type UseMutationOptions } from "@tanstack/react-query" import type { FieldValues, UseFormReturn } from "react-hook-form" import { ApiError } from "@garage/api" +import { useDialogClose } from "./use-dialog-close" export function useFormMutation( form: UseFormReturn, options: UseMutationOptions, ) { + const closeDialog = useDialogClose() + return useMutation({ ...options, - onError: (err, vars,values, ctx) => { + onSuccess: async (data, vars, onMutateResult, ctx) => { + await options.onSuccess?.(data, vars, onMutateResult, ctx) + // If this form is rendered inside a dialog wrapper that registers + // a close handler (e.g. FormDialog), dismiss the dialog after a + // successful submit. Plain pages render the form without the + // provider, so this is a no-op there. + closeDialog?.() + }, + onError: (err, vars, values, ctx) => { if (err instanceof ApiError && err.validationErrors) { Object.entries(err.validationErrors).forEach(([field, msgs]) => { form.setError(field as any, { message: msgs[0] }) diff --git a/packages/api/src/clients/document-print.ts b/packages/api/src/clients/document-print.ts index 608173f..aca561f 100644 --- a/packages/api/src/clients/document-print.ts +++ b/packages/api/src/clients/document-print.ts @@ -11,9 +11,11 @@ export type DocumentPrintType = | "job_card" | "invoice" | "payment_received" + | "expense" | "purchase_order" | "bill" - | string + | "payment_made" + | "credit_note" export type DocumentPrintMode = "print" | "download" diff --git a/packages/api/src/clients/settings.ts b/packages/api/src/clients/settings.ts index 4683d5d..42ca504 100644 --- a/packages/api/src/clients/settings.ts +++ b/packages/api/src/clients/settings.ts @@ -1,10 +1,34 @@ import { ApiClient, type ApiClientOptions } from "../infra/client" -import type { ApiPath, ApiRequestBody } from "../infra/types" +import type { ApiPath } from "../infra/types" export const SETTINGS_ROUTES = { INDEX: "/api/settings", } as const satisfies Record +export type SettingsUpdatePayload = { + name?: string + email?: string + phone?: string + alternative_phone?: string + website?: string + time_zone?: string + upi_id?: string + first_day_of_work?: string + latitude?: string | number + longitude?: string | number + bank_details?: string + first_address_line?: string + second_address_line?: string + country_id?: number | string + state_id?: number | string + city?: string + zip_code?: string + description?: string + security?: string + privacy_policy?: string + logo?: File | null +} + export class SettingsClient extends ApiClient { constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) { super(baseUrl, defaultOptions) @@ -14,7 +38,23 @@ export class SettingsClient extends ApiClient { return this.get(SETTINGS_ROUTES.INDEX) } - async update(payload: ApiRequestBody) { - return this.put(SETTINGS_ROUTES.INDEX, payload) + async update(payload: SettingsUpdatePayload) { + const hasFile = payload && (payload as any).logo instanceof File + if (!hasFile) { + const { logo: _logo, ...rest } = (payload ?? {}) as Record + return this.put(SETTINGS_ROUTES.INDEX, rest as any) + } + + const fd = new FormData() + for (const [key, value] of Object.entries(payload as Record)) { + if (value === undefined || value === null) continue + if (value instanceof File) { + fd.append(key, value) + } else { + fd.append(key, String(value)) + } + } + fd.append("_method", "PUT") + return this.postFormData(SETTINGS_ROUTES.INDEX, fd) } } diff --git a/packages/api/src/infra/index.ts b/packages/api/src/infra/index.ts index 5448454..efd0dd6 100644 --- a/packages/api/src/infra/index.ts +++ b/packages/api/src/infra/index.ts @@ -19,4 +19,5 @@ export { export { ApiClient, ApiError, type ApiClientOptions } from "./client" export { DEFAULT_PER_PAGE } from "./crud-client" export * from "./crud-client" +export { parseApiError } from "./parse-error" export type { AuthUser } from "./token" diff --git a/packages/api/src/infra/parse-error.ts b/packages/api/src/infra/parse-error.ts new file mode 100644 index 0000000..72e9e62 --- /dev/null +++ b/packages/api/src/infra/parse-error.ts @@ -0,0 +1,35 @@ +import { ApiError } from "./client" + +/** + * Extract a user-facing error message from a thrown ApiError (or any unknown error). + * Order: first Laravel field error → payload.message → error.message → fallback. + */ +export function parseApiError(error: unknown, fallback = "Request failed"): string { + const e = error as { payload?: { errors?: unknown; message?: string }; message?: string } | undefined + if (!e) return fallback + + const errors = e.payload?.errors + if (errors && typeof errors === "object" && !Array.isArray(errors)) { + const first = Object.values(errors as Record)[0] + if (Array.isArray(first) && typeof first[0] === "string") { + return first[0] + } + if (typeof first === "string") { + return first + } + } + + if (typeof e.payload?.message === "string" && e.payload.message.length > 0) { + return e.payload.message + } + + if (error instanceof ApiError && error.message) { + return error.message + } + + if (typeof e.message === "string" && e.message.length > 0) { + return e.message + } + + return fallback +}