humam kerdiah fcbba6247d 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.
2026-05-14 12:21:01 +04:00

217 lines
8.4 KiB
TypeScript

"use client"
import { useParams, useRouter } from "next/navigation"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { useState, useRef, useTransition } from "react"
import { Plus, Trash2, FileIcon, ImageIcon, FileTextIcon } from "lucide-react"
import { toast } from "sonner"
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 { 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"
function getFileIcon(mimeType?: string) {
if (mimeType?.startsWith("image/")) return ImageIcon
if (mimeType?.includes("pdf")) return FileTextIcon
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()
const queryClient = useQueryClient()
const router = useRouter()
const [isRefreshing, startRefreshTransition] = useTransition()
const fileInputRef = useRef<HTMLInputElement>(null)
const [isUploading, setIsUploading] = useState(false)
const queryKey = [JOB_CARD_ROUTES.INDEX, jobCardId, "attachments"]
const jobcard = useJobCard()
const attachments = jobcard?.attachment_files
const deleteMutation = useMutation({
mutationFn: (attachmentId: number) =>
api.jobCards.deleteAttachment(jobCardId, attachmentId),
onSuccess: () => {
toast.success("Attachment deleted successfully.")
queryClient.invalidateQueries({ queryKey })
startRefreshTransition(() => router.refresh())
},
onError: () => {
toast.error("Failed to delete attachment.")
},
})
const handleDelete = async (attachment: any) => {
const confirmed = await confirm({
title: "Delete Attachment",
description: `Are you sure you want to delete "${attachment.original_name}"?`,
confirmLabel: "Delete",
variant: "destructive",
})
if (confirmed) {
deleteMutation.mutate(attachment.id)
}
}
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (!files || files.length === 0) return
const fileList = Array.from(files)
setIsUploading(true)
const loadingToast = toast.loading("Uploading attachment(s)...")
try {
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) {
fileInputRef.current.value = ""
}
}
}
return (
<DashboardPage
header={null}
>
<div className="flex items-center justify-end mb-4">
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={handleUpload}
/>
<Button
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
>
<Plus className="size-4" />
{isUploading ? "Uploading..." : "Upload Attachment"}
</Button>
</div>
{attachments?.length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
No attachments yet. Click "Upload Attachment" to add files.
</CardContent>
</Card>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{(attachments as any[])?.map((attachment) => {
const Icon = getFileIcon(attachment.attachment_path)
return (
<Card key={attachment.id}>
<CardContent className="flex items-center gap-3 p-4">
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
<Icon className="size-5" />
</div>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<a
href={CONSTANTS.getAssetUrl(attachment.attachment_path)}
target="_blank"
rel="noopener noreferrer"
className="truncate text-sm font-medium hover:underline"
title={attachment.original_name}
>
{attachment.original_name}
</a>
{attachment.created_at && (
<span className="text-xs text-muted-foreground">
{new Date(attachment.created_at).toLocaleDateString()}
</span>
)}
</div>
<Button
variant="ghost"
size="icon-sm"
onClick={() => handleDelete(attachment)}
title="Delete attachment"
>
<Trash2 className="size-4 text-destructive" />
</Button>
</CardContent>
</Card>
)
})}
</div>
)}
</DashboardPage>
)
}