diff --git a/.github/skills/api-enums-reference/SKILL.md b/.github/skills/api-enums-reference/SKILL.md new file mode 100644 index 0000000..6d91364 --- /dev/null +++ b/.github/skills/api-enums-reference/SKILL.md @@ -0,0 +1,76 @@ +--- +name: api-enums-reference +description: "Use the central API enums file as the source of truth for enum values in this project. Use when: adding enum fields, updating enum options, creating form selects, typing status/discount/rate fields, syncing backend enum changes, or avoiding duplicated hardcoded enum literals." +--- + +# API Enums Reference + +Use this skill whenever work touches enum-like fields in API clients, schemas, forms, table filters, or page logic. + +## Source of Truth + +All shared enum values and enum union types must come from: + +- packages/api/src/contracts/enums.ts + +Do not recreate enum arrays inline when an equivalent enum already exists in this file. + +## Rules + +1. Reuse before creating. +Search and import from `@garage/api` exports (or local contracts path inside packages/api) before adding new literals. + +2. Keep runtime and type together. +For every enum, keep this pattern in `enums.ts`: + +```ts +export const ExampleStatus = ['a', 'b'] as const; +export type ExampleStatus = (typeof ExampleStatus)[number]; +``` + +3. Preserve backend values exactly. +Enum string values are case- and space-sensitive; keep exact spelling from backend migrations/spec. + +4. Avoid duplicate synonyms. +If two domains share the same canonical values, prefer reusing an existing enum unless domain separation is intentional. + +5. Update centrally first. +When backend enum options change, update `packages/api/src/contracts/enums.ts` first, then update consuming UI/API code. + +6. Prefer imports in forms and schemas. +Use central enums for select options and for typed payload/status fields instead of hardcoded string unions. + +## Workflow + +1. Identify the enum field and backend values. +2. Check `packages/api/src/contracts/enums.ts` for an existing enum. +3. If found, import and use it. +4. If missing, add a new const+type pair in `enums.ts`. +5. Update consumers to reference the central enum. +6. Verify there are no duplicated literal arrays for the same field. + +## Examples + +```ts +import { InvoiceStatus, type InvoiceStatus as InvoiceStatusType } from '@garage/api' + +const statusOptions = InvoiceStatus + +type Payload = { + status: InvoiceStatusType +} +``` + +```ts +import { DiscountType } from '@garage/api' + +const discountOptions = DiscountType.map((value) => ({ + label: value, + value, +})) +``` + +## Notes + +- If a module needs a presentation-specific label, map from the central enum value instead of changing raw enum literals. +- If backend adds/removes values, keep API and dashboard aligned in the same change set. diff --git a/.github/skills/date-time-pickers/SKILL.md b/.github/skills/date-time-pickers/SKILL.md new file mode 100644 index 0000000..b441c5c --- /dev/null +++ b/.github/skills/date-time-pickers/SKILL.md @@ -0,0 +1,86 @@ +--- +name: date-time-pickers +description: "Use RhfDateField and RhfTimeField (shadcn Calendar/Popover-based) for all date and time inputs in forms. Use when: adding date fields, adding time fields, replacing `type=\"date\"` or `type=\"time\"` RhfTextField inputs, building any form that captures a date or time value." +--- + +# Date & Time Pickers + +Always use the shadcn-based picker components for date and time fields. Never use `` or ``. + +## Components + +| Use For | Component | Import | +|---|---|---| +| Date fields (YYYY-MM-DD) | `RhfDateField` | `@/shared/components/form` | +| Time fields (HH:MM:SS) | `RhfTimeField` | `@/shared/components/form` | + +## RhfDateField + +Renders a shadcn Calendar inside a Popover. Value is a `string` in `"YYYY-MM-DD"` format. + +```tsx +import { RhfDateField } from "@/shared/components/form" + + +``` + +**Schema type**: `z.string().optional()` (stores `"YYYY-MM-DD"`) + +**Default value**: `""` for empty, or `new Date().toISOString().split("T")[0]` for today. + +**`mapToFormValues`**: `d.check_in_date ? d.check_in_date.split("T")[0] : ""` + +**`mapFormToPayload`**: `values.check_in_date || undefined` + +## RhfTimeField + +Renders an HH / MM / SS spinner inside a Popover. Value is a `string` in `"HH:MM:SS"` format. + +```tsx +import { RhfTimeField } from "@/shared/components/form" + +// With seconds (default) + + +// Without seconds + +``` + +**Props**: +- `withSeconds?: boolean` — show the SS spinner (default `true`) +- `placeholder?: string` — trigger button placeholder text + +**Schema type**: `z.string().optional()` (stores `"HH:MM:SS"` or `"HH:MM"`) + +**Default value for current time**: +```ts +(() => { + const n = new Date() + return `${String(n.getHours()).padStart(2,"0")}:${String(n.getMinutes()).padStart(2,"0")}:${String(n.getSeconds()).padStart(2,"0")}` +})() +``` + +**`mapToFormValues`** (API returns `"HH:MM"` or ISO datetime): +- If ISO: `d.check_in_time ? d.check_in_time.split("T")[1]?.slice(0, 8) ?? "" : ""` +- If plain time string: `d.check_in_time ?? ""` + +**`mapFormToPayload`**: `values.check_in_time || undefined` + +## Underlying Controls (non-RHF use) + +If you need to use the pickers outside of RHF: + +```tsx +import { DatePickerField, TimePickerField } from "@/shared/components/form" + + + +``` + +## File Locations + +- Control: `apps/dashboard/shared/components/form/controls/date-picker-field.tsx` +- Control: `apps/dashboard/shared/components/form/controls/time-picker-field.tsx` +- RHF wrapper: `apps/dashboard/shared/components/form/fields/rhf-date-field.tsx` +- RHF wrapper: `apps/dashboard/shared/components/form/fields/rhf-time-field.tsx` +- Exports: `apps/dashboard/shared/components/form/index.ts` diff --git a/apps/dashboard/app/(authenticated)/calendar/appointment/[id]/layout.tsx b/apps/dashboard/app/(authenticated)/calendar/appointment/[id]/layout.tsx new file mode 100644 index 0000000..7753921 --- /dev/null +++ b/apps/dashboard/app/(authenticated)/calendar/appointment/[id]/layout.tsx @@ -0,0 +1,29 @@ +import { DashboardDetailsPage } from "@/base/components/layout/dashboard" +import { AppointmentActions } from "@/modules/appointments/appointment-actions" +import { AppointmentProvider } from "@/modules/appointments/appointment-context" +import { CalendarCheck2 } from "lucide-react" +import React from "react" + +export default async function layout(props: { + params: Promise<{ id: string }> + children: React.ReactNode +}) { + const { id } = await props.params + + return ( + + } + title={`Appointment #${id}`} + backHref="/calendar/appointment/list" + actions={} + tabs={[ + { href: `/calendar/appointment/${id}`, label: "Details" }, + ]} + > + {props.children} + + + ) +} diff --git a/apps/dashboard/app/(authenticated)/calendar/appointment/[id]/page.tsx b/apps/dashboard/app/(authenticated)/calendar/appointment/[id]/page.tsx new file mode 100644 index 0000000..daf8104 --- /dev/null +++ b/apps/dashboard/app/(authenticated)/calendar/appointment/[id]/page.tsx @@ -0,0 +1,49 @@ +"use client" + +import { use } from "react" +import { useQuery } from "@tanstack/react-query" +import { useAuthApi } from "@/shared/useApi" +import { APPOINTMENT_ROUTES } from "@garage/api" +import { AppointmentGeneralInfo } from "@/modules/appointments/appointment-general-info" +import DashboardPage from "@/base/components/layout/dashboard/dashboard-page" +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function AppointmentDetailsPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = use(params) + const api = useAuthApi() + + const { data, isLoading } = useQuery({ + queryKey: [APPOINTMENT_ROUTES.INDEX, "detail", id], + queryFn: async () => { + const response = await api.appointments.list() + const items = (response as any)?.data ?? [] + return items.find((item: any) => String(item.id) === id) ?? null + }, + }) + + if (isLoading) { + return ( + +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+
+ ) + } + + if (!data) { + return ( + +

Appointment not found.

+
+ ) + } + + return ( + + + + ) +} diff --git a/apps/dashboard/app/(authenticated)/calendar/appointment/list/page.tsx b/apps/dashboard/app/(authenticated)/calendar/appointment/list/page.tsx new file mode 100644 index 0000000..b8cae45 --- /dev/null +++ b/apps/dashboard/app/(authenticated)/calendar/appointment/list/page.tsx @@ -0,0 +1,112 @@ +"use client" + +import { useRouter } from "next/navigation" +import { ResourcePage } from "@/shared/data-view/resource-page" +import { ColumnHeader } from "@/shared/data-view/table-view" +import FormDialog from "@/shared/components/form-dialog" +import { AppointmentForm } from "@/modules/appointments/appointment-form" +import { APPOINTMENT_ROUTES } from "@garage/api" +import type { AppointmentsClient } from "@garage/api" +import { CalendarCheck2Icon, ClipboardListIcon, ClockIcon, ExternalLinkIcon } from "lucide-react" +import { Badge } from "@/shared/components/ui/badge" +import { Button } from "@/shared/components/ui/button" + +const STATUS_COLORS: Record = { + requested: "bg-yellow-100 text-yellow-800", + confirmed: "bg-blue-100 text-blue-800", + in_progress: "bg-purple-100 text-purple-800", + completed: "bg-green-100 text-green-800", + cancelled: "bg-red-100 text-red-800", +} + +export default function AppointmentsPage() { + const router = useRouter() + + return ( + + pageTitle="Appointments" + routeKey={APPOINTMENT_ROUTES.INDEX} + getClient={(api) => api.appointments} + onRowClick={(row) => router.push(`/calendar/appointment/${(row as any).id}`)} + headerProps={({ selectedItem, invalidateQuery }) => ({ + actions: ( + + {(resourceId) => ( + + )} + + ), + })} + columns={({ actionsColumn }) => [ + { + accessorKey: "title", + header: ({ column }) => , + cell: ({ row }) => ( +
+ + {(row.original as any).title} +
+ ), + }, + { + accessorKey: "date", + header: ({ column }) => , + }, + { + accessorKey: "from_time", + header: ({ column }) => , + cell: ({ row }) => { + const r = row.original as any + return ( +
+ + {r.from_time} – {r.to_time} +
+ ) + }, + }, + { + accessorKey: "status", + header: ({ column }) => , + cell: ({ row }) => { + const status = (row.original as any).status + const colorClass = STATUS_COLORS[status] ?? "bg-gray-100 text-gray-800" + return ( + + {status?.replace("_", " ") ?? "—"} + + ) + }, + }, + { + id: "job_card", + header: ({ column }) => , + cell: ({ row }) => { + const jobCardId = (row.original as any).job_card_id + if (!jobCardId) return + return ( + + ) + }, + }, + actionsColumn(), + ]} + /> + ) +} diff --git a/apps/dashboard/app/(authenticated)/calendars/page.tsx b/apps/dashboard/app/(authenticated)/calendars/page.tsx new file mode 100644 index 0000000..e69de29 diff --git a/apps/dashboard/app/(authenticated)/items/adjustment/page.tsx b/apps/dashboard/app/(authenticated)/items/adjustment/page.tsx new file mode 100644 index 0000000..977677d --- /dev/null +++ b/apps/dashboard/app/(authenticated)/items/adjustment/page.tsx @@ -0,0 +1,292 @@ +"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(null) + const [isUploading, setIsUploading] = useState(false) + const [sessionFiles, setSessionFiles] = useState([]) + + 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) => { + 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 ( + !v && handleClose()}> + + + Attachments — {adjustmentRef} + + +
+ + +
+ + {sessionFiles.length === 0 ? ( + + + No attachments uploaded in this session. Click "Upload Attachment" to add files. + + + ) : ( +
+ {sessionFiles.map((attachment) => { + const Icon = getFileIcon(attachment.attachment_path) + return ( + + +
+ +
+
+ + {attachment.original_name} + + {attachment.created_at && ( + + {new Date(attachment.created_at).toLocaleDateString()} + + )} +
+ +
+
+ ) + })} +
+ )} +
+
+ ) +} + +// ── Page ── + +export default function InventoryAdjustmentsPage() { + const [attachmentTarget, setAttachmentTarget] = useState<{ + id: string + ref: string + } | null>(null) + + return ( + <> + + pageTitle="Inventory Adjustments" + routeKey={INVENTORY_ADJUSTMENT_ROUTES.INDEX} + getClient={(api) => api.inventoryAdjustments} + headerProps={({ selectedItem, invalidateQuery }) => ({ + actions: ( + + {(resourceId) => ( + + )} + + ), + })} + columns={({ actionsColumn }) => [ + { + accessorKey: "reference_number", + header: ({ column }) => , + cell: ({ row }) => (row.original as any).reference_number || "—", + }, + { + accessorKey: "date", + header: ({ column }) => , + cell: ({ row }) => { + const val = (row.original as any).date + return val ? new Date(val).toLocaleDateString() : "—" + }, + }, + { + accessorKey: "chart_of_account", + header: ({ column }) => , + cell: ({ row }) => (row.original as any).chart_of_account || "—", + }, + { + accessorKey: "notes", + header: ({ column }) => , + cell: ({ row }) => { + const notes = (row.original as any).notes + return notes ? ( + {notes} + ) : "—" + }, + }, + { + accessorKey: "created_at", + header: ({ column }) => , + 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 ( + + ) + }, + }, + actionsColumn(), + ]} + /> + + {attachmentTarget && ( + setAttachmentTarget(null)} + /> + )} + + ) +} diff --git a/apps/dashboard/app/(authenticated)/productivity/employees/[id]/layout.tsx b/apps/dashboard/app/(authenticated)/productivity/employees/[id]/layout.tsx new file mode 100644 index 0000000..f41c04e --- /dev/null +++ b/apps/dashboard/app/(authenticated)/productivity/employees/[id]/layout.tsx @@ -0,0 +1,39 @@ +import { DashboardDetailsPage } from '@/base/components/layout/dashboard' +import { getServerApi } from '@garage/api/server' +import { EmployeeActions } from '@/modules/employees/employee-actions' +import { EmployeeProvider } from '@/modules/employees/employee-context' +import React from 'react' + +export default async function layout(props: { + params: Promise<{ id: string }> + children: React.ReactNode +}) { + const { id } = await props.params + const api = await getServerApi() + const employee = await api.employees.getById(id) + + const firstName = employee.data?.first_name || '' + const lastName = employee.data?.last_name || '' + const title = [firstName, lastName].filter(Boolean).join(' ') || 'Employee Details' + const employeeLabel = title + + return ( + <> + + } + tabs={[ + { href: `/productivity/employees/${id}`, label: 'Details' }, + { href: `/productivity/employees/${id}/permissions`, label: 'Permissions' }, + ]} + > + {props.children} + + + + ) +} diff --git a/apps/dashboard/app/(authenticated)/productivity/employees/[id]/page.tsx b/apps/dashboard/app/(authenticated)/productivity/employees/[id]/page.tsx new file mode 100644 index 0000000..15711c2 --- /dev/null +++ b/apps/dashboard/app/(authenticated)/productivity/employees/[id]/page.tsx @@ -0,0 +1,19 @@ +import { getServerApi } from '@garage/api/server' +import { EmployeeGeneralInfo } from '@/modules/employees/employee-general-info' +import DashboardPage from '@/base/components/layout/dashboard/dashboard-page' + +export default async function page(props: { params: Promise<{ id: string }> }) { + const { id } = await props.params + const api = await getServerApi() + const employee = await api.employees.getById(id) + + if (!employee.data) { + return
Employee not found.
+ } + + return ( + + + + ) +} diff --git a/apps/dashboard/app/(authenticated)/productivity/employees/[id]/permissions/page.tsx b/apps/dashboard/app/(authenticated)/productivity/employees/[id]/permissions/page.tsx new file mode 100644 index 0000000..7721774 --- /dev/null +++ b/apps/dashboard/app/(authenticated)/productivity/employees/[id]/permissions/page.tsx @@ -0,0 +1,10 @@ +"use client" + +import { use } from "react" +import { EmployeePermissionsForm } from "@/modules/employees/employee-permissions-form" + +export default function EmployeePermissionsPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = use(params) + + return +} diff --git a/apps/dashboard/app/(authenticated)/productivity/employees/page.tsx b/apps/dashboard/app/(authenticated)/productivity/employees/page.tsx index 82ae50e..22ad472 100644 --- a/apps/dashboard/app/(authenticated)/productivity/employees/page.tsx +++ b/apps/dashboard/app/(authenticated)/productivity/employees/page.tsx @@ -6,13 +6,16 @@ import FormDialog from "@/shared/components/form-dialog" import { EmployeeForm } from "@/modules/employees/employee-form" import { EMPLOYEE_ROUTES } from "@garage/api" import type { EmployeesClient } from "@garage/api" +import { useRouter } from "next/navigation" export default function EmployeesPage() { + const router = useRouter() return ( pageTitle="Employees" routeKey={EMPLOYEE_ROUTES.INDEX} getClient={(api) => api.employees} + onRowClick={(row) => router.push(`/productivity/employees/${(row as any).id}`)} headerProps={({ selectedItem, invalidateQuery }) => ({ actions: ( diff --git a/apps/dashboard/app/(authenticated)/purchase/bill/page.tsx b/apps/dashboard/app/(authenticated)/purchase/bill/page.tsx new file mode 100644 index 0000000..4204e99 --- /dev/null +++ b/apps/dashboard/app/(authenticated)/purchase/bill/page.tsx @@ -0,0 +1,73 @@ +"use client" + +import FormDialog from "@/shared/components/form-dialog" +import { Badge } from "@/shared/components/ui/badge" +import { ResourcePage } from "@/shared/data-view/resource-page" +import { ColumnHeader } from "@/shared/data-view/table-view" +import { BillForm } from "@/modules/bills/bill-form" +import { BILL_ROUTES } from "@garage/api" +import type { BillsClient } from "@garage/api" + +export default function BillsPage() { + return ( + + pageTitle="Bills" + routeKey={BILL_ROUTES.INDEX} + getClient={(api) => api.bills} + headerProps={({ selectedItem, invalidateQuery }) => ({ + actions: ( + + {(resourceId) => ( + + )} + + ), + })} + columns={({ actionsColumn }) => [ + { + accessorKey: "bill_number", + header: ({ column }) => , + cell: ({ row }) => (row.original as any).bill_number || "—", + }, + { + accessorKey: "title", + header: ({ column }) => , + }, + { + accessorKey: "vendor_name", + header: ({ column }) => , + cell: ({ row }) => (row.original as any).vendor_name || "—", + }, + { + accessorKey: "bill_date", + header: ({ column }) => , + cell: ({ row }) => { + const value = (row.original as any).bill_date + return value ? new Date(value).toLocaleDateString() : "—" + }, + }, + { + accessorKey: "bill_due_date", + header: ({ column }) => , + cell: ({ row }) => { + const value = (row.original as any).bill_due_date + return value ? new Date(value).toLocaleDateString() : "—" + }, + }, + { + accessorKey: "status", + header: ({ column }) => , + cell: ({ row }) => { + const status = (row.original as any).status + return {status || "—"} + }, + }, + actionsColumn(), + ]} + /> + ) +} diff --git a/apps/dashboard/app/(authenticated)/purchase/payments-made/page.tsx b/apps/dashboard/app/(authenticated)/purchase/payments-made/page.tsx new file mode 100644 index 0000000..0adf019 --- /dev/null +++ b/apps/dashboard/app/(authenticated)/purchase/payments-made/page.tsx @@ -0,0 +1,377 @@ +"use client" + +import { useState, useRef } from "react" +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 { PAYMENT_MADE_ROUTES } from "@garage/api" +import type { PaymentMadesClient } 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, + paymentId, + paymentRef, + onClose, +}: { + open: boolean + paymentId: string + paymentRef: string + onClose: () => void +}) { + const api = useAuthApi() + const queryClient = useQueryClient() + const fileInputRef = useRef(null) + const [isUploading, setIsUploading] = useState(false) + const [sessionFiles, setSessionFiles] = useState([]) + + 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) => { + 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 ( + !v && handleClose()}> + + + Attachments — {paymentRef} + + +
+ + +
+ + {sessionFiles.length === 0 ? ( + + + No attachments uploaded in this session. Click "Upload Attachment" to add files. + + + ) : ( +
+ {sessionFiles.map((attachment) => { + const Icon = getFileIcon(attachment.attachment_path) + return ( + + +
+ +
+
+ + {attachment.original_name} + + {attachment.created_at && ( + + {new Date(attachment.created_at).toLocaleDateString()} + + )} +
+ +
+
+ ) + })} +
+ )} +
+
+ ) +} + +// ── Page ── + +type PaymentMadeItem = { + id: number + payment_number?: string + vendor_name?: string + employee_name?: string + payment_for?: string + payment_made?: string | number + payment_mode_name?: string + payment_date?: string + paid_through?: string + notes?: string + created_at?: string +} + +export default function PaymentsMadePage() { + const [attachmentTarget, setAttachmentTarget] = useState<{ + id: string + ref: string + } | null>(null) + + return ( + <> + + pageTitle="Payments Made" + routeKey={PAYMENT_MADE_ROUTES.INDEX} + getClient={(api) => api.paymentMades} + headerProps={({ invalidateQuery }) => ({ + actions: ( + + {(resourceId) => ( + + )} + + ), + })} + columns={({ actionsColumn }) => [ + { + accessorKey: "payment_number", + header: ({ column }) => , + cell: ({ row }) => { + const item = row.original as unknown as PaymentMadeItem + return ( +
+ + {item.payment_number || "—"} +
+ ) + }, + }, + { + accessorKey: "vendor_name", + header: ({ column }) => , + cell: ({ row }) => { + const item = row.original as unknown as PaymentMadeItem + return ( +
+ + {item.vendor_name || "—"} +
+ ) + }, + }, + { + accessorKey: "payment_for", + header: ({ column }) => , + cell: ({ row }) => { + const item = row.original as unknown as PaymentMadeItem + return ( +
+ + {item.payment_for || "—"} +
+ ) + }, + }, + { + accessorKey: "payment_made", + header: ({ column }) => , + 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 ( +
+ + + {amount} + +
+ ) + }, + }, + { + accessorKey: "payment_mode_name", + header: ({ column }) => , + cell: ({ row }) => { + const item = row.original as unknown as PaymentMadeItem + return ( +
+ + {item.payment_mode_name || "—"} +
+ ) + }, + }, + { + accessorKey: "payment_date", + header: ({ column }) => , + 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 ( +
+ + {formatted} +
+ ) + }, + }, + { + id: "attachments", + header: () => null, + cell: ({ row }) => { + const item = row.original as any + return ( + + ) + }, + }, + actionsColumn(), + ]} + /> + + {attachmentTarget && ( + setAttachmentTarget(null)} + /> + )} + + ) +} diff --git a/apps/dashboard/app/(authenticated)/purchase/purchase-order/page.tsx b/apps/dashboard/app/(authenticated)/purchase/purchase-order/page.tsx new file mode 100644 index 0000000..28fa0d6 --- /dev/null +++ b/apps/dashboard/app/(authenticated)/purchase/purchase-order/page.tsx @@ -0,0 +1,72 @@ +"use client" + +import { ResourcePage } from "@/shared/data-view/resource-page" +import { ColumnHeader } from "@/shared/data-view/table-view" +import FormDialog from "@/shared/components/form-dialog" +import { PurchaseOrderForm } from "@/modules/purchase-orders/purchase-order-form" +import { PURCHASE_ORDER_ROUTES } from "@garage/api" +import type { PurchaseOrdersClient } from "@garage/api" + +export default function PurchaseOrdersPage() { + return ( + + pageTitle="Purchase Orders" + routeKey={PURCHASE_ORDER_ROUTES.INDEX} + getClient={(api) => api.purchaseOrders} + headerProps={({ selectedItem, invalidateQuery }) => ({ + actions: ( + + {(resourceId) => ( + + )} + + ), + })} + columns={({ actionsColumn }) => [ + { + accessorKey: "order_number", + header: ({ column }) => , + cell: ({ row }) => (row.original as any).order_number || "—", + }, + { + accessorKey: "title", + header: ({ column }) => , + }, + { + accessorKey: "vendor_name", + header: ({ column }) => , + cell: ({ row }) => (row.original as any).vendor_name || "—", + }, + { + accessorKey: "order_date", + header: ({ column }) => , + cell: ({ row }) => { + const val = (row.original as any).order_date + return val ? new Date(val).toLocaleDateString() : "—" + }, + }, + { + accessorKey: "delivery_date", + header: ({ column }) => , + cell: ({ row }) => { + const val = (row.original as any).delivery_date + return val ? new Date(val).toLocaleDateString() : "—" + }, + }, + { + accessorKey: "created_at", + header: ({ column }) => , + cell: ({ row }) => { + const val = (row.original as any).created_at + return val ? new Date(val).toLocaleDateString() : "—" + }, + }, + actionsColumn(), + ]} + /> + ) +} diff --git a/apps/dashboard/app/(authenticated)/purchase/vendor-credit/page.tsx b/apps/dashboard/app/(authenticated)/purchase/vendor-credit/page.tsx new file mode 100644 index 0000000..748594d --- /dev/null +++ b/apps/dashboard/app/(authenticated)/purchase/vendor-credit/page.tsx @@ -0,0 +1,65 @@ +"use client" + +import FormDialog from "@/shared/components/form-dialog" +import { Badge } from "@/shared/components/ui/badge" +import { ResourcePage } from "@/shared/data-view/resource-page" +import { ColumnHeader } from "@/shared/data-view/table-view" +import { VendorCreditForm } from "@/modules/vendor-credits/vendor-credit-form" +import { VENDOR_CREDIT_ROUTES } from "@garage/api" +import type { VendorCreditsClient } from "@garage/api" + +export default function VendorCreditsPage() { + return ( + + pageTitle="Vendor Credits" + routeKey={VENDOR_CREDIT_ROUTES.INDEX} + getClient={(api) => api.vendorCredits} + headerProps={({ selectedItem, invalidateQuery }) => ({ + actions: ( + + {(resourceId) => ( + + )} + + ), + })} + columns={({ actionsColumn }) => [ + { + accessorKey: "subject", + header: ({ column }) => , + }, + { + accessorKey: "vendor_name", + header: ({ column }) => , + cell: ({ row }) => (row.original as any).vendor_name || "—", + }, + { + accessorKey: "bill_number", + header: ({ column }) => , + cell: ({ row }) => (row.original as any).bill_number || "—", + }, + { + accessorKey: "vendor_credit_date", + header: ({ column }) => , + cell: ({ row }) => { + const value = (row.original as any).vendor_credit_date + return value ? new Date(value).toLocaleDateString() : "—" + }, + }, + { + accessorKey: "status", + header: ({ column }) => , + cell: ({ row }) => { + const status = (row.original as any).status + return {status || "—"} + }, + }, + actionsColumn(), + ]} + /> + ) +} diff --git a/apps/dashboard/app/(authenticated)/sales/customers/[id]/layout.tsx b/apps/dashboard/app/(authenticated)/sales/customers/[id]/layout.tsx new file mode 100644 index 0000000..9514d23 --- /dev/null +++ b/apps/dashboard/app/(authenticated)/sales/customers/[id]/layout.tsx @@ -0,0 +1,40 @@ +import { DashboardDetailsPage } from '@/base/components/layout/dashboard' +import { getServerApi } from '@garage/api/server' +import { CustomerActions } from '@/modules/customers/customer-actions' +import { CustomerProvider } from '@/modules/customers/customer-context' +import React from 'react' + +export default async function layout(props: { + params: Promise<{ id: string }> + children: React.ReactNode +}) { + const { id } = await props.params + const api = await getServerApi() + const customer = await api.customers.getById(id) + + const firstName = customer.data?.first_name ?? '' + const lastName = customer.data?.last_name ?? '' + const fullName = [firstName, lastName].filter(Boolean).join(' ') || 'Customer Details' + const customerLabel = fullName + + return ( + <> + + } + tabs={[ + { href: `/sales/customers/${id}`, label: 'Details' }, + { href: `/sales/customers/${id}/notes`, label: 'Notes' }, + { href: `/sales/customers/${id}/vehicles`, label: 'Vehicles' }, + ]} + > + {props.children} + + + + ) +} diff --git a/apps/dashboard/app/(authenticated)/sales/customers/[id]/notes/page.tsx b/apps/dashboard/app/(authenticated)/sales/customers/[id]/notes/page.tsx new file mode 100644 index 0000000..6fd251c --- /dev/null +++ b/apps/dashboard/app/(authenticated)/sales/customers/[id]/notes/page.tsx @@ -0,0 +1,206 @@ +"use client" + +import { useParams } from "next/navigation" +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" +import { type ColumnDef } from "@tanstack/react-table" +import { useState } from "react" +import { Plus, Trash2, StickyNote } from "lucide-react" +import { toast } from "sonner" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { z } from "zod" + +import { useAuthApi } from "@/shared/useApi" +import { DataTable, ColumnHeader } from "@/shared/data-view/table-view" +import { confirm } from "@/shared/components/confirm-dialog" +import { Button } from "@/shared/components/ui/button" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/shared/components/ui/dialog" +import { + Field, + FieldLabel, + FieldError, +} from "@/shared/components/ui/field" +import { Textarea } from "@/shared/components/ui/textarea" +import DashboardPage from "@/base/components/layout/dashboard/dashboard-page" + +type CustomerNote = { + id: number + note: string + created_at: string + updated_at: string +} + +const addNoteSchema = z.object({ + note: z.string().min(1, "Note content is required"), +}) +type AddNoteValues = z.infer + +export default function CustomerNotesPage() { + const { id: customerId } = useParams<{ id: string }>() + const api = useAuthApi() + const queryClient = useQueryClient() + const [dialogOpen, setDialogOpen] = useState(false) + + const queryKey = ["customer-notes", customerId] + + const { data: customerData, isLoading } = useQuery({ + queryKey, + queryFn: () => api.customers.getById(customerId), + }) + + const notes: CustomerNote[] = (customerData?.data as any)?.notes ?? [] + + const meta = (customerData as any)?.meta + const pagination = { + page: meta?.current_page ?? 1, + pageSize: meta?.per_page ?? 15, + pageCount: meta?.last_page ?? 1, + total: meta?.total ?? notes.length, + } + + const addNoteMutation = useMutation({ + mutationFn: (values: AddNoteValues) => + api.customers.addNote(customerId, { note: values.note }), + onSuccess: () => { + toast.success("Note added successfully.") + queryClient.invalidateQueries({ queryKey }) + setDialogOpen(false) + reset() + }, + onError: () => { + toast.error("Failed to add note.") + }, + }) + + const deleteNoteMutation = useMutation({ + mutationFn: (noteId: number) => api.customers.deleteNote(customerId, noteId), + onSuccess: () => { + toast.success("Note deleted successfully.") + queryClient.invalidateQueries({ queryKey }) + }, + onError: () => { + toast.error("Failed to delete note.") + }, + }) + + const { + handleSubmit, + register, + reset, + formState: { errors }, + } = useForm({ + resolver: zodResolver(addNoteSchema), + defaultValues: { note: "" }, + }) + + const handleDelete = async (note: CustomerNote) => { + const confirmed = await confirm({ + title: "Delete Note", + description: "Are you sure you want to delete this note?", + confirmLabel: "Delete", + variant: "destructive", + }) + if (confirmed) { + deleteNoteMutation.mutate(note.id) + } + } + + const columns: ColumnDef[] = [ + { + accessorKey: "note", + header: ({ column }) => , + cell: ({ getValue }) => ( + {getValue()} + ), + }, + { + accessorKey: "created_at", + header: ({ column }) => , + cell: ({ getValue }) => { + const val = getValue() + return val ? new Date(val).toLocaleDateString() : "—" + }, + }, + { + id: "actions", + header: () => Actions, + cell: ({ row }) => ( +
+ +
+ ), + }, + ] + + return ( + setDialogOpen(true)}> + + Add Note + + } + > + {}} + isLoading={isLoading} + /> + + + + + Add Note + +
addNoteMutation.mutate(values))} + className="grid gap-4" + > + + Note +