import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:hl_lieferservice/domain/entity/delivery.dart'; import 'package:hl_lieferservice/domain/entity/delivery_item.dart'; import 'package:hl_lieferservice/domain/entity/scan_progress.dart'; import 'package:hl_lieferservice/domain/entity/tour_details.dart'; import 'package:hl_lieferservice/feature/car_selection/bloc/bloc.dart'; import 'package:hl_lieferservice/feature/car_selection/bloc/state.dart'; import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart'; import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.dart'; import 'package:hl_lieferservice/feature/delivery/detail/presentation/widget/discount_editor.dart'; import 'package:hl_lieferservice/feature/loading/widget/reason_catalog.dart'; import 'package:hl_lieferservice/feature/loading/widget/reason_picker_sheet.dart'; /// Step 3 — Artikel & Gutschriften. /// /// * **Mengen-Gutschrift** (Belegzeile ganz ODER teilweise entfernen) ist /// voll funktional über `RemoveItem`/`UnremoveItem` (mit `quantity`) → /// Backend Audit-Action `remove`/`unremove`. Die `creditedQuantity` des /// Items ist die Wahrheit (vom Server), kein lokaler Draft mehr. /// Regeln (serverseitig erzwungen): scannbare Position muss `done` sein, /// Lieferung muss `active` sein. Die UI spiegelt das als Gate + Hinweis. /// * **Betrags-Gutschrift** (≤ 150 € in 10er-Schritten + Grund) ist /// backend-gestützt über `SetDeliveryCredit`/`RemoveDeliveryCredit` am /// `TourBloc` → `POST /deliveries/{id}/credit`. Wahrheit ist der Server /// (`TourDetails.creditOf`), kein lokaler Draft mehr. class StepArticles extends StatelessWidget { const StepArticles({ super.key, required this.delivery, required this.details, }); final Delivery delivery; final TourDetails details; @override Widget build(BuildContext context) { // Innerhalb einer Belegzeile: Oberartikel vor seinen Komponenten, damit // Komponenten direkt darunter eingerückt erscheinen. final items = List.of(delivery.items) ..sort((a, b) { final byLine = a.belegzeilenNr.compareTo(b.belegzeilenNr); if (byLine != 0) return byLine; final byParent = (a.isComponent ? 1 : 0).compareTo(b.isComponent ? 1 : 0); if (byParent != 0) return byParent; return (a.komponentenArtikelNr ?? '') .compareTo(b.komponentenArtikelNr ?? ''); }); // Komponenten je Oberartikel (Artikelnummer des Parents → Komponenten). // Grundlage für E: Komponenten sind einzeln NICHT entfernbar — nur der // Oberartikel kann (ganzes Set) entfernt werden, was auf die Komponenten // kaskadiert. final componentsByParentNr = >{}; for (final it in items) { final pNr = it.parentArtikelNr; if (it.isComponent && pNr != null) { componentsByParentNr.putIfAbsent(pNr, () => []).add(it); } } List componentsOf(DeliveryItem it) { final nr = details.articleOf(it.articleId)?.articleNumber; if (nr == null) return const []; return componentsByParentNr[nr] ?? const []; } return ListView( padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), children: [ _SectionHeader(text: 'Artikel'), const SizedBox(height: 8), if (delivery.state != DeliveryState.active) ...[ const _LockedHint( text: 'Nur bei aktiver Lieferung änderbar.', ), const SizedBox(height: 8), ], if (items.isEmpty) const _EmptyHint(text: 'Keine Artikel hinterlegt.') else Card( margin: EdgeInsets.zero, child: Column( children: [ for (int i = 0; i < items.length; i++) ...[ _ArticleManagementRow( item: items[i], details: details, deliveryId: delivery.id, deliveryActive: delivery.state == DeliveryState.active, components: componentsOf(items[i]), ), if (i < items.length - 1) const Divider(height: 1, indent: 16, endIndent: 16), ], ], ), ), const SizedBox(height: 24), _SectionHeader(text: 'Gutschriften'), const SizedBox(height: 8), Card( margin: EdgeInsets.zero, child: Padding( padding: const EdgeInsets.all(16), child: DiscountEditor( deliveryId: delivery.id, active: delivery.state == DeliveryState.active, ), ), ), ], ); } } class _SectionHeader extends StatelessWidget { const _SectionHeader({required this.text}); final String text; @override Widget build(BuildContext context) { return Text( text, style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w700, ), ); } } class _EmptyHint extends StatelessWidget { const _EmptyHint({required this.text}); final String text; @override Widget build(BuildContext context) { final theme = Theme.of(context); return Card( margin: EdgeInsets.zero, child: Padding( padding: const EdgeInsets.all(16), child: Text( text, style: TextStyle( color: theme.colorScheme.onSurfaceVariant, fontStyle: FontStyle.italic, ), ), ), ); } } /// Eine Zeile in der Artikel-Verwaltung. Quelle der Wahrheit ist /// `item.scanProgress.creditedQuantity` (vom Backend) — kein lokaler Draft. /// Zeigt: /// - verbleibende Liefermenge (Soll − Gutschrift) /// - Gutschrift-Button → Mengen-Dialog (1…Restmenge + Grund), gesperrt für /// scannbare, noch nicht gescannte Positionen oder inaktive Lieferung /// - Wiederherstellen-Button, sobald etwas gutgeschrieben ist class _ArticleManagementRow extends StatelessWidget { const _ArticleManagementRow({ required this.item, required this.details, required this.deliveryId, required this.deliveryActive, this.components = const [], }); final DeliveryItem item; final TourDetails details; final String deliveryId; final bool deliveryActive; /// Komponenten dieses Items, falls es ein Set-Oberartikel ist (sonst leer). /// Entfernen/Wiederherstellen kaskadiert auf diese Komponenten. final List components; Future _openCreditDialog( BuildContext context, { required int remaining, }) async { final tourBloc = context.read(); final actorCarId = _actorCarId(context); // Identische Auswahl wie im Beladen-/Scan-Screen: Grund-Picker // (Presets + „Anderer Grund") + Mengen-Stepper (Teilmengen-Gutschrift). final result = await showReasonPickerSheet( context: context, title: 'Grund für das Entfernen', presets: ReasonCatalog.itemRemove, confirmLabel: 'Entfernen', maxQuantity: remaining, ); if (result == null) return; // Eine Aktion für ganz UND teilweise: das Backend kippt die Zeile auf // `removed`, sobald die volle Menge gutgeschrieben ist. tourBloc.add(RemoveItem( deliveryItemId: item.id, reason: result.reason, actorCarId: actorCarId, quantity: result.quantity, // Gutschrift-Grund zusätzlich als Lieferungs-Notiz festhalten. saveReasonAsNote: true, )); } void _restoreAll(BuildContext context) { // quantity: null → gesamte Gutschrift zurücknehmen. context.read().add(UnremoveItem( deliveryItemId: item.id, actorCarId: _actorCarId(context), )); } /// Entfernt das GANZE Set (Oberartikel + alle Komponenten) auf einmal — /// „entweder Parent komplett oder gar nix". Kein Mengen-Stepper /// (`maxQuantity: null` ⇒ jede Zeile komplett). Future _openSetRemoveDialog(BuildContext context) async { final tourBloc = context.read(); final actorCarId = _actorCarId(context); final result = await showReasonPickerSheet( context: context, title: 'Grund für das Entfernen (ganzes Set)', presets: ReasonCatalog.itemRemove, confirmLabel: 'Set entfernen', maxQuantity: null, ); if (result == null) return; // Oberartikel + jede Komponente komplett entfernen (quantity null). tourBloc.add(RemoveItem( deliveryItemId: item.id, reason: result.reason, actorCarId: actorCarId, quantity: null, saveReasonAsNote: true, )); for (final c in components) { tourBloc.add(RemoveItem( deliveryItemId: c.id, reason: result.reason, actorCarId: actorCarId, quantity: null, // Grund nur einmal (am Oberartikel) als Notiz festhalten. saveReasonAsNote: false, )); } } /// Stellt das ganze Set wieder her (Oberartikel + Komponenten). void _restoreSet(BuildContext context) { final tourBloc = context.read(); final actorCarId = _actorCarId(context); tourBloc.add(UnremoveItem(deliveryItemId: item.id, actorCarId: actorCarId)); for (final c in components) { tourBloc .add(UnremoveItem(deliveryItemId: c.id, actorCarId: actorCarId)); } } /// Holt die aktuelle Auto-ID aus dem CarSelectBloc — gleiche Konvention /// wie der Loading-Flow: das aktiv gewählte Fahrzeug ist der „Akteur" /// des Audit-Events. Falls (defensiv) noch keine Auswahl getroffen ist, /// fallback auf einen Null-UUID-String, damit der Backend-Call nicht /// validation-failt. String _actorCarId(BuildContext context) { final state = context.read().state; if (state is CarSelectComplete) return state.selectedCar.id; return '00000000-0000-0000-0000-000000000000'; } @override Widget build(BuildContext context) { final theme = Theme.of(context); final article = details.articleOf(item.articleId); final warehouse = details.warehouseOf(item.warehouseId); final required = item.requiredQuantity; final credited = item.scanProgress.creditedQuantity; final remaining = required - credited; final fullyRemoved = item.isRemoved; // Status == removed (voll gutgeschr.) final partiallyCredited = credited > 0 && !fullyRemoved; // E: Komponenten eines Sets sind einzeln NICHT entfernbar; nur der // Oberartikel kann (ganzes Set) entfernt werden. final isComponent = item.isComponent; final hasComponents = components.isNotEmpty; // Gate: scannbare Position muss `done` sein, sonst keine Gutschrift. final scannable = article?.scannable ?? false; final isDone = item.scanProgress.status == ScanStatus.done; final blockedByScan = scannable && !isDone && !fullyRemoved; // Beim Set zusätzlich: jede scanbare Komponente muss erst verladen sein, // sonst würde das Backend deren Entfernen ablehnen. final componentsBlocked = components.any((c) { final cArticle = details.articleOf(c.articleId); final cScannable = cArticle?.scannable ?? false; final cDone = c.scanProgress.status == ScanStatus.done; return cScannable && !cDone && !c.isRemoved; }); final canCredit = deliveryActive && !blockedByScan && !componentsBlocked && remaining > 0; final Color avatarColor; final String avatarText; if (fullyRemoved) { avatarColor = Colors.red.shade400; avatarText = '0×'; } else if (partiallyCredited) { avatarColor = Colors.amber.shade700; avatarText = '$remaining×'; } else { avatarColor = theme.colorScheme.primary; avatarText = '$required×'; } return ListTile( // Komponenten um eine Stufe eingerückt (gehören zum Oberartikel darüber). contentPadding: EdgeInsets.only(left: item.isComponent ? 40 : 16, right: 16), leading: CircleAvatar( backgroundColor: avatarColor, foregroundColor: theme.colorScheme.onPrimary, child: Text( avatarText, style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold), ), ), title: Text( '${item.isComponent ? '↳ ' : ''}${article?.name ?? '⟨Unbekannter Artikel⟩'}', style: TextStyle( fontWeight: FontWeight.w600, decoration: fullyRemoved ? TextDecoration.lineThrough : null, color: fullyRemoved ? theme.colorScheme.onSurfaceVariant : null, ), ), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( [ article?.articleNumber ?? item.articleId, if (warehouse != null) warehouse.name, if (article?.scannable == false) 'Dienstleistung', ].join(' · '), style: TextStyle( fontSize: 12, color: theme.colorScheme.onSurfaceVariant, ), ), if (fullyRemoved) _StatusLine( text: 'Komplett gutgeschrieben' '${item.scanProgress.heldReason != null ? ' – ${item.scanProgress.heldReason}' : ''}', color: Colors.red.shade400, ) else if (partiallyCredited) _StatusLine( text: '$credited von $required gutgeschrieben', color: Colors.amber.shade800, ), if (blockedByScan) _StatusLine( text: 'Erst scannen/verladen — dann Gutschrift möglich', color: theme.colorScheme.onSurfaceVariant, ), // E: Komponenten-Hinweis — einzeln nicht entfernbar. if (isComponent && deliveryActive && !fullyRemoved) _StatusLine( text: 'Nur über den Oberartikel entfernbar', color: theme.colorScheme.onSurfaceVariant, ), // Set-Oberartikel: blockiert, solange eine Komponente noch nicht // verladen ist. if (hasComponents && componentsBlocked && deliveryActive && !fullyRemoved) _StatusLine( text: 'Erst alle Set-Teile verladen — dann ganzes Set entfernbar', color: theme.colorScheme.onSurfaceVariant, ), ], ), // E: Komponenten haben KEINE eigenen Aktionen — Entfernen/Wiederherstellen // läuft ausschließlich über den Oberartikel (kaskadiert auf das Set). trailing: isComponent ? null : Row( mainAxisSize: MainAxisSize.min, children: [ if (credited > 0) IconButton( // Wiederherstellen nur bei aktiver Lieferung — bei // abgeschlossener/abgebrochener Lieferung gesperrt (greift // auch backend-seitig, hier zusätzlich in der UI). tooltip: deliveryActive ? (hasComponents ? 'Set wiederherstellen' : 'Gutschrift zurücknehmen') : 'Nur bei aktiver Lieferung', icon: Icon( Icons.restore, color: deliveryActive ? theme.colorScheme.primary : theme.colorScheme.onSurfaceVariant, ), onPressed: deliveryActive ? () => hasComponents ? _restoreSet(context) : _restoreAll(context) : null, ), if (!fullyRemoved) IconButton.outlined( tooltip: (blockedByScan || componentsBlocked) ? 'Erst scannen/verladen' : (!deliveryActive ? 'Nur bei aktiver Lieferung' : (hasComponents ? 'Ganzes Set entfernen' : 'Gutschrift / entfernen')), style: ButtonStyle( backgroundColor: WidgetStatePropertyAll( canCredit ? Colors.redAccent : theme.colorScheme.surfaceContainerHighest, ), ), onPressed: canCredit ? () => hasComponents ? _openSetRemoveDialog(context) : _openCreditDialog(context, remaining: remaining) : null, icon: Icon( Icons.delete, color: canCredit ? theme.colorScheme.onPrimary : theme.colorScheme.onSurfaceVariant, ), ), ], ), ); } } /// Kleine farbige Statuszeile unter dem Artikelnamen. class _StatusLine extends StatelessWidget { const _StatusLine({required this.text, required this.color}); final String text; final Color color; @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.only(top: 2), child: Text( text, style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, color: color, ), ), ); } } /// Lock-Hinweis (analog DiscountEditor): zeigt mit Schloss-Symbol an, dass die /// Artikel-Aktionen (Entfernen / Wiederherstellen) nur bei aktiver Lieferung /// möglich sind. class _LockedHint extends StatelessWidget { const _LockedHint({required this.text}); final String text; @override Widget build(BuildContext context) { final theme = Theme.of(context); return Container( width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8), ), child: Row( children: [ Icon(Icons.lock_outline, size: 16, color: theme.colorScheme.onSurfaceVariant), const SizedBox(width: 8), Expanded( child: Text( text, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ), ], ), ); } }