From 6b356d2855c1de2b563b02e024f796d9a90b353b Mon Sep 17 00:00:00 2001 From: humam kerdiah Date: Wed, 13 May 2026 15:55:40 +0400 Subject: [PATCH 1/5] fix estimate table and expense items --- .claude/settings.local.json | 9 +++++ .env.prod | 2 + .../items/expense-item/page.tsx | 7 +++- .../(authenticated)/sales/estimates/page.tsx | 21 ++++++---- .../(authenticated)/sales/invoice/page.tsx | 19 +++++++-- .../modules/estimates/estimate-form.tsx | 5 ++- .../expense-items/expense-item-form.tsx | 40 ++++++++++++------- .../modules/invoices/invoice-form.tsx | 5 ++- .../modules/job-cards/job-card-form.tsx | 5 ++- .../resource-page/use-resource-page.ts | 6 ++- .../shared/hooks/use-resource-form.ts | 17 ++++++-- 11 files changed, 96 insertions(+), 40 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 .env.prod diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..9e421fb --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(pnpm --version)", + "Bash(grep -E \"\\\\.\\(tsx?|jsx?\\)$\")", + "Bash(grep -E \"\\\\.\\(tsx?\\)$\")" + ] + } +} diff --git a/.env.prod b/.env.prod new file mode 100644 index 0000000..4d35848 --- /dev/null +++ b/.env.prod @@ -0,0 +1,2 @@ +NIXPACKS_NODE_VERSION=22 +NEXT_PUBLIC_API_URL=http://reparee.test \ No newline at end of file diff --git a/apps/dashboard/app/(authenticated)/items/expense-item/page.tsx b/apps/dashboard/app/(authenticated)/items/expense-item/page.tsx index 9ee37bc..5b77926 100644 --- a/apps/dashboard/app/(authenticated)/items/expense-item/page.tsx +++ b/apps/dashboard/app/(authenticated)/items/expense-item/page.tsx @@ -16,11 +16,14 @@ export default function ExpenseItemPage() { headerProps={({ selectedItem, invalidateQuery }) => ({ actions: ( - {(resourceId) => ( + {(resourceId, { close }) => ( { + invalidateQuery() + close() + }} /> )} diff --git a/apps/dashboard/app/(authenticated)/sales/estimates/page.tsx b/apps/dashboard/app/(authenticated)/sales/estimates/page.tsx index ab2504f..a9136a9 100644 --- a/apps/dashboard/app/(authenticated)/sales/estimates/page.tsx +++ b/apps/dashboard/app/(authenticated)/sales/estimates/page.tsx @@ -1,5 +1,6 @@ "use client" +import { useRouter } from 'next/navigation' import { ResourcePage } from '@/shared/data-view/resource-page' import { ColumnHeader } from '@/shared/data-view/table-view' import FormDialog from '@/shared/components/form-dialog' @@ -14,11 +15,13 @@ import { getVehicleLabel } from '@/modules/vehicles/utils/getVehicleLabel' import { getFullName } from '@/shared/utils/getFullName' export default function EstimatesPage() { + const router = useRouter() return ( pageTitle="Estimates" routeKey={ESTIMATE_ROUTES.INDEX} getClient={(api) => api.estimates} + onRowClick={(row) => router.push(`/sales/estimates/${(row as any).id}`)} headerProps={({ selectedItem, invalidateQuery }) => ({ actions: ( @@ -39,7 +42,7 @@ export default function EstimatesPage() { const item = row.original return (
- + e.stopPropagation()}> {item.title} @@ -55,12 +58,14 @@ export default function EstimatesPage() { accessorKey: "customer_name", header: ({ column }) => , cell: ({ row }) => { - const item:any = row.original + const item:any = row.original + if (!item.customer?.id) return "—" return ( -
- - {getFullName(item.customer) || "—"} -
+ ) } }, @@ -68,8 +73,8 @@ export default function EstimatesPage() { accessorKey: "vehicle", header: ({ column }) => , cell: ({ row }) => { - const item :any= row.original - return + ) : ( +

{customerLabel}

+ )} +

