2026-04-26 10:15:34 +02:00
|
|
|
import 'package:file_picker/file_picker.dart';
|
2026-04-16 11:50:29 +02:00
|
|
|
import 'package:flutter/material.dart';
|
2026-04-22 11:06:02 +02:00
|
|
|
import 'package:flux/core/blocs/session/session_cubit.dart';
|
2026-04-26 10:15:34 +02:00
|
|
|
import 'package:flux/core/utils/string_extensions.dart';
|
2026-04-20 16:52:20 +02:00
|
|
|
import 'package:flux/features/customers/data/customer_repository.dart';
|
|
|
|
|
import 'package:flux/features/customers/models/customer_file_model.dart';
|
|
|
|
|
import 'package:flux/features/services/models/service_file_model.dart';
|
|
|
|
|
import 'package:get_it/get_it.dart';
|
2026-04-16 11:50:29 +02:00
|
|
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
|
|
|
|
import '../models/service_model.dart';
|
|
|
|
|
|
|
|
|
|
class ServicesRepository {
|
|
|
|
|
final _supabase = Supabase.instance.client;
|
2026-04-22 11:06:02 +02:00
|
|
|
final companyId = GetIt.I.get<SessionCubit>().state.company!.id;
|
2026-04-20 16:52:20 +02:00
|
|
|
final CustomerRepository _customerRepository = GetIt.I<CustomerRepository>();
|
|
|
|
|
|
|
|
|
|
// --- RECUPERO SINGOLO SERVIZIO CON JOIN COMPLETO ---
|
|
|
|
|
Future<ServiceModel> fetchServiceById(String id) async {
|
|
|
|
|
try {
|
|
|
|
|
final response = await _supabase
|
|
|
|
|
.from('service')
|
|
|
|
|
.select('''
|
|
|
|
|
*,
|
|
|
|
|
customer(nome),
|
|
|
|
|
energy_service(*),
|
|
|
|
|
fin_service(*),
|
|
|
|
|
entertainment_service(*),
|
|
|
|
|
service_file(*)
|
|
|
|
|
''')
|
|
|
|
|
.eq('id', id)
|
|
|
|
|
.single();
|
|
|
|
|
|
|
|
|
|
return ServiceModel.fromMap(response);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
throw Exception('Errore nel caricamento del servizio: $e');
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-16 11:50:29 +02:00
|
|
|
|
|
|
|
|
// --- RECUPERO PAGINATO CON FILTRI E JOIN ---
|
|
|
|
|
Future<List<ServiceModel>> fetchServices({
|
|
|
|
|
required String companyId,
|
|
|
|
|
required int offset,
|
|
|
|
|
int limit = 50,
|
|
|
|
|
String? searchTerm,
|
|
|
|
|
DateTimeRange? dateRange,
|
|
|
|
|
}) async {
|
|
|
|
|
try {
|
|
|
|
|
// Nota: 'customer(name, surname)' serve per il display name nella card
|
|
|
|
|
var query = _supabase
|
|
|
|
|
.from('service')
|
|
|
|
|
.select('''
|
|
|
|
|
*,
|
2026-04-20 16:52:20 +02:00
|
|
|
customer(nome),
|
2026-04-16 11:50:29 +02:00
|
|
|
energy_service(*),
|
|
|
|
|
fin_service(*),
|
2026-04-20 16:52:20 +02:00
|
|
|
entertainment_service(*),
|
|
|
|
|
service_file(*)
|
2026-04-16 11:50:29 +02:00
|
|
|
''')
|
|
|
|
|
.eq('company_id', companyId);
|
|
|
|
|
|
|
|
|
|
// Filtro Range Date
|
|
|
|
|
if (dateRange != null) {
|
|
|
|
|
query = query
|
|
|
|
|
.gte('created_at', dateRange.start.toIso8601String())
|
|
|
|
|
.lte('created_at', dateRange.end.toIso8601String());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (searchTerm != null && searchTerm.isNotEmpty) {
|
|
|
|
|
// Filtra sui campi della tabella principale O su quelli della tabella joinata
|
|
|
|
|
query = query.or(
|
2026-04-20 16:52:20 +02:00
|
|
|
'number.ilike.%$searchTerm%,note.ilike.%$searchTerm%,customer.nome.ilike.%$searchTerm%',
|
2026-04-16 11:50:29 +02:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final response = await query
|
|
|
|
|
.order('created_at', ascending: false)
|
|
|
|
|
.range(offset, offset + limit - 1);
|
|
|
|
|
|
|
|
|
|
return (response as List)
|
|
|
|
|
.map((map) => ServiceModel.fromMap(map))
|
|
|
|
|
.toList();
|
|
|
|
|
} catch (e) {
|
|
|
|
|
throw Exception('Errore nel caricamento servizi: $e');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 12:34:05 +02:00
|
|
|
Stream<List<ServiceModel>> getLastStoreServicesStream({
|
|
|
|
|
required String storeId,
|
|
|
|
|
required int limit,
|
|
|
|
|
}) {
|
|
|
|
|
return _supabase
|
|
|
|
|
.from('service')
|
|
|
|
|
.stream(primaryKey: ['id'])
|
|
|
|
|
.eq('store_id', storeId)
|
|
|
|
|
.order('created_at', ascending: false)
|
|
|
|
|
.limit(limit)
|
|
|
|
|
.map(
|
|
|
|
|
(listOfMaps) =>
|
|
|
|
|
listOfMaps.map((map) => ServiceModel.fromMap(map)).toList(),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-16 11:50:29 +02:00
|
|
|
// --- SALVATAGGIO COMPLETO (PRIMA PADRE, POI FIGLI) ---
|
2026-04-26 10:15:34 +02:00
|
|
|
Future<ServiceModel> saveFullService(ServiceModel service) async {
|
2026-04-16 11:50:29 +02:00
|
|
|
try {
|
2026-04-20 16:52:20 +02:00
|
|
|
// 1. Upsert del record principale
|
2026-04-16 11:50:29 +02:00
|
|
|
final serviceData = await _supabase
|
|
|
|
|
.from('service')
|
|
|
|
|
.upsert(service.toMap())
|
|
|
|
|
.select()
|
|
|
|
|
.single();
|
|
|
|
|
|
|
|
|
|
final String newId = serviceData['id'];
|
|
|
|
|
|
2026-04-20 16:52:20 +02:00
|
|
|
// 2. MODIFICA: Pulizia atomica dei figli
|
|
|
|
|
// Se stiamo modificando (id != null), resettiamo le tabelle collegate
|
2026-04-16 11:50:29 +02:00
|
|
|
if (service.id != null) {
|
2026-04-20 16:52:20 +02:00
|
|
|
await Future.wait([
|
|
|
|
|
_supabase.from('energy_service').delete().eq('service_id', newId),
|
|
|
|
|
_supabase.from('fin_service').delete().eq('service_id', newId),
|
|
|
|
|
_supabase
|
|
|
|
|
.from('entertainment_service')
|
|
|
|
|
.delete()
|
|
|
|
|
.eq('service_id', newId),
|
|
|
|
|
// Aggiungi qui eventuali altre tabelle pivot o file
|
|
|
|
|
]);
|
2026-04-16 11:50:29 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-20 16:52:20 +02:00
|
|
|
// 3. Inserimento dei moduli in parallelo per velocità
|
|
|
|
|
final List<Future> insertTasks = [];
|
|
|
|
|
|
2026-04-16 11:50:29 +02:00
|
|
|
if (service.energyServices.isNotEmpty) {
|
2026-04-20 16:52:20 +02:00
|
|
|
insertTasks.add(
|
|
|
|
|
_supabase
|
|
|
|
|
.from('energy_service')
|
|
|
|
|
.insert(
|
|
|
|
|
service.energyServices
|
|
|
|
|
.map((item) => item.copyWith(serviceId: newId).toMap())
|
|
|
|
|
.toList(),
|
|
|
|
|
),
|
|
|
|
|
);
|
2026-04-16 11:50:29 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (service.finServices.isNotEmpty) {
|
2026-04-20 16:52:20 +02:00
|
|
|
insertTasks.add(
|
|
|
|
|
_supabase
|
|
|
|
|
.from('fin_service')
|
|
|
|
|
.insert(
|
|
|
|
|
service.finServices
|
|
|
|
|
.map((item) => item.copyWith(serviceId: newId).toMap())
|
|
|
|
|
.toList(),
|
|
|
|
|
),
|
|
|
|
|
);
|
2026-04-16 11:50:29 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (service.entertainmentServices.isNotEmpty) {
|
2026-04-20 16:52:20 +02:00
|
|
|
insertTasks.add(
|
|
|
|
|
_supabase
|
|
|
|
|
.from('entertainment_service')
|
|
|
|
|
.insert(
|
|
|
|
|
service.entertainmentServices
|
|
|
|
|
.map((item) => item.copyWith(serviceId: newId).toMap())
|
|
|
|
|
.toList(),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (insertTasks.isNotEmpty) {
|
|
|
|
|
await Future.wait(insertTasks);
|
|
|
|
|
}
|
2026-04-26 10:15:34 +02:00
|
|
|
|
|
|
|
|
// 4. UPLOAD DEI FILE LOCALI (Nuovi)
|
|
|
|
|
// Filtriamo solo i file che non hanno ancora un ID (quindi sono locali)
|
|
|
|
|
final localFilesToUpload = service.files
|
|
|
|
|
.where((f) => f.id == null)
|
|
|
|
|
.toList();
|
|
|
|
|
|
|
|
|
|
if (localFilesToUpload.isNotEmpty) {
|
2026-04-20 16:52:20 +02:00
|
|
|
final List<Future> uploadTasks = [];
|
|
|
|
|
|
2026-04-26 10:15:34 +02:00
|
|
|
for (var file in localFilesToUpload) {
|
2026-04-20 16:52:20 +02:00
|
|
|
final storagePath =
|
|
|
|
|
'$companyId/services/$newId/${DateTime.now().millisecondsSinceEpoch}_${file.name}.${file.extension}';
|
|
|
|
|
final String mimeType = file.extension.toLowerCase() == 'pdf'
|
|
|
|
|
? 'application/pdf'
|
|
|
|
|
: 'image/${file.extension}';
|
2026-04-26 10:15:34 +02:00
|
|
|
|
|
|
|
|
final fileToSave = file.copyWith(
|
|
|
|
|
serviceId: newId,
|
|
|
|
|
storagePath: storagePath,
|
|
|
|
|
);
|
2026-04-20 16:52:20 +02:00
|
|
|
|
|
|
|
|
// Creiamo una funzione asincrona per caricare file e scrivere nel DB
|
|
|
|
|
Future<void> uploadAndLink() async {
|
|
|
|
|
// A. Upload nel Bucket Storage (usiamo i bytes così funziona anche su Web!)
|
|
|
|
|
await _supabase.storage
|
|
|
|
|
.from('documents')
|
|
|
|
|
.uploadBinary(
|
|
|
|
|
storagePath,
|
|
|
|
|
fileToSave.localBytes!,
|
2026-04-26 10:15:34 +02:00
|
|
|
fileOptions: FileOptions(contentType: mimeType, upsert: true),
|
2026-04-20 16:52:20 +02:00
|
|
|
);
|
|
|
|
|
|
2026-04-26 10:15:34 +02:00
|
|
|
// B. Inserimento riga nel DB relazionale
|
2026-04-20 16:52:20 +02:00
|
|
|
await _supabase.from('service_file').insert(fileToSave.toMap());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
uploadTasks.add(uploadAndLink());
|
2026-04-16 11:50:29 +02:00
|
|
|
}
|
2026-04-20 16:52:20 +02:00
|
|
|
|
|
|
|
|
// Eseguiamo tutti gli upload in parallelo per la massima velocità
|
|
|
|
|
await Future.wait(uploadTasks);
|
2026-04-16 11:50:29 +02:00
|
|
|
}
|
2026-04-26 10:15:34 +02:00
|
|
|
|
|
|
|
|
// 5. GRAN FINALE: RECUPERO DEL MODELLO COMPLETO E AGGIORNATO
|
|
|
|
|
// Interroghiamo Supabase per farci restituire la pratica con TUTTI gli ID generati
|
|
|
|
|
// (inclusi quelli della tabella service_file appena inseriti)
|
|
|
|
|
final updatedServiceData = await _supabase
|
|
|
|
|
.from('service')
|
|
|
|
|
.select('''
|
|
|
|
|
*,
|
|
|
|
|
energy_service(*),
|
|
|
|
|
fin_service(*),
|
|
|
|
|
entertainment_service(*),
|
|
|
|
|
service_file(*)
|
|
|
|
|
''')
|
|
|
|
|
.eq('id', newId)
|
|
|
|
|
.single();
|
|
|
|
|
|
|
|
|
|
return ServiceModel.fromMap(updatedServiceData);
|
2026-04-16 11:50:29 +02:00
|
|
|
} catch (e) {
|
2026-04-20 16:52:20 +02:00
|
|
|
// Qui potresti aggiungere una logica di "rollback manuale" se necessario
|
|
|
|
|
throw Exception('Errore durante il salvataggio corazzato: $e');
|
2026-04-16 11:50:29 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- ELIMINAZIONE ---
|
|
|
|
|
Future<void> deleteService(String id) async {
|
|
|
|
|
try {
|
|
|
|
|
await _supabase.from('service').delete().eq('id', id);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
throw Exception('Errore durante l\'eliminazione: $e');
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-20 16:52:20 +02:00
|
|
|
|
|
|
|
|
// --- RECUPERO TIPI CONTENUTI PIÙ FREQUENTI PER AUTOCOMPLETE ---
|
|
|
|
|
Future<List<String>> fetchTopEntertainmentTypes(String companyId) async {
|
|
|
|
|
try {
|
|
|
|
|
// Cerchiamo i tipi più frequenti associati ai servizi di questa company
|
|
|
|
|
// Nota: dobbiamo passare attraverso la tabella 'service' per filtrare per company_id
|
|
|
|
|
final response = await _supabase
|
|
|
|
|
.from('entertainment_service')
|
|
|
|
|
.select('type, service!inner(store!inner(company_id))')
|
|
|
|
|
.eq('service.store.company_id', companyId)
|
|
|
|
|
.limit(100); // Prendiamo un campione
|
|
|
|
|
|
|
|
|
|
// Logica rapida per contare le occorrenze e prendere i primi 5
|
|
|
|
|
final Map<String, int> counts = {};
|
|
|
|
|
for (var item in (response as List)) {
|
|
|
|
|
final type = item['type'] as String;
|
|
|
|
|
counts[type] = (counts[type] ?? 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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-26 10:15:34 +02:00
|
|
|
/// Ascolta in tempo reale i file caricati per una pratica
|
|
|
|
|
Stream<List<ServiceFileModel>> getServiceFilesStream(String serviceId) {
|
|
|
|
|
return _supabase
|
|
|
|
|
.from('service_file')
|
|
|
|
|
.stream(primaryKey: ['id'])
|
|
|
|
|
.eq('service_id', serviceId)
|
|
|
|
|
.order('created_at', ascending: false)
|
|
|
|
|
.map(
|
|
|
|
|
(listOfMaps) =>
|
|
|
|
|
listOfMaps.map((map) => ServiceFileModel.fromMap(map)).toList(),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<ServiceFileModel> uploadAndRegisterServiceFile({
|
|
|
|
|
required String serviceId,
|
|
|
|
|
required PlatformFile pickedFile,
|
|
|
|
|
}) async {
|
|
|
|
|
final cleanFileName = pickedFile.name.replaceAll(
|
|
|
|
|
RegExp(r'[^a-zA-Z0-9\.\-]'),
|
|
|
|
|
'_',
|
|
|
|
|
);
|
|
|
|
|
final storagePath =
|
|
|
|
|
'$companyId/services/$serviceId/${DateTime.now().millisecondsSinceEpoch}_$cleanFileName';
|
|
|
|
|
final int fileSize = pickedFile.size;
|
|
|
|
|
final fileToSave = ServiceFileModel(
|
|
|
|
|
serviceId: serviceId,
|
|
|
|
|
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('service_file')
|
|
|
|
|
.insert(fileToSave.toMap())
|
|
|
|
|
.select()
|
|
|
|
|
.single();
|
|
|
|
|
|
|
|
|
|
return ServiceFileModel.fromMap(response);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
throw 'Errore durante l\'upload: $e';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 16:52:20 +02:00
|
|
|
Future<void> copyFileToCustomer({
|
|
|
|
|
required ServiceFileModel file,
|
|
|
|
|
required String customerId,
|
|
|
|
|
}) async {
|
|
|
|
|
CustomerFileModel fileToCopy = CustomerFileModel(
|
|
|
|
|
customerId: customerId,
|
|
|
|
|
name: file.name,
|
2026-04-26 10:15:34 +02:00
|
|
|
storagePath: file.storagePath,
|
2026-04-20 16:52:20 +02:00
|
|
|
extension: file.extension,
|
|
|
|
|
fileSize: file.fileSize,
|
|
|
|
|
);
|
2026-04-26 10:15:34 +02:00
|
|
|
await _customerRepository.saveFileReference(fileToCopy);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> deleteServiceFiles(List<ServiceFileModel> files) async {
|
|
|
|
|
if (files.isEmpty) return;
|
|
|
|
|
// 1. Prepariamo le liste di ID e di Percorsi
|
|
|
|
|
final List<String> idsToDelete = files.map((f) => f.id!).toList();
|
|
|
|
|
final List<String> storagePaths = files.map((f) => f.storagePath).toList();
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await _supabase.from('service_file').delete().inFilter('id', idsToDelete);
|
|
|
|
|
|
|
|
|
|
await _supabase.storage.from('documents').remove(storagePaths);
|
|
|
|
|
|
|
|
|
|
debugPrint("Eliminati con successo ${files.length} file.");
|
|
|
|
|
} on PostgrestException catch (e) {
|
|
|
|
|
debugPrint("Errore DB: ${e.message}");
|
|
|
|
|
throw 'Errore database: ${e.message}';
|
|
|
|
|
} catch (e) {
|
|
|
|
|
debugPrint("Errore generico: $e");
|
|
|
|
|
throw 'Errore durante l\'eliminazione dei file: $e';
|
|
|
|
|
}
|
2026-04-20 16:52:20 +02:00
|
|
|
}
|
2026-04-16 11:50:29 +02:00
|
|
|
}
|