diff --git a/lib/core/routes/app_router.dart b/lib/core/routes/app_router.dart index 6ac8f4b..96df281 100644 --- a/lib/core/routes/app_router.dart +++ b/lib/core/routes/app_router.dart @@ -7,8 +7,7 @@ import 'package:flux/features/customers/ui/customer_detail_screen.dart'; import 'package:flux/features/home/ui/home_screen.dart'; import 'package:flux/features/master_data/products/ui/products_screen.dart'; import 'package:flux/features/master_data/store/ui/create_store_screen.dart'; -import 'package:flux/features/services/models/service_model.dart'; -import 'package:flux/features/services/ui/service_form_screen.dart'; +import 'package:flux/features/services/ui/service_form_screen/service_form_screen.dart'; import 'package:go_router/go_router.dart'; import 'dart:async'; @@ -80,9 +79,7 @@ class AppRouter { path: '/service-form', name: 'service-form', builder: (context, state) { - // Recuperiamo il ServiceModel se passato come extra - final service = state.extra as ServiceModel?; - return ServiceFormScreen(initialService: service); + return ServiceFormScreen(); }, ), ], diff --git a/lib/features/home/ui/dashboard_adaptive_grid.dart b/lib/features/home/ui/dashboard_adaptive_grid.dart index 221c698..191291d 100644 --- a/lib/features/home/ui/dashboard_adaptive_grid.dart +++ b/lib/features/home/ui/dashboard_adaptive_grid.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flux/core/theme/theme.dart'; import 'package:flux/features/home/ui/dashboard_action_card.dart'; +import 'package:flux/features/services/utils/service_actions.dart'; import 'package:go_router/go_router.dart'; class DashboardAdaptiveGrid extends StatelessWidget { @@ -36,7 +37,7 @@ class DashboardAdaptiveGrid extends StatelessWidget { label: 'Nuova Op', icon: Icons.add_task, color: context.accent, - onTap: () {}, + onTap: () => startNewService(context), ), DashboardActionCard( label: 'Clienti', diff --git a/lib/features/services/blocs/services_cubit.dart b/lib/features/services/blocs/services_cubit.dart index 66cc1a4..966bdb9 100644 --- a/lib/features/services/blocs/services_cubit.dart +++ b/lib/features/services/blocs/services_cubit.dart @@ -3,55 +3,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flux/core/blocs/session/session_bloc.dart'; import 'package:flux/features/services/data/services_repository.dart'; +import 'package:flux/features/services/models/energy_service_model.dart'; +import 'package:flux/features/services/models/entertainment_service_model.dart'; +import 'package:flux/features/services/models/fin_service_model.dart'; import 'package:flux/features/services/models/service_model.dart'; import 'package:get_it/get_it.dart'; - -class ServicesState extends Equatable { - final List allServices; - final bool isLoading; - final bool hasReachedMax; // Per lo scroll infinito - final String? errorMessage; - // Parametri di ricerca - final String query; - final DateTimeRange? dateRange; - - const ServicesState({ - this.allServices = const [], - this.isLoading = false, - this.hasReachedMax = false, - this.errorMessage, - this.query = '', - this.dateRange, - }); - ServicesState copyWith({ - List? allServices, - bool? isLoading, - String? errorMessage, - bool? hasReachedMax, - String? query, - DateTimeRange? dateRange, - }) { - return ServicesState( - allServices: allServices ?? this.allServices, - isLoading: isLoading ?? this.isLoading, - errorMessage: - errorMessage, // Se non lo passiamo, torna null (pulisce l'errore) - hasReachedMax: hasReachedMax ?? this.hasReachedMax, - query: query ?? this.query, - dateRange: dateRange ?? this.dateRange, - ); - } - - @override - List get props => [ - allServices, - isLoading, - hasReachedMax, - errorMessage, - query, - dateRange, - ]; -} +part 'services_state.dart'; class ServicesCubit extends Cubit { final ServicesRepository _repository = GetIt.I(); @@ -59,58 +16,178 @@ class ServicesCubit extends Cubit { ServicesCubit(this._sessionBloc) : super(const ServicesState()); - // Carica tutto il pacchetto + // --- CARICAMENTO E PAGINAZIONE --- + Future loadServices({bool refresh = false}) async { - // Se non è un refresh e abbiamo già dati, non disturbare Supabase - if (!refresh && state.allServices.isNotEmpty) return; + // Se stiamo già caricando, evitiamo chiamate doppie if (state.isLoading) return; - // Se facciamo refresh, resettiamo tutto - final currentOffset = refresh ? 0 : state.allServices.length; + // Se non è un refresh e abbiamo già raggiunto la fine dei dati, ci fermiamo + if (!refresh && state.hasReachedMax) return; emit( state.copyWith( isLoading: true, + errorMessage: null, + // Se è un refresh, svuotiamo la lista attuale per mostrare lo shimmer/loading allServices: refresh ? [] : state.allServices, hasReachedMax: refresh ? false : state.hasReachedMax, ), ); try { + final currentOffset = refresh ? 0 : state.allServices.length; + final companyId = _sessionBloc.state.company?.id; + + if (companyId == null) { + throw Exception("Company ID non trovato nella sessione"); + } + final newServices = await _repository.fetchServices( - companyId: _sessionBloc.state.company!.id, + companyId: companyId, offset: currentOffset, + limit: 50, searchTerm: state.query, dateRange: state.dateRange, ); + // Se ricevi meno record del limite, significa che non ce ne sono altri sul DB + final bool reachedMax = newServices.length < 50; + emit( state.copyWith( isLoading: false, - allServices: List.from(state.allServices)..addAll(newServices), - hasReachedMax: - newServices.length < - 50, // Se ne arrivano meno di 50, siamo alla fine + allServices: refresh + ? newServices + : [...state.allServices, ...newServices], + hasReachedMax: reachedMax, ), ); } catch (e) { - emit(state.copyWith(isLoading: false, errorMessage: e.toString())); + emit( + state.copyWith( + isLoading: false, + errorMessage: "Errore nel caricamento servizi: $e", + ), + ); } } + // --- GESTIONE FILTRI --- + + /// Aggiorna i parametri di ricerca e ricarica da zero void updateFilters({String? query, DateTimeRange? range}) { - emit(state.copyWith(query: query, dateRange: range)); - loadServices(refresh: true); // Applica i filtri e riparte da zero + emit( + state.copyWith( + query: query ?? state.query, + dateRange: range ?? state.dateRange, + ), + ); + loadServices(refresh: true); } - // Salva e ricarica - Future addService(ServiceModel service) async { - emit(state.copyWith(isLoading: true)); + /// Pulisce tutti i filtri + void clearFilters() { + emit(state.copyWith(query: '', dateRange: null)); + loadServices(refresh: true); + } + + // --- GESTIONE BOZZA (DRAFT) --- + + /// Inizializza un nuovo servizio o ne carica uno esistente per la modifica + void initServiceForm(ServiceModel? existingService) { + if (existingService != null) { + emit(state.copyWith(currentService: existingService)); + } else { + // Crea un template vuoto con lo store di default (se disponibile) + emit( + state.copyWith( + currentService: ServiceModel( + storeId: _sessionBloc.state.selectedStore?.id ?? '', + number: '', // Sarà compilato dall'utente + createdAt: DateTime.now(), + ), + ), + ); + } + } + + /// Metodo generico per aggiornare i campi base (AL, MNP, Note, ecc.) + void updateField({ + int? al, + int? mnp, + int? nip, + int? unica, + int? telepass, + String? note, + String? number, + bool? isBozza, + bool? resultOk, + String? customerId, + }) { + if (state.currentService == null) return; + + final updated = state.currentService!.copyWith( + al: al, + mnp: mnp, + nip: nip, + unica: unica, + telepass: telepass, + note: note, + number: number, + isBozza: isBozza, + resultOk: resultOk, + customerId: customerId, + ); + + emit(state.copyWith(currentService: updated)); + } + + // --- GESTIONE MODULI COMPLESSI --- + + void updateEnergyServices(List energyList) { + emit( + state.copyWith( + currentService: state.currentService?.copyWith( + energyServices: energyList, + ), + ), + ); + } + + void updateFinServices(List finList) { + emit( + state.copyWith( + currentService: state.currentService?.copyWith(finServices: finList), + ), + ); + } + + void updateEntertainmentServices(List entList) { + emit( + state.copyWith( + currentService: state.currentService?.copyWith( + entertainmentServices: entList, + ), + ), + ); + } + + // --- PERSISTENZA --- + + Future saveCurrentService() async { + if (state.currentService == null) return; + + emit(state.copyWith(isSaving: true, errorMessage: null)); try { - await _repository.saveFullService(service); - await loadServices(); // Ricarichiamo la lista aggiornata + // Usiamo il repository corazzato che abbiamo scritto prima + await _repository.saveFullService(state.currentService!); + + // Reset della bozza e ricaricamento lista + emit(state.copyWith(isSaving: false, currentService: null)); + await loadServices(refresh: true); } catch (e) { - emit(state.copyWith(isLoading: false, errorMessage: e.toString())); + emit(state.copyWith(isSaving: false, errorMessage: e.toString())); } } } diff --git a/lib/features/services/blocs/services_state.dart b/lib/features/services/blocs/services_state.dart new file mode 100644 index 0000000..222c114 --- /dev/null +++ b/lib/features/services/blocs/services_state.dart @@ -0,0 +1,57 @@ +part of 'services_cubit.dart'; + +class ServicesState extends Equatable { + final List allServices; + final ServiceModel? currentService; // La bozza che stiamo editando + final bool isLoading; + final bool isSaving; // Per mostrare il caricamento solo sul tasto salva + final String? errorMessage; + final String query; + final DateTimeRange? dateRange; + final bool hasReachedMax; + + const ServicesState({ + this.allServices = const [], + this.currentService, + this.isLoading = false, + this.isSaving = false, + this.errorMessage, + this.query = '', + this.dateRange, + this.hasReachedMax = false, + }); + + ServicesState copyWith({ + List? allServices, + ServiceModel? currentService, + bool? isLoading, + bool? isSaving, + String? errorMessage, + String? query, + DateTimeRange? dateRange, + bool? hasReachedMax, + }) { + return ServicesState( + allServices: allServices ?? this.allServices, + currentService: currentService ?? this.currentService, + isLoading: isLoading ?? this.isLoading, + isSaving: isSaving ?? this.isSaving, + errorMessage: errorMessage, + query: query ?? this.query, + dateRange: dateRange ?? this.dateRange, + hasReachedMax: hasReachedMax ?? this.hasReachedMax, + ); + } + + @override + List get props => [ + allServices, + currentService, + isLoading, + isSaving, + errorMessage, + query, + dateRange, + hasReachedMax, + ]; +} diff --git a/lib/features/services/data/services_repository.dart b/lib/features/services/data/services_repository.dart index 7da3966..02952e7 100644 --- a/lib/features/services/data/services_repository.dart +++ b/lib/features/services/data/services_repository.dart @@ -55,8 +55,7 @@ class ServicesRepository { // --- SALVATAGGIO COMPLETO (PRIMA PADRE, POI FIGLI) --- Future saveFullService(ServiceModel service) async { try { - // 1. Inseriamo il record principale - // Se service.id è null, Supabase fa INSERT. Se c'è, fa UPDATE (grazie all'upsert o gestione manuale) + // 1. Upsert del record principale final serviceData = await _supabase .from('service') .upsert(service.toMap()) @@ -65,45 +64,65 @@ class ServicesRepository { final String newId = serviceData['id']; - // 2. Pulizia vecchi record figli (necessaria se è una MODIFICA) - // Se stiamo modificando, cancelliamo i vecchi per reinserire i nuovi (più semplice) + // 2. MODIFICA: Pulizia atomica dei figli + // Se stiamo modificando (id != null), resettiamo le tabelle collegate if (service.id != null) { - await _supabase.from('energy_service').delete().eq('service_id', newId); - await _supabase.from('fin_service').delete().eq('service_id', newId); - await _supabase - .from('entertainment_service') - .delete() - .eq('service_id', newId); + await Future.wait([ + _supabase.from('energy_service').delete().eq('service_id', newId), + _supabase.from('fin_service').delete().eq('service_id', newId), + _supabase + .from('entertainment_service') + .delete() + .eq('service_id', newId), + // Aggiungi qui eventuali altre tabelle pivot o file + ]); } - // 3. Inserimento EnergyServices + // 3. Inserimento dei moduli in parallelo per velocità + final List insertTasks = []; + if (service.energyServices.isNotEmpty) { - final List> toInsert = []; - for (var item in service.energyServices) { - toInsert.add(item.copyWith(serviceId: newId).toMap()); - } - await _supabase.from('energy_service').insert(toInsert); + insertTasks.add( + _supabase + .from('energy_service') + .insert( + service.energyServices + .map((item) => item.copyWith(serviceId: newId).toMap()) + .toList(), + ), + ); } - // 4. Inserimento FinServices if (service.finServices.isNotEmpty) { - final List> toInsert = []; - for (var item in service.finServices) { - toInsert.add(item.copyWith(serviceId: newId).toMap()); - } - await _supabase.from('fin_service').insert(toInsert); + insertTasks.add( + _supabase + .from('fin_service') + .insert( + service.finServices + .map((item) => item.copyWith(serviceId: newId).toMap()) + .toList(), + ), + ); } - // 5. Inserimento EntertainmentServices if (service.entertainmentServices.isNotEmpty) { - final List> toInsert = []; - for (var item in service.entertainmentServices) { - toInsert.add(item.copyWith(serviceId: newId).toMap()); - } - await _supabase.from('entertainment_service').insert(toInsert); + insertTasks.add( + _supabase + .from('entertainment_service') + .insert( + service.entertainmentServices + .map((item) => item.copyWith(serviceId: newId).toMap()) + .toList(), + ), + ); + } + + if (insertTasks.isNotEmpty) { + await Future.wait(insertTasks); } } catch (e) { - throw Exception('Errore durante il salvataggio: $e'); + // Qui potresti aggiungere una logica di "rollback manuale" se necessario + throw Exception('Errore durante il salvataggio corazzato: $e'); } } diff --git a/lib/features/services/models/energy_service_model.dart b/lib/features/services/models/energy_service_model.dart index 9cf9b54..5f35833 100644 --- a/lib/features/services/models/energy_service_model.dart +++ b/lib/features/services/models/energy_service_model.dart @@ -1,4 +1,6 @@ import 'package:equatable/equatable.dart'; +import 'package:flux/features/services/models/entertainment_service_model.dart'; +import 'package:flux/features/services/models/fin_service_model.dart'; enum EnergyType { luce, gas } // Mappa il tuo public.energy_type diff --git a/lib/features/services/models/service_model.dart b/lib/features/services/models/service_model.dart index 5bbfc10..6a14e46 100644 --- a/lib/features/services/models/service_model.dart +++ b/lib/features/services/models/service_model.dart @@ -115,12 +115,14 @@ class ServiceModel extends Equatable { factory ServiceModel.fromMap(Map map) { return ServiceModel( - id: map['id'], - createdAt: DateTime.parse(map['created_at']), - storeId: map['store_id'], - employeeId: map['employee_id'], - customerId: map['customer_id'], - number: map['number'] ?? '', + id: map['id'].toString(), + createdAt: map['created_at'] != null + ? DateTime.parse(map['created_at']) + : DateTime.now(), + storeId: map['store_id'] ?? '', + employeeId: map['employee_id']?.toString(), + customerId: map['customer_id']?.toString(), + number: map['number']?.toString() ?? '', isBozza: map['bozza'] ?? true, note: map['note'] ?? '', resultOk: map['result_ok'] ?? true, @@ -130,7 +132,7 @@ class ServiceModel extends Equatable { unica: map['unica'] ?? 0, telepass: map['telepass'] ?? 0, - // Mappaggio delle liste collegate (se incluse nella query) + // Estrazione sicura liste collegate energyServices: (map['energy_service'] as List?) ?.map((x) => EnergyServiceModel.fromMap(x)) @@ -146,9 +148,12 @@ class ServiceModel extends Equatable { ?.map((x) => EntertainmentServiceModel.fromMap(x)) .toList() ?? const [], + + // Display name del cliente con fallback customerDisplayName: map['customer'] != null - ? "${map['customer']['name']} ${map['customer']['surname']}" - : "Cliente sconosciuto", + ? "${map['customer']['name'] ?? ''} ${map['customer']['surname'] ?? ''}" + .trim() + : "Cliente non assegnato", ); } diff --git a/lib/features/services/ui/service_action_card.dart b/lib/features/services/ui/service_action_card.dart new file mode 100644 index 0000000..ef06dc7 --- /dev/null +++ b/lib/features/services/ui/service_action_card.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; + +class ServiceActionCard extends StatelessWidget { + final String title; + final IconData icon; + final VoidCallback onTap; + final Color color; + final int count; + const ServiceActionCard({ + super.key, + required this.title, + required this.icon, + required this.onTap, + required this.color, + this.count = 0, + }); + + @override + Widget build(BuildContext context) { + final bool isActive = count > 0; + + return Card( + elevation: isActive ? 4 : 1, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide( + color: isActive ? color : Colors.transparent, + width: 2, + ), + ), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(16), + child: Container( + width: 110, // Dimensione fissa per farle stare in una Row/Wrap + padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: isActive ? color.withValues(alpha: 0.1) : Colors.transparent, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + color: isActive ? color : Colors.grey.shade400, + size: 32, + ), + const SizedBox(height: 8), + Text( + title, + textAlign: TextAlign.center, + style: TextStyle( + fontWeight: isActive ? FontWeight.bold : FontWeight.normal, + color: isActive ? color : Colors.grey.shade600, + fontSize: 12, + ), + ), + if (isActive) ...[ + const SizedBox(height: 4), + CircleAvatar( + radius: 10, + backgroundColor: color, + child: Text( + count.toString(), + style: const TextStyle(fontSize: 10, color: Colors.white), + ), + ), + ], + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/services/ui/service_form_screen.dart b/lib/features/services/ui/service_form_screen.dart deleted file mode 100644 index d63599c..0000000 --- a/lib/features/services/ui/service_form_screen.dart +++ /dev/null @@ -1,150 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flux/features/services/blocs/services_cubit.dart'; -import 'package:flux/features/services/models/energy_service_model.dart'; -import 'package:flux/features/services/models/service_model.dart'; - -class ServiceFormScreen extends StatefulWidget { - final ServiceModel? initialService; // Se nullo, è un nuovo inserimento - - const ServiceFormScreen({super.key, this.initialService}); - - @override - State createState() => _ServiceFormScreenState(); -} - -class _ServiceFormScreenState extends State { - late ServiceModel currentService; - - @override - void initState() { - super.initState(); - // Se passiamo un servizio esistente lo carichiamo, altrimenti ne creiamo uno "vuoto" - currentService = - widget.initialService ?? - ServiceModel( - storeId: 'ID_NEGOZIO_QUI', // Poi lo prenderai dal profilo utente - number: '', - energyServices: const [], - finServices: const [], - entertainmentServices: const [], - ); - } - - // Metodo generico per aggiungere un servizio energia - void _addEnergy() { - setState(() { - final newList = - List.from(currentService.energyServices)..add( - EnergyServiceModel( - type: EnergyType.luce, // Default - expiration: DateTime.now().add(const Duration(days: 365)), - providerId: '', // Lo sceglierà l'utente - ), - ); - currentService = currentService.copyWith(energyServices: newList); - }); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: Text( - widget.initialService == null ? "Nuova Pratica" : "Modifica", - ), - ), - body: SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: Column( - children: [ - // --- SEZIONE DATI GENERALI --- - TextField( - decoration: const InputDecoration(labelText: "Numero Pratica"), - onChanged: (v) => - currentService = currentService.copyWith(number: v), - ), - - const Divider(height: 32), - - // --- SEZIONE ENERGY --- - _SectionHeader( - title: "Energia (Luce/Gas)", - onAdd: _addEnergy, - icon: Icons.electric_bolt, - ), - ...currentService.energyServices.asMap().entries.map((entry) { - int idx = entry.key; - var item = entry.value; - return Card( - child: ListTile( - title: Text( - "${item.type.name.toUpperCase()} - Scadenza: ${item.expiration.year}", - ), - trailing: IconButton( - icon: const Icon(Icons.delete, color: Colors.red), - onPressed: () { - setState(() { - final newList = List.from( - currentService.energyServices, - )..removeAt(idx); - currentService = currentService.copyWith( - energyServices: newList, - ); - }); - }, - ), - ), - ); - }), - - const SizedBox(height: 40), - - // --- BOTTONE SALVA --- - ElevatedButton( - style: ElevatedButton.styleFrom( - minimumSize: const Size.fromHeight(50), - ), - onPressed: () { - context.read().addService(currentService); - Navigator.pop(context); - }, - child: const Text("SALVA TUTTO"), - ), - ], - ), - ), - ); - } -} - -class _SectionHeader extends StatelessWidget { - final String title; - final VoidCallback onAdd; - final IconData icon; - - const _SectionHeader({ - required this.title, - required this.onAdd, - required this.icon, - }); - - @override - Widget build(BuildContext context) { - return Row( - children: [ - Icon(icon, color: Colors.orange), - const SizedBox(width: 8), - Text( - title, - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - ), - const Spacer(), - IconButton( - onPressed: onAdd, - icon: const Icon(Icons.add_circle, color: Colors.green, size: 30), - ), - ], - ); - } -} diff --git a/lib/features/services/ui/service_form_screen/general_info_section.dart b/lib/features/services/ui/service_form_screen/general_info_section.dart new file mode 100644 index 0000000..e2330bf --- /dev/null +++ b/lib/features/services/ui/service_form_screen/general_info_section.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flux/features/services/blocs/services_cubit.dart'; +import 'package:flux/features/services/models/service_model.dart'; + +class GeneralInfoSection extends StatelessWidget { + final ServiceModel service; + const GeneralInfoSection({super.key, required this.service}); + + @override + Widget build(BuildContext context) { + return Card( + elevation: 2, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.info_outline, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 8), + Text( + "Info Generali", + style: Theme.of(context).textTheme.titleMedium, + ), + ], + ), + const SizedBox(height: 16), + + // Numero di Riferimento / Telefono + TextFormField( + initialValue: service.number, + keyboardType: TextInputType + .phone, // Fa aprire il tastierino numerico su mobile + decoration: const InputDecoration( + labelText: "Numero di Telefono / Riferimento", + hintText: "Es. 3331234567", + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.phone), + ), + onChanged: (val) { + context.read().updateField(number: val); + }, + ), + const SizedBox(height: 16), + + // I due Switch affiancati (Bozza e A buon fine) + Row( + children: [ + Expanded( + child: SwitchListTile( + title: const Text("Bozza"), + subtitle: const Text( + "Pratica in lavorazione", + style: TextStyle(fontSize: 12), + ), + value: service.isBozza, + activeThumbColor: Colors.orange, + contentPadding: EdgeInsets.zero, + onChanged: (val) { + context.read().updateField(isBozza: val); + }, + ), + ), + const SizedBox(width: 16), + Expanded( + child: SwitchListTile( + title: const Text("A buon fine"), + subtitle: const Text( + "Esito positivo", + style: TextStyle(fontSize: 12), + ), + value: service.resultOk, + activeThumbColor: Colors.green, + contentPadding: EdgeInsets.zero, + onChanged: (val) { + context.read().updateField(resultOk: val); + }, + ), + ), + ], + ), + const SizedBox(height: 16), + + // Campo Note + TextFormField( + initialValue: service.note, + maxLines: 4, + minLines: 2, + decoration: const InputDecoration( + labelText: "Note Operazione", + hintText: + "Scrivi qui eventuali dettagli o richieste del cliente...", + border: OutlineInputBorder(), + alignLabelWithHint: true, + ), + onChanged: (val) { + context.read().updateField(note: val); + }, + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/services/ui/service_form_screen/service_form_screen.dart b/lib/features/services/ui/service_form_screen/service_form_screen.dart new file mode 100644 index 0000000..b91a77c --- /dev/null +++ b/lib/features/services/ui/service_form_screen/service_form_screen.dart @@ -0,0 +1,177 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flux/features/services/blocs/services_cubit.dart'; +import 'package:flux/features/services/ui/service_form_screen/general_info_section.dart'; + +class ServiceFormScreen extends StatelessWidget { + const ServiceFormScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text("Nuova Pratica"), + actions: [ + _SaveButton(), // Tasto salva intelligente + ], + ), + body: BlocBuilder( + builder: (context, state) { + final service = state.currentService; + + // Se la bozza non è ancora inizializzata, mostriamo un loader + if (service == null) { + return const Center(child: CircularProgressIndicator()); + } + + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // SEZIONE 1: CLIENTE + const _CustomerSection(), + const SizedBox(height: 24), + + // SEZIONE 2: INFO GENERALI (Da fare) + GeneralInfoSection(service: service), + const SizedBox(height: 24), + + // SEZIONE 3: I MODULI (Da fare) + Text( + "Servizi e Accessori", + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 12), + + // const _ServicesGrid(), + const SizedBox(height: 32), + + // SEZIONE 4: ALLEGATI (Da fare) + // const _AttachmentsSection(), + ], + ), + ); + }, + ), + ); + } +} + +// --- COMPONENTI DELLA PAGINA --- + +class _SaveButton extends StatelessWidget { + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + if (state.isSaving) { + return const Padding( + padding: EdgeInsets.all(16.0), + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ), + ); + } + return IconButton( + icon: const Icon(Icons.save), + tooltip: "Salva Pratica", + onPressed: () { + // TODO: Aggiungere una validazione prima di salvare! + context.read().saveCurrentService(); + }, + ); + }, + ); + } +} + +class _CustomerSection extends StatelessWidget { + const _CustomerSection(); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final service = state.currentService!; + final hasCustomer = service.customerId != null; + + return Card( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.person, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 8), + Text( + "Dati Cliente", + style: Theme.of(context).textTheme.titleMedium, + ), + ], + ), + const SizedBox(height: 16), + + // Se non c'è il cliente, mostriamo il tastone per cercarlo + if (!hasCustomer) + Center( + child: ElevatedButton.icon( + onPressed: () { + // TODO: Aprire modale/dialog per ricerca clienti + print("Apro ricerca clienti..."); + }, + icon: const Icon(Icons.search), + label: const Text("Seleziona o Crea Cliente"), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 12, + ), + ), + ), + ) + // Se c'è, mostriamo chi è e diamo la possibilità di cambiarlo + else + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + service.customerDisplayName ?? "Cliente Selezionato", + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ), + TextButton.icon( + onPressed: () { + // TODO: Aprire modale/dialog per ricerca clienti + }, + icon: const Icon(Icons.edit, size: 18), + label: const Text("Cambia"), + ), + ], + ), + ], + ), + ), + ); + }, + ); + } +} diff --git a/lib/features/services/ui/services_screen.dart b/lib/features/services/ui/services_screen.dart index aea5315..cdee646 100644 --- a/lib/features/services/ui/services_screen.dart +++ b/lib/features/services/ui/services_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flux/features/services/blocs/services_cubit.dart'; import 'package:flux/features/services/models/service_model.dart'; +import 'package:flux/features/services/utils/service_actions.dart'; import 'package:go_router/go_router.dart'; // Importa i tuoi modelli e cubit @@ -111,7 +112,7 @@ class _ServicesScreenState extends State { }, ), floatingActionButton: FloatingActionButton( - onPressed: () => context.pushNamed('service-form'), // GoRouter + onPressed: () => startNewService(context), child: const Icon(Icons.add), ), ); diff --git a/lib/features/services/utils/service_actions.dart b/lib/features/services/utils/service_actions.dart new file mode 100644 index 0000000..a5c6e6e --- /dev/null +++ b/lib/features/services/utils/service_actions.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flux/core/blocs/session/session_bloc.dart'; +import 'package:flux/features/master_data/store/bloc/store_cubit.dart'; +import 'package:flux/features/services/blocs/services_cubit.dart'; +import 'package:flux/features/services/models/service_model.dart'; +import 'package:go_router/go_router.dart'; + +/// Avvia la creazione di un nuovo servizio partendo dalla selezione dell'operatore. +void startNewService(BuildContext context) { + final session = context.read().state; + final currentStoreId = session.selectedStore?.id; + + if (currentStoreId == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("Seleziona uno store prima di iniziare")), + ); + return; + } + + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (modalContext) { + // Usiamo lo StoreCubit invece dello StaffCubit! + return BlocBuilder( + builder: (context, storeState) { + // Recuperiamo lo staff assegnato a questo specifico store usando la mappa che avevi già creato + final storeStaff = storeState.staffByStore[currentStoreId] ?? []; + + return Container( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + "Chi sta eseguendo l'operazione?", + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 20), + + if (storeStaff.isEmpty) + const Text( + "Nessun membro dello staff configurato per questo store.\nVai in Anagrafica > Negozi per assegnare il personale.", + textAlign: TextAlign.center, + ), + + ...storeStaff.map( + (member) => ListTile( + leading: const CircleAvatar(child: Icon(Icons.person)), + title: Text(member.name), + onTap: () { + // 1. Inizializza il form nel Cubit + context.read().initServiceForm( + ServiceModel( + storeId: currentStoreId, + employeeId: member.id, + number: '', + createdAt: DateTime.now(), + ), + ); + + // 2. Chiudi la modal + Navigator.pop(modalContext); + + // 3. Naviga verso il form + context.pushNamed('service-form'); + }, + ), + ), + const SizedBox(height: 16), + ], + ), + ); + }, + ); + }, + ); +}