stampa ddt
This commit is contained in:
@@ -3,6 +3,7 @@ import 'package:flux/features/documents/models/shipment_document_model.dart';
|
||||
import 'package:flux/features/master_data/providers/models/provider_model.dart';
|
||||
import 'package:flux/features/master_data/providers/models/provider_role.dart';
|
||||
import 'package:flux/features/settings/document_sequence/models/document_sequence_model.dart';
|
||||
import 'package:flux/features/tickets/models/ticket_model.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
@@ -34,7 +35,7 @@ class TicketsShipmentRepository {
|
||||
// NUOVO METODO: Salva il DDT e aggiorna i Ticket
|
||||
Future<void> createShipmentDocument({
|
||||
required ShipmentDocumentModel document,
|
||||
required String newTicketStatus, // es: 'shipped' o 'inExternalLab'
|
||||
required TicketStatus newTicketStatus, // es: 'shipped' o 'inExternalLab'
|
||||
}) async {
|
||||
try {
|
||||
// 1. Inseriamo il singolo Documento di Trasporto
|
||||
@@ -42,8 +43,8 @@ class TicketsShipmentRepository {
|
||||
|
||||
// 2. Aggiorniamo lo stato di TUTTI i ticket inclusi nel DDT
|
||||
await _supabase
|
||||
.from('tickets')
|
||||
.update({'ticket_status': newTicketStatus})
|
||||
.from('ticket')
|
||||
.update({'ticket_status': newTicketStatus.value})
|
||||
.inFilter('id', document.ticketIds);
|
||||
} catch (e) {
|
||||
throw ('Errore durante la creazione della spedizione: $e');
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flux/core/blocs/session/session_cubit.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart'; // Per estrarre gli store
|
||||
import '../models/provider_model.dart';
|
||||
@@ -13,7 +14,13 @@ class ProviderFormCubit extends Cubit<ProviderFormState> {
|
||||
final _client = Supabase.instance.client; // Lo usiamo al volo per gli store
|
||||
|
||||
ProviderFormCubit()
|
||||
: super(ProviderFormState(provider: ProviderModel.empty(companyId: '')));
|
||||
: super(
|
||||
ProviderFormState(
|
||||
provider: ProviderModel.empty(
|
||||
companyId: GetIt.I.get<SessionCubit>().state.company!.id!,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// --- INIZIALIZZAZIONE ---
|
||||
Future<void> initForm({
|
||||
@@ -51,7 +58,8 @@ class ProviderFormCubit extends Cubit<ProviderFormState> {
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: ProviderFormStatus.initial,
|
||||
provider: existingProvider ?? ProviderModel.empty(companyId: ''),
|
||||
provider:
|
||||
existingProvider ?? ProviderModel.empty(companyId: companyId),
|
||||
availableStores: storesResponse as List<dynamic>,
|
||||
selectedStoreIds: linkedStoreIds,
|
||||
localLocations: existingProvider?.locations ?? [],
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:flux/core/blocs/session/session_cubit.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
import '../models/provider_model.dart';
|
||||
@@ -5,6 +6,7 @@ import '../models/provider_location_model.dart';
|
||||
|
||||
class ProviderRepository {
|
||||
final _supabase = GetIt.I.get<SupabaseClient>();
|
||||
final _companyId = GetIt.I.get<SessionCubit>().state.company!.id!;
|
||||
|
||||
// 1. Carica i provider abilitati per uno specifico Store
|
||||
Future<List<ProviderModel>> getProvidersByStore(String storeId) async {
|
||||
@@ -44,9 +46,10 @@ class ProviderRepository {
|
||||
List<String> enabledStoreIds,
|
||||
) async {
|
||||
// A. Salva/Aggiorna il Provider principale
|
||||
final providerWithCompany = provider.copyWith(companyId: _companyId);
|
||||
final savedRow = await _supabase
|
||||
.from('provider')
|
||||
.upsert(provider.toMap())
|
||||
.upsert(providerWithCompany.toMap())
|
||||
.select()
|
||||
.single();
|
||||
|
||||
|
||||
@@ -129,8 +129,8 @@ class ProviderModel extends Equatable {
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
if (id != null) 'id': id,
|
||||
Map<String, dynamic> baseMap = {
|
||||
if (id != null && id!.trim().isNotEmpty) 'id': id,
|
||||
'company_id': companyId,
|
||||
'name': name,
|
||||
'is_active': isActive,
|
||||
@@ -146,6 +146,7 @@ class ProviderModel extends Equatable {
|
||||
// Trasformiamo gli Enum di nuovo in stringhe per Supabase
|
||||
'roles': roles.map((e) => e.name).toList(),
|
||||
};
|
||||
return baseMap;
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -60,7 +60,13 @@ class _ProviderListScreenState extends State<ProviderListScreen> {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Gestione Fornitori')),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: () => context.pushNamed(Routes.providerForm),
|
||||
onPressed: () async {
|
||||
final providerListCubit = context.read<ProviderListCubit>();
|
||||
final storeId = context.read<SessionCubit>().state.currentStore?.id;
|
||||
await context.pushNamed(Routes.providerForm);
|
||||
if (!mounted || storeId == null) return;
|
||||
providerListCubit.loadProviders(storeId);
|
||||
},
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Nuovo Fornitore'),
|
||||
),
|
||||
|
||||
@@ -77,21 +77,21 @@ class TicketListCubit extends Cubit<TicketListState> {
|
||||
loadTickets(refresh: true); // Applica i filtri e ricarica
|
||||
}
|
||||
|
||||
void toggleTicketSelection(String ticketId) {
|
||||
final currentSelection = Set<String>.from(state.selectedTicketIds);
|
||||
if (currentSelection.contains(ticketId)) {
|
||||
currentSelection.remove(ticketId);
|
||||
void toggleTicketSelection(TicketModel ticket) {
|
||||
final currentSelection = Set<TicketModel>.from(state.selectedTickets);
|
||||
if (currentSelection.contains(ticket)) {
|
||||
currentSelection.remove(ticket);
|
||||
} else {
|
||||
currentSelection.add(ticketId);
|
||||
currentSelection.add(ticket);
|
||||
}
|
||||
emit(state.copyWith(selectedTicketIds: currentSelection));
|
||||
emit(state.copyWith(selectedTickets: currentSelection));
|
||||
}
|
||||
|
||||
void clearSelection() {
|
||||
emit(state.copyWith(selectedTicketIds: {}));
|
||||
emit(state.copyWith(selectedTickets: {}));
|
||||
}
|
||||
|
||||
void selectAll(List<String> ticketIds) {
|
||||
emit(state.copyWith(selectedTicketIds: ticketIds.toSet()));
|
||||
void selectAll(List<TicketModel> tickets) {
|
||||
emit(state.copyWith(selectedTickets: tickets.toSet()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ class TicketListState extends Equatable {
|
||||
final bool isLoading;
|
||||
final bool hasReachedMax;
|
||||
final String errorMessage;
|
||||
final Set<String> selectedTicketIds;
|
||||
final Set<TicketModel> selectedTickets;
|
||||
|
||||
// Filtri attivi
|
||||
final String? searchTerm;
|
||||
@@ -21,7 +21,7 @@ class TicketListState extends Equatable {
|
||||
this.isLoading = false,
|
||||
this.hasReachedMax = false,
|
||||
this.errorMessage = '',
|
||||
this.selectedTicketIds = const {},
|
||||
this.selectedTickets = const {},
|
||||
this.searchTerm,
|
||||
this.dateRange,
|
||||
this.statusFilter,
|
||||
@@ -34,7 +34,7 @@ class TicketListState extends Equatable {
|
||||
bool? isLoading,
|
||||
bool? hasReachedMax,
|
||||
String? errorMessage,
|
||||
Set<String>? selectedTicketIds,
|
||||
Set<TicketModel>? selectedTickets,
|
||||
String? searchTerm,
|
||||
DateTimeRange? dateRange,
|
||||
TicketStatus? statusFilter,
|
||||
@@ -54,7 +54,7 @@ class TicketListState extends Equatable {
|
||||
statusFilter: clearStatus ? null : (statusFilter ?? this.statusFilter),
|
||||
ticketTypeFilter: ticketTypeFilter ?? this.ticketTypeFilter,
|
||||
staffIdFilter: staffIdFilter ?? this.staffIdFilter,
|
||||
selectedTicketIds: selectedTicketIds ?? this.selectedTicketIds,
|
||||
selectedTickets: selectedTickets ?? this.selectedTickets,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ class TicketListState extends Equatable {
|
||||
isLoading,
|
||||
hasReachedMax,
|
||||
errorMessage,
|
||||
selectedTicketIds,
|
||||
selectedTickets,
|
||||
searchTerm,
|
||||
dateRange,
|
||||
statusFilter,
|
||||
|
||||
@@ -6,6 +6,8 @@ import 'package:flux/features/documents/models/shipment_document_model.dart';
|
||||
import 'package:flux/features/master_data/providers/models/provider_location_model.dart';
|
||||
import 'package:flux/features/master_data/providers/models/provider_model.dart';
|
||||
import 'package:flux/features/settings/document_sequence/data/document_sequence_repository.dart';
|
||||
import 'package:flux/features/settings/document_sequence/models/document_sequence_model.dart';
|
||||
import 'package:flux/features/tickets/models/ticket_model.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
part 'ticket_shipping_state.dart';
|
||||
@@ -85,27 +87,6 @@ class TicketShippingCubit extends Cubit<TicketShippingState> {
|
||||
Future<void> toggleAutoNumber(bool value) async {
|
||||
// Aggiorniamo subito l'UI per mostrare che lo switch si è acceso
|
||||
emit(state.copyWith(isAutoNumber: value));
|
||||
|
||||
if (value) {
|
||||
// Se lo switch è acceso, chiediamo il numero al DB
|
||||
try {
|
||||
final nextNumber = await _sequenceRepository.getNextDocumentNumber(
|
||||
'ddt',
|
||||
);
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
document: state.document.copyWith(docNumber: nextNumber),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
// Se qualcosa va storto, spegniamo lo switch e mostriamo l'errore
|
||||
emit(state.copyWith(isAutoNumber: false, errorMessage: e.toString()));
|
||||
}
|
||||
} else {
|
||||
// Se lo spegne, svuotiamo semplicemente il campo
|
||||
emit(state.copyWith(document: state.document.copyWith(docNumber: '')));
|
||||
}
|
||||
}
|
||||
|
||||
// Metodo unico e pulito per aggiornare i campi testuali/numerici del documento
|
||||
@@ -131,7 +112,7 @@ class TicketShippingCubit extends Cubit<TicketShippingState> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> confirmShipment({required String newTicketStatus}) async {
|
||||
Future<void> confirmShipment({required TicketStatus newTicketStatus}) async {
|
||||
if (state.document.providerId.isEmpty ||
|
||||
state.document.destinationLocationId.isEmpty) {
|
||||
emit(
|
||||
@@ -142,7 +123,7 @@ class TicketShippingCubit extends Cubit<TicketShippingState> {
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (state.document.docNumber.trim().isEmpty) {
|
||||
if (!state.isAutoNumber && state.document.docNumber.trim().isEmpty) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: TicketShippingStatus.failure,
|
||||
@@ -153,6 +134,17 @@ class TicketShippingCubit extends Cubit<TicketShippingState> {
|
||||
}
|
||||
|
||||
emit(state.copyWith(status: TicketShippingStatus.loading));
|
||||
if (state.isAutoNumber) {
|
||||
try {
|
||||
final nextNumber = await _sequenceRepository.getNextDocumentNumber(
|
||||
DocumentType.shipment.name,
|
||||
);
|
||||
updateDocument(docNumber: nextNumber);
|
||||
} catch (e) {
|
||||
emit(state.copyWith(isAutoNumber: false, errorMessage: e.toString()));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await _repository.createShipmentDocument(
|
||||
|
||||
@@ -17,7 +17,7 @@ class TicketShippingState extends Equatable {
|
||||
required this.document,
|
||||
this.availableProviders = const [],
|
||||
this.availableLocations = const [],
|
||||
this.isAutoNumber = false,
|
||||
this.isAutoNumber = true,
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import 'package:flutter/foundation.dart' show kIsWeb, defaultTargetPlatform;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flux/core/blocs/session/session_cubit.dart';
|
||||
import 'package:flux/features/documents/models/shipment_document_model.dart';
|
||||
import 'package:flux/features/master_data/providers/models/provider_location_model.dart';
|
||||
import 'package:flux/features/master_data/providers/models/provider_model.dart';
|
||||
import 'package:flux/features/tickets/blocs/ticket_list_cubit.dart';
|
||||
import 'package:flux/features/tickets/blocs/ticket_list_state.dart';
|
||||
import 'package:flux/features/tickets/blocs/ticket_shipping_cubit.dart';
|
||||
import 'package:flux/features/tickets/ui/widgets/ticket_list_card.dart';
|
||||
import 'package:flux/features/tickets/ui/widgets/ticket_shipping_modal.dart';
|
||||
import 'package:flux/features/tickets/utils/ticket_shipping_pdf_service.dart';
|
||||
import 'package:pdf/pdf.dart';
|
||||
import 'package:printing/printing.dart';
|
||||
|
||||
class TicketList extends StatelessWidget {
|
||||
final ScrollController scrollController;
|
||||
@@ -35,8 +45,8 @@ class TicketList extends StatelessWidget {
|
||||
}
|
||||
|
||||
final ticket = state.tickets[index];
|
||||
final isSelected = state.selectedTicketIds.contains(ticket.id);
|
||||
final isSelectionMode = state.selectedTicketIds.isNotEmpty;
|
||||
final isSelected = state.selectedTickets.contains(ticket);
|
||||
final isSelectionMode = state.selectedTickets.isNotEmpty;
|
||||
|
||||
// Per Desktop mostriamo la checkbox vera e propria
|
||||
final isDesktop = MediaQuery.of(context).size.width > 800;
|
||||
@@ -53,7 +63,7 @@ class TicketList extends StatelessWidget {
|
||||
AnimatedPositioned(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
bottom: state.selectedTicketIds.isNotEmpty
|
||||
bottom: state.selectedTickets.isNotEmpty
|
||||
? 90
|
||||
: -100, // Nasconde o mostra
|
||||
left: 16,
|
||||
@@ -77,7 +87,7 @@ class TicketList extends StatelessWidget {
|
||||
context.read<TicketListCubit>().clearSelection(),
|
||||
),
|
||||
Text(
|
||||
'${state.selectedTicketIds.length} selezionati',
|
||||
'${state.selectedTickets.length} selezionati',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
@@ -86,11 +96,89 @@ class TicketList extends StatelessWidget {
|
||||
const Spacer(),
|
||||
|
||||
// IL NOSTRO FAMOSO BOTTONE SPEDISCI
|
||||
// IL BOTTONE SPEDISCI NELLA BARRA IN BASSO
|
||||
FilledButton.icon(
|
||||
onPressed: () {
|
||||
// Qui lanceremo la modale per creare il DDT
|
||||
// passando la lista: state.selectedTicketIds.toList()
|
||||
print("Spedisco i ticket: ${state.selectedTicketIds}");
|
||||
onPressed: () async {
|
||||
// 1. Apriamo la modale e ASPETTIAMO il risultato (tipizzandolo come Record)
|
||||
final result =
|
||||
await showModalBottomSheet<
|
||||
({
|
||||
ShipmentDocumentModel document,
|
||||
ProviderModel provider,
|
||||
ProviderLocationModel location,
|
||||
})
|
||||
>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) {
|
||||
return BlocProvider(
|
||||
create: (context) => TicketShippingCubit(
|
||||
ticketIds: state.selectedTickets
|
||||
.map((t) => t.id!)
|
||||
.toList(),
|
||||
)..loadRepairCenters(),
|
||||
child: TicketShippingModal(
|
||||
ticketIds: state.selectedTickets
|
||||
.map((t) => t.id!)
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// 2. Se l'utente ha chiuso trascinando giù, result è null.
|
||||
// Se ha salvato con successo, result contiene il nostro Record!
|
||||
if (result != null && context.mounted) {
|
||||
try {
|
||||
// Recuperiamo la Company (dal tuo SessionCubit o AuthCubit)
|
||||
final company = context
|
||||
.read<SessionCubit>()
|
||||
.state
|
||||
.company!;
|
||||
|
||||
final ticketListCubit = context
|
||||
.read<TicketListCubit>();
|
||||
|
||||
// 3. GENERIAMO I BYTES DEL PDF
|
||||
final pdfBytes =
|
||||
await TicketShippingPdfService.generateDdt(
|
||||
company: company,
|
||||
provider: result.provider,
|
||||
location: result.location,
|
||||
document: result.document,
|
||||
tickets: state.selectedTickets.toList(),
|
||||
);
|
||||
|
||||
// 4. LANCIAMO LA STAMPA (Anteprima nativa / Browser)
|
||||
// Check sicuro: se NON siamo sul web E la piattaforma nativa è macOS
|
||||
if (!kIsWeb &&
|
||||
defaultTargetPlatform == TargetPlatform.macOS) {
|
||||
// Scialuppa di salvataggio per il Mac
|
||||
await Printing.sharePdf(
|
||||
bytes: pdfBytes,
|
||||
filename: 'ddt_${result.document.docNumber}.pdf',
|
||||
);
|
||||
} else {
|
||||
// Per Web, Windows, Linux, Android e iOS... diamo spettacolo!
|
||||
await Printing.layoutPdf(
|
||||
onLayout: (PdfPageFormat format) async =>
|
||||
pdfBytes,
|
||||
name: 'ddt_${result.document.docNumber}.pdf',
|
||||
);
|
||||
}
|
||||
|
||||
// 5. Pulizia finale: Deselezioniamo tutti i ticket e ricarichiamo la lista
|
||||
ticketListCubit.clearSelection();
|
||||
// (Se necessario, chiama il metodo per ricaricare la lista dei ticket dal DB)
|
||||
ticketListCubit.loadTickets(refresh: true);
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Errore stampa PDF: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.local_shipping),
|
||||
label: const Text('Spedisci'),
|
||||
|
||||
@@ -52,7 +52,7 @@ class TicketListCard extends StatelessWidget {
|
||||
if (ticket.id != null) {
|
||||
context
|
||||
.read<TicketListCubit>()
|
||||
.toggleTicketSelection(ticket.id!);
|
||||
.toggleTicketSelection(ticket);
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -61,7 +61,7 @@ class TicketListCard extends StatelessWidget {
|
||||
if (ticket.id != null) {
|
||||
context
|
||||
.read<TicketListCubit>()
|
||||
.toggleTicketSelection(ticket.id!);
|
||||
.toggleTicketSelection(ticket);
|
||||
}
|
||||
},
|
||||
child: AnimatedSwitcher(
|
||||
@@ -168,7 +168,7 @@ class TicketListCard extends StatelessWidget {
|
||||
// Modalità selezione attiva: un tap normale seleziona/deseleziona
|
||||
if (ticket.id != null) {
|
||||
context.read<TicketListCubit>().toggleTicketSelection(
|
||||
ticket.id!,
|
||||
ticket,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
@@ -184,7 +184,7 @@ class TicketListCard extends StatelessWidget {
|
||||
// Pressione lunga: forza la selezione (utile per iniziare il workflow)
|
||||
if (!isSelectionMode && ticket.id != null) {
|
||||
context.read<TicketListCubit>().toggleTicketSelection(
|
||||
ticket.id!,
|
||||
ticket,
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
373
lib/features/tickets/ui/widgets/ticket_shipping_modal.dart
Normal file
373
lib/features/tickets/ui/widgets/ticket_shipping_modal.dart
Normal file
@@ -0,0 +1,373 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flux/features/tickets/blocs/ticket_shipping_cubit.dart';
|
||||
import 'package:flux/features/tickets/models/ticket_model.dart';
|
||||
|
||||
class TicketShippingModal extends StatefulWidget {
|
||||
final List<String> ticketIds;
|
||||
|
||||
const TicketShippingModal({super.key, required this.ticketIds});
|
||||
|
||||
@override
|
||||
State<TicketShippingModal> createState() => _TicketShippingModalState();
|
||||
}
|
||||
|
||||
class _TicketShippingModalState extends State<TicketShippingModal> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Appena si apre la modale, carichiamo la lista dei laboratori
|
||||
context.read<TicketShippingCubit>().loadRepairCenters();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom + 24,
|
||||
top: 24,
|
||||
left: 24,
|
||||
right: 24,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(28)),
|
||||
),
|
||||
child: BlocConsumer<TicketShippingCubit, TicketShippingState>(
|
||||
listener: (context, state) {
|
||||
if (state.status == TicketShippingStatus.success) {
|
||||
final provider = state.availableProviders.firstWhere(
|
||||
(p) => p.id == state.document.providerId,
|
||||
);
|
||||
final location = state.availableLocations.firstWhere(
|
||||
(l) => l.id == state.document.destinationLocationId,
|
||||
);
|
||||
|
||||
// Creiamo un Dart Record elegante e lo "spariamo" fuori
|
||||
final ddtData = (
|
||||
document: state.document,
|
||||
provider: provider,
|
||||
location: location,
|
||||
);
|
||||
|
||||
Navigator.pop(context, ddtData);
|
||||
}
|
||||
if (state.status == TicketShippingStatus.failure &&
|
||||
state.errorMessage != null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(state.errorMessage!),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
if (state.status == TicketShippingStatus.loading &&
|
||||
state.availableProviders.isEmpty) {
|
||||
return const SizedBox(
|
||||
height: 200,
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
final doc =
|
||||
state.document; // Scorciatoia comoda per il nostro modello
|
||||
|
||||
return Form(
|
||||
key: _formKey,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildHeader(context, state),
|
||||
const Divider(height: 32),
|
||||
|
||||
// 1. DESTINAZIONE
|
||||
_buildSectionTitle(Icons.business, "Destinatario"),
|
||||
_buildProviderDropdown(context, state),
|
||||
const SizedBox(height: 16),
|
||||
if (doc.providerId.isNotEmpty)
|
||||
_buildLocationDropdown(context, state),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 2. DATI DOCUMENTO
|
||||
_buildSectionTitle(Icons.description, "Dati Documento"),
|
||||
_buildNumberingSection(context, state),
|
||||
const SizedBox(height: 16),
|
||||
_buildDatePicker(context, state),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 3. DETTAGLI MERCE E TRASPORTO
|
||||
_buildSectionTitle(Icons.inventory_2, "Dettagli Trasporto"),
|
||||
_buildShippingDetails(context, state),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// BOTTONE CONFERMA
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 54,
|
||||
child: FilledButton.icon(
|
||||
onPressed: state.status == TicketShippingStatus.loading
|
||||
? null
|
||||
: () {
|
||||
if (_formKey.currentState!.validate()) {
|
||||
// Assicurati che lo stato qui coincida con l'Enum del tuo TicketStatus
|
||||
context
|
||||
.read<TicketShippingCubit>()
|
||||
.confirmShipment(
|
||||
newTicketStatus:
|
||||
TicketStatus.waitingForReturn,
|
||||
);
|
||||
}
|
||||
},
|
||||
icon: state.status == TicketShippingStatus.loading
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.local_shipping),
|
||||
label: const Text(
|
||||
"GENERA DDT E SPEDISCI",
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(BuildContext context, TicketShippingState state) {
|
||||
return Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
|
||||
child: Icon(
|
||||
Icons.share_location,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
"Spedizione Multipla",
|
||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||
),
|
||||
Text(
|
||||
"${widget.ticketIds.length} ticket pronti per il laboratorio",
|
||||
style: const TextStyle(color: Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProviderDropdown(
|
||||
BuildContext context,
|
||||
TicketShippingState state,
|
||||
) {
|
||||
return DropdownButtonFormField<String>(
|
||||
value: state.document.providerId.isEmpty
|
||||
? null
|
||||
: state.document.providerId,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "Seleziona Centro Riparazioni",
|
||||
prefixIcon: Icon(Icons.store),
|
||||
),
|
||||
items: state.availableProviders
|
||||
.map((p) => DropdownMenuItem(value: p.id, child: Text(p.name)))
|
||||
.toList(),
|
||||
onChanged: (val) => val != null
|
||||
? context.read<TicketShippingCubit>().selectProvider(val)
|
||||
: null,
|
||||
validator: (v) => v == null || v.isEmpty ? 'Campo obbligatorio' : null,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLocationDropdown(
|
||||
BuildContext context,
|
||||
TicketShippingState state,
|
||||
) {
|
||||
return DropdownButtonFormField<String>(
|
||||
value: state.document.destinationLocationId.isEmpty
|
||||
? null
|
||||
: state.document.destinationLocationId,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "Sede di destinazione",
|
||||
prefixIcon: Icon(Icons.location_on),
|
||||
),
|
||||
items: state.availableLocations
|
||||
.map((l) => DropdownMenuItem(value: l.id, child: Text(l.name)))
|
||||
.toList(),
|
||||
onChanged: (val) => val != null
|
||||
? context.read<TicketShippingCubit>().selectLocation(val)
|
||||
: null,
|
||||
validator: (v) => v == null || v.isEmpty ? 'Campo obbligatorio' : null,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNumberingSection(
|
||||
BuildContext context,
|
||||
TicketShippingState state,
|
||||
) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: state.isAutoNumber
|
||||
? const Text('Numero auto-generato alla conferma')
|
||||
: TextFormField(
|
||||
// Key è fondamentale per far aggiornare il campo quando cambia da auto a manuale
|
||||
key: ValueKey('docNum_${state.isAutoNumber}'),
|
||||
initialValue: state.document.docNumber,
|
||||
readOnly: state.isAutoNumber, // Bloccato se automatico
|
||||
decoration: InputDecoration(
|
||||
labelText: "Numero DDT",
|
||||
helperText: state.isAutoNumber
|
||||
? "Generato automaticamente"
|
||||
: "Inserimento manuale",
|
||||
),
|
||||
onChanged: (val) => context
|
||||
.read<TicketShippingCubit>()
|
||||
.updateDocument(docNumber: val),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
// Switch per modalità automatica
|
||||
Column(
|
||||
children: [
|
||||
const Text(
|
||||
"Auto",
|
||||
style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold),
|
||||
),
|
||||
Switch(
|
||||
value: state.isAutoNumber,
|
||||
onChanged: (val) =>
|
||||
context.read<TicketShippingCubit>().toggleAutoNumber(val),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDatePicker(BuildContext context, TicketShippingState state) {
|
||||
final date = state.document.docDate;
|
||||
return ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text("Data Documento"),
|
||||
subtitle: Text(
|
||||
"${date.day.toString().padLeft(2, '0')}/${date.month.toString().padLeft(2, '0')}/${date.year}",
|
||||
),
|
||||
trailing: const Icon(Icons.calendar_month),
|
||||
onTap: () async {
|
||||
final newDate = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: date,
|
||||
firstDate: DateTime.now().subtract(const Duration(days: 365)),
|
||||
lastDate: DateTime.now().add(const Duration(days: 365)),
|
||||
);
|
||||
if (newDate != null) {
|
||||
context.read<TicketShippingCubit>().updateDocument(docDate: newDate);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildShippingDetails(
|
||||
BuildContext context,
|
||||
TicketShippingState state,
|
||||
) {
|
||||
return Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
initialValue: state.document.packageCount.toString(),
|
||||
decoration: const InputDecoration(labelText: "N. Colli"),
|
||||
keyboardType: TextInputType.number,
|
||||
onChanged: (val) => context
|
||||
.read<TicketShippingCubit>()
|
||||
.updateDocument(packageCount: int.tryParse(val) ?? 1),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
initialValue: state.document.weight?.toString() ?? '',
|
||||
decoration: const InputDecoration(
|
||||
labelText: "Peso",
|
||||
suffixText: "Kg",
|
||||
),
|
||||
keyboardType: const TextInputType.numberWithOptions(
|
||||
decimal: true,
|
||||
),
|
||||
onChanged: (val) => context
|
||||
.read<TicketShippingCubit>()
|
||||
.updateDocument(weight: double.tryParse(val)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
initialValue: state.document.shippingReason,
|
||||
decoration: const InputDecoration(labelText: "Causale Trasporto"),
|
||||
onChanged: (val) => context
|
||||
.read<TicketShippingCubit>()
|
||||
.updateDocument(shippingReason: val),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
initialValue: state.document.notes ?? '',
|
||||
decoration: const InputDecoration(
|
||||
labelText: "Aspetto Beni (es. Scatola, Busta)",
|
||||
),
|
||||
onChanged: (val) =>
|
||||
context.read<TicketShippingCubit>().updateDocument(notes: val),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectionTitle(IconData icon, String title) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 18, color: Colors.grey),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
title.toUpperCase(),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.grey,
|
||||
letterSpacing: 1.1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
205
lib/features/tickets/utils/ticket_shipping_pdf_service.dart
Normal file
205
lib/features/tickets/utils/ticket_shipping_pdf_service.dart
Normal file
@@ -0,0 +1,205 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:flux/features/company/models/company_model.dart';
|
||||
import 'package:flux/features/documents/models/shipment_document_model.dart';
|
||||
import 'package:flux/features/master_data/providers/models/provider_location_model.dart';
|
||||
import 'package:flux/features/master_data/providers/models/provider_model.dart';
|
||||
import 'package:flux/features/tickets/models/ticket_model.dart';
|
||||
import 'package:pdf/pdf.dart';
|
||||
import 'package:pdf/widgets.dart' as pw;
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
class TicketShippingPdfService {
|
||||
static Future<Uint8List> generateDdt({
|
||||
required CompanyModel company,
|
||||
required ProviderModel provider,
|
||||
required ProviderLocationModel location,
|
||||
required ShipmentDocumentModel document,
|
||||
required List<TicketModel> tickets,
|
||||
}) async {
|
||||
final pdf = pw.Document();
|
||||
|
||||
// Formattatore per le date
|
||||
final dateFormat = DateFormat('dd/MM/yyyy');
|
||||
|
||||
pdf.addPage(
|
||||
pw.MultiPage(
|
||||
pageFormat: PdfPageFormat.a4,
|
||||
margin: const pw.EdgeInsets.all(32),
|
||||
// --- INTESTAZIONE (Ripetuta su ogni pagina) ---
|
||||
header: (pw.Context context) {
|
||||
return pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
pw.Row(
|
||||
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Dati Mittente (La tua Company)
|
||||
pw.Expanded(
|
||||
child: pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
pw.Text(
|
||||
company.name,
|
||||
style: pw.TextStyle(
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
pw.Text(company.address),
|
||||
pw.Text(
|
||||
'${company.city} (${company.province}) - ${company.zipCode}',
|
||||
),
|
||||
pw.Text('P.IVA: ${company.vatId}'),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Dati Destinatario (Il Laboratorio e la Sede)
|
||||
pw.Expanded(
|
||||
child: pw.Container(
|
||||
padding: const pw.EdgeInsets.all(8),
|
||||
decoration: pw.BoxDecoration(
|
||||
border: pw.Border.all(color: PdfColors.grey),
|
||||
borderRadius: const pw.BorderRadius.all(
|
||||
pw.Radius.circular(4),
|
||||
),
|
||||
),
|
||||
child: pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
pw.Text(
|
||||
'DESTINATARIO:',
|
||||
style: pw.TextStyle(
|
||||
fontSize: 10,
|
||||
color: PdfColors.grey700,
|
||||
),
|
||||
),
|
||||
pw.Text(
|
||||
provider.name,
|
||||
style: pw.TextStyle(fontWeight: pw.FontWeight.bold),
|
||||
),
|
||||
pw.SizedBox(height: 4),
|
||||
pw.Text('Destinazione merce:'),
|
||||
pw.Text(location.address),
|
||||
pw.Text(
|
||||
'${location.zipCode} ${location.city} (${location.province})',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
pw.SizedBox(height: 20),
|
||||
// Titolo Documento
|
||||
pw.Center(
|
||||
child: pw.Text(
|
||||
'DOCUMENTO DI TRASPORTO (D.D.T.)',
|
||||
style: pw.TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
pw.SizedBox(height: 10),
|
||||
// Dati Documento
|
||||
pw.Row(
|
||||
mainAxisAlignment: pw.MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
pw.Text(
|
||||
'Numero: ${document.docNumber}',
|
||||
style: pw.TextStyle(fontWeight: pw.FontWeight.bold),
|
||||
),
|
||||
pw.Text(
|
||||
'Data: ${dateFormat.format(document.docDate)}',
|
||||
style: pw.TextStyle(fontWeight: pw.FontWeight.bold),
|
||||
),
|
||||
pw.Text('Causale: ${document.shippingReason}'),
|
||||
],
|
||||
),
|
||||
pw.SizedBox(height: 20),
|
||||
],
|
||||
);
|
||||
},
|
||||
|
||||
// --- IL CORPO (La tabella dei ticket che scorre) ---
|
||||
build: (pw.Context context) {
|
||||
return [
|
||||
pw.TableHelper.fromTextArray(
|
||||
headers: ['Rif. Ticket', 'Modello', 'Difetto / Note', 'Q.tà'],
|
||||
headerStyle: pw.TextStyle(
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
color: PdfColors.white,
|
||||
),
|
||||
headerDecoration: const pw.BoxDecoration(
|
||||
color: PdfColors.blueGrey800,
|
||||
),
|
||||
cellAlignment: pw.Alignment.centerLeft,
|
||||
data: tickets.map((t) {
|
||||
return [
|
||||
t.id?.substring(0, 8).toUpperCase() ??
|
||||
'-', // Magari hai un ID progressivo migliore
|
||||
t.targetModelName ?? 'Sconosciuto',
|
||||
t.request,
|
||||
'1', // Tipicamente 1 per ogni ticket
|
||||
];
|
||||
}).toList(),
|
||||
),
|
||||
];
|
||||
},
|
||||
|
||||
// --- PIÈ DI PAGINA (Ripetuto su ogni pagina, ma con le firme) ---
|
||||
footer: (pw.Context context) {
|
||||
return pw.Column(
|
||||
children: [
|
||||
pw.Divider(),
|
||||
pw.Row(
|
||||
mainAxisAlignment: pw.MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
pw.Text('Colli: ${document.packageCount}'),
|
||||
pw.Text('Peso: ${document.weight ?? '-'} Kg'),
|
||||
pw.Text(
|
||||
'Aspetto: ${document.notes ?? 'Scatola'}',
|
||||
), // Adatta se hai un campo specifico
|
||||
],
|
||||
),
|
||||
pw.SizedBox(height: 20),
|
||||
// Spazio Firme
|
||||
pw.Row(
|
||||
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
_buildSignatureBox('Firma Mittente'),
|
||||
_buildSignatureBox('Firma Vettore (Corriere)'),
|
||||
_buildSignatureBox('Firma Destinatario'),
|
||||
],
|
||||
),
|
||||
pw.SizedBox(height: 10),
|
||||
pw.Align(
|
||||
alignment: pw.Alignment.centerRight,
|
||||
child: pw.Text(
|
||||
'Pagina ${context.pageNumber} di ${context.pagesCount}',
|
||||
style: const pw.TextStyle(
|
||||
fontSize: 10,
|
||||
color: PdfColors.grey,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
return pdf.save();
|
||||
}
|
||||
|
||||
static pw.Widget _buildSignatureBox(String title) {
|
||||
return pw.Column(
|
||||
children: [
|
||||
pw.Text(title, style: const pw.TextStyle(fontSize: 10)),
|
||||
pw.SizedBox(height: 40),
|
||||
pw.Container(width: 120, height: 1, color: PdfColors.black),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:flux/features/attachments/data/attachments_repository.dart';
|
||||
import 'package:flux/features/auth/bloc/auth_cubit.dart';
|
||||
import 'package:flux/features/company/data/company_repository.dart';
|
||||
import 'package:flux/features/documents/data/tickets_shipment_repository.dart';
|
||||
import 'package:flux/features/master_data/providers/blocs/provider_list_cubit.dart';
|
||||
import 'package:flux/features/operations/blocs/operation_list_cubit.dart';
|
||||
import 'package:flux/features/operations/data/operations_repository.dart';
|
||||
@@ -126,6 +127,9 @@ Future<void> setupLocator() async {
|
||||
);
|
||||
getIt.registerLazySingleton<CompanyRepository>(() => CompanyRepository());
|
||||
getIt.registerLazySingleton<TrackingRepository>(() => TrackingRepository());
|
||||
getIt.registerLazySingleton<TicketsShipmentRepository>(
|
||||
() => TicketsShipmentRepository(),
|
||||
);
|
||||
}
|
||||
|
||||
class FluxApp extends StatefulWidget {
|
||||
|
||||
@@ -24,6 +24,8 @@
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
<key>com.apple.security.print</key>
|
||||
<key>com.apple.security.files.downloads.read-write</key>
|
||||
<true/>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
|
||||
<key>com.apple.security.files.downloads.read-write</key>
|
||||
<true/>
|
||||
@@ -19,6 +20,8 @@
|
||||
<key>com.apple.security.files.user-selected.read-write</key>
|
||||
<true/>
|
||||
<key>com.apple.security.print</key>
|
||||
<key>com.apple.security.files.downloads.read-write</key>
|
||||
<true/>
|
||||
<true/>
|
||||
|
||||
</dict>
|
||||
|
||||
Reference in New Issue
Block a user