|
|
@@ -1,14 +1,14 @@
|
|
|
-"use client";
|
|
|
+'use client'
|
|
|
|
|
|
-import { useTableKeyboardNav } from "@/hooks/use-table-keyboard-nav";
|
|
|
-import { interactiveRow } from '@/lib/interactive-row';
|
|
|
-import { useDebouncedSearch } from '@/hooks/use-debounced-search';
|
|
|
+import { useTableKeyboardNav } from '@/hooks/use-table-keyboard-nav'
|
|
|
+import { interactiveRow } from '@/lib/interactive-row'
|
|
|
+import { useDebouncedSearch } from '@/hooks/use-debounced-search'
|
|
|
|
|
|
-import { useState, useCallback, useTransition } from "react";
|
|
|
-import { useTranslations } from "next-intl";
|
|
|
-import { useRouter, usePathname, useSearchParams } from "next/navigation";
|
|
|
-import { Button } from "@/components/ui/button";
|
|
|
-import { Input } from "@/components/ui/input";
|
|
|
+import { useState, useCallback, useTransition } from 'react'
|
|
|
+import { useTranslations } from 'next-intl'
|
|
|
+import { useRouter, usePathname, useSearchParams } from 'next/navigation'
|
|
|
+import { Button } from '@/components/ui/button'
|
|
|
+import { Input } from '@/components/ui/input'
|
|
|
import {
|
|
|
Table,
|
|
|
TableBody,
|
|
|
@@ -16,41 +16,54 @@ import {
|
|
|
TableHead,
|
|
|
TableHeader,
|
|
|
TableRow,
|
|
|
-} from "@/components/ui/table";
|
|
|
+} from '@/components/ui/table'
|
|
|
import {
|
|
|
DropdownMenu,
|
|
|
DropdownMenuContent,
|
|
|
DropdownMenuItem,
|
|
|
DropdownMenuTrigger,
|
|
|
-} from "@/components/ui/dropdown-menu";
|
|
|
+} from '@/components/ui/dropdown-menu'
|
|
|
import {
|
|
|
ContextMenu,
|
|
|
ContextMenuContent,
|
|
|
ContextMenuItem,
|
|
|
ContextMenuTrigger,
|
|
|
-} from "@/components/ui/context-menu";
|
|
|
-import { DataTablePagination } from "@/components/data-table-pagination";
|
|
|
-import { TableContextMenuHint } from "@/components/table-context-menu-hint";
|
|
|
-import { useGlassModal } from "@/components/glass-modal";
|
|
|
-import { useConfirm } from "@/components/confirm-dialog";
|
|
|
-import { LaborPresetForm } from "@/features/labor-presets/Components/LaborPresetForm";
|
|
|
-import { deleteLaborPreset, getLaborPreset } from "@/features/labor-presets/Actions/laborPresetActions";
|
|
|
-import { ArrowDown, ArrowUp, ArrowUpDown, Loader2, MoreVertical, Pencil, Plus, Search, Trash2 } from "lucide-react";
|
|
|
+} from '@/components/ui/context-menu'
|
|
|
+import { DataTablePagination } from '@/components/data-table-pagination'
|
|
|
+import { TableContextMenuHint } from '@/components/table-context-menu-hint'
|
|
|
+import { useGlassModal } from '@/components/glass-modal'
|
|
|
+import { useConfirm } from '@/components/confirm-dialog'
|
|
|
+import { LaborPresetForm } from '@/features/labor-presets/Components/LaborPresetForm'
|
|
|
+import {
|
|
|
+ deleteLaborPreset,
|
|
|
+ getLaborPreset,
|
|
|
+} from '@/features/labor-presets/Actions/laborPresetActions'
|
|
|
+import {
|
|
|
+ ArrowDown,
|
|
|
+ ArrowUp,
|
|
|
+ ArrowUpDown,
|
|
|
+ Loader2,
|
|
|
+ MoreVertical,
|
|
|
+ Pencil,
|
|
|
+ Plus,
|
|
|
+ Search,
|
|
|
+ Trash2,
|
|
|
+} from 'lucide-react'
|
|
|
|
|
|
interface LaborPresetRow {
|
|
|
- id: string;
|
|
|
- name: string;
|
|
|
- description: string | null;
|
|
|
- _count: { items: number };
|
|
|
- items: { hours: number; pricingType: string }[];
|
|
|
+ id: string
|
|
|
+ name: string
|
|
|
+ description: string | null
|
|
|
+ _count: { items: number }
|
|
|
+ items: { hours: number; pricingType: string }[]
|
|
|
}
|
|
|
|
|
|
interface PaginatedData {
|
|
|
- presets: LaborPresetRow[];
|
|
|
- total: number;
|
|
|
- page: number;
|
|
|
- pageSize: number;
|
|
|
- totalPages: number;
|
|
|
+ presets: LaborPresetRow[]
|
|
|
+ total: number
|
|
|
+ page: number
|
|
|
+ pageSize: number
|
|
|
+ totalPages: number
|
|
|
}
|
|
|
|
|
|
export function LaborPresetsClient({
|
|
|
@@ -58,51 +71,68 @@ export function LaborPresetsClient({
|
|
|
search,
|
|
|
sortBy,
|
|
|
sortOrder,
|
|
|
- currencyCode = "USD",
|
|
|
+ currencyCode = 'USD',
|
|
|
defaultLaborRate = 0,
|
|
|
+ inventoryParts = [],
|
|
|
}: {
|
|
|
- data: PaginatedData;
|
|
|
- search: string;
|
|
|
- sortBy: string;
|
|
|
- sortOrder: "asc" | "desc";
|
|
|
- currencyCode?: string;
|
|
|
- defaultLaborRate?: number;
|
|
|
+ data: PaginatedData
|
|
|
+ search: string
|
|
|
+ sortBy: string
|
|
|
+ sortOrder: 'asc' | 'desc'
|
|
|
+ currencyCode?: string
|
|
|
+ defaultLaborRate?: number
|
|
|
+ /** Stocked parts for the preset form's "import from inventory" picker. */
|
|
|
+ inventoryParts?: {
|
|
|
+ id: string
|
|
|
+ name: string
|
|
|
+ partNumber: string | null
|
|
|
+ unit: string | null
|
|
|
+ sellPrice: number
|
|
|
+ unitCost: number
|
|
|
+ quantity: number
|
|
|
+ }[]
|
|
|
}) {
|
|
|
- const router = useRouter();
|
|
|
- const pathname = usePathname();
|
|
|
- const searchParams = useSearchParams();
|
|
|
- const t = useTranslations("laborPresets");
|
|
|
- const [isPending, startTransition] = useTransition();
|
|
|
- const tableNav = useTableKeyboardNav();
|
|
|
- const [showForm, setShowForm] = useState(false);
|
|
|
+ const router = useRouter()
|
|
|
+ const pathname = usePathname()
|
|
|
+ const searchParams = useSearchParams()
|
|
|
+ const t = useTranslations('laborPresets')
|
|
|
+ const [isPending, startTransition] = useTransition()
|
|
|
+ const tableNav = useTableKeyboardNav()
|
|
|
+ const [showForm, setShowForm] = useState(false)
|
|
|
const [editPreset, setEditPreset] = useState<{
|
|
|
- id: string;
|
|
|
- name: string;
|
|
|
- description: string | null;
|
|
|
- items: { description: string; hours: number; rate: number; pricingType?: string; sortOrder: number }[];
|
|
|
- } | null>(null);
|
|
|
- const modal = useGlassModal();
|
|
|
- const confirm = useConfirm();
|
|
|
+ id: string
|
|
|
+ name: string
|
|
|
+ description: string | null
|
|
|
+ items: {
|
|
|
+ description: string
|
|
|
+ hours: number
|
|
|
+ rate: number
|
|
|
+ pricingType?: string
|
|
|
+ sortOrder: number
|
|
|
+ }[]
|
|
|
+ } | null>(null)
|
|
|
+ const modal = useGlassModal()
|
|
|
+ const confirm = useConfirm()
|
|
|
|
|
|
const navigate = useCallback(
|
|
|
(params: Record<string, string | number | undefined>) => {
|
|
|
- const newParams = new URLSearchParams(searchParams.toString());
|
|
|
+ const newParams = new URLSearchParams(searchParams.toString())
|
|
|
for (const [key, value] of Object.entries(params)) {
|
|
|
- if (value === undefined || value === "") {
|
|
|
- newParams.delete(key);
|
|
|
+ if (value === undefined || value === '') {
|
|
|
+ newParams.delete(key)
|
|
|
} else {
|
|
|
- newParams.set(key, String(value));
|
|
|
+ newParams.set(key, String(value))
|
|
|
}
|
|
|
}
|
|
|
- if (!("page" in params) && "search" in params) {
|
|
|
- newParams.delete("page");
|
|
|
+ if (!('page' in params) && 'search' in params) {
|
|
|
+ newParams.delete('page')
|
|
|
}
|
|
|
startTransition(() => {
|
|
|
- router.push(`${pathname}?${newParams.toString()}`);
|
|
|
- });
|
|
|
+ router.push(`${pathname}?${newParams.toString()}`)
|
|
|
+ })
|
|
|
},
|
|
|
[router, pathname, searchParams]
|
|
|
- );
|
|
|
+ )
|
|
|
|
|
|
// Live search: filters as you type, no Enter required. Submitting the
|
|
|
// form (Enter) commits immediately, bypassing the debounce.
|
|
|
@@ -110,48 +140,50 @@ export function LaborPresetsClient({
|
|
|
value: searchInput,
|
|
|
setValue: setSearchInput,
|
|
|
commitNow: handleSearch,
|
|
|
- } = useDebouncedSearch(search, (term) => navigate({ search: term }));
|
|
|
+ } = useDebouncedSearch(search, (term) => navigate({ search: term }))
|
|
|
|
|
|
const handleSort = useCallback(
|
|
|
(column: string) => {
|
|
|
- const newOrder = sortBy === column && sortOrder === "asc" ? "desc" : "asc";
|
|
|
- navigate({ sortBy: column, sortOrder: newOrder });
|
|
|
+ const newOrder = sortBy === column && sortOrder === 'asc' ? 'desc' : 'asc'
|
|
|
+ navigate({ sortBy: column, sortOrder: newOrder })
|
|
|
},
|
|
|
[navigate, sortBy, sortOrder]
|
|
|
- );
|
|
|
+ )
|
|
|
|
|
|
const SortIcon = ({ column }: { column: string }) => {
|
|
|
- if (sortBy !== column) return <ArrowUpDown className="ml-1 h-3 w-3 opacity-50" />;
|
|
|
- return sortOrder === "asc"
|
|
|
- ? <ArrowUp className="ml-1 h-3 w-3" />
|
|
|
- : <ArrowDown className="ml-1 h-3 w-3" />;
|
|
|
- };
|
|
|
+ if (sortBy !== column) return <ArrowUpDown className="ml-1 h-3 w-3 opacity-50" />
|
|
|
+ return sortOrder === 'asc' ? (
|
|
|
+ <ArrowUp className="ml-1 h-3 w-3" />
|
|
|
+ ) : (
|
|
|
+ <ArrowDown className="ml-1 h-3 w-3" />
|
|
|
+ )
|
|
|
+ }
|
|
|
|
|
|
const handleEdit = async (id: string) => {
|
|
|
- const result = await getLaborPreset(id);
|
|
|
+ const result = await getLaborPreset(id)
|
|
|
if (result.success && result.data) {
|
|
|
- setEditPreset(result.data);
|
|
|
- setShowForm(true);
|
|
|
+ setEditPreset(result.data)
|
|
|
+ setShowForm(true)
|
|
|
} else {
|
|
|
- modal.open("error", t("errors.error"), result.error || t("errors.loadFailed"));
|
|
|
+ modal.open('error', t('errors.error'), result.error || t('errors.loadFailed'))
|
|
|
}
|
|
|
- };
|
|
|
+ }
|
|
|
|
|
|
const handleDelete = async (id: string, name: string) => {
|
|
|
const ok = await confirm({
|
|
|
- title: t("deletePreset.title"),
|
|
|
- description: t("deletePreset.description", { name }),
|
|
|
- confirmLabel: t("deletePreset.confirm"),
|
|
|
+ title: t('deletePreset.title'),
|
|
|
+ description: t('deletePreset.description', { name }),
|
|
|
+ confirmLabel: t('deletePreset.confirm'),
|
|
|
destructive: true,
|
|
|
- });
|
|
|
- if (!ok) return;
|
|
|
- const result = await deleteLaborPreset(id);
|
|
|
+ })
|
|
|
+ if (!ok) return
|
|
|
+ const result = await deleteLaborPreset(id)
|
|
|
if (result.success) {
|
|
|
- router.refresh();
|
|
|
+ router.refresh()
|
|
|
} else {
|
|
|
- modal.open("error", t("errors.error"), result.error || t("errors.deleteFailed"));
|
|
|
+ modal.open('error', t('errors.error'), result.error || t('errors.deleteFailed'))
|
|
|
}
|
|
|
- };
|
|
|
+ }
|
|
|
|
|
|
return (
|
|
|
<div className="space-y-4">
|
|
|
@@ -161,7 +193,7 @@ export function LaborPresetsClient({
|
|
|
<form onSubmit={handleSearch} className="relative flex-1 sm:max-w-sm">
|
|
|
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
|
<Input
|
|
|
- placeholder={t("searchPlaceholder")}
|
|
|
+ placeholder={t('searchPlaceholder')}
|
|
|
value={searchInput}
|
|
|
onChange={(e) => setSearchInput(e.target.value)}
|
|
|
className="h-9 pl-9"
|
|
|
@@ -173,12 +205,12 @@ export function LaborPresetsClient({
|
|
|
<Button
|
|
|
size="sm"
|
|
|
onClick={() => setShowForm(true)}
|
|
|
- aria-label={t("addPackage")}
|
|
|
- title={t("addPackage")}
|
|
|
+ aria-label={t('addPackage')}
|
|
|
+ title={t('addPackage')}
|
|
|
className="h-9 w-9 shrink-0 p-0 md:h-8 md:w-auto md:px-3"
|
|
|
>
|
|
|
<Plus className="h-4 w-4 md:mr-1 md:h-3.5 md:w-3.5" />
|
|
|
- <span className="hidden md:inline">{t("addPackage")}</span>
|
|
|
+ <span className="hidden md:inline">{t('addPackage')}</span>
|
|
|
</Button>
|
|
|
</div>
|
|
|
|
|
|
@@ -186,14 +218,14 @@ export function LaborPresetsClient({
|
|
|
<div className="space-y-2 md:hidden">
|
|
|
{data.presets.length === 0 ? (
|
|
|
<div className="rounded-lg border border-dashed p-8 text-center text-sm text-muted-foreground">
|
|
|
- {search ? t("empty.noMatch") : t("empty.noPresets")}
|
|
|
+ {search ? t('empty.noMatch') : t('empty.noPresets')}
|
|
|
</div>
|
|
|
) : (
|
|
|
data.presets.map((preset) => {
|
|
|
- const hourlyItems = preset.items.filter((i) => i.pricingType !== "service");
|
|
|
- const serviceItems = preset.items.filter((i) => i.pricingType === "service");
|
|
|
- const totalHours = hourlyItems.reduce((sum, i) => sum + i.hours, 0);
|
|
|
- const totalUnits = serviceItems.reduce((sum, i) => sum + i.hours, 0);
|
|
|
+ const hourlyItems = preset.items.filter((i) => i.pricingType !== 'service')
|
|
|
+ const serviceItems = preset.items.filter((i) => i.pricingType === 'service')
|
|
|
+ const totalHours = hourlyItems.reduce((sum, i) => sum + i.hours, 0)
|
|
|
+ const totalUnits = serviceItems.reduce((sum, i) => sum + i.hours, 0)
|
|
|
return (
|
|
|
<div key={preset.id} className="flex items-start gap-2 rounded-lg border bg-card p-3">
|
|
|
<button
|
|
|
@@ -209,16 +241,16 @@ export function LaborPresetsClient({
|
|
|
)}
|
|
|
<div className="mt-2 flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
|
|
|
<span>
|
|
|
- {t("table.itemCount")}: {preset._count.items}
|
|
|
+ {t('table.itemCount')}: {preset._count.items}
|
|
|
</span>
|
|
|
{totalHours > 0 && (
|
|
|
<span className="inline-flex items-center rounded-md border border-emerald-500/30 bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-medium text-emerald-600">
|
|
|
- {totalHours} {t("table.hrs")}
|
|
|
+ {totalHours} {t('table.hrs')}
|
|
|
</span>
|
|
|
)}
|
|
|
{totalUnits > 0 && (
|
|
|
<span className="inline-flex items-center rounded-md border border-blue-500/30 bg-blue-500/10 px-1.5 py-0.5 text-[10px] font-medium text-blue-600">
|
|
|
- {totalUnits} {t("table.units")}
|
|
|
+ {totalUnits} {t('table.units')}
|
|
|
</span>
|
|
|
)}
|
|
|
</div>
|
|
|
@@ -229,7 +261,7 @@ export function LaborPresetsClient({
|
|
|
variant="ghost"
|
|
|
size="icon"
|
|
|
className="-mr-1 h-9 w-9 shrink-0"
|
|
|
- aria-label={t("actions.openMenu")}
|
|
|
+ aria-label={t('actions.openMenu')}
|
|
|
>
|
|
|
<MoreVertical className="h-4 w-4" />
|
|
|
</Button>
|
|
|
@@ -237,19 +269,19 @@ export function LaborPresetsClient({
|
|
|
<DropdownMenuContent align="end">
|
|
|
<DropdownMenuItem onClick={() => handleEdit(preset.id)}>
|
|
|
<Pencil className="mr-2 h-4 w-4" />
|
|
|
- {t("actions.edit")}
|
|
|
+ {t('actions.edit')}
|
|
|
</DropdownMenuItem>
|
|
|
<DropdownMenuItem
|
|
|
className="text-destructive"
|
|
|
onClick={() => handleDelete(preset.id, preset.name)}
|
|
|
>
|
|
|
<Trash2 className="mr-2 h-4 w-4" />
|
|
|
- {t("actions.delete")}
|
|
|
+ {t('actions.delete')}
|
|
|
</DropdownMenuItem>
|
|
|
</DropdownMenuContent>
|
|
|
</DropdownMenu>
|
|
|
</div>
|
|
|
- );
|
|
|
+ )
|
|
|
})
|
|
|
)}
|
|
|
</div>
|
|
|
@@ -261,19 +293,27 @@ export function LaborPresetsClient({
|
|
|
<TableHeader>
|
|
|
<TableRow>
|
|
|
<TableHead>
|
|
|
- <button type="button" className="flex items-center hover:text-foreground" onClick={() => handleSort("name")}>
|
|
|
- {t("table.name")}
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ className="flex items-center hover:text-foreground"
|
|
|
+ onClick={() => handleSort('name')}
|
|
|
+ >
|
|
|
+ {t('table.name')}
|
|
|
<SortIcon column="name" />
|
|
|
</button>
|
|
|
</TableHead>
|
|
|
<TableHead className="hidden sm:table-cell">
|
|
|
- <button type="button" className="flex items-center hover:text-foreground" onClick={() => handleSort("description")}>
|
|
|
- {t("table.description")}
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ className="flex items-center hover:text-foreground"
|
|
|
+ onClick={() => handleSort('description')}
|
|
|
+ >
|
|
|
+ {t('table.description')}
|
|
|
<SortIcon column="description" />
|
|
|
</button>
|
|
|
</TableHead>
|
|
|
- <TableHead>{t("table.itemCount")}</TableHead>
|
|
|
- <TableHead className="hidden md:table-cell">{t("table.type")}</TableHead>
|
|
|
+ <TableHead>{t('table.itemCount')}</TableHead>
|
|
|
+ <TableHead className="hidden md:table-cell">{t('table.type')}</TableHead>
|
|
|
<TableHead className="w-[50px]" />
|
|
|
</TableRow>
|
|
|
</TableHeader>
|
|
|
@@ -281,81 +321,90 @@ export function LaborPresetsClient({
|
|
|
{data.presets.length === 0 ? (
|
|
|
<TableRow>
|
|
|
<TableCell colSpan={5} className="h-32 text-center text-muted-foreground">
|
|
|
- {search ? t("empty.noMatch") : t("empty.noPresets")}
|
|
|
+ {search ? t('empty.noMatch') : t('empty.noPresets')}
|
|
|
</TableCell>
|
|
|
</TableRow>
|
|
|
) : (
|
|
|
data.presets.map((preset) => (
|
|
|
<ContextMenu key={preset.id} modal={false}>
|
|
|
- <ContextMenuTrigger asChild>
|
|
|
- <TableRow
|
|
|
- className="cursor-pointer"
|
|
|
- {...interactiveRow(() => handleEdit(preset.id))}
|
|
|
- >
|
|
|
- <TableCell className="font-medium">{preset.name}</TableCell>
|
|
|
- <TableCell className="hidden sm:table-cell text-muted-foreground">
|
|
|
- {preset.description || "-"}
|
|
|
- </TableCell>
|
|
|
- <TableCell>{preset._count.items}</TableCell>
|
|
|
- <TableCell className="hidden md:table-cell">
|
|
|
- {(() => {
|
|
|
- const hourlyItems = preset.items.filter((i) => i.pricingType !== "service");
|
|
|
- const serviceItems = preset.items.filter((i) => i.pricingType === "service");
|
|
|
- const totalHours = hourlyItems.reduce((sum, i) => sum + i.hours, 0);
|
|
|
- const totalUnits = serviceItems.reduce((sum, i) => sum + i.hours, 0);
|
|
|
- return (
|
|
|
- <div className="flex gap-1.5">
|
|
|
- {totalHours > 0 && (
|
|
|
- <span className="inline-flex items-center rounded-md border px-1.5 py-0.5 text-[10px] font-medium border-emerald-500/30 bg-emerald-500/10 text-emerald-600">
|
|
|
- {totalHours} {t("table.hrs")}
|
|
|
- </span>
|
|
|
- )}
|
|
|
- {totalUnits > 0 && (
|
|
|
- <span className="inline-flex items-center rounded-md border px-1.5 py-0.5 text-[10px] font-medium border-blue-500/30 bg-blue-500/10 text-blue-600">
|
|
|
- {totalUnits} {t("table.units")}
|
|
|
- </span>
|
|
|
- )}
|
|
|
- </div>
|
|
|
- );
|
|
|
- })()}
|
|
|
- </TableCell>
|
|
|
- <TableCell onClick={(e) => e.stopPropagation()}>
|
|
|
- <DropdownMenu>
|
|
|
- <DropdownMenuTrigger asChild>
|
|
|
- <Button variant="ghost" size="icon" className="h-8 w-8" aria-label={t("actions.openMenu")}>
|
|
|
- <MoreVertical className="h-4 w-4" />
|
|
|
- </Button>
|
|
|
- </DropdownMenuTrigger>
|
|
|
- <DropdownMenuContent align="end">
|
|
|
- <DropdownMenuItem onClick={() => handleEdit(preset.id)}>
|
|
|
- <Pencil className="mr-2 h-4 w-4" />
|
|
|
- {t("actions.edit")}
|
|
|
- </DropdownMenuItem>
|
|
|
- <DropdownMenuItem
|
|
|
- className="text-destructive"
|
|
|
- onClick={() => handleDelete(preset.id, preset.name)}
|
|
|
- >
|
|
|
- <Trash2 className="mr-2 h-4 w-4" />
|
|
|
- {t("actions.delete")}
|
|
|
- </DropdownMenuItem>
|
|
|
- </DropdownMenuContent>
|
|
|
- </DropdownMenu>
|
|
|
- </TableCell>
|
|
|
- </TableRow>
|
|
|
- </ContextMenuTrigger>
|
|
|
- <ContextMenuContent className="min-w-52">
|
|
|
- <ContextMenuItem onClick={() => handleEdit(preset.id)}>
|
|
|
- <Pencil className="mr-2 h-4 w-4" />
|
|
|
- {t("actions.edit")}
|
|
|
- </ContextMenuItem>
|
|
|
- <ContextMenuItem
|
|
|
- variant="destructive"
|
|
|
- onClick={() => handleDelete(preset.id, preset.name)}
|
|
|
- >
|
|
|
- <Trash2 className="mr-2 h-4 w-4" />
|
|
|
- {t("actions.delete")}
|
|
|
- </ContextMenuItem>
|
|
|
- </ContextMenuContent>
|
|
|
+ <ContextMenuTrigger asChild>
|
|
|
+ <TableRow
|
|
|
+ className="cursor-pointer"
|
|
|
+ {...interactiveRow(() => handleEdit(preset.id))}
|
|
|
+ >
|
|
|
+ <TableCell className="font-medium">{preset.name}</TableCell>
|
|
|
+ <TableCell className="hidden sm:table-cell text-muted-foreground">
|
|
|
+ {preset.description || '-'}
|
|
|
+ </TableCell>
|
|
|
+ <TableCell>{preset._count.items}</TableCell>
|
|
|
+ <TableCell className="hidden md:table-cell">
|
|
|
+ {(() => {
|
|
|
+ const hourlyItems = preset.items.filter(
|
|
|
+ (i) => i.pricingType !== 'service'
|
|
|
+ )
|
|
|
+ const serviceItems = preset.items.filter(
|
|
|
+ (i) => i.pricingType === 'service'
|
|
|
+ )
|
|
|
+ const totalHours = hourlyItems.reduce((sum, i) => sum + i.hours, 0)
|
|
|
+ const totalUnits = serviceItems.reduce((sum, i) => sum + i.hours, 0)
|
|
|
+ return (
|
|
|
+ <div className="flex gap-1.5">
|
|
|
+ {totalHours > 0 && (
|
|
|
+ <span className="inline-flex items-center rounded-md border px-1.5 py-0.5 text-[10px] font-medium border-emerald-500/30 bg-emerald-500/10 text-emerald-600">
|
|
|
+ {totalHours} {t('table.hrs')}
|
|
|
+ </span>
|
|
|
+ )}
|
|
|
+ {totalUnits > 0 && (
|
|
|
+ <span className="inline-flex items-center rounded-md border px-1.5 py-0.5 text-[10px] font-medium border-blue-500/30 bg-blue-500/10 text-blue-600">
|
|
|
+ {totalUnits} {t('table.units')}
|
|
|
+ </span>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+ })()}
|
|
|
+ </TableCell>
|
|
|
+ <TableCell onClick={(e) => e.stopPropagation()}>
|
|
|
+ <DropdownMenu>
|
|
|
+ <DropdownMenuTrigger asChild>
|
|
|
+ <Button
|
|
|
+ variant="ghost"
|
|
|
+ size="icon"
|
|
|
+ className="h-8 w-8"
|
|
|
+ aria-label={t('actions.openMenu')}
|
|
|
+ >
|
|
|
+ <MoreVertical className="h-4 w-4" />
|
|
|
+ </Button>
|
|
|
+ </DropdownMenuTrigger>
|
|
|
+ <DropdownMenuContent align="end">
|
|
|
+ <DropdownMenuItem onClick={() => handleEdit(preset.id)}>
|
|
|
+ <Pencil className="mr-2 h-4 w-4" />
|
|
|
+ {t('actions.edit')}
|
|
|
+ </DropdownMenuItem>
|
|
|
+ <DropdownMenuItem
|
|
|
+ className="text-destructive"
|
|
|
+ onClick={() => handleDelete(preset.id, preset.name)}
|
|
|
+ >
|
|
|
+ <Trash2 className="mr-2 h-4 w-4" />
|
|
|
+ {t('actions.delete')}
|
|
|
+ </DropdownMenuItem>
|
|
|
+ </DropdownMenuContent>
|
|
|
+ </DropdownMenu>
|
|
|
+ </TableCell>
|
|
|
+ </TableRow>
|
|
|
+ </ContextMenuTrigger>
|
|
|
+ <ContextMenuContent className="min-w-52">
|
|
|
+ <ContextMenuItem onClick={() => handleEdit(preset.id)}>
|
|
|
+ <Pencil className="mr-2 h-4 w-4" />
|
|
|
+ {t('actions.edit')}
|
|
|
+ </ContextMenuItem>
|
|
|
+ <ContextMenuItem
|
|
|
+ variant="destructive"
|
|
|
+ onClick={() => handleDelete(preset.id, preset.name)}
|
|
|
+ >
|
|
|
+ <Trash2 className="mr-2 h-4 w-4" />
|
|
|
+ {t('actions.delete')}
|
|
|
+ </ContextMenuItem>
|
|
|
+ </ContextMenuContent>
|
|
|
</ContextMenu>
|
|
|
))
|
|
|
)}
|
|
|
@@ -372,15 +421,17 @@ export function LaborPresetsClient({
|
|
|
/>
|
|
|
|
|
|
<LaborPresetForm
|
|
|
- key={editPreset?.id ?? "new"}
|
|
|
+ key={editPreset?.id ?? 'new'}
|
|
|
open={showForm}
|
|
|
onOpenChange={(open) => {
|
|
|
- setShowForm(open);
|
|
|
- if (!open) setEditPreset(null);
|
|
|
+ setShowForm(open)
|
|
|
+ if (!open) setEditPreset(null)
|
|
|
}}
|
|
|
preset={editPreset ?? undefined}
|
|
|
defaultLaborRate={defaultLaborRate}
|
|
|
+ inventoryParts={inventoryParts}
|
|
|
+ currencyCode={currencyCode}
|
|
|
/>
|
|
|
</div>
|
|
|
- );
|
|
|
+ )
|
|
|
}
|