Files
Holzleitner-Lieferservice-App/lib/feature/delivery/detail/presentation/steps/step_articles.dart
Dennis Nemec a9bf8ecdd1 Final commit.
2026-06-01 17:12:28 +02:00

396 lines
14 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 ?? '');
});
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,
),
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,
});
final DeliveryItem item;
final TourDetails details;
final String deliveryId;
final bool deliveryActive;
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),
));
}
/// 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;
// 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;
final canCredit = deliveryActive && !blockedByScan && 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,
),
],
),
trailing: 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
? '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)
IconButton.outlined(
tooltip: blockedByScan
? 'Erst scannen/verladen'
: (!deliveryActive
? 'Nur bei aktiver Lieferung'
: 'Gutschrift / entfernen'),
style: ButtonStyle(
backgroundColor: WidgetStatePropertyAll(
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,
),
),
],
),
);
}
}
/// 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,
),
),
),
],
),
);
}
}