feat(delivery): Abschluss-Navigation, Mengen-Hinweis, Set-Handling

A) Nach erfolgreichem Abschluss (aktiv→completed) poppt die Detail-Page
   automatisch zurück zur Übersicht. Scaffold ist jetzt StatefulWidget
   mit BlocListener<TourBloc>; nur „gearmt", wenn die Lieferung beim
   Öffnen aktiv war → erneutes Öffnen einer fertigen Lieferung poppt nicht.

B) Step „Info": Artikelliste zeigt weiter die Ursprungsmenge
   (requiredQuantity). Bei entfernten/teilweise gutgeschriebenen Positionen
   erscheint pro Zeile ein „Menge geändert"-Hinweis + ein tappbares Banner,
   das zu Step 3 „Artikel" springt.

C) Beladen: nicht-scanbare Set-Köpfe (Parent-Komponenten) werden jetzt
   IMMER mit ihrem Set gezeigt — als Kopf in der Lagergruppe ihrer
   Komponenten statt isoliert unter „Dienstleistungen". _ItemRow leitet
   scanNotRequired aus der Artikel-Scanbarkeit ab.

D) Step „Übersicht": Wording der Zahlungsweise-Sperre bei offen==0
   präzisiert („Keine Zahlung mehr offen (bereits bezahlt)").

E) Step „Artikel": Komponenten eines Sets sind einzeln nicht mehr
   entfernbar (kein Button + Hinweis). Das Entfernen/Wiederherstellen läuft
   nur über den Oberartikel und kaskadiert auf das ganze Set (ganz oder
   gar nix). Set-Entfernen ist blockiert, solange eine Komponente noch
   nicht verladen ist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dennis Nemec
2026-06-23 15:57:52 +02:00
parent 6d2f496700
commit 4c6bef6897
5 changed files with 413 additions and 126 deletions

View File

