From 541000390e509fd345b25f2797e89932f48cebff Mon Sep 17 00:00:00 2001 From: Mark M2 Macbook Date: Tue, 14 Apr 2026 11:29:49 +0200 Subject: [PATCH] ripristinato codice stabile da branch staff --- lib/core/blocs/session/session_bloc.dart | 2 +- lib/core/routes/app_router.dart | 4 +- lib/core/widgets/flux_text_field.dart | 6 +- lib/features/home/ui/home_screen.dart | 2 +- .../master_data/master_data_hub_content.dart | 115 +++++++ .../products/blocs/product_cubit.dart | 105 ++++++ .../products/blocs/product_state.dart | 44 +++ .../products/data/product_repository.dart | 86 +++++ .../products/models/brand_model.dart | 58 ++++ .../products/models/model_model.dart | 70 ++++ .../products/ui/brand_selector.dart | 58 ++++ .../master_data/products/ui/models_list.dart | 79 +++++ .../products/ui/product_dialogs.dart | 110 ++++++ .../products/ui/products_screen.dart | 74 ++++ .../products/ui/round_action_button.dart | 23 ++ .../master_data/staff/blocs/staff_cubit.dart | 136 ++++++++ .../master_data/staff/blocs/staff_state.dart | 42 +++ .../staff/data/staff_repository.dart | 90 +++++ .../staff/models/staff_member_model.dart | 47 +++ .../master_data/staff/ui/staff_screen.dart | 315 ++++++++++++++++++ .../master_data/store/bloc/store_bloc.dart | 56 ++++ .../master_data/store/bloc/store_events.dart | 18 + .../master_data/store/bloc/store_state.dart | 34 ++ .../store/data/store_repository.dart | 36 ++ .../master_data/store/models/store_model.dart | 102 ++++++ .../store/ui/create_store_screen.dart | 253 ++++++++++++++ .../master_data/store/ui/stores_screen.dart | 296 ++++++++++++++++ lib/main.dart | 12 +- 28 files changed, 2260 insertions(+), 13 deletions(-) create mode 100644 lib/features/master_data/master_data_hub_content.dart create mode 100644 lib/features/master_data/products/blocs/product_cubit.dart create mode 100644 lib/features/master_data/products/blocs/product_state.dart create mode 100644 lib/features/master_data/products/data/product_repository.dart create mode 100644 lib/features/master_data/products/models/brand_model.dart create mode 100644 lib/features/master_data/products/models/model_model.dart create mode 100644 lib/features/master_data/products/ui/brand_selector.dart create mode 100644 lib/features/master_data/products/ui/models_list.dart create mode 100644 lib/features/master_data/products/ui/product_dialogs.dart create mode 100644 lib/features/master_data/products/ui/products_screen.dart create mode 100644 lib/features/master_data/products/ui/round_action_button.dart create mode 100644 lib/features/master_data/staff/blocs/staff_cubit.dart create mode 100644 lib/features/master_data/staff/blocs/staff_state.dart create mode 100644 lib/features/master_data/staff/data/staff_repository.dart create mode 100644 lib/features/master_data/staff/models/staff_member_model.dart create mode 100644 lib/features/master_data/staff/ui/staff_screen.dart create mode 100644 lib/features/master_data/store/bloc/store_bloc.dart create mode 100644 lib/features/master_data/store/bloc/store_events.dart create mode 100644 lib/features/master_data/store/bloc/store_state.dart create mode 100644 lib/features/master_data/store/data/store_repository.dart create mode 100644 lib/features/master_data/store/models/store_model.dart create mode 100644 lib/features/master_data/store/ui/create_store_screen.dart create mode 100644 lib/features/master_data/store/ui/stores_screen.dart diff --git a/lib/core/blocs/session/session_bloc.dart b/lib/core/blocs/session/session_bloc.dart index 08ccaca..e4eea39 100644 --- a/lib/core/blocs/session/session_bloc.dart +++ b/lib/core/blocs/session/session_bloc.dart @@ -2,7 +2,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:equatable/equatable.dart'; import 'package:flux/core/enums/enums.dart'; import 'package:flux/features/company/models/company_model.dart'; -import 'package:flux/features/store/models/store_model.dart'; +import 'package:flux/features/master_data/store/models/store_model.dart'; import 'package:get_it/get_it.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'dart:async'; diff --git a/lib/core/routes/app_router.dart b/lib/core/routes/app_router.dart index 3400bf6..8af21d0 100644 --- a/lib/core/routes/app_router.dart +++ b/lib/core/routes/app_router.dart @@ -5,8 +5,8 @@ import 'package:flux/features/company/ui/create_company_screen.dart'; import 'package:flux/features/customers/models/customer_model.dart'; import 'package:flux/features/customers/ui/customer_detail_screen.dart'; import 'package:flux/features/home/ui/home_screen.dart'; -import 'package:flux/features/products/ui/products_screen.dart'; -import 'package:flux/features/store/ui/create_store_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:go_router/go_router.dart'; import 'dart:async'; diff --git a/lib/core/widgets/flux_text_field.dart b/lib/core/widgets/flux_text_field.dart index 67b0204..8a442bb 100644 --- a/lib/core/widgets/flux_text_field.dart +++ b/lib/core/widgets/flux_text_field.dart @@ -12,8 +12,8 @@ class FluxTextField extends StatelessWidget { final int? minLines; final int? maxLines; final Function(String)? onSubmitted; - final int? maxLenght; final Function(String)? onChanged; + final int? maxLength; const FluxTextField({ super.key, // Usiamo super.key per Flutter moderno @@ -26,8 +26,8 @@ class FluxTextField extends StatelessWidget { this.minLines, this.maxLines = 1, this.onSubmitted, - this.maxLenght, this.onChanged, + this.maxLength, }); @override @@ -64,7 +64,7 @@ class FluxTextField extends StatelessWidget { ), onSubmitted: onSubmitted, onChanged: onChanged, - maxLength: maxLenght, + maxLength: maxLength, ); } } diff --git a/lib/features/home/ui/home_screen.dart b/lib/features/home/ui/home_screen.dart index 6caecec..10f5e05 100644 --- a/lib/features/home/ui/home_screen.dart +++ b/lib/features/home/ui/home_screen.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; 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/anagrafiche/master_data_hub_content.dart'; +import 'package:flux/features/master_data/master_data_hub_content.dart'; import 'dashboard_content.dart'; // Importiamo il contenuto della dashboard class HomeScreen extends StatefulWidget { diff --git a/lib/features/master_data/master_data_hub_content.dart b/lib/features/master_data/master_data_hub_content.dart new file mode 100644 index 0000000..a55d2ac --- /dev/null +++ b/lib/features/master_data/master_data_hub_content.dart @@ -0,0 +1,115 @@ +import 'package:flutter/material.dart'; +import 'package:flux/core/theme/theme.dart'; +import 'package:flux/features/customers/ui/customers_content.dart'; +import 'package:flux/features/master_data/products/ui/products_screen.dart'; +import 'package:flux/features/master_data/staff/ui/staff_screen.dart'; +import 'package:flux/features/master_data/store/ui/stores_screen.dart'; + +class MasterDataHubContent extends StatelessWidget { + final Function(Widget) onOpenPage; + + const MasterDataHubContent({super.key, required this.onOpenPage}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Anagrafiche", + style: TextStyle( + fontSize: 28, + fontWeight: FontWeight.bold, + color: context.accent, + ), + ), + const SizedBox(height: 8), + Text( + "Gestisci i dati fondamentali del tuo business", + style: TextStyle(color: context.secondaryText), + ), + const SizedBox(height: 32), + + Expanded( + child: GridView.count( + crossAxisCount: MediaQuery.of(context).size.width > 600 ? 3 : 2, + mainAxisSpacing: 16, + crossAxisSpacing: 16, + children: [ + _HubCard( + label: 'Prodotti', + icon: Icons.inventory_2_outlined, + color: Colors.blue, + onTap: () => onOpenPage( + const ProductsScreen(), + ), // Apre ProductsScreen, // Indice per ProductsScreen + ), + _HubCard( + label: 'Clienti', + icon: Icons.people_outlined, + color: Colors.orange, + onTap: () => onOpenPage( + const CustomersContent(), + ), // Indice per CustomersContent + ), + _HubCard( + label: 'Commessi', + icon: Icons.badge_outlined, + color: Colors.teal, + onTap: () => onOpenPage(const StaffScreen()), + ), + _HubCard( + label: 'Negozi', + icon: Icons.storefront_outlined, + color: Colors.purple, + onTap: () => onOpenPage(const StoresScreen()), + ), + ], + ), + ), + ], + ), + ); + } +} + +// Widget semplice per le card dell'hub +class _HubCard extends StatelessWidget { + final String label; + final IconData icon; + final Color color; + final VoidCallback onTap; + + const _HubCard({ + required this.label, + required this.icon, + required this.color, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Card( + elevation: 0, + color: context.background, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide(color: context.accent.withValues(alpha: 0.1)), + ), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(16), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, color: color, size: 40), + const SizedBox(height: 12), + Text(label, style: const TextStyle(fontWeight: FontWeight.bold)), + ], + ), + ), + ); + } +} diff --git a/lib/features/master_data/products/blocs/product_cubit.dart b/lib/features/master_data/products/blocs/product_cubit.dart new file mode 100644 index 0000000..d469b75 --- /dev/null +++ b/lib/features/master_data/products/blocs/product_cubit.dart @@ -0,0 +1,105 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flux/core/blocs/session/session_bloc.dart'; +import 'package:flux/features/master_data/products/data/product_repository.dart'; +import 'package:flux/features/master_data/products/models/brand_model.dart'; +import 'package:flux/features/master_data/products/models/model_model.dart'; +import 'package:get_it/get_it.dart'; + +part 'product_state.dart'; + +class ProductCubit extends Cubit { + final ProductRepository _repository = GetIt.I(); + final SessionBloc _sessionBloc; + + ProductCubit(this._sessionBloc) : super(const ProductState()); + + // Caricamento iniziale dei Brand + Future loadBrands() async { + emit(state.copyWith(status: ProductStatus.loading)); + try { + final brands = await _repository.getBrands( + _sessionBloc.state.company!.id, + ); + emit(state.copyWith(status: ProductStatus.success, brands: brands)); + } catch (e) { + emit( + state.copyWith(status: ProductStatus.error, errorMessage: e.toString()), + ); + } + } + + // Selezione Brand e caricamento Modelli + Future selectBrand(BrandModel? brand) async { + if (brand == null) { + emit(state.copyWith(selectedBrand: null, models: [])); + return; + } + + emit(state.copyWith(status: ProductStatus.loading, selectedBrand: brand)); + try { + final models = await _repository.getModelsByBrand(brand.id!); + emit(state.copyWith(status: ProductStatus.success, models: models)); + } catch (e) { + emit( + state.copyWith(status: ProductStatus.error, errorMessage: e.toString()), + ); + } + } + + // Aggiungi/Modifica Brand + Future saveBrand(String name, {String? id}) async { + try { + final brand = BrandModel( + id: id, + name: name, + companyId: _sessionBloc.state.company!.id, + ); + final newBrand = await _repository.upsertBrand(brand); + await loadBrands(); // Ricarichiamo la lista aggiornata + selectBrand(newBrand); + } catch (e) { + emit( + state.copyWith(status: ProductStatus.error, errorMessage: e.toString()), + ); + } + } + + // Aggiungi/Modifica Modello + Future saveModel(String name, {String? id}) async { + if (state.selectedBrand == null) return; + + try { + final model = ModelModel( + id: id, + name: name, + brandId: state.selectedBrand!.id!, + nameWithBrand: '', // Gestito dal trigger SQL + ); + await _repository.upsertModel(model); + await selectBrand( + state.selectedBrand, + ); // Ricarichiamo i modelli del brand + } catch (e) { + emit( + state.copyWith(status: ProductStatus.error, errorMessage: e.toString()), + ); + } + } + + // Disattivazione (Soft Delete) + Future toggleStatus(String table, String id, bool currentStatus) async { + try { + await _repository.toggleActiveStatus(table, id, !currentStatus); + if (table == 'brand') { + await loadBrands(); + } else { + await selectBrand(state.selectedBrand); + } + } catch (e) { + emit( + state.copyWith(status: ProductStatus.error, errorMessage: e.toString()), + ); + } + } +} diff --git a/lib/features/master_data/products/blocs/product_state.dart b/lib/features/master_data/products/blocs/product_state.dart new file mode 100644 index 0000000..8d5ab94 --- /dev/null +++ b/lib/features/master_data/products/blocs/product_state.dart @@ -0,0 +1,44 @@ +part of 'product_cubit.dart'; + +enum ProductStatus { initial, loading, success, error } + +class ProductState extends Equatable { + final ProductStatus status; + final List brands; + final List models; + final BrandModel? selectedBrand; // Il brand attualmente selezionato + final String? errorMessage; + + const ProductState({ + this.status = ProductStatus.initial, + this.brands = const [], + this.models = const [], + this.selectedBrand, + this.errorMessage, + }); + + ProductState copyWith({ + ProductStatus? status, + List? brands, + List? models, + BrandModel? selectedBrand, + String? errorMessage, + }) { + return ProductState( + status: status ?? this.status, + brands: brands ?? this.brands, + models: models ?? this.models, + selectedBrand: selectedBrand ?? this.selectedBrand, + errorMessage: errorMessage ?? this.errorMessage, + ); + } + + @override + List get props => [ + status, + brands, + models, + selectedBrand, + errorMessage, + ]; +} diff --git a/lib/features/master_data/products/data/product_repository.dart b/lib/features/master_data/products/data/product_repository.dart new file mode 100644 index 0000000..874854f --- /dev/null +++ b/lib/features/master_data/products/data/product_repository.dart @@ -0,0 +1,86 @@ +import 'package:get_it/get_it.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import '../models/brand_model.dart'; +import '../models/model_model.dart'; + +class ProductRepository { + final SupabaseClient _client = GetIt.I(); + + // --- BRAND --- + + /// Recupera tutti i brand dell'azienda + Future> getBrands(String companyId) async { + try { + final response = await _client + .from('brand') + .select() + .eq('company_id', companyId) + .eq('is_active', true) + .order('name'); + + return (response as List).map((b) => BrandModel.fromJson(b)).toList(); + } catch (e) { + throw 'Errore nel recupero dei Brand'; + } + } + + /// Crea o aggiorna un brand + Future upsertBrand(BrandModel brand) async { + try { + final response = await _client + .from('brand') + .upsert(brand.toJson()) + .select() + .single(); + + return BrandModel.fromJson(response); + } catch (e) { + throw 'Errore nel salvataggio del Brand'; + } + } + + // --- MODEL --- + + /// Recupera i modelli di un brand specifico + Future> getModelsByBrand(String brandId) async { + try { + final response = await _client + .from('model') + .select() + .eq('brand_id', brandId) + .eq('is_active', true) + .order('name'); + + return (response as List).map((m) => ModelModel.fromJson(m)).toList(); + } catch (e) { + throw 'Errore nel recupero dei modelli'; + } + } + + /// Crea o aggiorna un modello + /// NOTA: name_with_brand verrà gestito dal trigger SQL che hai lanciato! + Future upsertModel(ModelModel model) async { + try { + final response = await _client + .from('model') + .upsert(model.toJson()) + .select() + .single(); + + return ModelModel.fromJson(response); + } catch (e) { + throw 'Errore nel salvataggio del modello'; + } + } + + // --- DELETE (LOGICA) --- + + /// Disattiva un brand o un modello (Soft Delete per non rompere le FK delle operazioni passate) + Future toggleActiveStatus(String table, String id, bool status) async { + try { + await _client.from(table).update({'is_active': status}).eq('id', id); + } catch (e) { + throw 'Errore durante la modifica dello stato'; + } + } +} diff --git a/lib/features/master_data/products/models/brand_model.dart b/lib/features/master_data/products/models/brand_model.dart new file mode 100644 index 0000000..72e02b1 --- /dev/null +++ b/lib/features/master_data/products/models/brand_model.dart @@ -0,0 +1,58 @@ +import 'package:equatable/equatable.dart'; +import 'package:flux/core/utils/string_extensions.dart'; + +class BrandModel extends Equatable { + final String? id; + final String name; + final String companyId; + final bool isActive; + final DateTime? createdAt; + + const BrandModel({ + this.id, + required this.name, + required this.companyId, + this.isActive = true, + this.createdAt, + }); + + factory BrandModel.fromJson(Map json) { + return BrandModel( + id: json['id'] as String, + name: (json['name'] as String).myFormat(), + companyId: json['company_id'] as String, + isActive: json['is_active'] as bool? ?? true, + createdAt: json['created_at'] != null + ? DateTime.parse(json['created_at']) + : null, + ); + } + + Map toJson() { + return { + if (id != null) 'id': id, + 'name': name.toLowerCase().trim(), + 'company_id': companyId, + 'is_active': isActive, + }; + } + + BrandModel copyWith({ + String? id, + String? name, + String? companyId, + bool? isActive, + DateTime? createdAt, + }) { + return BrandModel( + id: id ?? this.id, + name: name ?? this.name, + companyId: companyId ?? this.companyId, + isActive: isActive ?? this.isActive, + createdAt: createdAt ?? this.createdAt, + ); + } + + @override + List get props => [id, name, companyId, isActive, createdAt]; +} diff --git a/lib/features/master_data/products/models/model_model.dart b/lib/features/master_data/products/models/model_model.dart new file mode 100644 index 0000000..31aa504 --- /dev/null +++ b/lib/features/master_data/products/models/model_model.dart @@ -0,0 +1,70 @@ +import 'package:equatable/equatable.dart'; +import 'package:flux/core/utils/string_extensions.dart'; + +class ModelModel extends Equatable { + final String? id; + final String name; + final String nameWithBrand; + final String brandId; + final bool isActive; + final DateTime? createdAt; + + const ModelModel({ + this.id, + required this.name, + required this.nameWithBrand, + required this.brandId, + this.isActive = true, + this.createdAt, + }); + + factory ModelModel.fromJson(Map json) { + return ModelModel( + id: json['id'] as String, + name: (json['name'] as String).myFormat(), + nameWithBrand: (json['name_with_brand'] as String).myFormat(), + brandId: json['brand_id'] as String, + isActive: json['is_active'] as bool? ?? true, + createdAt: json['created_at'] != null + ? DateTime.parse(json['created_at']) + : null, + ); + } + + Map toJson() { + return { + if (id != null) 'id': id, + 'name': name.toLowerCase().trim(), + 'brand_id': brandId, + 'is_active': isActive, + }; + } + + ModelModel copyWith({ + String? id, + String? name, + String? nameWithBrand, + String? brandId, + bool? isActive, + DateTime? createdAt, + }) { + return ModelModel( + id: id ?? this.id, + name: name ?? this.name, + nameWithBrand: nameWithBrand ?? this.nameWithBrand, + brandId: brandId ?? this.brandId, + isActive: isActive ?? this.isActive, + createdAt: createdAt ?? this.createdAt, + ); + } + + @override + List get props => [ + id, + name, + nameWithBrand, + brandId, + isActive, + createdAt, + ]; +} diff --git a/lib/features/master_data/products/ui/brand_selector.dart b/lib/features/master_data/products/ui/brand_selector.dart new file mode 100644 index 0000000..857bddc --- /dev/null +++ b/lib/features/master_data/products/ui/brand_selector.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flux/core/theme/theme.dart'; +import 'package:flux/features/master_data/products/blocs/product_cubit.dart'; +import 'package:flux/features/master_data/products/models/brand_model.dart'; +import 'package:flux/features/master_data/products/ui/product_dialogs.dart'; +import 'package:flux/features/master_data/products/ui/round_action_button.dart'; + +class BrandSelector extends StatelessWidget { + final ProductState state; + const BrandSelector(this.state, {super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: context.background, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: context.accent.withValues(alpha: 0.1)), + ), + child: Row( + children: [ + Expanded( + child: DropdownButtonFormField( + initialValue: state.selectedBrand, + //value: state.selectedBrand, + decoration: const InputDecoration( + labelText: "Seleziona Brand", + prefixIcon: Icon(Icons.branding_watermark_outlined), + ), + items: state.brands.map((brand) { + return DropdownMenuItem(value: brand, child: Text(brand.name)); + }).toList(), + onChanged: (brand) => + context.read().selectBrand(brand), + ), + ), + const SizedBox(width: 16), + // Pulsanti rapidi Brand + RoundActionButton( + icon: Icons.add, + onTap: () => showBrandDialog(context), + tooltip: "Nuovo Brand", + ), + if (state.selectedBrand != null) ...[ + const SizedBox(width: 8), + RoundActionButton( + icon: Icons.edit_outlined, + onTap: () => showBrandDialog(context, brand: state.selectedBrand), + tooltip: "Modifica Brand", + ), + ], + ], + ), + ); + } +} diff --git a/lib/features/master_data/products/ui/models_list.dart b/lib/features/master_data/products/ui/models_list.dart new file mode 100644 index 0000000..f44e6a5 --- /dev/null +++ b/lib/features/master_data/products/ui/models_list.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flux/core/theme/theme.dart'; +import 'package:flux/features/master_data/products/blocs/product_cubit.dart'; +import 'package:flux/features/master_data/products/ui/product_dialogs.dart'; + +class ModelsList extends StatelessWidget { + final ProductState state; + const ModelsList(this.state, {super.key}); + + @override + Widget build(BuildContext context) { + if (state.selectedBrand == null) { + return const Center( + child: Text("Seleziona un brand per gestire i modelli"), + ); + } + + if (state.status == ProductStatus.loading && state.models.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Modelli di ${state.selectedBrand!.name}", + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + ElevatedButton.icon( + onPressed: () => showModelDialog(context), + icon: const Icon(Icons.add), + label: const Text("NUOVO MODELLO"), + ), + ], + ), + const SizedBox(height: 16), + Expanded( + child: ListView.separated( + itemCount: state.models.length, + separatorBuilder: (_, _) => const Divider(height: 1), + itemBuilder: (context, index) { + final model = state.models[index]; + return ListTile( + title: Text(model.name), + subtitle: Text( + model.nameWithBrand, + ), // Quello gestito dal trigger! + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.edit_outlined), + onPressed: () => showModelDialog(context, model: model), + ), + IconButton( + icon: Icon( + model.isActive + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + color: model.isActive ? context.accent : Colors.grey, + ), + onPressed: () => context + .read() + .toggleStatus('model', model.id!, model.isActive), + ), + ], + ), + ); + }, + ), + ), + ], + ); + } +} diff --git a/lib/features/master_data/products/ui/product_dialogs.dart b/lib/features/master_data/products/ui/product_dialogs.dart new file mode 100644 index 0000000..3560d0c --- /dev/null +++ b/lib/features/master_data/products/ui/product_dialogs.dart @@ -0,0 +1,110 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flux/features/master_data/products/blocs/product_cubit.dart'; +import 'package:flux/features/master_data/products/models/brand_model.dart'; +import 'package:flux/features/master_data/products/models/model_model.dart'; + +void showBrandDialog(BuildContext context, {BrandModel? brand}) { + final controller = TextEditingController(text: brand?.name); + + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(brand == null ? "Nuovo Brand" : "Modifica Brand"), + content: TextField( + controller: controller, + autofocus: true, + decoration: const InputDecoration( + labelText: "Nome Brand", + hintText: "es. Apple, Samsung...", + ), + onSubmitted: (_) => _submitBrand(controller, context, brand), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text("Annulla"), + ), + ElevatedButton( + onPressed: () => _submitBrand(controller, context, brand), + child: const Text("Salva"), + ), + ], + ), + ); +} + +void _submitBrand( + TextEditingController controller, + BuildContext context, + BrandModel? brand, +) { + if (controller.text.trim().isNotEmpty) { + context.read().saveBrand(controller.text, id: brand?.id); + Navigator.pop(context); + } +} + +void showModelDialog(BuildContext context, {ModelModel? model}) { + final controller = TextEditingController(text: model?.name); + + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(model == null ? "Nuovo Modello" : "Modifica Modello"), + content: TextField( + controller: controller, + autofocus: true, + decoration: const InputDecoration( + labelText: "Nome Modello", + hintText: "es. iPhone 15, Galaxy S24...", + ), + onSubmitted: (_) => _submitModel(controller, context, model), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text("Annulla"), + ), + ElevatedButton( + onPressed: () => _submitModel(controller, context, model), + child: const Text("Salva"), + ), + ], + ), + ); +} + +void _submitModel( + TextEditingController controller, + BuildContext context, + ModelModel? model, +) { + if (controller.text.isNotEmpty) { + context.read().saveModel(controller.text, id: model?.id); + Navigator.pop(context); + } +} + +void confirmToggle(BuildContext context, String title, VoidCallback onConfirm) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text("Sei sicuro?"), + content: Text("Stai per cambiare lo stato di: $title"), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text("Annulla"), + ), + TextButton( + onPressed: () { + onConfirm(); + Navigator.pop(context); + }, + child: const Text("Conferma", style: TextStyle(color: Colors.red)), + ), + ], + ), + ); +} diff --git a/lib/features/master_data/products/ui/products_screen.dart b/lib/features/master_data/products/ui/products_screen.dart new file mode 100644 index 0000000..28ffeab --- /dev/null +++ b/lib/features/master_data/products/ui/products_screen.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flux/core/theme/theme.dart'; +import 'package:flux/features/master_data/products/blocs/product_cubit.dart'; +import 'package:flux/features/master_data/products/ui/brand_selector.dart'; +import 'package:flux/features/master_data/products/ui/models_list.dart'; +import 'package:go_router/go_router.dart'; + +class ProductsScreen extends StatelessWidget { + const ProductsScreen({super.key}); + + @override + Widget build(BuildContext context) { + // Carichiamo i brand appena la pagina viene creata + context.read().loadBrands(); + + return Scaffold( + backgroundColor: context.background, + appBar: AppBar( + backgroundColor: context.background, + elevation: 0, + centerTitle: false, + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => context.pop(), // Torna alla Dashboard + ), + title: Text( + "Anagrafica Prodotti", + style: TextStyle( + color: context.primaryText, + fontWeight: FontWeight.bold, + fontSize: 20, + ), + ), + ), + body: BlocConsumer( + listener: (context, state) { + if (state.status == ProductStatus.error) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(state.errorMessage ?? 'Errore')), + ); + } + }, + builder: (context, state) { + return Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Marche e Modelli", + style: TextStyle( + fontSize: 28, + fontWeight: FontWeight.bold, + color: context.accent, + ), + ), + const SizedBox(height: 24), + + // SEZIONE BRAND + BrandSelector(state), + + const SizedBox(height: 32), + + // SEZIONE MODELLI + Expanded(child: ModelsList(state)), + ], + ), + ); + }, + ), + ); + } +} diff --git a/lib/features/master_data/products/ui/round_action_button.dart b/lib/features/master_data/products/ui/round_action_button.dart new file mode 100644 index 0000000..b174be0 --- /dev/null +++ b/lib/features/master_data/products/ui/round_action_button.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; + +class RoundActionButton extends StatelessWidget { + final IconData icon; + final VoidCallback onTap; + final String tooltip; + + const RoundActionButton({ + super.key, + required this.icon, + required this.onTap, + required this.tooltip, + }); + + @override + Widget build(BuildContext context) { + return IconButton.filledTonal( + onPressed: onTap, + icon: Icon(icon), + tooltip: tooltip, + ); + } +} diff --git a/lib/features/master_data/staff/blocs/staff_cubit.dart b/lib/features/master_data/staff/blocs/staff_cubit.dart new file mode 100644 index 0000000..c1bff15 --- /dev/null +++ b/lib/features/master_data/staff/blocs/staff_cubit.dart @@ -0,0 +1,136 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flux/core/blocs/session/session_bloc.dart'; +import 'package:flux/features/master_data/staff/data/staff_repository.dart'; +import 'package:flux/features/master_data/staff/models/staff_member_model.dart'; +import 'package:flux/features/master_data/store/models/store_model.dart'; +import 'package:get_it/get_it.dart'; + +part 'staff_state.dart'; + +class StaffCubit extends Cubit { + final StaffRepository _repository = GetIt.I.get(); + final SessionBloc _sessionBloc; + + StaffCubit(this._sessionBloc) : super(const StaffState()); + + // Carica tutto lo staff della compagnia + Future loadAllStaff() async { + emit(state.copyWith(isLoading: true, error: null)); + try { + final staff = await _repository.getStaffMembers( + _sessionBloc.state.company!.id, + ); + final Map> storesByStaff = {}; + for (StaffMemberModel member in staff) { + storesByStaff[member.id!] = await loadStoresByStaff(member.id!); + } + emit( + state.copyWith( + allStaff: staff, + isLoading: false, + storesByStaff: storesByStaff, + ), + ); + } catch (e) { + emit(state.copyWith(isLoading: false, error: e.toString())); + } + } + + Future> loadStoresByStaff(String staffId) async { + try { + return await _repository.getStaffMemberStore(staffId); + } catch (e) { + emit(state.copyWith(error: e.toString())); + return []; + } + } + + // Carica lo staff di uno specifico negozio e aggiorna la mappa + Future loadStaffForStore(String storeId) async { + try { + final staffInStore = await _repository.getStaffMembersInStore(storeId); + final newMap = Map>.from( + state.staffByStore, + ); + newMap[storeId] = staffInStore; + emit(state.copyWith(staffByStore: newMap)); + } catch (e) { + // Qui potresti gestire l'errore silenziosamente per non bloccare tutta l'UI + } + } + + // Salva o aggiorna un membro + Future saveStaffMember(StaffMemberModel member) async { + emit(state.copyWith(isLoading: true)); + try { + await _repository.saveStaffMember(member); + await loadAllStaff(); // Ricarichiamo la lista aggiornata + } catch (e) { + emit(state.copyWith(isLoading: false, error: e.toString())); + } + } + + // Associa un dipendente a un negozio + Future assignMemberToStore(String staffId, String storeId) async { + try { + await _repository.assignToStore(staffId, storeId); + final stuffStores = await loadStoresByStaff(staffId); + final Map> storesByStaff = Map.from( + state.storesByStaff, + ); + storesByStaff[staffId] = stuffStores; + emit(state.copyWith(storesByStaff: storesByStaff)); + } catch (e) { + emit(state.copyWith(error: "Errore nell'assegnazione: $e")); + } + } + + // Rimuove un dipendente da un negozio + Future removeMemberFromStore(String staffId, String storeId) async { + try { + await _repository.removeFromStore(staffId, storeId); + final stuffStores = await loadStoresByStaff(staffId); + final Map> storesByStaff = Map.from( + state.storesByStaff, + ); + storesByStaff[staffId] = stuffStores; + emit(state.copyWith(storesByStaff: storesByStaff)); + } catch (e) { + emit(state.copyWith(error: "Errore nella rimozione: $e")); + } + } + + Future saveStaffWithStores({ + required StaffMemberModel member, + required List selectedStoreIds, + }) async { + emit(state.copyWith(isLoading: true)); + try { + // 1. Salva o aggiorna l'anagrafica (ci serve l'ID) + // Se è un nuovo membro, Supabase ci restituirà l'ID generato + final savedMember = await _repository.saveStaffMember(member); + final String staffId = savedMember.id!; + + // 2. Sincronizzazione Negozi + // Per semplicità e pulizia, rimuoviamo le vecchie assegnazioni e inseriamo le nuove + // (Oppure facciamo un confronto tra liste, ma il reset & rewrite è più sicuro qui) + await _repository.clearStoreAssignments(staffId); + + if (selectedStoreIds.isNotEmpty) { + await Future.wait( + selectedStoreIds.map( + (storeId) => _repository.assignToStore(staffId, storeId), + ), + ); + } + + // 3. Rinfresca i dati + await loadAllStaff(); + + emit(state.copyWith(isLoading: false)); + } catch (e) { + emit(state.copyWith(isLoading: false, error: e.toString())); + } + } +} diff --git a/lib/features/master_data/staff/blocs/staff_state.dart b/lib/features/master_data/staff/blocs/staff_state.dart new file mode 100644 index 0000000..a8fbfd4 --- /dev/null +++ b/lib/features/master_data/staff/blocs/staff_state.dart @@ -0,0 +1,42 @@ +part of 'staff_cubit.dart'; + +class StaffState extends Equatable { + final List allStaff; + final Map> storesByStaff; + final Map> staffByStore; + final bool isLoading; + final String? error; + + const StaffState({ + this.allStaff = const [], + this.storesByStaff = const {}, + this.staffByStore = const {}, + this.isLoading = false, + this.error, + }); + + StaffState copyWith({ + List? allStaff, + Map>? storesByStaff, + Map>? staffByStore, + bool? isLoading, + String? error, + }) { + return StaffState( + allStaff: allStaff ?? this.allStaff, + storesByStaff: storesByStaff ?? this.storesByStaff, + staffByStore: staffByStore ?? this.staffByStore, + isLoading: isLoading ?? this.isLoading, + error: error, + ); + } + + @override + List get props => [ + allStaff, + storesByStaff, + staffByStore, + isLoading, + error, + ]; +} diff --git a/lib/features/master_data/staff/data/staff_repository.dart b/lib/features/master_data/staff/data/staff_repository.dart new file mode 100644 index 0000000..dad6dc0 --- /dev/null +++ b/lib/features/master_data/staff/data/staff_repository.dart @@ -0,0 +1,90 @@ +import 'package:flux/features/master_data/staff/models/staff_member_model.dart'; +import 'package:flux/features/master_data/store/models/store_model.dart'; +import 'package:get_it/get_it.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +class StaffRepository { + final SupabaseClient _supabase = GetIt.I.get(); + + // --- ANAGRAFICA PURA --- + + // Prende tutto lo staff della Company (per l'Hub Anagrafiche) + Future> getStaffMembers(String companyId) async { + final response = await _supabase + .from('staff_member') + .select() + .eq('company_id', companyId) + .order('name', ascending: true); + + return (response as List).map((s) => StaffMemberModel.fromJson(s)).toList(); + } + + Future saveStaffMember(StaffMemberModel member) async { + final response = await _supabase + .from('staff_member') + .upsert(member.toJson()) + .select() + .single(); + + return StaffMemberModel.fromJson(response); + } + + // --- LOGICA DI GIUNZIONE (Staff <-> Store) --- + + // Recupera i membri assegnati a uno specifico negozio + // Qui facciamo una JOIN per avere i dati del membro partendo dalla tabella di giunzione + Future> getStaffMembersInStore(String storeId) async { + final response = await _supabase + .from('staff_in_stores') + .select( + 'staff_member (*)', + ) // Prende tutti i campi della tabella staff_member collegata + .eq('store_id', storeId); + + return (response as List) + .map((item) => StaffMemberModel.fromJson(item['staff_member'])) + .toList(); + } + + // Recupera i negozi assegnati a uno specifico membro + // Qui facciamo una JOIN per avere i dati del membro partendo dalla tabella di giunzione + Future> getStaffMemberStore(String staffId) async { + final response = await _supabase + .from('staff_in_stores') + .select( + 'store (*)', + ) // Prende tutti i campi della tabella store collegata + .eq('staff_member_id', staffId); + + return (response as List) + .map((item) => StoreModel.fromJson(item['store'])) + .toList(); + } + + // Assegna un membro a un negozio + Future assignToStore(String staffId, String storeId) async { + await _supabase.from('staff_in_stores').insert({ + 'staff_member_id': staffId, + 'store_id': storeId, + }); + } + + // Rimuove l'assegnazione + Future removeFromStore(String staffId, String storeId) async { + await _supabase + .from('staff_in_stores') + .delete() + .eq('staff_member_id', staffId) + .eq('store_id', storeId); + } + + // Nel StaffRepository + + // Utility per pulire le assegnazioni esistenti prima di riscriverle + Future clearStoreAssignments(String staffId) async { + await _supabase + .from('staff_in_stores') + .delete() + .eq('staff_member_id', staffId); + } +} diff --git a/lib/features/master_data/staff/models/staff_member_model.dart b/lib/features/master_data/staff/models/staff_member_model.dart new file mode 100644 index 0000000..425d901 --- /dev/null +++ b/lib/features/master_data/staff/models/staff_member_model.dart @@ -0,0 +1,47 @@ +import 'package:equatable/equatable.dart'; +import 'package:flux/core/utils/string_extensions.dart'; // Assicurati che il percorso sia corretto + +class StaffMemberModel extends Equatable { + final String? id; + final String name; + final String email; + final String phone; + final bool isActive; + final String companyId; + + const StaffMemberModel({ + this.id, + required this.name, + this.email = '', + this.phone = '', + this.isActive = true, + required this.companyId, + }); + + factory StaffMemberModel.fromJson(Map json) { + return StaffMemberModel( + id: json['id'], + // Applichiamo il tuo myFormat per visualizzare i nomi correttamente + name: (json['name'] as String).myFormat(), + // L'email la teniamo lowercase per standard tecnico + email: (json['email'] as String? ?? '').toLowerCase().trim(), + phone: (json['phone'] as String? ?? '').trim(), + isActive: json['is_active'] ?? true, + companyId: json['company_id'], + ); + } + + Map toJson() { + return { + if (id != null) 'id': id, + 'name': name.toLowerCase().trim(), // Salviamo pulito per le query + 'email': email.toLowerCase().trim(), + 'phone': phone.trim(), + 'is_active': isActive, + 'company_id': companyId, + }; + } + + @override + List get props => [id, name, email, phone, isActive, companyId]; +} diff --git a/lib/features/master_data/staff/ui/staff_screen.dart b/lib/features/master_data/staff/ui/staff_screen.dart new file mode 100644 index 0000000..8684f2a --- /dev/null +++ b/lib/features/master_data/staff/ui/staff_screen.dart @@ -0,0 +1,315 @@ +import 'package:flutter/material.dart'; +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/core/widgets/flux_text_field.dart'; +import 'package:flux/features/master_data/staff/blocs/staff_cubit.dart'; // Tuo percorso +import 'package:flux/features/master_data/staff/models/staff_member_model.dart'; +import 'package:flux/features/master_data/store/bloc/store_bloc.dart'; + +class StaffScreen extends StatefulWidget { + const StaffScreen({super.key}); + + @override + State createState() => _StaffScreenState(); +} + +class _StaffScreenState extends State { + String? _selectedStoreId; + bool _showAllCompanyStaff = true; // Partiamo con la vista globale + + @override + void initState() { + super.initState(); + // Carichiamo subito tutto + context.read().loadAllStaff(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: context.background, + appBar: AppBar( + title: const Text("Anagrafica Personale"), + actions: [ + // Toggle per vista Azienda / Negozio + Padding( + padding: const EdgeInsets.only(right: 16), + child: FilterChip( + label: const Text("Tutta l'Azienda"), + selected: _showAllCompanyStaff, + onSelected: (val) => setState(() => _showAllCompanyStaff = val), + selectedColor: context.accent.withValues(alpha: 0.2), + ), + ), + ], + ), + body: Column( + children: [ + // --- BARRA FILTRO NEGOZIO (Visibile solo se non 'Tutta l'Azienda') --- + AnimatedContainer( + duration: const Duration(milliseconds: 300), + height: _showAllCompanyStaff ? 0 : 80, + child: _showAllCompanyStaff + ? const SizedBox() + : _buildStoreSelector(), + ), + + // --- LISTA PERSONALE --- + Expanded( + child: BlocBuilder( + builder: (context, state) { + final list = _showAllCompanyStaff + ? state.allStaff + : (state.staffByStore[_selectedStoreId] ?? []); + + if (state.isLoading && list.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + + if (list.isEmpty) { + return _buildEmptyState(); + } + + return ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: list.length, + separatorBuilder: (_, _) => const SizedBox(height: 12), + itemBuilder: (context, index) { + final member = list[index]; + return _buildStaffCard(member); + }, + ); + }, + ), + ), + ], + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: () => _openStaffForm(context), + label: const Text("Aggiungi"), + icon: const Icon(Icons.person_add_alt_1), + ), + ); + } + + Widget _buildStoreSelector() { + return BlocBuilder( + // Assumendo tu abbia uno StoreCubit + builder: (context, state) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: DropdownButtonFormField( + initialValue: _selectedStoreId, + decoration: const InputDecoration(labelText: "Filtra per Negozio"), + items: state.stores + .map((s) => DropdownMenuItem(value: s.id, child: Text(s.nome))) + .toList(), + onChanged: (id) { + setState(() => _selectedStoreId = id); + if (id != null) context.read().loadStaffForStore(id); + }, + ), + ); + }, + ); + } + + Widget _buildStaffCard(StaffMemberModel member) { + return Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide(color: context.accent.withValues(alpha: 0.1)), + ), + child: ListTile( + contentPadding: const EdgeInsets.all(12), + leading: CircleAvatar( + backgroundColor: context.accent.withValues(alpha: 0.1), + child: Text(member.name[0], style: TextStyle(color: context.accent)), + ), + title: Text( + member.name, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (member.email.isNotEmpty) Text(member.email), + Text(member.phone.isNotEmpty ? member.phone : "Nessun telefono"), + ], + ), + trailing: const Icon(Icons.edit_note), + onTap: () => _openStaffForm(context, member: member), + ), + ); + } + + void _openStaffForm(BuildContext context, {StaffMemberModel? member}) { + final nameController = TextEditingController(text: member?.name); + final emailController = TextEditingController(text: member?.email); + final phoneController = TextEditingController(text: member?.phone); + + // 1. Inizializziamo la lista temporanea attingendo dallo stato del Cubit + // Usiamo storesByStaff (la mappa che indicizza i negozi per ogni ID dipendente) + List tempSelectedStores = + context + .read() + .state + .storesByStaff[member?.id] + ?.map((s) => s.id!) + .toList() ?? + []; + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => StatefulBuilder( + // <--- QUESTO è il segreto per le Chip + builder: (context, setModalState) { + return Container( + decoration: BoxDecoration( + color: Theme.of(context).scaffoldBackgroundColor, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(24), + ), + ), + padding: EdgeInsets.only( + top: 24, + left: 24, + right: 24, + bottom: MediaQuery.of(context).viewInsets.bottom + 24, + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + member == null + ? "Nuovo Collaboratore" + : "Modifica Collaboratore", + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 24), + FluxTextField( + controller: nameController, + label: "Nome e Cognome", + icon: Icons.person, + ), + const SizedBox(height: 16), + FluxTextField( + controller: emailController, + label: "Email", + icon: Icons.email, + ), + const SizedBox(height: 16), + FluxTextField( + controller: phoneController, + label: "Telefono", + icon: Icons.phone, + ), + const SizedBox(height: 24), + const Text( + "Assegna ai Negozi", + style: TextStyle(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 12), + + // --- SELETTORE NEGOZI (CHIPS) --- + // Qui usiamo il BlocBuilder per i negozi, ma il setModalState per il refresh + BlocBuilder( + builder: (context, storeState) { + if (storeState.status == StoreStatus.loading) { + return const CircularProgressIndicator(); + } + return Wrap( + spacing: 8, + runSpacing: 8, + children: storeState.stores.map((store) { + final isSelected = tempSelectedStores.contains( + store.id, + ); + return FilterChip( + label: Text(store.nome), + selected: isSelected, + onSelected: (selected) { + // IMPORTANTE: setModalState aggiorna l'UI del BottomSheet + setModalState(() { + if (selected) { + tempSelectedStores.add(store.id!); + } else { + tempSelectedStores.remove(store.id); + } + }); + }, + selectedColor: context.accent.withValues( + alpha: 0.2, + ), + checkmarkColor: context.accent, + ); + }).toList(), + ); + }, + ), + + const SizedBox(height: 32), + SizedBox( + width: double.infinity, + height: 50, + child: ElevatedButton( + onPressed: () { + final companyId = context + .read() + .state + .company! + .id; + + final updatedMember = StaffMemberModel( + id: member?.id, + name: nameController.text, + email: emailController.text, + phone: phoneController.text, + companyId: companyId, + ); + + // Chiamiamo il metodo atomico nel Cubit + context.read().saveStaffWithStores( + member: updatedMember, + selectedStoreIds: tempSelectedStores, + ); + + Navigator.pop(context); + }, + child: const Text("SALVA COLLABORATORE"), + ), + ), + ], + ), + ), + ); + }, + ), + ); + } + + Widget _buildEmptyState() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.people_outline, size: 64, color: context.secondaryText), + const SizedBox(height: 16), + Text( + "Nessun membro trovato", + style: TextStyle(color: context.secondaryText), + ), + ], + ), + ); + } +} diff --git a/lib/features/master_data/store/bloc/store_bloc.dart b/lib/features/master_data/store/bloc/store_bloc.dart new file mode 100644 index 0000000..0fa8dbd --- /dev/null +++ b/lib/features/master_data/store/bloc/store_bloc.dart @@ -0,0 +1,56 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flux/core/blocs/session/session_bloc.dart'; +import 'package:flux/features/master_data/store/data/store_repository.dart'; +import 'package:flux/features/master_data/store/models/store_model.dart'; +import 'package:get_it/get_it.dart'; + +part 'store_events.dart'; +part 'store_state.dart'; + +class StoreBloc extends Bloc { + final StoreRepository _repository = GetIt.I(); + final SessionBloc _sessionBloc; + + StoreBloc(this._sessionBloc) : super(const StoreState(stores: [])) { + on(_onCreateStore); + on(_onLoadStores); + } + + Future _onCreateStore( + CreateStoreRequested event, + Emitter emit, + ) async { + emit(state.copyWith(status: StoreStatus.loading)); + try { + await _repository.createStore(event.store); + emit(state.copyWith(status: StoreStatus.success)); + } catch (e) { + emit( + state.copyWith(status: StoreStatus.failure, errorMessage: e.toString()), + ); + } + } + + Future _onLoadStores( + LoadStoresRequested event, + Emitter emit, + ) async { + emit(state.copyWith(status: StoreStatus.loading)); + try { + final stores = await _repository.getStoresByCompany( + _sessionBloc.state.company!.id, + ); + emit( + state.copyWith( + status: StoreStatus.success, + stores: stores, // Assicurati di avere 'stores' nello StoreState + ), + ); + } catch (e) { + emit( + state.copyWith(status: StoreStatus.failure, errorMessage: e.toString()), + ); + } + } +} diff --git a/lib/features/master_data/store/bloc/store_events.dart b/lib/features/master_data/store/bloc/store_events.dart new file mode 100644 index 0000000..bc13f5a --- /dev/null +++ b/lib/features/master_data/store/bloc/store_events.dart @@ -0,0 +1,18 @@ +part of 'store_bloc.dart'; + +abstract class StoreEvent extends Equatable { + const StoreEvent(); + + @override + List get props => []; +} + +class CreateStoreRequested extends StoreEvent { + final StoreModel store; + const CreateStoreRequested({required this.store}); + + @override + List get props => [store]; +} + +class LoadStoresRequested extends StoreEvent {} diff --git a/lib/features/master_data/store/bloc/store_state.dart b/lib/features/master_data/store/bloc/store_state.dart new file mode 100644 index 0000000..38a1c01 --- /dev/null +++ b/lib/features/master_data/store/bloc/store_state.dart @@ -0,0 +1,34 @@ +part of 'store_bloc.dart'; + +enum StoreStatus { initial, loading, success, failure } + +class StoreState extends Equatable { + final StoreStatus status; + final StoreModel? store; + final String? errorMessage; + final List stores; + + const StoreState({ + this.status = StoreStatus.initial, + this.store, + this.errorMessage, + required this.stores, + }); + + StoreState copyWith({ + StoreStatus? status, + StoreModel? store, + String? errorMessage, + List? stores, + }) { + return StoreState( + status: status ?? this.status, + store: store ?? this.store, + errorMessage: errorMessage ?? this.errorMessage, + stores: stores ?? this.stores, + ); + } + + @override + List get props => [status, store, errorMessage, stores]; +} diff --git a/lib/features/master_data/store/data/store_repository.dart b/lib/features/master_data/store/data/store_repository.dart new file mode 100644 index 0000000..96487b7 --- /dev/null +++ b/lib/features/master_data/store/data/store_repository.dart @@ -0,0 +1,36 @@ +import 'package:get_it/get_it.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import '../models/store_model.dart'; + +class StoreRepository { + final SupabaseClient _client = GetIt.I.get(); + + /// Crea un nuovo negozio associato alla compagnia dell'utente + Future createStore(StoreModel store) async { + try { + await _client.from('store').insert(store.toJson()); + } on PostgrestException catch (e) { + // Intercettiamo errori specifici del database + throw e.message; + } catch (e) { + throw 'Errore imprevisto durante la creazione del negozio: $e'; + } + } + + /// Recupera tutti i negozi di una determinata compagnia + Future> getStoresByCompany(String companyId) async { + try { + final response = await _client + .from('store') + .select() + .eq('company_id', companyId) + .order('created_at'); + + return (response as List) + .map((json) => StoreModel.fromJson(json)) + .toList(); + } catch (e) { + throw 'Errore nel recupero dei negozi'; + } + } +} diff --git a/lib/features/master_data/store/models/store_model.dart b/lib/features/master_data/store/models/store_model.dart new file mode 100644 index 0000000..680e2f2 --- /dev/null +++ b/lib/features/master_data/store/models/store_model.dart @@ -0,0 +1,102 @@ +import 'package:equatable/equatable.dart'; + +class StoreModel extends Equatable { + final String? id; + final String nome; + final String companyId; + final bool isActive; + final bool isPaid; + final DateTime? paymentExpiration; + final String indirizzo; + final String cap; + final String comune; + final String provincia; + + const StoreModel({ + this.id, + required this.nome, + required this.companyId, + this.isActive = true, + this.isPaid = false, + this.paymentExpiration, + required this.indirizzo, + required this.cap, + required this.comune, + required this.provincia, + }); + + // Fondamentale per Equatable: definisce quali proprietà determinano l'uguaglianza + @override + List get props => [ + id, + nome, + companyId, + isActive, + isPaid, + paymentExpiration, + indirizzo, + cap, + comune, + provincia, + ]; + + // Il mitico copyWith per creare nuove istanze modificando solo ciò che serve + StoreModel copyWith({ + String? id, + String? nome, + String? companyId, + bool? isActive, + bool? isPaid, + DateTime? paymentExpiration, + String? indirizzo, + String? cap, + String? comune, + String? provincia, + }) { + return StoreModel( + id: id ?? this.id, + nome: nome ?? this.nome, + companyId: companyId ?? this.companyId, + isActive: isActive ?? this.isActive, + isPaid: isPaid ?? this.isPaid, + paymentExpiration: paymentExpiration ?? this.paymentExpiration, + indirizzo: indirizzo ?? this.indirizzo, + cap: cap ?? this.cap, + comune: comune ?? this.comune, + provincia: provincia ?? this.provincia, + ); + } + + factory StoreModel.fromJson(Map json) { + return StoreModel( + id: json['id'] as String, + nome: json['nome'], + companyId: json['company_id'] as String, + isActive: json['is_active'] ?? true, + isPaid: json['is_paid'] ?? false, + paymentExpiration: json['payment_expiration'] != null + ? DateTime.parse(json['payment_expiration']) + : null, + indirizzo: json['indirizzo'], + cap: json['cap'], + comune: json['comune'], + provincia: json['provincia'], + ); + } + + Map toJson() { + return { + if (id != null) 'id': id, + 'nome': nome, + 'company_id': companyId, + 'is_active': isActive, + 'is_paid': isPaid, + if (paymentExpiration != null) + 'payment_expiration': paymentExpiration!.toIso8601String(), + 'indirizzo': indirizzo, + 'cap': cap, + 'comune': comune, + 'provincia': provincia, + }; + } +} diff --git a/lib/features/master_data/store/ui/create_store_screen.dart b/lib/features/master_data/store/ui/create_store_screen.dart new file mode 100644 index 0000000..0526aae --- /dev/null +++ b/lib/features/master_data/store/ui/create_store_screen.dart @@ -0,0 +1,253 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flux/features/master_data/store/bloc/store_bloc.dart'; +import 'package:flux/features/master_data/store/models/store_model.dart'; +import 'package:flux/core/blocs/session/session_bloc.dart'; +import 'package:flux/core/theme/theme.dart'; +import 'package:flux/core/widgets/flux_text_field.dart'; + +class CreateStoreScreen extends StatefulWidget { + const CreateStoreScreen({super.key}); + + @override + State createState() => _CreateStoreScreenState(); +} + +class _CreateStoreScreenState extends State { + final _formKey = GlobalKey(); + + final _nomeController = TextEditingController(); + final _indirizzoController = TextEditingController(); + final _capController = TextEditingController(); + final _comuneController = TextEditingController(); + final _provinciaController = TextEditingController(); + + @override + void dispose() { + _nomeController.dispose(); + _indirizzoController.dispose(); + _capController.dispose(); + _comuneController.dispose(); + _provinciaController.dispose(); + super.dispose(); + } + + /// Funzione magica per copiare i dati dall'azienda salvata in GetIt + void _useCompanyAddress() { + final company = context.read().state.company; + if (company != null) { + setState(() { + _indirizzoController.text = company.indirizzo; + _capController.text = company.cap; + _comuneController.text = + company.citta; // Nel DB company è 'citta', store è 'comune' + _provinciaController.text = company.provincia; + // Suggeriamo anche un nome se vuoto + if (_nomeController.text.isEmpty) { + _nomeController.text = '${company.ragioneSociale} - Sede'; + } + }); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Dati indirizzo copiati dalla sede legale'), + ), + ); + } + } + + void _onSave() { + if (_formKey.currentState!.validate()) { + final company = context.read().state.company; + + if (company == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Errore: Azienda non trovata')), + ); + return; + } + + final store = StoreModel( + nome: _nomeController.text.trim(), + companyId: company.id, + indirizzo: _indirizzoController.text.trim(), + cap: _capController.text.trim(), + comune: _comuneController.text.trim(), + provincia: _provinciaController.text.trim().toUpperCase(), + ); + + context.read().add(CreateStoreRequested(store: store)); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Il tuo primo Negozio')), + body: BlocConsumer( + listener: (context, state) { + if (state.status == StoreStatus.success) { + context.read().add(AppStarted()); + } + if (state.status == StoreStatus.failure) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(state.errorMessage ?? 'Errore salvataggio'), + ), + ); + } + }, + builder: (context, state) { + return SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24.0), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHeader(context), + const SizedBox(height: 32), + + // Nome del Negozio (Icona obbligatoria) + FluxTextField( + label: 'Nome del Negozio', + icon: Icons.storefront_rounded, + controller: _nomeController, + keyboardType: TextInputType.name, + ), + + const SizedBox(height: 24), + + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _SectionTitle(title: 'LOCALIZZAZIONE'), + TextButton.icon( + onPressed: _useCompanyAddress, + icon: const Icon(Icons.copy_rounded, size: 16), + label: const Text( + 'Copia da Azienda', + style: TextStyle(fontSize: 12), + ), + ), + ], + ), + const SizedBox(height: 12), + + // Indirizzo (Icona obbligatoria) + FluxTextField( + label: 'Indirizzo e n. civico', + icon: Icons.map_outlined, + controller: _indirizzoController, + keyboardType: TextInputType.streetAddress, + ), + const SizedBox(height: 16), + + // RIGA TRIPLA: Comune, CAP, PR + Row( + crossAxisAlignment: CrossAxisAlignment + .start, // Allinea in alto in caso di errori + children: [ + Expanded( + flex: 2, + child: FluxTextField( + label: 'CAP', + icon: Icons.post_add_rounded, // Icona aggiunta + controller: _capController, + keyboardType: TextInputType.number, + maxLength: 5, + ), + ), + + const SizedBox(width: 12), + Expanded( + flex: 3, + child: FluxTextField( + label: 'Comune', + icon: Icons.location_city_rounded, // Icona aggiunta + controller: _comuneController, + keyboardType: TextInputType.name, + ), + ), + const SizedBox(width: 12), + Expanded( + flex: 2, // Aumentato leggermente per ospitare l'icona + child: FluxTextField( + label: 'PR', + icon: Icons.explore_outlined, // Icona aggiunta + controller: _provinciaController, + keyboardType: TextInputType.name, + onChanged: (value) => value.toUpperCase(), + maxLength: 2, + ), + ), + ], + ), + + const SizedBox(height: 48), + + _buildSubmitButton(context, state), + ], + ), + ), + ), + ); + }, + ), + ); + } + + Widget _buildSubmitButton(BuildContext context, StoreState state) { + return SizedBox( + width: double.infinity, + height: 56, + child: ElevatedButton( + onPressed: state.status == StoreStatus.loading ? null : _onSave, + child: state.status == StoreStatus.loading + ? const CircularProgressIndicator(color: Colors.white) + : const Text('ATTIVA NEGOZIO'), + ), + ); + } + + Widget _buildHeader(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.add_location_alt_rounded, color: context.accent, size: 48), + const SizedBox(height: 16), + Text( + 'Dove si trova il tuo store?', + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + color: context.primaryText, + ), + ), + const SizedBox(height: 8), + Text( + 'Configura il primo punto vendita per iniziare a gestire i tuoi clienti e le operazioni.', + style: TextStyle(color: context.secondaryText, fontSize: 15), + ), + ], + ); + } +} + +class _SectionTitle extends StatelessWidget { + final String title; + const _SectionTitle({required this.title}); + + @override + Widget build(BuildContext context) { + return Text( + title, + style: TextStyle( + color: context.accent, + fontWeight: FontWeight.w800, + letterSpacing: 1.2, + fontSize: 12, + ), + ); + } +} diff --git a/lib/features/master_data/store/ui/stores_screen.dart b/lib/features/master_data/store/ui/stores_screen.dart new file mode 100644 index 0000000..28447cc --- /dev/null +++ b/lib/features/master_data/store/ui/stores_screen.dart @@ -0,0 +1,296 @@ +import 'package:flutter/material.dart'; +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/core/widgets/flux_text_field.dart'; +import 'package:flux/features/master_data/staff/blocs/staff_cubit.dart'; +import 'package:flux/features/master_data/store/bloc/store_bloc.dart'; +import 'package:flux/features/master_data/store/models/store_model.dart'; + +class StoresScreen extends StatefulWidget { + const StoresScreen({super.key}); + + @override + State createState() => _StoresScreenState(); +} + +class _StoresScreenState extends State { + @override + void initState() { + super.initState(); + // Carichiamo i negozi e anche lo staff (per poterlo assegnare) + context.read().add(LoadStoresRequested()); + context.read().loadAllStaff(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text("I Tuoi Negozi")), + body: BlocBuilder( + builder: (context, state) { + if (state.status == StoreStatus.loading) + return const Center(child: CircularProgressIndicator()); + + return ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: state.stores.length, + itemBuilder: (context, index) { + final store = state.stores[index]; + return _buildStoreCard(store); + }, + ); + }, + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: () => _openStoreForm(context), + label: const Text("Nuovo Negozio"), + icon: const Icon(Icons.store), + ), + ); + } + + Widget _buildStoreCard(StoreModel store) { + return Card( + margin: const EdgeInsets.only(bottom: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide( + color: store.isActive + ? Colors.transparent + : Colors.red.withValues(alpha: 0.3), + ), + ), + child: Column( + children: [ + ListTile( + leading: Icon( + Icons.storefront, + color: store.isActive ? context.accent : Colors.grey, + ), + title: Text( + store.nome, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: Text( + "${store.comune} (${store.provincia}) - ${store.indirizzo}", + ), + trailing: Switch( + value: store.isActive, + onChanged: (val) { + // context.read().add(ToggleStoreStatus(store.id, val)); + }, + ), + ), + const Divider(height: 1), + Padding( + padding: const EdgeInsets.all(12), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // Mostra quanti dipendenti ci sono (usando lo StaffCubit) + BlocBuilder( + builder: (context, staffState) { + final staffCount = + staffState.storesByStaff[store.id]?.length ?? 0; + return ActionChip( + avatar: const Icon(Icons.people, size: 16), + label: Text("$staffCount Dipendenti"), + onPressed: () => _manageStoreStaff(store), + ); + }, + ), + TextButton.icon( + onPressed: () => _openStoreForm(context, store: store), + icon: const Icon(Icons.edit, size: 18), + label: const Text("Modifica"), + ), + ], + ), + ), + ], + ), + ); + } + + void _manageStoreStaff(StoreModel store) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => BlocBuilder( + builder: (context, state) { + return Container( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text("Personale di ${store.nome}", style: context.titleLarge), + const SizedBox(height: 16), + // Lista di TUTTO lo staff dell'azienda con checkbox + ...state.allStaff.map((person) { + final bool isAssigned = + state.storesByStaff[store.id!]?.any( + (s) => s.id == person.id, + ) ?? + false; + + return CheckboxListTile( + title: Text(person.name), + value: isAssigned, + onChanged: (selected) { + if (selected == true) { + context.read().assignMemberToStore( + person.id!, + store.id!, + ); + } else { + context.read().removeMemberFromStore( + person.id!, + store.id!, + ); + } + }, + ); + }), + ], + ), + ); + }, + ), + ); + } + + void _openStoreForm(BuildContext context, {StoreModel? store}) { + final nomeController = TextEditingController(text: store?.nome); + final indirizzoController = TextEditingController(text: store?.indirizzo); + final capController = TextEditingController(text: store?.cap); + final comuneController = TextEditingController(text: store?.comune); + final provinciaController = TextEditingController(text: store?.provincia); + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => Container( + decoration: BoxDecoration( + color: Theme.of(context).scaffoldBackgroundColor, + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + ), + padding: EdgeInsets.only( + top: 24, + left: 24, + right: 24, + bottom: MediaQuery.of(context).viewInsets.bottom + 24, + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + store == null ? "Nuovo Punto Vendita" : "Modifica Negozio", + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 24), + + // --- DATI PRINCIPALI --- + FluxTextField( + controller: nomeController, + label: "Nome Negozio (es. Flux Milano)", + icon: Icons.storefront_rounded, + keyboardType: TextInputType.name, + ), + const SizedBox(height: 16), + FluxTextField( + controller: indirizzoController, + label: "Indirizzo", + icon: Icons.map_outlined, + keyboardType: TextInputType.streetAddress, + ), + const SizedBox(height: 16), + + // --- CAP, COMUNE, PROVINCIA (In riga) --- + Row( + children: [ + Expanded( + flex: 2, + child: FluxTextField( + controller: capController, + label: "CAP", + icon: Icons.post_add_rounded, + keyboardType: TextInputType.number, + maxLength: 5, + ), + ), + const SizedBox(width: 8), + Expanded( + flex: 4, + child: FluxTextField( + controller: comuneController, + label: "Comune", + icon: Icons.location_city_rounded, + keyboardType: TextInputType.name, + ), + ), + const SizedBox(width: 8), + Expanded( + flex: 2, + child: FluxTextField( + controller: provinciaController, + label: "Prov", + icon: Icons.explore_outlined, + keyboardType: TextInputType.name, + onChanged: (value) => value.toUpperCase(), + maxLength: 2, + ), + ), + ], + ), + + const SizedBox(height: 32), + + // --- TASTO SALVA --- + SizedBox( + width: double.infinity, + height: 50, + child: ElevatedButton( + onPressed: () { + if (nomeController.text.isEmpty) return; + + final storeData = StoreModel( + id: store?.id, // Se nullo, Supabase ne crea uno nuovo + nome: nomeController.text, + indirizzo: indirizzoController.text, + cap: capController.text, + comune: comuneController.text, + provincia: provinciaController.text, + companyId: context + .read() + .state + .company! + .id, // Recuperiamo la companyId + isActive: store?.isActive ?? true, + isPaid: store?.isPaid ?? false, + paymentExpiration: store?.paymentExpiration, + ); + + // Chiamata al Bloc per il salvataggio + context.read().add( + CreateStoreRequested(store: storeData), + ); + + Navigator.pop(context); + }, + child: Text(store == null ? "CREA NEGOZIO" : "AGGIORNA DATI"), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index 038e970..9ed3399 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -10,12 +10,12 @@ import 'package:flux/features/company/bloc/company_bloc.dart'; import 'package:flux/features/company/data/company_repository.dart'; import 'package:flux/features/customers/blocs/customer_bloc.dart'; import 'package:flux/features/customers/data/customer_repository.dart'; -import 'package:flux/features/products/blocs/product_cubit.dart'; -import 'package:flux/features/products/data/product_repository.dart'; -import 'package:flux/features/staff/blocs/staff_cubit.dart'; -import 'package:flux/features/staff/data/staff_repository.dart'; -import 'package:flux/features/store/bloc/store_bloc.dart'; -import 'package:flux/features/store/data/store_repository.dart'; +import 'package:flux/features/master_data/products/blocs/product_cubit.dart'; +import 'package:flux/features/master_data/products/data/product_repository.dart'; +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_bloc.dart'; +import 'package:flux/features/master_data/store/data/store_repository.dart'; import 'package:flux/features/settings/settings.dart'; import 'package:get_it/get_it.dart'; import 'package:go_router/go_router.dart';