diff --git a/lib/core/widgets/shared_forms/operation_files_section.dart b/lib/core/widgets/shared_forms/shared_attachments_section.dart similarity index 97% rename from lib/core/widgets/shared_forms/operation_files_section.dart rename to lib/core/widgets/shared_forms/shared_attachments_section.dart index 56f5620..474b51f 100644 --- a/lib/core/widgets/shared_forms/operation_files_section.dart +++ b/lib/core/widgets/shared_forms/shared_attachments_section.dart @@ -10,7 +10,6 @@ import 'package:flux/features/attachments/ui/quick_rename_dialog.dart'; import 'package:flux/features/attachments/blocs/attachments_bloc.dart'; import 'package:get_it/get_it.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'package:flux/features/operations/models/operation_model.dart'; import 'package:flux/features/attachments/models/attachment_model.dart'; import 'package:pdf/widgets.dart' as pw; import 'package:pdfx/pdfx.dart' as px; // Isoliamo pdfx @@ -29,16 +28,24 @@ class _ExportItem { }); } -class OperationFilesSection extends StatefulWidget { - final OperationModel currentOp; +class SharedAttachmentsSection extends StatefulWidget { + final String? parentId; + final String customerDisplayName; + final AttachmentParentType parentType; - const OperationFilesSection({super.key, required this.currentOp}); + const SharedAttachmentsSection({ + super.key, + this.parentId, + this.customerDisplayName = 'Cliente_sconosciuto', + required this.parentType, + }); @override - State createState() => _OperationFilesSectionState(); + State createState() => + _SharedAttachmentsSectionState(); } -class _OperationFilesSectionState extends State { +class _SharedAttachmentsSectionState extends State { String? _exportDirectory; @override @@ -181,7 +188,8 @@ class _OperationFilesSectionState extends State { suggestedName = selectedFiles.first.name; } else { // Se sono più file uniti - suggestedName = '${widget.currentOp.customerDisplayName}_Unito'; + + suggestedName = '${widget.customerDisplayName}_Unito'; } if (!mounted) return; @@ -497,15 +505,14 @@ class _OperationFilesSectionState extends State { }, ), // Bottone Associa a Cliente - if (widget.currentOp.customerId != null && - widget.currentOp.customerId!.isNotEmpty) + if (widget.parentId != null && widget.parentId != '') IconButton( icon: const Icon(Icons.person_add, color: Colors.blue), tooltip: 'Copia nei documenti del Cliente', onPressed: () { context.read().add( LinkAttachmentsToEntityEvent( - targetId: widget.currentOp.customerId!, + targetId: widget.parentId!, targetType: AttachmentParentType.customer, ), ); diff --git a/lib/core/widgets/shared_forms/customer_section.dart b/lib/core/widgets/shared_forms/shared_customer_section.dart similarity index 98% rename from lib/core/widgets/shared_forms/customer_section.dart rename to lib/core/widgets/shared_forms/shared_customer_section.dart index 4c1c9ce..9a2afcc 100644 --- a/lib/core/widgets/shared_forms/customer_section.dart +++ b/lib/core/widgets/shared_forms/shared_customer_section.dart @@ -5,11 +5,11 @@ import 'package:flux/features/customers/models/customer_model.dart'; import 'package:flux/features/customers/ui/quick_customer_dialog.dart'; import 'package:flux/features/operations/models/operation_model.dart'; -class CustomerSection extends StatelessWidget { +class SharedCustomerSection extends StatelessWidget { final OperationModel? currentOp; final ValueChanged onCustomerSelected; - const CustomerSection({ + const SharedCustomerSection({ super.key, required this.currentOp, required this.onCustomerSelected, diff --git a/lib/core/widgets/shared_forms/staff_section.dart b/lib/core/widgets/shared_forms/shared_staff_section.dart similarity index 97% rename from lib/core/widgets/shared_forms/staff_section.dart rename to lib/core/widgets/shared_forms/shared_staff_section.dart index fe705b4..ed8b37f 100644 --- a/lib/core/widgets/shared_forms/staff_section.dart +++ b/lib/core/widgets/shared_forms/shared_staff_section.dart @@ -7,13 +7,17 @@ import 'package:flux/features/operations/models/operation_model.dart'; import 'package:get_it/get_it.dart'; class StaffSection extends StatelessWidget { - final OperationModel? currentOp; + final String? label; + final String? staffId; + final String? staffName; final ValueChanged onStaffSelected; const StaffSection({ super.key, - required this.currentOp, required this.onStaffSelected, + this.label, + this.staffId, + this.staffName, }); @override diff --git a/lib/features/operations/ui/operation_form_screen.dart b/lib/features/operations/ui/operation_form_screen.dart index 28e7b35..1271cc8 100644 --- a/lib/features/operations/ui/operation_form_screen.dart +++ b/lib/features/operations/ui/operation_form_screen.dart @@ -1,12 +1,13 @@ 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/attachments/blocs/attachments_bloc.dart'; import 'package:flux/features/operations/blocs/operations_cubit.dart'; import 'package:flux/features/operations/models/operation_model.dart'; -import 'package:flux/core/widgets/shared_forms/customer_section.dart'; +import 'package:flux/core/widgets/shared_forms/shared_customer_section.dart'; import 'package:flux/features/operations/ui/widgets/details_section.dart'; -import 'package:flux/core/widgets/shared_forms/operation_files_section.dart'; -import 'package:flux/core/widgets/shared_forms/staff_section.dart'; +import 'package:flux/core/widgets/shared_forms/shared_attachments_section.dart'; +import 'package:flux/core/widgets/shared_forms/shared_staff_section.dart'; import 'package:get_it/get_it.dart'; class OperationFormScreen extends StatefulWidget { @@ -215,8 +216,9 @@ class _OperationFormScreenState extends State { flex: 3, child: SingleChildScrollView( padding: const EdgeInsets.all(16.0), - child: OperationFilesSection( - currentOp: state.currentOperation!, + child: SharedAttachmentsSection( + parentType: AttachmentParentType.operation, + parentId: state.currentOperation?.id, ), ), ), @@ -327,7 +329,7 @@ class _OperationFormScreenState extends State { ), const Divider(height: 50), _buildSectionTitle('Cliente & Riferimento'), - CustomerSection( + SharedCustomerSection( currentOp: currentOp, onCustomerSelected: (customer) { context.read().updateOperationFields( @@ -405,7 +407,12 @@ class _OperationFormScreenState extends State { ), const Divider(height: 32), - if (showFiles) ...[OperationFilesSection(currentOp: currentOp!)], + if (showFiles) ...[ + SharedAttachmentsSection( + parentType: AttachmentParentType.operation, + parentId: currentOp?.id, + ), + ], ], ); } diff --git a/lib/features/tickets/blocs/ticket_form_cubit.dart b/lib/features/tickets/blocs/ticket_form_cubit.dart index 24c1cf8..e4e7782 100644 --- a/lib/features/tickets/blocs/ticket_form_cubit.dart +++ b/lib/features/tickets/blocs/ticket_form_cubit.dart @@ -13,11 +13,7 @@ class TicketFormCubit extends Cubit { TicketFormCubit() : super( // Inizializziamo con un ticket vuoto di default - TicketFormState( - ticket: TicketModel.empty( - companyId: GetIt.I.get().state.company!.id!, - ), - ), + TicketFormState(ticket: TicketModel.empty()), ); /// 1. INIZIALIZZAZIONE (Se stiamo modificando un ticket esistente) @@ -32,18 +28,15 @@ class TicketFormCubit extends Cubit { final currentStore = _sessionCubit.state.currentStore; final companyId = _sessionCubit.state.company?.id ?? ''; - final newTicket = - TicketModel.empty( - companyId: _sessionCubit.state.company!.id!, - ).copyWith( - companyId: companyId, - storeId: currentStore?.id, - staffId: currentUser?.id, - createdById: currentUser?.name, - // Impostiamo lo stato iniziale - status: TicketStatus.open, - ticketType: TicketType.repair, // Default - ); + final newTicket = TicketModel.empty().copyWith( + companyId: companyId, + storeId: currentStore?.id, + createdById: currentUser?.id, + createdByName: currentUser?.name, + // Impostiamo lo stato iniziale + status: TicketStatus.open, + ticketType: TicketType.repair, // Default + ); emit(state.copyWith(ticket: newTicket, status: TicketFormStatus.ready)); } @@ -77,7 +70,10 @@ class TicketFormCubit extends Cubit { void updateCreator({required String staffId, required String staffName}) { emit( state.copyWith( - ticket: state.ticket.copyWith(staffId: staffId, createdById: staffName), + ticket: state.ticket.copyWith( + createdById: staffId, + createdByName: staffName, + ), ), ); } diff --git a/lib/features/tickets/models/ticket_model.dart b/lib/features/tickets/models/ticket_model.dart index 2ad59e1..6fe89ef 100644 --- a/lib/features/tickets/models/ticket_model.dart +++ b/lib/features/tickets/models/ticket_model.dart @@ -183,7 +183,6 @@ class TicketModel extends Equatable { DateTime? closedAt, DateTime? returnedAt, String? request, - String? staffId, WarrantyType? warrantyType, String? publicNotes, String? internalNotes, diff --git a/lib/features/tickets/ui/ticket_form_screen.dart b/lib/features/tickets/ui/ticket_form_screen.dart new file mode 100644 index 0000000..355d3a2 --- /dev/null +++ b/lib/features/tickets/ui/ticket_form_screen.dart @@ -0,0 +1,475 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flux/features/tickets/blocs/ticket_form_cubit.dart'; +import 'package:flux/features/tickets/blocs/ticket_form_state.dart'; +import 'package:flux/features/tickets/models/ticket_model.dart'; +import 'package:flux/core/widgets/shared_forms/shared_customer_section.dart'; +import 'package:flux/core/widgets/shared_forms/shared_model_section.dart'; +import 'package:flux/core/widgets/shared_forms/shared_staff_section.dart'; // Il tuo widget agnostico dello staff + +class TicketFormScreen extends StatefulWidget { + final TicketModel? existingTicket; + + const TicketFormScreen({super.key, this.existingTicket}); + + @override + State createState() => _TicketFormScreenState(); +} + +class _TicketFormScreenState extends State { + final _formKey = GlobalKey(); + + // Controllers testuali + final _altPhoneCtrl = TextEditingController(); + final _serialCtrl = TextEditingController(); + final _requestCtrl = TextEditingController(); + final _accessoriesCtrl = TextEditingController(); + final _publicNotesCtrl = TextEditingController(); + final _internalNotesCtrl = TextEditingController(); + final _priceCtrl = TextEditingController(); + final _costCtrl = TextEditingController(); + + bool _isInitialized = false; + + @override + void initState() { + super.initState(); + // Inizializziamo il Cubit + context.read().initForm(widget.existingTicket); + } + + @override + void dispose() { + _altPhoneCtrl.dispose(); + _serialCtrl.dispose(); + _requestCtrl.dispose(); + _accessoriesCtrl.dispose(); + _publicNotesCtrl.dispose(); + _internalNotesCtrl.dispose(); + _priceCtrl.dispose(); + _costCtrl.dispose(); + super.dispose(); + } + + // Sincronizza i controller con lo stato iniziale senza sovrascrivere se l'utente sta digitando + void _syncTextControllers(TicketModel model) { + if (_altPhoneCtrl.text.isEmpty) + _altPhoneCtrl.text = model.alternativePhoneNumber ?? ''; + if (_serialCtrl.text.isEmpty) _serialCtrl.text = model.targetSn ?? ''; + if (_requestCtrl.text.isEmpty) _requestCtrl.text = model.request ?? ''; + if (_accessoriesCtrl.text.isEmpty) + _accessoriesCtrl.text = model.includedAccessories ?? ''; + if (_publicNotesCtrl.text.isEmpty) + _publicNotesCtrl.text = model.publicNotes ?? ''; + if (_internalNotesCtrl.text.isEmpty) + _internalNotesCtrl.text = model.internalNotes ?? ''; + if (_priceCtrl.text.isEmpty && model.customerPrice > 0) + _priceCtrl.text = model.customerPrice.toString(); + if (_costCtrl.text.isEmpty && model.internalCost > 0) + _costCtrl.text = model.internalCost.toString(); + _isInitialized = true; + } + + // Chiamato prima del salvataggio per pushare i testi nei campi del Cubit + void _flushControllersToCubit() { + context.read().updateFields( + alternativePhoneNumber: _altPhoneCtrl.text, + targetSn: _serialCtrl.text, + request: _requestCtrl.text, + includedAccessories: _accessoriesCtrl.text, + publicNotes: _publicNotesCtrl.text, + internalNotes: _internalNotesCtrl.text, + customerPrice: double.tryParse(_priceCtrl.text) ?? 0.0, + internalCost: double.tryParse(_costCtrl.text) ?? 0.0, + ); + } + + void _saveTicket({required bool keepAdding}) { + if (_formKey.currentState!.validate()) { + _flushControllersToCubit(); + context.read().saveTicket(keepAdding: keepAdding); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return BlocConsumer( + listenWhen: (previous, current) => previous.status != current.status, + listener: (context, state) { + if (state.status == TicketFormStatus.ready && !_isInitialized) { + _syncTextControllers(state.ticket); + } + + if (state.status == TicketFormStatus.success) { + Navigator.of(context).pop(); + } else if (state.status == TicketFormStatus.successAndAddAnother) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Scheda salvata! Inserisci la prossima.'), + ), + ); + // Svuotiamo i controller per il nuovo ticket + _altPhoneCtrl.clear(); + _serialCtrl.clear(); + _requestCtrl.clear(); + _accessoriesCtrl.clear(); + _publicNotesCtrl.clear(); + _internalNotesCtrl.clear(); + _priceCtrl.clear(); + _costCtrl.clear(); + _isInitialized = false; + } else if (state.status == TicketFormStatus.failure) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(state.errorMessage ?? 'Errore di salvataggio'), + backgroundColor: theme.colorScheme.error, + ), + ); + } + }, + builder: (context, state) { + final ticket = state.ticket; + + return Scaffold( + appBar: AppBar( + title: Text( + ticket.id == null ? 'Nuova Scheda Assistenza' : 'Modifica Scheda', + ), + actions: [ + // PICCOLO BADGE DI STATO IN ALTO A DESTRA + Padding( + padding: const EdgeInsets.only(right: 16.0), + child: Chip( + label: Text( + ticket.status.name.toUpperCase(), + style: const TextStyle(color: Colors.white, fontSize: 10), + ), + backgroundColor: ticket.status.color, + ), + ), + ], + ), + body: Form( + key: _formKey, + child: SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + child: Center( + child: ConstrainedBox( + // Limitiamo la larghezza su schermi grandi per non renderlo illeggibile + constraints: const BoxConstraints(maxWidth: 800), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // --- CARD 1: INTESTAZIONE & CLIENTE --- + _buildCard( + title: 'Anagrafica', + icon: Icons.person, + children: [ + // Qui usiamo la StaffSection per scegliere "A nome di chi" apriamo la scheda + StaffSection( + label: 'Creato Da', + staffId: ticket.createdById, + staffName: ticket.createdByName, + onStaffSelected: (staff) => + context.read().updateCreator( + staffId: staff.id, + staffName: staff.name, + ), + ), + const Divider(height: 32), + SharedCustomerSection( + customerId: ticket.customerId, + customerName: ticket.customerName, + onCustomerSelected: (customer) => context + .read() + .updateCustomer(customer), + ), + const SizedBox(height: 16), + TextFormField( + controller: _altPhoneCtrl, + decoration: const InputDecoration( + labelText: + 'Recapito Alternativo (es. se lascia il telefono principale)', + prefixIcon: Icon(Icons.phone), + ), + ), + ], + ), + + // --- CARD 2: DISPOSITIVO --- + _buildCard( + title: 'Dispositivo', + icon: Icons.devices, + children: [ + SharedModelSection( + label: 'Modello da Riparare', + modelId: ticket.targetModelId, + modelName: ticket.targetModelName, + onModelSelected: (id, name) => context + .read() + .updateModel(modelId: id, modelName: name), + ), + const SizedBox(height: 16), + TextFormField( + controller: _serialCtrl, + decoration: const InputDecoration( + labelText: 'Seriale / IMEI', + prefixIcon: Icon(Icons.qr_code), + ), + ), + ], + ), + + // --- CARD 3: PROBLEMA E LAVORAZIONE --- + _buildCard( + title: 'Dettagli Riparazione', + icon: Icons.build, + children: [ + // Tipo Lavorazione e Tipo Garanzia + Row( + children: [ + Expanded( + child: DropdownButtonFormField( + value: ticket.ticketType, + decoration: const InputDecoration( + labelText: 'Tipo Lavorazione', + ), + items: TicketType.values + .map( + (t) => DropdownMenuItem( + value: t, + child: Text( + t.name, + ), // Se hai estensioni, usa t.displayName + ), + ) + .toList(), + onChanged: (val) => context + .read() + .updateFields(ticketType: val), + ), + ), + const SizedBox(width: 16), + Expanded( + child: DropdownButtonFormField( + value: ticket.status, + decoration: const InputDecoration( + labelText: 'Stato Attuale', + ), + items: TicketStatus.values + .map( + (s) => DropdownMenuItem( + value: s, + child: Text(s.name), // Idem qui + ), + ) + .toList(), + onChanged: (val) => context + .read() + .updateFields(status: val), + ), + ), + ], + ), + const SizedBox(height: 16), + TextFormField( + controller: _requestCtrl, + maxLines: 4, + decoration: const InputDecoration( + labelText: + 'Difetto dichiarato o Richiesta del cliente', + alignLabelWithHint: true, + ), + ), + const SizedBox(height: 16), + TextFormField( + controller: _accessoriesCtrl, + decoration: const InputDecoration( + labelText: + 'Accessori Consegnati (es. cover, caricatore...)', + prefixIcon: Icon(Icons.cable), + ), + ), + const SizedBox(height: 16), + SwitchListTile( + title: const Text('Prestato Telefono di Cortesia?'), + value: ticket.hasCourtesyDevice, + onChanged: (val) => context + .read() + .updateFields(hasCourtesyDevice: val), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: BorderSide(color: theme.dividerColor), + ), + ), + ], + ), + + // --- CARD 4: COSTI E NOTE --- + _buildCard( + title: 'Costi & Note', + icon: Icons.euro, + children: [ + Row( + children: [ + Expanded( + child: TextFormField( + controller: _priceCtrl, + keyboardType: + const TextInputType.numberWithOptions( + decimal: true, + ), + decoration: const InputDecoration( + labelText: 'Preventivo Cliente (€)', + prefixIcon: Icon(Icons.sell_outlined), + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: TextFormField( + controller: _costCtrl, + keyboardType: + const TextInputType.numberWithOptions( + decimal: true, + ), + decoration: const InputDecoration( + labelText: 'Nostro Costo (€)', + prefixIcon: Icon( + Icons.shopping_cart_outlined, + ), + ), + ), + ), + ], + ), + const SizedBox(height: 16), + TextFormField( + controller: _publicNotesCtrl, + maxLines: 2, + decoration: const InputDecoration( + labelText: + 'Note Pubbliche (Visibili su ricevuta)', + ), + ), + const SizedBox(height: 16), + TextFormField( + controller: _internalNotesCtrl, + maxLines: 3, + decoration: InputDecoration( + labelText: 'Note Interne (Solo per lo Staff)', + fillColor: Colors.amber.withValues(alpha: 0.1), + filled: true, + ), + ), + ], + ), + + // --- CARD 5: ASSEGNAZIONE & FILE --- + _buildCard( + title: 'Assegnazione Tecnico', + icon: Icons.engineering, + children: [ + StaffSection( + label: 'Assegnato A', + staffId: ticket.assignedToId, + staffName: ticket.assignedToName, + onStaffSelected: (staff) => + context.read().updateFields( + assignedToId: staff.id, + assignedToName: staff.name, + ), + ), + // TODO: Inserire qui il tuo SharedAttachmentsSection per foto pre-riparazione + ], + ), + + const SizedBox(height: 80), // Spazio per il bottom nav + ], + ), + ), + ), + ), + ), + bottomNavigationBar: SafeArea( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + children: [ + Expanded( + flex: 1, + child: OutlinedButton( + onPressed: state.status == TicketFormStatus.saving + ? null + : () => _saveTicket(keepAdding: true), + child: const Text( + 'Salva e Aggiungi Altro', + textAlign: TextAlign.center, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + flex: 1, + child: ElevatedButton( + onPressed: state.status == TicketFormStatus.saving + ? null + : () => _saveTicket(keepAdding: false), + child: state.status == TicketFormStatus.saving + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2, + ), + ) + : const Text('Salva ed Esci'), + ), + ), + ], + ), + ), + ), + ); + }, + ); + } + + // Helper per creare le Card esteticamente coerenti + Widget _buildCard({ + required String title, + required IconData icon, + required List children, + }) { + return Card( + margin: const EdgeInsets.only(bottom: 24), + elevation: 2, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Padding( + padding: const EdgeInsets.all(20.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, color: Theme.of(context).colorScheme.primary), + const SizedBox(width: 12), + Text( + title, + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + ), + ], + ), + const Divider(height: 32), + ...children, + ], + ), + ), + ); + } +}