@ -73,15 +73,59 @@ class DeliveryDetail extends StatelessWidget {
}
}
class _DeliveryDetailScaffold extends StatelessWidget {
class _DeliveryDetailScaffold extends StatefulWidget {
const _DeliveryDetailScaffold({required this.deliveryId});
final String deliveryId;
@override
State<_DeliveryDetailScaffold> createState() =>
_DeliveryDetailScaffoldState();
}
class _DeliveryDetailScaffoldState extends State<_DeliveryDetailScaffold> {
/// „Gearmt" = die Lieferung war während dieser Page-Session aktiv. Nur dann
/// poppen wir bei `completed` automatisch zurück zur Übersicht. Öffnet der
/// Fahrer eine bereits abgeschlossene Lieferung, bleibt `_armed == false`
/// und die Page bleibt offen (kein ungewolltes Zurückspringen).
bool _armed = false;
bool _popped = false;
@override
void initState() {
super.initState();
final s = context.read<TourBloc>().state;
if (s is TourLoaded && _findDelivery(s.details)?.state == DeliveryState.active) {
_armed = true;
}
}
Delivery? _findDelivery(TourDetails details) {
for (final d in details.deliveries) {
if (d.id == widget.deliveryId) return d;
}
return null;
}
/// Nach erfolgreichem Abschluss (aktiv → completed) zurück zur Übersicht.
void _onTourState(BuildContext context, TourState state) {
if (_popped || state is! TourLoaded) return;
final d = _findDelivery(state.details);
if (d == null) return;
if (d.state == DeliveryState.active) {
_armed = true;
} else if (_armed && d.state == DeliveryState.completed) {
_popped = true;
Navigator.of(context).pop();
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return BlocBuilder<TourBloc, TourState>(
return BlocListener<TourBloc, TourState>(
listener: _onTourState,
child: BlocBuilder<TourBloc, TourState>(
builder: (context, tourState) {
if (tourState is! TourLoaded) {
return const Scaffold(
@ -94,7 +138,9 @@ class _DeliveryDetailScaffold extends StatelessWidget {
return Scaffold(
appBar: AppBar(title: const Text('Lieferung')),
body: Center(
child: Text('Lieferung $deliveryId nicht in der Tour gefunden.'),
child: Text(
'Lieferung ${widget.deliveryId} nicht in der Tour gefunden.',
),
),
);
}
@ -118,15 +164,9 @@ class _DeliveryDetailScaffold extends StatelessWidget {
),
);
},
),
);
}
Delivery? _findDelivery(TourDetails details) {
for (final d in details.deliveries) {
if (d.id == deliveryId) return d;
}
return null;
}
}
// ─── Step-Header (Pills) ────────────────────────────────────────────────

View File

@ -49,6 +49,24 @@ class StepArticles extends StatelessWidget {
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 = <String, List<DeliveryItem>>{};
for (final it in items) {
final pNr = it.parentArtikelNr;
if (it.isComponent && pNr != null) {
componentsByParentNr.putIfAbsent(pNr, () => []).add(it);
}
}
List<DeliveryItem> 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: [
@ -73,6 +91,7 @@ class StepArticles extends StatelessWidget {
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),
@ -149,6 +168,7 @@ class _ArticleManagementRow extends StatelessWidget {
required this.details,
required this.deliveryId,
required this.deliveryActive,
this.components = const [],
});
final DeliveryItem item;
@ -156,6 +176,10 @@ class _ArticleManagementRow extends StatelessWidget {
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<DeliveryItem> components;
Future<void> _openCreditDialog(
BuildContext context, {
required int remaining,
@ -193,6 +217,51 @@ class _ArticleManagementRow extends StatelessWidget {
));
}
/// Entfernt das GANZE Set (Oberartikel + alle Komponenten) auf einmal —
/// „entweder Parent komplett oder gar nix". Kein Mengen-Stepper
/// (`maxQuantity: null` ⇒ jede Zeile komplett).
Future<void> _openSetRemoveDialog(BuildContext context) async {
final tourBloc = context.read<TourBloc>();
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<TourBloc>();
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,
@ -216,11 +285,27 @@ class _ArticleManagementRow extends StatelessWidget {
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;
final canCredit = deliveryActive && !blockedByScan && remaining > 0;
// 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;
@ -285,18 +370,40 @@ class _ArticleManagementRow extends StatelessWidget {
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,
),
],
),
trailing: Row(
// 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).
// abgeschlossener/abgebrochener Lieferung gesperrt (greift
// auch backend-seitig, hier zusätzlich in der UI).
tooltip: deliveryActive
? 'Gutschrift zurücknehmen'
? (hasComponents
? 'Set wiederherstellen'
: 'Gutschrift zurücknehmen')
: 'Nur bei aktiver Lieferung',
icon: Icon(
Icons.restore,
@ -304,15 +411,21 @@ class _ArticleManagementRow extends StatelessWidget {
? theme.colorScheme.primary
: theme.colorScheme.onSurfaceVariant,
),
onPressed: deliveryActive ? () => _restoreAll(context) : null,
onPressed: deliveryActive
? () => hasComponents
? _restoreSet(context)
: _restoreAll(context)
: null,
),
if (!fullyRemoved)
IconButton.outlined(
tooltip: blockedByScan
tooltip: (blockedByScan || componentsBlocked)
? 'Erst scannen/verladen'
: (!deliveryActive
? 'Nur bei aktiver Lieferung'
: 'Gutschrift / entfernen'),
: (hasComponents
? 'Ganzes Set entfernen'
: 'Gutschrift / entfernen')),
style: ButtonStyle(
backgroundColor: WidgetStatePropertyAll(
canCredit
@ -321,7 +434,9 @@ class _ArticleManagementRow extends StatelessWidget {
),
),
onPressed: canCredit
? () => _openCreditDialog(context, remaining: remaining)
? () => hasComponents
? _openSetRemoveDialog(context)
: _openCreditDialog(context, remaining: remaining)
: null,
icon: Icon(
Icons.delete,

View File

@ -9,6 +9,9 @@ import 'package:hl_lieferservice/domain/entity/delivery_item.dart';
import 'package:hl_lieferservice/domain/entity/tour_details.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/bloc/workflow_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/detail/bloc/workflow_event.dart';
import 'package:hl_lieferservice/feature/delivery/detail/bloc/workflow_state.dart';
/// Step 1 — Informationen zur Lieferung.
///
@ -716,7 +719,19 @@ class _ArticleList extends StatelessWidget {
);
}
return Card(
// Hat sich bei mindestens einem Artikel die tatsächliche Menge geändert
// (entfernt oder teilweise gutgeschrieben)? Dann ein Banner mit Verweis
// auf Step 3 „Artikel", wo die Änderungen verwaltet werden.
final anyChanged = items.any(_quantityChanged);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (anyChanged) ...[
const _QuantityChangedBanner(),
const SizedBox(height: 8),
],
Card(
margin: EdgeInsets.zero,
child: Column(
children: [
@ -727,6 +742,55 @@ class _ArticleList extends StatelessWidget {
],
],
),
),
],
);
}
}
/// `true`, wenn die tatsächlich auszuliefernde Menge von der ursprünglich
/// bestellten abweicht — also die Position ganz entfernt oder teilweise
/// gutgeschrieben wurde.
bool _quantityChanged(DeliveryItem item) =>
item.isRemoved || item.scanProgress.creditedQuantity > 0;
/// Tappbares Banner über der Artikelliste: weist darauf hin, dass sich die
/// Menge mindestens eines Artikels geändert hat, und springt zu Step 3
/// („Artikel"), wo die Änderungen sichtbar/verwaltbar sind.
class _QuantityChangedBanner extends StatelessWidget {
const _QuantityChangedBanner();
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final amber = Colors.amber.shade800;
return InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () => context
.read<DeliveryWorkflowBloc>()
.add(const WorkflowGoToStep(WorkflowStep.articles)),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: amber.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: amber.withValues(alpha: 0.4)),
),
child: Row(
children: [
Icon(Icons.edit_note, color: amber),
const SizedBox(width: 10),
Expanded(
child: Text(
'Bei mindestens einem Artikel hat sich die Menge geändert. '
'Details unter Schritt 3 „Artikel".',
style: theme.textTheme.bodySmall?.copyWith(color: amber),
),
),
Icon(Icons.chevron_right, color: amber, size: 20),
],
),
),
);
}
}
@ -744,6 +808,8 @@ class _ArticleRow extends StatelessWidget {
final warehouse = details.warehouseOf(item.warehouseId);
final isScannable = article?.scannable ?? false;
final removed = item.isRemoved;
// Menge tatsächlich verändert (entfernt oder teilweise gutgeschrieben)?
final changed = _quantityChanged(item);
return ListTile(
// Komponenten um eine Stufe eingerückt (gehören zum Oberartikel darüber).
@ -756,6 +822,8 @@ class _ArticleRow extends StatelessWidget {
foregroundColor: removed
? theme.colorScheme.onSurfaceVariant
: theme.colorScheme.onPrimary,
// Bewusst die URSPRÜNGLICH bestellte Menge (requiredQuantity), nicht
// die reduzierte — Änderungen werden separat als Hinweis ausgewiesen.
child: Text(
'${item.requiredQuantity}×',
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
@ -769,7 +837,10 @@ class _ArticleRow extends StatelessWidget {
color: removed ? theme.colorScheme.onSurfaceVariant : null,
),
),
subtitle: Text(
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
[
article?.articleNumber ?? item.articleId,
if (warehouse != null) warehouse.name,
@ -783,6 +854,31 @@ class _ArticleRow extends StatelessWidget {
decoration: removed ? TextDecoration.lineThrough : null,
),
),
if (changed)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Row(
children: [
Icon(Icons.edit_note, size: 14, color: Colors.amber.shade800),
const SizedBox(width: 4),
Flexible(
child: Text(
removed
? 'Menge geändert: entfernt (Ursprung ${item.requiredQuantity}×)'
: 'Menge geändert: jetzt ${item.deliveredQuantity}× '
'(Ursprung ${item.requiredQuantity}×)',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Colors.amber.shade800,
),
),
),
],
),
),
],
),
trailing: isScannable
? Text(
'${item.scanProgress.scannedQuantity} / ${item.requiredQuantity}',

View File

@ -471,8 +471,8 @@ class _PaymentMethodPicker extends StatelessWidget {
] else if (!hasOpenAmount) ...[
const SizedBox(height: 8),
const _PickerHint(
text: 'Kein offener Betrag — Auswahl der Zahlungsweise '
'nicht erforderlich.',
text: 'Keine Zahlung mehr offen (bereits bezahlt) — '
'Auswahl der Zahlungsweise nicht erforderlich.',
),
],
],

