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:
2026-05-14 12:21:01 +04:00
parent 05b55b5721
commit fcbba6247d
22 changed files with 571 additions and 60 deletions
@@ -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})`
},
{