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>
511 lines
18 KiB
Dart
511 lines
18 KiB
Dart
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<DeliveryItem>.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 = <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: [
|
||
_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<DeliveryItem> components;
|
||
|
||
Future<void> _openCreditDialog(
|
||
BuildContext context, {
|
||
required int remaining,
|
||
}) async {
|
||
final tourBloc = context.read<TourBloc>();
|
||
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<TourBloc>().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<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,
|
||
/// fallback auf einen Null-UUID-String, damit der Backend-Call nicht
|
||
/// validation-failt.
|
||
String _actorCarId(BuildContext context) {
|
||
final state = context.read<CarSelectBloc>().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,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|