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) {
+
+
+ setShareOpen(true)}>
+
+ Share
+
Store Authorisation
@@ -235,6 +241,8 @@ export function EstimateActions({ estimateId }: EstimateActionsProps) {
+
+
{/* Edit Dialog */}
+ setShareOpen(true)}>
+
+ Share
+
{transition && (
handleStatusChange(transition.next)}>
@@ -93,6 +100,8 @@ export function InspectionActions({ inspectionId, status, onStatusChange }: Insp
+
+
+ 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 (
+
+ )
+}
diff --git a/apps/dashboard/shared/hooks/use-document-share.ts b/apps/dashboard/shared/hooks/use-document-share.ts
new file mode 100644
index 0000000..36483e6
--- /dev/null
+++ b/apps/dashboard/shared/hooks/use-document-share.ts
@@ -0,0 +1,63 @@
+"use client"
+
+import { useState } from "react"
+import { toast } from "sonner"
+import type { DocumentShareResponse, DocumentShareType } from "@garage/api"
+import { useAuthApi } from "../useApi"
+
+interface UseDocumentShareOptions {
+ onSuccess?: (result: DocumentShareResponse) => void
+ onError?: (error: Error) => void
+}
+
+export function useDocumentShare(type: DocumentShareType, id: string | number, options?: UseDocumentShareOptions) {
+ const api = useAuthApi()
+ const [isSharing, setIsSharing] = useState(false)
+
+ const shareEmail = async (input: { email: string; message?: string }) => {
+ setIsSharing(true)
+ try {
+ const result = await api.documentShare.share({
+ document_type: type,
+ document_id: id,
+ channel: "email",
+ email: input.email,
+ message: input.message,
+ })
+ toast.success("Email sent. Link expires in 24 hours.")
+ options?.onSuccess?.(result)
+ return result
+ } catch (error) {
+ toast.error("Failed to send email")
+ options?.onError?.(error as Error)
+ throw error
+ } finally {
+ setIsSharing(false)
+ }
+ }
+
+ const shareWhatsapp = async (input: { phone?: string; message?: string }) => {
+ setIsSharing(true)
+ try {
+ const result = await api.documentShare.share({
+ document_type: type,
+ document_id: id,
+ channel: "link",
+ phone: input.phone,
+ message: input.message,
+ })
+ window.open(result.whatsapp_url, "_blank", "noopener,noreferrer")
+ toast.success("Share link ready. Expires in 24 hours.")
+ options?.onSuccess?.(result)
+ return result
+ } catch (error) {
+ toast.error("Failed to create share link")
+ options?.onError?.(error as Error)
+ throw error
+ } finally {
+ setIsSharing(false)
+ }
+ }
+
+ return { shareEmail, shareWhatsapp, isSharing }
+}
diff --git a/packages/api/src/api.ts b/packages/api/src/api.ts
index b547622..e5514e6 100644
--- a/packages/api/src/api.ts
+++ b/packages/api/src/api.ts
@@ -57,6 +57,7 @@ import { AutoGenerateClient } from "./clients/auto-generate"
import { ExpenseItemsClient } from "./clients/expense-items"
import { InventoryCategoriesClient } from "./clients/inventory-categories"
import { DocumentPrintClient } from "./clients/document-print"
+import { DocumentShareClient } from "./clients/document-share"
export function createApi(options?: ApiClientOptions) {
return {
@@ -118,6 +119,7 @@ export function createApi(options?: ApiClientOptions) {
expenseItems: new ExpenseItemsClient(undefined, options),
inventoryCategories: new InventoryCategoriesClient(undefined, options),
documentPrint: new DocumentPrintClient(undefined, options),
+ documentShare: new DocumentShareClient(undefined, options),
}
}
diff --git a/packages/api/src/clients/document-share.ts b/packages/api/src/clients/document-share.ts
new file mode 100644
index 0000000..00f546e
--- /dev/null
+++ b/packages/api/src/clients/document-share.ts
@@ -0,0 +1,63 @@
+import { ApiClient, ApiError, type ApiClientOptions } from "../infra/client"
+import type { ApiPath } from "../infra/types"
+
+export const DOCUMENT_SHARE_ROUTES = {
+ STORE: "/api/documents/share",
+} as const satisfies Record
+
+export type DocumentShareType =
+ | "inspection"
+ | "estimate"
+ | "job_card"
+ | "invoice"
+ | "payment_received"
+ | "purchase_order"
+ | "bill"
+ | string
+
+export type DocumentShareChannel = "email" | "link"
+
+export interface DocumentSharePayload {
+ document_type: DocumentShareType
+ document_id: number | string
+ channel: DocumentShareChannel
+ email?: string
+ phone?: string
+ message?: string
+}
+
+export interface DocumentShareResponse {
+ share_url: string
+ whatsapp_url: string
+ expires_at: string | null
+}
+
+export class DocumentShareClient extends ApiClient {
+ constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
+ super(baseUrl, defaultOptions)
+ }
+
+ async share(payload: DocumentSharePayload): Promise {
+ const endpoint = DOCUMENT_SHARE_ROUTES.STORE
+ const url = `${this.baseUrl.replace(/\/+$/, "")}${endpoint}`
+ const headers = new Headers(this.defaultOptions.headers as Record)
+ headers.set("Accept", "application/json")
+ headers.set("Content-Type", "application/json")
+
+ const body = { ...payload, document_id: Number(payload.document_id) }
+ const response = await fetch(url, {
+ method: "POST",
+ headers,
+ body: JSON.stringify(body),
+ })
+
+ const text = await response.text()
+ const data = text ? JSON.parse(text) : null
+
+ if (!response.ok) {
+ throw new ApiError(response.status, response.statusText, endpoint, "post", data)
+ }
+
+ return data as DocumentShareResponse
+ }
+}
diff --git a/packages/api/src/clients/index.ts b/packages/api/src/clients/index.ts
index 6efa2a2..3726b02 100644
--- a/packages/api/src/clients/index.ts
+++ b/packages/api/src/clients/index.ts
@@ -46,6 +46,14 @@ export {
export { BillsClient, BILL_ROUTES } from "./bills"
export { ReasonsClient, REASON_ROUTES } from "./reasons"
export { DocumentPrintClient, DOCUMENT_PRINT_ROUTES, type DocumentPrintType, type DocumentPrintMode } from "./document-print"
+export {
+ DocumentShareClient,
+ DOCUMENT_SHARE_ROUTES,
+ type DocumentShareType,
+ type DocumentShareChannel,
+ type DocumentSharePayload,
+ type DocumentShareResponse,
+} from "./document-share"
export { HolidaysClient, HOLIDAY_ROUTES } from "./holidays"
export { MakeAndModelsClient, MAKE_AND_MODEL_ROUTES } from "./make-and-models"
export { TimeSheetsClient, TIME_SHEET_ROUTES } from "./time-sheets"