b2in/resources/views/livewire/admin/partners/registration-codes.blade.php
2026-01-23 17:33:10 +01:00

1254 lines
56 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
use App\Models\RegistrationCode;
use Spatie\Permission\Models\Role;
use Illuminate\Support\Carbon;
use Illuminate\Validation\Rule;
use Livewire\Volt\Component;
use Livewire\WithPagination;
use function Livewire\Volt\{layout, title};
layout('components.layouts.app');
title('Registrierungscodes verwalten');
new class extends Component {
use WithPagination;
public string $role = 'broker';
public string $codePrefix = 'M';
public string $codePart1 = '';
public string $codePart2 = '';
public string $codePart3 = '';
public string $codePart4 = '';
public ?string $name = null;
public string $customerBrokerType = 'broker'; // Tab-Auswahl für Kunden: broker oder retailer
public ?int $brokerPartnerId = null;
public ?string $expiresAt = null;
public ?string $note = null;
public string $rangeFrom = '';
public string $rangeTo = '';
public string $rangeFromPart1 = '';
public string $rangeFromPart2 = '';
public string $rangeFromPart3 = '';
public string $rangeFromPart4 = '';
public string $rangeToPart1 = '';
public string $rangeToPart2 = '';
public string $rangeToPart3 = '';
public string $rangeToPart4 = '';
// Filter und Sortierung für Tabelle
public string $tableRoleFilter = '';
public string $tableStatusFilter = '';
public string $tableAssignmentFilter = '';
public string $search = '';
public string $sortField = 'created_at';
public string $sortDirection = 'desc';
// roleOptions werden dynamisch aus der Datenbank geladen
public array $roleOptions = [];
public function mount(): void
{
$this->loadRoleOptions();
$this->codePrefix = $this->roleOptions[$this->role]['prefix'];
$this->expiresAt = now()->addDays(7)->format('Y-m-d');
if ($this->role === 'customer') {
$this->regenerateCustomerRange();
} else {
$this->regenerateNumber();
}
}
private function loadRoleOptions(): void
{
$roles = Role::whereNotNull('reg_prefix')->orderBy('id', 'asc')->get();
foreach ($roles as $role) {
// Konvertiere Role name zu lowercase für Konsistenz mit registration_codes.role
$key = strtolower(str_replace('-', '', $role->name)); // "Broker" → "broker"
// Für Broker spezielle Behandlung
$this->roleOptions[$key] = [
'label' => $role->display_name ?? $role->name,
'prefix' => $role->reg_prefix,
'startNumber' => $role->reg_start_number ?? 10000000,
'description' => $role->reg_description,
'color' => $role->color ?? 'zinc',
'icon' => $role->icon ?? 'key',
];
}
}
public function updatedRole(): void
{
$this->codePrefix = $this->roleOptions[$this->role]['prefix'];
$this->name = null;
$this->brokerPartnerId = null;
$this->customerBrokerType = 'broker';
$this->rangeFrom = '';
$this->rangeTo = '';
$this->rangeFromPart1 = '';
$this->rangeFromPart2 = '';
$this->rangeFromPart3 = '';
$this->rangeFromPart4 = '';
$this->rangeToPart1 = '';
$this->rangeToPart2 = '';
$this->rangeToPart3 = '';
$this->rangeToPart4 = '';
if ($this->role === 'customer') {
$this->regenerateCustomerRange();
} else {
$this->regenerateNumber();
}
}
public function updatedRangeFromPart1(): void
{
$this->updateRangeFrom();
}
public function updatedRangeFromPart2(): void
{
$this->updateRangeFrom();
}
public function updatedRangeFromPart3(): void
{
$this->updateRangeFrom();
}
public function updatedRangeFromPart4(): void
{
$this->updateRangeFrom();
}
public function updatedRangeToPart1(): void
{
$this->updateRangeTo();
}
public function updatedRangeToPart2(): void
{
$this->updateRangeTo();
}
public function updatedRangeToPart3(): void
{
$this->updateRangeTo();
}
public function updatedRangeToPart4(): void
{
$this->updateRangeTo();
}
protected function updateRangeFrom(): void
{
$this->rangeFrom = trim($this->rangeFromPart1 . ' ' . $this->rangeFromPart2 . ' ' . $this->rangeFromPart3 . ' ' . $this->rangeFromPart4);
}
protected function updateRangeTo(): void
{
$this->rangeTo = trim($this->rangeToPart1 . ' ' . $this->rangeToPart2 . ' ' . $this->rangeToPart3 . ' ' . $this->rangeToPart4);
}
public function updatedCustomerBrokerType(): void
{
$this->brokerPartnerId = null;
}
public function regenerateNumber(): void
{
$number = $this->getNextAvailableNumber();
$this->codePart1 = substr($number, 0, 2);
$this->codePart2 = substr($number, 2, 2);
$this->codePart3 = substr($number, 4, 2);
$this->codePart4 = substr($number, 6, 2);
}
public function regenerateCustomerRange(): void
{
$fromNumber = $this->getNextAvailableNumber();
// Setze "Code von" Parts
$this->rangeFromPart1 = substr($fromNumber, 0, 2);
$this->rangeFromPart2 = substr($fromNumber, 2, 2);
$this->rangeFromPart3 = substr($fromNumber, 4, 2);
$this->rangeFromPart4 = substr($fromNumber, 6, 2);
// Berechne "Code bis" (+10)
$fromNumberInt = (int) $fromNumber;
$toNumberInt = $fromNumberInt + 10;
$toNumber = str_pad((string) $toNumberInt, 8, '0', STR_PAD_LEFT);
// Setze "Code bis" Parts
$this->rangeToPart1 = substr($toNumber, 0, 2);
$this->rangeToPart2 = substr($toNumber, 2, 2);
$this->rangeToPart3 = substr($toNumber, 4, 2);
$this->rangeToPart4 = substr($toNumber, 6, 2);
// Aktualisiere rangeFrom und rangeTo
$this->updateRangeFrom();
$this->updateRangeTo();
}
public function updatedCodePart1(): void
{
$this->validateNumberUniqueness();
}
public function updatedCodePart2(): void
{
$this->validateNumberUniqueness();
}
public function updatedCodePart3(): void
{
$this->validateNumberUniqueness();
}
public function updatedCodePart4(): void
{
$this->validateNumberUniqueness();
}
protected function validateNumberUniqueness(): void
{
// Prüfe nur wenn alle Teile ausgefüllt sind
if (strlen($this->codePart1) < 2 || strlen($this->codePart2) < 2 ||
strlen($this->codePart3) < 2 || strlen($this->codePart4) < 2) {
$this->resetErrorBag(['codePart1', 'codePart2', 'codePart3', 'codePart4']);
return;
}
$number = $this->normalizeNumber($this->codePart1 . $this->codePart2 . $this->codePart3 . $this->codePart4);
// Prüfe ob diese Nummer bereits verwendet wird (unabhängig von Prefix/Rolle)
$exists = RegistrationCode::whereRaw('SUBSTRING(code, 2) = ?', [$number])->exists();
if ($exists) {
$this->addError('codePart1', __('Diese Nummer wird bereits verwendet. Jede Nummer ist einzigartig über alle Rollen hinweg.'));
} else {
$this->resetErrorBag(['codePart1', 'codePart2', 'codePart3', 'codePart4']);
}
}
public function createCode(): void
{
$prefix = $this->normalizedPrefix();
// Range-Modus für Kunden (immer)
if ($this->role === 'customer') {
$this->createCodeRange($prefix);
return;
}
// Einzelner Code
$number = $this->normalizeNumber($this->codePart1 . $this->codePart2 . $this->codePart3 . $this->codePart4);
$fullCode = $prefix . $number;
$this->validate($this->rules($prefix), $this->messages());
// Prüfe auf doppelten Code (Nummer unabhängig von Prefix/Rolle)
$numberExists = RegistrationCode::whereRaw('SUBSTRING(code, 2) = ?', [$number])->exists();
if ($numberExists) {
$this->addError('codePart1', __('Diese Nummer wird bereits verwendet. Jede Nummer ist einzigartig über alle Rollen hinweg.'));
return;
}
// Aktualisiere die Teile für die Anzeige
$this->codePart1 = substr($number, 0, 2);
$this->codePart2 = substr($number, 2, 2);
$this->codePart3 = substr($number, 4, 2);
$this->codePart4 = substr($number, 6, 2);
$metadata = [];
if ($this->note) {
$metadata['note'] = $this->note;
}
RegistrationCode::create([
'code' => $fullCode,
'role' => $this->role,
'name' => in_array($this->role, ['broker', 'retailer', 'manufacturer'], true) ? $this->name : null,
'status' => RegistrationCode::STATUS_AVAILABLE,
'broker_partner_id' => null,
'partner_id' => null,
'assigned_to_code_id' => $this->role === 'customer' ? $this->brokerPartnerId : null,
'expires_at' => $this->expiresAt ? Carbon::parse($this->expiresAt)->endOfDay() : null,
'metadata' => !empty($metadata) ? $metadata : null,
]);
Flux::toast(
heading: 'Success!',
text: 'Registrierungscode wurde angelegt.',
variant: 'success',
position: "top end",
duration: 3000
);
$this->reset(['name', 'brokerPartnerId', 'expiresAt', 'note', 'rangeFrom', 'rangeTo', 'rangeFromPart1', 'rangeFromPart2', 'rangeFromPart3', 'rangeFromPart4', 'rangeToPart1', 'rangeToPart2', 'rangeToPart3', 'rangeToPart4']);
$this->codePrefix = $this->roleOptions[$this->role]['prefix'];
$this->expiresAt = now()->addDays(7)->format('Y-m-d');
$this->regenerateNumber();
}
protected function createCodeRange(string $prefix): void
{
// Aktualisiere rangeFrom und rangeTo aus den Parts
$this->updateRangeFrom();
$this->updateRangeTo();
$this->validate([
'rangeFromPart1' => ['required', 'digits:2'],
'rangeFromPart2' => ['required', 'digits:2'],
'rangeFromPart3' => ['required', 'digits:2'],
'rangeFromPart4' => ['required', 'digits:2'],
'rangeToPart1' => ['required', 'digits:2'],
'rangeToPart2' => ['required', 'digits:2'],
'rangeToPart3' => ['required', 'digits:2'],
'rangeToPart4' => ['required', 'digits:2'],
'brokerPartnerId' => ['required', 'exists:registration_codes,id'],
], [
'rangeFromPart1.required' => __('Bitte geben Sie den Startwert der Range ein.'),
'rangeFromPart1.digits' => __('Bitte genau 2 Ziffern eingeben.'),
'rangeFromPart2.required' => __('Bitte geben Sie den Startwert der Range ein.'),
'rangeFromPart2.digits' => __('Bitte genau 2 Ziffern eingeben.'),
'rangeFromPart3.required' => __('Bitte geben Sie den Startwert der Range ein.'),
'rangeFromPart3.digits' => __('Bitte genau 2 Ziffern eingeben.'),
'rangeFromPart4.required' => __('Bitte geben Sie den Startwert der Range ein.'),
'rangeFromPart4.digits' => __('Bitte genau 2 Ziffern eingeben.'),
'rangeToPart1.required' => __('Bitte geben Sie den Endwert der Range ein.'),
'rangeToPart1.digits' => __('Bitte genau 2 Ziffern eingeben.'),
'rangeToPart2.required' => __('Bitte geben Sie den Endwert der Range ein.'),
'rangeToPart2.digits' => __('Bitte genau 2 Ziffern eingeben.'),
'rangeToPart3.required' => __('Bitte geben Sie den Endwert der Range ein.'),
'rangeToPart3.digits' => __('Bitte genau 2 Ziffern eingeben.'),
'rangeToPart4.required' => __('Bitte geben Sie den Endwert der Range ein.'),
'rangeToPart4.digits' => __('Bitte genau 2 Ziffern eingeben.'),
'brokerPartnerId.required' => __('Bitte wählen Sie einen Makler oder Händler aus.'),
]);
// Normalisiere die Eingabe (zusammenfügen der Parts)
$fromNumberStr = $this->normalizeNumber($this->rangeFromPart1 . $this->rangeFromPart2 . $this->rangeFromPart3 . $this->rangeFromPart4);
$toNumberStr = $this->normalizeNumber($this->rangeToPart1 . $this->rangeToPart2 . $this->rangeToPart3 . $this->rangeToPart4);
// Validiere Format (8 Ziffern)
if (!preg_match('/^\d{8}$/', $fromNumberStr)) {
$this->addError('rangeFrom', __('Format: 40 00 00 01 (8 Ziffern)'));
return;
}
if (!preg_match('/^\d{8}$/', $toNumberStr)) {
$this->addError('rangeTo', __('Format: 40 00 00 50 (8 Ziffern)'));
return;
}
$fromNumber = (int) $fromNumberStr;
$toNumber = (int) $toNumberStr;
if ($fromNumber >= $toNumber) {
$this->addError('rangeFrom', __('Der Startwert muss kleiner als der Endwert sein.'));
return;
}
if (($toNumber - $fromNumber) > 1000) {
$this->addError('rangeTo', __('Die Range darf maximal 1000 Codes umfassen.'));
return;
}
$created = 0;
$skipped = 0;
for ($num = $fromNumber; $num <= $toNumber; $num++) {
$numberStr = str_pad((string) $num, 8, '0', STR_PAD_LEFT);
$fullCode = $prefix . $numberStr;
// Prüfe ob bereits vorhanden
if (RegistrationCode::whereRaw('SUBSTRING(code, 2) = ?', [$numberStr])->exists()) {
$skipped++;
continue;
}
$metadata = [];
if ($this->note) {
$metadata['note'] = $this->note;
}
RegistrationCode::create([
'code' => $fullCode,
'role' => $this->role,
'name' => null,
'status' => RegistrationCode::STATUS_AVAILABLE,
'broker_partner_id' => null,
'partner_id' => null,
'assigned_to_code_id' => $this->brokerPartnerId,
'expires_at' => $this->expiresAt ? Carbon::parse($this->expiresAt)->endOfDay() : null,
'metadata' => !empty($metadata) ? $metadata : null,
]);
$created++;
}
Flux::toast(
heading: 'Success!',
text: __(':created Codes wurden angelegt. :skipped wurden übersprungen (bereits vorhanden).', ['created' => $created, 'skipped' => $skipped]),
variant: 'success',
position: "top end",
duration: 3000
);
$this->reset(['brokerPartnerId', 'expiresAt', 'note', 'rangeFrom', 'rangeTo', 'rangeFromPart1', 'rangeFromPart2', 'rangeFromPart3', 'rangeFromPart4', 'rangeToPart1', 'rangeToPart2', 'rangeToPart3', 'rangeToPart4']);
$this->expiresAt = now()->addDays(7)->format('Y-m-d');
if ($this->role === 'customer') {
$this->regenerateCustomerRange();
} else {
$this->regenerateNumber();
}
}
public function expire(int $codeId): void
{
/** @var RegistrationCode|null $code */
$code = RegistrationCode::find($codeId);
if (!$code) {
return;
}
if ($code->status === RegistrationCode::STATUS_USED) {
$this->addError('code', __('Dieser Code wurde bereits genutzt und kann nicht deaktiviert werden.'));
return;
}
$code->update([
'status' => RegistrationCode::STATUS_EXPIRED,
'expires_at' => $code->expires_at ?? now(),
]);
session()->flash('message', __('Code wurde deaktiviert.'));
}
protected function rules(string $prefix): array
{
$rules = [
'role' => ['required', Rule::in(array_keys($this->roleOptions))],
'codePrefix' => ['required', 'alpha', 'size:1'],
'name' => [
Rule::requiredIf(in_array($this->role, ['broker', 'retailer', 'manufacturer'], true)),
'nullable',
'string',
'max:255',
],
'expiresAt' => ['nullable', 'date'],
'note' => ['nullable', 'string', 'max:255'],
];
// Für Kunden gelten Range-Regeln
if ($this->role === 'customer') {
$rules['rangeFromPart1'] = ['required', 'digits:2'];
$rules['rangeFromPart2'] = ['required', 'digits:2'];
$rules['rangeFromPart3'] = ['required', 'digits:2'];
$rules['rangeFromPart4'] = ['required', 'digits:2'];
$rules['rangeToPart1'] = ['required', 'digits:2'];
$rules['rangeToPart2'] = ['required', 'digits:2'];
$rules['rangeToPart3'] = ['required', 'digits:2'];
$rules['rangeToPart4'] = ['required', 'digits:2'];
$rules['brokerPartnerId'] = ['required', 'exists:registration_codes,id'];
} else {
// Normale Code-Felder für andere Rollen
$rules['codePart1'] = ['required', 'digits:2'];
$rules['codePart2'] = ['required', 'digits:2'];
$rules['codePart3'] = ['required', 'digits:2'];
$rules['codePart4'] = ['required', 'digits:2'];
}
return $rules;
}
protected function messages(): array
{
return [
'role.required' => __('Bitte Rolle wählen.'),
'codePrefix.required' => __('Prefix ist erforderlich.'),
'codePrefix.alpha' => __('Prefix darf nur Buchstaben enthalten.'),
'codePrefix.size' => __('Prefix muss genau 1 Zeichen lang sein.'),
'codePart1.required' => __('Erster Block ist erforderlich.'),
'codePart1.digits' => __('Bitte genau 2 Ziffern eingeben.'),
'codePart2.required' => __('Zweiter Block ist erforderlich.'),
'codePart2.digits' => __('Bitte genau 2 Ziffern eingeben.'),
'codePart3.required' => __('Dritter Block ist erforderlich.'),
'codePart3.digits' => __('Bitte genau 2 Ziffern eingeben.'),
'codePart4.required' => __('Vierter Block ist erforderlich.'),
'codePart4.digits' => __('Bitte genau 2 Ziffern eingeben.'),
'name.required' => __('Für Makler-, Händler- und Herstellercodes ist ein Name erforderlich.'),
'name.max' => __('Der Name darf maximal 255 Zeichen lang sein.'),
'brokerPartnerId.required' => __('Für Kundencodes muss ein Makler oder Händler gewählt werden.'),
'brokerPartnerId.exists' => __('Bitte einen gültigen Makler- oder Händlercode auswählen.'),
'expiresAt.date' => __('Bitte ein gültiges Datum wählen.'),
'note.max' => __('Notiz darf max. 255 Zeichen lang sein.'),
];
}
protected function normalizedPrefix(): string
{
$prefix = strtoupper(trim($this->codePrefix));
if ($prefix === '') {
$prefix = $this->roleOptions[$this->role]['prefix'] ?? 'X';
}
return substr($prefix, 0, 1);
}
protected function normalizeNumber(string $number): string
{
$digits = preg_replace('/\D+/', '', $number);
$digits = str_pad(substr($digits, 0, 8), 8, '0', STR_PAD_LEFT);
return $digits;
}
protected function getNextAvailableNumber(): string
{
$startNumber = $this->roleOptions[$this->role]['startNumber'] ?? '10000001';
$currentNumber = (int) $startNumber;
// Finde die nächste verfügbare Nummer im Bereich dieser Rolle
$maxAttempts = 10000; // Sicherheitslimit
$attempts = 0;
while ($attempts < $maxAttempts) {
$numberStr = str_pad((string) $currentNumber, 8, '0', STR_PAD_LEFT);
// Prüfe ob diese Nummer bereits verwendet wird (unabhängig von Prefix/Rolle)
$exists = RegistrationCode::whereRaw('SUBSTRING(code, 2) = ?', [$numberStr])->exists();
if (!$exists) {
return $numberStr;
}
$currentNumber++;
$attempts++;
}
// Fallback: zufällige Nummer wenn Bereich voll ist
return $this->generateRandomUniqueNumber();
}
protected function generateRandomUniqueNumber(): string
{
$attempts = 0;
do {
$attempts++;
$number = str_pad((string) random_int(10000000, 99999999), 8, '0', STR_PAD_LEFT);
$exists = RegistrationCode::whereRaw('SUBSTRING(code, 2) = ?', [$number])->exists();
} while ($exists && $attempts < 100);
return $number;
}
public function getNextAvailableNumberForDisplay(): string
{
$number = $this->getNextAvailableNumber();
return substr($number, 0, 2) . ' ' . substr($number, 2, 2) . ' ' . substr($number, 4, 2) . ' ' . substr($number, 6, 2);
}
public function sortBy(string $field): void
{
if ($this->sortField === $field) {
$this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';
} else {
$this->sortField = $field;
$this->sortDirection = 'asc';
}
}
public function updatingSearch(): void
{
$this->resetPage();
}
public function updatingTableRoleFilter(): void
{
$this->resetPage();
}
public function updatingTableStatusFilter(): void
{
$this->resetPage();
}
public function updatingTableAssignmentFilter(): void
{
$this->resetPage();
}
public function updatedTableRoleFilter(): void
{
// Reset assignment filter wenn Rolle geändert wird
$this->tableAssignmentFilter = '';
$this->resetPage();
}
public function with(): array
{
$stats = [
'available' => RegistrationCode::where('status', RegistrationCode::STATUS_AVAILABLE)->count(),
'used' => RegistrationCode::where('status', RegistrationCode::STATUS_USED)->count(),
'expired' => RegistrationCode::where('status', RegistrationCode::STATUS_EXPIRED)->count(),
];
$recentCodes = RegistrationCode::with(['usedBy'])
->latest()
->take(25)
->get();
// Für Kunden: Filtere Codes basierend auf customerBrokerType
$brokerAndRetailerCodes = RegistrationCode::whereIn('role', $this->role === 'customer' ? [$this->customerBrokerType] : ['broker', 'retailer'])
->where('status', RegistrationCode::STATUS_AVAILABLE)
->orderBy('code')
->get();
// Query für Tabelle mit Filtern und Sortierung
$tableQuery = RegistrationCode::with(['usedBy', 'assignedToCode'])
->when($this->tableRoleFilter, fn($q) => $q->where('role', $this->tableRoleFilter))
->when($this->tableStatusFilter, fn($q) => $q->where('status', $this->tableStatusFilter))
->when($this->tableAssignmentFilter && $this->tableRoleFilter === 'customer', fn($q) =>
$q->where('assigned_to_code_id', $this->tableAssignmentFilter)
)
->when($this->search, fn($q) =>
$q->where(function($query) {
$searchTerm = '%' . $this->search . '%';
$query->where('code', 'like', $searchTerm)
->orWhere('name', 'like', $searchTerm)
->orWhereHas('usedBy', fn($userQuery) =>
$userQuery->where('name', 'like', $searchTerm)
->orWhere('email', 'like', $searchTerm)
);
})
);
$codes = $tableQuery->orderBy($this->sortField, $this->sortDirection)->paginate(25);
// Finde alle Makler/Händler, die Kunden zugeordnet haben (mit neuer Spalte)
$assignmentIds = RegistrationCode::where('role', 'customer')
->whereNotNull('assigned_to_code_id')
->distinct()
->pluck('assigned_to_code_id');
$availableAssignments = RegistrationCode::whereIn('id', $assignmentIds)
->whereIn('role', ['broker', 'retailer'])
->orderBy('code')
->get();
return [
'stats' => $stats,
'recentCodes' => $recentCodes,
'roleOptions' => $this->roleOptions,
'nextAvailableNumber' => $this->getNextAvailableNumberForDisplay(),
'brokerAndRetailerCodes' => $brokerAndRetailerCodes,
'codes' => $codes,
'availableAssignments' => $availableAssignments ?? collect(),
];
}
}; ?>
<div class="space-y-6 p-6">
<flux:toast />
{{-- Header --}}
<div class="flex items-center justify-between">
<div>
<flux:heading size="xl" class="mb-2">{{ __('Registrierungscodes') }}</flux:heading>
<flux:subheading>{{ __('Codes erzeugen, zuordnen und deaktivieren') }}</flux:subheading>
</div>
</div>
{{-- Statistik --}}
<div class="grid grid-cols-1 gap-6 md:grid-cols-3">
<flux:card class="shadow-elegant">
<div class="flex items-center justify-between">
<div>
<flux:subheading>{{ __('Verfügbar') }}</flux:subheading>
<flux:heading size="2xl" class="mt-2">{{ $stats['available'] }}</flux:heading>
</div>
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-green-100 dark:bg-green-900/20">
<flux:icon.key class="h-6 w-6 text-green-600 dark:text-green-400" />
</div>
</div>
</flux:card>
<flux:card class="shadow-elegant">
<div class="flex items-center justify-between">
<div>
<flux:subheading>{{ __('Verbraucht') }}</flux:subheading>
<flux:heading size="2xl" class="mt-2">{{ $stats['used'] }}</flux:heading>
</div>
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-zinc-100 dark:bg-zinc-800">
<flux:icon.user class="h-6 w-6 text-zinc-700 dark:text-zinc-300" />
</div>
</div>
</flux:card>
<flux:card class="shadow-elegant">
<div class="flex items-center justify-between">
<div>
<flux:subheading>{{ __('Deaktiviert/abgelaufen') }}</flux:subheading>
<flux:heading size="2xl" class="mt-2">{{ $stats['expired'] }}</flux:heading>
</div>
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-orange-100 dark:bg-orange-900/20">
<flux:icon.x-circle class="h-6 w-6 text-orange-600 dark:text-orange-400" />
</div>
</div>
</flux:card>
</div>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
{{-- Formular --}}
<flux:card class="shadow-elegant">
<form wire:submit="createCode" class="space-y-6">
<div>
<flux:heading size="lg" class="mb-2">{{ __('Neuen Code anlegen') }}</flux:heading>
<flux:subheading>{{ __('Rolle wählen, Nummer generieren, optional zuordnen') }}</flux:subheading>
</div>
<flux:separator />
<flux:field>
<flux:label>{{ __('Rolle') }}</flux:label>
<flux:select wire:model.live="role">
@foreach($roleOptions as $key => $meta)
<flux:select.option :value="$key">
{{ $meta['label'] }} — {{ $meta['description'] }}
</flux:select.option>
@endforeach
</flux:select>
@error('role') <flux:error>{{ $message }}</flux:error> @enderror
</flux:field>
@if($role === 'customer')
{{-- Code von --}}
<flux:field>
<flux:label>{{ __('Code von') }}</flux:label>
<div class="flex items-center gap-2">
<div class="flex items-center gap-1">
<flux:input
wire:model="codePrefix"
maxlength="1"
readonly
class="w-16 text-center font-mono text-lg font-semibold"
/>
<span class="text-zinc-400">|</span>
<flux:input
wire:model.live.debounce.500ms="rangeFromPart1"
maxlength="2"
placeholder="00"
class="w-16 text-center font-mono"
/>
<flux:input
wire:model.live.debounce.500ms="rangeFromPart2"
maxlength="2"
placeholder="00"
class="w-16 text-center font-mono"
/>
<flux:input
wire:model.live.debounce.500ms="rangeFromPart3"
maxlength="2"
placeholder="00"
class="w-16 text-center font-mono"
/>
<flux:input
wire:model.live.debounce.500ms="rangeFromPart4"
maxlength="2"
placeholder="00"
class="w-16 text-center font-mono"
/>
</div>
</div>
@error('rangeFrom') <flux:error>{{ $message }}</flux:error> @enderror
<p class="text-xs text-zinc-500 mt-1">{{ __('Format: Prefix + 4×2 Ziffern') }}</p>
</flux:field>
{{-- Code bis --}}
<flux:field>
<flux:label>{{ __('Code bis') }}</flux:label>
<div class="flex items-center gap-2">
<div class="flex items-center gap-1">
<flux:input
wire:model="codePrefix"
maxlength="1"
readonly
class="w-16 text-center font-mono text-lg font-semibold"
/>
<span class="text-zinc-400">|</span>
<flux:input
wire:model.live.debounce.500ms="rangeToPart1"
maxlength="2"
placeholder="00"
class="w-16 text-center font-mono"
/>
<flux:input
wire:model.live.debounce.500ms="rangeToPart2"
maxlength="2"
placeholder="00"
class="w-16 text-center font-mono"
/>
<flux:input
wire:model.live.debounce.500ms="rangeToPart3"
maxlength="2"
placeholder="00"
class="w-16 text-center font-mono"
/>
<flux:input
wire:model.live.debounce.500ms="rangeToPart4"
maxlength="2"
placeholder="00"
class="w-16 text-center font-mono"
/>
</div>
</div>
@error('rangeTo') <flux:error>{{ $message }}</flux:error> @enderror
<p class="text-xs text-zinc-500 mt-1">{{ __('Format: Prefix + 4×2 Ziffern') }}</p>
</flux:field>
@else
{{-- Normale Code-Eingabe für andere Rollen --}}
<flux:field>
<flux:label>{{ __('Code') }}</flux:label>
<div class="flex items-center gap-2">
<div class="flex items-center gap-1">
<flux:input
wire:model="codePrefix"
maxlength="1"
readonly
class="w-16 text-center font-mono text-lg font-semibold"
/>
<span class="text-zinc-400">|</span>
<flux:input
wire:model.live.debounce.500ms="codePart1"
maxlength="2"
placeholder="00"
class="w-16 text-center font-mono"
/>
<flux:input
wire:model.live.debounce.500ms="codePart2"
maxlength="2"
placeholder="00"
class="w-16 text-center font-mono"
/>
<flux:input
wire:model.live.debounce.500ms="codePart3"
maxlength="2"
placeholder="00"
class="w-16 text-center font-mono"
/>
<flux:input
wire:model.live.debounce.500ms="codePart4"
maxlength="2"
placeholder="00"
class="w-16 text-center font-mono"
/>
</div>
</div>
@error('codePrefix') <flux:error>{{ $message }}</flux:error> @enderror
@error('codePart1') <flux:error>{{ $message }}</flux:error> @enderror
@error('codePart2') <flux:error>{{ $message }}</flux:error> @enderror
@error('codePart3') <flux:error>{{ $message }}</flux:error> @enderror
@error('codePart4') <flux:error>{{ $message }}</flux:error> @enderror
<div class="flex items-center justify-between mt-1">
<p class="text-xs text-zinc-500">{{ __('Format: Prefix + 4×2 Ziffern') }}</p>
<p class="text-xs text-zinc-400">{{ __('Nächste:') }} {{ $nextAvailableNumber }}</p>
</div>
</flux:field>
@endif
@if(in_array($role, ['broker', 'retailer', 'manufacturer'], true))
<flux:field>
<flux:label>{{ __('Name') }} <span class="text-red-500">*</span></flux:label>
<flux:description>
@if($role === 'manufacturer')
{{ __('Name des Herstellers') }}
@else
{{ __('Name für die Zuordnung von Kunden') }}
@endif
</flux:description>
<flux:input
wire:model="name"
placeholder="{{ __('z.B. Max Mustermann') }}"
icon="user"
/>
@error('name') <flux:error>{{ $message }}</flux:error> @enderror
</flux:field>
@endif
@if($role === 'customer')
<flux:field>
<flux:label>{{ __('Makler oder Händler (Pflicht für Kundencodes)') }}</flux:label>
<flux:description>{{ __('Wählen Sie zwischen Makler oder Händler und ordnen Sie dann einen Code zu') }}</flux:description>
{{-- Tabs für Makler/Händler-Auswahl --}}
<div class="mb-4">
<div class="flex gap-2 border-b border-zinc-200 dark:border-zinc-700">
<button
type="button"
wire:click="$set('customerBrokerType', 'broker')"
class="px-4 py-2 text-sm font-medium transition-colors {{ $customerBrokerType === 'broker' ? 'border-b-2 border-primary-500 text-primary-600 dark:text-primary-400' : 'text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-300' }}"
>
{{ __('Makler') }}
</button>
<button
type="button"
wire:click="$set('customerBrokerType', 'retailer')"
class="px-4 py-2 text-sm font-medium transition-colors {{ $customerBrokerType === 'retailer' ? 'border-b-2 border-primary-500 text-primary-600 dark:text-primary-400' : 'text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-300' }}"
>
{{ __('Händler') }}
</button>
</div>
</div>
{{-- Zuordnung --}}
<flux:field>
<flux:label>{{ __('Zuordnen zu') }}</flux:label>
<flux:select variant="listbox" searchable wire:model="brokerPartnerId" placeholder="{{ __('Bitte wählen') }}">
<flux:select.option :value="null">{{ __('Bitte wählen') }}</flux:select.option>
@foreach($brokerAndRetailerCodes as $code)
@php
$displayName = $code->name ?? $code->code;
$roleLabel = $roleOptions[$code->role]['label'] ?? ucfirst($code->role);
@endphp
<flux:select.option :value="$code->id">
{{ $displayName }} ({{ $code->code }})
</flux:select.option>
@endforeach
</flux:select>
@error('brokerPartnerId') <flux:error>{{ $message }}</flux:error> @enderror
</flux:field>
</flux:field>
@endif
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<flux:field>
<flux:label>{{ __('Ablaufdatum') }}</flux:label>
<flux:date-picker wire:model="expiresAt" locale="de" />
@error('expiresAt') <flux:error>{{ $message }}</flux:error> @enderror
</flux:field>
<flux:field>
<flux:label>{{ __('Notiz (optional)') }}</flux:label>
<flux:input wire:model.defer="note" placeholder="{{ __('z.B. Messe Hamburg 2025') }}" />
@error('note') <flux:error>{{ $message }}</flux:error> @enderror
</flux:field>
</div>
<flux:separator />
<x-error-alert />
<div class="flex justify-end">
<flux:button
type="submit"
variant="primary"
icon="key"
wire:loading.attr="disabled"
wire:target="createCode"
>
<span wire:loading.remove wire:target="createCode">
{{ __('Code speichern') }}
</span>
<span wire:loading wire:target="createCode">
<flux:icon.arrow-path class="animate-spin inline-block mr-2 h-4 w-4" />
{{ __('Speichern...') }}
</span>
</flux:button>
</div>
</form>
</flux:card>
{{-- Liste --}}
<flux:card class="shadow-elegant">
<div class="mb-4">
<flux:heading size="lg" class="mb-2">{{ __('Letzte Codes') }}</flux:heading>
<flux:subheading>{{ __('Zuletzt angelegte oder verbrauchte Codes') }}</flux:subheading>
</div>
<flux:separator class="mb-4" />
<div class="space-y-3 max-h-[600px] overflow-y-auto">
@forelse($recentCodes as $code)
@php
$statusColor = match($code->status) {
'available' => 'green',
'used' => 'zinc',
'expired' => 'orange',
default => 'zinc',
};
$roleLabel = $roleOptions[$code->role]['label'] ?? ucfirst($code->role);
@endphp
<div class="rounded-lg border border-zinc-200 p-4 dark:border-zinc-700">
<div class="flex items-start justify-between gap-3">
<div class="flex-1 space-y-1">
<div class="font-mono font-semibold text-lg text-zinc-900 dark:text-white">
{{ $code->code }}
</div>
@if($code->name)
<div class="text-sm font-medium text-zinc-700 dark:text-zinc-300">
{{ __('Name:') }} {{ $code->name }}
</div>
@endif
@if($code->role === 'customer' && $code->assignedToCode)
@php
$brokerName = $code->assignedToCode->name ?? $code->assignedToCode->code;
$brokerRoleLabel = $roleOptions[$code->assignedToCode->role]['label'] ?? ucfirst($code->assignedToCode->role);
@endphp
<div class="text-sm text-zinc-500 dark:text-zinc-400">
{{ __('Zugeordnet zu:') }} {{ $brokerRoleLabel }} {{ $brokerName }} ({{ $code->assignedToCode->code }})
</div>
@endif
<div class="flex items-center gap-2 flex-wrap">
<flux:badge size="sm" color="{{ $statusColor }}">
@if($code->status === 'available')
{{ __('Verfügbar') }}
@elseif($code->status === 'used')
{{ __('Verbraucht') }}
@else
{{ __('Abgelaufen') }}
@endif
</flux:badge>
<flux:badge size="sm" color="zinc">
{{ $roleLabel }}
</flux:badge>
</div>
@if($code->usedBy)
<div class="text-sm text-zinc-500 dark:text-zinc-400">
{{ __('Verbraucht von:') }} {{ $code->usedBy->name ?? $code->usedBy->email }}
@if($code->used_at)
<span class="text-xs">({{ $code->used_at->format('d.m.Y H:i') }})</span>
@endif
</div>
@endif
@if($code->metadata && isset($code->metadata['note']))
<div class="text-xs text-zinc-400 italic">
{{ $code->metadata['note'] }}
</div>
@endif
<div class="text-xs text-zinc-400">
{{ __('Angelegt:') }} {{ $code->created_at->format('d.m.Y H:i') }}
@if($code->expires_at)
<br>{{ __('Gültig bis:') }} {{ $code->expires_at->format('d.m.Y H:i') }}
@endif
</div>
</div>
@if($code->status === RegistrationCode::STATUS_AVAILABLE)
<flux:button
type="button"
variant="ghost"
size="sm"
icon="x-circle"
wire:click="expire({{ $code->id }})"
wire:confirm="{{ __('Möchten Sie diesen Code wirklich deaktivieren?') }}"
>
{{ __('Deaktivieren') }}
</flux:button>
@endif
</div>
</div>
@empty
<div class="py-8 text-center text-zinc-500">
<flux:icon.key class="mx-auto h-12 w-12 text-zinc-400" />
<div class="mt-2">{{ __('Noch keine Codes angelegt') }}</div>
</div>
@endforelse
</div>
</flux:card>
</div>
{{-- Codes Tabelle --}}
<flux:card class="shadow-elegant">
<div class="mb-6">
<flux:heading size="lg" class="mb-2">{{ __('Alle Registrierungscodes') }}</flux:heading>
<flux:subheading>{{ __('Übersicht aller Codes mit Filtern und Sortierung') }}</flux:subheading>
</div>
{{-- Filter --}}
<div class="mb-6 grid grid-cols-1 gap-4 md:grid-cols-{{ $tableRoleFilter === 'customer' ? '5' : '4' }}">
<flux:input
wire:model.live.debounce.300ms="search"
icon="magnifying-glass"
placeholder="{{ __('Code oder Name suchen...') }}"
/>
<flux:select wire:model.live="tableRoleFilter">
<flux:select.option value="">{{ __('Alle Rollen') }}</flux:select.option>
@foreach($roleOptions as $key => $meta)
<flux:select.option :value="$key">{{ $meta['label'] }}</flux:select.option>
@endforeach
</flux:select>
@if($tableRoleFilter === 'customer')
<flux:select variant="listbox" searchable wire:model.live="tableAssignmentFilter" placeholder="{{ __('Alle Zuordnungen') }}">
<flux:select.option value="">{{ __('Alle Zuordnungen') }}</flux:select.option>
@foreach($availableAssignments as $assignment)
@php
$assignmentName = $assignment->name ?? $assignment->code;
$assignmentRoleLabel = $roleOptions[$assignment->role]['label'] ?? ucfirst($assignment->role);
@endphp
<flux:select.option :value="$assignment->id">
{{ $assignmentRoleLabel }}: {{ $assignmentName }} ({{ $assignment->code }})
</flux:select.option>
@endforeach
</flux:select>
@endif
<flux:select wire:model.live="tableStatusFilter">
<flux:select.option value="">{{ __('Alle Status') }}</flux:select.option>
<flux:select.option value="available">{{ __('Verfügbar') }}</flux:select.option>
<flux:select.option value="used">{{ __('Verbraucht') }}</flux:select.option>
<flux:select.option value="expired">{{ __('Abgelaufen') }}</flux:select.option>
</flux:select>
@if($search || $tableRoleFilter || $tableStatusFilter || $tableAssignmentFilter)
<flux:button wire:click="$set('search', ''); $set('tableRoleFilter', ''); $set('tableStatusFilter', ''); $set('tableAssignmentFilter', '')" variant="ghost" icon="x-mark">
{{ __('Filter zurücksetzen') }}
</flux:button>
@endif
</div>
<flux:table>
<flux:table.columns sticky>
<flux:table.column wire:click="sortBy('code')" class="cursor-pointer">
<div class="flex items-center gap-2">
{{ __('Code') }}
@if($sortField === 'code')
<flux:icon.{{ $sortDirection === 'asc' ? 'chevron-up' : 'chevron-down' }} variant="micro" />
@endif
</div>
</flux:table.column>
<flux:table.column wire:click="sortBy('role')" class="cursor-pointer">
<div class="flex items-center gap-2">
{{ __('Rolle') }}
@if($sortField === 'role')
<flux:icon.{{ $sortDirection === 'asc' ? 'chevron-up' : 'chevron-down' }} variant="micro" />
@endif
</div>
</flux:table.column>
<flux:table.column>{{ __('Name') }}</flux:table.column>
<flux:table.column wire:click="sortBy('status')" class="cursor-pointer">
<div class="flex items-center gap-2">
{{ __('Status') }}
@if($sortField === 'status')
<flux:icon.{{ $sortDirection === 'asc' ? 'chevron-up' : 'chevron-down' }} variant="micro" />
@endif
</div>
</flux:table.column>
<flux:table.column>{{ __('Zuordnung') }}</flux:table.column>
<flux:table.column wire:click="sortBy('created_at')" class="cursor-pointer">
<div class="flex items-center gap-2">
{{ __('Angelegt') }}
@if($sortField === 'created_at')
<flux:icon.{{ $sortDirection === 'asc' ? 'chevron-up' : 'chevron-down' }} variant="micro" />
@endif
</div>
</flux:table.column>
<flux:table.column>{{ __('Aktionen') }}</flux:table.column>
</flux:table.columns>
<flux:table.rows>
@forelse($codes as $code)
@php
$statusColor = match($code->status) {
'available' => 'green',
'used' => 'zinc',
'expired' => 'orange',
default => 'zinc',
};
$roleLabel = $roleOptions[$code->role]['label'] ?? ucfirst($code->role);
$name = $code->name;
@endphp
<flux:table.row :key="$code->id">
<flux:table.cell variant="strong">
<span class="font-mono">{{ $code->code }}</span>
</flux:table.cell>
<flux:table.cell>
<flux:badge size="sm" color="zinc">{{ $roleLabel }}</flux:badge>
</flux:table.cell>
<flux:table.cell>
@if($name)
<span class="font-medium">{{ $name }}</span>
@else
<span class="text-zinc-400">—</span>
@endif
</flux:table.cell>
<flux:table.cell>
<flux:badge size="sm" color="{{ $statusColor }}">
@if($code->status === 'available')
{{ __('Verfügbar') }}
@elseif($code->status === 'used')
{{ __('Verbraucht') }}
@else
{{ __('Abgelaufen') }}
@endif
</flux:badge>
</flux:table.cell>
<flux:table.cell>
@if($code->role === 'customer' && $code->assignedToCode)
@php
$brokerName = $code->assignedToCode->name ?? $code->assignedToCode->code;
$brokerRoleLabel = $roleOptions[$code->assignedToCode->role]['label'] ?? ucfirst($code->assignedToCode->role);
@endphp
<div class="text-sm">
<span class="text-zinc-500">{{ $brokerRoleLabel }}:</span>
<span class="font-medium">{{ $brokerName }}</span>
</div>
@elseif($code->usedBy)
<div class="text-sm">
<span class="text-zinc-500">{{ __('Verbraucht von:') }}</span>
<span class="font-medium">{{ $code->usedBy->name ?? $code->usedBy->email }}</span>
</div>
@else
<span class="text-zinc-400">—</span>
@endif
</flux:table.cell>
<flux:table.cell>
<div class="text-sm text-zinc-500">
{{ $code->created_at->format('d.m.Y H:i') }}
</div>
</flux:table.cell>
<flux:table.cell>
@if($code->status === RegistrationCode::STATUS_AVAILABLE)
<flux:button
type="button"
variant="ghost"
size="sm"
icon="x-circle"
wire:click="expire({{ $code->id }})"
wire:confirm="{{ __('Möchten Sie diesen Code wirklich deaktivieren?') }}"
>
{{ __('Deaktivieren') }}
</flux:button>
@endif
</flux:table.cell>
</flux:table.row>
@empty
<flux:table.row>
<flux:table.cell colspan="8">
<div class="py-12 text-center">
<flux:icon.key class="mx-auto h-12 w-12 text-zinc-400" />
<flux:heading size="lg" class="mt-4">{{ __('Keine Codes gefunden') }}</flux:heading>
<flux:subheading class="mt-2">
@if($search || $tableRoleFilter || $tableStatusFilter)
{{ __('Versuchen Sie, Ihre Filter anzupassen.') }}
@else
{{ __('Erstellen Sie einen neuen Code, um zu beginnen.') }}
@endif
</flux:subheading>
</div>
</flux:table.cell>
</flux:table.row>
@endforelse
</flux:table.rows>
</flux:table>
{{-- Pagination --}}
<div class="mt-6">
{{ $codes->links() }}
</div>
</flux:card>
</div>