diff --git a/shop/components/order-wizard.tsx b/shop/components/order-wizard.tsx index b92771f..708303e 100644 --- a/shop/components/order-wizard.tsx +++ b/shop/components/order-wizard.tsx @@ -87,7 +87,7 @@ export function OrderWizard({ const [step, setStep] = useState(initialOrder ? 3 : 1) const [isSubmitting, setIsSubmitting] = useState(false) const [selectedCompanyId, setSelectedCompanyId] = useState( - initialOrder?.company_id ?? null + initialOrder?.company_id ?? (isAdmin ? 'all' : null) ) const [basketItems, setBasketItems] = useState(() => { if (!initialOrder || !initialOrder.order_data?.items) return [] diff --git a/shop/components/wizard/step-customer.tsx b/shop/components/wizard/step-customer.tsx index b3ebdd2..1f78992 100644 --- a/shop/components/wizard/step-customer.tsx +++ b/shop/components/wizard/step-customer.tsx @@ -1,12 +1,23 @@ 'use client' -import React from 'react' +import React, { useState, useMemo, useEffect } from 'react' import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/components/ui/card' import { Label } from '@/components/ui/label' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' -import { User, Building2, UserPlus, Search, Check, Loader2, ChevronRight } from 'lucide-react' -import { EndCustomer, Profile } from '@/lib/types' +import { + User, + Building2, + UserPlus, + Search, + Check, + Loader2, + ChevronRight, + ChevronLeft, + ChevronsLeft, + ChevronsRight, +} from 'lucide-react' +import { EndCustomer } from '@/lib/types' interface StepCustomerProps { isAdmin: boolean @@ -38,7 +49,6 @@ export function StepCustomer({ endCustomers, searchTerm, setSearchTerm, - filteredEndCustomers, selectedEndCustomerId, setSelectedEndCustomerId, newCustomerForm, @@ -47,6 +57,45 @@ export function StepCustomer({ isCreatingCustomer, nextStep, }: StepCustomerProps) { + const [pageSize, setPageSize] = useState(10) + const [currentPage, setCurrentPage] = useState(1) + + // Reset page to 1 when search or company filter or pageSize changes + useEffect(() => { + setCurrentPage(1) + }, [searchTerm, selectedCompanyId, pageSize]) + + // Filter customers by selected company (if admin and selectedCompanyId != 'all') + const customersByCompany = useMemo(() => { + let list = endCustomers.filter((c) => !c.is_anonymized) + if (isAdmin && selectedCompanyId && selectedCompanyId !== 'all') { + list = list.filter((c) => c.partner_id === selectedCompanyId) + } + return list + }, [endCustomers, isAdmin, selectedCompanyId]) + + // Search filter + const filteredCustomers = useMemo(() => { + const term = searchTerm.toLowerCase().trim() + if (!term) return customersByCompany + return customersByCompany.filter( + (c) => + c.company_name?.toLowerCase().includes(term) || + c.first_name?.toLowerCase().includes(term) || + c.last_name?.toLowerCase().includes(term) || + c.city?.toLowerCase().includes(term) || + c.zip?.toLowerCase().includes(term) + ) + }, [customersByCompany, searchTerm]) + + const totalCount = filteredCustomers.length + const totalPages = Math.ceil(totalCount / pageSize) || 1 + + const paginatedCustomers = useMemo(() => { + const from = (currentPage - 1) * pageSize + return filteredCustomers.slice(from, from + pageSize) + }, [filteredCustomers, currentPage, pageSize]) + return ( @@ -59,20 +108,20 @@ export function StepCustomer({ - {/* Admin Company Selector */} + {/* Admin Partner Dropdown */} {isAdmin && (
setSearchTerm(e.target.value)} - className="pl-9 bg-white/5 border-white/10 text-white placeholder:text-slate-500" - /> + {/* Search & Page Size Header */} +
+
+ + setSearchTerm(e.target.value)} + className="pl-9 bg-white/5 border-white/10 text-white placeholder:text-slate-500" + /> +
+ + {/* Page Size Selector */} +
+ Einträge pro Seite: + +
- {filteredEndCustomers.length === 0 ? ( + + {filteredCustomers.length === 0 ? (

Keine passenden Kunden gefunden.

) : ( -
- {filteredEndCustomers.map(customer => ( - + ))} +
+ + {/* Pagination Footer */} +
+
+ Zeige {(currentPage - 1) * pageSize + 1} bis{' '} + {Math.min(currentPage * pageSize, totalCount)} von {totalCount} Kunden +
+ +
+ + + + + Seite {currentPage} von {totalPages} + + + + +
+
+ )}
)} @@ -169,24 +299,28 @@ export function StepCustomer({ {/* Modus B: Neuen Kunden anlegen */} {customerMode === 'create' && (
- {([ - { label: 'Firmenname *', key: 'company_name', span: true, placeholder: 'GmbH / Einzelunternehmen' }, - { label: 'USt-IdNr.', key: 'vat_id', span: false, placeholder: 'DE123456789' }, - { label: 'Vorname', key: 'first_name', span: false, placeholder: '' }, - { label: 'Nachname', key: 'last_name', span: false, placeholder: '' }, - { label: 'Straße & Hausnummer', key: 'street', span: true, placeholder: '' }, - { label: 'PLZ', key: 'zip', span: false, placeholder: '' }, - { label: 'Ort', key: 'city', span: false, placeholder: '' }, - { label: 'Kontoinhaber', key: 'bank_owner', span: false, placeholder: 'Max Mustermann' }, - { label: 'IBAN', key: 'bank_iban', span: false, placeholder: 'DE89370400440532013000' }, - { label: 'BIC', key: 'bank_bic', span: false, placeholder: 'SOLADE21XXX' }, - { label: 'Bankname', key: 'bank_name', span: false, placeholder: 'Musterbank' }, - ] as const).map(({ label, key, span, placeholder }) => ( + {( + [ + { label: 'Firmenname *', key: 'company_name', span: true, placeholder: 'GmbH / Einzelunternehmen' }, + { label: 'USt-IdNr.', key: 'vat_id', span: false, placeholder: 'DE123456789' }, + { label: 'Vorname', key: 'first_name', span: false, placeholder: '' }, + { label: 'Nachname', key: 'last_name', span: false, placeholder: '' }, + { label: 'Straße & Hausnummer', key: 'street', span: true, placeholder: '' }, + { label: 'PLZ', key: 'zip', span: false, placeholder: '' }, + { label: 'Ort', key: 'city', span: false, placeholder: '' }, + { label: 'Kontoinhaber', key: 'bank_owner', span: false, placeholder: 'Max Mustermann' }, + { label: 'IBAN', key: 'bank_iban', span: false, placeholder: 'DE89370400440532013000' }, + { label: 'BIC', key: 'bank_bic', span: false, placeholder: 'SOLADE21XXX' }, + { label: 'Bankname', key: 'bank_name', span: false, placeholder: 'Musterbank' }, + ] as const + ).map(({ label, key, span, placeholder }) => (
setNewCustomerForm((prev: any) => ({ ...prev, [key]: e.target.value }))} + onChange={(e) => + setNewCustomerForm((prev: any) => ({ ...prev, [key]: e.target.value })) + } placeholder={placeholder} className="bg-white/5 border-white/10 text-white placeholder:text-slate-500" /> @@ -198,10 +332,18 @@ export function StepCustomer({ disabled={isCreatingCustomer || !newCustomerForm.company_name?.trim()} className="gap-2" > - {isCreatingCustomer ? : } + {isCreatingCustomer ? ( + + ) : ( + + )} Kunden speichern & auswählen -
@@ -213,7 +355,9 @@ export function StepCustomer({ onClick={nextStep} disabled={ customerMode === 'create' || - (customerMode === 'select' && endCustomers.filter(c => !c.is_anonymized).length > 0 && !selectedEndCustomerId) + (customerMode === 'select' && + customersByCompany.length > 0 && + !selectedEndCustomerId) } > Weiter zum Abrechnungsmodell diff --git a/shop/lib/actions/queries.ts b/shop/lib/actions/queries.ts index 3f3699d..1027714 100644 --- a/shop/lib/actions/queries.ts +++ b/shop/lib/actions/queries.ts @@ -1,6 +1,7 @@ 'use server' import { createClient } from '@/lib/supabase/server' +import { createAdminClient } from '@/lib/supabase/admin' import type { Order, EndCustomer, EndCustomerWithOrders, OrderWithRegisterName } from '@/lib/types' /** @@ -73,3 +74,105 @@ export async function getPartnerCustomersWithOrders(): Promise { + const { partnerCompanyId, page = 1, pageSize = 10, search = '' } = params + const supabase = await createClient() + + const { + data: { user }, + } = await supabase.auth.getUser() + + if (!user) { + throw new Error('Nicht authentifiziert') + } + + const { data: dbUser } = await supabase + .from('users') + .select('role, company_id') + .eq('id', user.id) + .single() + + const isAdmin = dbUser?.role === 'admin' + + let client = supabase + if (isAdmin) { + try { + client = createAdminClient() as any + } catch { + client = supabase + } + } + + let query = client + .from('end_customers') + .select('*', { count: 'exact' }) + .eq('is_anonymized', false) + + if (isAdmin) { + if (partnerCompanyId && partnerCompanyId !== 'all' && partnerCompanyId !== '') { + query = query.eq('partner_id', partnerCompanyId) + } + } else { + if (dbUser?.company_id) { + query = query.eq('partner_id', dbUser.company_id) + } + } + + if (search && search.trim() !== '') { + const term = `%${search.trim()}%` + query = query.or( + `company_name.ilike.${term},first_name.ilike.${term},last_name.ilike.${term},city.ilike.${term},zip.ilike.${term}` + ) + } + + query = query.order('company_name', { ascending: true }) + + const validPageSize = [10, 20, 50].includes(pageSize) ? pageSize : 10 + const validPage = Math.max(1, page) + const from = (validPage - 1) * validPageSize + const to = from + validPageSize - 1 + + query = query.range(from, to) + + const { data, error, count } = await query + + if (error) { + throw error + } + + const totalCount = count ?? 0 + const totalPages = Math.ceil(totalCount / validPageSize) || 1 + + return { + customers: (data as EndCustomer[]) || [], + totalCount, + totalPages, + page: validPage, + pageSize: validPageSize, + isAdmin, + } +} + + diff --git a/shop/supabase/migrations/20260721000000_wizard_admin_customer_select.sql b/shop/supabase/migrations/20260721000000_wizard_admin_customer_select.sql new file mode 100644 index 0000000..c2009db --- /dev/null +++ b/shop/supabase/migrations/20260721000000_wizard_admin_customer_select.sql @@ -0,0 +1,21 @@ +-- Migration: Rollenbasierte Endkunden-RLS für Wizard und Partner-Dashboard +-- Stellt sicher, dass Admins alle Endkunden sehen/filtern können und Partner strikt ihre eigenen. + +CREATE OR REPLACE FUNCTION public.is_admin(user_id UUID) +RETURNS BOOLEAN AS $$ + SELECT EXISTS ( + SELECT 1 FROM public.users + WHERE id = user_id AND role = 'admin' + ); +$$ LANGUAGE sql STABLE SECURITY DEFINER; + +-- SELECT Policy auf end_customers +DROP POLICY IF EXISTS "Partner sehen nur eigene Endkunden" ON public.end_customers; +CREATE POLICY "Partner sehen nur eigene Endkunden" ON public.end_customers +FOR SELECT USING (partner_id = public.get_auth_company_id() OR public.is_admin(auth.uid())); + +-- ALL/INSERT/UPDATE Policy auf end_customers +DROP POLICY IF EXISTS "Partner verwalten eigene Endkunden" ON public.end_customers; +CREATE POLICY "Partner verwalten eigene Endkunden" ON public.end_customers +FOR ALL USING (partner_id = public.get_auth_company_id() OR public.is_admin(auth.uid())) +WITH CHECK (partner_id = public.get_auth_company_id() OR public.is_admin(auth.uid()));