import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flux/core/blocs/session/session_cubit.dart'; import 'package:flux/core/enums_and_consts/consts.dart'; import 'package:flux/core/utils/extensions.dart'; import 'package:flux/features/attachments/models/attachment_model.dart'; import 'package:get_it/get_it.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import '../models/operation_model.dart'; class OperationsRepository { final _supabase = Supabase.instance.client; final companyId = GetIt.I.get().state.company!.id; // --- RECUPERO SINGOLO SERVIZIO CON JOIN COMPLETO --- Future fetchOperationById(String id) async { try { final response = await _supabase .from(Tables.operations) .select(''' *, ${Tables.customers}(*), ${Tables.stores}(name), ${Tables.staffMembers}(name), ${Tables.providers}(name), ${Tables.models}(name_with_brand), ${Tables.attachments}(*) ''') .eq('id', id) .single(); return OperationModel.fromMap(response); } catch (e) { throw Exception('Errore nel caricamento del servizio: $e'); } } // 🥷 2. RECUPERO PAGINATO ASSOLUTO CON CONTEGGIO TOTALI Future fetchPaginatedOperations({ required String companyId, String? storeId, String? staffId, String? providerId, required int page, // Usiamo 'page' (1, 2, 3...) invece di 'offset' int itemsPerPage = 25, // Default a 25 elementi per pagina String? searchTerm, DateTimeRange? dateRange, }) async { try { // Calcoliamo il range di partenza e fine per Supabase // Es. Pagina 1, 25 items -> range(0, 24) // Es. Pagina 2, 25 items -> range(25, 49) final from = (page - 1) * itemsPerPage; final to = from + itemsPerPage - 1; var query = _supabase .from(Tables.operations) .select(''' *, ${Tables.customers}(*), ${Tables.stores}(name), ${Tables.providers}(name, color_hex), ${Tables.models}(name_with_brand), ${Tables.staffMembers}(name), ${Tables.attachments}(*) ''') .eq('company_id', companyId); // Filtro Range Date if (dateRange != null) { query = query .gte('created_at', dateRange.start.toIso8601String()) .lte('created_at', dateRange.end.toIso8601String()); } if (storeId != null) { query = query.or('store_id.eq.$storeId,store_id.is.null'); } if (staffId != null) { query = query.or('staff_id.eq.$staffId,staff_id.is.null'); } if (providerId != null) { query = query.or('provider_id.eq.$providerId,provider_id.is.null'); } if (searchTerm != null && searchTerm.isNotEmpty) { query = query.or( 'reference.ilike.%$searchTerm%,note.ilike.%$searchTerm%,customer.name.ilike.%$searchTerm%', ); } final response = await query .order('created_at', ascending: false) .range(from, to) .count(CountOption.exact); // 3. Estrazione dei dati final List operations = (response.data as List) .map((map) => OperationModel.fromMap(map)) .toList(); final int totalCount = response.count; return PaginatedOperations( operations: operations, totalCount: totalCount, ); } catch (e) { throw Exception('Errore nel recupero della pagina $page: $e'); } } // --- RECUPERO PAGINATO CON FILTRI E JOIN --- /* Future> fetchOperations({ required String companyId, String? storeId, String? staffId, String? providerId, required int offset, int limit = 50, String? searchTerm, DateTimeRange? dateRange, }) async { try { var query = _supabase .from(Tables.operations) .select(''' *, ${Tables.customers}(*), ${Tables.stores}(name), ${Tables.providers}(name), ${Tables.models}(name_with_brand), ${Tables.staffMembers}(name), ${Tables.attachments}(*) ''') .eq('company_id', companyId); // Filtro Range Date if (dateRange != null) { query = query .gte('created_at', dateRange.start.toIso8601String()) .lte('created_at', dateRange.end.toIso8601String()); } if (storeId != null) { query = query.or('store_id.eq.$storeId,store_id.is.null'); } if (staffId != null) { query = query.or('staff_id.eq.$staffId,staff_id.is.null'); } if (providerId != null) { query = query.or('provider_id.eq.$providerId,provider_id.is.null'); } if (searchTerm != null && searchTerm.isNotEmpty) { // Filtra sui campi della tabella principale O su quelli della tabella joinata query = query.or( 'reference.ilike.%$searchTerm%,note.ilike.%$searchTerm%,customer.name.ilike.%$searchTerm%', ); } final response = await query .order('created_at', ascending: false) .range(offset, offset + limit - 1); return (response as List) .map((map) => OperationModel.fromMap(map)) .toList(); } catch (e) { throw Exception('$e'); } } */ Stream>> watchStoreOperations({ required String storeId, required int limit, }) { return _supabase .from(Tables.operations) .stream(primaryKey: ['id']) .eq('store_id', storeId) .order('created_at', ascending: false) .limit(limit); } // --- SALVATAGGIO COMPLETO (PRIMA PADRE, POI FIGLI) --- Future saveFullOperation({ required OperationModel operation, }) async { try { // 1. Salvataggio classico dell'operazione corrente final response = await _supabase .from(Tables.operations) .upsert(operation.toMap()) .select( '*, ${Tables.providers}(*), ${Tables.models}(*), ${Tables.stores}(*), ${Tables.staffMembers}(*), ${Tables.customers}(*), ${Tables.attachments}(*)', ) .single(); final savedOperation = OperationModel.fromMap(response); // 2. ALLINEAMENTO BATCH SEMPRE ATTIVO! if (operation.batchUuid.isNotEmpty) { await _supabase .from(Tables.operations) .update({'note': operation.note}) // Spalmiamo la nota attuale .eq( 'batch_uuid', operation.batchUuid, ); // Su tutte le pratiche di questo scontrino } return savedOperation; } catch (e) { throw Exception("Errore nel salvataggio dell'operazione: $e"); } } // --- ELIMINAZIONE --- Future deleteOperation(String id) async { try { await _supabase.from(Tables.operations).delete().eq('id', id); } catch (e) { throw Exception('$e'); } } // --- RECUPERO TIPI CONTENUTI PIÙ FREQUENTI PER AUTOCOMPLETE --- Future> fetchTopEntertainmentTypes(String companyId) async { try { // Cerchiamo i tipi più frequenti associati ai servizi di questa company // Nota: dobbiamo passare attraverso la tabella 'operation' per filtrare per company_id final response = await _supabase .from(Tables.operations) .select('description') .eq('company_id', companyId) .eq('type', 'Entertainment') .limit(50); // Prendiamo un campione // Logica rapida per contare le occorrenze e prendere i primi 5 final Map counts = {}; for (var item in (response as List)) { final description = item['description'] as String; counts[description] = (counts[description] ?? 0) + 1; } var sortedKeys = counts.keys.toList() ..sort((a, b) => counts[b]!.compareTo(counts[a]!)); return sortedKeys.take(5).toList(); } catch (e) { return [ "Netflix", "DAZN", "Disney+", "Sky", ]; // Fallback se non c'è ancora storia } } /// Ascolta in tempo reale i file caricati per una pratica Stream> getOperationFilesStream(String operationId) { return _supabase .from(Tables.attachments) .stream(primaryKey: ['id']) .eq('operation_id', operationId) .order('created_at', ascending: false) .map( (listOfMaps) => listOfMaps.map((map) => AttachmentModel.fromMap(map)).toList(), ); } Future uploadAndRegisterOperationFile({ required String operationId, required PlatformFile pickedFile, }) async { final cleanFileName = pickedFile.name.replaceAll( RegExp(r'[^a-zA-Z0-9\.\-]'), '_', ); final storagePath = '$companyId/operations/$operationId/${DateTime.now().millisecondsSinceEpoch}_$cleanFileName'; final int fileSize = pickedFile.size; final fileToSave = AttachmentModel( companyId: GetIt.I.get().state.company!.id!, operationId: operationId, name: cleanFileName.fileNameWithoutExtension(), extension: cleanFileName.fileExtension(), storagePath: storagePath, fileSize: fileSize, ); final String mimeType = fileToSave.extension.toLowerCase() == 'pdf' ? 'application/pdf' : 'image/${fileToSave.extension}'; try { // Usiamo bytes invece del path per massima compatibilità if (pickedFile.bytes == null && pickedFile.path == null) { throw 'Impossibile leggere il contenuto del file'; } // Se siamo su desktop/mobile abbiamo il path, su web abbiamo i bytes if (pickedFile.bytes != null) { await _supabase.storage .from('documents') .uploadBinary( storagePath, pickedFile.bytes!, fileOptions: FileOptions(contentType: mimeType, upsert: true), ); } final response = await _supabase .from(Tables.attachments) .insert(fileToSave.toMap()) .select() .single(); return AttachmentModel.fromMap(response); } catch (e) { throw 'Errore durante l\'upload: $e'; } } Future copyFileToCustomer({ required AttachmentModel file, required String customerId, }) async { await _supabase .from(Tables.attachments) .update({'customer_id': customerId}) .eq('id', file.id!); } Future renameAttachment(String id, String newName) async { try { await _supabase .from(Tables.attachments) .update({'name': newName}) .eq('id', id); } catch (e) { throw '$e'; } } Future deleteSpecificOperationFile(AttachmentModel file) async { try { if (file.customerId == null) { await _supabase.from(Tables.attachments).delete().eq('id', file.id!); await _supabase.storage.from('documents').remove([file.storagePath!]); } else { await _supabase .from(Tables.attachments) .update({'operation_id': null}) .eq('id', file.id!); } } catch (e) { throw '$e'; } } Future deleteOperationFiles(List files) async { if (files.isEmpty) return; // 1. Prepariamo le liste di ID e di Percorsi final List idsToDelete = []; final List idsToEdit = []; final List storagePathsToDelete = []; for (var file in files) { if (file.customerId == null) { idsToDelete.add(file.id!); storagePathsToDelete.add(file.storagePath!); } else { idsToEdit.add(file.id!); } } try { if (idsToDelete.isNotEmpty) { await _supabase .from(Tables.attachments) .delete() .inFilter('id', idsToDelete); await _supabase.storage.from('documents').remove(storagePathsToDelete); } if (idsToEdit.isNotEmpty) { await _supabase .from(Tables.attachments) .update({'operation_id': null}) .inFilter('id', idsToEdit); } } on PostgrestException catch (e) { throw 'Errore database: ${e.message}'; } catch (e) { throw 'Errore durante l\'eliminazione dei file: $e'; } } } class PaginatedOperations { final List operations; final int totalCount; PaginatedOperations({required this.operations, required this.totalCount}); }