feat(estimates): integrate inventory items
This commit is contained in:
parent
fb5c2cc26a
commit
663bdc41b1
15 changed files with 629 additions and 28 deletions
7
frontend/athena/app/api/inventory/items/search/route.ts
Normal file
7
frontend/athena/app/api/inventory/items/search/route.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return proxyHermesRequest(request, `/inventory/items/search${request.nextUrl.search}`);
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { FileCheck2, Plus, Send, Trash2, XCircle } from "lucide-react";
|
||||
import { AlertTriangle, FileCheck2, PackageSearch, Plus, Send, Trash2, XCircle } from "lucide-react";
|
||||
|
||||
import ConfirmDialog from "@/components/common/ConfirmDialog";
|
||||
import { useToast } from "@/components/common/ToastProvider";
|
||||
|
|
@ -17,6 +17,10 @@ import {
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/lib/api";
|
||||
import type {
|
||||
InventoryCategory,
|
||||
InventoryItem,
|
||||
} from "@/types/inventory";
|
||||
import type {
|
||||
RepairEstimate,
|
||||
RepairEstimateItemType,
|
||||
|
|
@ -89,6 +93,12 @@ function dateTime(value: string | null) {
|
|||
|
||||
type EstimateFormItem = {
|
||||
item_type: RepairEstimateItemType;
|
||||
inventory_item_id: number | null;
|
||||
inventory_snapshot_name: string;
|
||||
inventory_snapshot_sku: string;
|
||||
inventory_snapshot_manufacturer: string | null;
|
||||
inventory_snapshot_part_number: string | null;
|
||||
inventory_price_overridden: boolean;
|
||||
title: string;
|
||||
description: string | null;
|
||||
quantity: string;
|
||||
|
|
@ -118,6 +128,12 @@ function centsToEuroInput(cents: number): string {
|
|||
function emptyItem(): EstimateFormItem {
|
||||
return {
|
||||
item_type: "labor",
|
||||
inventory_item_id: null,
|
||||
inventory_snapshot_name: "",
|
||||
inventory_snapshot_sku: "",
|
||||
inventory_snapshot_manufacturer: null,
|
||||
inventory_snapshot_part_number: null,
|
||||
inventory_price_overridden: false,
|
||||
title: "",
|
||||
description: "",
|
||||
quantity: "1.00",
|
||||
|
|
@ -167,6 +183,14 @@ export default function RepairEstimatesSection({
|
|||
const [saving, setSaving] = useState(false);
|
||||
const [pendingId, setPendingId] = useState<number | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<RepairEstimate | null>(null);
|
||||
const [inventoryDialogOpen, setInventoryDialogOpen] = useState(false);
|
||||
const [inventoryItems, setInventoryItems] = useState<InventoryItem[]>([]);
|
||||
const [inventoryCategories, setInventoryCategories] = useState<InventoryCategory[]>([]);
|
||||
const [inventorySearch, setInventorySearch] = useState("");
|
||||
const [inventoryCategory, setInventoryCategory] = useState("all");
|
||||
const [inventoryManufacturer, setInventoryManufacturer] = useState("");
|
||||
const [inventoryLoading, setInventoryLoading] = useState(false);
|
||||
const [inventoryError, setInventoryError] = useState("");
|
||||
|
||||
const loadEstimates = useCallback(async () => {
|
||||
if (!canRead) {
|
||||
|
|
@ -191,6 +215,38 @@ export default function RepairEstimatesSection({
|
|||
});
|
||||
}, [loadEstimates]);
|
||||
|
||||
const searchInventoryItems = useCallback(async () => {
|
||||
setInventoryLoading(true);
|
||||
setInventoryError("");
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.set("limit", "20");
|
||||
if (inventorySearch.trim()) params.set("q", inventorySearch.trim());
|
||||
if (inventoryCategory !== "all") params.set("category", inventoryCategory);
|
||||
if (inventoryManufacturer.trim()) params.set("manufacturer", inventoryManufacturer.trim());
|
||||
const [itemsResponse, categoriesResponse] = await Promise.all([
|
||||
api.get<InventoryItem[]>(`/inventory/items/search?${params.toString()}`),
|
||||
api.get<InventoryCategory[]>("/inventory/categories"),
|
||||
]);
|
||||
setInventoryItems(itemsResponse.data);
|
||||
setInventoryCategories(categoriesResponse.data);
|
||||
} catch (err) {
|
||||
setInventoryError(getErrorMessage(err));
|
||||
} finally {
|
||||
setInventoryLoading(false);
|
||||
}
|
||||
}, [inventoryCategory, inventoryManufacturer, inventorySearch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!inventoryDialogOpen) {
|
||||
return;
|
||||
}
|
||||
const id = window.setTimeout(() => {
|
||||
void searchInventoryItems();
|
||||
}, 200);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [inventoryDialogOpen, searchInventoryItems]);
|
||||
|
||||
const clientSubtotal = useMemo(() => payload.items.reduce((sum, item) => {
|
||||
const quantity = Number(item.quantity.replace(",", ".")) || 0;
|
||||
const unitPriceCents = parseEuroToCents(item.unit_price_euros) ?? 0;
|
||||
|
|
@ -215,6 +271,12 @@ export default function RepairEstimatesSection({
|
|||
valid_until: estimate.valid_until,
|
||||
items: estimate.items.map((item) => ({
|
||||
item_type: item.item_type,
|
||||
inventory_item_id: item.inventory_item_id ?? null,
|
||||
inventory_snapshot_name: item.inventory_snapshot_name,
|
||||
inventory_snapshot_sku: item.inventory_snapshot_sku,
|
||||
inventory_snapshot_manufacturer: item.inventory_snapshot_manufacturer,
|
||||
inventory_snapshot_part_number: item.inventory_snapshot_part_number,
|
||||
inventory_price_overridden: false,
|
||||
title: item.title,
|
||||
description: item.description ?? "",
|
||||
quantity: String(item.quantity),
|
||||
|
|
@ -232,6 +294,25 @@ export default function RepairEstimatesSection({
|
|||
}));
|
||||
}
|
||||
|
||||
function addInventoryItem(item: InventoryItem) {
|
||||
const formItem: EstimateFormItem = {
|
||||
item_type: "part",
|
||||
inventory_item_id: item.id,
|
||||
inventory_snapshot_name: item.name,
|
||||
inventory_snapshot_sku: item.sku,
|
||||
inventory_snapshot_manufacturer: item.manufacturer,
|
||||
inventory_snapshot_part_number: item.manufacturer_part_number,
|
||||
inventory_price_overridden: false,
|
||||
title: item.name,
|
||||
description: item.description ?? "",
|
||||
quantity: "1",
|
||||
unit: item.unit,
|
||||
unit_price_euros: centsToEuroInput(item.selling_price_cents ?? 0),
|
||||
};
|
||||
setPayload((current) => ({ ...current, items: [...current.items, formItem] }));
|
||||
setInventoryDialogOpen(false);
|
||||
}
|
||||
|
||||
async function saveEstimate() {
|
||||
setSaving(true);
|
||||
try {
|
||||
|
|
@ -254,6 +335,8 @@ export default function RepairEstimatesSection({
|
|||
valid_until: payload.valid_until || null,
|
||||
items: payload.items.map((item) => ({
|
||||
item_type: item.item_type,
|
||||
inventory_item_id: item.inventory_item_id,
|
||||
inventory_price_overridden: item.inventory_price_overridden,
|
||||
title: item.title.trim(),
|
||||
description: item.description?.trim() || null,
|
||||
quantity: item.quantity.replace(",", "."),
|
||||
|
|
@ -371,7 +454,15 @@ export default function RepairEstimatesSection({
|
|||
<div key={item.id} className="flex items-start justify-between gap-3 p-3 text-sm">
|
||||
<div>
|
||||
<p className="font-medium text-slate-950">{item.position}. {item.title}</p>
|
||||
<p className="text-xs text-slate-500">{itemTypeLabels[item.item_type]} · {item.quantity} {item.unit} × {money(item.unit_price_cents, estimate.currency)}</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{item.inventory_item_id ? `Lagerartikel · ${item.inventory_snapshot_sku} · ` : ""}
|
||||
{itemTypeLabels[item.item_type]} · {item.quantity} {item.unit} × {money(item.unit_price_cents, estimate.currency)}
|
||||
</p>
|
||||
{item.inventory_item_id && (
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
{item.inventory_snapshot_manufacturer || "Hersteller nicht angegeben"} · {item.inventory_snapshot_name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="font-medium text-slate-950">{money(item.total_cents, estimate.currency)}</p>
|
||||
</div>
|
||||
|
|
@ -409,18 +500,32 @@ export default function RepairEstimatesSection({
|
|||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-semibold text-slate-950">Positionen</h4>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setPayload((current) => ({ ...current, items: [...current.items, emptyItem()] }))}><Plus />Position</Button>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setInventoryDialogOpen(true)}><PackageSearch />Lagerartikel auswählen</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setPayload((current) => ({ ...current, items: [...current.items, emptyItem()] }))}><Plus />Position</Button>
|
||||
</div>
|
||||
</div>
|
||||
{payload.items.map((item, index) => (
|
||||
<div key={index} className="rounded-lg border bg-slate-50 p-3">
|
||||
{item.inventory_item_id && (
|
||||
<div className="mb-3 flex flex-col gap-2 rounded-lg border bg-white p-3 text-sm text-slate-600 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<span className="font-semibold text-slate-950">Lagerartikel</span> · {item.inventory_snapshot_sku} · {item.inventory_snapshot_manufacturer || "Hersteller nicht angegeben"}
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-xs font-medium text-slate-600">
|
||||
<input type="checkbox" checked={item.inventory_price_overridden} onChange={(event) => updateItem(index, { inventory_price_overridden: event.target.checked })} />
|
||||
Preis manuell überschreiben
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-3 md:grid-cols-[1fr_1fr_0.7fr_0.7fr_0.8fr_auto]">
|
||||
<select className="h-8 rounded-lg border border-input bg-white px-2 text-sm" value={item.item_type} onChange={(event) => updateItem(index, { item_type: event.target.value as RepairEstimateItemType })}>
|
||||
<select className="h-8 rounded-lg border border-input bg-white px-2 text-sm" value={item.item_type} disabled={Boolean(item.inventory_item_id)} onChange={(event) => updateItem(index, { item_type: event.target.value as RepairEstimateItemType })}>
|
||||
{Object.entries(itemTypeLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
|
||||
</select>
|
||||
<Input placeholder="Titel" value={item.title} onChange={(event) => updateItem(index, { title: event.target.value })} />
|
||||
<Input placeholder="Titel" value={item.title} readOnly={Boolean(item.inventory_item_id)} onChange={(event) => updateItem(index, { title: event.target.value })} />
|
||||
<Input placeholder="Menge" value={item.quantity} onChange={(event) => updateItem(index, { quantity: event.target.value })} />
|
||||
<Input placeholder="Einheit" value={item.unit} onChange={(event) => updateItem(index, { unit: event.target.value })} />
|
||||
<Input inputMode="decimal" placeholder="Einzelpreis €" value={item.unit_price_euros} onChange={(event) => updateItem(index, { unit_price_euros: event.target.value })} />
|
||||
<Input placeholder="Einheit" value={item.unit} readOnly={Boolean(item.inventory_item_id)} onChange={(event) => updateItem(index, { unit: event.target.value })} />
|
||||
<Input inputMode="decimal" placeholder="Einzelpreis €" value={item.unit_price_euros} readOnly={Boolean(item.inventory_item_id && !item.inventory_price_overridden)} onChange={(event) => updateItem(index, { unit_price_euros: event.target.value })} />
|
||||
<Button type="button" variant="ghost" size="icon" disabled={payload.items.length === 1} onClick={() => setPayload((current) => ({ ...current, items: current.items.filter((_, itemIndex) => itemIndex !== index) }))} aria-label="Position entfernen"><Trash2 /></Button>
|
||||
</div>
|
||||
<Input className="mt-3" placeholder="Beschreibung optional" value={item.description ?? ""} onChange={(event) => updateItem(index, { description: event.target.value })} />
|
||||
|
|
@ -442,6 +547,65 @@ export default function RepairEstimatesSection({
|
|||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={inventoryDialogOpen} onOpenChange={setInventoryDialogOpen}>
|
||||
<DialogContent className="sm:max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Lagerartikel auswählen</DialogTitle>
|
||||
<DialogDescription>Aktive Artikel werden als KV-Position übernommen. Preis und Stammdaten werden beim Speichern serverseitig geprüft.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-3 md:grid-cols-[1fr_220px_180px]">
|
||||
<Input placeholder="SKU, Name, Hersteller oder Gerät suchen" value={inventorySearch} onChange={(event) => setInventorySearch(event.target.value)} />
|
||||
<select className="h-8 rounded-lg border border-input bg-white px-2 text-sm" value={inventoryCategory} onChange={(event) => setInventoryCategory(event.target.value)}>
|
||||
<option value="all">Alle Kategorien</option>
|
||||
{inventoryCategories.map((category) => <option key={category.id} value={category.id}>{category.name}</option>)}
|
||||
</select>
|
||||
<Input placeholder="Hersteller" value={inventoryManufacturer} onChange={(event) => setInventoryManufacturer(event.target.value)} />
|
||||
</div>
|
||||
|
||||
{inventoryError ? (
|
||||
<p className="rounded-lg border bg-red-50 p-3 text-sm text-red-700">{inventoryError}</p>
|
||||
) : inventoryLoading ? (
|
||||
<p className="rounded-lg border bg-slate-50 p-3 text-sm text-slate-500">Lagerartikel werden geladen...</p>
|
||||
) : inventoryItems.length === 0 ? (
|
||||
<p className="rounded-lg border bg-slate-50 p-3 text-sm text-slate-500">Keine aktiven Lagerartikel gefunden.</p>
|
||||
) : (
|
||||
<div className="max-h-[48vh] divide-y overflow-y-auto rounded-lg border">
|
||||
{inventoryItems.map((item) => {
|
||||
const lowAfterSelection = item.quantity_available - 1 <= item.reorder_level;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className="grid w-full gap-2 p-3 text-left hover:bg-slate-50 md:grid-cols-[1fr_auto]"
|
||||
onClick={() => addInventoryItem(item)}
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-slate-950">{item.sku} · {item.name}</p>
|
||||
<p className="text-sm text-slate-500">
|
||||
{item.manufacturer || "Hersteller nicht angegeben"} · Verfügbar {item.quantity_available} {item.unit} · {item.location?.name ?? "Kein Lagerort"}
|
||||
</p>
|
||||
{lowAfterSelection && (
|
||||
<p className="mt-1 inline-flex items-center gap-1 text-xs font-medium text-amber-700">
|
||||
<AlertTriangle size={14} /> Auswahl erreicht oder unterschreitet den Mindestbestand.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="font-semibold text-slate-950">{money(item.selling_price_cents ?? 0, item.currency)}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setInventoryDialogOpen(false)}>Schließen</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(deleteTarget)}
|
||||
title="Kostenvoranschlag löschen?"
|
||||
|
|
|
|||
|
|
@ -166,6 +166,8 @@ export interface RepairDocument {
|
|||
|
||||
export interface RepairEstimateItemPayload {
|
||||
item_type: RepairEstimateItemType;
|
||||
inventory_item_id?: number | null;
|
||||
inventory_price_overridden?: boolean;
|
||||
title: string;
|
||||
description: string | null;
|
||||
quantity: string;
|
||||
|
|
@ -186,6 +188,10 @@ export interface RepairEstimatePayload {
|
|||
export interface RepairEstimateItem extends RepairEstimateItemPayload {
|
||||
id: number;
|
||||
estimate_id: number;
|
||||
inventory_snapshot_name: string;
|
||||
inventory_snapshot_sku: string;
|
||||
inventory_snapshot_manufacturer: string | null;
|
||||
inventory_snapshot_part_number: string | null;
|
||||
position: number;
|
||||
total_cents: number;
|
||||
created_at: string;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue