Compare commits
3 Commits
5ea0719225
...
af501d18c7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af501d18c7 | ||
|
|
49a7a0768d | ||
|
|
e0d9c1d20e |
@@ -17,8 +17,10 @@ export function HeaderWrapper({ navbar, children }: HeaderWrapperProps) {
|
|||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isWizard = pathname === "/order" || pathname === "/wizard";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col min-h-screen bg-slate-50 text-slate-900 dark:bg-[#020617] dark:text-white">
|
<div className={`flex flex-col ${isWizard ? "h-screen overflow-hidden" : "min-h-screen"} bg-slate-50 text-slate-900 dark:bg-[#020617] dark:text-white`}>
|
||||||
{/* Demo Banner */}
|
{/* Demo Banner */}
|
||||||
<DemoWrapper>
|
<DemoWrapper>
|
||||||
<div
|
<div
|
||||||
@@ -31,7 +33,7 @@ export function HeaderWrapper({ navbar, children }: HeaderWrapperProps) {
|
|||||||
|
|
||||||
{navbar}
|
{navbar}
|
||||||
|
|
||||||
<main className="flex-1">
|
<main className={`flex-1 ${isWizard ? "overflow-hidden" : ""}`}>
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ import {
|
|||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { updateOrderStatus, rejectOrder } from "@/lib/actions/orders";
|
import { updateOrderStatus, rejectOrder, updateOrderPayload } from "@/lib/actions/orders";
|
||||||
|
|
||||||
interface OrdersTableProps {
|
interface OrdersTableProps {
|
||||||
initialOrders: any[];
|
initialOrders: any[];
|
||||||
@@ -83,6 +83,102 @@ export function OrdersTable({ initialOrders }: OrdersTableProps) {
|
|||||||
const [rejectionReason, setRejectionReason] = useState("");
|
const [rejectionReason, setRejectionReason] = useState("");
|
||||||
const [isSubmittingReject, setIsSubmittingReject] = useState(false);
|
const [isSubmittingReject, setIsSubmittingReject] = useState(false);
|
||||||
|
|
||||||
|
// Preiskorrektur States
|
||||||
|
const [isEditingSnapshot, setIsEditingSnapshot] = useState(false);
|
||||||
|
const [editedSnapshot, setEditedSnapshot] = useState<any | null>(null);
|
||||||
|
const [isSavingSnapshot, setIsSavingSnapshot] = useState(false);
|
||||||
|
|
||||||
|
// Initialize editedSnapshot when selectedOrder changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedOrder) {
|
||||||
|
setEditedSnapshot(JSON.parse(JSON.stringify(selectedOrder.order_data || {})));
|
||||||
|
setIsEditingSnapshot(false);
|
||||||
|
} else {
|
||||||
|
setEditedSnapshot(null);
|
||||||
|
setIsEditingSnapshot(false);
|
||||||
|
}
|
||||||
|
}, [selectedOrder]);
|
||||||
|
|
||||||
|
const updateItemBasePrice = (itemIdx: number, newPrice: number) => {
|
||||||
|
if (!editedSnapshot) return;
|
||||||
|
const updatedItems = [...editedSnapshot.items];
|
||||||
|
const item = { ...updatedItems[itemIdx] };
|
||||||
|
item.base_price = newPrice;
|
||||||
|
|
||||||
|
const moduleTotal = (item.selected_modules || []).reduce((acc: number, m: any) => acc + (m.total_price ?? (m.price * (m.quantity || 1))), 0);
|
||||||
|
item.item_total = newPrice + moduleTotal;
|
||||||
|
|
||||||
|
updatedItems[itemIdx] = item;
|
||||||
|
recalculateSnapshot(updatedItems);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateModulePriceOrQty = (itemIdx: number, modIdx: number, field: 'price' | 'quantity', value: number) => {
|
||||||
|
if (!editedSnapshot) return;
|
||||||
|
const updatedItems = [...editedSnapshot.items];
|
||||||
|
const item = { ...updatedItems[itemIdx] };
|
||||||
|
const updatedModules = [...(item.selected_modules || [])];
|
||||||
|
const mod = { ...updatedModules[modIdx] };
|
||||||
|
|
||||||
|
if (field === 'price') {
|
||||||
|
mod.price = value;
|
||||||
|
} else {
|
||||||
|
mod.quantity = value;
|
||||||
|
}
|
||||||
|
mod.total_price = mod.price * (mod.quantity || 1);
|
||||||
|
updatedModules[modIdx] = mod;
|
||||||
|
|
||||||
|
item.selected_modules = updatedModules;
|
||||||
|
const moduleTotal = updatedModules.reduce((acc: number, m: any) => acc + (m.total_price ?? (m.price * (m.quantity || 1))), 0);
|
||||||
|
item.item_total = (item.base_price || 0) + moduleTotal;
|
||||||
|
|
||||||
|
updatedItems[itemIdx] = item;
|
||||||
|
recalculateSnapshot(updatedItems);
|
||||||
|
};
|
||||||
|
|
||||||
|
const recalculateSnapshot = (updatedItems: any[]) => {
|
||||||
|
if (!editedSnapshot) return;
|
||||||
|
const taxRate = editedSnapshot.tax_rate ?? 19;
|
||||||
|
const subtotal = updatedItems.reduce((acc: number, item: any) => acc + (item.item_total || 0), 0);
|
||||||
|
const taxAmount = Math.round(subtotal * (taxRate / 100) * 100) / 100;
|
||||||
|
const total = Math.round((subtotal + taxAmount) * 100) / 100;
|
||||||
|
|
||||||
|
setEditedSnapshot({
|
||||||
|
...editedSnapshot,
|
||||||
|
items: updatedItems,
|
||||||
|
subtotal,
|
||||||
|
tax_amount: taxAmount,
|
||||||
|
total
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveSnapshot = async () => {
|
||||||
|
if (!editedSnapshot || !selectedOrder) return;
|
||||||
|
setIsSavingSnapshot(true);
|
||||||
|
try {
|
||||||
|
const updated = await updateOrderPayload(selectedOrder.id, {
|
||||||
|
orderSnapshot: editedSnapshot
|
||||||
|
});
|
||||||
|
setOrders(prev => prev.map(o => o.id === selectedOrder.id ? { ...o, order_data: editedSnapshot, total_price: editedSnapshot.total } : o));
|
||||||
|
setSelectedOrder({ ...selectedOrder, order_data: editedSnapshot, total_price: editedSnapshot.total });
|
||||||
|
setIsEditingSnapshot(false);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(error);
|
||||||
|
alert("Fehler beim Speichern der Änderungen: " + error.message);
|
||||||
|
} finally {
|
||||||
|
setIsSavingSnapshot(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Badge-Counter berechnen
|
||||||
|
const countAll = orders.length;
|
||||||
|
const countOffen = orders.filter(o => ["pending", "pending_approval", "in_review"].includes(o.status)).length;
|
||||||
|
const countApproved = orders.filter(o => o.status === "approved").length;
|
||||||
|
const countActive = orders.filter(o => ["active", "completed"].includes(o.status)).length;
|
||||||
|
const countRejected = orders.filter(o => ["rejected", "cancelled"].includes(o.status)).length;
|
||||||
|
|
||||||
|
const displaySnapshot = editedSnapshot || selectedOrder?.order_data || {};
|
||||||
|
const displayItems = displaySnapshot.items || [];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setOrders(initialOrders);
|
setOrders(initialOrders);
|
||||||
}, [initialOrders]);
|
}, [initialOrders]);
|
||||||
@@ -142,7 +238,12 @@ export function OrdersTable({ initialOrders }: OrdersTableProps) {
|
|||||||
company.includes(search.toLowerCase()) ||
|
company.includes(search.toLowerCase()) ||
|
||||||
contact.includes(search.toLowerCase());
|
contact.includes(search.toLowerCase());
|
||||||
|
|
||||||
const matchesStatus = statusFilter === "all" || order.status === statusFilter;
|
let matchesStatus = false;
|
||||||
|
if (statusFilter === "all") matchesStatus = true;
|
||||||
|
else if (statusFilter === "offen") matchesStatus = ["pending", "pending_approval", "in_review"].includes(order.status);
|
||||||
|
else if (statusFilter === "approved") matchesStatus = order.status === "approved";
|
||||||
|
else if (statusFilter === "active") matchesStatus = ["active", "completed"].includes(order.status);
|
||||||
|
else if (statusFilter === "rejected") matchesStatus = ["rejected", "cancelled"].includes(order.status);
|
||||||
|
|
||||||
return matchesSearch && matchesStatus;
|
return matchesSearch && matchesStatus;
|
||||||
})
|
})
|
||||||
@@ -208,57 +309,41 @@ export function OrdersTable({ initialOrders }: OrdersTableProps) {
|
|||||||
variant={statusFilter === "all" ? "default" : "outline"}
|
variant={statusFilter === "all" ? "default" : "outline"}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setStatusFilter("all")}
|
onClick={() => setStatusFilter("all")}
|
||||||
className="text-xs"
|
className="text-xs gap-1.5"
|
||||||
>
|
>
|
||||||
Alle
|
Alle <Badge variant="secondary" className="bg-white/10 text-white border-0 font-normal px-1 py-0 h-4 min-w-[16px] flex items-center justify-center rounded-full text-[10px]">{countAll}</Badge>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={statusFilter === "pending" ? "default" : "outline"}
|
variant={statusFilter === "offen" ? "default" : "outline"}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setStatusFilter("pending")}
|
onClick={() => setStatusFilter("offen")}
|
||||||
className="text-xs border-amber-500/20 hover:bg-amber-500/10 text-amber-400"
|
className="text-xs border-amber-500/20 hover:bg-amber-500/10 text-amber-400 gap-1.5"
|
||||||
>
|
>
|
||||||
Eingegangen
|
Offen / in_review <Badge variant="secondary" className="bg-amber-500/20 text-amber-400 border-0 font-normal px-1 py-0 h-4 min-w-[16px] flex items-center justify-center rounded-full text-[10px]">{countOffen}</Badge>
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={statusFilter === "pending_approval" ? "default" : "outline"}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setStatusFilter("pending_approval")}
|
|
||||||
className="text-xs border-amber-500/30 hover:bg-amber-500/20 text-amber-300"
|
|
||||||
>
|
|
||||||
Wartet auf Freigabe
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={statusFilter === "in_review" ? "default" : "outline"}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setStatusFilter("in_review")}
|
|
||||||
className="text-xs border-purple-500/20 hover:bg-purple-500/10 text-purple-400"
|
|
||||||
>
|
|
||||||
In Prüfung
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={statusFilter === "approved" ? "default" : "outline"}
|
variant={statusFilter === "approved" ? "default" : "outline"}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setStatusFilter("approved")}
|
onClick={() => setStatusFilter("approved")}
|
||||||
className="text-xs border-emerald-500/20 hover:bg-emerald-500/10 text-emerald-400"
|
className="text-xs border-emerald-500/20 hover:bg-emerald-500/10 text-emerald-400 gap-1.5"
|
||||||
>
|
>
|
||||||
Freigegeben
|
Genehmigt <Badge variant="secondary" className="bg-emerald-500/20 text-emerald-400 border-0 font-normal px-1 py-0 h-4 min-w-[16px] flex items-center justify-center rounded-full text-[10px]">{countApproved}</Badge>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={statusFilter === "active" ? "default" : "outline"}
|
variant={statusFilter === "active" ? "default" : "outline"}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setStatusFilter("active")}
|
onClick={() => setStatusFilter("active")}
|
||||||
className="text-xs border-blue-500/20 hover:bg-blue-500/10 text-blue-400"
|
className="text-xs border-blue-500/20 hover:bg-blue-500/10 text-blue-400 gap-1.5"
|
||||||
>
|
>
|
||||||
Aktiviert
|
Aktiv <Badge variant="secondary" className="bg-blue-500/20 text-blue-400 border-0 font-normal px-1 py-0 h-4 min-w-[16px] flex items-center justify-center rounded-full text-[10px]">{countActive}</Badge>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={statusFilter === "rejected" ? "default" : "outline"}
|
variant={statusFilter === "rejected" ? "default" : "outline"}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setStatusFilter("rejected")}
|
onClick={() => setStatusFilter("rejected")}
|
||||||
className="text-xs border-red-500/20 hover:bg-red-500/10 text-red-400"
|
className="text-xs border-red-500/20 hover:bg-red-500/10 text-red-400 gap-1.5"
|
||||||
>
|
>
|
||||||
Abgelehnt
|
Abgelehnt <Badge variant="secondary" className="bg-red-500/20 text-red-400 border-0 font-normal px-1 py-0 h-4 min-w-[16px] flex items-center justify-center rounded-full text-[10px]">{countRejected}</Badge>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -372,7 +457,7 @@ export function OrdersTable({ initialOrders }: OrdersTableProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="sm" asChild className="h-8 border-amber-500/20 hover:bg-amber-500/10 text-amber-400 text-xs gap-1">
|
<Button variant="outline" size="sm" asChild className="h-8 border-amber-500/20 hover:bg-amber-500/10 text-amber-400 text-xs gap-1">
|
||||||
<a href={`/order?mode=admin_edit&orderId=${order.id}`}>
|
<a href={`/order?mode=admin_edit&orderId=${order.id}`}>
|
||||||
<Edit3 className="w-3 h-3" /> Im Wizard bearbeiten
|
<Edit3 className="w-3 h-3" /> Im Wizard öffnen
|
||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="sm" asChild className="h-8 border-white/10 hover:bg-primary/20 text-xs">
|
<Button variant="outline" size="sm" asChild className="h-8 border-white/10 hover:bg-primary/20 text-xs">
|
||||||
@@ -395,30 +480,34 @@ export function OrdersTable({ initialOrders }: OrdersTableProps) {
|
|||||||
<Dialog open={!!selectedOrder} onOpenChange={(open) => !open && setSelectedOrder(null)}>
|
<Dialog open={!!selectedOrder} onOpenChange={(open) => !open && setSelectedOrder(null)}>
|
||||||
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto bg-slate-950 border-white/10 text-white p-6 space-y-6">
|
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto bg-slate-950 border-white/10 text-white p-6 space-y-6">
|
||||||
<DialogHeader className="border-b border-white/10 pb-4">
|
<DialogHeader className="border-b border-white/10 pb-4">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<DialogTitle className="text-xl font-bold text-white flex items-center gap-2">
|
<DialogTitle className="text-xl font-bold text-white flex items-center gap-2">
|
||||||
<FileText className="w-5 h-5 text-primary" />
|
<FileText className="w-5 h-5 text-primary" />
|
||||||
Anfragenr. #{selectedOrder.order_number || selectedOrder.id.slice(0, 8)}
|
Anfragenr. #{selectedOrder.order_number || selectedOrder.id.slice(0, 8)}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<Badge className={`text-xs px-2.5 py-0.5 rounded ${statusClass[selectedOrder.status]}`}>
|
<Badge className={`text-xs px-2.5 py-0.5 rounded ${statusClass[selectedOrder.status]}`}>
|
||||||
{statusLabel[selectedOrder.status] ?? selectedOrder.status}
|
{statusLabel[selectedOrder.status] ?? selectedOrder.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<DialogDescription className="text-xs text-slate-400 mt-1 flex items-center gap-2">
|
<DialogDescription className="text-xs text-slate-400 mt-1 flex items-center gap-2">
|
||||||
<Calendar className="w-3.5 h-3.5 text-slate-500" />
|
<Calendar className="w-3.5 h-3.5 text-slate-500" />
|
||||||
Erstellt am {new Date(selectedOrder.created_at).toLocaleString('de-DE')}
|
Erstellt am {new Date(selectedOrder.created_at).toLocaleString('de-DE')}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<div className="text-xs text-slate-400 uppercase tracking-wider font-semibold">Gesamtsumme</div>
|
<div className="text-xs text-slate-400 uppercase tracking-wider font-semibold">
|
||||||
<div className="text-2xl font-extrabold text-primary">
|
{isEditingSnapshot ? "Vorschau Summe" : "Gesamtsumme"}
|
||||||
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(selectedOrder.total_price || 0)}
|
</div>
|
||||||
</div>
|
<div className="text-2xl font-extrabold text-primary">
|
||||||
</div>
|
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(
|
||||||
</div>
|
isEditingSnapshot ? (editedSnapshot?.total || 0) : (selectedOrder.total_price || 0)
|
||||||
</DialogHeader>
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
{/* Kunden & Stammdaten */}
|
{/* Kunden & Stammdaten */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
@@ -458,13 +547,53 @@ export function OrdersTable({ initialOrders }: OrdersTableProps) {
|
|||||||
|
|
||||||
{/* Geräte & Produktauswahl (Nach Kassen / Boxen gruppiert) */}
|
{/* Geräte & Produktauswahl (Nach Kassen / Boxen gruppiert) */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="text-xs font-bold text-slate-400 uppercase tracking-wider flex items-center gap-1.5">
|
<div className="flex items-center justify-between">
|
||||||
<Package className="w-4 h-4 text-primary" />
|
<div className="text-xs font-bold text-slate-400 uppercase tracking-wider flex items-center gap-1.5">
|
||||||
Konfigurierte Kassen ({selectedOrder.order_data?.items?.length || 0})
|
<Package className="w-4 h-4 text-primary" />
|
||||||
|
Konfigurierte Kassen ({displayItems.length})
|
||||||
|
</div>
|
||||||
|
{selectedOrder.status === 'in_review' && !isEditingSnapshot && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setIsEditingSnapshot(true)}
|
||||||
|
className="h-7 border-primary/30 text-primary hover:bg-primary/10 text-xs font-bold gap-1 rounded-md"
|
||||||
|
>
|
||||||
|
<Edit3 className="w-3 h-3" /> Preise anpassen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isEditingSnapshot && (
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between bg-amber-500/10 border border-amber-500/20 p-3 rounded-xl gap-4 animate-pulse">
|
||||||
|
<span className="text-xs text-amber-400 font-semibold">Sie befinden sich im Preiskorrektur-Modus.</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setEditedSnapshot(JSON.parse(JSON.stringify(selectedOrder.order_data || {})));
|
||||||
|
setIsEditingSnapshot(false);
|
||||||
|
}}
|
||||||
|
className="h-7 text-xs text-slate-300 hover:text-white"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSaveSnapshot}
|
||||||
|
disabled={isSavingSnapshot}
|
||||||
|
className="h-7 text-xs bg-amber-600 hover:bg-amber-500 text-white font-bold"
|
||||||
|
>
|
||||||
|
{isSavingSnapshot ? <Loader2 className="w-3 h-3 animate-spin mr-1" /> : null}
|
||||||
|
Änderungen speichern
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-3">
|
<div className="grid grid-cols-1 gap-3">
|
||||||
{(selectedOrder.order_data?.items || []).map((item: any, idx: number) => {
|
{displayItems.map((item: any, idx: number) => {
|
||||||
const devName = item.device_name || `Kasse #${idx + 1}`
|
const devName = item.device_name || `Kasse #${idx + 1}`
|
||||||
const licNum = item.license_number
|
const licNum = item.license_number
|
||||||
const modules = item.selected_modules || []
|
const modules = item.selected_modules || []
|
||||||
@@ -492,10 +621,23 @@ export function OrdersTable({ initialOrders }: OrdersTableProps) {
|
|||||||
{/* Inhalt der Kasse: Hauptprodukt + Preis */}
|
{/* Inhalt der Kasse: Hauptprodukt + Preis */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="font-bold text-sm text-white">{item.product_name}</span>
|
<span className="font-bold text-sm text-white">{item.product_name}</span>
|
||||||
{(item.price || item.base_price) && (
|
{isEditingSnapshot ? (
|
||||||
<span className="text-xs font-bold text-slate-200 font-mono">
|
<div className="flex items-center gap-2">
|
||||||
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(item.price || item.base_price)}
|
<Label htmlFor={`price-${idx}`} className="text-[10px] text-slate-400 font-sans">Preis (€):</Label>
|
||||||
</span>
|
<Input
|
||||||
|
id={`price-${idx}`}
|
||||||
|
type="number"
|
||||||
|
value={item.base_price || 0}
|
||||||
|
onChange={(e) => updateItemBasePrice(idx, parseFloat(e.target.value) || 0)}
|
||||||
|
className="w-20 h-7 bg-slate-950 border-white/10 text-white text-xs px-2 font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
(item.price || item.base_price) && (
|
||||||
|
<span className="text-xs font-bold text-slate-200 font-mono">
|
||||||
|
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(item.price || item.base_price)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -508,14 +650,34 @@ export function OrdersTable({ initialOrders }: OrdersTableProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
{modules.map((mod: any, mIdx: number) => (
|
{modules.map((mod: any, mIdx: number) => (
|
||||||
<div key={mIdx} className="p-2 rounded-lg bg-slate-950/60 border border-white/5 text-xs flex items-center justify-between">
|
<div key={mIdx} className="p-2 rounded-lg bg-slate-950/60 border border-white/5 text-xs flex items-center justify-between gap-3">
|
||||||
<span className="text-slate-300 font-medium truncate">
|
<span className="text-slate-300 font-medium truncate">
|
||||||
+ {mod.module_name || mod.name} {mod.quantity > 1 ? `(${mod.quantity}x)` : ''}
|
+ {mod.module_name || mod.name} {!isEditingSnapshot && mod.quantity > 1 ? `(${mod.quantity}x)` : ''}
|
||||||
</span>
|
</span>
|
||||||
{mod.price && (
|
{isEditingSnapshot ? (
|
||||||
<span className="text-slate-400 font-mono text-[11px]">
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
+{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(mod.price)}
|
<Input
|
||||||
</span>
|
type="number"
|
||||||
|
title="Einzelpreis"
|
||||||
|
value={mod.price || 0}
|
||||||
|
onChange={(e) => updateModulePriceOrQty(idx, mIdx, 'price', parseFloat(e.target.value) || 0)}
|
||||||
|
className="w-14 h-6 bg-slate-900 border-white/10 text-white text-[10px] px-1 font-mono"
|
||||||
|
/>
|
||||||
|
<span className="text-[10px] text-slate-500 font-sans">x</span>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
title="Menge"
|
||||||
|
value={mod.quantity || 1}
|
||||||
|
onChange={(e) => updateModulePriceOrQty(idx, mIdx, 'quantity', parseInt(e.target.value) || 1)}
|
||||||
|
className="w-10 h-6 bg-slate-900 border-white/10 text-white text-[10px] px-1 font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
mod.price && (
|
||||||
|
<span className="text-slate-400 font-mono text-[11px] shrink-0">
|
||||||
|
+{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(mod.price * (mod.quantity || 1))}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -571,34 +733,34 @@ export function OrdersTable({ initialOrders }: OrdersTableProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter className="border-t border-white/10 pt-4 flex flex-col sm:flex-row justify-between items-center gap-3">
|
<DialogFooter className="border-t border-white/10 pt-4 flex flex-col sm:flex-row justify-between items-center gap-3">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setSelectedOrder(null)}
|
onClick={() => setSelectedOrder(null)}
|
||||||
className="text-slate-400 hover:text-white text-xs"
|
className="text-slate-400 hover:text-white text-xs"
|
||||||
>
|
>
|
||||||
Schließen
|
Schließen
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button variant="outline" size="sm" asChild className="h-8 border-amber-500/20 hover:bg-amber-500/10 text-amber-400 text-xs gap-1">
|
<Button variant="outline" size="sm" asChild className="h-8 border-amber-500/20 hover:bg-amber-500/10 text-amber-400 text-xs gap-1">
|
||||||
<a href={`/order?mode=admin_edit&orderId=${selectedOrder.id}`}>
|
<a href={`/order?mode=admin_edit&orderId=${selectedOrder.id}`}>
|
||||||
<Edit3 className="w-3 h-3" /> Im Wizard bearbeiten
|
<Edit3 className="w-3 h-3" /> Im Wizard öffnen
|
||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="sm" asChild className="h-8 border-white/10 hover:bg-primary/20 text-xs">
|
<Button variant="outline" size="sm" asChild className="h-8 border-white/10 hover:bg-primary/20 text-xs">
|
||||||
<a href={`/api/admin/orders/${selectedOrder.id}/download?inline=true`} target="_blank" rel="noopener noreferrer">
|
<a href={`/api/admin/orders/${selectedOrder.id}/download?inline=true`} target="_blank" rel="noopener noreferrer">
|
||||||
<ExternalLink className="w-3.5 h-3.5 mr-1" /> PDF Vorschau
|
<ExternalLink className="w-3.5 h-3.5 mr-1" /> PDF Vorschau
|
||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="secondary" size="sm" asChild className="h-8 text-xs">
|
<Button variant="secondary" size="sm" asChild className="h-8 text-xs">
|
||||||
<a href={`/api/admin/orders/${selectedOrder.id}/download`}>
|
<a href={`/api/admin/orders/${selectedOrder.id}/download`}>
|
||||||
<Download className="w-3.5 h-3.5 mr-1" /> PDF Download
|
<Download className="w-3.5 h-3.5 mr-1" /> PDF Download
|
||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import type { EndCustomerWithDevices, FlattenedDevice } from '@/lib/types'
|
import type { EndCustomerWithDevices, FlattenedDevice } from '@/lib/types'
|
||||||
import {
|
import {
|
||||||
@@ -250,7 +251,16 @@ export function CustomerAccordionList({ customers }: CustomerAccordionListProps)
|
|||||||
{/* Akkordeon-Header */}
|
{/* Akkordeon-Header */}
|
||||||
<div
|
<div
|
||||||
onClick={() => toggleCustomer(customer.id)}
|
onClick={() => toggleCustomer(customer.id)}
|
||||||
className="p-5 flex items-center justify-between cursor-pointer hover:bg-white/[0.02] transition-colors select-none gap-4 flex-wrap"
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
toggleCustomer(customer.id)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
tabIndex={0}
|
||||||
|
role="button"
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
className="p-5 flex items-center justify-between cursor-pointer hover:bg-white/[0.02] transition-colors select-none gap-4 flex-wrap focus:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-inset"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-4 min-w-[240px]">
|
<div className="flex items-center gap-4 min-w-[240px]">
|
||||||
<div className="w-10 h-10 rounded-xl bg-primary/10 border border-primary/20 flex items-center justify-center text-primary font-bold">
|
<div className="w-10 h-10 rounded-xl bg-primary/10 border border-primary/20 flex items-center justify-center text-primary font-bold">
|
||||||
@@ -324,28 +334,38 @@ export function CustomerAccordionList({ customers }: CustomerAccordionListProps)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Ausgeklappter Bereich mit Kassen */}
|
{/* Ausgeklappter Bereich mit Kassen */}
|
||||||
{isOpen && (
|
<AnimatePresence initial={false}>
|
||||||
<div className="border-t border-white/5 bg-slate-950/40 p-5 space-y-3">
|
{isOpen && (
|
||||||
{devices.length === 0 ? (
|
<motion.div
|
||||||
<div className="text-slate-500 text-sm py-4 text-center">
|
initial={{ height: 0, opacity: 0 }}
|
||||||
Keine Kassen oder Bestellungen für diesen Kunden vorhanden.
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2, ease: 'easeInOut' }}
|
||||||
|
className="overflow-hidden border-t border-white/5 bg-slate-950/40"
|
||||||
|
>
|
||||||
|
<div className="p-5 space-y-3">
|
||||||
|
{devices.length === 0 ? (
|
||||||
|
<div className="text-slate-500 text-sm py-4 text-center">
|
||||||
|
Keine Kassen oder Bestellungen für diesen Kunden vorhanden.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-xs uppercase tracking-wider text-slate-400 font-semibold mb-2">
|
||||||
|
Zugeordnete Kassen ({devices.length})
|
||||||
|
</p>
|
||||||
|
{devices.map((device, idx) => (
|
||||||
|
<DeviceCard
|
||||||
|
key={`${device.orderId}-${device.deviceId}-${idx}`}
|
||||||
|
device={device}
|
||||||
|
customerId={customer.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</motion.div>
|
||||||
<div className="space-y-3">
|
)}
|
||||||
<p className="text-xs uppercase tracking-wider text-slate-400 font-semibold mb-2">
|
</AnimatePresence>
|
||||||
Zugeordnete Kassen ({devices.length})
|
|
||||||
</p>
|
|
||||||
{devices.map((device, idx) => (
|
|
||||||
<DeviceCard
|
|
||||||
key={`${device.orderId}-${device.deviceId}-${idx}`}
|
|
||||||
device={device}
|
|
||||||
customerId={customer.id}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -105,6 +105,8 @@ export const InvoicePDF = ({
|
|||||||
const items = orderSnapshot?.items ?? [];
|
const items = orderSnapshot?.items ?? [];
|
||||||
const taxRate = orderSnapshot?.tax_rate ?? 19;
|
const taxRate = orderSnapshot?.tax_rate ?? 19;
|
||||||
const isSubscription = order?.type === 'subscription' || orderSnapshot?.billing_cycle === 'monthly';
|
const isSubscription = order?.type === 'subscription' || orderSnapshot?.billing_cycle === 'monthly';
|
||||||
|
const formattedOrderNumber = (order.order_number || order.id || '').replace(/^BE-/, 'AE-');
|
||||||
|
const isUpgrade = !!orderSnapshot?.last_license_date;
|
||||||
|
|
||||||
let oneTimeNet = 0;
|
let oneTimeNet = 0;
|
||||||
let monthlyNet = 0;
|
let monthlyNet = 0;
|
||||||
@@ -162,7 +164,7 @@ export const InvoicePDF = ({
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Text style={styles.title}>
|
<Text style={styles.title}>
|
||||||
Anfragebestätigung: {order.order_number || order.id}
|
{isUpgrade ? 'Erweiterungsangebot' : 'Anfragebestätigung'}: #{formattedOrderNumber}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 15 }}>
|
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 15 }}>
|
||||||
@@ -177,7 +179,7 @@ export const InvoicePDF = ({
|
|||||||
|
|
||||||
<View style={{ width: '48%' }}>
|
<View style={{ width: '48%' }}>
|
||||||
<Text style={styles.label}>Bestelldetails</Text>
|
<Text style={styles.label}>Bestelldetails</Text>
|
||||||
<Text>Bestellnummer: {order.order_number || order.id}</Text>
|
<Text>Bestellnummer: #{formattedOrderNumber}</Text>
|
||||||
<Text>Zahlungsart: {isSubscription ? 'SEPA-Lastschrift' : 'Rechnung'}</Text>
|
<Text>Zahlungsart: {isSubscription ? 'SEPA-Lastschrift' : 'Rechnung'}</Text>
|
||||||
<Text>Datum: {new Date(order.created_at || Date.now()).toLocaleDateString('de-DE')}</Text>
|
<Text>Datum: {new Date(order.created_at || Date.now()).toLocaleDateString('de-DE')}</Text>
|
||||||
|
|
||||||
@@ -197,7 +199,7 @@ export const InvoicePDF = ({
|
|||||||
<View key={groupIdx} style={{ marginBottom: 12, borderWidth: 1, borderColor: '#e2e8f0', borderRadius: 4, padding: 8, backgroundColor: '#f8fafc' }}>
|
<View key={groupIdx} style={{ marginBottom: 12, borderWidth: 1, borderColor: '#e2e8f0', borderRadius: 4, padding: 8, backgroundColor: '#f8fafc' }}>
|
||||||
<View style={{ borderBottomWidth: 1, borderBottomColor: '#cbd5e1', paddingBottom: 4, marginBottom: 6 }}>
|
<View style={{ borderBottomWidth: 1, borderBottomColor: '#cbd5e1', paddingBottom: 4, marginBottom: 6 }}>
|
||||||
<Text style={{ fontSize: 10, fontWeight: 'bold', color: '#1e3a8a' }}>
|
<Text style={{ fontSize: 10, fontWeight: 'bold', color: '#1e3a8a' }}>
|
||||||
{deviceName === 'Zusatzleistung' ? 'Backoffice' : `Kasse: ${deviceName}`}
|
{deviceName === 'Zusatzleistung' ? 'Backoffice' : `${isUpgrade ? 'Erweiterung für Gerät' : 'Kasse'}: ${deviceName}`}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
{devItems.map((item: any, idx: number) => (
|
{devItems.map((item: any, idx: number) => (
|
||||||
|
|||||||
@@ -1089,6 +1089,7 @@ export function OrderWizard({
|
|||||||
billingBadgeClass={billingBadgeClass}
|
billingBadgeClass={billingBadgeClass}
|
||||||
existingModuleIds={existingModuleIds}
|
existingModuleIds={existingModuleIds}
|
||||||
activeCategoryId={activeCategoryId}
|
activeCategoryId={activeCategoryId}
|
||||||
|
editingDeviceName={editingIdx !== null ? (basketItems[editingIdx]?.deviceName || `Kasse ${editingIdx + 1}`) : null}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ interface StepSoftwareProps {
|
|||||||
/** Modul-IDs, die bereits lizenziert sind (Upgrade-Modus) */
|
/** Modul-IDs, die bereits lizenziert sind (Upgrade-Modus) */
|
||||||
existingModuleIds?: string[]
|
existingModuleIds?: string[]
|
||||||
activeCategoryId: string | null
|
activeCategoryId: string | null
|
||||||
|
editingDeviceName?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CategoryIcon({ icon, className }: { icon?: string | null; className?: string }) {
|
export function CategoryIcon({ icon, className }: { icon?: string | null; className?: string }) {
|
||||||
@@ -52,6 +53,7 @@ export function StepSoftware({
|
|||||||
billingBadgeClass,
|
billingBadgeClass,
|
||||||
existingModuleIds = [],
|
existingModuleIds = [],
|
||||||
activeCategoryId,
|
activeCategoryId,
|
||||||
|
editingDeviceName = null,
|
||||||
}: StepSoftwareProps) {
|
}: StepSoftwareProps) {
|
||||||
const currentCategory = visibleCategories.find(c => c.id === activeCategoryId) ?? visibleCategories[0] ?? null
|
const currentCategory = visibleCategories.find(c => c.id === activeCategoryId) ?? visibleCategories[0] ?? null
|
||||||
|
|
||||||
@@ -69,15 +71,22 @@ export function StepSoftware({
|
|||||||
return (
|
return (
|
||||||
<Card className="glass-dark border-white/10 h-full flex flex-col rounded-2xl">
|
<Card className="glass-dark border-white/10 h-full flex flex-col rounded-2xl">
|
||||||
<CardHeader className="border-b border-white/5 pb-4">
|
<CardHeader className="border-b border-white/5 pb-4">
|
||||||
<CardTitle className="text-xl flex items-center gap-2 text-white font-bold">
|
<CardTitle className="text-xl flex items-center justify-between gap-2 text-white font-bold flex-wrap">
|
||||||
<ShoppingCart className="w-5 h-5 text-primary" />
|
<div className="flex items-center gap-2">
|
||||||
Optionen wählen
|
<ShoppingCart className="w-5 h-5 text-primary" />
|
||||||
|
<span>Optionen wählen</span>
|
||||||
|
</div>
|
||||||
|
{editingDeviceName && (
|
||||||
|
<Badge className="bg-amber-500/20 text-amber-400 border border-amber-500/30 text-xs font-bold gap-1 px-2.5 py-1 rounded-lg animate-pulse shrink-0">
|
||||||
|
✏️ Bearbeite Kasse: "{editingDeviceName}"
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="text-slate-400">
|
<CardDescription className="text-slate-400">
|
||||||
Passen Sie die Konfiguration für {currentCategory.name} an.
|
Passen Sie die Konfiguration für {currentCategory.name} an.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex-1 overflow-y-auto pt-6 space-y-6">
|
<CardContent className="flex-1 pt-6 space-y-6">
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
<motion.div
|
<motion.div
|
||||||
key={currentCategory.id}
|
key={currentCategory.id}
|
||||||
@@ -260,13 +269,14 @@ export function StepSoftware({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={module.id}
|
key={module.id}
|
||||||
|
title={isExistingLicense ? "Dieses Modul ist auf dieser Kasse bereits aktiv und dauerhaft lizenziert." : undefined}
|
||||||
className={`flex flex-col p-4 rounded-xl border transition-all duration-200 ${
|
className={`flex flex-col p-4 rounded-xl border transition-all duration-200 ${
|
||||||
isExistingLicense
|
isExistingLicense
|
||||||
? 'border-primary/20 bg-primary/5 opacity-75'
|
? 'border-primary/20 bg-primary/5 opacity-75'
|
||||||
: disabled
|
: disabled
|
||||||
? 'border-white/5 bg-white/5 opacity-40'
|
? 'border-white/5 bg-white/5 opacity-40'
|
||||||
: checked
|
: checked
|
||||||
? 'border-primary/30 bg-primary/5'
|
? 'border-primary bg-primary/10 shadow-[0_0_15px_rgba(59,130,246,0.15)] text-white'
|
||||||
: 'border-white/5 bg-white/5 hover:bg-white/10'
|
: 'border-white/5 bg-white/5 hover:bg-white/10'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ export function StepSummary({
|
|||||||
onAddNewBasketItem,
|
onAddNewBasketItem,
|
||||||
onDeleteBasketItem,
|
onDeleteBasketItem,
|
||||||
}: StepSummaryProps) {
|
}: StepSummaryProps) {
|
||||||
|
const [confirmDeleteIdx, setConfirmDeleteIdx] = React.useState<number | null>(null)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 h-full items-stretch text-left">
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 h-full items-stretch text-left">
|
||||||
{/* LINKER BEREICH: Scrollbares Bedienfeld */}
|
{/* LINKER BEREICH: Scrollbares Bedienfeld */}
|
||||||
@@ -211,16 +213,26 @@ export function StepSummary({
|
|||||||
{onDeleteBasketItem && (
|
{onDeleteBasketItem && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant={confirmDeleteIdx === itemIdx ? "destructive" : "ghost"}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
onDeleteBasketItem(itemIdx)
|
if (confirmDeleteIdx === itemIdx) {
|
||||||
|
onDeleteBasketItem(itemIdx)
|
||||||
|
setConfirmDeleteIdx(null)
|
||||||
|
} else {
|
||||||
|
setConfirmDeleteIdx(itemIdx)
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
className="h-7 px-2 text-xs text-slate-400 hover:text-red-400 hover:bg-red-500/20 gap-1 rounded-lg"
|
onMouseLeave={() => setConfirmDeleteIdx(null)}
|
||||||
title="Kasse löschen"
|
className={`h-7 px-2 text-xs transition-all ${
|
||||||
|
confirmDeleteIdx === itemIdx
|
||||||
|
? 'bg-red-600 text-white hover:bg-red-700 font-bold px-3 shadow-[0_0_10px_rgba(220,38,38,0.5)]'
|
||||||
|
: 'text-slate-400 hover:text-red-400 hover:bg-red-500/20'
|
||||||
|
} gap-1 rounded-lg`}
|
||||||
>
|
>
|
||||||
<Icons.Trash2 className="w-3 h-3" /> Löschen
|
<Icons.Trash2 className="w-3.5 h-3.5" />
|
||||||
|
{confirmDeleteIdx === itemIdx ? 'Wirklich löschen?' : 'Löschen'}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -283,7 +283,11 @@ export function SummarySidebar({
|
|||||||
onClick={nextStep}
|
onClick={nextStep}
|
||||||
disabled={isNextStepDisabled && basketItems.length === 0}
|
disabled={isNextStepDisabled && basketItems.length === 0}
|
||||||
>
|
>
|
||||||
Weiter zu Schritt 4 <ChevronRight className="ml-1 w-4 h-4" />
|
{editingIdx !== null ? (
|
||||||
|
<>Änderungen für "{basketItems[editingIdx]?.deviceName || `Kasse ${editingIdx + 1}`}" übernehmen</>
|
||||||
|
) : (
|
||||||
|
<>Weiter zu Schritt 4 <ChevronRight className="ml-1 w-4 h-4" /></>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" className="w-full text-slate-400 hover:text-white h-7 text-xs" onClick={prevStep}>
|
<Button variant="ghost" className="w-full text-slate-400 hover:text-white h-7 text-xs" onClick={prevStep}>
|
||||||
Zurück zum Abrechnungsmodell
|
Zurück zum Abrechnungsmodell
|
||||||
|
|||||||
@@ -99,31 +99,92 @@ export function getOrderEmailTemplate(
|
|||||||
return { text, html }
|
return { text, html }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getStatusEmailTemplate(orderNumber: string, oldLabel: string, newLabel: string) {
|
export function getStatusEmailTemplate(
|
||||||
const text = `Hallo,\n\nder Status deiner Anfrage ${orderNumber} hat sich geändert.\n\nStatus: ${newLabel} (vorher: ${oldLabel})\n\nViele Grüße,\nDein CASPOS Team`
|
orderNumber: string,
|
||||||
|
oldLabel: string,
|
||||||
|
newLabel: string,
|
||||||
|
statusKey?: string,
|
||||||
|
rejectionReason?: string
|
||||||
|
) {
|
||||||
|
const formattedOrderNumber = orderNumber.replace(/^BE-/, 'AE-')
|
||||||
|
|
||||||
|
let title = 'Statusänderung'
|
||||||
|
let intro = `der Status deiner Anfrage <strong>#${formattedOrderNumber}</strong> wurde aktualisiert.`
|
||||||
|
let statusColor = '#f59e0b' // Amber default
|
||||||
|
let showAttachmentBadge = false
|
||||||
|
|
||||||
|
if (statusKey === 'approved' || statusKey === 'active' || statusKey === 'completed') {
|
||||||
|
title = 'Freigabebestätigung'
|
||||||
|
intro = `Ihre Anfrage <strong>#${formattedOrderNumber}</strong> wurde erfolgreich freigegeben und ist nun aktiv. Die unterzeichnete Anfragebestätigung finden Sie im Anhang dieses Schreibens.`
|
||||||
|
statusColor = '#10b981' // Emerald
|
||||||
|
showAttachmentBadge = true
|
||||||
|
} else if (statusKey === 'rejected') {
|
||||||
|
title = 'Anfrage abgelehnt'
|
||||||
|
intro = `Ihre Anfrage <strong>#${formattedOrderNumber}</strong> wurde vom Support geprüft und abgelehnt.`
|
||||||
|
statusColor = '#ef4444' // Red
|
||||||
|
} else if (statusKey === 'pending_approval' || statusKey === 'in_review' || statusKey === 'pending') {
|
||||||
|
title = 'Eingangsbestätigung'
|
||||||
|
intro = `Ihre Anfrage <strong>#${formattedOrderNumber}</strong> ist eingegangen und wird derzeit von unserem Support geprüft.`
|
||||||
|
statusColor = '#f59e0b'
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = `Hallo,\n\n${title}\n\n${intro.replace(/<[^>]*>/g, '')}\n\nStatus: ${newLabel} (vorher: ${oldLabel})\n${rejectionReason ? `Grund: ${rejectionReason}\n` : ''}\nViele Grüße,\nDein CASPOS Team`
|
||||||
|
|
||||||
|
const reasonSection = rejectionReason
|
||||||
|
? `
|
||||||
|
<div style="margin-top: 20px; padding: 12px 16px; background-color: rgba(239, 68, 68, 0.1); border-left: 4px solid #ef4444; border-radius: 4px;">
|
||||||
|
<p style="margin: 0; font-size: 11px; color: #ef4444; font-weight: bold; text-transform: uppercase; tracking-spacing: 0.05em;">Begründung:</p>
|
||||||
|
<p style="margin: 4px 0 0 0; font-size: 14px; color: #f8fafc; font-style: italic;">"${rejectionReason}"</p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: ''
|
||||||
|
|
||||||
|
const footerActions = showAttachmentBadge
|
||||||
|
? `
|
||||||
|
<p style="text-align: center; margin: 32px 0 0 0;">
|
||||||
|
<span style="display: inline-block; background-color: #1e293b; border: 1px solid #334155; border-radius: 6px; padding: 10px 20px; color: #3b82f6; font-size: 13px; font-weight: bold; letter-spacing: 0.025em;">
|
||||||
|
📎 PDF im Anhang verfügbar
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
`
|
||||||
|
: ''
|
||||||
|
|
||||||
const html = `
|
const html = `
|
||||||
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e2e8f0; border-radius: 8px;">
|
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 32px; border: 1px solid #1e293b; border-radius: 12px; background-color: #0b0f19; color: #f8fafc;">
|
||||||
<h2 style="color: #0f172a; margin-bottom: 16px;">Statusänderung</h2>
|
<div style="text-align: center; margin-bottom: 32px;">
|
||||||
<p style="color: #475569; font-size: 16px; line-height: 1.5;">Hallo,</p>
|
<h1 style="color: #3b82f6; font-size: 26px; margin: 0; font-weight: 900; letter-spacing: -0.025em; text-transform: uppercase;">CASPOS</h1>
|
||||||
<p style="color: #475569; font-size: 16px; line-height: 1.5;">der Status deiner Anfrage <strong>${orderNumber}</strong> wurde aktualisiert.</p>
|
<p style="color: #64748b; font-size: 10px; margin: 2px 0 0 0; text-transform: uppercase; letter-spacing: 0.2em; font-weight: bold;">Die Kasse</p>
|
||||||
<div style="background-color: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; padding: 16px; margin: 24px 0;">
|
</div>
|
||||||
<table style="width: 100%; border-collapse: collapse; font-size: 14px; color: #475569;">
|
|
||||||
|
<div style="background-color: #111827; border: 1px solid #1e293b; border-radius: 8px; padding: 24px; margin-bottom: 24px;">
|
||||||
|
<h2 style="color: #ffffff; font-size: 18px; margin-top: 0; margin-bottom: 12px; font-weight: 700;">${title}</h2>
|
||||||
|
<p style="color: #cbd5e1; font-size: 14px; line-height: 1.6; margin-top: 0;">Hallo,</p>
|
||||||
|
<p style="color: #cbd5e1; font-size: 14px; line-height: 1.6;">${intro}</p>
|
||||||
|
|
||||||
|
<table style="width: 100%; border-collapse: collapse; font-size: 13px; color: #cbd5e1; margin-top: 20px;">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 4px 0; font-weight: bold; width: 140px;">Nummer:</td>
|
<td style="padding: 8px 0; border-bottom: 1px solid #1e293b; color: #64748b; width: 140px; font-weight: 600; text-transform: uppercase; font-size: 11px;">Anfragenummer:</td>
|
||||||
<td style="padding: 4px 0;">${orderNumber}</td>
|
<td style="padding: 8px 0; border-bottom: 1px solid #1e293b; font-family: monospace; font-weight: bold; color: #3b82f6; font-size: 14px;">#${formattedOrderNumber}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 4px 0; font-weight: bold;">Vorher:</td>
|
<td style="padding: 8px 0; border-bottom: 1px solid #1e293b; color: #64748b; font-weight: 600; text-transform: uppercase; font-size: 11px;">Vorher:</td>
|
||||||
<td style="padding: 4px 0; text-decoration: line-through; color: #94a3b8;">${oldLabel}</td>
|
<td style="padding: 8px 0; border-bottom: 1px solid #1e293b; text-decoration: line-through; color: #475569;">${oldLabel}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 4px 0; font-weight: bold;">Aktuell:</td>
|
<td style="padding: 8px 0; border-bottom: 1px solid #1e293b; color: #64748b; font-weight: 600; text-transform: uppercase; font-size: 11px;">Aktuell:</td>
|
||||||
<td style="padding: 4px 0; font-weight: bold; color: #3b82f6;">${newLabel}</td>
|
<td style="padding: 8px 0; border-bottom: 1px solid #1e293b; font-weight: bold; color: ${statusColor}; font-size: 14px;">${newLabel}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
${reasonSection}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${footerActions}
|
||||||
|
|
||||||
|
<div style="text-align: center; margin-top: 32px; border-top: 1px solid #1e293b; padding-top: 20px; color: #475569; font-size: 11px; line-height: 1.5;">
|
||||||
|
<p style="margin: 0; font-weight: 600;">CASPOS Computerabrechnungssysteme GmbH · Alte Bundesstraße 16 · 76846 Hauenstein</p>
|
||||||
|
<p style="margin: 4px 0 0 0;">Dies ist eine automatisch generierte Systembenachrichtigung.</p>
|
||||||
</div>
|
</div>
|
||||||
<p style="color: #475569; font-size: 16px; line-height: 1.5;">Viele Grüße,<br>Dein CASPOS Team</p>
|
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
|
|
||||||
|
|||||||
@@ -468,7 +468,7 @@ export async function updateOrderStatus(
|
|||||||
const oldLabel = statusLabelMap[oldStatus] || oldStatus
|
const oldLabel = statusLabelMap[oldStatus] || oldStatus
|
||||||
const newLabel = statusLabelMap[newStatus] || newStatus
|
const newLabel = statusLabelMap[newStatus] || newStatus
|
||||||
|
|
||||||
const statusEmail = getStatusEmailTemplate(orderNumber, oldLabel, newLabel)
|
const statusEmail = getStatusEmailTemplate(orderNumber, oldLabel, newLabel, newStatus)
|
||||||
|
|
||||||
const mailOptions: any = {
|
const mailOptions: any = {
|
||||||
to: user.email,
|
to: user.email,
|
||||||
@@ -642,7 +642,7 @@ export async function rejectOrder(
|
|||||||
const { data: { user: orderUser }, error: userError } = await admin.auth.admin.getUserById(order.user_id)
|
const { data: { user: orderUser }, error: userError } = await admin.auth.admin.getUserById(order.user_id)
|
||||||
if (!userError && orderUser && orderUser.email) {
|
if (!userError && orderUser && orderUser.email) {
|
||||||
const orderNumber = order.order_number || order.id.slice(0, 8)
|
const orderNumber = order.order_number || order.id.slice(0, 8)
|
||||||
const statusEmail = getStatusEmailTemplate(orderNumber, 'Wartet auf Freigabe', `Abgelehnt (Grund: ${reason})`)
|
const statusEmail = getStatusEmailTemplate(orderNumber, 'Wartet auf Freigabe', 'Abgelehnt', 'rejected', reason)
|
||||||
|
|
||||||
await sendMail({
|
await sendMail({
|
||||||
to: orderUser.email,
|
to: orderUser.email,
|
||||||
|
|||||||
Reference in New Issue
Block a user