407 lines
12 KiB
Dart
407 lines
12 KiB
Dart
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<SessionCubit>().state.company!.id;
|
|
|
|
// --- RECUPERO SINGOLO SERVIZIO CON JOIN COMPLETO ---
|
|
Future<OperationModel> 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<PaginatedOperations> 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}(*),
|
|
${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<OperationModel> 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<List<OperationModel>> 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<List<Map<String, dynamic>>> 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<OperationModel> 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<void> 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<List<String>> 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<String, int> 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<List<AttachmentModel>> 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<AttachmentModel> 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<SessionCubit>().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<void> copyFileToCustomer({
|
|
required AttachmentModel file,
|
|
required String customerId,
|
|
}) async {
|
|
await _supabase
|
|
.from(Tables.attachments)
|
|
.update({'customer_id': customerId})
|
|
.eq('id', file.id!);
|
|
}
|
|
|
|
Future<void> renameAttachment(String id, String newName) async {
|
|
try {
|
|
await _supabase
|
|
.from(Tables.attachments)
|
|
.update({'name': newName})
|
|
.eq('id', id);
|
|
} catch (e) {
|
|
throw '$e';
|
|
}
|
|
}
|
|
|
|
Future<void> 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<void> deleteOperationFiles(List<AttachmentModel> files) async {
|
|
if (files.isEmpty) return;
|
|
// 1. Prepariamo le liste di ID e di Percorsi
|
|
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!);
|
|
}
|
|
}
|
|
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<OperationModel> operations;
|
|
final int totalCount;
|
|
|
|
PaginatedOperations({required this.operations, required this.totalCount});
|
|
}
|