From 5229571fa15394057008b914ec3e02b0cb6d3e5a Mon Sep 17 00:00:00 2001 From: Mark M2 Macbook Date: Thu, 16 Apr 2026 11:50:29 +0200 Subject: [PATCH] service (#2) Reviewed-on: http://catelliub.zapto.org:3000/brontomark/flux/pulls/2 Co-authored-by: Mark M2 Macbook Co-committed-by: Mark M2 Macbook --- lib/core/routes/app_router.dart | 11 + lib/features/home/ui/home_screen.dart | 40 ++-- .../providers/blocs/provider_cubit.dart | 113 ++++++++++ .../providers/data/provider_repository.dart | 93 +++++++++ .../providers/models/provider_model.dart | 96 +++++++++ .../services/blocs/services_cubit.dart | 116 +++++++++++ .../services/data/services_repository.dart | 118 +++++++++++ .../services/models/energy_service_model.dart | 72 +++++++ .../models/entertainment_service_model.dart | 77 +++++++ .../services/models/fin_service_model.dart | 63 ++++++ .../services/models/service_model.dart | 173 +++++++++++++++ .../services/ui/service_form_screen.dart | 150 +++++++++++++ lib/features/services/ui/services_screen.dart | 197 ++++++++++++++++++ lib/main.dart | 8 + 14 files changed, 1313 insertions(+), 14 deletions(-) create mode 100644 lib/features/master_data/providers/blocs/provider_cubit.dart create mode 100644 lib/features/master_data/providers/data/provider_repository.dart create mode 100644 lib/features/master_data/providers/models/provider_model.dart create mode 100644 lib/features/services/blocs/services_cubit.dart create mode 100644 lib/features/services/data/services_repository.dart create mode 100644 lib/features/services/models/energy_service_model.dart create mode 100644 lib/features/services/models/entertainment_service_model.dart create mode 100644 lib/features/services/models/fin_service_model.dart create mode 100644 lib/features/services/models/service_model.dart create mode 100644 lib/features/services/ui/service_form_screen.dart create mode 100644 lib/features/services/ui/services_screen.dart diff --git a/lib/core/routes/app_router.dart b/lib/core/routes/app_router.dart index 8af21d0..6ac8f4b 100644 --- a/lib/core/routes/app_router.dart +++ b/lib/core/routes/app_router.dart @@ -7,6 +7,8 @@ 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:go_router/go_router.dart'; import 'dart:async'; @@ -74,6 +76,15 @@ class AppRouter { name: 'products', builder: (context, state) => const ProductsScreen(), ), + GoRoute( + 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); + }, + ), ], ); } diff --git a/lib/features/home/ui/home_screen.dart b/lib/features/home/ui/home_screen.dart index 10f5e05..d092197 100644 --- a/lib/features/home/ui/home_screen.dart +++ b/lib/features/home/ui/home_screen.dart @@ -3,6 +3,8 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flux/core/blocs/session/session_bloc.dart'; import 'package:flux/core/theme/theme.dart'; import 'package:flux/features/master_data/master_data_hub_content.dart'; +import 'package:flux/features/services/blocs/services_cubit.dart'; +import 'package:flux/features/services/ui/services_screen.dart'; import 'dashboard_content.dart'; // Importiamo il contenuto della dashboard class HomeScreen extends StatefulWidget { @@ -16,6 +18,16 @@ class _HomeScreenState extends State { int _selectedIndex = 0; bool _extendRailway = false; + @override + void initState() { + super.initState(); + // Caricamento "silenzioso" all'avvio dell'app + // Usiamo WidgetsBinding per assicurarci che il contesto sia pronto + WidgetsBinding.instance.addPostFrameCallback((_) { + context.read().loadServices(); + }); + } + @override Widget build(BuildContext context) { return BlocBuilder( @@ -63,7 +75,7 @@ class _HomeScreenState extends State { ), BottomNavigationBarItem( icon: Icon(Icons.receipt_long), - label: 'Operazioni', + label: 'Servizi', ), BottomNavigationBarItem( icon: Icon(Icons.folder_shared), @@ -111,7 +123,7 @@ class _HomeScreenState extends State { NavigationRailDestination( icon: Icon(Icons.receipt_long_outlined), selectedIcon: Icon(Icons.receipt_long), - label: Text('Operazioni'), + label: Text('Servizi'), ), NavigationRailDestination( icon: Icon(Icons.folder_shared_outlined), @@ -148,17 +160,18 @@ class _HomeScreenState extends State { // Switch tra le sottopagine Widget _buildPageContent(int index, bool isLargeScreen) { - switch (index) { - case 0: - return DashboardContent( + return IndexedStack( + index: index, + children: [ + DashboardContent( isLargeScreen: isLargeScreen, onTabRequested: (idx) => setState(() => _selectedIndex = 2), - ); - case 1: - return const Center(child: Text('Operazioni')); - case 2: + ), + + ServicesScreen(), + // L'unico punto di ingresso per tutte le anagrafiche - return MasterDataHubContent( + MasterDataHubContent( // Qui gestiamo la navigazione "interna" all'hub onOpenPage: (widget) { Navigator.push( @@ -166,9 +179,8 @@ class _HomeScreenState extends State { MaterialPageRoute(builder: (context) => widget), ); }, - ); - default: - return DashboardContent(isLargeScreen: isLargeScreen); - } + ), + ], + ); } } diff --git a/lib/features/master_data/providers/blocs/provider_cubit.dart b/lib/features/master_data/providers/blocs/provider_cubit.dart new file mode 100644 index 0000000..07ea2d7 --- /dev/null +++ b/lib/features/master_data/providers/blocs/provider_cubit.dart @@ -0,0 +1,113 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flux/features/master_data/providers/data/provider_repository.dart'; +import 'package:get_it/get_it.dart'; +import '../models/provider_model.dart'; + +class ProvidersState extends Equatable { + final List allProviders; // Tutti i provider della company + final List + associatedIds; // ID dei provider attivi nello store selezionato + final bool isLoading; + final String? errorMessage; + + const ProvidersState({ + this.allProviders = const [], + this.associatedIds = const [], + this.isLoading = false, + this.errorMessage, + }); + + ProvidersState copyWith({ + List? allProviders, + List? associatedIds, + bool? isLoading, + String? errorMessage, + }) { + return ProvidersState( + allProviders: allProviders ?? this.allProviders, + associatedIds: associatedIds ?? this.associatedIds, + isLoading: isLoading ?? this.isLoading, + errorMessage: errorMessage, + ); + } + + @override + List get props => [ + allProviders, + associatedIds, + isLoading, + errorMessage, + ]; +} + +class ProvidersCubit extends Cubit { + final ProviderRepository _repository = GetIt.I(); + + ProvidersCubit() : super(const ProvidersState()); + + // Carica i provider della company e quelli associati a uno store specifico + Future loadProviders(String companyId, String? storeId) async { + emit(state.copyWith(isLoading: true)); + try { + final all = await _repository.fetchAllCompanyProviders(companyId); + List associated = []; + + if (storeId != null) { + associated = await _repository.fetchAssociatedProviderIds(storeId); + } + + emit( + state.copyWith( + allProviders: all, + associatedIds: associated, + isLoading: false, + ), + ); + } catch (e) { + emit(state.copyWith(isLoading: false, errorMessage: e.toString())); + } + } + + // Aggiunge o rimuove l'associazione con lo store + Future toggleProviderAssociation({ + required String providerId, + required String storeId, + required bool isCurrentlyAssociated, + }) async { + try { + if (isCurrentlyAssociated) { + await _repository.disassociateProviderFromStore( + providerId: providerId, + storeId: storeId, + ); + // Aggiorniamo lo stato locale rimuovendo l'ID + final newIds = List.from(state.associatedIds) + ..remove(providerId); + emit(state.copyWith(associatedIds: newIds)); + } else { + await _repository.associateProviderToStore( + providerId: providerId, + storeId: storeId, + ); + // Aggiorniamo lo stato locale aggiungendo l'ID + final newIds = List.from(state.associatedIds)..add(providerId); + emit(state.copyWith(associatedIds: newIds)); + } + } catch (e) { + emit(state.copyWith(errorMessage: "Errore durante l'aggiornamento: $e")); + } + } + + // Salvataggio/Update anagrafica (nuovo o modifica) + Future saveProvider(ProviderModel provider) async { + emit(state.copyWith(isLoading: true)); + try { + await _repository.saveProvider(provider); + // Ricarichiamo la lista per vedere le modifiche + await loadProviders(provider.companyId, null); + } catch (e) { + emit(state.copyWith(isLoading: false, errorMessage: e.toString())); + } + } +} diff --git a/lib/features/master_data/providers/data/provider_repository.dart b/lib/features/master_data/providers/data/provider_repository.dart new file mode 100644 index 0000000..46bb48a --- /dev/null +++ b/lib/features/master_data/providers/data/provider_repository.dart @@ -0,0 +1,93 @@ +import 'package:supabase_flutter/supabase_flutter.dart'; +import '../models/provider_model.dart'; + +class ProviderRepository { + final _supabase = Supabase.instance.client; + + // --- ASSOCIAZIONE PROVIDER <-> STORE --- + + // Aggiunge un provider a un negozio (Attiva mandato) + Future associateProviderToStore({ + required String providerId, + required String storeId, + }) async { + try { + await _supabase.from('providers_in_stores').insert({ + 'provider_id': providerId, + 'store_id': storeId, + }); + } catch (e) { + throw Exception('Errore durante l\'associazione provider: $e'); + } + } + + // Rimuove un provider da un negozio (Disattiva mandato) + Future disassociateProviderFromStore({ + required String providerId, + required String storeId, + }) async { + try { + await _supabase + .from('providers_in_stores') + .delete() + .eq('provider_id', providerId) + .eq('store_id', storeId); + } catch (e) { + throw Exception('Errore durante la disassociazione provider: $e'); + } + } + + // Recupera tutti i provider di una company (per la lista generale) + Future> fetchAllCompanyProviders(String companyId) async { + try { + final response = await _supabase + .from('provider') + .select() + .eq('company_id', companyId); + + return (response as List).map((m) => ProviderModel.fromMap(m)).toList(); + } catch (e) { + throw Exception('Errore fetch provider: $e'); + } + } + + // Recupera gli ID dei provider associati a uno store (utile per le checkbox) + Future> fetchAssociatedProviderIds(String storeId) async { + try { + final response = await _supabase + .from('providers_in_stores') + .select('provider_id') + .eq('store_id', storeId); + + return (response as List) + .map((item) => item['provider_id'].toString()) + .toList(); + } catch (e) { + throw Exception('Errore recupero ID associati: $e'); + } + } + + // --- FUNZIONI STANDARD --- + + // Questa la userai nel Form Servizi: carica solo i provider abilitati per lo store + Future> fetchActiveProvidersForStore( + String storeId, + ) async { + try { + final response = await _supabase + .from('provider') + .select('*, providers_in_stores!inner(store_id)') + .eq('providers_in_stores.store_id', storeId) + .eq('is_active', true); + + return (response as List).map((m) => ProviderModel.fromMap(m)).toList(); + } catch (e) { + throw Exception('Errore fetch provider attivi: $e'); + } + } + + // Salva o aggiorna l'anagrafica del Provider + Future saveProvider(ProviderModel provider) async { + await _supabase.from('provider').upsert(provider.toMap()); + } +} diff --git a/lib/features/master_data/providers/models/provider_model.dart b/lib/features/master_data/providers/models/provider_model.dart new file mode 100644 index 0000000..55cb4f8 --- /dev/null +++ b/lib/features/master_data/providers/models/provider_model.dart @@ -0,0 +1,96 @@ +import 'package:equatable/equatable.dart'; + +class ProviderModel extends Equatable { + final String id; + final String nome; + final bool telefoniaFissa; + final bool telefoniaMobile; + final bool energia; + final bool assicurazioni; + final bool intrattenimento; + final bool altro; + final bool isActive; + final String companyId; + + const ProviderModel({ + required this.id, + required this.nome, + required this.telefoniaFissa, + required this.telefoniaMobile, + required this.energia, + required this.assicurazioni, + required this.intrattenimento, + required this.altro, + required this.isActive, + required this.companyId, + }); + + factory ProviderModel.fromMap(Map map) { + return ProviderModel( + id: map['id'], + nome: map['nome'], + telefoniaFissa: map['telefonia_fissa'] ?? false, + telefoniaMobile: map['telefonia_mobile'] ?? false, + energia: map['energia'] ?? false, + assicurazioni: map['assicurazioni'] ?? false, + intrattenimento: map['intrattenimento'] ?? false, + altro: map['altro'] ?? false, + isActive: map['is_active'] ?? true, + companyId: map['company_id'], + ); + } + + Map toMap() { + return { + 'nome': nome, + 'telefonia_fissa': telefoniaFissa, + 'telefonia_mobile': telefoniaMobile, + 'energia': energia, + 'assicurazioni': assicurazioni, + 'intrattenimento': intrattenimento, + 'altro': altro, + 'is_active': isActive, + 'company_id': companyId, + }; + } + + @override + List get props => [ + id, + nome, + telefoniaFissa, + telefoniaMobile, + energia, + assicurazioni, + intrattenimento, + altro, + isActive, + companyId, + ]; + + ProviderModel copyWith({ + String? id, + String? nome, + bool? telefoniaFissa, + bool? telefoniaMobile, + bool? energia, + bool? assicurazioni, + bool? intrattenimento, + bool? altro, + bool? isActive, + String? companyId, + }) { + return ProviderModel( + id: id ?? this.id, + nome: nome ?? this.nome, + telefoniaFissa: telefoniaFissa ?? this.telefoniaFissa, + telefoniaMobile: telefoniaMobile ?? this.telefoniaMobile, + energia: energia ?? this.energia, + assicurazioni: assicurazioni ?? this.assicurazioni, + intrattenimento: intrattenimento ?? this.intrattenimento, + altro: altro ?? this.altro, + isActive: isActive ?? this.isActive, + companyId: companyId ?? this.companyId, + ); + } +} diff --git a/lib/features/services/blocs/services_cubit.dart b/lib/features/services/blocs/services_cubit.dart new file mode 100644 index 0000000..66cc1a4 --- /dev/null +++ b/lib/features/services/blocs/services_cubit.dart @@ -0,0 +1,116 @@ +import 'package:equatable/equatable.dart'; +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/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, + ]; +} + +class ServicesCubit extends Cubit { + final ServicesRepository _repository = GetIt.I(); + final SessionBloc _sessionBloc; + + ServicesCubit(this._sessionBloc) : super(const ServicesState()); + + // Carica tutto il pacchetto + Future loadServices({bool refresh = false}) async { + // Se non è un refresh e abbiamo già dati, non disturbare Supabase + if (!refresh && state.allServices.isNotEmpty) return; + if (state.isLoading) return; + + // Se facciamo refresh, resettiamo tutto + final currentOffset = refresh ? 0 : state.allServices.length; + + emit( + state.copyWith( + isLoading: true, + allServices: refresh ? [] : state.allServices, + hasReachedMax: refresh ? false : state.hasReachedMax, + ), + ); + + try { + final newServices = await _repository.fetchServices( + companyId: _sessionBloc.state.company!.id, + offset: currentOffset, + searchTerm: state.query, + dateRange: state.dateRange, + ); + + 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 + ), + ); + } catch (e) { + emit(state.copyWith(isLoading: false, errorMessage: e.toString())); + } + } + + void updateFilters({String? query, DateTimeRange? range}) { + emit(state.copyWith(query: query, dateRange: range)); + loadServices(refresh: true); // Applica i filtri e riparte da zero + } + + // Salva e ricarica + Future addService(ServiceModel service) async { + emit(state.copyWith(isLoading: true)); + try { + await _repository.saveFullService(service); + await loadServices(); // Ricarichiamo la lista aggiornata + } catch (e) { + emit(state.copyWith(isLoading: false, errorMessage: e.toString())); + } + } +} diff --git a/lib/features/services/data/services_repository.dart b/lib/features/services/data/services_repository.dart new file mode 100644 index 0000000..7da3966 --- /dev/null +++ b/lib/features/services/data/services_repository.dart @@ -0,0 +1,118 @@ +import 'package:flutter/material.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import '../models/service_model.dart'; + +class ServicesRepository { + final _supabase = Supabase.instance.client; + + // --- RECUPERO PAGINATO CON FILTRI E JOIN --- + Future> fetchServices({ + required String companyId, + required int offset, + int limit = 50, + String? searchTerm, + DateTimeRange? dateRange, + }) async { + try { + // Nota: 'customer(name, surname)' serve per il display name nella card + var query = _supabase + .from('service') + .select(''' + *, + customer(name, surname), + energy_service(*), + fin_service(*), + entertainment_service(*) + ''') + .eq('company_id', companyId); + + // Filtro Range Date + if (dateRange != null) { + query = query + .gte('created_at', dateRange.start.toIso8601String()) + .lte('created_at', dateRange.end.toIso8601String()); + } + + if (searchTerm != null && searchTerm.isNotEmpty) { + // Filtra sui campi della tabella principale O su quelli della tabella joinata + query = query.or( + 'number.ilike.%$searchTerm%,note.ilike.%$searchTerm%,customer.name.ilike.%$searchTerm%,customer.surname.ilike.%$searchTerm%', + ); + } + + final response = await query + .order('created_at', ascending: false) + .range(offset, offset + limit - 1); + + return (response as List) + .map((map) => ServiceModel.fromMap(map)) + .toList(); + } catch (e) { + throw Exception('Errore nel caricamento servizi: $e'); + } + } + + // --- 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) + final serviceData = await _supabase + .from('service') + .upsert(service.toMap()) + .select() + .single(); + + 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) + 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); + } + + // 3. Inserimento EnergyServices + 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); + } + + // 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); + } + + // 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); + } + } catch (e) { + throw Exception('Errore durante il salvataggio: $e'); + } + } + + // --- ELIMINAZIONE --- + Future deleteService(String id) async { + try { + await _supabase.from('service').delete().eq('id', id); + } catch (e) { + throw Exception('Errore durante l\'eliminazione: $e'); + } + } +} diff --git a/lib/features/services/models/energy_service_model.dart b/lib/features/services/models/energy_service_model.dart new file mode 100644 index 0000000..9cf9b54 --- /dev/null +++ b/lib/features/services/models/energy_service_model.dart @@ -0,0 +1,72 @@ +import 'package:equatable/equatable.dart'; + +enum EnergyType { luce, gas } // Mappa il tuo public.energy_type + +class EnergyServiceModel extends Equatable { + final String? id; + final DateTime? createdAt; + final EnergyType type; + final DateTime expiration; + final String providerId; + final String? serviceId; + + const EnergyServiceModel({ + this.id, + this.createdAt, + required this.type, + required this.expiration, + required this.providerId, + this.serviceId, + }); + + EnergyServiceModel copyWith({ + String? id, + DateTime? createdAt, + EnergyType? type, + DateTime? expiration, + String? providerId, + String? serviceId, + }) { + return EnergyServiceModel( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + type: type ?? this.type, + expiration: expiration ?? this.expiration, + providerId: providerId ?? this.providerId, + serviceId: serviceId ?? this.serviceId, + ); + } + + @override + List get props => [ + id, + createdAt, + type, + expiration, + providerId, + serviceId, + ]; + + factory EnergyServiceModel.fromMap(Map map) { + return EnergyServiceModel( + id: map['id'], + createdAt: map['created_at'] != null + ? DateTime.parse(map['created_at']) + : null, + type: map['type'] == 'gas' ? EnergyType.gas : EnergyType.luce, + expiration: DateTime.parse(map['expiration']), + providerId: map['provider_id'], + serviceId: map['service_id'], + ); + } + + Map toMap() { + return { + if (id != null) 'id': id, + 'type': type.name, // .name trasforma l'enum in 'luce' o 'gas' + 'expiration': expiration.toIso8601String(), + 'provider_id': providerId, + 'service_id': serviceId, + }; + } +} diff --git a/lib/features/services/models/entertainment_service_model.dart b/lib/features/services/models/entertainment_service_model.dart new file mode 100644 index 0000000..f34743a --- /dev/null +++ b/lib/features/services/models/entertainment_service_model.dart @@ -0,0 +1,77 @@ +import 'package:equatable/equatable.dart'; + +class EntertainmentServiceModel extends Equatable { + final String? id; + final DateTime? createdAt; + final String type; // es. Sky, DAZN, ecc. + final bool constrained; // Vincolato? + final DateTime constrainExpiration; + final String? serviceId; + final String? providerId; + + const EntertainmentServiceModel({ + this.id, + this.createdAt, + required this.type, + required this.constrained, + required this.constrainExpiration, + this.serviceId, + this.providerId, + }); + + EntertainmentServiceModel copyWith({ + String? id, + DateTime? createdAt, + String? type, + bool? constrained, + DateTime? constrainExpiration, + String? serviceId, + String? providerId, + }) { + return EntertainmentServiceModel( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + type: type ?? this.type, + constrained: constrained ?? this.constrained, + constrainExpiration: constrainExpiration ?? this.constrainExpiration, + serviceId: serviceId ?? this.serviceId, + providerId: providerId ?? this.providerId, + ); + } + + @override + List get props => [ + id, + createdAt, + type, + constrained, + constrainExpiration, + serviceId, + providerId, + ]; + + factory EntertainmentServiceModel.fromMap(Map map) { + return EntertainmentServiceModel( + id: map['id'], + createdAt: map['created_at'] != null + ? DateTime.parse(map['created_at']) + : null, + type: map['type'], + constrained: map['constrained'] ?? false, + constrainExpiration: DateTime.parse(map['constrain_expiration']), + serviceId: map['service_id'], + providerId: map['provider_id'], + ); + } + + Map toMap() { + return { + if (id != null) 'id': id, + 'type': type, + 'constrained': constrained, + 'constrain_expiration': constrainExpiration.toIso8601String(), + 'service_id': serviceId, + 'provider_id': providerId, + }; + } +} diff --git a/lib/features/services/models/fin_service_model.dart b/lib/features/services/models/fin_service_model.dart new file mode 100644 index 0000000..9cdaa5a --- /dev/null +++ b/lib/features/services/models/fin_service_model.dart @@ -0,0 +1,63 @@ +import 'package:equatable/equatable.dart'; + +class FinServiceModel extends Equatable { + final String? id; + final DateTime? createdAt; + final DateTime expiration; + final String? serviceId; + final String? modelId; // FK verso model (es. iPhone, Samsung, ecc.) + final String? providerId; + + const FinServiceModel({ + this.id, + this.createdAt, + required this.expiration, + this.serviceId, + this.modelId, + this.providerId, + }); + + FinServiceModel copyWith({ + String? id, + DateTime? createdAt, + DateTime? expiration, + String? serviceId, + String? modelId, + String? providerId, + }) { + return FinServiceModel( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + expiration: expiration ?? this.expiration, + serviceId: serviceId ?? this.serviceId, + modelId: modelId ?? this.modelId, + providerId: providerId ?? this.providerId, + ); + } + + @override + List get props => [id, createdAt, expiration, serviceId, modelId]; + + factory FinServiceModel.fromMap(Map map) { + return FinServiceModel( + id: map['id'], + createdAt: map['created_at'] != null + ? DateTime.parse(map['created_at']) + : null, + expiration: DateTime.parse(map['expiration']), + serviceId: map['service_id'], + modelId: map['model_id'], + providerId: map['provider_id'], + ); + } + + Map toMap() { + return { + if (id != null) 'id': id, + 'expiration': expiration.toIso8601String(), + 'service_id': serviceId, + 'model_id': modelId, + 'provider_id': providerId, + }; + } +} diff --git a/lib/features/services/models/service_model.dart b/lib/features/services/models/service_model.dart new file mode 100644 index 0000000..5bbfc10 --- /dev/null +++ b/lib/features/services/models/service_model.dart @@ -0,0 +1,173 @@ +import 'package:equatable/equatable.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'; + +class ServiceModel extends Equatable { + final String? id; + final DateTime? createdAt; + final String storeId; + final String? employeeId; + final String? customerId; + final String number; + final bool isBozza; + final String note; + final bool resultOk; + final String? customerDisplayName; + + // Telefonia + final int al; + final int mnp; + final int nip; + final int unica; + final int telepass; + + // Moduli (Liste) + final List energyServices; + final List finServices; + final List entertainmentServices; + + const ServiceModel({ + this.id, + this.createdAt, + required this.storeId, + this.employeeId, + this.customerId, + required this.number, + this.isBozza = true, + this.note = '', + this.resultOk = true, + this.al = 0, + this.mnp = 0, + this.nip = 0, + this.unica = 0, + this.telepass = 0, + this.energyServices = const [], + this.finServices = const [], + this.entertainmentServices = const [], + this.customerDisplayName, + }); + + ServiceModel copyWith({ + String? id, + DateTime? createdAt, + String? storeId, + String? employeeId, + String? customerId, + String? number, + bool? isBozza, + String? note, + bool? resultOk, + int? al, + int? mnp, + int? nip, + int? unica, + int? telepass, + List? energyServices, + List? finServices, + List? entertainmentServices, + String? customerDisplayName, + }) { + return ServiceModel( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + storeId: storeId ?? this.storeId, + employeeId: employeeId ?? this.employeeId, + customerId: customerId ?? this.customerId, + number: number ?? this.number, + isBozza: isBozza ?? this.isBozza, + note: note ?? this.note, + resultOk: resultOk ?? this.resultOk, + al: al ?? this.al, + mnp: mnp ?? this.mnp, + nip: nip ?? this.nip, + unica: unica ?? this.unica, + telepass: telepass ?? this.telepass, + energyServices: energyServices ?? this.energyServices, + finServices: finServices ?? this.finServices, + entertainmentServices: + entertainmentServices ?? this.entertainmentServices, + customerDisplayName: customerDisplayName ?? this.customerDisplayName, + ); + } + + @override + List get props => [ + id, + createdAt, + storeId, + employeeId, + customerId, + number, + isBozza, + note, + resultOk, + al, + mnp, + nip, + unica, + telepass, + energyServices, + finServices, + entertainmentServices, + customerDisplayName, + ]; + + 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'] ?? '', + isBozza: map['bozza'] ?? true, + note: map['note'] ?? '', + resultOk: map['result_ok'] ?? true, + al: map['al'] ?? 0, + mnp: map['mnp'] ?? 0, + nip: map['nip'] ?? 0, + unica: map['unica'] ?? 0, + telepass: map['telepass'] ?? 0, + + // Mappaggio delle liste collegate (se incluse nella query) + energyServices: + (map['energy_service'] as List?) + ?.map((x) => EnergyServiceModel.fromMap(x)) + .toList() ?? + const [], + finServices: + (map['fin_service'] as List?) + ?.map((x) => FinServiceModel.fromMap(x)) + .toList() ?? + const [], + entertainmentServices: + (map['entertainment_service'] as List?) + ?.map((x) => EntertainmentServiceModel.fromMap(x)) + .toList() ?? + const [], + customerDisplayName: map['customer'] != null + ? "${map['customer']['name']} ${map['customer']['surname']}" + : "Cliente sconosciuto", + ); + } + + Map toMap() { + return { + if (id != null) 'id': id, + 'store_id': storeId, + 'employee_id': employeeId, + 'customer_id': customerId, + 'number': number, + 'bozza': isBozza, + 'note': note, + 'result_ok': resultOk, + 'al': al, + 'mnp': mnp, + 'nip': nip, + 'unica': unica, + 'telepass': telepass, + // Le liste non le mettiamo qui perché vanno in tabelle diverse! + }; + } +} diff --git a/lib/features/services/ui/service_form_screen.dart b/lib/features/services/ui/service_form_screen.dart new file mode 100644 index 0000000..d63599c --- /dev/null +++ b/lib/features/services/ui/service_form_screen.dart @@ -0,0 +1,150 @@ +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/services_screen.dart b/lib/features/services/ui/services_screen.dart new file mode 100644 index 0000000..aea5315 --- /dev/null +++ b/lib/features/services/ui/services_screen.dart @@ -0,0 +1,197 @@ +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:go_router/go_router.dart'; +// Importa i tuoi modelli e cubit + +class ServicesScreen extends StatefulWidget { + const ServicesScreen({super.key}); + + @override + State createState() => _ServicesScreenState(); +} + +class _ServicesScreenState extends State { + final ScrollController _scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + // Agganciamo il listener per la paginazione (Scroll Infinito) + _scrollController.addListener(_onScroll); + } + + void _onScroll() { + if (_isBottom) { + context.read().loadServices(); + } + } + + bool get _isBottom { + if (!_scrollController.hasClients) return false; + final maxScroll = _scrollController.position.maxScrollExtent; + final currentScroll = _scrollController.offset; + // Carica quando mancano 200px alla fine + return currentScroll >= (maxScroll * 0.9); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text("Gestione Servizi"), + elevation: 0, + actions: [ + IconButton( + icon: const Icon(Icons.search), + onPressed: () { + // Qui potrai implementare una barra di ricerca + }, + ), + ], + ), + body: BlocBuilder( + builder: (context, state) { + // 1. Stato di caricamento iniziale + if (state.isLoading && state.allServices.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + + // 2. Lista vuota + if (state.allServices.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text("Nessuna pratica trovata."), + const SizedBox(height: 10), + ElevatedButton( + onPressed: () => context.read().loadServices( + refresh: true, + ), + child: const Text("Riprova"), + ), + ], + ), + ); + } + + // 3. La Lista (con Pull-to-refresh) + return RefreshIndicator( + onRefresh: () => + context.read().loadServices(refresh: true), + child: ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.only(bottom: 80), // Spazio per il FAB + itemCount: state.hasReachedMax + ? state.allServices.length + : state.allServices.length + 1, + itemBuilder: (context, index) { + if (index >= state.allServices.length) { + return const Center( + child: Padding( + padding: EdgeInsets.all(16.0), + child: CircularProgressIndicator(strokeWidth: 2), + ), + ); + } + + final service = state.allServices[index]; + return _buildServiceCard(context, service); + }, + ), + ); + }, + ), + floatingActionButton: FloatingActionButton( + onPressed: () => context.pushNamed('service-form'), // GoRouter + child: const Icon(Icons.add), + ), + ); + } + + Widget _buildServiceCard(BuildContext context, ServiceModel service) { + return Card( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + elevation: 2, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: ListTile( + contentPadding: const EdgeInsets.all(12), + title: Row( + children: [ + Expanded( + child: Text( + service.customerDisplayName ?? "Cliente sconosciuto", + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + ), + if (service.isBozza) + const Chip( + label: Text( + "BOZZA", + style: TextStyle(fontSize: 10, color: Colors.white), + ), + backgroundColor: Colors.orange, + visualDensity: VisualDensity.compact, + ), + ], + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 4), + Text( + "Pratica: ${service.number} • ${service.createdAt?.day}/${service.createdAt?.month}/${service.createdAt?.year}", + ), + const SizedBox(height: 8), + // I nostri mini-chip per i servizi attivati + Wrap( + spacing: 6, + children: [ + if (service.al > 0 || service.mnp > 0) + _miniBadge("📞 Tel", Colors.blue), + if (service.energyServices.isNotEmpty) + _miniBadge("⚡ Energy", Colors.green), + if (service.finServices.isNotEmpty) + _miniBadge("💰 Fin", Colors.purple), + if (service.entertainmentServices.isNotEmpty) + _miniBadge("📺 Ent", Colors.red), + ], + ), + ], + ), + trailing: const Icon(Icons.chevron_right), + onTap: () => context.pushNamed('service-form', extra: service), + ), + ); + } + + Widget _miniBadge(String text, Color color) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: color.withValues(alpha: 0.5)), + ), + child: Text( + text, + style: TextStyle( + color: color, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index fa4d3c4..147acc4 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,3 +1,5 @@ +import 'dart:developer'; + import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; @@ -16,6 +18,8 @@ import 'package:flux/features/master_data/staff/blocs/staff_cubit.dart'; import 'package:flux/features/master_data/staff/data/staff_repository.dart'; import 'package:flux/features/master_data/store/bloc/store_cubit.dart'; import 'package:flux/features/master_data/store/data/store_repository.dart'; +import 'package:flux/features/services/blocs/services_cubit.dart'; +import 'package:flux/features/services/data/services_repository.dart'; import 'package:flux/features/settings/settings.dart'; import 'package:get_it/get_it.dart'; import 'package:go_router/go_router.dart'; @@ -59,6 +63,7 @@ Future setupLocator() async { getIt.registerLazySingleton(() => CustomerRepository()); getIt.registerLazySingleton(() => ProductRepository()); getIt.registerLazySingleton(() => StaffRepository()); + getIt.registerLazySingleton(() => ServicesRepository()); } class FluxApp extends StatefulWidget { @@ -95,6 +100,9 @@ class _FluxAppState extends State { create: (_) => StaffCubit(context.read())..loadAllStaff(), ), + BlocProvider( + create: (_) => ServicesCubit(context.read()), + ), ], child: BlocBuilder( builder: (context, state) {