diff --git a/apps/dashboard/app/(authenticated)/items/parts/page.tsx b/apps/dashboard/app/(authenticated)/items/parts/page.tsx index d3806d0..cea2d08 100644 --- a/apps/dashboard/app/(authenticated)/items/parts/page.tsx +++ b/apps/dashboard/app/(authenticated)/items/parts/page.tsx @@ -24,6 +24,7 @@ export default function PartsPage() { api.parts.importData(file)} onSuccess={invalidateQuery} + entityLabel="Parts" /> api.parts.exportData(filters)} diff --git a/apps/dashboard/app/(authenticated)/items/services/page.tsx b/apps/dashboard/app/(authenticated)/items/services/page.tsx index ecf4ebc..803e004 100644 --- a/apps/dashboard/app/(authenticated)/items/services/page.tsx +++ b/apps/dashboard/app/(authenticated)/items/services/page.tsx @@ -24,6 +24,7 @@ export default function ServicesPage() { api.services.importData(file)} onSuccess={invalidateQuery} + entityLabel="Services" /> api.services.exportData(filters)} diff --git a/apps/dashboard/app/(authenticated)/sales/customers/page.tsx b/apps/dashboard/app/(authenticated)/sales/customers/page.tsx index 4c5dc21..0ecc949 100644 --- a/apps/dashboard/app/(authenticated)/sales/customers/page.tsx +++ b/apps/dashboard/app/(authenticated)/sales/customers/page.tsx @@ -32,6 +32,7 @@ export default function CustomersPage() { api.customers.importData(file)} onSuccess={invalidateQuery} + entityLabel="Customers" /> api.customers.exportData(filters)} diff --git a/apps/dashboard/app/(authenticated)/sales/job-cards/page.tsx b/apps/dashboard/app/(authenticated)/sales/job-cards/page.tsx index 561465d..2aab1d2 100644 --- a/apps/dashboard/app/(authenticated)/sales/job-cards/page.tsx +++ b/apps/dashboard/app/(authenticated)/sales/job-cards/page.tsx @@ -39,7 +39,7 @@ export default function JobCardsPage() { const router = useRouter() const [searchInput, setSearchInput] = useState("") const [search, setSearch] = useState("") - const [statusFilter, setStatusFilter] = useState("check_in") + const [statusFilter, setStatusFilter] = useState("all") const filter = useFilterParams(jobCardFilterConfig) diff --git a/apps/dashboard/app/(authenticated)/sales/vehicles/page.tsx b/apps/dashboard/app/(authenticated)/sales/vehicles/page.tsx index 08b4ebc..89987e5 100644 --- a/apps/dashboard/app/(authenticated)/sales/vehicles/page.tsx +++ b/apps/dashboard/app/(authenticated)/sales/vehicles/page.tsx @@ -35,6 +35,7 @@ export default function VehiclesPage() { api.vehicles.importData(file)} onSuccess={invalidateQuery} + entityLabel="Vehicles" /> api.vehicles.exportData(filters)} diff --git a/apps/dashboard/base/components/layout/dashboard/app-sidebar.tsx b/apps/dashboard/base/components/layout/dashboard/app-sidebar.tsx index d9f9e6c..ccaf404 100644 --- a/apps/dashboard/base/components/layout/dashboard/app-sidebar.tsx +++ b/apps/dashboard/base/components/layout/dashboard/app-sidebar.tsx @@ -141,9 +141,20 @@ export function AppSidebar({ navGroups, logo, user, ...props }: AppSidebarProps) ) } +function isPathActive(pathname: string, href: string): boolean { + if (href === '/') return pathname === '/' + return pathname === href || pathname.startsWith(href + '/') +} + +function isItemActive(pathname: string, item: { href: string; matchPath?: string; isActive?: boolean }): boolean { + if (item.isActive !== undefined) return item.isActive + if (item.matchPath) return isPathActive(pathname, item.matchPath) + return isPathActive(pathname, item.href) +} + function SimpleNavItem({ item, isCollapsed }: { item: NavItem; isCollapsed: boolean }) { const pathname = usePathname() - const isActive = item.isActive ?? pathname === item.href + const isActive = isItemActive(pathname, item) return ( @@ -169,8 +180,8 @@ function SimpleNavItem({ item, isCollapsed }: { item: NavItem; isCollapsed: bool function CollapsibleNavItem({ item, isCollapsed }: { item: NavItem; isCollapsed: boolean }) { const pathname = usePathname() - const isChildActive = item.items?.some((sub) => pathname === sub.href) - const isActive = item.isActive ?? (pathname === item.href || isChildActive === true) + const isChildActive = item.items?.some((sub) => isItemActive(pathname, sub)) + const isActive = item.isActive ?? (isItemActive(pathname, item) || isChildActive === true) // Collapsed sidebar → flyout dropdown with sub-items if (isCollapsed) { @@ -210,7 +221,7 @@ function CollapsibleNavItem({ item, isCollapsed }: { item: NavItem; isCollapsed: {item.items?.map((sub) => { - const isSubActive = sub.isActive ?? pathname === sub.href + const isSubActive = isItemActive(pathname, sub) return ( {item.items?.map((sub) => { - const isSubActive = sub.isActive ?? pathname === sub.href + const isSubActive = isItemActive(pathname, sub) return ( diff --git a/apps/dashboard/base/components/layout/dashboard/dashboard-details-page-layout.tsx b/apps/dashboard/base/components/layout/dashboard/dashboard-details-page-layout.tsx index b77c428..8a36f3b 100644 --- a/apps/dashboard/base/components/layout/dashboard/dashboard-details-page-layout.tsx +++ b/apps/dashboard/base/components/layout/dashboard/dashboard-details-page-layout.tsx @@ -68,14 +68,22 @@ export default function DashboardDetailsPageLayout({ )} {(avatarSrc || avatarFallback) && ( - - - {avatarSrc && } - - {avatarFallback ?? title.charAt(0).toUpperCase()} - - - + avatarSrc ? ( + + + + + {avatarFallback ?? title.charAt(0).toUpperCase()} + + + + ) : ( + + + {avatarFallback ?? title.charAt(0).toUpperCase()} + + + ) )} {!avatarSrc && !avatarFallback && icon && (
diff --git a/apps/dashboard/base/types/navigation.ts b/apps/dashboard/base/types/navigation.ts index 93fd3c0..44a64ef 100644 --- a/apps/dashboard/base/types/navigation.ts +++ b/apps/dashboard/base/types/navigation.ts @@ -6,6 +6,12 @@ export type NavItem = { href: string icon?: ReactNode isActive?: boolean + /** + * Optional prefix used to decide active state when the link target + * differs from the page's URL pattern (e.g. `/calendar/appointment/list` + * is the link, but the item should stay active on `/calendar/appointment/{id}`). + */ + matchPath?: string badge?: string | number items?: NavSubItem[] } @@ -15,6 +21,7 @@ export type NavSubItem = { href: string icon?: ReactNode isActive?: boolean + matchPath?: string } export type NavGroup = { diff --git a/apps/dashboard/config/navGroups.tsx b/apps/dashboard/config/navGroups.tsx index 13159b6..8762934 100644 --- a/apps/dashboard/config/navGroups.tsx +++ b/apps/dashboard/config/navGroups.tsx @@ -76,7 +76,7 @@ export const navGroups: NavGroup[] = [ icon: , items: [ // { title: "Work Schedule", href: "/calendar/work-schedule/list", icon: }, - { title: "Appointments", href: "/calendar/appointment/list", icon: }, + { title: "Appointments", href: "/calendar/appointment/list", matchPath: "/calendar/appointment", icon: }, ], }, { @@ -176,7 +176,7 @@ export const navGroups: NavGroup[] = [ { title: "Insurance Types", href: "/settings/insurance-types", icon: }, { title: "Tax & Rates", href: "/settings/tax-rates", icon: }, { title: "Make & Models", href: "/settings/make-and-models", icon: }, - { title: "Configurations", href: "/settings/configurations/preferences/sales", icon: }, + { title: "Configurations", href: "/settings/configurations/preferences/sales", matchPath: "/settings/configurations", icon: }, // { title: "Templates", href: "/settings/templates", icon: }, // { title: "Integrations", href: "/settings/integrations/providers", icon: }, // { title: "Master", href: "/settings/master/body-type", icon: }, diff --git a/apps/dashboard/modules/vehicles/rhf-vehicle-select-field.tsx b/apps/dashboard/modules/vehicles/rhf-vehicle-select-field.tsx index efd6d57..434b987 100644 --- a/apps/dashboard/modules/vehicles/rhf-vehicle-select-field.tsx +++ b/apps/dashboard/modules/vehicles/rhf-vehicle-select-field.tsx @@ -52,6 +52,27 @@ function buildVehicleOption(item: any): VehicleOption { } } +function VehicleThumb({ src }: { src?: string }) { + const [broken, setBroken] = useState(false) + if (!src || broken) { + return ( +
+ +
+ ) + } + return ( + setBroken(true)} + className="size-9 shrink-0 rounded-md object-cover border border-border" + /> + ) +} + function extractItems(response: unknown): any[] { if (Array.isArray(response)) return response const obj = response as any @@ -168,20 +189,7 @@ export function RhfVehicleSelectField< filtered.map((opt) => (
- {/* Thumbnail */} - {opt.image_url ? ( - - ) : ( -
- -
- )} + {/* Identity */}
diff --git a/apps/dashboard/modules/vehicles/vehicle-form.tsx b/apps/dashboard/modules/vehicles/vehicle-form.tsx index f39cf99..5b5e07d 100644 --- a/apps/dashboard/modules/vehicles/vehicle-form.tsx +++ b/apps/dashboard/modules/vehicles/vehicle-form.tsx @@ -69,6 +69,9 @@ const mapLookupOption = (item: any) => ({ const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label } +const toFormString = (value: unknown): string => + value == null ? "" : String(value) + function mapToFormValues(data: unknown): VehicleFormValues { const d = (data as any)?.data ?? data ?? {} @@ -79,16 +82,16 @@ function mapToFormValues(data: unknown): VehicleFormValues { vehicle_transmission_id: toRelation(d.vehicle_transmission_id, d.vehicle_transmission?.title), vehicle_color_id: toRelation(d.vehicle_color_id, d.vehicle_color?.title), customer_id: toRelation(d.customer_id, d.customer?.name), - make: d.make || "", - model: d.model || "", - year: d.year || "", - sub_model: d.sub_model || "", - license_plate: d.license_plate || "", - vin_number: d.vin_number || "", - engine_size: d.engine_size || "", - drivetrain: d.drivetrain || "", - mileage: d.mileage || "", - note: d.note || "", + make: toFormString(d.make), + model: toFormString(d.model), + year: toFormString(d.year), + sub_model: toFormString(d.sub_model), + license_plate: toFormString(d.license_plate), + vin_number: toFormString(d.vin_number), + engine_size: toFormString(d.engine_size), + drivetrain: toFormString(d.drivetrain), + mileage: toFormString(d.mileage), + note: toFormString(d.note), image: null, } } @@ -121,7 +124,7 @@ function mapToPayload(values: VehicleFormValues) { export function VehicleForm({ resourceId, initialData, onSuccess }: VehicleFormProps) { const api = useAuthApi() - const { form, isEditing } = useResourceForm({ + const { form, isEditing, data: resolvedData } = useResourceForm({ schema: vehicleFormSchema, defaultValues: DEFAULT_VALUES, resourceId, @@ -131,6 +134,9 @@ export function VehicleForm({ resourceId, initialData, onSuccess }: VehicleFormP mapToFormValues, }) + const existingImageUrl: string | null = + (resolvedData as any)?.data?.image_url ?? (resolvedData as any)?.image_url ?? null + const { mutate, error, isPending } = useFormMutation(form, { mutationFn: (values: VehicleFormValues) => { const payload = mapToPayload(values) @@ -260,7 +266,11 @@ export function VehicleForm({ resourceId, initialData, onSuccess }: VehicleFormP
- + diff --git a/apps/dashboard/next.config.mjs b/apps/dashboard/next.config.mjs index b40b041..1ec8fe3 100644 --- a/apps/dashboard/next.config.mjs +++ b/apps/dashboard/next.config.mjs @@ -8,6 +8,18 @@ const nextConfig = { hostname: 'newgarage.yslootahtech.com', port: '', pathname: '/**', + }, + { + protocol: 'http', + hostname: 'reparee.test', + port: '', + pathname: '/**', + }, + { + protocol: 'https', + hostname: 'reparee.test', + port: '', + pathname: '/**', } ] } diff --git a/apps/dashboard/shared/components/export-data-button.tsx b/apps/dashboard/shared/components/export-data-button.tsx index e5a6692..1ce37ac 100644 --- a/apps/dashboard/shared/components/export-data-button.tsx +++ b/apps/dashboard/shared/components/export-data-button.tsx @@ -2,7 +2,7 @@ import { useState } from "react" import { Button } from "@/shared/components/ui/button" -import { Download, Loader2 } from "lucide-react" +import { Upload, Loader2 } from "lucide-react" import { toast } from "sonner" type ExportDataButtonProps = { @@ -47,7 +47,7 @@ export function ExportDataButton({ disabled={isPending} onClick={handleExport} > - {isPending ? : } + {isPending ? : } {label} ) diff --git a/apps/dashboard/shared/components/form/controls/image-input-field.tsx b/apps/dashboard/shared/components/form/controls/image-input-field.tsx index edb94e0..680a825 100644 --- a/apps/dashboard/shared/components/form/controls/image-input-field.tsx +++ b/apps/dashboard/shared/components/form/controls/image-input-field.tsx @@ -7,6 +7,7 @@ import { cn } from "@/shared/lib/utils" export type ImageInputFieldProps = BaseFieldControlProps & { accept?: string + initialPreviewUrl?: string | null } export function ImageInputField({ @@ -17,21 +18,24 @@ export function ImageInputField({ disabled, invalid, accept = "image/*", + initialPreviewUrl, }: ImageInputFieldProps) { const inputRef = useRef(null) - const [preview, setPreview] = useState(null) + const [filePreview, setFilePreview] = useState(null) const [isDragging, setIsDragging] = useState(false) useEffect(() => { if (!value) { - setPreview(null) + setFilePreview(null) return } const url = URL.createObjectURL(value) - setPreview(url) + setFilePreview(url) return () => URL.revokeObjectURL(url) }, [value]) + const preview = filePreview ?? initialPreviewUrl ?? null + const handleFile = useCallback( (file: File | null) => { if (file && !file.type.startsWith("image/")) return @@ -110,7 +114,7 @@ export function ImageInputField({ alt="Preview" className="max-h-30 max-w-full rounded-md object-contain" /> - {!disabled && ( + {!disabled && filePreview && ( + {result && ( + + )} ) } diff --git a/apps/dashboard/shared/components/import-results-dialog.tsx b/apps/dashboard/shared/components/import-results-dialog.tsx new file mode 100644 index 0000000..bf57bdb --- /dev/null +++ b/apps/dashboard/shared/components/import-results-dialog.tsx @@ -0,0 +1,138 @@ +"use client" + +import * as React from "react" +import { CheckCircle2, XCircle } from "lucide-react" +import type { ImportFailureRow } from "@garage/api" + +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/shared/components/ui/dialog" +import { Button } from "@/shared/components/ui/button" +import { ScrollArea } from "@/shared/components/ui/scroll-area" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/shared/components/ui/table" + +export type { ImportFailureRow } + +export type ImportResultsDialogProps = { + open: boolean + onOpenChange: (open: boolean) => void + importedCount: number + failedCount: number + failedRows: ImportFailureRow[] + entityLabel?: string +} + +function formatValue(value: unknown): string { + if (value === null || value === undefined || value === "") return "—" + if (typeof value === "string") return value + if (typeof value === "number" || typeof value === "boolean") return String(value) + try { + return JSON.stringify(value) + } catch { + return String(value) + } +} + +export function ImportResultsDialog({ + open, + onOpenChange, + importedCount, + failedCount, + failedRows, + entityLabel = "Records", +}: ImportResultsDialogProps) { + const hasFailures = failedCount > 0 + const flatFailures = failedRows.flatMap((r) => + r.errors.map((e) => ({ row: r.row, ...e })), + ) + + return ( + + + + Import results + + {hasFailures + ? "Some rows could not be imported. Fix the issues below and re-upload only the failing rows." + : `All ${entityLabel.toLowerCase()} imported successfully.`} + + + +
+
+ + + {importedCount} imported + +
+ {hasFailures && ( +
+ + + {failedCount} failed + +
+ )} +
+ + {hasFailures && ( + + + + + Row + Field + Value + Reason + + + + {flatFailures.map((f, idx) => ( + + {f.row} + {f.label} + + {formatValue(f.value)} + + +
{f.message}
+ {f.valid_examples && f.valid_examples.length > 0 && ( +
+ + Try one of: {f.valid_examples.slice(0, 3).join(", ")} + {f.valid_examples.length > 3 ? "…" : ""} + +
+ {f.valid_examples.join(", ")} +
+
+ )} +
+
+ ))} +
+
+
+ )} + + + + +
+
+ ) +} diff --git a/apps/dashboard/shared/hooks/use-resource-form.ts b/apps/dashboard/shared/hooks/use-resource-form.ts index c278d64..a343a2c 100644 --- a/apps/dashboard/shared/hooks/use-resource-form.ts +++ b/apps/dashboard/shared/hooks/use-resource-form.ts @@ -16,11 +16,12 @@ type UseResourceFormOptions queryKey?: QueryKey } -type UseResourceFormReturn = { +type UseResourceFormReturn = { form: UseFormReturn isEditing: boolean isInitializing: boolean invalidate: () => void + data: TApiData | undefined } export function useResourceForm({ @@ -31,7 +32,7 @@ export function useResourceForm): UseResourceFormReturn { +}: UseResourceFormOptions): UseResourceFormReturn { const isEditing = !!resourceId const queryClient = useQueryClient() const resolvedQueryKey = queryKey ?? ["resource", resourceId] @@ -44,7 +45,8 @@ export function useResourceForm({ resolver: zodResolver(schema) as any, @@ -70,5 +72,11 @@ export function useResourceForm(this.byIdRoute as ByIdRoute, { params: { id } } as never) as never } - async importData(file: File): Promise<{ message?: string }> { + async importData(file: File): Promise { const formData = new FormData() formData.append("file", file) const route = (this.importRoute ?? `${this.indexRoute}/import`) as string @@ -78,6 +78,26 @@ export abstract class CrudClient< export type BaseCrudItem = { id: number } +export type ImportFailureRow = { + row: number + errors: { + field: string + label: string + value: unknown + message: string + valid_examples?: string[] + }[] +} + +export type ImportDataResponse = { + message?: string + data?: { + imported_count: number + failed_count: number + failed_rows: ImportFailureRow[] + } +} + type ClientMethodReturn = C extends { [K in TMethod]: (...args: any[]) => infer TResult } ? Awaited