396 lines
14 KiB
Dart
396 lines
14 KiB
Dart
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,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|