ticket labels e ticket receipt

This commit is contained in:
2026-05-10 14:09:57 +02:00
parent 385c3da0a5
commit 5c86483563
20 changed files with 1024 additions and 157 deletions

View File

@@ -153,8 +153,12 @@ class TicketFormCubit extends Cubit<TicketFormState> {
if (ticketToSave.customerId == null || ticketToSave.customerId!.isEmpty) {
throw Exception("Seleziona un cliente prima di salvare.");
}
final savedTicket = await _repository.saveTicket(ticketToSave);
TicketModel? savedTicket;
if (ticketToSave.id == null) {
savedTicket = await _repository.insertTicket(ticketToSave);
} else {
savedTicket = await _repository.updateTicket(ticketToSave);
}
if (keepAdding) {
emit(
@@ -198,7 +202,7 @@ class TicketFormCubit extends Cubit<TicketFormState> {
throw Exception("Seleziona un cliente prima di poter usare il QR.");
}
final savedTicket = await _repository.saveTicket(ticketToSave);
final savedTicket = await _repository.insertTicket(ticketToSave);
// Aggiorniamo silenziosamente lo stato con il ticket che ora ha un ID!
emit(state.copyWith(ticket: savedTicket, status: TicketFormStatus.ready));

View File

@@ -192,12 +192,42 @@ class TicketRepository {
}
}
/// Salva il ticket con upsert
Future<TicketModel> saveTicket(TicketModel ticket) async {
Future<String> generateTicketReference(String companyId) async {
final response = await Supabase.instance.client.rpc(
'get_next_document_number',
params: {'p_company_id': companyId, 'p_doc_type': 'ticket'},
);
// Estraiamo i dati dal JSON
final int nextValue = response['next_value'];
final String prefix = response['prefix'] ?? '';
final year = DateTime.now().year; // 2026
// Formattazione con zeri iniziali (es. 000125)
final paddedNumber = nextValue.toString().padLeft(6, '0');
// Costruiamo la stringa. Se c'è un prefisso mette "TCK-2026-000125",
// altrimenti solo "2026-000125"
if (prefix.isNotEmpty) {
return '$prefix-$year-$paddedNumber';
} else {
return '$year-$paddedNumber';
}
}
/// Salva il ticket
Future<TicketModel> insertTicket(TicketModel ticket) async {
if (ticket.id != null) {
throw Exception('Impossibile creare un ticket esistente, id not null');
}
try {
final ticketToSave = ticket.copyWith(
referenceId: await generateTicketReference(ticket.companyId),
);
final response = await _supabase
.from(_tableName)
.upsert(ticket.toMap())
.insert(ticketToSave.toMap())
.select()
.single();

View File

@@ -98,7 +98,7 @@ class TicketModel extends Equatable {
final WarrantyType? warrantyType;
final String? publicNotes;
final String? internalNotes;
final int? referenceNumber;
final String? referenceId;
final String? alternativePhoneNumber;
final bool hasCourtesyDevice;
final TicketType ticketType;
@@ -106,7 +106,6 @@ class TicketModel extends Equatable {
final DateTime? estimatedDeliveryAt;
final TicketResult? ticketResult;
final String? resolutionNotes;
final String? legacyId;
final String? customerName;
final String? targetModelName;
final String? sourceModelName;
@@ -134,7 +133,7 @@ class TicketModel extends Equatable {
this.warrantyType,
this.publicNotes,
this.internalNotes,
this.referenceNumber,
this.referenceId,
this.alternativePhoneNumber,
this.hasCourtesyDevice = false,
required this.ticketType,
@@ -142,7 +141,6 @@ class TicketModel extends Equatable {
this.estimatedDeliveryAt,
this.ticketResult,
this.resolutionNotes,
this.legacyId,
this.customerName,
this.targetModelName,
this.sourceModelName,
@@ -185,7 +183,7 @@ class TicketModel extends Equatable {
WarrantyType? warrantyType,
String? publicNotes,
String? internalNotes,
int? referenceNumber,
String? referenceId,
String? alternativePhoneNumber,
bool? hasCourtesyDevice,
TicketType? ticketType,
@@ -193,7 +191,6 @@ class TicketModel extends Equatable {
DateTime? estimatedDeliveryAt,
TicketResult? ticketResult,
String? resolutionNotes,
String? legacyId,
String? customerName,
String? targetModelName,
String? sourceModelName,
@@ -221,7 +218,7 @@ class TicketModel extends Equatable {
warrantyType: warrantyType ?? this.warrantyType,
publicNotes: publicNotes ?? this.publicNotes,
internalNotes: internalNotes ?? this.internalNotes,
referenceNumber: referenceNumber ?? this.referenceNumber,
referenceId: referenceId ?? this.referenceId,
alternativePhoneNumber:
alternativePhoneNumber ?? this.alternativePhoneNumber,
hasCourtesyDevice: hasCourtesyDevice ?? this.hasCourtesyDevice,
@@ -230,7 +227,6 @@ class TicketModel extends Equatable {
estimatedDeliveryAt: estimatedDeliveryAt ?? this.estimatedDeliveryAt,
ticketResult: ticketResult ?? this.ticketResult,
resolutionNotes: resolutionNotes ?? this.resolutionNotes,
legacyId: legacyId ?? this.legacyId,
customerName: customerName ?? this.customerName,
targetModelName: targetModelName ?? this.targetModelName,
sourceModelName: sourceModelName ?? this.sourceModelName,
@@ -269,7 +265,7 @@ class TicketModel extends Equatable {
warrantyType: WarrantyType.fromString(map['warranty_type'] as String?),
publicNotes: map['public_notes'] as String?,
internalNotes: map['internal_notes'] as String?,
referenceNumber: map['reference_number'] as int?,
referenceId: map['reference_id'] as String?,
alternativePhoneNumber: map['alternative_phone_number'] as String?,
hasCourtesyDevice: map['has_courtesy_device'] as bool? ?? false,
ticketType: TicketType.fromString(map['ticket_type'] as String),
@@ -279,7 +275,6 @@ class TicketModel extends Equatable {
: null,
ticketResult: TicketResult.fromString(map['ticket_result'] as String?),
resolutionNotes: map['resolution_notes'] as String?,
legacyId: map['legacy_id'] as String?,
customerName: (map['customer']?['name'] as String?).myFormat(),
targetModelName: (map['target_model']?['name_with_brand'] as String?)
?.myFormat(),
@@ -314,6 +309,7 @@ class TicketModel extends Equatable {
'warranty_type': warrantyType,
'public_notes': publicNotes,
'internal_notes': internalNotes,
'reference_id': referenceId,
'alternative_phone_number': alternativePhoneNumber,
'has_courtesy_device': hasCourtesyDevice,
'ticket_type': ticketType.value,
@@ -322,7 +318,6 @@ class TicketModel extends Equatable {
'estimated_delivery_at': estimatedDeliveryAt!.toUtc().toIso8601String(),
if (ticketResult != null) 'ticket_result': ticketResult!.value,
'resolution_notes': resolutionNotes,
'legacy_id': legacyId,
'included_accessories': includedAccessories,
};
}
@@ -346,7 +341,6 @@ class TicketModel extends Equatable {
warrantyType,
publicNotes,
internalNotes,
referenceNumber,
alternativePhoneNumber,
hasCourtesyDevice,
ticketType,
@@ -354,7 +348,6 @@ class TicketModel extends Equatable {
estimatedDeliveryAt,
ticketResult,
resolutionNotes,
legacyId,
includedAccessories,
customerName,
targetModelName,

View File

@@ -0,0 +1,342 @@
import 'dart:typed_data';
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;
import 'package:printing/printing.dart';
import 'package:flux/features/tickets/models/ticket_model.dart';
import 'package:flux/features/company/models/company_model.dart';
class TicketPdfService {
/// Funzione principale: Genera il PDF A4 con le due metà
Future<Uint8List> generateTicketReceipt(
TicketModel ticket,
CompanyModel company,
) async {
final pdf = pw.Document();
// Carichiamo il font per essere sicuri che i caratteri siano ok
final font = await PdfGoogleFonts.robotoRegular();
final boldFont = await PdfGoogleFonts.robotoBold();
pdf.addPage(
pw.Page(
pageFormat: PdfPageFormat.a4,
margin: const pw.EdgeInsets.all(20),
build: (context) {
return pw.Column(
children: [
// 1. METÀ SUPERIORE: CLIENTE
_buildTicketHalf(
ticket,
company,
font,
boldFont,
isForCustomer: true,
),
pw.SizedBox(height: 10),
// Linea tratteggiata per il taglio
pw.Container(
margin: const pw.EdgeInsets.symmetric(vertical: 10),
child: pw.Text(
'-' * 100,
style: const pw.TextStyle(color: PdfColors.grey400),
),
),
pw.SizedBox(height: 10),
// 2. METÀ INFERIORE: NEGOZIO
_buildTicketHalf(
ticket,
company,
font,
boldFont,
isForCustomer: false,
),
],
);
},
),
);
return pdf.save();
}
/// Helper per costruire una singola metà (Cliente o Negozio)
pw.Widget _buildTicketHalf(
TicketModel ticket,
CompanyModel company,
pw.Font font,
pw.Font boldFont, {
required bool isForCustomer,
}) {
return pw.Container(
height: 380, // Circa metà A4 meno i margini
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
// HEADER: Logo e Dati Azienda (Solo per cliente o ID per negozio)
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text(
company.name,
style: pw.TextStyle(font: boldFont, fontSize: 16),
),
if (isForCustomer) ...[
pw.Text(
"${company.address}, ${company.city}",
style: const pw.TextStyle(fontSize: 10),
),
pw.Text(
"P.IVA: ${company.vatId}",
style: const pw.TextStyle(fontSize: 10),
),
],
],
),
pw.Row(
children: [
pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.end,
children: [
pw.Text(
isForCustomer
? "RICEVUTA CLIENTE"
: "COPIA INTERNA NEGOZIO",
style: pw.TextStyle(
font: boldFont,
fontSize: 12,
color: PdfColors.grey700,
),
),
pw.Text(
"Rif: ${ticket.referenceId}",
style: pw.TextStyle(font: boldFont, fontSize: 14),
),
pw.Text(
"Data: ${ticket.createdAt?.toString().substring(0, 10) ?? ''}",
style: const pw.TextStyle(fontSize: 10),
),
],
),
pw.SizedBox(width: 10),
// IL NOSTRO QR CODE MAGICO
pw.BarcodeWidget(
barcode: pw.Barcode.qrCode(),
data: ticket.id!, // Salviamo l'ID univoco nel QR!
width: 45,
height: 45,
),
],
),
],
),
pw.Divider(thickness: 1),
pw.SizedBox(height: 10),
// DATI CLIENTE
pw.Row(
children: [
pw.Expanded(
child: _infoBlock(
"CLIENTE",
ticket.customerName ?? 'Cliente Sconosciuto',
font,
boldFont,
),
),
pw.Expanded(
child: _infoBlock(
"CONTATTO ALTERNATIVO",
ticket.alternativePhoneNumber ?? 'N/D',
font,
boldFont,
),
),
],
),
pw.SizedBox(height: 15),
// DETTAGLI LAVORAZIONE
_infoBlock(
"DESCRIZIONE PROBLEMA / LAVORAZIONE RICHIESTA",
ticket.request,
font,
boldFont,
),
pw.SizedBox(height: 8),
pw.Row(
children: [
pw.Expanded(
child: _infoBlock(
"ACCESSORI CONSEGNATI",
ticket.includedAccessories ?? 'Nessuno',
font,
boldFont,
),
),
pw.Expanded(
child: _infoBlock(
"GARANZIA",
ticket.warrantyType?.displayValue ?? 'Standard',
font,
boldFont,
),
),
],
),
pw.SizedBox(height: 15),
// NOTE (Pubbliche o Private a seconda della copia)
if (isForCustomer)
_infoBlock("NOTE", ticket.publicNotes ?? '-', font, boldFont)
else
_infoBlock(
"NOTE INTERNE (PRIVATE)",
ticket.internalNotes ?? '-',
font,
boldFont,
),
pw.Spacer(),
// FOOTER: Disclaimer e Firma
if (!isForCustomer) ...[
pw.Text(
"CONDIZIONI E LIBERATORIA:",
style: pw.TextStyle(font: boldFont, fontSize: 8),
),
pw.Text(
company.ticketDisclaimer ??
'Firma per accettazione delle condizioni di riparazione.',
style: const pw.TextStyle(fontSize: 7),
textAlign: pw.TextAlign.justify,
),
pw.SizedBox(height: 20),
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Container(
width: 150,
decoration: pw.BoxDecoration(
border: const pw.Border(top: pw.BorderSide(width: 0.5)),
),
),
pw.Text(
"Firma del Cliente per accettazione",
style: const pw.TextStyle(fontSize: 8),
),
],
),
] else
pw.Align(
alignment: pw.Alignment.centerRight,
child: pw.Text(
"Grazie per averci scelto!",
style: pw.TextStyle(
font: font,
fontSize: 10,
fontStyle: pw.FontStyle.italic,
),
),
),
],
),
);
}
pw.Widget _infoBlock(
String label,
String value,
pw.Font font,
pw.Font boldFont,
) {
return pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text(
label,
style: pw.TextStyle(
font: boldFont,
fontSize: 8,
color: PdfColors.grey600,
),
),
pw.Text(value, style: pw.TextStyle(font: font, fontSize: 11)),
],
);
}
Future<Uint8List> generateLabelPdf(
TicketModel ticket,
CompanyModel company,
) async {
final pdf = pw.Document();
final font = await PdfGoogleFonts.robotoRegular();
final boldFont = await PdfGoogleFonts.robotoBold();
// Prendiamo le misure salvate (se custom) o usiamo default
final widthMm = company.labelWidth ?? 62.0;
final heightMm = company.labelHeight ?? 29.0;
// Creiamo il formato fisico esatto!
final format = company.isLabelVertical
? PdfPageFormat(heightMm * PdfPageFormat.mm, widthMm * PdfPageFormat.mm)
: PdfPageFormat(
widthMm * PdfPageFormat.mm,
heightMm * PdfPageFormat.mm,
);
pdf.addPage(
pw.Page(
pageFormat: format,
margin: const pw.EdgeInsets.all(2), // Margini minimi per le etichette
build: (context) {
return pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Expanded(
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
mainAxisAlignment: pw.MainAxisAlignment.center,
children: [
pw.Text(
ticket.referenceId ?? '',
style: pw.TextStyle(font: boldFont, fontSize: 10),
),
pw.Text(
ticket.customerName ?? 'Cliente sconosciuto',
style: pw.TextStyle(font: font, fontSize: 9),
),
pw.Text(
ticket.createdAt?.toString().substring(0, 10) ?? '',
style: const pw.TextStyle(fontSize: 7),
),
],
),
),
// QR Code compatto
pw.BarcodeWidget(
barcode: pw.Barcode.qrCode(),
data: ticket.id!,
width: 20,
height: 20,
),
],
);
},
),
);
return pdf.save();
}
}