Final commit.
This commit is contained in:
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user