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:
185
lib/feature/delivery/detail/presentation/steps/step_payment.dart
Normal file
185
lib/feature/delivery/detail/presentation/steps/step_payment.dart
Normal file
@ -0,0 +1,185 @@
|
||||
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/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/model/delivery_payment_status.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/presentation/widget/delivered_items_card.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/presentation/widget/payment_breakdown_card.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/presentation/widget/payment_record_card.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/presentation/widget/payment_sheet.dart';
|
||||
|
||||
/// Step „Zahlung" — Zahlungsabwicklung („Abkassieren") vor der Übersicht.
|
||||
///
|
||||
/// Zeigt die ausgelieferten Artikel mit Menge und Preis, die Betrags-
|
||||
/// aufstellung und den Zahlungsstatus. Über „Zahlung abwickeln" öffnet sich
|
||||
/// das Zahlungs-Modal; die Bestätigung wird am Server protokolliert.
|
||||
///
|
||||
/// Ist nichts offen (bereits bezahlt), kann der Fahrer den Step
|
||||
/// überspringen. Sonst gibt die Detail-Page „Weiter" erst frei, wenn eine
|
||||
/// gültige Zahlung protokolliert ist (siehe `DeliveryPaymentStatus`).
|
||||
class StepPayment extends StatelessWidget {
|
||||
const StepPayment({super.key, required this.delivery, required this.details});
|
||||
|
||||
final Delivery delivery;
|
||||
final TourDetails details;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final status = DeliveryPaymentStatus.of(delivery, details);
|
||||
final canCollect =
|
||||
delivery.state == DeliveryState.active && status.hasOpenAmount;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
|
||||
children: [
|
||||
const _SectionHeader(text: 'Ausgelieferte Artikel'),
|
||||
const SizedBox(height: 8),
|
||||
DeliveredItemsCard(delivery: delivery, details: details),
|
||||
const SizedBox(height: 24),
|
||||
const _SectionHeader(text: 'Betrag'),
|
||||
const SizedBox(height: 8),
|
||||
PaymentBreakdownCard(
|
||||
status: status,
|
||||
credit: details.creditOf(delivery.id),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
PaymentRecordCard(status: status),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (canCollect)
|
||||
_CollectPanel(
|
||||
status: status,
|
||||
onCollect: () => _openPaymentSheet(context, status),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openPaymentSheet(
|
||||
BuildContext context,
|
||||
DeliveryPaymentStatus status,
|
||||
) async {
|
||||
final tourBloc = context.read<TourBloc>();
|
||||
final actorCarId = _actorCarId(context);
|
||||
final customer = details.customerOf(delivery);
|
||||
|
||||
final result = await showPaymentSheet(
|
||||
context,
|
||||
openCents: status.openCents,
|
||||
customerNumber: customer?.erpCustomerId ?? 0,
|
||||
// Vorauswahl: zuletzt abgewickelte Methode, sonst die vom Beleg.
|
||||
preselectedMethodId:
|
||||
status.latestPayment?.paymentMethodId ?? delivery.paymentMethodId,
|
||||
);
|
||||
|
||||
switch (result) {
|
||||
case PaymentConfirmed(:final method):
|
||||
tourBloc.add(
|
||||
RecordDeliveryPayment(
|
||||
deliveryId: delivery.id,
|
||||
paymentMethodId: method.id,
|
||||
expectedAmountCents: status.openCents,
|
||||
actorCarId: actorCarId,
|
||||
),
|
||||
);
|
||||
case DeliveryCancelRequested(:final reason):
|
||||
tourBloc.add(CancelDelivery(deliveryId: delivery.id, reason: reason));
|
||||
case null:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
String? _actorCarId(BuildContext context) {
|
||||
final state = context.read<CarSelectBloc>().state;
|
||||
return state is CarSelectComplete ? state.selectedCar.id : null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Fester Bereich unten: offener Betrag + großer „Zahlung abwickeln"-Button.
|
||||
/// Bleibt sichtbar, auch wenn die Artikelliste lang ist.
|
||||
class _CollectPanel extends StatelessWidget {
|
||||
const _CollectPanel({required this.status, required this.onCollect});
|
||||
|
||||
final DeliveryPaymentStatus status;
|
||||
final VoidCallback onCollect;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final settled = status.validPayment != null;
|
||||
return Material(
|
||||
elevation: 6,
|
||||
color: theme.colorScheme.surface,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text('Offener Betrag', style: theme.textTheme.titleMedium),
|
||||
const Spacer(),
|
||||
Text(
|
||||
formatEuroCents(status.openCents),
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
if (settled)
|
||||
// Bereits abgewickelt: Korrektur möglich, aber nicht mehr im
|
||||
// Vordergrund — „Weiter" ist jetzt der nächste Schritt.
|
||||
OutlinedButton.icon(
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(52),
|
||||
),
|
||||
onPressed: onCollect,
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
label: const Text('Zahlung korrigieren'),
|
||||
)
|
||||
else
|
||||
FilledButton.icon(
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(68),
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
onPressed: onCollect,
|
||||
icon: const Icon(Icons.point_of_sale, size: 28),
|
||||
label: const Text('Zahlung abwickeln'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user