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>
545 lines
18 KiB
Dart
545 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_credit.dart';
|
||
import 'package:hl_lieferservice/domain/entity/delivery_item.dart';
|
||
import 'package:hl_lieferservice/domain/entity/tour_details.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';
|
||
import 'package:hl_lieferservice/feature/payment_methods/bloc/payment_methods_cubit.dart';
|
||
|
||
/// Step 5 — Übersicht & Abschluss.
|
||
///
|
||
/// Listet alle Artikel mit der **tatsächlich auszuliefernden Menge** auf
|
||
/// (Original-Soll minus lokaler Partial-Remove-Drafts minus
|
||
/// Komplett-Removes). Dazu Anzahlung-Anzeige, optionale Gutschrift,
|
||
/// Zahlungsmethoden-Dropdown.
|
||
///
|
||
/// Der „Unterschreiben"-Button lebt in der Bottom-Navigation des
|
||
/// Page-Wrappers; hier zeigen wir den Resümee-Block, der direkt vor der
|
||
/// Unterschrift steht.
|
||
class StepSummary extends StatelessWidget {
|
||
const StepSummary({
|
||
super.key,
|
||
required this.delivery,
|
||
required this.details,
|
||
});
|
||
|
||
final Delivery delivery;
|
||
final TourDetails details;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return BlocBuilder<DeliveryWorkflowBloc, DeliveryWorkflowState>(
|
||
builder: (context, wfState) {
|
||
return ListView(
|
||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
|
||
children: [
|
||
_SectionHeader(text: 'Ausgelieferte Artikel'),
|
||
const SizedBox(height: 8),
|
||
_DeliveredItems(
|
||
delivery: delivery,
|
||
details: details,
|
||
),
|
||
const SizedBox(height: 24),
|
||
_SectionHeader(text: 'Zahlung'),
|
||
const SizedBox(height: 8),
|
||
_PaymentSummary(
|
||
delivery: delivery,
|
||
credit: details.creditOf(delivery.id),
|
||
),
|
||
const SizedBox(height: 24),
|
||
_SectionHeader(text: 'Zahlungsmethode'),
|
||
const SizedBox(height: 8),
|
||
_PaymentMethodPicker(
|
||
delivery: delivery,
|
||
overrideId: wfState.paymentMethodOverrideId,
|
||
credit: details.creditOf(delivery.id),
|
||
),
|
||
const SizedBox(height: 16),
|
||
const _SignHint(),
|
||
],
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
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 _DeliveredItems extends StatelessWidget {
|
||
const _DeliveredItems({
|
||
required this.delivery,
|
||
required this.details,
|
||
});
|
||
|
||
final Delivery delivery;
|
||
final TourDetails details;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(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 ?? '');
|
||
});
|
||
if (items.isEmpty) {
|
||
return Card(
|
||
margin: EdgeInsets.zero,
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(16),
|
||
child: Text(
|
||
'Keine Artikel hinterlegt.',
|
||
style: TextStyle(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
fontStyle: FontStyle.italic,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
return Card(
|
||
margin: EdgeInsets.zero,
|
||
child: Column(
|
||
children: [
|
||
for (int i = 0; i < items.length; i++) ...[
|
||
_DeliveredRow(
|
||
item: items[i],
|
||
details: details,
|
||
),
|
||
if (i < items.length - 1)
|
||
const Divider(height: 1, indent: 16, endIndent: 16),
|
||
],
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _DeliveredRow extends StatelessWidget {
|
||
const _DeliveredRow({
|
||
required this.item,
|
||
required this.details,
|
||
});
|
||
|
||
final DeliveryItem item;
|
||
final TourDetails details;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final article = details.articleOf(item.articleId);
|
||
// Ausgeliefert = Soll − Gutschrift (vom Backend). Voll gutgeschrieben
|
||
// (status removed) ⇒ credited == required ⇒ delivered 0.
|
||
final credited = item.scanProgress.creditedQuantity;
|
||
final delivered = (item.requiredQuantity - credited).clamp(
|
||
0,
|
||
item.requiredQuantity,
|
||
);
|
||
|
||
final Color avatarColor;
|
||
if (delivered == 0) {
|
||
avatarColor = Colors.red.shade400;
|
||
} else if (delivered < item.requiredQuantity) {
|
||
avatarColor = Colors.amber.shade700;
|
||
} else {
|
||
avatarColor = Colors.green.shade600;
|
||
}
|
||
|
||
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(
|
||
'$delivered×',
|
||
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
|
||
),
|
||
),
|
||
title: Text(
|
||
'${item.isComponent ? '↳ ' : ''}${article?.name ?? '⟨Unbekannter Artikel⟩'}',
|
||
style: TextStyle(
|
||
fontWeight: FontWeight.w600,
|
||
decoration: delivered == 0 ? TextDecoration.lineThrough : null,
|
||
color: delivered == 0 ? theme.colorScheme.onSurfaceVariant : null,
|
||
),
|
||
),
|
||
subtitle: Text(
|
||
[
|
||
if (delivered < item.requiredQuantity)
|
||
'von ${item.requiredQuantity} bestellt · Gutschrift: $credited'
|
||
else
|
||
'Artikelnr. ${article?.articleNumber ?? item.articleId}',
|
||
'${item.unitPrice.toStringAsFixed(2)} € / Stück',
|
||
].join(' · '),
|
||
style: TextStyle(
|
||
fontSize: 12,
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
trailing: Text(
|
||
'${item.lineTotal.toStringAsFixed(2)} €',
|
||
style: theme.textTheme.titleMedium?.copyWith(
|
||
fontWeight: FontWeight.w700,
|
||
decoration: delivered == 0 ? TextDecoration.lineThrough : null,
|
||
color: delivered == 0 ? theme.colorScheme.onSurfaceVariant : null,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Offener Betrag der Lieferung in Euro: Warenwert (Σ Stückpreis × gelieferte
|
||
/// Menge) − Anzahlung − Gutschrift, nie negativ. Einzige Quelle dieser Formel —
|
||
/// genutzt von der Zahlungs-Übersicht UND der Zahlungsmethoden-Auswahl.
|
||
double _openAmount(Delivery delivery, DeliveryCredit? credit) {
|
||
final creditEuros = (credit?.amountCents ?? 0) / 100.0;
|
||
final warenwert =
|
||
delivery.items.fold<double>(0, (acc, item) => acc + item.lineTotal);
|
||
return (warenwert - delivery.prepaidAmount - creditEuros)
|
||
.clamp(0.0, double.infinity);
|
||
}
|
||
|
||
class _PaymentSummary extends StatelessWidget {
|
||
const _PaymentSummary({required this.delivery, required this.credit});
|
||
|
||
final Delivery delivery;
|
||
final DeliveryCredit? credit;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
// Warenwert = Σ Stückpreis × ausgelieferte Menge (entfernte/teil-entfernte
|
||
// Positionen fallen automatisch raus).
|
||
final warenwert = delivery.items
|
||
.fold<double>(0, (acc, item) => acc + item.lineTotal);
|
||
// Offener Betrag über den gemeinsamen Helper (gleiche Formel wie die
|
||
// Zahlungsmethoden-Auswahl).
|
||
final open = _openAmount(delivery, credit);
|
||
return Card(
|
||
margin: EdgeInsets.zero,
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(16),
|
||
child: Column(
|
||
children: [
|
||
_SummaryRow(
|
||
icon: Icons.receipt_long_outlined,
|
||
label: 'Warenwert',
|
||
valueText: '${warenwert.toStringAsFixed(2)} €',
|
||
valueColor: theme.colorScheme.onSurface,
|
||
),
|
||
const SizedBox(height: 12),
|
||
_SummaryRow(
|
||
icon: Icons.savings_outlined,
|
||
label: 'Bei Bestellung bezahlt',
|
||
valueText: '− ${delivery.prepaidAmount.toStringAsFixed(2)} €',
|
||
valueColor: delivery.prepaidAmount > 0
|
||
? Colors.green.shade700
|
||
: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
if (credit != null) ...[
|
||
const SizedBox(height: 12),
|
||
_SummaryRow(
|
||
icon: Icons.card_giftcard_outlined,
|
||
label: 'Gutschrift',
|
||
valueText: '− ${(credit!.amountCents / 100).toStringAsFixed(2)} €',
|
||
valueColor: Colors.amber.shade800,
|
||
subtitle: credit!.reason,
|
||
),
|
||
],
|
||
const Divider(height: 24),
|
||
_SummaryRow(
|
||
icon: Icons.account_balance_wallet_outlined,
|
||
label: 'Offener Betrag',
|
||
valueText: '${open.toStringAsFixed(2)} €',
|
||
valueColor: open > 0
|
||
? theme.colorScheme.primary
|
||
: Colors.green.shade700,
|
||
emphasize: true,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _SummaryRow extends StatelessWidget {
|
||
const _SummaryRow({
|
||
required this.icon,
|
||
required this.label,
|
||
required this.valueText,
|
||
required this.valueColor,
|
||
this.subtitle,
|
||
this.emphasize = false,
|
||
});
|
||
|
||
final IconData icon;
|
||
final String label;
|
||
final String valueText;
|
||
final Color valueColor;
|
||
final String? subtitle;
|
||
|
||
/// Hebt Label + Wert hervor (für den „Offener Betrag"-Abschluss).
|
||
final bool emphasize;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
return Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Padding(
|
||
padding: const EdgeInsets.only(top: 2),
|
||
child: Icon(icon, color: theme.colorScheme.primary),
|
||
),
|
||
const SizedBox(width: 10),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
label,
|
||
style: emphasize
|
||
? const TextStyle(fontWeight: FontWeight.w700)
|
||
: null,
|
||
),
|
||
if (subtitle != null)
|
||
Text(
|
||
subtitle!,
|
||
style: TextStyle(
|
||
fontSize: 12,
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
Text(
|
||
valueText,
|
||
style: (emphasize
|
||
? theme.textTheme.titleLarge
|
||
: theme.textTheme.titleMedium)
|
||
?.copyWith(
|
||
fontWeight: FontWeight.w700,
|
||
color: valueColor,
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
class _PaymentMethodPicker extends StatelessWidget {
|
||
const _PaymentMethodPicker({
|
||
required this.delivery,
|
||
required this.overrideId,
|
||
required this.credit,
|
||
});
|
||
|
||
final Delivery delivery;
|
||
final String? overrideId;
|
||
final DeliveryCredit? credit;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return BlocBuilder<PaymentMethodsCubit, PaymentMethodsState>(
|
||
builder: (context, state) {
|
||
if (state is PaymentMethodsLoading || state is PaymentMethodsInitial) {
|
||
return const Card(
|
||
margin: EdgeInsets.zero,
|
||
child: Padding(
|
||
padding: EdgeInsets.all(16),
|
||
child: Row(
|
||
children: [
|
||
SizedBox(
|
||
width: 16,
|
||
height: 16,
|
||
child: CircularProgressIndicator(strokeWidth: 2),
|
||
),
|
||
SizedBox(width: 12),
|
||
Text('Zahlungsmethoden laden …'),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
if (state is PaymentMethodsFailed) {
|
||
return Card(
|
||
margin: EdgeInsets.zero,
|
||
color: Theme.of(context).colorScheme.errorContainer,
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(16),
|
||
child: Text(
|
||
state.message,
|
||
style: TextStyle(
|
||
color: Theme.of(context).colorScheme.onErrorContainer,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
final loaded = state as PaymentMethodsLoaded;
|
||
// Ausschließlich die Backend-Methoden — keine frontend-seitige
|
||
// Fabrikation/Hardcodierung. Es werden genau die angezeigt, die im
|
||
// Backend (Postgres `payment_methods`, aktiv) hinterlegt sind.
|
||
final methods = loaded.methods;
|
||
final selectedId = overrideId ?? delivery.paymentMethodId;
|
||
// Als Dropdown-Value nur setzen, wenn die Methode tatsächlich in der
|
||
// Backend-Liste ist (sonst würde Flutter asserten). Ist die zugewiesene
|
||
// Methode zwischenzeitlich deaktiviert/entfernt, bleibt das Feld leer.
|
||
final selectedValue =
|
||
methods.any((m) => m.id == selectedId) ? selectedId : null;
|
||
// Zahlungsmethode nur bei aktiver Lieferung änderbar. Bei
|
||
// abgeschlossener/abgebrochener/pausierter Lieferung zeigt das
|
||
// Dropdown den gewählten Stand, ist aber gesperrt.
|
||
final active = delivery.state == DeliveryState.active;
|
||
// Steht kein offener Betrag mehr aus (vollständig vorab bezahlt
|
||
// oder per Gutschrift ausgeglichen), ist keine Zahlungsweise zu
|
||
// wählen → Auswahl deaktivieren.
|
||
final hasOpenAmount = _openAmount(delivery, credit) > 0;
|
||
final editable = active && hasOpenAmount;
|
||
return Card(
|
||
margin: EdgeInsets.zero,
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(16),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
DropdownButtonFormField<String>(
|
||
initialValue: selectedValue,
|
||
decoration: const InputDecoration(
|
||
labelText: 'Zahlungsmethode',
|
||
border: OutlineInputBorder(),
|
||
),
|
||
items: [
|
||
for (final m in methods)
|
||
DropdownMenuItem(
|
||
value: m.id,
|
||
child: Text(m.name),
|
||
),
|
||
],
|
||
// `null` deaktiviert das Dropdown (Flutter-Konvention).
|
||
onChanged: editable
|
||
? (newId) {
|
||
if (newId == null) return;
|
||
context.read<DeliveryWorkflowBloc>().add(
|
||
WorkflowOverridePaymentMethod(
|
||
// Zurück auf die Original-Methode → Override
|
||
// löschen, damit das Domain-Modell "no
|
||
// override" kennt.
|
||
paymentMethodId:
|
||
newId == delivery.paymentMethodId
|
||
? null
|
||
: newId,
|
||
),
|
||
);
|
||
}
|
||
: null,
|
||
),
|
||
if (!active) ...[
|
||
const SizedBox(height: 8),
|
||
const _PickerHint(
|
||
text: 'Lieferung abgeschlossen — Zahlungsmethode nicht '
|
||
'mehr änderbar.',
|
||
),
|
||
] else if (!hasOpenAmount) ...[
|
||
const SizedBox(height: 8),
|
||
const _PickerHint(
|
||
text: 'Keine Zahlung mehr offen (bereits bezahlt) — '
|
||
'Auswahl der Zahlungsweise nicht erforderlich.',
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Dezenter Sperr-/Info-Hinweis unter dem Zahlungsmethoden-Dropdown
|
||
/// (Schloss-Icon + Text in gedämpfter Farbe).
|
||
class _PickerHint extends StatelessWidget {
|
||
const _PickerHint({required this.text});
|
||
|
||
final String text;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final muted = Theme.of(context).colorScheme.onSurfaceVariant;
|
||
return Row(
|
||
children: [
|
||
Icon(Icons.lock_outline, size: 16, color: muted),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: Text(
|
||
text,
|
||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: muted),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
class _SignHint extends StatelessWidget {
|
||
const _SignHint();
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
return Container(
|
||
padding: const EdgeInsets.all(12),
|
||
decoration: BoxDecoration(
|
||
color: theme.colorScheme.primary.withValues(alpha: 0.07),
|
||
borderRadius: BorderRadius.circular(8),
|
||
border: Border.all(
|
||
color: theme.colorScheme.primary.withValues(alpha: 0.3),
|
||
),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Icon(Icons.draw_outlined, color: theme.colorScheme.primary),
|
||
const SizedBox(width: 10),
|
||
Expanded(
|
||
child: Text(
|
||
'Mit „Unterschreiben" unten schließt der Kunde den Vorgang ab.',
|
||
style: TextStyle(
|
||
fontSize: 13,
|
||
color: theme.colorScheme.primary,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|