Compare commits
6
Commits
0171daea38
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b145c1469 | ||
|
|
33da1204dd | ||
|
|
75e44f5b26 | ||
|
|
29d90b5e71 | ||
|
|
f9aac2db37 | ||
|
|
11d920ba6a |
@@ -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} /> : "—"
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 /> },
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
Reference in New Issue
Block a user