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:
@ -73,60 +73,100 @@ class DeliveryDetail extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _DeliveryDetailScaffold extends StatelessWidget {
|
class _DeliveryDetailScaffold extends StatefulWidget {
|
||||||
const _DeliveryDetailScaffold({required this.deliveryId});
|
const _DeliveryDetailScaffold({required this.deliveryId});
|
||||||
|
|
||||||
final String deliveryId;
|
final String deliveryId;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
State<_DeliveryDetailScaffold> createState() =>
|
||||||
final theme = Theme.of(context);
|
_DeliveryDetailScaffoldState();
|
||||||
return BlocBuilder<TourBloc, TourState>(
|
}
|
||||||
builder: (context, tourState) {
|
|
||||||
if (tourState is! TourLoaded) {
|
class _DeliveryDetailScaffoldState extends State<_DeliveryDetailScaffold> {
|
||||||
return const Scaffold(
|
/// „Gearmt" = die Lieferung war während dieser Page-Session aktiv. Nur dann
|
||||||
body: Center(child: CircularProgressIndicator()),
|
/// 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).
|
||||||
final details = tourState.details;
|
bool _armed = false;
|
||||||
final delivery = _findDelivery(details);
|
bool _popped = false;
|
||||||
if (delivery == null) {
|
|
||||||
return Scaffold(
|
@override
|
||||||
appBar: AppBar(title: const Text('Lieferung')),
|
void initState() {
|
||||||
body: Center(
|
super.initState();
|
||||||
child: Text('Lieferung $deliveryId nicht in der Tour gefunden.'),
|
final s = context.read<TourBloc>().state;
|
||||||
),
|
if (s is TourLoaded && _findDelivery(s.details)?.state == DeliveryState.active) {
|
||||||
);
|
_armed = true;
|
||||||
}
|
}
|
||||||
final customer = details.customerOf(delivery);
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(
|
|
||||||
backgroundColor: theme.primaryColor,
|
|
||||||
foregroundColor: theme.colorScheme.onPrimary,
|
|
||||||
title: Text(customer?.name ?? 'Lieferung'),
|
|
||||||
),
|
|
||||||
body: Column(
|
|
||||||
children: [
|
|
||||||
const _StepHeader(),
|
|
||||||
const Divider(height: 1),
|
|
||||||
Expanded(
|
|
||||||
child: _StepBody(delivery: delivery, details: details),
|
|
||||||
),
|
|
||||||
const Divider(height: 1),
|
|
||||||
_BottomNav(delivery: delivery, details: details),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Delivery? _findDelivery(TourDetails details) {
|
Delivery? _findDelivery(TourDetails details) {
|
||||||
for (final d in details.deliveries) {
|
for (final d in details.deliveries) {
|
||||||
if (d.id == deliveryId) return d;
|
if (d.id == widget.deliveryId) return d;
|
||||||
}
|
}
|
||||||
return null;
|
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 BlocListener<TourBloc, TourState>(
|
||||||
|
listener: _onTourState,
|
||||||
|
child: BlocBuilder<TourBloc, TourState>(
|
||||||
|
builder: (context, tourState) {
|
||||||
|
if (tourState is! TourLoaded) {
|
||||||
|
return const Scaffold(
|
||||||
|
body: Center(child: CircularProgressIndicator()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final details = tourState.details;
|
||||||
|
final delivery = _findDelivery(details);
|
||||||
|
if (delivery == null) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('Lieferung')),
|
||||||
|
body: Center(
|
||||||
|
child: Text(
|
||||||
|
'Lieferung ${widget.deliveryId} nicht in der Tour gefunden.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final customer = details.customerOf(delivery);
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: theme.primaryColor,
|
||||||
|
foregroundColor: theme.colorScheme.onPrimary,
|
||||||
|
title: Text(customer?.name ?? 'Lieferung'),
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
const _StepHeader(),
|
||||||
|
const Divider(height: 1),
|
||||||
|
Expanded(
|
||||||
|
child: _StepBody(delivery: delivery, details: details),
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
_BottomNav(delivery: delivery, details: details),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Step-Header (Pills) ────────────────────────────────────────────────
|
// ─── Step-Header (Pills) ────────────────────────────────────────────────
|
||||||
|
|||||||
@ -49,6 +49,24 @@ class StepArticles extends StatelessWidget {
|
|||||||
return (a.komponentenArtikelNr ?? '')
|
return (a.komponentenArtikelNr ?? '')
|
||||||
.compareTo(b.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(
|
return ListView(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
|
||||||
children: [
|
children: [
|
||||||
@ -73,6 +91,7 @@ class StepArticles extends StatelessWidget {
|
|||||||
details: details,
|
details: details,
|
||||||
deliveryId: delivery.id,
|
deliveryId: delivery.id,
|
||||||
deliveryActive: delivery.state == DeliveryState.active,
|
deliveryActive: delivery.state == DeliveryState.active,
|
||||||
|
components: componentsOf(items[i]),
|
||||||
),
|
),
|
||||||
if (i < items.length - 1)
|
if (i < items.length - 1)
|
||||||
const Divider(height: 1, indent: 16, endIndent: 16),
|
const Divider(height: 1, indent: 16, endIndent: 16),
|
||||||
@ -149,6 +168,7 @@ class _ArticleManagementRow extends StatelessWidget {
|
|||||||
required this.details,
|
required this.details,
|
||||||
required this.deliveryId,
|
required this.deliveryId,
|
||||||
required this.deliveryActive,
|
required this.deliveryActive,
|
||||||
|
this.components = const [],
|
||||||
});
|
});
|
||||||
|
|
||||||
final DeliveryItem item;
|
final DeliveryItem item;
|
||||||
@ -156,6 +176,10 @@ class _ArticleManagementRow extends StatelessWidget {
|
|||||||
final String deliveryId;
|
final String deliveryId;
|
||||||
final bool deliveryActive;
|
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(
|
Future<void> _openCreditDialog(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
required int remaining,
|
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
|
/// Holt die aktuelle Auto-ID aus dem CarSelectBloc — gleiche Konvention
|
||||||
/// wie der Loading-Flow: das aktiv gewählte Fahrzeug ist der „Akteur"
|
/// wie der Loading-Flow: das aktiv gewählte Fahrzeug ist der „Akteur"
|
||||||
/// des Audit-Events. Falls (defensiv) noch keine Auswahl getroffen ist,
|
/// 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 fullyRemoved = item.isRemoved; // Status == removed (voll gutgeschr.)
|
||||||
final partiallyCredited = credited > 0 && !fullyRemoved;
|
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.
|
// Gate: scannbare Position muss `done` sein, sonst keine Gutschrift.
|
||||||
final scannable = article?.scannable ?? false;
|
final scannable = article?.scannable ?? false;
|
||||||
final isDone = item.scanProgress.status == ScanStatus.done;
|
final isDone = item.scanProgress.status == ScanStatus.done;
|
||||||
final blockedByScan = scannable && !isDone && !fullyRemoved;
|
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 Color avatarColor;
|
||||||
final String avatarText;
|
final String avatarText;
|
||||||
@ -285,53 +370,83 @@ class _ArticleManagementRow extends StatelessWidget {
|
|||||||
text: 'Erst scannen/verladen — dann Gutschrift möglich',
|
text: 'Erst scannen/verladen — dann Gutschrift möglich',
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
],
|
// E: Komponenten-Hinweis — einzeln nicht entfernbar.
|
||||||
),
|
if (isComponent && deliveryActive && !fullyRemoved)
|
||||||
trailing: Row(
|
_StatusLine(
|
||||||
mainAxisSize: MainAxisSize.min,
|
text: 'Nur über den Oberartikel entfernbar',
|
||||||
children: [
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
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
|
|
||||||
? 'Gutschrift zurücknehmen'
|
|
||||||
: 'Nur bei aktiver Lieferung',
|
|
||||||
icon: Icon(
|
|
||||||
Icons.restore,
|
|
||||||
color: deliveryActive
|
|
||||||
? theme.colorScheme.primary
|
|
||||||
: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
onPressed: deliveryActive ? () => _restoreAll(context) : null,
|
|
||||||
),
|
),
|
||||||
if (!fullyRemoved)
|
// Set-Oberartikel: blockiert, solange eine Komponente noch nicht
|
||||||
IconButton.outlined(
|
// verladen ist.
|
||||||
tooltip: blockedByScan
|
if (hasComponents &&
|
||||||
? 'Erst scannen/verladen'
|
componentsBlocked &&
|
||||||
: (!deliveryActive
|
deliveryActive &&
|
||||||
? 'Nur bei aktiver Lieferung'
|
!fullyRemoved)
|
||||||
: 'Gutschrift / entfernen'),
|
_StatusLine(
|
||||||
style: ButtonStyle(
|
text: 'Erst alle Set-Teile verladen — dann ganzes Set entfernbar',
|
||||||
backgroundColor: WidgetStatePropertyAll(
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
canCredit
|
|
||||||
? Colors.redAccent
|
|
||||||
: theme.colorScheme.surfaceContainerHighest,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
onPressed: canCredit
|
|
||||||
? () => _openCreditDialog(context, remaining: remaining)
|
|
||||||
: null,
|
|
||||||
icon: Icon(
|
|
||||||
Icons.delete,
|
|
||||||
color: canCredit
|
|
||||||
? theme.colorScheme.onPrimary
|
|
||||||
: 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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/domain/entity/tour_details.dart';
|
||||||
import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.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/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.
|
/// Step 1 — Informationen zur Lieferung.
|
||||||
///
|
///
|
||||||
@ -716,16 +719,77 @@ class _ArticleList extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Card(
|
// Hat sich bei mindestens einem Artikel die tatsächliche Menge geändert
|
||||||
margin: EdgeInsets.zero,
|
// (entfernt oder teilweise gutgeschrieben)? Dann ein Banner mit Verweis
|
||||||
child: Column(
|
// auf Step 3 „Artikel", wo die Änderungen verwaltet werden.
|
||||||
children: [
|
final anyChanged = items.any(_quantityChanged);
|
||||||
for (int i = 0; i < items.length; i++) ...[
|
|
||||||
_ArticleRow(item: items[i], details: details),
|
return Column(
|
||||||
if (i < items.length - 1)
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
const Divider(height: 1, indent: 16, endIndent: 16),
|
children: [
|
||||||
],
|
if (anyChanged) ...[
|
||||||
|
const _QuantityChangedBanner(),
|
||||||
|
const SizedBox(height: 8),
|
||||||
],
|
],
|
||||||
|
Card(
|
||||||
|
margin: EdgeInsets.zero,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
for (int i = 0; i < items.length; i++) ...[
|
||||||
|
_ArticleRow(item: items[i], details: details),
|
||||||
|
if (i < items.length - 1)
|
||||||
|
const Divider(height: 1, indent: 16, endIndent: 16),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `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 warehouse = details.warehouseOf(item.warehouseId);
|
||||||
final isScannable = article?.scannable ?? false;
|
final isScannable = article?.scannable ?? false;
|
||||||
final removed = item.isRemoved;
|
final removed = item.isRemoved;
|
||||||
|
// Menge tatsächlich verändert (entfernt oder teilweise gutgeschrieben)?
|
||||||
|
final changed = _quantityChanged(item);
|
||||||
|
|
||||||
return ListTile(
|
return ListTile(
|
||||||
// Komponenten um eine Stufe eingerückt (gehören zum Oberartikel darüber).
|
// Komponenten um eine Stufe eingerückt (gehören zum Oberartikel darüber).
|
||||||
@ -756,6 +822,8 @@ class _ArticleRow extends StatelessWidget {
|
|||||||
foregroundColor: removed
|
foregroundColor: removed
|
||||||
? theme.colorScheme.onSurfaceVariant
|
? theme.colorScheme.onSurfaceVariant
|
||||||
: theme.colorScheme.onPrimary,
|
: theme.colorScheme.onPrimary,
|
||||||
|
// Bewusst die URSPRÜNGLICH bestellte Menge (requiredQuantity), nicht
|
||||||
|
// die reduzierte — Änderungen werden separat als Hinweis ausgewiesen.
|
||||||
child: Text(
|
child: Text(
|
||||||
'${item.requiredQuantity}×',
|
'${item.requiredQuantity}×',
|
||||||
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
|
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
|
||||||
@ -769,19 +837,47 @@ class _ArticleRow extends StatelessWidget {
|
|||||||
color: removed ? theme.colorScheme.onSurfaceVariant : null,
|
color: removed ? theme.colorScheme.onSurfaceVariant : null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
subtitle: Text(
|
subtitle: Column(
|
||||||
[
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
article?.articleNumber ?? item.articleId,
|
children: [
|
||||||
if (warehouse != null) warehouse.name,
|
Text(
|
||||||
if (!isScannable) 'Dienstleistung',
|
[
|
||||||
if (item.unitPrice > 0)
|
article?.articleNumber ?? item.articleId,
|
||||||
'${item.unitPrice.toStringAsFixed(2)} € / Stück',
|
if (warehouse != null) warehouse.name,
|
||||||
].join(' · '),
|
if (!isScannable) 'Dienstleistung',
|
||||||
style: TextStyle(
|
if (item.unitPrice > 0)
|
||||||
fontSize: 12,
|
'${item.unitPrice.toStringAsFixed(2)} € / Stück',
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
].join(' · '),
|
||||||
decoration: removed ? TextDecoration.lineThrough : null,
|
style: TextStyle(
|
||||||
),
|
fontSize: 12,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
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
|
trailing: isScannable
|
||||||
? Text(
|
? Text(
|
||||||
|
|||||||
@ -471,8 +471,8 @@ class _PaymentMethodPicker extends StatelessWidget {
|
|||||||
] else if (!hasOpenAmount) ...[
|
] else if (!hasOpenAmount) ...[
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
const _PickerHint(
|
const _PickerHint(
|
||||||
text: 'Kein offener Betrag — Auswahl der Zahlungsweise '
|
text: 'Keine Zahlung mehr offen (bereits bezahlt) — '
|
||||||
'nicht erforderlich.',
|
'Auswahl der Zahlungsweise nicht erforderlich.',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|||||||
@ -535,6 +535,39 @@ class _CustomerBody extends StatelessWidget {
|
|||||||
// erkennt. Liegen außerhalb von `groups` (die nur scanbare Items führen).
|
// erkennt. Liegen außerhalb von `groups` (die nur scanbare Items führen).
|
||||||
final serviceItems = details.nonScannableItems(delivery).toList();
|
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(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
// Header bekommt einen eigenen, leicht abgehobenen Hintergrund
|
// Header bekommt einen eigenen, leicht abgehobenen Hintergrund
|
||||||
@ -627,26 +660,30 @@ class _CustomerBody extends StatelessWidget {
|
|||||||
warehouse: group.warehouse,
|
warehouse: group.warehouse,
|
||||||
items: group.items,
|
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(
|
_ItemRow(
|
||||||
item: item,
|
item: item,
|
||||||
details: details,
|
details: details,
|
||||||
onAction: (action) => onItemAction(item, action),
|
onAction: (action) => onItemAction(item, action),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
// Gebuchte Dienstleistungen (nicht-scanbare Positionen): eigene
|
// Gebuchte Dienstleistungen (nicht-scanbare Positionen ohne
|
||||||
// Zwischenüberschrift „Dienstleistungen" (optisch wie
|
// eigene Set-Komponenten): eigene Zwischenüberschrift
|
||||||
// Standardlager) und dieselbe Item-Card wie scanbare Artikel —
|
// „Dienstleistungen" (optisch wie Standardlager) und dieselbe
|
||||||
// einziger Unterschied ist der Hinweis, dass kein Scanvorgang
|
// Item-Card wie scanbare Artikel — einziger Unterschied ist der
|
||||||
// nötig ist (`scanNotRequired`).
|
// Hinweis, dass kein Scanvorgang nötig ist.
|
||||||
if (serviceItems.isNotEmpty) ...[
|
if (serviceItemsView.isNotEmpty) ...[
|
||||||
const _ServiceSectionHeader(),
|
const _ServiceSectionHeader(),
|
||||||
for (final item in _parentFirst(serviceItems))
|
for (final item in _parentFirst(serviceItemsView))
|
||||||
_ItemRow(
|
_ItemRow(
|
||||||
item: item,
|
item: item,
|
||||||
details: details,
|
details: details,
|
||||||
onAction: (action) => onItemAction(item, action),
|
onAction: (action) => onItemAction(item, action),
|
||||||
scanNotRequired: true,
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@ -1111,24 +1148,23 @@ class _ItemRow extends StatelessWidget {
|
|||||||
required this.item,
|
required this.item,
|
||||||
required this.details,
|
required this.details,
|
||||||
required this.onAction,
|
required this.onAction,
|
||||||
this.scanNotRequired = false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
final DeliveryItem item;
|
final DeliveryItem item;
|
||||||
final TourDetails details;
|
final TourDetails details;
|
||||||
final void Function(_ItemAction action) onAction;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final article = details.articleOf(item.articleId);
|
final article = details.articleOf(item.articleId);
|
||||||
final warehouse = details.warehouseOf(item.warehouseId);
|
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;
|
final isExternalWarehouse = warehouse != null && !warehouse.isStandard;
|
||||||
// Manueller Fallback-Button: nur für scanbare, noch offene Positionen
|
// Manueller Fallback-Button: nur für scanbare, noch offene Positionen
|
||||||
// (nicht done/entfernt/pausiert) — analog dazu, was ein Barcode-Scan
|
// (nicht done/entfernt/pausiert) — analog dazu, was ein Barcode-Scan
|
||||||
|
|||||||
Reference in New Issue
Block a user