Final commit.

This commit is contained in:
Dennis Nemec
2026-06-01 17:12:28 +02:00
parent 3ecbc82885
commit a9bf8ecdd1
385 changed files with 29081 additions and 12089 deletions

View File

@ -0,0 +1,395 @@
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,
),
),
),
],
),
);
}
}