From 8b4fd5f02e53f4b72df21d47fc4bdb47f075b128 Mon Sep 17 00:00:00 2001 From: DanielS Date: Tue, 21 Jul 2026 08:43:00 +0200 Subject: [PATCH] feat(dashboard): add customer accordion view and extension flow --- shop/app/my-customers/page.tsx | 118 +------- shop/app/order/page.tsx | 9 +- shop/app/wizard/page.tsx | 16 + shop/components/customer-accordion-list.tsx | 308 ++++++++++++++++++++ shop/lib/actions/queries.ts | 41 ++- shop/lib/types.ts | 8 + 6 files changed, 390 insertions(+), 110 deletions(-) create mode 100644 shop/app/wizard/page.tsx create mode 100644 shop/components/customer-accordion-list.tsx diff --git a/shop/app/my-customers/page.tsx b/shop/app/my-customers/page.tsx index 94a3481..04110a5 100644 --- a/shop/app/my-customers/page.tsx +++ b/shop/app/my-customers/page.tsx @@ -1,41 +1,27 @@ export const dynamic = 'force-dynamic'; import { redirect } from 'next/navigation' import Link from 'next/link' -import { getEndCustomers } from '@/lib/actions/end-customers' -import type { EndCustomer } from '@/lib/types' -import { ArrowLeft, Building2, Plus, Edit2, AlertTriangle } from 'lucide-react' +import { getPartnerCustomersWithOrders } from '@/lib/actions/queries' +import type { EndCustomerWithOrders } from '@/lib/types' +import { ArrowLeft, Building2, Plus, AlertTriangle } from 'lucide-react' import { Button } from '@/components/ui/button' -import { Card, CardContent } from '@/components/ui/card' -import { Badge } from '@/components/ui/badge'; -import { createClient } from '@/lib/supabase/server'; +import { createClient } from '@/lib/supabase/server' +import { CustomerAccordionList } from '@/components/customer-accordion-list' export default async function MyCustomersPage() { const supabase = await createClient() const { data: { user } } = await supabase.auth.getUser() if (!user) redirect('/auth/login') - let customers: EndCustomer[] = [] + let customers: EndCustomerWithOrders[] = [] let fetchError: string | null = null try { - customers = await getEndCustomers() + customers = await getPartnerCustomersWithOrders() } catch (err: any) { - console.error("Error loading end customers:", err) - fetchError = err.message || "Es gab ein Problem beim Laden Ihrer Endkunden." + console.error("Error loading partner customers with orders:", err) + fetchError = err.message || "Es gab ein Problem beim Laden Ihrer Kunden und Kassen." } - // Anzahl Bestellungen pro Endkunde - const { data: orderCounts, error: orderError } = await supabase.from('orders').select('end_customer_id').not('end_customer_id', 'is', null); - if (orderError) { - console.error("Error loading order counts:", orderError) - } - - const countMap: Record = {} - ;(orderCounts ?? []).forEach(o => { - if (o.end_customer_id) { - countMap[o.end_customer_id] = (countMap[o.end_customer_id] ?? 0) + 1 - } - }) - return (
@@ -50,10 +36,10 @@ export default async function MyCustomersPage() {

- Meine Kunden + Kunden & Kassen

- Verwalten Sie Ihre Endkunden – deren Daten werden für Bestellungen verwendet. + Verwalten Sie Ihre Kunden und deren zugewiesene Kassen / Abonnements.

@@ -70,86 +56,8 @@ export default async function MyCustomersPage() {
)} - {/* Keine Kunden */} - {customers.length === 0 && ( - - - -

Noch keine Endkunden angelegt.

- - - -
-
- )} - - {/* Kundenliste */} - {customers.length > 0 && ( -
- - - - - - - - - - - - {customers.map(customer => ( - - - - - - - - ))} - -
UnternehmenAdresseBestellungenStatusAktionen
-

{customer.company_name}

- {(customer.first_name || customer.last_name) && ( -

- {[customer.first_name, customer.last_name].filter(Boolean).join(' ')} -

- )} - {customer.vat_id && ( -

{customer.vat_id}

- )} -
-

- {customer.street ?? '–'} -

-

- {[customer.zip, customer.city].filter(Boolean).join(' ') || '–'} -

-
- - - {countMap[customer.id] ?? 0} - - Bestellung(en) - - - {customer.is_anonymized ? ( - - Anonymisiert - - ) : ( - Aktiv - )} - - {!customer.is_anonymized && ( - - - - )} -
-
- )} + {/* Akkordeon-Ansicht */} + {/* DSGVO-Hinweis */}
diff --git a/shop/app/order/page.tsx b/shop/app/order/page.tsx index be6e251..53bec42 100644 --- a/shop/app/order/page.tsx +++ b/shop/app/order/page.tsx @@ -8,11 +8,12 @@ import { redirect } from 'next/navigation' import { Suspense } from 'react' interface PageProps { - searchParams: Promise<{ id?: string }> + searchParams: Promise<{ id?: string; orderId?: string; mode?: string }> } export default async function OrderPage({ searchParams }: PageProps) { const params = await searchParams + const orderId = params.orderId || params.id return (
@@ -26,14 +27,14 @@ export default async function OrderPage({ searchParams }: PageProps) {
}> - +
) } -async function OrderDataWrapper({ orderId }: { orderId?: string }) { +async function OrderDataWrapper({ orderId, mode }: { orderId?: string; mode?: string }) { const supabase = await createClient() const { data: { user } } = await supabase.auth.getUser() @@ -83,7 +84,7 @@ async function OrderDataWrapper({ orderId }: { orderId?: string }) { redirect('/order') } - if (orderData.status === 'completed' && !isAdmin) { + if (orderData.status === 'completed' && !isAdmin && mode !== 'extension') { redirect('/order') } diff --git a/shop/app/wizard/page.tsx b/shop/app/wizard/page.tsx new file mode 100644 index 0000000..a6b8c60 --- /dev/null +++ b/shop/app/wizard/page.tsx @@ -0,0 +1,16 @@ +import { redirect } from 'next/navigation' + +interface PageProps { + searchParams: Promise<{ id?: string; orderId?: string; mode?: string }> +} + +export default async function WizardPage({ searchParams }: PageProps) { + const params = await searchParams + const id = params.orderId || params.id + const mode = params.mode + const query = new URLSearchParams() + if (id) query.set('orderId', id) + if (mode) query.set('mode', mode) + + redirect(`/order?${query.toString()}`) +} diff --git a/shop/components/customer-accordion-list.tsx b/shop/components/customer-accordion-list.tsx new file mode 100644 index 0000000..52c5964 --- /dev/null +++ b/shop/components/customer-accordion-list.tsx @@ -0,0 +1,308 @@ +'use client' + +import React, { useState } from 'react' +import Link from 'next/link' +import type { EndCustomerWithOrders } from '@/lib/types' +import { + ChevronDown, + ChevronUp, + Building2, + Edit2, + Sparkles, + Download, + ExternalLink, + Plus, + Monitor, + AlertTriangle, + Search, +} from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { Card, CardContent } from '@/components/ui/card' +import { Input } from '@/components/ui/input' + +const statusLabel: Record = { + pending: 'Eingegangen', + in_review: 'In Prüfung', + active: 'Aktiv', + completed: 'Abgeschlossen', + cancelled: 'Storniert', + rejected: 'Abgelehnt', +} + +const statusClass: Record = { + pending: 'bg-amber-500/20 text-amber-400 border-amber-500/30', + in_review: 'bg-purple-500/20 text-purple-400 border-purple-500/30', + active: 'bg-green-500/20 text-green-400 border-green-500/30', + completed: 'bg-blue-500/20 text-blue-400 border-blue-500/30', + cancelled: 'bg-slate-500/20 text-slate-400 border-slate-500/30', + rejected: 'bg-rose-500/20 text-rose-400 border-rose-500/30', +} + +interface CustomerAccordionListProps { + customers: EndCustomerWithOrders[] +} + +export function CustomerAccordionList({ customers }: CustomerAccordionListProps) { + const [openCustomerIds, setOpenCustomerIds] = useState>(() => { + // Default open all customers with orders + const initial: Record = {} + customers.forEach((c) => { + initial[c.id] = true + }) + return initial + }) + + const [searchTerm, setSearchTerm] = useState('') + + const toggleCustomer = (id: string) => { + setOpenCustomerIds((prev) => ({ + ...prev, + [id]: !prev[id], + })) + } + + const filteredCustomers = customers.filter((c) => { + const term = searchTerm.toLowerCase() + return ( + c.company_name.toLowerCase().includes(term) || + (c.first_name && c.first_name.toLowerCase().includes(term)) || + (c.last_name && c.last_name.toLowerCase().includes(term)) || + (c.city && c.city.toLowerCase().includes(term)) || + (c.email && c.email.toLowerCase().includes(term)) + ) + }) + + if (customers.length === 0) { + return ( + + + +

Noch keine Endkunden angelegt.

+ + + +
+
+ ) + } + + return ( +
+ {/* Suche */} +
+ + setSearchTerm(e.target.value)} + className="pl-9 bg-slate-900/50 border-white/10 text-white placeholder:text-slate-500 focus:border-primary" + /> +
+ + {/* Accordion List */} +
+ {filteredCustomers.map((customer) => { + const isOpen = !!openCustomerIds[customer.id] + const orders = customer.orders || [] + + return ( + + {/* Accordion Header */} +
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" + > +
+
+ +
+
+
+

{customer.company_name}

+ {customer.is_anonymized && ( + + Anonymisiert + + )} +
+

+ {[customer.first_name, customer.last_name].filter(Boolean).join(' ') || 'Kein Ansprechpartner'} + {customer.city ? ` • ${customer.city}` : ''} + {customer.email ? ` • ${customer.email}` : ''} +

+
+
+ +
+ + + {orders.length} {orders.length === 1 ? 'Kasse / Bestellung' : 'Kassen / Bestellungen'} + + + {!customer.is_anonymized && ( + e.stopPropagation()} + > + + + )} + + +
+
+ + {/* Accordion Content (Kassen/Orders) */} + {isOpen && ( +
+ {orders.length === 0 ? ( +
+ Keine Kassen oder Bestellungen für diesen Kunden vorhanden. +
+ ) : ( +
+

+ Zugeordnete Kassen & Systeme ({orders.length}) +

+ {orders.map((order) => { + const items = order.order_data?.items || [] + + return ( +
+ {/* Kassen Info */} +
+
+ + {order.register_name} + + + {statusLabel[order.status] ?? order.status} + +
+

+ #{order.order_number} • Erstellt am{' '} + {new Date(order.created_at).toLocaleDateString('de-DE', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + })} +

+ + {/* Module / Produkte summary */} + {items.length > 0 && ( +
+ {items.map((item, idx) => ( + + {item.product_name} + {item.selected_modules?.length > 0 && ( + + (+{item.selected_modules.length} Module) + + )} + + ))} +
+ )} +
+ + {/* Preis & Action Buttons */} +
+
+

+ Gesamtwert +

+

+ {new Intl.NumberFormat('de-DE', { + style: 'currency', + currency: 'EUR', + }).format(order.total_price)} +

+
+ + {/* Upgrade / Abo Button (Flow Fall B) */} + + + + + {/* PDF Download Button */} + {order.pdf_url && ( + + )} + + {/* Details Button */} + + + +
+
+ ) + })} +
+ )} +
+ )} +
+ ) + })} +
+
+ ) +} diff --git a/shop/lib/actions/queries.ts b/shop/lib/actions/queries.ts index e304dcb..3f3699d 100644 --- a/shop/lib/actions/queries.ts +++ b/shop/lib/actions/queries.ts @@ -1,7 +1,7 @@ 'use server' import { createClient } from '@/lib/supabase/server' -import type { Order, EndCustomer } from '@/lib/types' +import type { Order, EndCustomer, EndCustomerWithOrders, OrderWithRegisterName } from '@/lib/types' /** * Holt alle Bestellanfragen. @@ -34,3 +34,42 @@ export async function getCompanyEndCustomers(): Promise { if (error) throw error return data as EndCustomer[] } + +/** + * Holt alle Endkunden inkl. ihrer Bestellungen/Kassen verschachtelt. + * Extrahiert den Kassen-Namen aus order_data.items[0].device_name (Fallback "Kasse 1"). + * RLS filtert automatisch über get_auth_company_id(). + */ +export async function getPartnerCustomersWithOrders(): Promise { + const supabase = await createClient() + + const { data, error } = await supabase + .from('end_customers') + .select('*, orders(*)') + .order('company_name', { ascending: true }) + + if (error) throw error + + return (data || []).map((customer: any) => { + const rawOrders = customer.orders || [] + // Sort orders by created_at desc + const sortedOrders = [...rawOrders].sort( + (a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime() + ) + + const orders: OrderWithRegisterName[] = sortedOrders.map((order: any) => { + const items = order.order_data?.items || [] + const deviceName = items[0]?.device_name || 'Kasse 1' + return { + ...order, + register_name: deviceName, + } + }) + + return { + ...customer, + orders, + } + }) +} + diff --git a/shop/lib/types.ts b/shop/lib/types.ts index 613b761..8d5fe5c 100644 --- a/shop/lib/types.ts +++ b/shop/lib/types.ts @@ -170,6 +170,14 @@ export type Order = { created_at: string } +export type OrderWithRegisterName = Order & { + register_name: string +} + +export type EndCustomerWithOrders = EndCustomer & { + orders: OrderWithRegisterName[] +} + // ─── Lizenz-Output (für Lizenzserver / ERP) ─────────────────────────────────── export type LicenseOption = {