2026-04-06 02:32:47 +03:00

293 lines
12 KiB
TypeScript

"use client"
import { useState, useRef } from "react"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { Paperclip, Plus, Trash2, FileIcon, ImageIcon, FileTextIcon } from "lucide-react"
import { toast } from "sonner"
import { ResourcePage } from "@/shared/data-view/resource-page"
import { ColumnHeader } from "@/shared/data-view/table-view"
import FormDialog from "@/shared/components/form-dialog"
import { InventoryAdjustmentForm } from "@/modules/inventory-adjustments/inventory-adjustment-form"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent } from "@/shared/components/ui/card"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog"
import { confirm } from "@/shared/components/confirm-dialog"
import { useAuthApi } from "@/shared/useApi"
import { INVENTORY_ADJUSTMENT_ROUTES } from "@garage/api"
import type { InventoryAdjustmentsClient } from "@garage/api"
// ── Attachment helpers ──
type AttachmentFile = {
id: number
original_name?: string
attachment_path?: string
created_at?: string
}
function getFileIcon(path?: string) {
if (!path) return FileIcon
const lower = path.toLowerCase()
if (/\.(jpg|jpeg|png|gif|webp|svg)$/.test(lower)) return ImageIcon
if (/\.pdf$/.test(lower)) return FileTextIcon
return FileIcon
}
// ── Attachments Dialog ──
function AttachmentsDialog({
open,
adjustmentId,
adjustmentRef,
onClose,
}: {
open: boolean
adjustmentId: string
adjustmentRef: string
onClose: () => void
}) {
const api = useAuthApi()
const queryClient = useQueryClient()
const fileInputRef = useRef<HTMLInputElement>(null)
const [isUploading, setIsUploading] = useState(false)
const [sessionFiles, setSessionFiles] = useState<AttachmentFile[]>([])
const queryKey = [INVENTORY_ADJUSTMENT_ROUTES.INDEX, adjustmentId, "attachments"]
const deleteMutation = useMutation({
mutationFn: (attachmentId: number) =>
api.inventoryAdjustments.deleteAttachment(adjustmentId, attachmentId),
onSuccess: (_, attachmentId) => {
toast.success("Attachment deleted.")
setSessionFiles((prev) => prev.filter((f) => f.id !== attachmentId))
queryClient.invalidateQueries({ queryKey })
},
onError: () => toast.error("Failed to delete attachment."),
})
const handleDelete = async (attachment: AttachmentFile) => {
const confirmed = await confirm({
title: "Delete Attachment",
description: `Are you sure you want to delete "${attachment.original_name ?? "this file"}"?`,
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
setIsUploading(true)
const fileArray = Array.from(files)
try {
const result = await toast.promise(
api.inventoryAdjustments.addAttachment(adjustmentId, fileArray),
{
loading: "Uploading attachment(s)...",
success: "Attachment(s) uploaded successfully",
error: "Failed to upload attachment(s)",
},
)
// Track uploaded files locally for display within this session
const now = new Date().toISOString()
const uploaded: AttachmentFile[] = fileArray.map((file, i) => ({
id: Date.now() + i,
original_name: file.name,
attachment_path: file.name,
created_at: now,
}))
setSessionFiles((prev) => [...prev, ...uploaded])
queryClient.invalidateQueries({ queryKey })
} finally {
setIsUploading(false)
if (fileInputRef.current) fileInputRef.current.value = ""
}
}
const handleClose = () => {
setSessionFiles([])
onClose()
}
return (
<Dialog open={open} onOpenChange={(v) => !v && handleClose()}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Attachments {adjustmentRef}</DialogTitle>
</DialogHeader>
<div className="flex justify-end">
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={handleUpload}
/>
<Button
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
size="sm"
>
<Plus className="size-4" />
{isUploading ? "Uploading..." : "Upload Attachment"}
</Button>
</div>
{sessionFiles.length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground">
No attachments uploaded in this session. Click "Upload Attachment" to add files.
</CardContent>
</Card>
) : (
<div className="grid gap-3 sm:grid-cols-2">
{sessionFiles.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">
<span className="truncate text-sm font-medium" title={attachment.original_name}>
{attachment.original_name}
</span>
{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>
)}
</DialogContent>
</Dialog>
)
}
// ── Page ──
export default function InventoryAdjustmentsPage() {
const [attachmentTarget, setAttachmentTarget] = useState<{
id: string
ref: string
} | null>(null)
return (
<>
<ResourcePage<InventoryAdjustmentsClient>
pageTitle="Inventory Adjustments"
routeKey={INVENTORY_ADJUSTMENT_ROUTES.INDEX}
getClient={(api) => api.inventoryAdjustments}
headerProps={({ selectedItem, invalidateQuery }) => ({
actions: (
<FormDialog title="Inventory Adjustment">
{(resourceId) => (
<InventoryAdjustmentForm
resourceId={resourceId}
initialData={selectedItem}
onSuccess={invalidateQuery}
/>
)}
</FormDialog>
),
})}
columns={({ actionsColumn }) => [
{
accessorKey: "reference_number",
header: ({ column }) => <ColumnHeader column={column} title="Reference #" />,
cell: ({ row }) => (row.original as any).reference_number || "—",
},
{
accessorKey: "date",
header: ({ column }) => <ColumnHeader column={column} title="Date" />,
cell: ({ row }) => {
const val = (row.original as any).date
return val ? new Date(val).toLocaleDateString() : "—"
},
},
{
accessorKey: "chart_of_account",
header: ({ column }) => <ColumnHeader column={column} title="Chart of Account" />,
cell: ({ row }) => (row.original as any).chart_of_account || "—",
},
{
accessorKey: "notes",
header: ({ column }) => <ColumnHeader column={column} title="Notes" />,
cell: ({ row }) => {
const notes = (row.original as any).notes
return notes ? (
<span className="max-w-50 truncate block" title={notes}>{notes}</span>
) : "—"
},
},
{
accessorKey: "created_at",
header: ({ column }) => <ColumnHeader column={column} title="Created" />,
cell: ({ row }) => {
const val = (row.original as any).created_at
return val ? new Date(val).toLocaleDateString() : "—"
},
},
{
id: "attachments",
header: () => null,
cell: ({ row }) => {
const item = row.original as any
return (
<Button
variant="ghost"
size="icon-sm"
title="Manage Attachments"
onClick={(e) => {
e.stopPropagation()
setAttachmentTarget({
id: String(item.id),
ref: item.reference_number || `ADJ-${item.id}`,
})
}}
>
<Paperclip className="size-4" />
</Button>
)
},
},
actionsColumn(),
]}
/>
{attachmentTarget && (
<AttachmentsDialog
open={!!attachmentTarget}
adjustmentId={attachmentTarget.id}
adjustmentRef={attachmentTarget.ref}
onClose={() => setAttachmentTarget(null)}
/>
)}
</>
)
}