6 Commits
Author SHA1 Message Date
Claude CodeandClaude Opus 4.8 7b145c1469 fix(dashboard): use /api/v1/saas path for subdomain workspace resolution
The edge middleware resolved the workspace by calling the SaaS landlord at
/api/saas/workspaces/by-subdomain/<sub>, but the landlord serves that route
under /api/v1/saas/... (the garage backend already calls it with /v1). The
missing version segment returned 404, so no workspace_uuid/api_base_url
cookies were set and the proxy responded "Workspace session not found",
blocking email/password login on a cold visit to <sub>.reparee.com/login.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 06:51:56 +00:00
aseelandClaude Opus 4.8 33da1204dd fix(api): route SSR requests to the tenant backend with workspace context
getServerApi only forwarded the auth token, using the default API base URL
and sending no workspace header. In the multi-tenant setup the per-workspace
backend URL (api_base_url) and id (workspace_uuid) live in httpOnly cookies,
and client requests reach the right tenant via the /api/proxy route. SSR
never replicated that, so server components hit the wrong/default backend and
got "not found" for resources that exist — e.g. the vendor detail page.

Read api_base_url and workspace_uuid from cookies and pass baseUrl +
X-Workspace-UUID to createApi, mirroring the proxy. Fixes every server-
rendered detail page, not just vendors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 16:37:44 +04:00
aseelandClaude Opus 4.8 75e44f5b26 fix(data-table): preserve prior rows when multi-selecting
onRowSelectionChange computed the next selection from the render-time
`rowSelection` closure, which could be stale by the time a second row was
toggled. The updater then produced a selection containing only the latest
row, and the persistedMap sync deleted the earlier one — so checking a
second part unchecked the first, making multi-select behave as single-select.

Track the latest selection in a ref and derive the updater from it, so each
toggle builds on the current selection. Fixes adding multiple parts/services/
expense items in invoices, estimates, and job cards.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 16:28:58 +04:00
aseelandClaude Opus 4.8 29d90b5e71 fix(vendor): stop detail route crashing when vendor fetch fails
The vendor detail page and layout called api.vendors.getById without a
try/catch. The API client throws ApiError on any non-2xx, so a 404 (missing
vendor), 401, or 5xx during SSR threw out of the server component and
white-screened the whole route with a generic "server-side exception" —
the existing `if (!vendor)` fallback was never reached.

