diff --git a/lib/features/documents/data/tickets_shipment_repository.dart b/lib/features/documents/data/tickets_shipment_repository.dart index 918f1d6..3791de1 100644 --- a/lib/features/documents/data/tickets_shipment_repository.dart +++ b/lib/features/documents/data/tickets_shipment_repository.dart @@ -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 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'); diff --git a/lib/features/master_data/providers/blocs/provider_form_cubit.dart b/lib/features/master_data/providers/blocs/provider_form_cubit.dart index d5dcc84..55f86e1 100644 --- a/lib/features/master_data/providers/blocs/provider_form_cubit.dart +++ b/lib/features/master_data/providers/blocs/provider_form_cubit.dart @@ -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 { 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().state.company!.id!, + ), + ), + ); // --- INIZIALIZZAZIONE --- Future initForm({ @@ -51,7 +58,8 @@ class ProviderFormCubit extends Cubit { emit( state.copyWith( status: ProviderFormStatus.initial, - provider: existingProvider ?? ProviderModel.empty(companyId: ''), + provider: + existingProvider ?? ProviderModel.empty(companyId: companyId), availableStores: storesResponse as List, selectedStoreIds: linkedStoreIds, localLocations: existingProvider?.locations ?? [], diff --git a/lib/features/master_data/providers/data/provider_repository.dart b/lib/features/master_data/providers/data/provider_repository.dart index 4101378..a11d5a3 100644 --- a/lib/features/master_data/providers/data/provider_repository.dart +++ b/lib/features/master_data/providers/data/provider_repository.dart @@ -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(); + final _companyId = GetIt.I.get().state.company!.id!; // 1. Carica i provider abilitati per uno specifico Store Future> getProvidersByStore(String storeId) async { @@ -44,9 +46,10 @@ class ProviderRepository { List 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(); diff --git a/lib/features/master_data/providers/models/provider_model.dart b/lib/features/master_data/providers/models/provider_model.dart index a1c0bd6..1a65965 100644 --- a/lib/features/master_data/providers/models/provider_model.dart +++ b/lib/features/master_data/providers/models/provider_model.dart @@ -129,8 +129,8 @@ class ProviderModel extends Equatable { } Map toMap() { - return { - if (id != null) 'id': id, + Map 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 diff --git a/lib/features/master_data/providers/ui/provider_list_screen.dart b/lib/features/master_data/providers/ui/provider_list_screen.dart index f9a906f..31067dc 100644 --- a/lib/features/master_data/providers/ui/provider_list_screen.dart +++ b/lib/features/master_data/providers/ui/provider_list_screen.dart @@ -60,7 +60,13 @@ class _ProviderListScreenState extends State { return Scaffold( appBar: AppBar(title: const Text('Gestione Fornitori')), floatingActionButton: FloatingActionButton.extended( - onPressed: () => context.pushNamed(Routes.providerForm), + onPressed: () async { + final providerListCubit = context.read(); + final storeId = context.read().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'), ), diff --git a/lib/features/tickets/blocs/ticket_list_cubit.dart b/lib/features/tickets/blocs/ticket_list_cubit.dart index 5acfe14..680f80c 100644 --- a/lib/features/tickets/blocs/ticket_list_cubit.dart +++ b/lib/features/tickets/blocs/ticket_list_cubit.dart @@ -77,21 +77,21 @@ class TicketListCubit extends Cubit { loadTickets(refresh: true); // Applica i filtri e ricarica } - void toggleTicketSelection(String ticketId) { - final currentSelection = Set.from(state.selectedTicketIds); - if (currentSelection.contains(ticketId)) { - currentSelection.remove(ticketId); + void toggleTicketSelection(TicketModel ticket) { + final currentSelection = Set.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 ticketIds) { - emit(state.copyWith(selectedTicketIds: ticketIds.toSet())); + void selectAll(List tickets) { + emit(state.copyWith(selectedTickets: tickets.toSet())); } } diff --git a/lib/features/tickets/blocs/ticket_list_state.dart b/lib/features/tickets/blocs/ticket_list_state.dart index d61f70d..a1f9686 100644 --- a/lib/features/tickets/blocs/ticket_list_state.dart +++ b/lib/features/tickets/blocs/ticket_list_state.dart @@ -7,7 +7,7 @@ class TicketListState extends Equatable { final bool isLoading; final bool hasReachedMax; final String errorMessage; - final Set selectedTicketIds; + final Set 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? selectedTicketIds, + Set? 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, diff --git a/lib/features/tickets/blocs/ticket_shipping_cubit.dart b/lib/features/tickets/blocs/ticket_shipping_cubit.dart index 1ed61e5..b220a28 100644 --- a/lib/features/tickets/blocs/ticket_shipping_cubit.dart +++ b/lib/features/tickets/blocs/ticket_shipping_cubit.dart @@ -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 { Future 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 { ); } - Future confirmShipment({required String newTicketStatus}) async { + Future confirmShipment({required TicketStatus newTicketStatus}) async { if (state.document.providerId.isEmpty || state.document.destinationLocationId.isEmpty) { emit( @@ -142,7 +123,7 @@ class TicketShippingCubit extends Cubit { ); 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 { } 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( diff --git a/lib/features/tickets/blocs/ticket_shipping_state.dart b/lib/features/tickets/blocs/ticket_shipping_state.dart index a513645..19bcaf8 100644 --- a/lib/features/tickets/blocs/ticket_shipping_state.dart +++ b/lib/features/tickets/blocs/ticket_shipping_state.dart @@ -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, }); diff --git a/lib/features/tickets/ui/widgets/ticket_list.dart b/lib/features/tickets/ui/widgets/ticket_list.dart index fee0f64..a536bdf 100644 --- a/lib/features/tickets/ui/widgets/ticket_list.dart +++ b/lib/features/tickets/ui/widgets/ticket_list.dart @@ -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().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() + .state + .company!; + + final ticketListCubit = context + .read(); + + // 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'), diff --git a/lib/features/tickets/ui/widgets/ticket_list_card.dart b/lib/features/tickets/ui/widgets/ticket_list_card.dart index d03aadf..20b3738 100644 --- a/lib/features/tickets/ui/widgets/ticket_list_card.dart +++ b/lib/features/tickets/ui/widgets/ticket_list_card.dart @@ -52,7 +52,7 @@ class TicketListCard extends StatelessWidget { if (ticket.id != null) { context .read() - .toggleTicketSelection(ticket.id!); + .toggleTicketSelection(ticket); } }, ) @@ -61,7 +61,7 @@ class TicketListCard extends StatelessWidget { if (ticket.id != null) { context .read() - .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().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().toggleTicketSelection( - ticket.id!, + ticket, ); } }, diff --git a/lib/features/tickets/ui/widgets/ticket_shipping_modal.dart b/lib/features/tickets/ui/widgets/ticket_shipping_modal.dart new file mode 100644 index 0000000..fe26493 --- /dev/null +++ b/lib/features/tickets/ui/widgets/ticket_shipping_modal.dart @@ -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 ticketIds; + + const TicketShippingModal({super.key, required this.ticketIds}); + + @override + State createState() => _TicketShippingModalState(); +} + +class _TicketShippingModalState extends State { + final _formKey = GlobalKey(); + + @override + void initState() { + super.initState(); + // Appena si apre la modale, carichiamo la lista dei laboratori + context.read().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( + 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() + .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( + 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().selectProvider(val) + : null, + validator: (v) => v == null || v.isEmpty ? 'Campo obbligatorio' : null, + ); + } + + Widget _buildLocationDropdown( + BuildContext context, + TicketShippingState state, + ) { + return DropdownButtonFormField( + 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().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() + .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().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().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() + .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() + .updateDocument(weight: double.tryParse(val)), + ), + ), + ], + ), + const SizedBox(height: 16), + TextFormField( + initialValue: state.document.shippingReason, + decoration: const InputDecoration(labelText: "Causale Trasporto"), + onChanged: (val) => context + .read() + .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().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, + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/tickets/utils/ticket_shipping_pdf_service.dart b/lib/features/tickets/utils/ticket_shipping_pdf_service.dart new file mode 100644 index 0000000..08fe3f8 --- /dev/null +++ b/lib/features/tickets/utils/ticket_shipping_pdf_service.dart @@ -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 generateDdt({ + required CompanyModel company, + required ProviderModel provider, + required ProviderLocationModel location, + required ShipmentDocumentModel document, + required List 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), + ], + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index 3ebdbf2..2489005 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 setupLocator() async { ); getIt.registerLazySingleton(() => CompanyRepository()); getIt.registerLazySingleton(() => TrackingRepository()); + getIt.registerLazySingleton( + () => TicketsShipmentRepository(), + ); } class FluxApp extends StatefulWidget { diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements index 5dbc9d9..dea5605 100644 --- a/macos/Runner/DebugProfile.entitlements +++ b/macos/Runner/DebugProfile.entitlements @@ -24,6 +24,8 @@ com.apple.security.device.audio-input com.apple.security.print + com.apple.security.files.downloads.read-write + diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements index 514ef18..bcc66f7 100644 --- a/macos/Runner/Release.entitlements +++ b/macos/Runner/Release.entitlements @@ -6,7 +6,8 @@ com.apple.security.network.client - + com.apple.security.cs.allow-jit + com.apple.security.files.downloads.read-write @@ -19,6 +20,8 @@ com.apple.security.files.user-selected.read-write com.apple.security.print + com.apple.security.files.downloads.read-write +