feat(zahlung): eigener Step "Zahlung" vor der Uebersicht
- Neuer Workflow-Step "Zahlung" zeigt ausgelieferte Artikel mit Menge und Preis, Betragsaufstellung, Zahlungsstatus und einen grossen Button "Zahlung abwickeln" (fest unten) - Zahlungs-Modal: grosser offener Betrag, Methoden als Cards aus den Backend-Stammdaten, bei EC-Karte gross die Kundennummer; schliesst nur ueber Abbrechen, "Zahlung erhalten" oder "Lieferung abbrechen" (mit Grund) - Bestaetigung wird am Server protokolliert (RecordDeliveryPayment); bis dahin sind "Weiter" und die Uebersicht gesperrt, ausser es ist nichts offen (bereits bezahlt). Aendert sich der Betrag danach, wird die Zahlung als veraltet markiert und muss erneut abgewickelt werden - Uebersicht zeigt die abgewickelte Zahlung statt eigener Methodenauswahl; Unterschrift-Flow ohne doppelte Inkasso-Stufe; Abschluss sendet Methode und Inkasso-Flag der gueltigen Zahlung - DeliveryPaymentStatus als einzige Quelle fuer offenen Betrag (gleiche Formel wie Backend); Artikelliste/Betragskarte als geteilte Widgets; Begruendungs-Dialog geteilt mit Info-Step - API-Client aus aktueller Backend-Spec neu generiert - Tests fuer Zahlungsstatus und Modal Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@ -17,7 +17,6 @@ class SignatureResult {
|
||||
required this.receiptConfirmed,
|
||||
required this.notesAcknowledged,
|
||||
required this.acknowledgedNoteIds,
|
||||
required this.paymentCollected,
|
||||
});
|
||||
|
||||
final Uint8List customerSignaturePng;
|
||||
@ -25,18 +24,13 @@ class SignatureResult {
|
||||
final bool receiptConfirmed;
|
||||
final bool notesAcknowledged;
|
||||
final List<String> acknowledgedNoteIds;
|
||||
|
||||
/// Fahrer hat das Inkasso (Bar/EC) des offenen Betrags bestätigt. `false`,
|
||||
/// wenn kein Inkasso anfiel (offen == 0 oder „Auf Rechnung").
|
||||
final bool paymentCollected;
|
||||
}
|
||||
|
||||
/// Mehrstufiger Unterschrift-Flow zum Abschließen einer Lieferung.
|
||||
///
|
||||
/// Stufe 0 (Fahrer, optional): nur wenn beim Abschluss ein offener Betrag
|
||||
/// per Vor-Ort-Inkasso (Bar/EC) zu kassieren ist ([requiresCollection]).
|
||||
/// Der Fahrer bestätigt, dass der Betrag erhalten/abgerechnet wurde — VOR
|
||||
/// beiden Unterschriften.
|
||||
/// Das Kassieren passiert vorher im eigenen Workflow-Step „Zahlung" und
|
||||
/// wird dort protokolliert — dieser Flow enthält nur noch die Unterschriften.
|
||||
///
|
||||
/// Stufe 1 (Kunde): sieht die Anmerkungen zur Lieferung, hakt zwei
|
||||
/// Bestätigungen ab (Anmerkungen-Kenntnisnahme — nur Pflicht, wenn Notizen
|
||||
/// vorhanden; Empfangsbestätigung — immer Pflicht) und unterschreibt.
|
||||
@ -51,31 +45,18 @@ class SignatureView extends StatefulWidget {
|
||||
required this.delivery,
|
||||
required this.details,
|
||||
required this.onSigned,
|
||||
this.requiresCollection = false,
|
||||
this.openAmount = 0,
|
||||
this.paymentMethodLabel = '',
|
||||
});
|
||||
|
||||
final Delivery delivery;
|
||||
final TourDetails details;
|
||||
final void Function(SignatureResult result) onSigned;
|
||||
|
||||
/// Offener Betrag muss vor Ort kassiert werden (offen > 0 UND Bar/EC).
|
||||
/// Schaltet Stufe 0 (Inkasso-Bestätigung) frei.
|
||||
final bool requiresCollection;
|
||||
|
||||
/// Offener Betrag in Euro (nur für die Anzeige in Stufe 0).
|
||||
final double openAmount;
|
||||
|
||||
/// Anzeigename der Zahlungsmethode (z. B. „Bar", „EC-Karte").
|
||||
final String paymentMethodLabel;
|
||||
|
||||
@override
|
||||
State<SignatureView> createState() => _SignatureViewState();
|
||||
}
|
||||
|
||||
/// Stufen des Abschluss-Flows.
|
||||
enum _SignStage { payment, customer, driver }
|
||||
enum _SignStage { customer, driver }
|
||||
|
||||
class _SignatureViewState extends State<SignatureView> {
|
||||
static const String _receiptText =
|
||||
@ -97,8 +78,7 @@ class _SignatureViewState extends State<SignatureView> {
|
||||
);
|
||||
|
||||
late final List<DeliveryNote> _notes;
|
||||
late _SignStage _stage;
|
||||
bool _paymentConfirmed = false;
|
||||
_SignStage _stage = _SignStage.customer;
|
||||
bool _receiptAccepted = false;
|
||||
bool _notesAccepted = false;
|
||||
bool _customerEmpty = true;
|
||||
@ -109,10 +89,8 @@ class _SignatureViewState extends State<SignatureView> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Inkasso-Bestätigung (Stufe 0) nur wenn gefordert, sonst direkt zum Kunden.
|
||||
_stage =
|
||||
widget.requiresCollection ? _SignStage.payment : _SignStage.customer;
|
||||
_notes = widget.details.notesByDeliveryId[widget.delivery.id] ??
|
||||
_notes =
|
||||
widget.details.notesByDeliveryId[widget.delivery.id] ??
|
||||
const <DeliveryNote>[];
|
||||
_customerController.addListener(() {
|
||||
if (_customerEmpty != _customerController.isEmpty) {
|
||||
@ -139,8 +117,6 @@ class _SignatureViewState extends State<SignatureView> {
|
||||
/// Ist der Primär-Button auf der aktuellen Stufe aktiv?
|
||||
bool get _stageValid {
|
||||
switch (_stage) {
|
||||
case _SignStage.payment:
|
||||
return _paymentConfirmed;
|
||||
case _SignStage.customer:
|
||||
return _customerStepValid;
|
||||
case _SignStage.driver:
|
||||
@ -150,9 +126,6 @@ class _SignatureViewState extends State<SignatureView> {
|
||||
|
||||
Future<void> _onPrimaryPressed() async {
|
||||
switch (_stage) {
|
||||
case _SignStage.payment:
|
||||
setState(() => _stage = _SignStage.customer);
|
||||
return;
|
||||
case _SignStage.customer:
|
||||
setState(() => _stage = _SignStage.driver);
|
||||
return;
|
||||
@ -168,33 +141,20 @@ class _SignatureViewState extends State<SignatureView> {
|
||||
notesAcknowledged: _notesEmpty ? false : _notesAccepted,
|
||||
acknowledgedNoteIds:
|
||||
_notesEmpty ? const [] : _notes.map((n) => n.id).toList(),
|
||||
paymentCollected: widget.requiresCollection && _paymentConfirmed,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String get _paymentText {
|
||||
final amount = widget.openAmount.toStringAsFixed(2).replaceAll('.', ',');
|
||||
final via = widget.paymentMethodLabel.isEmpty
|
||||
? ''
|
||||
: ' per ${widget.paymentMethodLabel}';
|
||||
return 'Ich bestätige, dass der offene Betrag von $amount €$via '
|
||||
'erhalten bzw. abgerechnet wurde.';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final customer = widget.details.customerOf(widget.delivery);
|
||||
final date = DateFormat('dd.MM.yyyy').format(DateTime.now());
|
||||
final isPayment = _stage == _SignStage.payment;
|
||||
final isDriver = _stage == _SignStage.driver;
|
||||
|
||||
final String title;
|
||||
switch (_stage) {
|
||||
case _SignStage.payment:
|
||||
title = 'Zahlung bestätigen';
|
||||
case _SignStage.customer:
|
||||
title = 'Unterschrift des Kunden';
|
||||
case _SignStage.driver:
|
||||
@ -207,56 +167,42 @@ class _SignatureViewState extends State<SignatureView> {
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
if (isPayment) ...[
|
||||
// Stufe 0 — Fahrer kassiert (Bar/EC) und bestätigt VOR den
|
||||
// Unterschriften.
|
||||
_PaymentDueCard(
|
||||
openAmount: widget.openAmount,
|
||||
methodLabel: widget.paymentMethodLabel,
|
||||
),
|
||||
if (!isDriver) ...[
|
||||
_NotesSection(notes: _notes),
|
||||
const SizedBox(height: 16),
|
||||
_ConfirmTile(
|
||||
value: _paymentConfirmed,
|
||||
value: _notesAccepted,
|
||||
enabled: !_notesEmpty,
|
||||
label: _notesText,
|
||||
onChanged: (v) => setState(() => _notesAccepted = v),
|
||||
),
|
||||
_ConfirmTile(
|
||||
value: _receiptAccepted,
|
||||
enabled: true,
|
||||
label: _paymentText,
|
||||
onChanged: (v) => setState(() => _paymentConfirmed = v),
|
||||
),
|
||||
] else ...[
|
||||
if (!isDriver) ...[
|
||||
_NotesSection(notes: _notes),
|
||||
const SizedBox(height: 16),
|
||||
_ConfirmTile(
|
||||
value: _notesAccepted,
|
||||
enabled: !_notesEmpty,
|
||||
label: _notesText,
|
||||
onChanged: (v) => setState(() => _notesAccepted = v),
|
||||
),
|
||||
_ConfirmTile(
|
||||
value: _receiptAccepted,
|
||||
enabled: true,
|
||||
label: _receiptText,
|
||||
onChanged: (v) => setState(() => _receiptAccepted = v),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
Text(
|
||||
'Lieferung an: ${customer?.name ?? '⟨Unbekannter Kunde⟩'}',
|
||||
style: theme.textTheme.titleSmall
|
||||
?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_SignaturePad(
|
||||
controller: isDriver ? _driverController : _customerController,
|
||||
onClear: () =>
|
||||
(isDriver ? _driverController : _customerController)
|
||||
.clear(),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${customer?.address.city ?? ''}, den $date',
|
||||
style: theme.textTheme.bodySmall,
|
||||
label: _receiptText,
|
||||
onChanged: (v) => setState(() => _receiptAccepted = v),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
Text(
|
||||
'Lieferung an: ${customer?.name ?? '⟨Unbekannter Kunde⟩'}',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_SignaturePad(
|
||||
controller: isDriver ? _driverController : _customerController,
|
||||
onClear:
|
||||
() =>
|
||||
(isDriver ? _driverController : _customerController)
|
||||
.clear(),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${customer?.address.city ?? ''}, den $date',
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton.icon(
|
||||
onPressed: _stageValid ? _onPrimaryPressed : null,
|
||||
@ -270,66 +216,6 @@ class _SignatureViewState extends State<SignatureView> {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Inkasso-Hinweis (Stufe 0) ──────────────────────────────────────────────
|
||||
|
||||
class _PaymentDueCard extends StatelessWidget {
|
||||
const _PaymentDueCard({required this.openAmount, required this.methodLabel});
|
||||
|
||||
final double openAmount;
|
||||
final String methodLabel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final amount = openAmount.toStringAsFixed(2).replaceAll('.', ',');
|
||||
return Card(
|
||||
margin: EdgeInsets.zero,
|
||||
color: theme.colorScheme.primary.withValues(alpha: 0.07),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Offener Betrag',
|
||||
style: theme.textTheme.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.payments_outlined, color: theme.colorScheme.primary),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
'$amount €',
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (methodLabel.isNotEmpty)
|
||||
Chip(
|
||||
label: Text(methodLabel),
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Bitte den Betrag bar entgegennehmen oder über das EC-Gerät '
|
||||
'abrechnen und anschließend bestätigen.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Notizen-Block ────────────────────────────────────────────────────────
|
||||
|
||||
class _NotesSection extends StatelessWidget {
|
||||
@ -345,8 +231,9 @@ class _NotesSection extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
'Anmerkungen zur Lieferung',
|
||||
style:
|
||||
theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (notes.isEmpty)
|
||||
|
||||
Reference in New Issue
Block a user