{subline}

) }, diff --git a/apps/dashboard/modules/estimates/estimate-form.tsx b/apps/dashboard/modules/estimates/estimate-form.tsx index ea4dffd..086ca88 100644 --- a/apps/dashboard/modules/estimates/estimate-form.tsx +++ b/apps/dashboard/modules/estimates/estimate-form.tsx @@ -140,12 +140,13 @@ const DISCOUNT_OPTIONS = DiscountType.map((value) => ({ export function EstimateForm({ resourceId, initialData, onSuccess }: EstimateFormProps) { const api = useAuthApi() - const { form, isEditing } = useResourceForm({ + const { form, isEditing, invalidate } = useResourceForm({ schema: estimateFormSchema, defaultValues: DEFAULT_VALUES, resourceId, initialData, queryKey: [ESTIMATE_ROUTES.BY_ID, resourceId], + initialize: (id) => api.estimates.show(id), mapToFormValues, }) @@ -163,7 +164,7 @@ export function EstimateForm({ resourceId, initialData, onSuccess }: EstimateFor return promise }, onSuccess: () => { - form.reset() + if (!isEditing) form.reset() onSuccess?.() }, }) diff --git a/apps/dashboard/modules/expense-items/expense-item-form.tsx b/apps/dashboard/modules/expense-items/expense-item-form.tsx index 814e1f4..823e783 100644 --- a/apps/dashboard/modules/expense-items/expense-item-form.tsx +++ b/apps/dashboard/modules/expense-items/expense-item-form.tsx @@ -27,9 +27,9 @@ import { INVENTORY_CATEGORY_ROUTES, INVENTORY_ROUTES, DEPARTMENT_ROUTES, - VENDOR_ROUTES, } from "@garage/api" import { InventoryCategoryCrudDialog } from "./inventory-category-crud-dialog" +import { RhfVendorSelectField } from "@/modules/vendors/rhf-vendor-select-field" // ── Constants ── @@ -84,15 +84,26 @@ function mapToFormValues(data: unknown): ExpenseItemFormValues { sku: d.sku || "", item_code: d.item_code || "", description: d.description || "", - category: toRelation(d.category_id, d.category_title ?? d.category_name), - unit_type: toRelation(d.unit_type_id, d.unit_type_title ?? d.unit_type_name), - department: toRelation(d.department_id, d.department_name ?? d.department_title), + category: toRelation( + d.category_id ?? d.category?.id, + d.category_title ?? d.category_name ?? d.category?.title ?? d.category?.name, + ), + unit_type: toRelation( + d.unit_type_id ?? d.unit_type?.id, + d.unit_type_title ?? d.unit_type_name ?? d.unit_type?.title ?? d.unit_type?.name, + ), + department: toRelation( + d.department_id ?? d.department?.id, + d.department_name ?? d.department_title ?? d.department?.name ?? d.department?.title, + ), purchase_information: d.purchase_information ?? true, purchase_price: d.purchase_price ?? undefined, purchase_chart_of_account: d.purchase_chart_of_account || "", purchase_preferred_vendor: toRelation( - d.purchase_preferred_vendor_id, - d.purchase_preferred_vendor_name, + d.purchase_preferred_vendor_id ?? d.purchase_preferred_vendor?.id, + d.purchase_preferred_vendor_name + ?? d.purchase_preferred_vendor?.company_name + ?? [d.purchase_preferred_vendor?.first_name, d.purchase_preferred_vendor?.last_name].filter(Boolean).join(" "), ), sales_information: d.sales_information ?? false, selling_price: d.selling_price ?? undefined, @@ -127,7 +138,7 @@ function mapFormToPayload(values: ExpenseItemFormValues) { export function ExpenseItemForm({ resourceId, initialData, onSuccess }: ExpenseItemFormProps) { const api = useAuthApi() - const { form, isEditing } = useResourceForm({ + const { form, isEditing, invalidate } = useResourceForm({ schema: expenseItemFormSchema, defaultValues: DEFAULT_VALUES, resourceId, @@ -150,7 +161,7 @@ export function ExpenseItemForm({ resourceId, initialData, onSuccess }: ExpenseI return promise }, onSuccess: () => { - form.reset() + if (!isEditing) form.reset() onSuccess?.() }, }) @@ -247,7 +258,7 @@ export function ExpenseItemForm({ resourceId, initialData, onSuccess }: ExpenseI {/* Purchase Information */} - {/* @@ -259,22 +270,21 @@ export function ExpenseItemForm({ resourceId, initialData, onSuccess }: ExpenseI placeholder="0.00" type="number" /> + {/* TODO(phase-2): wire Purchase Chart of Account to the chart-of-accounts module (currently disabled, marked "Coming soon"). */} - api.vendors.list()} - mapOption={(item: any) => ({ value: String(item.id), label: item.name ?? String(item.id) })} - {...STORE_OBJECT} - /> */} + /> {/* Sales Information */} {/* ({ + const { form, isEditing, invalidate } = useResourceForm({ schema: invoiceFormSchema, defaultValues: DEFAULT_VALUES, resourceId, initialData, queryKey: [INVOICE_ROUTES.BY_ID, resourceId], + initialize: (id) => api.invoices.show(id), mapToFormValues, }) @@ -277,7 +278,7 @@ export function InvoiceForm({ resourceId, initialData, onSuccess }: InvoiceFormP return promise }, onSuccess: () => { - form.reset() + if (!isEditing) form.reset() onSuccess?.() }, }) diff --git a/apps/dashboard/modules/job-cards/job-card-form.tsx b/apps/dashboard/modules/job-cards/job-card-form.tsx index 23ebf09..77ca6c2 100644 --- a/apps/dashboard/modules/job-cards/job-card-form.tsx +++ b/apps/dashboard/modules/job-cards/job-card-form.tsx @@ -231,12 +231,13 @@ export function JobCardForm({ resourceId, initialData, onSuccess }: JobCardFormP const api = useAuthApi() const [isCheckInDialogOpen, setIsCheckInDialogOpen] = useState(false) - const { form, isEditing } = useResourceForm({ + const { form, isEditing, invalidate } = useResourceForm({ schema: jobCardFormSchema, defaultValues: DEFAULT_VALUES, resourceId, initialData, queryKey: [JOB_CARD_ROUTES.BY_ID, resourceId], + initialize: (id) => api.jobCards.show(id), mapToFormValues, }) @@ -260,7 +261,7 @@ export function JobCardForm({ resourceId, initialData, onSuccess }: JobCardFormP }, onSuccess: () => { setIsCheckInDialogOpen(false) - form.reset() + if (!isEditing) form.reset() onSuccess?.() }, }) diff --git a/apps/dashboard/shared/data-view/resource-page/use-resource-page.ts b/apps/dashboard/shared/data-view/resource-page/use-resource-page.ts index 34e4a2e..5779bfa 100644 --- a/apps/dashboard/shared/data-view/resource-page/use-resource-page.ts +++ b/apps/dashboard/shared/data-view/resource-page/use-resource-page.ts @@ -1,6 +1,6 @@ "use client" -import { useState } from "react" +import { useEffect, useState } from "react" import { useMutation } from "@tanstack/react-query" import { toast } from "sonner" import { confirm } from "@/shared/components/confirm-dialog" @@ -42,6 +42,10 @@ export function useResourcePage({ const { open: openDialog, close: closeDialog, isOpen, resourceId } = useFormDialog(paramKey) const [selectedItem, setSelectedItem] = useState(null) + useEffect(() => { + if (!resourceId) setSelectedItem(null) + }, [resourceId]) + const tableQuery = useDataTableQuery({ queryKey: [routeKey], client, diff --git a/apps/dashboard/shared/hooks/use-resource-form.ts b/apps/dashboard/shared/hooks/use-resource-form.ts index 2b3a35b..c278d64 100644 --- a/apps/dashboard/shared/hooks/use-resource-form.ts +++ b/apps/dashboard/shared/hooks/use-resource-form.ts @@ -3,7 +3,7 @@ import { useEffect } from "react" import { useForm, type DefaultValues, type FieldValues, type UseFormReturn } from "react-hook-form" import { zodResolver } from "@hookform/resolvers/zod" -import { useQuery, type QueryKey } from "@tanstack/react-query" +import { useQuery, useQueryClient, type QueryKey } from "@tanstack/react-query" import type { ZodType } from "zod" type UseResourceFormOptions = { @@ -20,6 +20,7 @@ type UseResourceFormReturn = { form: UseFormReturn isEditing: boolean isInitializing: boolean + invalidate: () => void } export function useResourceForm({ @@ -32,11 +33,15 @@ export function useResourceForm): UseResourceFormReturn { const isEditing = !!resourceId + const queryClient = useQueryClient() + const resolvedQueryKey = queryKey ?? ["resource", resourceId] const { data: queriedData, isLoading: isQueryLoading } = useQuery({ - queryKey: queryKey ?? ["resource", resourceId], + queryKey: resolvedQueryKey, queryFn: () => initialize!(resourceId!), enabled: isEditing && !!initialize, + staleTime: 0, + refetchOnMount: "always", }) const resolvedData = queriedData ?? (isEditing ? initialData : undefined) @@ -49,7 +54,7 @@ export function useResourceForm { if (!isEditing) { if (initialData) { - form.reset({ ...defaultValues, ...initialData } as any) + form.reset({ ...defaultValues, ...mapToFormValues(initialData) } as any) } else { form.reset(defaultValues) } @@ -61,5 +66,9 @@ export function useResourceForm { + queryClient.invalidateQueries({ queryKey: resolvedQueryKey }) + } + + return { form, isEditing, isInitializing: isEditing && !!initialize && isQueryLoading, invalidate } } From 05b55b572176a836402825b1f39b30d5c6b91bd7 Mon Sep 17 00:00:00 2001 From: humam kerdiah Date: Wed, 13 May 2026 17:20:59 +0400 Subject: [PATCH 2/5] fix: add staleTime to job card expense, parts, and services queries; update job card general info and payments received components --- .../job-cards/[id]/expense-items/page.tsx | 1 + .../sales/job-cards/[id]/page.tsx | 15 +-- .../sales/job-cards/[id]/parts/page.tsx | 1 + .../sales/job-cards/[id]/services/page.tsx | 1 + apps/dashboard/app/layout.tsx | 2 + .../dashboard-details-page-layout.tsx | 1 + .../modules/estimates/estimate-form.tsx | 44 +++++++-- .../job-cards/job-card-general-info.tsx | 14 ++- .../job-cards/job-card-payments-received.tsx | 14 ++- .../payment-received-form.tsx | 95 ++++++++++++------- apps/dashboard/package.json | 1 + pnpm-lock.yaml | 76 +++++++++------ 12 files changed, 174 insertions(+), 91 deletions(-) diff --git a/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/expense-items/page.tsx b/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/expense-items/page.tsx index e9bc333..7df0650 100644 --- a/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/expense-items/page.tsx +++ b/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/expense-items/page.tsx @@ -44,6 +44,7 @@ export default function JobCardExpenseItemsPage({ const { data, isLoading } = useQuery({ queryKey, queryFn: () => api.jobCards.getExpenseItems(jobCardId), + staleTime: 30_000, }) const rows = (data as any)?.data ?? [] diff --git a/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/page.tsx b/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/page.tsx index 99cfd93..9e5abfb 100644 --- a/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/page.tsx +++ b/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/page.tsx @@ -1,21 +1,10 @@ -import { getServerApi } from '@garage/api/server' import { JobCardGeneralInfo } from '@/modules/job-cards/job-card-general-info' import DashboardPage from '@/base/components/layout/dashboard/dashboard-page' -import type { JobCardShowData } from '@garage/api' - -export default async function JobCardDetailPage(props: { params: Promise<{ id: string }> }) { - const { id } = await props.params - const api = await getServerApi() - const response = await api.jobCards.show(id) - const data = response.data - - if (!data) { - return
Job card not found.
- } +export default async function JobCardDetailPage() { return ( - + ) } diff --git a/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/parts/page.tsx b/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/parts/page.tsx index bf52d14..6a0ef4a 100644 --- a/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/parts/page.tsx +++ b/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/parts/page.tsx @@ -44,6 +44,7 @@ export default function JobCardPartsPage({ const { data, isLoading } = useQuery({ queryKey, queryFn: () => api.jobCards.getParts(jobCardId), + staleTime: 30_000, }) const rows = (data as any)?.data ?? [] diff --git a/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/services/page.tsx b/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/services/page.tsx index 1f8accb..59bf6ed 100644 --- a/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/services/page.tsx +++ b/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/services/page.tsx @@ -46,6 +46,7 @@ export default function JobCardServicesPage({ const { data, isLoading } = useQuery({ queryKey, queryFn: () => api.jobCards.getServices(jobCardId), + staleTime: 30_000, }) const rows = (data as any)?.data ?? [] diff --git a/apps/dashboard/app/layout.tsx b/apps/dashboard/app/layout.tsx index 19e7703..7249e80 100644 --- a/apps/dashboard/app/layout.tsx +++ b/apps/dashboard/app/layout.tsx @@ -1,4 +1,5 @@ import { Geist_Mono, Inter } from "next/font/google" +import NextTopLoader from "nextjs-toploader" import { QueryProvider } from "@/shared/components/query-provider" @@ -33,6 +34,7 @@ export default function RootLayout({ className={cn("antialiased", fontMono.variable, "font-sans", inter.variable)} > + {children} diff --git a/apps/dashboard/base/components/layout/dashboard/dashboard-details-page-layout.tsx b/apps/dashboard/base/components/layout/dashboard/dashboard-details-page-layout.tsx index d15a816..b77c428 100644 --- a/apps/dashboard/base/components/layout/dashboard/dashboard-details-page-layout.tsx +++ b/apps/dashboard/base/components/layout/dashboard/dashboard-details-page-layout.tsx @@ -109,6 +109,7 @@ export default function DashboardDetailsPageLayout({ = { cancelled: "destructive", } -export function JobCardGeneralInfo({ jobCard }: { jobCard: JobCard }) { +export function JobCardGeneralInfo({ jobCard: jobCardProp }: { jobCard?: JobCard } = {}) { + const jobCardFromContext = useJobCard() + const jobCard = (jobCardProp ?? jobCardFromContext) as JobCard + + if (!jobCard) { + return
Job card not found.
+ } + const formatStatus = (status?: string) => { if (!status) return null return status diff --git a/apps/dashboard/modules/job-cards/job-card-payments-received.tsx b/apps/dashboard/modules/job-cards/job-card-payments-received.tsx index cffb145..e9f996e 100644 --- a/apps/dashboard/modules/job-cards/job-card-payments-received.tsx +++ b/apps/dashboard/modules/job-cards/job-card-payments-received.tsx @@ -17,12 +17,20 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/shared/co import { Button } from "@/shared/components/ui/button" import { useJobCard } from "./job-card-context" import { formatDate, formatCurrency } from "@/shared/utils/formatters" +import { useState } from "react" export default function JobCardPaymentsReceived() { const jobCard = useJobCard() + const [hasOpened, setHasOpened] = useState(false) return ( - + { + if (open) setHasOpened(true) + }} + >
@@ -36,6 +44,7 @@ export default function JobCardPaymentsReceived() { + {hasOpened && ( extraParams={{ job_card_id: jobCard?.id }} routeKey={PAYMENT_RECEIVED_ROUTES.INDEX} @@ -48,6 +57,8 @@ export default function JobCardPaymentsReceived() { resourceId={resourceId} defaultJobCard={{ id: jobCard?.id, title: jobCard?.title }} invoiceCustomer={jobCard?.customer as any} + lockJobCard + lockCustomer onSuccess={invalidateQuery} /> )} @@ -127,6 +138,7 @@ export default function JobCardPaymentsReceived() { actionsColumn(), ]} /> + )} diff --git a/apps/dashboard/modules/payment-received/payment-received-form.tsx b/apps/dashboard/modules/payment-received/payment-received-form.tsx index f134775..436a89f 100644 --- a/apps/dashboard/modules/payment-received/payment-received-form.tsx +++ b/apps/dashboard/modules/payment-received/payment-received-form.tsx @@ -5,7 +5,8 @@ import { AlertTriangle, Plus, Save } from "lucide-react" import { Button } from "@/shared/components/ui/button" import { Alert, AlertTitle } from "@/shared/components/ui/alert" -import { FieldGroup } from "@/shared/components/ui/field" +import { Field, FieldGroup, FieldLabel } from "@/shared/components/ui/field" +import { Input } from "@/shared/components/ui/input" import { Rhform, RhfTextField, @@ -36,6 +37,8 @@ export type PaymentReceivedFormProps = { invoiceId?: string | null invoiceCustomer?: { id?: number | null; first_name?: string | null; last_name?: string | null } | null invoiceAmount?: number | string | null + lockJobCard?: boolean + lockCustomer?: boolean } // ── Default values ── @@ -90,27 +93,33 @@ const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) = // ── Component ── -export function PaymentReceivedForm({ resourceId, initialData, onSuccess, defaultJobCard, invoiceId, invoiceCustomer, invoiceAmount }: PaymentReceivedFormProps) { +export function PaymentReceivedForm({ resourceId, initialData, onSuccess, defaultJobCard, invoiceId, invoiceCustomer, invoiceAmount, lockJobCard, lockCustomer }: PaymentReceivedFormProps) { const api = useAuthApi() + const isJobCardLocked = !resourceId && (lockJobCard ?? !!defaultJobCard?.id) + const isCustomerLocked = !resourceId && (lockCustomer ?? !!invoiceCustomer?.id) + + const customerLabel = invoiceCustomer?.first_name + ? `${invoiceCustomer.first_name} ${invoiceCustomer.last_name || ""}`.trim() + : (invoiceCustomer as any)?.company_name || (invoiceCustomer as any)?.name || "" + const resolvedInitialData = useMemo(() => { const base: any = { ...(initialData as any) } if (!resourceId) { if (defaultJobCard?.id != null) { - base.job_card = toRelation(defaultJobCard.id, defaultJobCard.title ?? undefined) + base.job_card_id = defaultJobCard.id + base.job_card_name = defaultJobCard.title ?? undefined } if (invoiceCustomer?.id != null) { - const customerLabel = invoiceCustomer.first_name - ? `${invoiceCustomer.first_name} ${invoiceCustomer.last_name || ""}`.trim() - : (invoiceCustomer as any).company_name || (invoiceCustomer as any).name || undefined - base.customer = toRelation(invoiceCustomer.id, customerLabel) + base.customer_id = invoiceCustomer.id + base.customer_name = customerLabel } if (invoiceAmount != null && invoiceAmount !== "") { base.amount_received = Number(invoiceAmount) } } return Object.keys(base).length ? base : initialData - }, [resourceId, defaultJobCard, initialData, invoiceCustomer, invoiceAmount]) + }, [resourceId, defaultJobCard, initialData, invoiceCustomer, invoiceAmount, customerLabel]) const { form, isEditing } = useResourceForm({ schema: paymentReceivedFormSchema, @@ -153,33 +162,49 @@ export function PaymentReceivedForm({ resourceId, initialData, onSuccess, defaul
- api.customers.list()} - mapOption={(item: any) => ({ - value: String(item.id), - label: item.first_name - ? `${item.first_name} ${item.last_name || ""}`.trim() - : item.name || `#${item.id}`, - })} - {...STORE_OBJECT} - /> - api.jobCards.list()} - mapOption={(item: any) => ({ - value: String(item.id), - label: item.title, - })} - {...STORE_OBJECT} - /> + {isCustomerLocked ? ( + + + Customer* + + + + ) : ( + api.customers.list()} + mapOption={(item: any) => ({ + value: String(item.id), + label: item.first_name + ? `${item.first_name} ${item.last_name || ""}`.trim() + : item.name || `#${item.id}`, + })} + {...STORE_OBJECT} + /> + )} + {isJobCardLocked ? ( + + Job Card + + + ) : ( + api.jobCards.list()} + mapOption={(item: any) => ({ + value: String(item.id), + label: item.title, + })} + {...STORE_OBJECT} + /> + )}
diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index cca0b05..6b8f309 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -31,6 +31,7 @@ "lucide-react": "^0.577.0", "next": "16.1.7", "next-themes": "^0.4.6", + "nextjs-toploader": "^3.9.17", "nuqs": "^2.8.9", "object-to-formdata": "^4.5.1", "radix-ui": "^1.4.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c78c6ed..3b80d52 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,6 +62,9 @@ importers: next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + nextjs-toploader: + specifier: ^3.9.17 + version: 3.9.17(next@16.1.7(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) nuqs: specifier: ^2.8.9 version: 2.8.9(next@16.1.7(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) @@ -3707,6 +3710,13 @@ packages: sass: optional: true + nextjs-toploader@3.9.17: + resolution: {integrity: sha512-9OF0KSSLtoSAuNg2LZ3aTl4hR9mBDj5L9s9DZiFCbMlXehyICGjkIz5dVGzuATU2bheJZoBdFgq9w07AKSuQQw==} + peerDependencies: + next: '>= 6.0.0' + react: '>= 16.0.0' + react-dom: '>= 16.0.0' + node-domexception@1.0.0: resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} engines: {node: '>=10.5.0'} @@ -3727,6 +3737,9 @@ packages: resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} engines: {node: '>=18'} + nprogress@0.2.0: + resolution: {integrity: sha512-I19aIingLgR1fmhftnbWWO3dXc0hSxqHQHQb3H8m+K3TnEn/iSeTZZOyvKXWqQESMwuUVnatlCnZdLBZZt2VSA==} + nuqs@2.8.9: resolution: {integrity: sha512-8ou6AEwsxMWSYo2qkfZtYFVzngwbKmg4c00HVxC1fF6CEJv3Fwm6eoZmfVPALB+vw8Udo7KL5uy96PFcYe1BIQ==} peerDependencies: @@ -4921,7 +4934,7 @@ snapshots: '@babel/types': 7.29.0 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@10.2.2) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -5079,7 +5092,7 @@ snapshots: '@babel/parser': 7.29.2 '@babel/template': 7.28.6 '@babel/types': 7.29.0 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -5189,7 +5202,7 @@ snapshots: '@eslint/config-array@0.21.1': dependencies: '@eslint/object-schema': 2.1.7 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@10.2.2) minimatch: 3.1.2 transitivePeerDependencies: - supports-color @@ -5197,7 +5210,7 @@ snapshots: '@eslint/config-array@0.21.2': dependencies: '@eslint/object-schema': 2.1.7 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@10.2.2) minimatch: 3.1.5 transitivePeerDependencies: - supports-color @@ -5213,7 +5226,7 @@ snapshots: '@eslint/eslintrc@3.3.1': dependencies: ajv: 6.12.6 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@10.2.2) espree: 10.4.0 globals: 14.0.0 ignore: 5.3.2 @@ -5227,7 +5240,7 @@ snapshots: '@eslint/eslintrc@3.3.5': dependencies: ajv: 6.14.0 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@10.2.2) espree: 10.4.0 globals: 14.0.0 ignore: 5.3.2 @@ -6557,7 +6570,7 @@ snapshots: '@typescript-eslint/types': 8.50.0 '@typescript-eslint/typescript-estree': 8.50.0(typescript@5.9.2) '@typescript-eslint/visitor-keys': 8.50.0 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@10.2.2) eslint: 9.39.1(jiti@2.6.1) typescript: 5.9.2 transitivePeerDependencies: @@ -6569,7 +6582,7 @@ snapshots: '@typescript-eslint/types': 8.50.0 '@typescript-eslint/typescript-estree': 8.50.0(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.50.0 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@10.2.2) eslint: 9.39.4(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: @@ -6579,7 +6592,7 @@ snapshots: dependencies: '@typescript-eslint/tsconfig-utils': 8.50.0(typescript@5.9.2) '@typescript-eslint/types': 8.50.0 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@10.2.2) typescript: 5.9.2 transitivePeerDependencies: - supports-color @@ -6588,7 +6601,7 @@ snapshots: dependencies: '@typescript-eslint/tsconfig-utils': 8.50.0(typescript@5.9.3) '@typescript-eslint/types': 8.50.0 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@10.2.2) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -6611,7 +6624,7 @@ snapshots: '@typescript-eslint/types': 8.50.0 '@typescript-eslint/typescript-estree': 8.50.0(typescript@5.9.2) '@typescript-eslint/utils': 8.50.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.2) - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@10.2.2) eslint: 9.39.1(jiti@2.6.1) ts-api-utils: 2.1.0(typescript@5.9.2) typescript: 5.9.2 @@ -6623,7 +6636,7 @@ snapshots: '@typescript-eslint/types': 8.50.0 '@typescript-eslint/typescript-estree': 8.50.0(typescript@5.9.3) '@typescript-eslint/utils': 8.50.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@10.2.2) eslint: 9.39.4(jiti@2.6.1) ts-api-utils: 2.1.0(typescript@5.9.3) typescript: 5.9.3 @@ -6638,7 +6651,7 @@ snapshots: '@typescript-eslint/tsconfig-utils': 8.50.0(typescript@5.9.2) '@typescript-eslint/types': 8.50.0 '@typescript-eslint/visitor-keys': 8.50.0 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@10.2.2) minimatch: 9.0.5 semver: 7.7.3 tinyglobby: 0.2.15 @@ -6653,7 +6666,7 @@ snapshots: '@typescript-eslint/tsconfig-utils': 8.50.0(typescript@5.9.3) '@typescript-eslint/types': 8.50.0 '@typescript-eslint/visitor-keys': 8.50.0 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@10.2.2) minimatch: 9.0.5 semver: 7.7.3 tinyglobby: 0.2.15 @@ -6936,7 +6949,7 @@ snapshots: dependencies: bytes: 3.1.2 content-type: 1.0.5 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@10.2.2) http-errors: 2.0.1 iconv-lite: 0.7.2 on-finished: 2.4.1 @@ -7529,7 +7542,7 @@ snapshots: eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)): dependencies: '@nolyfill/is-core-module': 1.0.39 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@10.2.2) eslint: 9.39.4(jiti@2.6.1) get-tsconfig: 4.13.7 is-bun-module: 2.0.0 @@ -7693,7 +7706,7 @@ snapshots: ajv: 6.12.6 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@10.2.2) escape-string-regexp: 4.0.0 eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 @@ -7734,7 +7747,7 @@ snapshots: ajv: 6.14.0 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@10.2.2) escape-string-regexp: 4.0.0 eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 @@ -7846,7 +7859,7 @@ snapshots: content-type: 1.0.5 cookie: 0.7.2 cookie-signature: 1.2.2 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@10.2.2) depd: 2.0.0 encodeurl: 2.0.0 escape-html: 1.0.3 @@ -7944,7 +7957,7 @@ snapshots: finalhandler@2.1.1: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@10.2.2) encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 @@ -8148,13 +8161,6 @@ snapshots: jsprim: 2.0.2 sshpk: 1.18.0 - https-proxy-agent@7.0.6: - dependencies: - agent-base: 7.1.4 - debug: 4.4.3(supports-color@8.1.1) - transitivePeerDependencies: - - supports-color - https-proxy-agent@7.0.6(supports-color@10.2.2): dependencies: agent-base: 7.1.4 @@ -8696,6 +8702,14 @@ snapshots: - '@babel/core' - babel-plugin-macros + nextjs-toploader@3.9.17(next@16.1.7(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + next: 16.1.7(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + nprogress: 0.2.0 + prop-types: 15.8.1 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + node-domexception@1.0.0: {} node-fetch@3.3.2: @@ -8715,6 +8729,8 @@ snapshots: path-key: 4.0.0 unicorn-magic: 0.3.0 + nprogress@0.2.0: {} + nuqs@2.8.9(next@16.1.7(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4): dependencies: '@standard-schema/spec': 1.0.0 @@ -9244,7 +9260,7 @@ snapshots: router@2.2.0: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@10.2.2) depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 @@ -9295,7 +9311,7 @@ snapshots: send@1.2.1: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@10.2.2) encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 @@ -9363,7 +9379,7 @@ snapshots: fast-glob: 3.3.3 fs-extra: 11.3.4 fuzzysort: 3.1.0 - https-proxy-agent: 7.0.6 + https-proxy-agent: 7.0.6(supports-color@10.2.2) kleur: 4.1.5 msw: 2.12.14(@types/node@25.5.0)(typescript@5.9.3) node-fetch: 3.3.2 From fcbba6247d06ae446c37c29c573be81dda80d93b Mon Sep 17 00:00:00 2001 From: humam kerdiah Date: Thu, 14 May 2026 12:21:01 +0400 Subject: [PATCH 3/5] feat: add document sharing functionality across various modules - Introduced ShareDocumentButton component for sharing documents. - Added ShareDocumentDialog for email and WhatsApp sharing options. - Integrated document sharing in estimates, invoices, inspections, job cards, bills, and purchase orders. - Implemented useDocumentShare hook for handling share logic. - Created DocumentShareClient for API interactions related to document sharing. - Updated layouts and actions to include sharing options for relevant entities. --- .../purchase/bill/[id]/layout.tsx | 2 + .../purchase/purchase-order/[id]/layout.tsx | 2 + .../sales/estimates/[id]/layout.tsx | 2 + .../sales/inspections/[id]/layout.tsx | 8 +- .../sales/invoice/[id]/layout.tsx | 2 + .../(authenticated)/sales/invoice/page.tsx | 13 +- .../job-cards/[id]/appointments/page.tsx | 28 ++- .../sales/job-cards/[id]/attachments/page.tsx | 73 ++++++- .../sales/job-cards/[id]/layout.tsx | 4 +- apps/dashboard/modules/bills/bill-actions.tsx | 11 +- .../create-job-card-from-estimate-button.tsx | 25 +-- .../modules/estimates/estimate-actions.tsx | 10 +- .../inspections/inspection-actions.tsx | 11 +- .../modules/invoices/invoice-actions.tsx | 11 +- .../modules/job-cards/job-card-dropdown.tsx | 37 +++- .../purchase-order-actions.tsx | 47 +++-- .../components/share-document-button.tsx | 28 +++ .../components/share-document-dialog.tsx | 181 ++++++++++++++++++ .../shared/hooks/use-document-share.ts | 63 ++++++ packages/api/src/api.ts | 2 + packages/api/src/clients/document-share.ts | 63 ++++++ packages/api/src/clients/index.ts | 8 + 22 files changed, 571 insertions(+), 60 deletions(-) create mode 100644 apps/dashboard/shared/components/share-document-button.tsx create mode 100644 apps/dashboard/shared/components/share-document-dialog.tsx create mode 100644 apps/dashboard/shared/hooks/use-document-share.ts create mode 100644 packages/api/src/clients/document-share.ts diff --git a/apps/dashboard/app/(authenticated)/purchase/bill/[id]/layout.tsx b/apps/dashboard/app/(authenticated)/purchase/bill/[id]/layout.tsx index 035551c..4455b05 100644 --- a/apps/dashboard/app/(authenticated)/purchase/bill/[id]/layout.tsx +++ b/apps/dashboard/app/(authenticated)/purchase/bill/[id]/layout.tsx @@ -3,6 +3,7 @@ import { getServerApi } from '@garage/api/server' import { BillActions } from '@/modules/bills/bill-actions' import { BillProvider, type BillResponse } from '@/modules/bills/bill-context' import BillStatusBadge from '@/modules/bills/bill-status-badge' +import { ShareDocumentButton } from '@/shared/components/share-document-button' import { ReceiptIcon } from 'lucide-react' import React from 'react' @@ -26,6 +27,7 @@ export default async function BillDetailLayout(props: { actions={
+
} diff --git a/apps/dashboard/app/(authenticated)/purchase/purchase-order/[id]/layout.tsx b/apps/dashboard/app/(authenticated)/purchase/purchase-order/[id]/layout.tsx index 8ce7e11..1137693 100644 --- a/apps/dashboard/app/(authenticated)/purchase/purchase-order/[id]/layout.tsx +++ b/apps/dashboard/app/(authenticated)/purchase/purchase-order/[id]/layout.tsx @@ -3,6 +3,7 @@ import { getServerApi } from '@garage/api/server' import { PurchaseOrderActions } from '@/modules/purchase-orders/purchase-order-actions' import { PurchaseOrderProvider } from '@/modules/purchase-orders/purchase-order-context' import { CreateBillFromPOButton } from '@/modules/purchase-orders/create-bill-from-po-button' +import { ShareDocumentButton } from '@/shared/components/share-document-button' import { ClipboardList } from 'lucide-react' import React from 'react' @@ -30,6 +31,7 @@ export default async function layout(props: { actions={
+
} diff --git a/apps/dashboard/app/(authenticated)/sales/estimates/[id]/layout.tsx b/apps/dashboard/app/(authenticated)/sales/estimates/[id]/layout.tsx index 757c18e..470b81c 100644 --- a/apps/dashboard/app/(authenticated)/sales/estimates/[id]/layout.tsx +++ b/apps/dashboard/app/(authenticated)/sales/estimates/[id]/layout.tsx @@ -4,6 +4,7 @@ import { EstimateActions } from '@/modules/estimates/estimate-actions' import { EstimateProvider } from '@/modules/estimates/estimate-context' import { CreateInvoiceFromEstimateButton } from '@/modules/estimates/create-invoice-from-estimate-button' import { CreateJobCardFromEstimateButton } from '@/modules/estimates/create-job-card-from-estimate-button' +import { ShareDocumentButton } from '@/shared/components/share-document-button' import { FileTextIcon } from 'lucide-react' import React from 'react' import { formatDate } from '@/shared/utils/formatters' @@ -40,6 +41,7 @@ export default async function layout(props: {
+
} diff --git a/apps/dashboard/app/(authenticated)/sales/inspections/[id]/layout.tsx b/apps/dashboard/app/(authenticated)/sales/inspections/[id]/layout.tsx index 0febe70..7d61449 100644 --- a/apps/dashboard/app/(authenticated)/sales/inspections/[id]/layout.tsx +++ b/apps/dashboard/app/(authenticated)/sales/inspections/[id]/layout.tsx @@ -2,6 +2,7 @@ import { DashboardDetailsPage } from '@/base/components/layout/dashboard' import { getServerApi } from '@garage/api/server' import { InspectionActions } from '@/modules/inspections/inspection-actions' import { InspectionProvider } from '@/modules/inspections/inspection-context' +import { ShareDocumentButton } from '@/shared/components/share-document-button' import React from 'react' export default async function layout(props: { @@ -23,7 +24,12 @@ export default async function layout(props: { title={title} description={orderNumber ? `Order: ${orderNumber}` : undefined} backHref="/sales/inspections" - actions={} + actions={ +
+ + +
+ } tabs={[ { href: `/sales/inspections/${id}`, diff --git a/apps/dashboard/app/(authenticated)/sales/invoice/[id]/layout.tsx b/apps/dashboard/app/(authenticated)/sales/invoice/[id]/layout.tsx index ea5d981..14b4bb0 100644 --- a/apps/dashboard/app/(authenticated)/sales/invoice/[id]/layout.tsx +++ b/apps/dashboard/app/(authenticated)/sales/invoice/[id]/layout.tsx @@ -5,6 +5,7 @@ import { InvoiceProvider } from '@/modules/invoices/invoice-context' import { ReceiptIcon } from 'lucide-react' import React from 'react' import InvoiceStatusBadge from '@/modules/invoices/invoice-status-badge' +import { ShareDocumentButton } from '@/shared/components/share-document-button' export default async function InvoiceDetailLayout(props: { params: Promise<{ id: string }>, children: React.ReactNode }) { const { id } = await props.params @@ -24,6 +25,7 @@ export default async function InvoiceDetailLayout(props: { params: Promise<{ id:
+
} diff --git a/apps/dashboard/app/(authenticated)/sales/invoice/page.tsx b/apps/dashboard/app/(authenticated)/sales/invoice/page.tsx index 6df39f5..25a6233 100644 --- a/apps/dashboard/app/(authenticated)/sales/invoice/page.tsx +++ b/apps/dashboard/app/(authenticated)/sales/invoice/page.tsx @@ -138,7 +138,7 @@ export default function InvoicesPage() { cell: ({ row }) => { const item = row.original as unknown as InvoiceItem const customerLabel = getCustomerLabel(item) - const subline = item.customer?.phone || item.customer?.company_name || "—" + const phone = item.customer?.phone return (
@@ -146,12 +146,19 @@ export default function InvoicesPage() { ) : ( -

{customerLabel}

+

+ {customerLabel} + {phone && ( + · {phone} + )} +

)} -

{subline}

) }, 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 4e577d8..309ca44 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 @@ -12,6 +12,8 @@ import type { AppointmentsClient } from "@garage/api" import { CalendarCheck2Icon, ClockIcon } from "lucide-react" 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" const STATUS_COLORS: Record = { requested: "bg-yellow-100 text-yellow-800", @@ -42,10 +44,28 @@ export default function JobCardAppointmentsPage({ router.replace(`${pathname}?${params.toString()}`) }, [pathname, router, searchParams]) - const defaultJobCard = jobCard - ? { value: String((jobCard as any).id), label: (jobCard as any).label || (jobCard as any).title || `Job Card` } + const jc = jobCard as any + const defaultJobCard = jc + ? { value: String(jc.id), label: jc.label || jc.title || `Job Card` } : null + const appointmentDefaults = jc + ? { + job_card_id: jc.id, + job_card_title: jc.title || `Job Card #${jc.id}`, + customer_id: jc.customer?.id, + customer_name: getFullName(jc.customer), + vehicle_id: jc.vehicle?.id, + vehicle_name: getVehicleLabel(jc.vehicle), + service_writer_id: jc.service_writer?.id, + service_writer_name: getFullName(jc.service_writer), + technician_id: jc.primary_technician?.id, + technician_name: getFullName(jc.primary_technician), + department_id: jc.department?.id, + department_name: jc.department?.name, + } + : { job_card: defaultJobCard } + return ( routeKey={APPOINTMENT_ROUTES.INDEX} @@ -59,8 +79,8 @@ export default function JobCardAppointmentsPage({ {(resourceId) => ( { closeDialog(); invalidateQuery() }} + initialData={selectedItem ?? appointmentDefaults} + onSuccess={() => { closeDialog(); invalidateQuery(); router.refresh() }} /> )} diff --git a/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/attachments/page.tsx b/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/attachments/page.tsx index a1dc17f..0317014 100644 --- a/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/attachments/page.tsx +++ b/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/attachments/page.tsx @@ -10,7 +10,7 @@ import { useAuthApi } from "@/shared/useApi" import { confirm } from "@/shared/components/confirm-dialog" import { Button } from "@/shared/components/ui/button" import { Card, CardContent } from "@/shared/components/ui/card" -import { JOB_CARD_ROUTES } from "@garage/api" +import { ApiError, JOB_CARD_ROUTES } from "@garage/api" import DashboardPage from "@/base/components/layout/dashboard/dashboard-page" import { useJobCard } from "@/modules/job-cards/job-card-context" import { CONSTANTS } from "@/config/constants" @@ -22,6 +22,62 @@ function getFileIcon(mimeType?: string) { return FileIcon } +const ALLOWED_HINT = "Allowed: images, PDF, Office docs, audio, video." + +function describeAttachmentError(rawMessage: string, filename: string): string { + const msg = rawMessage.toLowerCase() + if (msg.includes("must be a file of type") || msg.includes("mimes")) { + return `${filename}: unsupported file type. ${ALLOWED_HINT}` + } + if (msg.includes("may not be greater") || msg.includes("max")) { + return `${filename}: exceeds 5 MB limit.` + } + if (msg.includes("must be a file")) { + return `${filename}: invalid file.` + } + return `${filename}: ${rawMessage}` +} + +function showUploadError(err: unknown, files: File[]) { + if (!(err instanceof ApiError)) { + toast.error("Failed to upload attachment(s)") + return + } + + const validation = err.validationErrors + if (!validation || Object.keys(validation).length === 0) { + toast.error(err.payload?.message ?? "Failed to upload attachment(s)") + return + } + + const messages: string[] = [] + for (const [key, msgs] of Object.entries(validation)) { + const firstMsg = Array.isArray(msgs) ? msgs[0] : String(msgs) + if (!firstMsg) continue + + const match = key.match(/^attachments\.(\d+)$/) + if (match) { + const idx = Number(match[1]) + const filename = files[idx]?.name ?? `File #${idx + 1}` + messages.push(describeAttachmentError(firstMsg, filename)) + } else if (key === "attachments") { + messages.push(firstMsg) + } else { + messages.push(firstMsg) + } + } + + if (messages.length === 0) { + toast.error(err.payload?.message ?? "Failed to upload attachment(s)") + return + } + + const shown = messages.slice(0, 3) + const extra = messages.length - shown.length + shown.forEach((m) => toast.error(m)) + if (extra > 0) toast.error(`...and ${extra} more`) +} + export default function JobCardAttachmentsPage() { const { id: jobCardId } = useParams<{ id: string }>() const api = useAuthApi() @@ -66,18 +122,19 @@ export default function JobCardAttachmentsPage() { const files = e.target.files if (!files || files.length === 0) return + const fileList = Array.from(files) setIsUploading(true) - const promise = api.jobCards.addAttachment(jobCardId, Array.from(files)) - toast.promise(promise, { - loading: "Uploading attachment(s)...", - success: "Attachment(s) uploaded successfully", - error: "Failed to upload attachment(s)", - }) + const loadingToast = toast.loading("Uploading attachment(s)...") try { - await promise + await api.jobCards.addAttachment(jobCardId, fileList) + toast.dismiss(loadingToast) + toast.success("Attachment(s) uploaded successfully") queryClient.invalidateQueries({ queryKey }) startRefreshTransition(() => router.refresh()) + } catch (err) { + toast.dismiss(loadingToast) + showUploadError(err, fileList) } finally { setIsUploading(false) if (fileInputRef.current) { diff --git a/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/layout.tsx b/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/layout.tsx index 9b7d9d4..e3dfdc3 100644 --- a/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/layout.tsx +++ b/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/layout.tsx @@ -16,7 +16,7 @@ export default async function JobCardDetailLayout(props: { params: Promise<{ id: const title = jobCard?.title || 'Job Card Details' const status = jobCard?.status || 'draft' - const docs = jobCard?.documents + const attachmentsCount = jobCard?.attachment_files?.length ?? jobCard?.documents?.length ?? 0 return ( @@ -55,7 +55,7 @@ export default async function JobCardDetailLayout(props: { params: Promise<{ id: // TODO: Needs refactor from API side then refactor in frontend { href: `/sales/job-cards/${id}/attachments`, - label: `Attachments (${docs?.length || 0})` + label: `Attachments (${attachmentsCount})` }, { diff --git a/apps/dashboard/modules/bills/bill-actions.tsx b/apps/dashboard/modules/bills/bill-actions.tsx index 2bd9095..150aff1 100644 --- a/apps/dashboard/modules/bills/bill-actions.tsx +++ b/apps/dashboard/modules/bills/bill-actions.tsx @@ -11,9 +11,11 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/shared/components/ui/dropdown-menu" -import { Ellipsis, Pencil, Trash2 } from "lucide-react" +import { Ellipsis, Pencil, 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" type BillActionsProps = { billId: string @@ -23,6 +25,7 @@ export function BillActions({ billId }: BillActionsProps) { const api = useAuthApi() const router = useRouter() const editDialog = useFormDialog("bill-details-edit") + const [shareOpen, setShareOpen] = useState(false) const handleDelete = async () => { await api.bills.destroy(billId) @@ -42,6 +45,10 @@ export function BillActions({ billId }: BillActionsProps) { Edit + setShareOpen(true)}> + + Share + Delete @@ -49,6 +56,8 @@ export function BillActions({ billId }: BillActionsProps) { + + { if (!v) editDialog.close() }}> diff --git a/apps/dashboard/modules/estimates/create-job-card-from-estimate-button.tsx b/apps/dashboard/modules/estimates/create-job-card-from-estimate-button.tsx index dbd38f1..b56ff36 100644 --- a/apps/dashboard/modules/estimates/create-job-card-from-estimate-button.tsx +++ b/apps/dashboard/modules/estimates/create-job-card-from-estimate-button.tsx @@ -1,7 +1,7 @@ "use client" import { useState } from "react" -import { ClipboardList } from "lucide-react" +import { ClipboardList, Loader2 } from "lucide-react" import { useRouter } from "next/navigation" import { ApiError } from "@garage/api" import { Button } from "@/shared/components/ui/button" @@ -30,34 +30,37 @@ export function CreateJobCardFromEstimateButton() { if (!confirmed) return setIsConverting(true) + const promise = api.estimates.convertToJobCard(estimateId, {}) + toast.promise(promise, { + loading: "Generating job card...", + success: "Estimate converted to job card successfully", + error: (error: unknown) => + error instanceof ApiError && error.status === 409 + ? "A job card already exists for this estimate." + : "Failed to convert estimate to job card", + }) try { - const response = await api.estimates.convertToJobCard(estimateId, {}) + const response = await promise const jobCardId = response?.data?.id - - toast.success("Estimate converted to job card successfully") - if (jobCardId) { router.push(`/sales/job-cards/${jobCardId}`) + return } } catch (error) { if (error instanceof ApiError && error.status === 409) { const jobCardId = (error.payload?.data as { job_card_id?: number } | undefined)?.job_card_id - toast.info("A job card already exists for this estimate.") if (jobCardId) { router.push(`/sales/job-cards/${jobCardId}`) return } } - - toast.error("Failed to convert estimate to job card") - } finally { - setIsConverting(false) } + setIsConverting(false) } return ( ) diff --git a/apps/dashboard/modules/estimates/estimate-actions.tsx b/apps/dashboard/modules/estimates/estimate-actions.tsx index 2dc1b28..90ad086 100644 --- a/apps/dashboard/modules/estimates/estimate-actions.tsx +++ b/apps/dashboard/modules/estimates/estimate-actions.tsx @@ -24,7 +24,7 @@ import { SelectTrigger, SelectValue, } from "@/shared/components/ui/select" -import { Ellipsis, Pencil, Trash2, ShieldCheck, Check, X, Printer } from "lucide-react" +import { Ellipsis, Pencil, Trash2, ShieldCheck, Check, X, Printer, Share2 } from "lucide-react" import { useState } from "react" import { useMutation, useQuery } from "@tanstack/react-query" import { toast } from "sonner" @@ -34,6 +34,7 @@ import { DatePickerField, TimePickerField } from "@/shared/components/form" import { cn } from "@/shared/lib/utils" import { EmployeeCombobox, type EmployeeOption } from "../employees/employee-combobox" import { useDocumentPrint } from "@/shared/hooks/use-document-print" +import { ShareDocumentDialog } from "@/shared/components/share-document-dialog" type EstimateActionsProps = { estimateId: string @@ -135,6 +136,7 @@ export function EstimateActions({ estimateId }: EstimateActionsProps) { const { print, isPrinting } = useDocumentPrint() const [editOpen, setEditOpen] = useState(false) const [authOpen, setAuthOpen] = useState(false) + const [shareOpen, setShareOpen] = useState(false) const [itemStatuses, setItemStatuses] = useState>({}) const [authMethod, setAuthMethod] = useState("in_person") const [employee, setEmployee] = useState(null) @@ -224,6 +226,10 @@ export function EstimateActions({ estimateId }: EstimateActionsProps) { {isPrinting ? "Printing..." : "Print"} + setShareOpen(true)}> + + Share + Store Authorisation @@ -235,6 +241,8 @@ export function EstimateActions({ estimateId }: EstimateActionsProps) { + + {/* Edit Dialog */} diff --git a/apps/dashboard/modules/inspections/inspection-actions.tsx b/apps/dashboard/modules/inspections/inspection-actions.tsx index 110467c..5fc3c64 100644 --- a/apps/dashboard/modules/inspections/inspection-actions.tsx +++ b/apps/dashboard/modules/inspections/inspection-actions.tsx @@ -12,11 +12,13 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/shared/components/ui/dropdown-menu" -import { Ellipsis, Pencil, Trash2, Play, CheckCircle2, Printer } from "lucide-react" +import { Ellipsis, Pencil, Trash2, Play, CheckCircle2, Printer, Share2 } from "lucide-react" +import { useState } from "react" import { toast } from "sonner" import { useFormDialog } from "@/shared/components/form-dialog" import { InspectionForm } from "./inspection-form" import { useDocumentPrint } from "@/shared/hooks/use-document-print" +import { ShareDocumentDialog } from "@/shared/components/share-document-dialog" type InspectionActionsProps = { inspectionId: string @@ -34,6 +36,7 @@ export function InspectionActions({ inspectionId, status, onStatusChange }: Insp const router = useRouter() const editDialog = useFormDialog("inspection-details-edit") const { print, isPrinting } = useDocumentPrint() + const [shareOpen, setShareOpen] = useState(false) const handleDelete = async () => { const promise = api.inspections.destroy(inspectionId) @@ -79,6 +82,10 @@ export function InspectionActions({ inspectionId, status, onStatusChange }: Insp {isPrinting ? "Printing..." : "Print"} + setShareOpen(true)}> + + Share + {transition && ( handleStatusChange(transition.next)}> @@ -93,6 +100,8 @@ export function InspectionActions({ inspectionId, status, onStatusChange }: Insp + + { if (!v) editDialog.close() }}> diff --git a/apps/dashboard/modules/invoices/invoice-actions.tsx b/apps/dashboard/modules/invoices/invoice-actions.tsx index f8f58ec..7231f35 100644 --- a/apps/dashboard/modules/invoices/invoice-actions.tsx +++ b/apps/dashboard/modules/invoices/invoice-actions.tsx @@ -1,5 +1,6 @@ "use client" +import { useState } from "react" import { useAuthApi } from "@/shared/useApi" import { useRouter } from "next/navigation" import { Button } from "@/shared/components/ui/button" @@ -9,8 +10,9 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/shared/components/ui/dropdown-menu" -import { Ellipsis, Printer, Trash2 } from "lucide-react" +import { Ellipsis, Printer, Share2, Trash2 } from "lucide-react" import { useDocumentPrint } from "@/shared/hooks/use-document-print" +import { ShareDocumentDialog } from "@/shared/components/share-document-dialog" type InvoiceActionsProps = { invoiceId: string @@ -20,6 +22,7 @@ export function InvoiceActions({ invoiceId }: InvoiceActionsProps) { const api = useAuthApi() const router = useRouter() const { print, isPrinting } = useDocumentPrint() + const [shareOpen, setShareOpen] = useState(false) const handleDelete = async () => { await api.invoices.destroy(invoiceId) @@ -39,12 +42,18 @@ export function InvoiceActions({ invoiceId }: InvoiceActionsProps) { {isPrinting ? "Printing..." : "Print"} + setShareOpen(true)}> + + Share + Delete + + ) } diff --git a/apps/dashboard/modules/job-cards/job-card-dropdown.tsx b/apps/dashboard/modules/job-cards/job-card-dropdown.tsx index 188fbb2..fe0f95c 100644 --- a/apps/dashboard/modules/job-cards/job-card-dropdown.tsx +++ b/apps/dashboard/modules/job-cards/job-card-dropdown.tsx @@ -5,11 +5,13 @@ import { confirm } from '@/shared/components/confirm-dialog'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/shared/components/ui/dropdown-menu' import { Button } from '@/shared/components/ui/button' import { toast } from 'sonner' -import { CalendarPlus, Ellipsis, FileText, Pencil, Printer, Trash2 } from 'lucide-react'; +import { CalendarPlus, Ellipsis, FileText, Loader2, Pencil, Printer, Share2, Trash2 } from 'lucide-react'; import { useDocumentPrint } from '@/shared/hooks/use-document-print'; import { useJobCard } from './job-card-context'; import { useState } from 'react'; import { useAuthApi } from '@/shared/useApi'; +import { ShareDocumentDialog } from '@/shared/components/share-document-dialog'; +import { ShareDocumentButton } from '@/shared/components/share-document-button'; // TODO: setting a sales person not working // TODO: unable to set a Primary technician for the job card. Need to investigate and fix it. @@ -21,6 +23,7 @@ export default function JobCardDropdown({ id }: { id: string }) { const { print, isPrinting } = useDocumentPrint() const jobCard = useJobCard() const [isConverting, setIsConverting] = useState(false) + const [shareOpen, setShareOpen] = useState(false) const handleEdit = () => { router.push(`/sales/job-cards/${id}/edit`) @@ -43,24 +46,32 @@ export default function JobCardDropdown({ id }: { id: string }) { if (!confirmed) return setIsConverting(true) + const promise = api.jobCards.convertToInvoice(id, {}) as Promise + toast.promise(promise, { + loading: "Converting job card to invoice...", + success: "Job card converted to invoice successfully", + error: (err: any) => { + const conflictId = err?.response?.data?.data?.invoice_id ?? err?.data?.data?.invoice_id + return conflictId + ? "An invoice already exists for this job card." + : (err?.message || "Failed to convert job card to invoice") + }, + }) try { - const res = await api.jobCards.convertToInvoice(id, {}) as any + const res = await promise const invoiceId = res?.data?.id - toast.success("Job card converted to invoice successfully") if (invoiceId) { router.push(`/sales/invoice/${invoiceId}`) + return } } catch (err: any) { const conflictId = err?.response?.data?.data?.invoice_id ?? err?.data?.data?.invoice_id if (conflictId) { - toast.info("An invoice already exists for this job card.") router.push(`/sales/invoice/${conflictId}`) - } else { - toast.error(err?.message || "Failed to convert job card to invoice") + return } - } finally { - setIsConverting(false) } + setIsConverting(false) } const handleDelete = async () => { @@ -85,7 +96,7 @@ export default function JobCardDropdown({ id }: { id: string }) {
{jobCard?.status !== "draft" && ( )} @@ -95,6 +106,8 @@ export default function JobCardDropdown({ id }: { id: string }) { Create Appointment + +
) } \ No newline at end of file diff --git a/apps/dashboard/modules/purchase-orders/purchase-order-actions.tsx b/apps/dashboard/modules/purchase-orders/purchase-order-actions.tsx index e786941..26c8c58 100644 --- a/apps/dashboard/modules/purchase-orders/purchase-order-actions.tsx +++ b/apps/dashboard/modules/purchase-orders/purchase-order-actions.tsx @@ -1,5 +1,6 @@ "use client" +import { useState } from "react" import { useAuthApi } from "@/shared/useApi" import { useRouter } from "next/navigation" import { Button } from "@/shared/components/ui/button" @@ -10,9 +11,10 @@ import { DropdownMenuTrigger, } from "@/shared/components/ui/dropdown-menu" import { confirm } from "@/shared/components/confirm-dialog" -import { Ellipsis, Printer, Trash2 } from "lucide-react" +import { Ellipsis, Printer, Share2, Trash2 } from "lucide-react" import { toast } from "sonner" import { useDocumentPrint } from "@/shared/hooks/use-document-print" +import { ShareDocumentDialog } from "@/shared/components/share-document-dialog" type PurchaseOrderActionsProps = { purchaseOrderId: string @@ -22,6 +24,7 @@ export function PurchaseOrderActions({ purchaseOrderId }: PurchaseOrderActionsPr const api = useAuthApi() const router = useRouter() const { print, isPrinting } = useDocumentPrint() + const [shareOpen, setShareOpen] = useState(false) const handleDelete = async () => { const confirmed = await confirm({ @@ -43,22 +46,30 @@ export function PurchaseOrderActions({ purchaseOrderId }: PurchaseOrderActionsPr } return ( - - - - - - print("purchase_order", purchaseOrderId, "print")} disabled={isPrinting}> - - {isPrinting ? "Printing..." : "Print"} - - - - Delete - - - + <> + + + + + + print("purchase_order", purchaseOrderId, "print")} disabled={isPrinting}> + + {isPrinting ? "Printing..." : "Print"} + + setShareOpen(true)}> + + Share + + + + Delete + + + + + + ) } diff --git a/apps/dashboard/shared/components/share-document-button.tsx b/apps/dashboard/shared/components/share-document-button.tsx new file mode 100644 index 0000000..78ac222 --- /dev/null +++ b/apps/dashboard/shared/components/share-document-button.tsx @@ -0,0 +1,28 @@ +"use client" + +import { useState } from "react" +import { Share2 } from "lucide-react" +import type { DocumentShareType } from "@garage/api" +import { Button } from "@/shared/components/ui/button" +import { ShareDocumentDialog } from "@/shared/components/share-document-dialog" + +interface ShareDocumentButtonProps { + type: DocumentShareType + id: string | number + label?: string + variant?: "default" | "outline" | "ghost" | "secondary" +} + +export function ShareDocumentButton({ type, id, label = "Share", variant = "outline" }: ShareDocumentButtonProps) { + const [open, setOpen] = useState(false) + + return ( + <> + + + + ) +} diff --git a/apps/dashboard/shared/components/share-document-dialog.tsx b/apps/dashboard/shared/components/share-document-dialog.tsx new file mode 100644 index 0000000..cd091d6 --- /dev/null +++ b/apps/dashboard/shared/components/share-document-dialog.tsx @@ -0,0 +1,181 @@ +"use client" + +import { useEffect, useState } from "react" +import type { DocumentShareType } from "@garage/api" +import { Button } from "@/shared/components/ui/button" +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/shared/components/ui/dialog" +import { Input } from "@/shared/components/ui/input" +import { Label } from "@/shared/components/ui/label" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/components/ui/tabs" +import { Textarea } from "@/shared/components/ui/textarea" +import { useDocumentShare } from "@/shared/hooks/use-document-share" + +interface ShareDocumentDialogProps { + type: DocumentShareType + id: string | number + open: boolean + onOpenChange: (open: boolean) => void +} + +const DEFAULT_MESSAGES: Record = { + estimate: { + email: "Hello, please find your estimate at the link below. Kindly review the details and let us know if you'd like to proceed.", + whatsapp: "Hello, here is your estimate. Please review and let us know if you'd like to proceed.", + }, + invoice: { + email: "Hello, please find your invoice at the link below. Let us know once payment has been arranged.", + whatsapp: "Hello, here is your invoice. Let us know once payment is arranged.", + }, + job_card: { + email: "Hello, please find the job card for your vehicle at the link below.", + whatsapp: "Hello, here is the job card for your vehicle.", + }, + inspection: { + email: "Hello, please find your vehicle inspection report at the link below.", + whatsapp: "Hello, here is your vehicle inspection report.", + }, + payment_received: { + email: "Hello, please find your payment receipt at the link below. Thank you.", + whatsapp: "Hello, here is your payment receipt. Thank you.", + }, + purchase_order: { + email: "Hello, please find our purchase order at the link below. Kindly confirm receipt.", + whatsapp: "Hello, here is our purchase order. Kindly confirm receipt.", + }, + bill: { + email: "Hello, please find the bill at the link below for your reference.", + whatsapp: "Hello, here is the bill for your reference.", + }, + expense: { + email: "Hello, please find the expense document at the link below.", + whatsapp: "Hello, here is the expense document.", + }, + payment_made: { + email: "Hello, please find the payment confirmation at the link below.", + whatsapp: "Hello, here is the payment confirmation.", + }, +} + +function getDefaultMessage(type: DocumentShareType, channel: "email" | "whatsapp"): string { + return DEFAULT_MESSAGES[type as string]?.[channel] ?? "" +} + +export function ShareDocumentDialog({ type, id, open, onOpenChange }: ShareDocumentDialogProps) { + const { shareEmail, shareWhatsapp, isSharing } = useDocumentShare(type, id) + const [email, setEmail] = useState("") + const [phone, setPhone] = useState("") + const [emailMessage, setEmailMessage] = useState("") + const [whatsappMessage, setWhatsappMessage] = useState("") + + useEffect(() => { + if (open) { + setEmailMessage(getDefaultMessage(type, "email")) + setWhatsappMessage(getDefaultMessage(type, "whatsapp")) + } + }, [open, type]) + + const close = () => { + onOpenChange(false) + setEmail("") + setPhone("") + setEmailMessage("") + setWhatsappMessage("") + } + + const handleEmail = async () => { + if (!email) return + await shareEmail({ email, message: emailMessage || undefined }) + close() + } + + const handleWhatsapp = async () => { + await shareWhatsapp({ phone: phone || undefined, message: whatsappMessage || undefined }) + close() + } + + return ( + + + + Share document + + + + Email + WhatsApp + + +
+ + setEmail(e.target.value)} + placeholder="customer@example.com" + /> +
+
+ +