View File

@ -535,6 +535,39 @@ class _CustomerBody extends StatelessWidget {
// erkennt. Liegen außerhalb von `groups` (die nur scanbare Items führen).
final serviceItems = details.nonScannableItems(delivery).toList();
// ── Set-Köpfe (Parent-Komponenten) immer mit ihrem Set anzeigen ──────
// Ein nicht-scanbarer Set-Kopf (Stücklisten-Oberartikel, z. B. als
// Pauschale geführt) soll NICHT isoliert unter „Dienstleistungen"
// erscheinen, sondern als Kopf über seinen (scanbaren) Komponenten in der
// jeweiligen Lagergruppe. Ein Item ist Set-Kopf, wenn seine Artikelnummer
// von einer Komponente als parentArtikelNr referenziert wird.
String? artNrOf(DeliveryItem it) =>
details.articleOf(it.articleId)?.articleNumber;
// Set-Köpfe je Lagergruppe (warehouseId → einzuhängende Köpfe) +
// gesammelte IDs, um sie aus der Dienstleistungs-Sektion zu entfernen.
final injectedParentsByWarehouseId = <String, List<DeliveryItem>>{};
final injectedParentIds = <String>{};
for (final group in groups) {
final groupParentNrs = group.items
.where((it) => it.isComponent)
.map((it) => it.parentArtikelNr)
.whereType<String>()
.toSet();
if (groupParentNrs.isEmpty) continue;
final parents = serviceItems
.where((s) => groupParentNrs.contains(artNrOf(s)))
.toList();
if (parents.isEmpty) continue;
injectedParentsByWarehouseId[group.warehouse.id] = parents;
injectedParentIds.addAll(parents.map((p) => p.id));
}
// Set-Köpfe, die in eine Gruppe eingehängt wurden, nicht doppelt unter
// „Dienstleistungen" zeigen. Set-Köpfe ohne scanbare Komponenten (kommen
// hier nicht vor) blieben weiterhin in der Liste.
final serviceItemsView = serviceItems
.where((s) => !injectedParentIds.contains(s.id))
.toList();
return Column(
children: [
// Header bekommt einen eigenen, leicht abgehobenen Hintergrund
@ -627,26 +660,30 @@ class _CustomerBody extends StatelessWidget {
warehouse: group.warehouse,
items: group.items,
),
for (final item in _parentFirst(group.items))
// Nicht-scanbare Set-Köpfe dieser Gruppe vor ihre Komponenten
// einhängen; `_parentFirst` ordnet Kopf vor Komponenten.
for (final item in _parentFirst([
...?injectedParentsByWarehouseId[group.warehouse.id],
...group.items,
]))
_ItemRow(
item: item,
details: details,
onAction: (action) => onItemAction(item, action),
),
],
// Gebuchte Dienstleistungen (nicht-scanbare Positionen): eigene
// Zwischenüberschrift „Dienstleistungen" (optisch wie
// Standardlager) und dieselbe Item-Card wie scanbare Artikel —
// einziger Unterschied ist der Hinweis, dass kein Scanvorgang
// nötig ist (`scanNotRequired`).
if (serviceItems.isNotEmpty) ...[
// Gebuchte Dienstleistungen (nicht-scanbare Positionen ohne
// eigene Set-Komponenten): eigene Zwischenüberschrift
// „Dienstleistungen" (optisch wie Standardlager) und dieselbe
// Item-Card wie scanbare Artikel — einziger Unterschied ist der
// Hinweis, dass kein Scanvorgang nötig ist.
if (serviceItemsView.isNotEmpty) ...[
const _ServiceSectionHeader(),
for (final item in _parentFirst(serviceItems))
for (final item in _parentFirst(serviceItemsView))
_ItemRow(
item: item,
details: details,
onAction: (action) => onItemAction(item, action),
scanNotRequired: true,
),
],
],
@ -1111,24 +1148,23 @@ class _ItemRow extends StatelessWidget {
required this.item,
required this.details,
required this.onAction,
this.scanNotRequired = false,
});
final DeliveryItem item;
final TourDetails details;
final void Function(_ItemAction action) onAction;
/// `true` für gebuchte Dienstleistungen (nicht-scanbare Positionen): die
/// Card wird GENAU SO wie ein Standardlager-Artikel gerendert, bekommt aber
/// unten den Hinweis „Kein Scanvorgang notwendig". Der Manuell-Button
/// entfällt bei nicht-scanbaren Positionen ohnehin (`canManualConfirm`).
final bool scanNotRequired;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final article = details.articleOf(item.articleId);
final warehouse = details.warehouseOf(item.warehouseId);
// `true` für nicht-scanbare Positionen (Dienstleistungen / Pauschalen /
// Set-Köpfe): die Card wird GENAU SO wie ein scanbarer Artikel gerendert,
// bekommt aber unten den Hinweis „Kein Scanvorgang notwendig" und keinen
// Mengen-Zähler. Aus dem Artikel abgeleitet, damit ein in eine Lagergruppe
// eingehängter nicht-scanbarer Set-Kopf automatisch korrekt rendert.
final scanNotRequired = !(article?.scannable ?? false);
final isExternalWarehouse = warehouse != null && !warehouse.isStandard;
// Manueller Fallback-Button: nur für scanbare, noch offene Positionen
// (nicht done/entfernt/pausiert) — analog dazu, was ein Barcode-Scan