Refactor StoreBloc to Cubit and Fix Staff Assignment UI (#1)

Convertito StoreBloc in StoreCubit per coerenza con il resto del progetto.

Sistemata la logica di assegnazione dipendenti nel modal dei negozi.

Utilizzato il doppio BlocBuilder per garantire la reattività tra StaffCubit e StoreCubit.

Reviewed-on: http://catelliub.zapto.org:3000/brontomark/flux/pulls/1
Co-authored-by: Mark M2 Macbook <marco@catelli.it>
Co-committed-by: Mark M2 Macbook <marco@catelli.it>
This commit is contained in:
2026-04-15 10:05:07 +02:00
committed by brontomark
parent 7f8c0d642a
commit 753b5489b6
21 changed files with 170 additions and 1700 deletions

View File

@@ -1,115 +0,0 @@
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/products/ui/products_screen.dart';
import 'package:flux/features/staff/ui/staff_screen.dart';
import 'package:flux/features/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()), // Coming soon
),
_HubCard(
label: 'Negozi',
icon: Icons.storefront_outlined,
color: Colors.purple,
onTap: () => onOpenPage(const StoresScreen()), // Coming soon
),
],
),
),
],
),
);
}
}
// 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)),
],
),
),
);
}
}

View File

@@ -5,7 +5,7 @@ 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';
import 'package:flux/features/master_data/store/bloc/store_cubit.dart';
class StaffScreen extends StatefulWidget {
const StaffScreen({super.key});
@@ -94,7 +94,7 @@ class _StaffScreenState extends State<StaffScreen> {
}
Widget _buildStoreSelector() {
return BlocBuilder<StoreBloc, StoreState>(
return BlocBuilder<StoreCubit, StoreState>(
// Assumendo tu abbia uno StoreCubit
builder: (context, state) {
return Padding(
@@ -222,7 +222,7 @@ class _StaffScreenState extends State<StaffScreen> {
// --- SELETTORE NEGOZI (CHIPS) ---
// Qui usiamo il BlocBuilder per i negozi, ma il setModalState per il refresh
BlocBuilder<StoreBloc, StoreState>(
BlocBuilder<StoreCubit, StoreState>(
builder: (context, storeState) {
if (storeState.status == StoreStatus.loading) {
return const CircularProgressIndicator();

View File

@@ -1,56 +0,0 @@
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<StoreEvent, StoreState> {
final StoreRepository _repository = GetIt.I<StoreRepository>();
final SessionBloc _sessionBloc;
StoreBloc(this._sessionBloc) : super(const StoreState(stores: [])) {
on<CreateStoreRequested>(_onCreateStore);
on<LoadStoresRequested>(_onLoadStores);
}
Future<void> _onCreateStore(
CreateStoreRequested event,
Emitter<StoreState> 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<void> _onLoadStores(
LoadStoresRequested event,
Emitter<StoreState> 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()),
);
}
}
}

View File

@@ -0,0 +1,97 @@
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/staff/data/staff_repository.dart';
import 'package:flux/features/master_data/staff/models/staff_member_model.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_state.dart';
class StoreCubit extends Cubit<StoreState> {
final StoreRepository _repository = GetIt.I<StoreRepository>();
final StaffRepository _staffRepository = GetIt.I<StaffRepository>();
final SessionBloc _sessionBloc;
StoreCubit(this._sessionBloc) : super(const StoreState(stores: []));
Future<void> createStore(final StoreModel store) async {
emit(state.copyWith(status: StoreStatus.loading));
try {
await _repository.createStore(store);
emit(state.copyWith(status: StoreStatus.success));
} catch (e) {
emit(
state.copyWith(status: StoreStatus.failure, errorMessage: e.toString()),
);
}
}
Future<void> loadStores() async {
emit(state.copyWith(status: StoreStatus.loading));
try {
final stores = await _repository.getStoresByCompany(
_sessionBloc.state.company!.id,
);
final Map<String, List<StaffMemberModel>> staffByStore = {};
for (StoreModel store in stores) {
staffByStore[store.id!] = await _staffRepository.getStaffMembersInStore(
store.id!,
);
}
emit(
state.copyWith(
status: StoreStatus.success,
stores: stores,
staffByStore: staffByStore,
),
);
} catch (e) {
emit(
state.copyWith(status: StoreStatus.failure, errorMessage: e.toString()),
);
}
}
Future<void> assignStaffToStore(String storeId, String staffId) async {
try {
await _staffRepository.assignToStore(staffId, storeId);
// Dopo l'assegnazione, potresti voler ricaricare lo staff per quel negozio
final updatedStaff = await _staffRepository.getStaffMembersInStore(
storeId,
);
final newMap = Map<String, List<StaffMemberModel>>.from(
state.staffByStore,
);
newMap[storeId] = updatedStaff;
emit(state.copyWith(status: StoreStatus.success, staffByStore: newMap));
} catch (e) {
emit(
state.copyWith(status: StoreStatus.failure, errorMessage: e.toString()),
);
}
}
// Rimuove un dipendente da un negozio
Future<void> removeStaffFromStore(String staffId, String storeId) async {
try {
await _staffRepository.removeFromStore(staffId, storeId);
final updatedStaff = await _staffRepository.getStaffMembersInStore(
storeId,
);
final newMap = Map<String, List<StaffMemberModel>>.from(
state.staffByStore,
);
newMap[storeId] = updatedStaff;
emit(state.copyWith(staffByStore: newMap));
} catch (e) {
emit(
state.copyWith(
status: StoreStatus.failure,
errorMessage: "Errore nella rimozione: $e",
),
);
}
}
}

View File

@@ -1,18 +0,0 @@
part of 'store_bloc.dart';
abstract class StoreEvent extends Equatable {
const StoreEvent();
@override
List<Object?> get props => [];
}
class CreateStoreRequested extends StoreEvent {
final StoreModel store;
const CreateStoreRequested({required this.store});
@override
List<Object?> get props => [store];
}
class LoadStoresRequested extends StoreEvent {}

View File

@@ -1,4 +1,4 @@
part of 'store_bloc.dart';
part of 'store_cubit.dart';
enum StoreStatus { initial, loading, success, failure }
@@ -7,12 +7,14 @@ class StoreState extends Equatable {
final StoreModel? store;
final String? errorMessage;
final List<StoreModel> stores;
final Map<String, List<StaffMemberModel>> staffByStore;
const StoreState({
this.status = StoreStatus.initial,
this.store,
this.errorMessage,
required this.stores,
this.staffByStore = const {},
});
StoreState copyWith({
@@ -20,15 +22,23 @@ class StoreState extends Equatable {
StoreModel? store,
String? errorMessage,
List<StoreModel>? stores,
Map<String, List<StaffMemberModel>>? staffByStore,
}) {
return StoreState(
status: status ?? this.status,
store: store ?? this.store,
errorMessage: errorMessage ?? this.errorMessage,
stores: stores ?? this.stores,
staffByStore: staffByStore ?? this.staffByStore,
);
}
@override
List<Object?> get props => [status, store, errorMessage, stores];
List<Object?> get props => [
status,
store,
errorMessage,
stores,
staffByStore,
];
}

View File

@@ -1,6 +1,6 @@
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/bloc/store_cubit.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';
@@ -76,7 +76,7 @@ class _CreateStoreScreenState extends State<CreateStoreScreen> {
provincia: _provinciaController.text.trim().toUpperCase(),
);
context.read<StoreBloc>().add(CreateStoreRequested(store: store));
context.read<StoreCubit>().createStore(store);
}
}
@@ -84,7 +84,7 @@ class _CreateStoreScreenState extends State<CreateStoreScreen> {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Il tuo primo Negozio')),
body: BlocConsumer<StoreBloc, StoreState>(
body: BlocConsumer<StoreCubit, StoreState>(
listener: (context, state) {
if (state.status == StoreStatus.success) {
context.read<SessionBloc>().add(AppStarted());

View File

@@ -4,7 +4,7 @@ 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/bloc/store_cubit.dart';
import 'package:flux/features/master_data/store/models/store_model.dart';
class StoresScreen extends StatefulWidget {
@@ -19,7 +19,7 @@ class _StoresScreenState extends State<StoresScreen> {
void initState() {
super.initState();
// Carichiamo i negozi e anche lo staff (per poterlo assegnare)
context.read<StoreBloc>().add(LoadStoresRequested());
context.read<StoreCubit>().loadStores();
context.read<StaffCubit>().loadAllStaff();
}
@@ -27,7 +27,7 @@ class _StoresScreenState extends State<StoresScreen> {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text("I Tuoi Negozi")),
body: BlocBuilder<StoreBloc, StoreState>(
body: BlocBuilder<StoreCubit, StoreState>(
builder: (context, state) {
if (state.status == StoreStatus.loading) {
return const Center(child: CircularProgressIndicator());
@@ -90,10 +90,10 @@ class _StoresScreenState extends State<StoresScreen> {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Mostra quanti dipendenti ci sono (usando lo StaffCubit)
BlocBuilder<StaffCubit, StaffState>(
builder: (context, staffState) {
BlocBuilder<StoreCubit, StoreState>(
builder: (context, storeState) {
final staffCount =
staffState.storesByStaff[store.id]?.length ?? 0;
storeState.staffByStore[store.id]?.length ?? 0;
return ActionChip(
avatar: const Icon(Icons.people, size: 16),
label: Text("$staffCount Dipendenti"),
@@ -119,33 +119,41 @@ class _StoresScreenState extends State<StoresScreen> {
context: context,
isScrollControlled: true,
builder: (context) => BlocBuilder<StaffCubit, StaffState>(
builder: (context, state) {
// 1. Prendi TUTTI i dipendenti
builder: (context, staffState) {
return BlocBuilder<StoreCubit, StoreState>(
// 2. Prendi le ASSEGNAZIONI
builder: (context, storeState) {
final assignedToThisStore =
storeState.staffByStore[store.id!] ?? [];
return Container(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text("Personale di ${store.nome}", style: context.titleLarge),
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(
...staffState.allStaff.map((person) {
// La spunta deve dipendere dallo StoreCubit!
final bool isAssigned = assignedToThisStore.any(
(s) => s.id == person.id,
) ??
false;
);
return CheckboxListTile(
title: Text(person.name),
value: isAssigned,
onChanged: (selected) {
if (selected == true) {
context.read<StaffCubit>().assignMemberToStore(
person.id!,
context.read<StoreCubit>().assignStaffToStore(
store.id!,
person.id!,
);
} else {
context.read<StaffCubit>().removeMemberFromStore(
context.read<StoreCubit>().removeStaffFromStore(
person.id!,
store.id!,
);
@@ -157,6 +165,8 @@ class _StoresScreenState extends State<StoresScreen> {
),
);
},
);
},
),
);
}
@@ -279,9 +289,7 @@ class _StoresScreenState extends State<StoresScreen> {
);
// Chiamata al Bloc per il salvataggio
context.read<StoreBloc>().add(
CreateStoreRequested(store: storeData),
);
context.read<StoreCubit>().createStore(storeData);
Navigator.pop(context);
},

View File

@@ -1,110 +0,0 @@
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/staff/data/staff_repository.dart';
import 'package:flux/features/staff/models/staff_member_model.dart';
import 'package:get_it/get_it.dart';
part 'staff_state.dart';
class StaffCubit extends Cubit<StaffState> {
final StaffRepository _repository = GetIt.I.get<StaffRepository>();
final SessionBloc _sessionBloc;
StaffCubit(this._sessionBloc) : super(const StaffState());
// Carica tutto lo staff della compagnia
Future<void> loadAllStaff() async {
emit(state.copyWith(isLoading: true, error: null));
try {
final staff = await _repository.getStaffMembers(
_sessionBloc.state.company!.id,
);
emit(state.copyWith(allStaff: staff, isLoading: false));
} catch (e) {
emit(state.copyWith(isLoading: false, error: e.toString()));
}
}
// Carica lo staff di uno specifico negozio e aggiorna la mappa
Future<void> loadStaffForStore(String storeId) async {
try {
final staffInStore = await _repository.getStaffMembersInStore(storeId);
final newMap = Map<String, List<StaffMemberModel>>.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<void> 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<void> assignMemberToStore(String staffId, String storeId) async {
try {
await _repository.assignToStore(staffId, storeId);
await loadStaffForStore(storeId); // Aggiorna solo quel negozio nell'UI
} catch (e) {
emit(state.copyWith(error: "Errore nell'assegnazione: $e"));
}
}
// Rimuove un dipendente da un negozio
Future<void> removeMemberFromStore(String staffId, String storeId) async {
try {
await _repository.removeFromStore(staffId, storeId);
await loadStaffForStore(storeId);
} catch (e) {
emit(state.copyWith(error: "Errore nella rimozione: $e"));
}
}
Future<void> saveStaffWithStores({
required StaffMemberModel member,
required List<String> 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();
// Aggiorniamo anche lo stato dei negozi coinvolti
for (var storeId in selectedStoreIds) {
await loadStaffForStore(storeId);
}
emit(state.copyWith(isLoading: false));
} catch (e) {
emit(state.copyWith(isLoading: false, error: e.toString()));
}
}
}

View File

@@ -1,33 +0,0 @@
part of 'staff_cubit.dart';
class StaffState extends Equatable {
final List<StaffMemberModel> allStaff;
final Map<String, List<StaffMemberModel>>
staffByStore; // storeId -> List of staff
final bool isLoading;
final String? error;
const StaffState({
this.allStaff = const [],
this.staffByStore = const {},
this.isLoading = false,
this.error,
});
StaffState copyWith({
List<StaffMemberModel>? allStaff,
Map<String, List<StaffMemberModel>>? staffByStore,
bool? isLoading,
String? error,
}) {
return StaffState(
allStaff: allStaff ?? this.allStaff,
staffByStore: staffByStore ?? this.staffByStore,
isLoading: isLoading ?? this.isLoading,
error: error,
);
}
@override
List<Object?> get props => [allStaff, staffByStore, isLoading, error];
}

View File

@@ -1,92 +0,0 @@
import 'package:flux/features/staff/models/staff_member_model.dart';
import 'package:flux/features/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<SupabaseClient>();
// --- ANAGRAFICA PURA ---
// Prende tutto lo staff della Company (per l'Hub Anagrafiche)
Future<List<StaffMemberModel>> 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<StaffMemberModel> 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<List<StaffMemberModel>> 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 associati ad un specifico membro
// Qui facciamo una JOIN per avere i dati del membro partendo dalla tabella di giunzione
Future<List<StoreModel>> getStoresWithStaffMember(
String staffMemberId,
) async {
final response = await _supabase
.from('staff_in_stores')
.select(
'store (*)',
) // Prende tutti i campi della tabella store collegata
.eq('staff_member_id', staffMemberId);
return (response as List)
.map((item) => StoreModel.fromJson(item['store']))
.toList();
}
// Assegna un membro a un negozio
Future<void> assignToStore(String staffId, String storeId) async {
await _supabase.from('staff_in_stores').insert({
'staff_member_id': staffId,
'store_id': storeId,
});
}
// Rimuove l'assegnazione
Future<void> 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<void> clearStoreAssignments(String staffId) async {
await _supabase
.from('staff_in_stores')
.delete()
.eq('staff_member_id', staffId);
}
}

View File

@@ -1,47 +0,0 @@
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<String, dynamic> 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<String, dynamic> 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<Object?> get props => [id, name, email, phone, isActive, companyId];
}

View File

@@ -1,292 +0,0 @@
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/staff/blocs/staff_cubit.dart'; // Tuo percorso
import 'package:flux/features/staff/models/staff_member_model.dart';
import 'package:flux/features/store/bloc/store_bloc.dart';
class StaffScreen extends StatefulWidget {
const StaffScreen({super.key});
@override
State<StaffScreen> createState() => _StaffScreenState();
}
class _StaffScreenState extends State<StaffScreen> {
String? _selectedStoreId;
bool _showAllCompanyStaff = false; // Partiamo con la vista globale
@override
void initState() {
super.initState();
// Carichiamo subito tutto
_selectedStoreId = context.read<SessionBloc>().state.selectedStore!.id;
context.read<StaffCubit>().loadStaffForStore(_selectedStoreId!);
}
@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<StaffCubit, StaffState>(
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<StoreBloc, StoreState>(
// Assumendo tu abbia uno StoreCubit
builder: (context, state) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: DropdownButtonFormField<String>(
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<StaffCubit>().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);
// Lista temporanea per le chip (se è un edit, andrebbe popolata con le assegnazioni attuali)
List<String> tempSelectedStores = [];
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => StatefulBuilder(
// Necessario per le chip dentro il BottomSheet
builder: (context, setModalState) => 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) ---
BlocBuilder<StoreBloc, StoreState>(
builder: (context, state) {
return Wrap(
spacing: 8,
runSpacing: 8,
children: state.stores.map((store) {
final isSelected = tempSelectedStores.contains(
store.id,
);
return FilterChip(
label: Text(store.nome),
selected: isSelected,
onSelected: (selected) {
setModalState(() {
selected
? tempSelectedStores.add(store.id!)
: 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 newMember = StaffMemberModel(
id: member?.id,
name: nameController.text,
email: emailController.text,
phone: phoneController.text,
companyId: context
.read<SessionBloc>()
.state
.company!
.id,
);
context.read<StaffCubit>().saveStaffWithStores(
member: newMember,
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),
),
],
),
);
}
}

View File

@@ -1,68 +0,0 @@
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/staff/models/staff_member_model.dart';
import 'package:flux/features/store/data/store_repository.dart';
import 'package:flux/features/store/models/store_model.dart';
import 'package:get_it/get_it.dart';
part 'store_events.dart';
part 'store_state.dart';
class StoreBloc extends Bloc<StoreEvent, StoreState> {
final StoreRepository _repository = GetIt.I<StoreRepository>();
final SessionBloc _sessionBloc;
StoreBloc(this._sessionBloc)
: super(const StoreState(stores: [], staffByStore: {})) {
on<CreateStoreRequested>(_onCreateStore);
on<LoadStoresRequested>(_onLoadStores);
}
Future<void> _onCreateStore(
CreateStoreRequested event,
Emitter<StoreState> 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<void> _onLoadStores(
LoadStoresRequested event,
Emitter<StoreState> emit,
) async {
emit(state.copyWith(status: StoreStatus.loading));
try {
final stores = await _repository.getStoresByCompany(
_sessionBloc.state.company!.id,
);
final staffByStore = <StoreModel, List<StaffMemberModel>>{};
for (final store in stores) {
final staff = await _repository.getStaffMembersInStore(
_sessionBloc.state.company!.id,
);
staffByStore[store] = staff;
}
emit(
state.copyWith(
status: StoreStatus.success,
stores: stores, // Assicurati di avere 'stores' nello StoreState
staffByStore: staffByStore,
),
);
} catch (e) {
emit(
state.copyWith(status: StoreStatus.failure, errorMessage: e.toString()),
);
}
}
}

View File

@@ -1,18 +0,0 @@
part of 'store_bloc.dart';
abstract class StoreEvent extends Equatable {
const StoreEvent();
@override
List<Object?> get props => [];
}
class CreateStoreRequested extends StoreEvent {
final StoreModel store;
const CreateStoreRequested({required this.store});
@override
List<Object?> get props => [store];
}
class LoadStoresRequested extends StoreEvent {}

View File

@@ -1,44 +0,0 @@
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<StoreModel> stores;
final Map<StoreModel, List<StaffMemberModel>> staffByStore;
const StoreState({
this.status = StoreStatus.initial,
this.store,
this.errorMessage,
required this.stores,
required this.staffByStore,
});
StoreState copyWith({
StoreStatus? status,
StoreModel? store,
String? errorMessage,
List<StoreModel>? stores,
Map<StoreModel, List<StaffMemberModel>>? staffByStore,
}) {
return StoreState(
status: status ?? this.status,
store: store ?? this.store,
errorMessage: errorMessage ?? this.errorMessage,
stores: stores ?? this.stores,
staffByStore: staffByStore ?? this.staffByStore,
);
}
@override
List<Object?> get props => [
status,
store,
errorMessage,
stores,
staffByStore,
];
}

View File

@@ -1,106 +0,0 @@
import 'package:flux/features/staff/models/staff_member_model.dart';
import 'package:get_it/get_it.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../models/store_model.dart';
class StoreRepository {
final SupabaseClient _supabase = GetIt.I.get<SupabaseClient>();
/// Crea un nuovo negozio associato alla compagnia dell'utente
Future<StoreModel> createStore(StoreModel store) async {
try {
final response = await _supabase
.from('store')
.upsert(store.toJson())
.select()
.single();
return StoreModel.fromJson(response);
} 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<List<StoreModel>> getStoresByCompany(String companyId) async {
try {
final response = await _supabase
.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';
}
}
Future<void> updateStoreStatus(String id, bool isActive) async {
await _supabase.from('store').update({'is_active': isActive}).eq('id', id);
}
// --- 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<List<StaffMemberModel>> 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 associati ad un specifico membro
// Qui facciamo una JOIN per avere i dati del membro partendo dalla tabella di giunzione
Future<List<StoreModel>> getStoresWithStaffMember(
String staffMemberId,
) async {
final response = await _supabase
.from('staff_in_stores')
.select(
'store (*)',
) // Prende tutti i campi della tabella store collegata
.eq('staff_member_id', staffMemberId);
return (response as List)
.map((item) => StoreModel.fromJson(item['store']))
.toList();
}
// Assegna un negozio a un membro
Future<void> assignToMember(String staffId, String storeId) async {
await _supabase.from('staff_in_stores').insert({
'staff_member_id': staffId,
'store_id': storeId,
});
}
// Rimuove l'assegnazione
Future<void> removeFromStore(String staffId, String storeId) async {
await _supabase
.from('staff_in_stores')
.delete()
.eq('staff_member_id', staffId)
.eq('store_id', storeId);
}
// Utility per pulire le assegnazioni esistenti prima di riscriverle
Future<void> clearStoreAssignments(String staffId) async {
await _supabase
.from('staff_in_stores')
.delete()
.eq('staff_member_id', staffId);
}
}

View File

@@ -1,102 +0,0 @@
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<Object?> 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<String, dynamic> 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<String, dynamic> 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,
};
}
}

View File

@@ -1,245 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/features/store/bloc/store_bloc.dart';
import 'package:flux/features/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<CreateStoreScreen> createState() => _CreateStoreScreenState();
}
class _CreateStoreScreenState extends State<CreateStoreScreen> {
final _formKey = GlobalKey<FormState>();
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<SessionBloc>().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<SessionBloc>().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<StoreBloc>().add(CreateStoreRequested(store: store));
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Il tuo primo Negozio')),
body: BlocConsumer<StoreBloc, StoreState>(
listener: (context, state) {
if (state.status == StoreStatus.success) {
context.read<SessionBloc>().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,
),
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,
),
const SizedBox(height: 16),
// RIGA TRIPLA: Comune, CAP, PR
Row(
crossAxisAlignment: CrossAxisAlignment
.start, // Allinea in alto in caso di errori
children: [
Expanded(
flex: 3,
child: FluxTextField(
label: 'Comune',
icon: Icons.location_city_rounded, // Icona aggiunta
controller: _comuneController,
),
),
const SizedBox(width: 12),
Expanded(
flex: 2,
child: FluxTextField(
label: 'CAP',
icon: Icons.post_add_rounded, // Icona aggiunta
controller: _capController,
keyboardType: TextInputType.number,
),
),
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,
),
),
],
),
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,
),
);
}
}

View File

@@ -1,297 +0,0 @@
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/staff/blocs/staff_cubit.dart';
import 'package:flux/features/staff/models/staff_member_model.dart';
import 'package:flux/features/store/bloc/store_bloc.dart';
import 'package:flux/features/store/models/store_model.dart';
class StoresScreen extends StatefulWidget {
const StoresScreen({super.key});
@override
State<StoresScreen> createState() => _StoresScreenState();
}
class _StoresScreenState extends State<StoresScreen> {
@override
void initState() {
super.initState();
// Carichiamo i negozi e anche lo staff (per poterlo assegnare)
context.read<StoreBloc>().add(LoadStoresRequested());
context.read<StaffCubit>().loadAllStaff();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text("I Tuoi Negozi")),
body: BlocBuilder<StoreBloc, StoreState>(
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, state.staffByStore);
},
);
},
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => _openStoreForm(context),
label: const Text("Nuovo Negozio"),
icon: const Icon(Icons.store),
),
);
}
Widget _buildStoreCard(
StoreModel store,
Map<StoreModel, List<StaffMemberModel>> map,
) {
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<StoreBloc>().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)
ActionChip(
avatar: const Icon(Icons.people, size: 16),
label: Text("${map[store]?.length ?? 0} Dipendenti"),
onPressed: () => _manageStoreStaff(store),
),
TextButton.icon(
onPressed: () => _openStoreForm(context, store: store),
icon: const Icon(Icons.edit, size: 18),
label: const Text("Modifica"),
),
],
),
),
],
),
);
}
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.text,
),
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.local_post_office_outlined,
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.text,
),
),
const SizedBox(width: 8),
Expanded(
flex: 2,
child: FluxTextField(
controller: provinciaController,
label: "Prov",
icon: Icons.explore_outlined,
keyboardType: TextInputType.text,
maxLength: 2,
onChanged: (val) => provinciaController.text = val
.toUpperCase(), // Rimpiazzo automatico in maiuscolo
// Qui potresti aggiungere un rimpiazzo automatico in maiuscolo
),
),
],
),
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<SessionBloc>()
.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<StoreBloc>().add(
CreateStoreRequested(store: storeData),
);
Navigator.pop(context);
},
child: Text(store == null ? "CREA NEGOZIO" : "AGGIORNA DATI"),
),
),
],
),
),
),
);
}
void _manageStoreStaff(StoreModel store) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => BlocBuilder<StaffCubit, StaffState>(
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.staffByStore[store.id!]?.any(
(s) => s.id == person.id,
) ??
false;
return CheckboxListTile(
title: Text(person.name),
value: isAssigned,
onChanged: (selected) {
if (selected == true) {
context.read<StaffCubit>().assignMemberToStore(
person.id!,
store.id!,
);
} else {
context.read<StaffCubit>().removeMemberFromStore(
person.id!,
store.id!,
);
}
},
);
}),
],
),
);
},
),
);
}
}

View File

@@ -14,7 +14,7 @@ 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/bloc/store_cubit.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';
@@ -84,10 +84,8 @@ class _FluxAppState extends State<FluxApp> {
providers: [
BlocProvider<AuthBloc>(create: (_) => AuthBloc()),
BlocProvider<CompanyBloc>(create: (_) => CompanyBloc()),
BlocProvider<StoreBloc>(
create: (_) =>
StoreBloc(context.read<SessionBloc>())
..add(LoadStoresRequested()),
BlocProvider<StoreCubit>(
create: (_) => StoreCubit(context.read<SessionBloc>())..loadStores(),
),
BlocProvider<CustomerBloc>(create: (_) => CustomerBloc()),
BlocProvider<ProductCubit>(