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-29 19:25:48 +02:00
|
|
|
import 'package:flux/core/utils/extensions.dart';
|
2026-05-01 11:54:39 +02:00
|
|
|
import 'package:flux/features/attachments/models/attachment_model.dart';
|
2026-04-20 16:52:20 +02:00
|
|
|
import 'package:get_it/get_it.dart';
|
2026-04-16 11:50:29 +02:00
|
|
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
2026-05-01 10:11:44 +02:00
|
|
|
import '../models/operation_model.dart';
|
2026-04-16 11:50:29 +02:00
|
|
|
|
2026-05-01 10:11:44 +02:00
|
|
|
class OperationsRepository {
|
2026-04-16 11:50:29 +02:00
|
|
|
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
|
|
|
|
|
|
|
|
// --- RECUPERO SINGOLO SERVIZIO CON JOIN COMPLETO ---
|
2026-05-01 10:11:44 +02:00
|
|
|
Future<OperationModel> fetchOperationById(String id) async {
|
2026-04-20 16:52:20 +02:00
|
|
|
try {
|
|
|
|
|
final response = await _supabase
|
2026-05-01 09:51:42 +02:00
|
|
|
.from('operation')
|
2026-04-20 16:52:20 +02:00
|
|
|
.select('''
|
|
|
|
|
*,
|
2026-05-01 11:54:39 +02:00
|
|
|
customer(name),
|
2026-05-02 10:22:47 +02:00
|
|
|
store(name),
|
|
|
|
|
staff_member(name),
|
|
|
|
|
provider(name),
|
|
|
|
|
model(name_with_brand),
|
|
|
|
|
attachments(*)
|
2026-04-20 16:52:20 +02:00
|
|
|
''')
|
|
|
|
|
.eq('id', id)
|
|
|
|
|
.single();
|
|
|
|
|
|
2026-05-01 10:11:44 +02:00
|
|
|
return OperationModel.fromMap(response);
|
2026-04-20 16:52:20 +02:00
|
|
|
} catch (e) {
|
|
|
|
|
throw Exception('Errore nel caricamento del servizio: $e');
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-16 11:50:29 +02:00
|
|
|
|
|
|
|
|
// --- RECUPERO PAGINATO CON FILTRI E JOIN ---
|
2026-05-01 10:11:44 +02:00
|
|
|
Future<List<OperationModel>> fetchOperations({
|
2026-04-16 11:50:29 +02:00
|
|
|
required String companyId,
|
|
|
|
|
required int offset,
|
|
|
|
|
int limit = 50,
|
|
|
|
|
String? searchTerm,
|
|
|
|
|
DateTimeRange? dateRange,
|
|
|
|
|
}) async {
|
|
|
|
|
try {
|
|
|
|
|
var query = _supabase
|
2026-05-01 09:51:42 +02:00
|
|
|
.from('operation')
|
2026-04-16 11:50:29 +02:00
|
|
|
.select('''
|
|
|
|
|
*,
|
2026-05-01 11:54:39 +02:00
|
|
|
customer(name),
|
2026-05-02 10:22:47 +02:00
|
|
|
store(name),
|
|
|
|
|
provider(name),
|
|
|
|
|
model(name_with_brand),
|
2026-05-01 11:54:39 +02:00
|
|
|
staff_member(name),
|
|
|
|
|
attachments(*)
|
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-05-01 11:54:39 +02:00
|
|
|
'reference.ilike.%$searchTerm%,note.ilike.%$searchTerm%,customer.name.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)
|
2026-05-01 10:11:44 +02:00
|
|
|
.map((map) => OperationModel.fromMap(map))
|
2026-04-16 11:50:29 +02:00
|
|
|
.toList();
|
|
|
|
|
} catch (e) {
|
2026-05-01 11:54:39 +02:00
|
|
|
throw Exception('$e');
|
2026-04-16 11:50:29 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-01 10:11:44 +02:00
|
|
|
Stream<List<OperationModel>> getLastStoreOperationsStream({
|
2026-04-29 12:34:05 +02:00
|
|
|
required String storeId,
|
|
|
|
|
required int limit,
|
|
|
|
|
}) {
|
|
|
|
|
return _supabase
|
2026-05-01 09:51:42 +02:00
|
|
|
.from('operation')
|
2026-04-29 12:34:05 +02:00
|
|
|
.stream(primaryKey: ['id'])
|
|
|
|
|
.eq('store_id', storeId)
|
|
|
|
|
.order('created_at', ascending: false)
|
|
|
|
|
.limit(limit)
|
|
|
|
|
.map(
|
|
|
|
|
(listOfMaps) =>
|
2026-05-01 10:11:44 +02:00
|
|
|
listOfMaps.map((map) => OperationModel.fromMap(map)).toList(),
|
2026-04-29 12:34:05 +02:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-16 11:50:29 +02:00
|
|
|
// --- SALVATAGGIO COMPLETO (PRIMA PADRE, POI FIGLI) ---
|
2026-05-01 10:11:44 +02:00
|
|
|
Future<OperationModel> saveFullOperation(OperationModel operation) async {
|
2026-04-16 11:50:29 +02:00
|
|
|
try {
|
2026-04-20 16:52:20 +02:00
|
|
|
// 1. Upsert del record principale
|
2026-05-01 10:11:44 +02:00
|
|
|
final operationData = await _supabase
|
2026-05-01 09:51:42 +02:00
|
|
|
.from('operation')
|
|
|
|
|
.upsert(operation.toMap())
|
2026-04-16 11:50:29 +02:00
|
|
|
.select()
|
|
|
|
|
.single();
|
|
|
|
|
|
2026-05-01 10:11:44 +02:00
|
|
|
final String newId = operationData['id'];
|
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
|
2026-05-01 10:11:44 +02:00
|
|
|
// (inclusi quelli della tabella operation_file appena inseriti)
|
|
|
|
|
final updatedOperationData = await _supabase
|
2026-05-01 09:51:42 +02:00
|
|
|
.from('operation')
|
2026-04-26 10:15:34 +02:00
|
|
|
.select('''
|
|
|
|
|
*,
|
2026-05-01 11:54:39 +02:00
|
|
|
staff_member(name),
|
2026-05-02 10:22:47 +02:00
|
|
|
store(name),
|
|
|
|
|
provider(name),
|
|
|
|
|
model(name_with_brand),
|
2026-05-01 11:54:39 +02:00
|
|
|
customer(name),
|
|
|
|
|
attachments(*)
|
2026-04-26 10:15:34 +02:00
|
|
|
''')
|
|
|
|
|
.eq('id', newId)
|
|
|
|
|
.single();
|
|
|
|
|
|
2026-05-01 10:11:44 +02:00
|
|
|
return OperationModel.fromMap(updatedOperationData);
|
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
|
2026-05-01 11:54:39 +02:00
|
|
|
throw Exception('$e');
|
2026-04-16 11:50:29 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- ELIMINAZIONE ---
|
2026-05-01 10:11:44 +02:00
|
|
|
Future<void> deleteOperation(String id) async {
|
2026-04-16 11:50:29 +02:00
|
|
|
try {
|
2026-05-01 09:51:42 +02:00
|
|
|
await _supabase.from('operation').delete().eq('id', id);
|
2026-04-16 11:50:29 +02:00
|
|
|
} catch (e) {
|
2026-05-01 11:54:39 +02:00
|
|
|
throw Exception('$e');
|
2026-04-16 11:50:29 +02:00
|
|
|
}
|
|
|
|
|
}
|
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
|
2026-05-01 09:51:42 +02:00
|
|
|
// Nota: dobbiamo passare attraverso la tabella 'operation' per filtrare per company_id
|
2026-04-20 16:52:20 +02:00
|
|
|
final response = await _supabase
|
2026-05-01 11:54:39 +02:00
|
|
|
.from('operation')
|
|
|
|
|
.select('description')
|
|
|
|
|
.eq('company_id', companyId)
|
|
|
|
|
.eq('type', 'Entertainment')
|
|
|
|
|
.limit(50); // Prendiamo un campione
|
2026-04-20 16:52:20 +02:00
|
|
|
|
|
|
|
|
// Logica rapida per contare le occorrenze e prendere i primi 5
|
|
|
|
|
final Map<String, int> counts = {};
|
|
|
|
|
for (var item in (response as List)) {
|
2026-05-01 11:54:39 +02:00
|
|
|
final description = item['description'] as String;
|
|
|
|
|
counts[description] = (counts[description] ?? 0) + 1;
|
2026-04-20 16:52:20 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
2026-05-01 11:54:39 +02:00
|
|
|
Stream<List<AttachmentModel>> getOperationFilesStream(String operationId) {
|
2026-04-26 10:15:34 +02:00
|
|
|
return _supabase
|
2026-05-01 11:54:39 +02:00
|
|
|
.from('attachment')
|
2026-04-26 10:15:34 +02:00
|
|
|
.stream(primaryKey: ['id'])
|
2026-05-01 10:11:44 +02:00
|
|
|
.eq('operation_id', operationId)
|
2026-04-26 10:15:34 +02:00
|
|
|
.order('created_at', ascending: false)
|
|
|
|
|
.map(
|
|
|
|
|
(listOfMaps) =>
|
2026-05-01 11:54:39 +02:00
|
|
|
listOfMaps.map((map) => AttachmentModel.fromMap(map)).toList(),
|
2026-04-26 10:15:34 +02:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-01 11:54:39 +02:00
|
|
|
Future<AttachmentModel> uploadAndRegisterOperationFile({
|
2026-05-01 10:11:44 +02:00
|
|
|
required String operationId,
|
2026-04-26 10:15:34 +02:00
|
|
|
required PlatformFile pickedFile,
|
|
|
|
|
}) async {
|
|
|
|
|
final cleanFileName = pickedFile.name.replaceAll(
|
|
|
|
|
RegExp(r'[^a-zA-Z0-9\.\-]'),
|
|
|
|
|
'_',
|
|
|
|
|
);
|
|
|
|
|
final storagePath =
|
2026-05-01 10:11:44 +02:00
|
|
|
'$companyId/operations/$operationId/${DateTime.now().millisecondsSinceEpoch}_$cleanFileName';
|
2026-04-26 10:15:34 +02:00
|
|
|
final int fileSize = pickedFile.size;
|
2026-05-01 11:54:39 +02:00
|
|
|
final fileToSave = AttachmentModel(
|
|
|
|
|
companyId: GetIt.I.get<SessionCubit>().state.company!.id!,
|
2026-05-01 10:11:44 +02:00
|
|
|
operationId: operationId,
|
2026-04-26 10:15:34 +02:00
|
|
|
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
|
2026-05-01 11:54:39 +02:00
|
|
|
.from('attachment')
|
2026-04-26 10:15:34 +02:00
|
|
|
.insert(fileToSave.toMap())
|
|
|
|
|
.select()
|
|
|
|
|
.single();
|
|
|
|
|
|
2026-05-01 11:54:39 +02:00
|
|
|
return AttachmentModel.fromMap(response);
|
2026-04-26 10:15:34 +02:00
|
|
|
} catch (e) {
|
|
|
|
|
throw 'Errore durante l\'upload: $e';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 16:52:20 +02:00
|
|
|
Future<void> copyFileToCustomer({
|
2026-05-02 10:22:47 +02:00
|
|
|
required AttachmentModel file,
|
2026-04-20 16:52:20 +02:00
|
|
|
required String customerId,
|
|
|
|
|
}) async {
|
2026-05-01 11:54:39 +02:00
|
|
|
await _supabase
|
|
|
|
|
.from('attachment')
|
|
|
|
|
.update({'customer_id': customerId})
|
|
|
|
|
.eq('id', file.id!);
|
2026-04-26 10:15:34 +02:00
|
|
|
}
|
|
|
|
|
|
2026-05-01 11:54:39 +02:00
|
|
|
Future<void> deleteOperationFiles(List<AttachmentModel> files) async {
|
2026-04-26 10:15:34 +02:00
|
|
|
if (files.isEmpty) return;
|
|
|
|
|
// 1. Prepariamo le liste di ID e di Percorsi
|
2026-05-02 10:22:47 +02:00
|
|
|
final List<String> idsToDelete = [];
|
|
|
|
|
final List<String> idsToEdit = [];
|
|
|
|
|
final List<String> storagePathsToDelete = [];
|
|
|
|
|
for (var file in files) {
|
|
|
|
|
if (file.customerId == null) {
|
|
|
|
|
idsToDelete.add(file.id!);
|
|
|
|
|
storagePathsToDelete.add(file.storagePath);
|
|
|
|
|
} else {
|
|
|
|
|
idsToEdit.add(file.id!);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-26 10:15:34 +02:00
|
|
|
try {
|
2026-05-02 10:22:47 +02:00
|
|
|
if (idsToDelete.isNotEmpty) {
|
|
|
|
|
await _supabase.from('attachment').delete().inFilter('id', idsToDelete);
|
|
|
|
|
await _supabase.storage.from('documents').remove(storagePathsToDelete);
|
|
|
|
|
}
|
|
|
|
|
if (idsToEdit.isNotEmpty) {
|
|
|
|
|
await _supabase
|
|
|
|
|
.from('attachment')
|
|
|
|
|
.update({'operation_id': null})
|
|
|
|
|
.inFilter('id', idsToEdit);
|
|
|
|
|
}
|
2026-04-26 10:15:34 +02:00
|
|
|
} on PostgrestException catch (e) {
|
|
|
|
|
throw 'Errore database: ${e.message}';
|
|
|
|
|
} catch (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
|
|
|
}
|