Guard both fetches: log the error and degrade gracefully (page shows
"Vendor not found", layout renders chrome with a fallback title).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 16:23:29 +04:00
aseelandClaude Opus 4.8 f9aac2db37 fix(vehicles): serialize booleans as 1/0 in multipart payload
The vehicle form uploads as multipart/form-data for the image, and the
FormData builder stringified every value, turning has_insurance=false into
the literal "false". Laravel's boolean rule rejects "true"/"false" strings,
producing "The has insurance field must be true or false." Serialize
booleans as "1"/"0" so they validate; null values remain skipped (nullable).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 13:45:04 +04:00
Najjar\NajjarV02andClaude Opus 4.8 11d920ba6a fix(dashboard): resolve Asma tester-reported UI issues
- 3.1 currency: replace hardcoded $ with AED <Money/> in item & job-card price cells
- 5.1 calendar: /calendars now renders the month calendar (was redirect to list)
- 7.2 nav: remove dead Time Clocks / Time Sheets links (no routes, 404)
- 5.4 check-in: enforce required department at schema level
- 5.6 payments received: add invoice picker so the invoice is marked Paid
- 5.2 share link: forward ws workspace param from the public inspection page

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 14:58:18 +04:00
21 changed files with 154 additions and 37 deletions
@@ -1,5 +1,12 @@
import { redirect } from "next/navigation" "use client"
import { AppointmentsCalendarView } from "@/modules/appointments/appointments-calendar-view"
export default function CalendarsPage() { export default function CalendarsPage() {
return redirect("/calendar/appointment/list") return (
<div className="space-y-4 p-4">
<h1 className="text-xl font-semibold">Calendar</h1>
<AppointmentsCalendarView />
</div>
)
} }
@@ -4,6 +4,7 @@ import { ResourcePage } from "@/shared/data-view/resource-page"
import { ColumnHeader } from "@/shared/data-view/table-view" import { ColumnHeader } from "@/shared/data-view/table-view"
import FormDialog from "@/shared/components/form-dialog" import FormDialog from "@/shared/components/form-dialog"
import { ExpenseItemForm } from "@/modules/expense-items/expense-item-form" import { ExpenseItemForm } from "@/modules/expense-items/expense-item-form"
import { Money } from "@/shared/components/money"
import { EXPENSE_ITEM_ROUTES } from "@garage/api" import { EXPENSE_ITEM_ROUTES } from "@garage/api"
import type { ExpenseItemsClient } from "@garage/api" import type { ExpenseItemsClient } from "@garage/api"
@@ -41,7 +42,7 @@ export default function ExpenseItemPage() {
header: ({ column }) => <ColumnHeader column={column} title="Purchase Price" />, header: ({ column }) => <ColumnHeader column={column} title="Purchase Price" />,
cell: ({ row }) => { cell: ({ row }) => {
const val = (row.original as any).purchase_price const val = (row.original as any).purchase_price
return val != null ? `$${Number(val).toFixed(2)}` : "—" return val != null ? <Money value={val} /> : "—"
}, },
}, },
{ {
@@ -4,6 +4,7 @@ import { ResourcePage } from "@/shared/data-view/resource-page"
import { ColumnHeader } from "@/shared/data-view/table-view" import { ColumnHeader } from "@/shared/data-view/table-view"
import FormDialog from "@/shared/components/form-dialog" import FormDialog from "@/shared/components/form-dialog"
import { ServiceGroupForm } from "@/modules/service-groups/service-group-form" import { ServiceGroupForm } from "@/modules/service-groups/service-group-form"
import { Money } from "@/shared/components/money"
import { Badge } from "@/shared/components/ui/badge" import { Badge } from "@/shared/components/ui/badge"
import { SERVICE_GROUP_ROUTES } from "@garage/api" import { SERVICE_GROUP_ROUTES } from "@garage/api"
import type { ServiceGroupsClient } from "@garage/api" import type { ServiceGroupsClient } from "@garage/api"
@@ -50,7 +51,7 @@ export default function ServiceGroupPage() {
header: ({ column }) => <ColumnHeader column={column} title="Price" />, header: ({ column }) => <ColumnHeader column={column} title="Price" />,
cell: ({ row }) => { cell: ({ row }) => {
const val = (row.original as any).selling_price const val = (row.original as any).selling_price
return val != null ? `$${Number(val).toFixed(2)}` : "—" return val != null ? <Money value={val} /> : "—"
}, },
}, },
{ {
@@ -5,6 +5,7 @@ import { ColumnHeader } from "@/shared/data-view/table-view"
import FormDialog from "@/shared/components/form-dialog" import FormDialog from "@/shared/components/form-dialog"
import { ImportDataButton } from "@/shared/components/import-data-button" import { ImportDataButton } from "@/shared/components/import-data-button"
import { ExportDataButton } from "@/shared/components/export-data-button" import { ExportDataButton } from "@/shared/components/export-data-button"
import { Money } from "@/shared/components/money"
import { useAuthApi } from "@/shared/useApi" import { useAuthApi } from "@/shared/useApi"
import { ServiceForm } from "@/modules/services/service-form" import { ServiceForm } from "@/modules/services/service-form"
import { SERVICE_ROUTES } from "@garage/api" import { SERVICE_ROUTES } from "@garage/api"
@@ -75,7 +76,7 @@ export default function ServicesPage() {
header: ({ column }) => <ColumnHeader column={column} title="Price" />, header: ({ column }) => <ColumnHeader column={column} title="Price" />,
cell: ({ row }) => { cell: ({ row }) => {
const val = (row.original as any).selling_price const val = (row.original as any).selling_price
return val != null ? `$${Number(val).toFixed(2)}` : "—" return val != null ? <Money value={val} /> : "—"
}, },
}, },
{ {
@@ -10,8 +10,15 @@ export default async function layout(props: {
}) { }) {
const { id } = await props.params const { id } = await props.params
const api = await getServerApi() const api = await getServerApi()
const response = (await api.vendors.getById(id)) as { data?: Record<string, any> } // Guard the fetch: a failed request (404/401/5xx) must not throw and crash
const vendor = response?.data // the whole route — render the chrome with a fallback title instead.
let vendor: Record<string, any> | undefined
try {
const response = (await api.vendors.getById(id)) as { data?: Record<string, any> }
vendor = response?.data
} catch (err) {
console.error(`Failed to load vendor ${id} (layout):`, err)
}
const company = vendor?.company_name as string | undefined const company = vendor?.company_name as string | undefined
const fullName = [vendor?.first_name, vendor?.last_name].filter(Boolean).join(" ").trim() const fullName = [vendor?.first_name, vendor?.last_name].filter(Boolean).join(" ").trim()
@@ -6,8 +6,15 @@ export default async function VendorDetailPage(props: { params: Promise<{ id: st
const api = await getServerApi() const api = await getServerApi()
// The OpenAPI schema doesn't yet describe GET /api/vendors/{id} (only PUT/DELETE), // The OpenAPI schema doesn't yet describe GET /api/vendors/{id} (only PUT/DELETE),
// so cast through `any` here until the schema is regenerated. // so cast through `any` here until the schema is regenerated.
const response = (await api.vendors.getById(id)) as { data?: Record<string, any> } // Guard the fetch: a failed request (404/401/5xx) must degrade gracefully
const vendor = response?.data // instead of throwing and white-screening the whole route.
let vendor: Record<string, any> | undefined
try {
const response = (await api.vendors.getById(id)) as { data?: Record<string, any> }
vendor = response?.data
} catch (err) {
console.error(`Failed to load vendor ${id}:`, err)
}
if (!vendor) { if (!vendor) {
return <div className="text-muted-foreground p-6">Vendor not found.</div> return <div className="text-muted-foreground p-6">Vendor not found.</div>
@@ -24,6 +24,7 @@ import { toast } from "sonner"
import { Ellipsis, Plus } from "lucide-react" import { Ellipsis, Plus } from "lucide-react"
import type { ColumnDef } from "@tanstack/react-table" import type { ColumnDef } from "@tanstack/react-table"
import { JobCardExpenseItemForm } from "@/modules/job-cards/job-card-expense-item-form" import { JobCardExpenseItemForm } from "@/modules/job-cards/job-card-expense-item-form"
import { Money } from "@/shared/components/money"
import { formatDate } from "@/shared/utils/formatters" import { formatDate } from "@/shared/utils/formatters"
// TODO: expense items invalidation is not working properly when create new expense item line. Need to investigate why and fix it. // TODO: expense items invalidation is not working properly when create new expense item line. Need to investigate why and fix it.
@@ -93,7 +94,7 @@ export default function JobCardExpenseItemsPage({
header: ({ column }) => <ColumnHeader column={column} title="Rate" />, header: ({ column }) => <ColumnHeader column={column} title="Rate" />,
cell: ({ row }) => { cell: ({ row }) => {
const val = row.original.rate const val = row.original.rate
return val != null ? `$${Number(val).toFixed(2)}` : "—" return val != null ? <Money value={val} /> : "—"
}, },
}, },
{ {
@@ -101,7 +102,7 @@ export default function JobCardExpenseItemsPage({
header: ({ column }) => <ColumnHeader column={column} title="Discount" />, header: ({ column }) => <ColumnHeader column={column} title="Discount" />,
cell: ({ row }) => { cell: ({ row }) => {
const val = row.original.discount_amount const val = row.original.discount_amount
return val != null && val > 0 ? `$${Number(val).toFixed(2)}` : "—" return val != null && val > 0 ? <Money value={val} /> : "—"
}, },
}, },
{ {
@@ -24,6 +24,7 @@ import { toast } from "sonner"
import { Ellipsis, Plus } from "lucide-react" import { Ellipsis, Plus } from "lucide-react"
import type { ColumnDef } from "@tanstack/react-table" import type { ColumnDef } from "@tanstack/react-table"
import { JobCardPartForm } from "@/modules/job-cards/job-card-part-form" import { JobCardPartForm } from "@/modules/job-cards/job-card-part-form"
import { Money } from "@/shared/components/money"
import { formatDate } from "@/shared/utils/formatters" import { formatDate } from "@/shared/utils/formatters"
import { JOB_CARD_ROUTES } from "@garage/api" import { JOB_CARD_ROUTES } from "@garage/api"
@@ -93,7 +94,7 @@ export default function JobCardPartsPage({
header: ({ column }) => <ColumnHeader column={column} title="Rate" />, header: ({ column }) => <ColumnHeader column={column} title="Rate" />,
cell: ({ row }) => { cell: ({ row }) => {
const val = row.original.rate const val = row.original.rate
return val != null ? `$${Number(val).toFixed(2)}` : "—" return val != null ? <Money value={val} /> : "—"
}, },
}, },
{ {
@@ -101,7 +102,7 @@ export default function JobCardPartsPage({
header: ({ column }) => <ColumnHeader column={column} title="Discount" />, header: ({ column }) => <ColumnHeader column={column} title="Discount" />,
cell: ({ row }) => { cell: ({ row }) => {
const val = row.original.discount_amount const val = row.original.discount_amount
return val != null && val > 0 ? `$${Number(val).toFixed(2)}` : "—" return val != null && val > 0 ? <Money value={val} /> : "—"
}, },
}, },
{ {
@@ -24,6 +24,7 @@ import { toast } from "sonner"
import { Ellipsis, Plus } from "lucide-react" import { Ellipsis, Plus } from "lucide-react"
import type { ColumnDef } from "@tanstack/react-table" import type { ColumnDef } from "@tanstack/react-table"
import { JobCardServiceForm } from "@/modules/job-cards/job-card-service-form" import { JobCardServiceForm } from "@/modules/job-cards/job-card-service-form"
import { Money } from "@/shared/components/money"
import { formatDate } from "@/shared/utils/formatters" import { formatDate } from "@/shared/utils/formatters"
import { Badge } from "@/shared/components/ui/badge" import { Badge } from "@/shared/components/ui/badge"
@@ -108,7 +109,7 @@ export default function JobCardServicesPage({
header: ({ column }) => <ColumnHeader column={column} title="Rate" />, header: ({ column }) => <ColumnHeader column={column} title="Rate" />,
cell: ({ row }) => { cell: ({ row }) => {
const val = row.original.rate const val = row.original.rate
return val != null ? `$${Number(val).toFixed(2)}` : "—" return val != null ? <Money value={val} /> : "—"
}, },
}, },
{ {
@@ -116,7 +117,7 @@ export default function JobCardServicesPage({
header: ({ column }) => <ColumnHeader column={column} title="Labor Rate" />, header: ({ column }) => <ColumnHeader column={column} title="Labor Rate" />,
cell: ({ row }) => { cell: ({ row }) => {
const lr = row.original.labor_rate const lr = row.original.labor_rate
return lr ? `${lr.title} ($${Number(lr.rate).toFixed(2)})` : "—" return lr ? <span>{lr.title} (<Money value={lr.rate} />)</span> : "—"
}, },
}, },
{ {
@@ -134,7 +135,7 @@ export default function JobCardServicesPage({
header: ({ column }) => <ColumnHeader column={column} title="Discount" />, header: ({ column }) => <ColumnHeader column={column} title="Discount" />,
cell: ({ row }) => { cell: ({ row }) => {
const val = row.original.discount_amount const val = row.original.discount_amount
return val != null && val > 0 ? `$${Number(val).toFixed(2)}` : "—" return val != null && val > 0 ? <Money value={val} /> : "—"
}, },
}, },
{ {
+27 -6
View File
@@ -1,7 +1,7 @@
"use client" "use client"
import { useEffect, useState } from "react" import { Suspense, useEffect, useState } from "react"
import { useParams } from "next/navigation" import { useParams, useSearchParams } from "next/navigation"
import { import {
AlertCircle, AlertCircle,
Calendar, Calendar,
@@ -125,9 +125,13 @@ function ScoreRing({ score }: { score: number | null }) {
) )
} }
export default function PublicInspectionPage() { function PublicInspectionContent() {
const params = useParams<{ token: string }>() const params = useParams<{ token: string }>()
const token = params.token const token = params.token
// Workspace UUID baked into the share URL — the public API endpoint needs it
// to resolve which tenant DB holds this inspection.
const searchParams = useSearchParams()
const ws = searchParams.get("ws") ?? ""
const [data, setData] = useState<PublicInspection | null>(null) const [data, setData] = useState<PublicInspection | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@@ -135,7 +139,8 @@ export default function PublicInspectionPage() {
useEffect(() => { useEffect(() => {
const base = process.env.NEXT_PUBLIC_API_URL ?? "" const base = process.env.NEXT_PUBLIC_API_URL ?? ""
fetch(`${base.replace(/\/$/, "")}/api/public/inspections/${token}`, { const wsQuery = ws ? `?ws=${encodeURIComponent(ws)}` : ""
fetch(`${base.replace(/\/$/, "")}/api/public/inspections/${token}${wsQuery}`, {
headers: { Accept: "application/json", "X-Lang": "en" }, headers: { Accept: "application/json", "X-Lang": "en" },
}) })
.then(async (res) => { .then(async (res) => {
@@ -148,7 +153,7 @@ export default function PublicInspectionPage() {
.then((json: { data: PublicInspection }) => setData(json.data)) .then((json: { data: PublicInspection }) => setData(json.data))
.catch((e: Error) => setError(e.message)) .catch((e: Error) => setError(e.message))
.finally(() => setLoading(false)) .finally(() => setLoading(false))
}, [token]) }, [token, ws])
if (loading) { if (loading) {
return ( return (
@@ -200,7 +205,7 @@ export default function PublicInspectionPage() {
<span className="font-medium text-foreground">Inspection report</span> <span className="font-medium text-foreground">Inspection report</span>
</div> </div>
<a <a
href={`${(process.env.NEXT_PUBLIC_API_URL ?? "").replace(/\/$/, "")}/api/public/inspections/${token}/print`} href={`${(process.env.NEXT_PUBLIC_API_URL ?? "").replace(/\/$/, "")}/api/public/inspections/${token}/print${ws ? `?ws=${encodeURIComponent(ws)}` : ""}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm border bg-white hover:bg-muted/40 transition" className="inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm border bg-white hover:bg-muted/40 transition"
@@ -418,6 +423,22 @@ export default function PublicInspectionPage() {
) )
} }
export default function PublicInspectionPage() {
return (
<Suspense
fallback={
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-gray-50 to-gray-100">
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="size-4 animate-spin" /> Loading inspection…
</div>
</div>
}
>
<PublicInspectionContent />
</Suspense>
)
}
function InfoCell({ function InfoCell({
icon: Icon, icon: Icon,
label, label,
+4 -2
View File
@@ -142,8 +142,10 @@ export const navGroups: NavGroup[] = [
icon: <UserCogIcon />, icon: <UserCogIcon />,
items: [ items: [
{ title: "Employees", href: "/productivity/employees", icon: <UsersIcon /> }, { title: "Employees", href: "/productivity/employees", icon: <UsersIcon /> },
{ title: "Time Clocks", href: "/productivity/time-clocks", icon: <TimerIcon /> }, // Time Clocks / Time Sheets have no routes yet — links 404. Re-enable once
{ title: "Time Sheets", href: "/productivity/timesheet", icon: <ClockIcon /> }, // /productivity/time-clocks and /productivity/timesheet pages exist.
// { title: "Time Clocks", href: "/productivity/time-clocks", icon: <TimerIcon /> },
// { title: "Time Sheets", href: "/productivity/timesheet", icon: <ClockIcon /> },
// { title: "Payroll", href: "/productivity/payroll", icon: <WalletIcon /> }, // { title: "Payroll", href: "/productivity/payroll", icon: <WalletIcon /> },
// { title: "Payments Made", href: "/productivity/employee-payments-made", icon: <HandCoinsIcon /> }, // { title: "Payments Made", href: "/productivity/employee-payments-made", icon: <HandCoinsIcon /> },
{ title: "Shop Calendars", href: "/productivity/shop-calendars", icon: <CalendarDaysIcon /> }, { title: "Shop Calendars", href: "/productivity/shop-calendars", icon: <CalendarDaysIcon /> },
+2 -2
View File
@@ -8,7 +8,7 @@ import { NextRequest, NextResponse } from "next/server"
* - Skips for the shared fallback host `tenant.reparee.com` and any non-tenant * - Skips for the shared fallback host `tenant.reparee.com` and any non-tenant
* host (apex, localhost, preview URLs). * host (apex, localhost, preview URLs).
* - For a tenant subdomain, HMAC-signs the subdomain and calls SaaS * - For a tenant subdomain, HMAC-signs the subdomain and calls SaaS
* `GET /api/saas/workspaces/by-subdomain/<sub>` to resolve to * `GET /api/v1/saas/workspaces/by-subdomain/<sub>` to resolve to
* { workspace_uuid, api_base_url }, then sets those as HttpOnly cookies. * { workspace_uuid, api_base_url }, then sets those as HttpOnly cookies.
* *
* Matcher excludes /api/proxy/* (proxy needs no resolution; cookies already set) * Matcher excludes /api/proxy/* (proxy needs no resolution; cookies already set)
@@ -47,7 +47,7 @@ export async function middleware(req: NextRequest) {
try { try {
const signature = await hmacSha256Hex(sub, secret) const signature = await hmacSha256Hex(sub, secret)
const resolveUrl = `${saasUrl.replace(/\/$/, "")}/api/saas/workspaces/by-subdomain/${encodeURIComponent(sub)}` const resolveUrl = `${saasUrl.replace(/\/$/, "")}/api/v1/saas/workspaces/by-subdomain/${encodeURIComponent(sub)}`
const res = await fetch(resolveUrl, { const res = await fetch(resolveUrl, {
headers: { "X-SaaS-Signature": signature, Accept: "application/json" }, headers: { "X-SaaS-Signature": signature, Accept: "application/json" },
cache: "no-store", cache: "no-store",
@@ -1,4 +1,5 @@
import { ColumnHeader } from "@/shared/data-view/table-view" import { ColumnHeader } from "@/shared/data-view/table-view"
import { Money } from "@/shared/components/money"
import type { ColumnDef } from "@tanstack/react-table" import type { ColumnDef } from "@tanstack/react-table"
/** Core expense-item columns shared between the expense items page and selector dialogs. */ /** Core expense-item columns shared between the expense items page and selector dialogs. */
@@ -16,7 +17,7 @@ export const expenseItemColumns = {
header: ({ column }) => <ColumnHeader column={column} title="Purchase Price" />, header: ({ column }) => <ColumnHeader column={column} title="Purchase Price" />,
cell: ({ row }) => { cell: ({ row }) => {
const val = (row.original as any).purchase_price const val = (row.original as any).purchase_price
return val != null ? `$${Number(val).toFixed(2)}` : "—" return val != null ? <Money value={val} /> : "—"
}, },
}, },
chartOfAccount: { chartOfAccount: {
@@ -38,6 +38,16 @@ const checkInSchema = z.object({
department: z.object({ value: z.any(), label: z.string() }).nullable().optional(), department: z.object({ value: z.any(), label: z.string() }).nullable().optional(),
delivery_date: z.string().optional(), delivery_date: z.string().optional(),
delivery_time: z.string().optional(), delivery_time: z.string().optional(),
}).superRefine((values, ctx) => {
// Department is marked required in the UI (asterisk); enforce it at the
// schema level so the form cannot be submitted without one.
if (!values.department) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["department"],
message: "Department is required",
})
}
}) })
type CheckInFormValues = z.infer<typeof checkInSchema> type CheckInFormValues = z.infer<typeof checkInSchema>
@@ -1,4 +1,5 @@
import { ColumnHeader } from "@/shared/data-view/table-view" import { ColumnHeader } from "@/shared/data-view/table-view"
import { Money } from "@/shared/components/money"
import { Badge } from "@/shared/components/ui/badge" import { Badge } from "@/shared/components/ui/badge"
import type { ColumnDef } from "@tanstack/react-table" import type { ColumnDef } from "@tanstack/react-table"
@@ -32,7 +33,7 @@ export const partColumns = {
header: ({ column }) => <ColumnHeader column={column} title="Sell Price" />, header: ({ column }) => <ColumnHeader column={column} title="Sell Price" />,
cell: ({ row }) => { cell: ({ row }) => {
const val = (row.original as any).selling_price const val = (row.original as any).selling_price
return val != null ? `$${Number(val).toFixed(2)}` : "—" return val != null ? <Money value={val} /> : "—"
}, },
}, },
purchasePrice: { purchasePrice: {
@@ -40,7 +41,7 @@ export const partColumns = {
header: ({ column }) => <ColumnHeader column={column} title="Purchase Price" />, header: ({ column }) => <ColumnHeader column={column} title="Purchase Price" />,
cell: ({ row }) => { cell: ({ row }) => {
const val = (row.original as any).purchase_price const val = (row.original as any).purchase_price
return val != null ? `$${Number(val).toFixed(2)}` : "—" return val != null ? <Money value={val} /> : "—"
}, },
}, },
stock: { stock: {
@@ -25,7 +25,7 @@ import {
paymentReceivedFormSchema, paymentReceivedFormSchema,
type PaymentReceivedFormValues, type PaymentReceivedFormValues,
} from "./payment-received.schema" } from "./payment-received.schema"
import { PAYMENT_MODE_ROUTES, CUSTOMER_ROUTES, JOB_CARD_ROUTES, PAYMENT_RECEIVED_ROUTES } from "@garage/api" import { PAYMENT_MODE_ROUTES, CUSTOMER_ROUTES, JOB_CARD_ROUTES, INVOICE_ROUTES, PAYMENT_RECEIVED_ROUTES } from "@garage/api"
// ── Props ── // ── Props ──
@@ -45,6 +45,7 @@ export type PaymentReceivedFormProps = {
const DEFAULT_VALUES: PaymentReceivedFormValues = { const DEFAULT_VALUES: PaymentReceivedFormValues = {
job_card: null, job_card: null,
invoice: null,
payment_mode: null, payment_mode: null,
customer: null, customer: null,
amount_received: 0, amount_received: 0,
@@ -61,6 +62,9 @@ function mapToFormValues(data: unknown): PaymentReceivedFormValues {
const jobCardId = d.job_card_id ?? d.job_card?.id const jobCardId = d.job_card_id ?? d.job_card?.id
const jobCardLabel = d.job_card?.title ?? d.job_card_name const jobCardLabel = d.job_card?.title ?? d.job_card_name
const invoiceId = d.invoice_id ?? d.invoice?.id
const invoiceLabel = d.invoice?.invoice_number ?? d.invoice?.title ?? d.invoice_number
const paymentModeId = d.payment_mode_id ?? d.payment_mode?.id const paymentModeId = d.payment_mode_id ?? d.payment_mode?.id
const paymentModeLabel = d.payment_mode?.title ?? d.payment_mode?.name ?? d.payment_mode_name const paymentModeLabel = d.payment_mode?.title ?? d.payment_mode?.name ?? d.payment_mode_name
@@ -76,6 +80,7 @@ function mapToFormValues(data: unknown): PaymentReceivedFormValues {
return { return {
job_card: toRelation(jobCardId, jobCardLabel), job_card: toRelation(jobCardId, jobCardLabel),
invoice: toRelation(invoiceId, invoiceLabel),
payment_mode: toRelation(paymentModeId, paymentModeLabel), payment_mode: toRelation(paymentModeId, paymentModeLabel),
customer: toRelation(customerId, customerLabel), customer: toRelation(customerId, customerLabel),
amount_received: d.amount_received != null ? Number(d.amount_received) : 0, amount_received: d.amount_received != null ? Number(d.amount_received) : 0,
@@ -86,6 +91,9 @@ function mapToFormValues(data: unknown): PaymentReceivedFormValues {
} }
function mapFormToPayload(values: PaymentReceivedFormValues, invoiceId?: string | null) { function mapFormToPayload(values: PaymentReceivedFormValues, invoiceId?: string | null) {
// Prefer the invoice the form was opened from; otherwise the one picked in
// the standalone form. Linking invoice_id is what flips the invoice to Paid.
const resolvedInvoiceId = invoiceId ?? toId(values.invoice)
return { return {
job_card_id: toId(values.job_card), job_card_id: toId(values.job_card),
payment_mode_id: toId(values.payment_mode), payment_mode_id: toId(values.payment_mode),
@@ -94,7 +102,7 @@ function mapFormToPayload(values: PaymentReceivedFormValues, invoiceId?: string
payment_number: values.payment_number || undefined, payment_number: values.payment_number || undefined,
payment_date: values.payment_date, payment_date: values.payment_date,
note: values.note || undefined, note: values.note || undefined,
...(invoiceId ? { invoice_id: Number(invoiceId) } : {}), ...(resolvedInvoiceId ? { invoice_id: Number(resolvedInvoiceId) } : {}),
} }
} }
@@ -114,6 +122,10 @@ export function PaymentReceivedForm({ resourceId, initialData, onSuccess, defaul
const isJobCardLocked = !resourceId && (lockJobCard ?? !!defaultJobCard?.id) const isJobCardLocked = !resourceId && (lockJobCard ?? !!defaultJobCard?.id)
const isCustomerLocked = !resourceId && (lockCustomer ?? !!invoiceCustomer?.id) const isCustomerLocked = !resourceId && (lockCustomer ?? !!invoiceCustomer?.id)
// When opened from a specific invoice the link is implicit (invoiceId prop).
// Standalone (Payments Received → Add) needs a picker so the payment can be
// tied to an invoice — otherwise the invoice never shows as Paid.
const showInvoiceField = !invoiceId
const customerLabel = invoiceCustomer?.first_name const customerLabel = invoiceCustomer?.first_name
? `${invoiceCustomer.first_name} ${invoiceCustomer.last_name || ""}`.trim() ? `${invoiceCustomer.first_name} ${invoiceCustomer.last_name || ""}`.trim()
@@ -225,6 +237,22 @@ export function PaymentReceivedForm({ resourceId, initialData, onSuccess, defaul
)} )}
</div> </div>
{showInvoiceField && (
<RhfAsyncSelectField
name="invoice"
label="Invoice"
placeholder="Select invoice to mark as paid"
description="Link this payment to an invoice so it is marked paid."
queryKey={[INVOICE_ROUTES.INDEX]}
listFn={() => api.invoices.list()}
mapOption={(item: any) => ({
value: String(item.id),
label: item.invoice_number ?? item.title ?? `#${item.id}`,
})}
{...STORE_OBJECT}
/>
)}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField <RhfTextField
name="amount_received" name="amount_received"
@@ -26,6 +26,7 @@ const optionalStringMax255Schema = z.preprocess(
const paymentReceivedFormSchema = z.object({ const paymentReceivedFormSchema = z.object({
// ── Relations ── // ── Relations ──
job_card: relationFieldSchema, job_card: relationFieldSchema,
invoice: relationFieldSchema,
payment_mode: requiredRelationFieldSchema, payment_mode: requiredRelationFieldSchema,
customer: requiredRelationFieldSchema, customer: requiredRelationFieldSchema,
@@ -1,4 +1,5 @@
import { ColumnHeader } from "@/shared/data-view/table-view" import { ColumnHeader } from "@/shared/data-view/table-view"
import { Money } from "@/shared/components/money"
import type { ColumnDef } from "@tanstack/react-table" import type { ColumnDef } from "@tanstack/react-table"
/** Core service columns shared between the services page and selector dialogs. */ /** Core service columns shared between the services page and selector dialogs. */
@@ -31,7 +32,7 @@ export const serviceColumns = {
header: ({ column }) => <ColumnHeader column={column} title="Price" />, header: ({ column }) => <ColumnHeader column={column} title="Price" />,
cell: ({ row }) => { cell: ({ row }) => {
const val = (row.original as any).selling_price const val = (row.original as any).selling_price
return val != null ? `$${Number(val).toFixed(2)}` : "—" return val != null ? <Money value={val} /> : "—"
}, },
}, },
} satisfies Record<string, ColumnDef<any, any>> } satisfies Record<string, ColumnDef<any, any>>
@@ -39,8 +39,13 @@ export function DataTable<TData>({
// Persisted map of id → original row data across all pages // Persisted map of id → original row data across all pages
const persistedMap = useRef<Map<string, TData>>(new Map()) const persistedMap = useRef<Map<string, TData>>(new Map())
// Current-page selection state that TanStack Table controls // Current-page selection state that TanStack Table controls.
// A ref mirrors the latest value so onRowSelectionChange never derives the
// next selection from a stale closure (which would drop previously-checked
// rows and make multi-select behave like single-select).
const [rowSelection, setRowSelection] = useState<RowSelectionState>({}) const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const rowSelectionRef = useRef<RowSelectionState>({})
rowSelectionRef.current = rowSelection
// When the page/data changes, restore selection state for the new page from the map // When the page/data changes, restore selection state for the new page from the map
useEffect(() => { useEffect(() => {
@@ -50,6 +55,7 @@ export function DataTable<TData>({
const id = String((row as Record<string, unknown>)[rowKeyStr]) const id = String((row as Record<string, unknown>)[rowKeyStr])
if (persistedMap.current.has(id)) restored[id] = true if (persistedMap.current.has(id)) restored[id] = true
}) })
rowSelectionRef.current = restored
setRowSelection(restored) setRowSelection(restored)
}, [data]) // eslint-disable-line react-hooks/exhaustive-deps }, [data]) // eslint-disable-line react-hooks/exhaustive-deps
@@ -128,7 +134,10 @@ export function DataTable<TData>({
}) })
}, },
onRowSelectionChange: (updater) => { onRowSelectionChange: (updater) => {
const next = typeof updater === "function" ? updater(rowSelection) : updater // Derive from the ref (always current), not the render-time closure,
// so toggling a second row preserves earlier selections.
const next = typeof updater === "function" ? updater(rowSelectionRef.current) : updater
rowSelectionRef.current = next
setRowSelection(next) setRowSelection(next)
if (selection) { if (selection) {
// Sync current page into the persisted map // Sync current page into the persisted map
+4
View File
@@ -20,6 +20,10 @@ function buildVehicleFormData(payload: Record<string, any>): FormData {
if (value == null) continue if (value == null) continue
if (value instanceof File) { if (value instanceof File) {
fd.append(key, value) fd.append(key, value)
} else if (typeof value === "boolean") {
// Laravel's `boolean` rule rejects the strings "true"/"false";
// serialize as "1"/"0" so multipart payloads validate correctly.
fd.append(key, value ? "1" : "0")
} else { } else {
fd.append(key, String(value)) fd.append(key, String(value))
} }
+15 -3
View File
@@ -6,10 +6,22 @@ import type { AuthUser } from "./infra/token"
export async function getServerApi() { export async function getServerApi() {
const cookieStore = await cookies() const cookieStore = await cookies()
const token = cookieStore.get("auth_token")?.value const token = cookieStore.get("auth_token")?.value
// Multi-tenant: the workspace's backend URL and id live in httpOnly cookies
// set by middleware. Client requests reach the right tenant via the
// /api/proxy route; SSR must replicate that — point at the tenant's
// api_base_url and send X-Workspace-UUID, or the request hits the wrong
// (default) backend and resources come back "not found".
const apiBaseUrl = cookieStore.get("api_base_url")?.value
const workspaceUuid = cookieStore.get("workspace_uuid")?.value
return createApi( const headers: Record<string, string> = {}
token ? { headers: { Authorization: `Bearer ${token}` } } : undefined, if (token) headers.Authorization = `Bearer ${token}`
) if (workspaceUuid) headers["X-Workspace-UUID"] = workspaceUuid
return createApi({
...(apiBaseUrl ? { baseUrl: apiBaseUrl } : {}),
...(Object.keys(headers).length ? { headers } : {}),
})
} }
export async function getServerUser(): Promise<AuthUser | null> { export async function getServerUser(): Promise<AuthUser | null> {