ticket labels e ticket receipt
This commit is contained in:
@@ -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));
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
342
lib/features/tickets/utils/ticket_pdf_service.dart
Normal file
342
lib/features/tickets/utils/ticket_pdf_service.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user