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.
This commit is contained in:
parent
05b55b5721
commit
fcbba6247d
@ -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={
|
||||
<div className="flex space-x-2 items-center">
|
||||
<BillStatusBadge bill={{id, status:data?.status}} />
|
||||
<ShareDocumentButton type="bill" id={id} />
|
||||
<BillActions billId={id} />
|
||||
</div>
|
||||
}
|
||||
|
||||
@ -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={
|
||||
<div className="flex items-center gap-2">
|
||||
<CreateBillFromPOButton />
|
||||
<ShareDocumentButton type="purchase_order" id={id} />
|
||||
<PurchaseOrderActions purchaseOrderId={id} />
|
||||
</div>
|
||||
}
|
||||
|
||||
@ -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: {
|
||||
<div className="flex items-center gap-2">
|
||||
<CreateInvoiceFromEstimateButton />
|
||||
<CreateJobCardFromEstimateButton />
|
||||
<ShareDocumentButton type="estimate" id={id} />
|
||||
<EstimateActions estimateId={id} />
|
||||
</div>
|
||||
}
|
||||
|
||||
@ -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={<InspectionActions inspectionId={id} status={status} />}
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<ShareDocumentButton type="inspection" id={id} />
|
||||
<InspectionActions inspectionId={id} status={status} />
|
||||
</div>
|
||||
}
|
||||
tabs={[
|
||||
{
|
||||
href: `/sales/inspections/${id}`,
|
||||
|
||||
@ -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:
|
||||
<div className="flex space-x-2 items-center">
|
||||
|
||||
<InvoiceStatusBadge invoice={{id, status:data?.status}} />
|
||||
<ShareDocumentButton type="invoice" id={id} />
|
||||
<InvoiceActions invoiceId={id} />
|
||||
</div>
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
<div className="min-w-[190px]">
|
||||
@ -146,12 +146,19 @@ export default function InvoicesPage() {
|
||||
<Button variant="outline" asChild size="sm" onClick={(e) => e.stopPropagation()}>
|
||||
<Link href={`/sales/customers/${item.customer.id}`}>
|
||||
<UserIcon /> {customerLabel}
|
||||
{phone && (
|
||||
<span className="ms-1 text-muted-foreground">· {phone}</span>
|
||||
)}
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<p className="font-medium leading-none">{customerLabel}</p>
|
||||
<p className="font-medium leading-none">
|
||||
{customerLabel}
|
||||
{phone && (
|
||||
<span className="ms-1 text-muted-foreground">· {phone}</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-muted-foreground">{subline}</p>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
|
||||
@ -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<string, string> = {
|
||||
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 (
|
||||
<ResourcePage<AppointmentsClient>
|
||||
routeKey={APPOINTMENT_ROUTES.INDEX}
|
||||
@ -59,8 +79,8 @@ export default function JobCardAppointmentsPage({
|
||||
{(resourceId) => (
|
||||
<AppointmentForm
|
||||
resourceId={resourceId}
|
||||
initialData={selectedItem ?? { job_card: defaultJobCard }}
|
||||
onSuccess={() => { closeDialog(); invalidateQuery() }}
|
||||
initialData={selectedItem ?? appointmentDefaults}
|
||||
onSuccess={() => { closeDialog(); invalidateQuery(); router.refresh() }}
|
||||
/>
|
||||
)}
|
||||
</FormDialog>
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 (
|
||||
<JobCardProvider jobCard={{ ...jobCard }}>
|
||||
@ -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})`
|
||||
},
|
||||
|
||||
{
|
||||
|
||||
@ -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) {
|
||||
<Pencil className="size-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setShareOpen(true)}>
|
||||
<Share2 className="size-4" />
|
||||
Share
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
|
||||
<Trash2 className="size-4" />
|
||||
Delete
|
||||
@ -49,6 +56,8 @@ export function BillActions({ billId }: BillActionsProps) {
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<ShareDocumentDialog type="bill" id={billId} open={shareOpen} onOpenChange={setShareOpen} />
|
||||
|
||||
<Dialog open={editDialog.isOpen} onOpenChange={(v) => { if (!v) editDialog.close() }}>
|
||||
<DialogContent className="min-w-xl lg:min-w-4xl">
|
||||
<DialogHeader>
|
||||
|
||||
@ -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 (
|
||||
<Button variant="outline" size="sm" onClick={handleConvert} disabled={isConverting}>
|
||||
<ClipboardList className="me-2 size-4" />
|
||||
{isConverting ? <Loader2 className="me-2 size-4 animate-spin" /> : <ClipboardList className="me-2 size-4" />}
|
||||
{isConverting ? "Generating..." : "Generate Job Card"}
|
||||
</Button>
|
||||
)
|
||||
|
||||
@ -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<Record<string, string>>({})
|
||||
const [authMethod, setAuthMethod] = useState("in_person")
|
||||
const [employee, setEmployee] = useState<EmployeeOption | null>(null)
|
||||
@ -224,6 +226,10 @@ export function EstimateActions({ estimateId }: EstimateActionsProps) {
|
||||
<Printer className="size-4" />
|
||||
{isPrinting ? "Printing..." : "Print"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setShareOpen(true)}>
|
||||
<Share2 className="size-4" />
|
||||
Share
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={openAuthDialog}>
|
||||
<ShieldCheck className="size-4" />
|
||||
Store Authorisation
|
||||
@ -235,6 +241,8 @@ export function EstimateActions({ estimateId }: EstimateActionsProps) {
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<ShareDocumentDialog type="estimate" id={estimateId} open={shareOpen} onOpenChange={setShareOpen} />
|
||||
|
||||
{/* Edit Dialog */}
|
||||
<Dialog open={editOpen} onOpenChange={setEditOpen}>
|
||||
<DialogContent className="min-w-xl">
|
||||
|
||||
@ -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
|
||||
<Printer className="size-4" />
|
||||
{isPrinting ? "Printing..." : "Print"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setShareOpen(true)}>
|
||||
<Share2 className="size-4" />
|
||||
Share
|
||||
</DropdownMenuItem>
|
||||
{transition && (
|
||||
<DropdownMenuItem onClick={() => handleStatusChange(transition.next)}>
|
||||
<transition.icon className="size-4" />
|
||||
@ -93,6 +100,8 @@ export function InspectionActions({ inspectionId, status, onStatusChange }: Insp
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<ShareDocumentDialog type="inspection" id={inspectionId} open={shareOpen} onOpenChange={setShareOpen} />
|
||||
|
||||
<Dialog open={editDialog.isOpen} onOpenChange={(v) => { if (!v) editDialog.close() }}>
|
||||
<DialogContent className="min-w-xl lg:min-w-4xl">
|
||||
<DialogHeader>
|
||||
|
||||
@ -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) {
|
||||
<Printer className="size-4" />
|
||||
{isPrinting ? "Printing..." : "Print"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setShareOpen(true)}>
|
||||
<Share2 className="size-4" />
|
||||
Share
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
|
||||
<Trash2 className="size-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<ShareDocumentDialog type="invoice" id={invoiceId} open={shareOpen} onOpenChange={setShareOpen} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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,25 +46,33 @@ export default function JobCardDropdown({ id }: { id: string }) {
|
||||
if (!confirmed) return
|
||||
|
||||
setIsConverting(true)
|
||||
const promise = api.jobCards.convertToInvoice(id, {}) as Promise<any>
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
const confirmed = await confirm({
|
||||
@ -85,7 +96,7 @@ export default function JobCardDropdown({ id }: { id: string }) {
|
||||
<div className="flex items-center gap-2">
|
||||
{jobCard?.status !== "draft" && (
|
||||
<Button variant="outline" onClick={handleConvertToInvoice} disabled={isConverting}>
|
||||
<FileText className="size-4" />
|
||||
{isConverting ? <Loader2 className="size-4 animate-spin" /> : <FileText className="size-4" />}
|
||||
{isConverting ? "Converting..." : "Convert to Invoice"}
|
||||
</Button>
|
||||
)}
|
||||
@ -95,6 +106,8 @@ export default function JobCardDropdown({ id }: { id: string }) {
|
||||
Create Appointment
|
||||
</Button>
|
||||
|
||||
<ShareDocumentButton type="job_card" id={id} />
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="self-stretch h-auto aspect-square">
|
||||
@ -110,6 +123,10 @@ export default function JobCardDropdown({ id }: { id: string }) {
|
||||
<Printer className="size-4" />
|
||||
{isPrinting ? "Printing..." : "Print"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setShareOpen(true)}>
|
||||
<Share2 className="size-4" />
|
||||
Share
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleCreateAppointment}>
|
||||
<CalendarPlus className="size-4" />
|
||||
@ -128,6 +145,8 @@ export default function JobCardDropdown({ id }: { id: string }) {
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<ShareDocumentDialog type="job_card" id={id} open={shareOpen} onOpenChange={setShareOpen} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -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,6 +46,7 @@ export function PurchaseOrderActions({ purchaseOrderId }: PurchaseOrderActionsPr
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
@ -54,11 +58,18 @@ export function PurchaseOrderActions({ purchaseOrderId }: PurchaseOrderActionsPr
|
||||
<Printer className="size-4" />
|
||||
{isPrinting ? "Printing..." : "Print"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setShareOpen(true)}>
|
||||
<Share2 className="size-4" />
|
||||
Share
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
|
||||
<Trash2 className="size-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<ShareDocumentDialog type="purchase_order" id={purchaseOrderId} open={shareOpen} onOpenChange={setShareOpen} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
28
apps/dashboard/shared/components/share-document-button.tsx
Normal file
28
apps/dashboard/shared/components/share-document-button.tsx
Normal file
@ -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 (
|
||||
<>
|
||||
<Button variant={variant} onClick={() => setOpen(true)}>
|
||||
<Share2 className="size-4" />
|
||||
{label}
|
||||
</Button>
|
||||
<ShareDocumentDialog type={type} id={id} open={open} onOpenChange={setOpen} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
181
apps/dashboard/shared/components/share-document-dialog.tsx
Normal file
181
apps/dashboard/shared/components/share-document-dialog.tsx
Normal file
@ -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<string, { email: string; whatsapp: string }> = {
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Share document</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Tabs defaultValue="email" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="email">Email</TabsTrigger>
|
||||
<TabsTrigger value="whatsapp">WhatsApp</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="email" className="space-y-3 py-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="share-email">Recipient email</Label>
|
||||
<Input
|
||||
id="share-email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="customer@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="share-email-message">Message</Label>
|
||||
<Textarea
|
||||
id="share-email-message"
|
||||
value={emailMessage}
|
||||
onChange={(e) => setEmailMessage(e.target.value)}
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter className="pt-2">
|
||||
<Button variant="outline" type="button" onClick={close}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" onClick={handleEmail} disabled={!email || isSharing}>
|
||||
{isSharing ? "Sending..." : "Send email"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</TabsContent>
|
||||
<TabsContent value="whatsapp" className="space-y-3 py-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="share-phone">Phone with country code (optional)</Label>
|
||||
<Input
|
||||
id="share-phone"
|
||||
type="tel"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
placeholder="+971501234567"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Leave empty to pick recipient inside WhatsApp.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="share-wa-message">Message</Label>
|
||||
<Textarea
|
||||
id="share-wa-message"
|
||||
value={whatsappMessage}
|
||||
onChange={(e) => setWhatsappMessage(e.target.value)}
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter className="pt-2">
|
||||
<Button variant="outline" type="button" onClick={close}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" onClick={handleWhatsapp} disabled={isSharing}>
|
||||
{isSharing ? "Opening..." : "Open WhatsApp"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
Share link expires 24 hours after creation.
|
||||
</p>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
63
apps/dashboard/shared/hooks/use-document-share.ts
Normal file
63
apps/dashboard/shared/hooks/use-document-share.ts
Normal file
@ -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 }
|
||||
}
|
||||
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
63
packages/api/src/clients/document-share.ts
Normal file
63
packages/api/src/clients/document-share.ts
Normal file
@ -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<string, ApiPath>
|
||||
|
||||
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<DocumentShareResponse> {
|
||||
const endpoint = DOCUMENT_SHARE_ROUTES.STORE
|
||||
const url = `${this.baseUrl.replace(/\/+$/, "")}${endpoint}`
|
||||
const headers = new Headers(this.defaultOptions.headers as Record<string, string>)
|
||||
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
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user