humam kerdiah 4f0a2f790f feat: add logo field to settings schema and update settings client to handle file uploads
feat: integrate dialog close context in vendor select field and CRUD dialog components

feat: enhance vendor general info to format status using utility function

feat: implement form dialog context for managing dialog close actions

feat: add async select field dialog close context for better form handling

fix: update form mutation hook to close dialog on successful submission

feat: extend document print types to include expense and credit note

feat: add settings update payload type to include logo and other fields

feat: create employee attendance and work history pages with resource management

feat: implement payment made and received detail pages with actions

feat: add quick shortcuts component for easy navigation in the dashboard

feat: create actions for payment made and received with print and delete options

feat: implement dialog close context for better dialog management

feat: add error parsing utility for improved error handling in API responses
2026-05-19 17:56:39 +04:00

424 lines
18 KiB
TypeScript

"use client"
import { useState, useRef } from "react"
import { useRouter } from "next/navigation"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import {
Paperclip,
Plus,
Trash2,
FileIcon,
ImageIcon,
FileTextIcon,
BadgeDollarSignIcon,
CalendarIcon,
CreditCardIcon,
HashIcon,
UserIcon,
BriefcaseIcon,
} 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 { PaymentMadeForm } from "@/modules/payment-mades/payment-made-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 { getFullName } from "@/shared/utils/getFullName"
import { PAYMENT_MADE_ROUTES } from "@garage/api"
import type { PaymentMadesClient } from "@garage/api"
import { RelationLink } from "@/shared/components/relation-link"
import { Building2, Printer } from "lucide-react"
import { useDocumentPrint } from "@/shared/hooks/use-document-print"
// ── 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,
paymentId,
paymentRef,
onClose,
}: {
open: boolean
paymentId: string
paymentRef: 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 = [PAYMENT_MADE_ROUTES.INDEX, paymentId, "attachments"]
const deleteMutation = useMutation({
mutationFn: (attachmentId: number) =>
api.paymentMades.deleteAttachment(paymentId, { attachment_id: attachmentId } as any),
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 formData = new FormData()
fileArray.forEach((file) => formData.append("attachments[]", file))
await toast.promise(
api.paymentMades.addAttachment(paymentId, formData),
{
loading: "Uploading attachment(s)...",
success: "Attachment(s) uploaded successfully",
error: "Failed to upload attachment(s)",
},
)
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 {paymentRef}</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 &quot;Upload Attachment&quot; 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 ──
type PaymentMadeItem = {
id: number
payment_number?: string
vendor?: { id?: number | string; company_name?: string | null; first_name?: string | null; last_name?: string | null; name?: string | null } | null
vendor_name?: string
employee?: { id?: number | string; first_name?: string | null; last_name?: string | null; name?: string | null } | null
employee_name?: string
payment_for?: string
payment_made?: string | number
payment_mode?: { name?: string | null; title?: string | null } | null
payment_mode_name?: string
payment_date?: string
paid_through?: string
notes?: string
created_at?: string
}
export default function PaymentsMadePage() {
const router = useRouter()
const { print, isPrinting } = useDocumentPrint()
const [attachmentTarget, setAttachmentTarget] = useState<{
id: string
ref: string
} | null>(null)
return (
<>
<ResourcePage<PaymentMadesClient>
pageTitle="Payments Made"
routeKey={PAYMENT_MADE_ROUTES.INDEX}
searchable
searchPlaceholder="Search payments..."
getClient={(api) => api.paymentMades}
onRowClick={(row) => router.push(`/purchase/payments-made/${(row as any).id}`)}
headerProps={({ selectedItem, invalidateQuery }) => ({
actions: (
<FormDialog title="Record Payment">
{(resourceId, { close }) => (
<PaymentMadeForm
resourceId={resourceId}
initialData={selectedItem}
onSuccess={() => {
invalidateQuery()
close()
}}
/>
)}
</FormDialog>
),
})}
columns={({ actionsColumn }) => [
{
accessorKey: "payment_number",
header: ({ column }) => <ColumnHeader column={column} title="Payment #" />,
cell: ({ row }) => {
const item = row.original as unknown as PaymentMadeItem
return (
<div className="flex items-center gap-2">
<HashIcon className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{item.payment_number || "—"}</span>
</div>
)
},
},
{
accessorKey: "vendor_name",
header: ({ column }) => <ColumnHeader column={column} title="Vendor" />,
cell: ({ row }) => {
const item = row.original as unknown as PaymentMadeItem
const isVendor =
item.vendor?.company_name ||
getFullName(item.vendor) ||
item.vendor?.name ||
item.vendor_name
const label =
isVendor ||
getFullName(item.employee) ||
item.employee?.name ||
item.employee_name
const href = isVendor && item.vendor?.id
? `/purchase/vendor/${item.vendor.id}`
: item.employee?.id
? `/productivity/employees/${item.employee.id}`
: null
return (
<RelationLink
href={href}
icon={isVendor ? Building2 : UserIcon}
label={label}
/>
)
},
},
{
accessorKey: "payment_for",
header: ({ column }) => <ColumnHeader column={column} title="Payment For" />,
cell: ({ row }) => {
const item = row.original as unknown as PaymentMadeItem
return (
<div className="flex items-center gap-2">
<BriefcaseIcon className="h-4 w-4 text-muted-foreground" />
<span className="capitalize">{item.payment_for || "—"}</span>
</div>
)
},
},
{
accessorKey: "payment_made",
header: ({ column }) => <ColumnHeader column={column} title="Amount" />,
cell: ({ row }) => {
const item = row.original as unknown as PaymentMadeItem
const amount = item.payment_made
? Number(item.payment_made).toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
: "—"
return (
<div className="flex items-center gap-2">
<BadgeDollarSignIcon className="h-4 w-4 text-emerald-600" />
<span className="font-semibold text-emerald-700 dark:text-emerald-400">
{amount}
</span>
</div>
)
},
},
{
accessorKey: "payment_mode_name",
header: ({ column }) => <ColumnHeader column={column} title="Payment Mode" />,
cell: ({ row }) => {
const item = row.original as unknown as PaymentMadeItem
const label =
item.payment_mode?.name ||
item.payment_mode?.title ||
item.payment_mode_name ||
"—"
return (
<div className="flex items-center gap-2">
<CreditCardIcon className="h-4 w-4 text-muted-foreground" />
<span className="capitalize">{label}</span>
</div>
)
},
},
{
accessorKey: "payment_date",
header: ({ column }) => <ColumnHeader column={column} title="Date" />,
cell: ({ row }) => {
const item = row.original as unknown as PaymentMadeItem
const formatted = item.payment_date
? new Date(item.payment_date).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
})
: "—"
return (
<div className="flex items-center gap-2">
<CalendarIcon className="h-4 w-4 text-muted-foreground" />
<span>{formatted}</span>
</div>
)
},
},
{
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.payment_number || `PAY-${item.id}`,
})
}}
>
<Paperclip className="size-4" />
</Button>
)
},
},
actionsColumn({
extraItems: (row) => [
{
label: isPrinting ? "Printing..." : "Print",
icon: Printer,
onClick: (r) => print("payment_made", String(r.id), "print"),
},
],
}),
]}
/>
{attachmentTarget && (
<AttachmentsDialog
open={!!attachmentTarget}
paymentId={attachmentTarget.id}
paymentRef={attachmentTarget.ref}
onClose={() => setAttachmentTarget(null)}
/>
)}
</>
)
}