Agrego frontend app
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gimnasio_soma/core/widgets/soma_toast.dart';
|
||||
import 'package:gimnasio_soma/features/pagos/domain/entities/pago.dart';
|
||||
import 'package:pdf/pdf.dart';
|
||||
import 'package:pdf/widgets.dart' as pw;
|
||||
import 'package:printing/printing.dart';
|
||||
|
||||
class PagosExport {
|
||||
// ── CSV ───────────────────────────────────────────────────────────────────
|
||||
|
||||
static Future<void> exportToCsv(
|
||||
BuildContext context,
|
||||
List<Pago> pagos, {
|
||||
String? filtroMes,
|
||||
}) async {
|
||||
final csvBytes = _buildCsvBytes(pagos);
|
||||
|
||||
final stamp = DateTime.now();
|
||||
final defaultName =
|
||||
'pagos_${stamp.year}${stamp.month.toString().padLeft(2, '0')}${stamp.day.toString().padLeft(2, '0')}.csv';
|
||||
|
||||
final outputPath = await FilePicker.platform.saveFile(
|
||||
dialogTitle: 'Guardar pagos como CSV',
|
||||
fileName: defaultName,
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['csv'],
|
||||
);
|
||||
|
||||
if (outputPath == null) return; // usuario canceló
|
||||
|
||||
await File(outputPath).writeAsBytes(csvBytes);
|
||||
|
||||
if (context.mounted) {
|
||||
SomaToast.show(context, message: 'CSV guardado correctamente', type: ToastType.success);
|
||||
}
|
||||
}
|
||||
|
||||
static List<int> _buildCsvBytes(List<Pago> pagos) {
|
||||
final buf = StringBuffer();
|
||||
buf.writeln('dni,nombre_apellido,anio_mes_pagado,monto_total,metodo,fecha_pago');
|
||||
for (final p in pagos) {
|
||||
final c = p.cliente;
|
||||
final dni = _csvCell(c?.dni ?? '');
|
||||
final nombre = _csvCell('${c?.nombre ?? ''} ${c?.apellido ?? ''}'.trim());
|
||||
// anio_mes: usar solo YYYY-MM para reimportar
|
||||
final mes = p.anioMesPagado.length >= 7 ? p.anioMesPagado.substring(0, 7) : p.anioMesPagado;
|
||||
final monto = p.montoTotal.toStringAsFixed(2);
|
||||
final metodo = _csvCell(p.metodo);
|
||||
final fecha = p.fechaPago != null
|
||||
? '${p.fechaPago!.year}-'
|
||||
'${p.fechaPago!.month.toString().padLeft(2, '0')}-'
|
||||
'${p.fechaPago!.day.toString().padLeft(2, '0')}'
|
||||
: '';
|
||||
buf.writeln('$dni,$nombre,$mes,$monto,$metodo,$fecha');
|
||||
}
|
||||
// BOM para compatibilidad con Excel (UTF-8)
|
||||
return [0xEF, 0xBB, 0xBF, ...utf8.encode(buf.toString())];
|
||||
}
|
||||
|
||||
// Envuelve la celda en comillas si contiene coma, comilla o salto de línea.
|
||||
static String _csvCell(String value) {
|
||||
if (value.contains(',') || value.contains('"') || value.contains('\n')) {
|
||||
return '"${value.replaceAll('"', '""')}"';
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// ── PDF ───────────────────────────────────────────────────────────────────
|
||||
|
||||
static Future<void> exportToPdf(
|
||||
BuildContext context,
|
||||
List<Pago> pagos, {
|
||||
String? filtroMes,
|
||||
}) async {
|
||||
final doc = _buildPdfDocument(pagos, filtroMes: filtroMes);
|
||||
|
||||
await Printing.layoutPdf(
|
||||
onLayout: (_) => doc.save(),
|
||||
name: filtroMes != null ? 'Pagos $filtroMes' : 'Pagos',
|
||||
);
|
||||
}
|
||||
|
||||
static pw.Document _buildPdfDocument(List<Pago> pagos, {String? filtroMes}) {
|
||||
final doc = pw.Document();
|
||||
|
||||
final totalMonto = pagos.fold<double>(0, (sum, p) => sum + p.montoTotal);
|
||||
|
||||
doc.addPage(
|
||||
pw.MultiPage(
|
||||
pageFormat: PdfPageFormat.a4,
|
||||
margin: const pw.EdgeInsets.symmetric(horizontal: 32, vertical: 36),
|
||||
header: (_) => pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
pw.Row(
|
||||
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
pw.Text(
|
||||
'SOMA – Listado de Pagos',
|
||||
style: pw.TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
),
|
||||
),
|
||||
if (filtroMes != null)
|
||||
pw.Text(
|
||||
filtroMes,
|
||||
style: const pw.TextStyle(fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
pw.SizedBox(height: 4),
|
||||
pw.Divider(thickness: 0.5),
|
||||
],
|
||||
),
|
||||
footer: (ctx) => pw.Row(
|
||||
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
pw.Text(
|
||||
'Total: \$${totalMonto.toStringAsFixed(2)} · ${pagos.length} pago${pagos.length == 1 ? '' : 's'}',
|
||||
style: pw.TextStyle(fontSize: 9, fontWeight: pw.FontWeight.bold),
|
||||
),
|
||||
pw.Text(
|
||||
'Pág. ${ctx.pageNumber} / ${ctx.pagesCount}',
|
||||
style: const pw.TextStyle(fontSize: 9),
|
||||
),
|
||||
],
|
||||
),
|
||||
build: (ctx) => [
|
||||
pw.TableHelper.fromTextArray(
|
||||
headers: ['DNI', 'Socio', 'Mes pagado', 'Método', 'Monto'],
|
||||
headerStyle: pw.TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
),
|
||||
cellStyle: const pw.TextStyle(fontSize: 9),
|
||||
headerDecoration: const pw.BoxDecoration(color: PdfColors.grey200),
|
||||
cellAlignments: {
|
||||
0: pw.Alignment.centerLeft,
|
||||
1: pw.Alignment.centerLeft,
|
||||
2: pw.Alignment.centerLeft,
|
||||
3: pw.Alignment.centerLeft,
|
||||
4: pw.Alignment.centerRight,
|
||||
},
|
||||
columnWidths: {
|
||||
0: const pw.FixedColumnWidth(72),
|
||||
1: const pw.FlexColumnWidth(2.5),
|
||||
2: const pw.FlexColumnWidth(1.8),
|
||||
3: const pw.FlexColumnWidth(1.8),
|
||||
4: const pw.FixedColumnWidth(68),
|
||||
},
|
||||
data: pagos.map((p) {
|
||||
final c = p.cliente;
|
||||
return [
|
||||
c?.dni ?? '',
|
||||
c != null ? '${c.apellido}, ${c.nombre}'.trim() : '',
|
||||
p.mesPagadoDisplay,
|
||||
p.metodo,
|
||||
'\$${p.montoTotal.toStringAsFixed(2)}',
|
||||
];
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
return doc;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import 'dart:convert';
|
||||
|
||||
/// Fila parseada de un CSV de pagos.
|
||||
class PagosImportRow {
|
||||
final int rowNumber;
|
||||
final String dni;
|
||||
final String anioMesPagado; // formato YYYY-MM-01
|
||||
final double montoTotal;
|
||||
final String metodoNombre;
|
||||
final String? fechaPago; // YYYY-MM-DD, opcional
|
||||
final String? validationError;
|
||||
|
||||
const PagosImportRow({
|
||||
required this.rowNumber,
|
||||
required this.dni,
|
||||
required this.anioMesPagado,
|
||||
required this.montoTotal,
|
||||
required this.metodoNombre,
|
||||
this.fechaPago,
|
||||
this.validationError,
|
||||
});
|
||||
|
||||
bool get isValid => validationError == null;
|
||||
}
|
||||
|
||||
/// Resultado del parseo de un CSV exportado por la app.
|
||||
class PagosImportResult {
|
||||
final List<PagosImportRow> rows; // incluye válidas e inválidas
|
||||
final List<String> parseErrors; // errores que impidieron leer el archivo
|
||||
|
||||
const PagosImportResult({required this.rows, this.parseErrors = const []});
|
||||
|
||||
List<PagosImportRow> get valid => rows.where((r) => r.isValid).toList();
|
||||
List<PagosImportRow> get invalid => rows.where((r) => !r.isValid).toList();
|
||||
}
|
||||
|
||||
/// Parsea el contenido de un CSV exportado con [PagosExport.exportToCsv].
|
||||
/// Columnas esperadas: dni, nombre_apellido, anio_mes_pagado, monto_total, metodo, fecha_pago
|
||||
PagosImportResult parsePagosCsv(List<int> bytes) {
|
||||
// Quitar BOM UTF-8 si está presente
|
||||
final content = bytes.length >= 3 &&
|
||||
bytes[0] == 0xEF &&
|
||||
bytes[1] == 0xBB &&
|
||||
bytes[2] == 0xBF
|
||||
? utf8.decode(bytes.sublist(3))
|
||||
: utf8.decode(bytes);
|
||||
|
||||
final lines = content
|
||||
.replaceAll('\r\n', '\n')
|
||||
.replaceAll('\r', '\n')
|
||||
.split('\n')
|
||||
.where((l) => l.trim().isNotEmpty)
|
||||
.toList();
|
||||
|
||||
if (lines.isEmpty) {
|
||||
return const PagosImportResult(
|
||||
rows: [],
|
||||
parseErrors: ['El archivo está vacío'],
|
||||
);
|
||||
}
|
||||
|
||||
// Verificar encabezado
|
||||
final headerCells = _splitCsvLine(lines[0]);
|
||||
const expectedHeaders = [
|
||||
'dni',
|
||||
'nombre_apellido',
|
||||
'anio_mes_pagado',
|
||||
'monto_total',
|
||||
'metodo',
|
||||
'fecha_pago',
|
||||
];
|
||||
final missingHeaders = expectedHeaders
|
||||
.where((h) => !headerCells.map((c) => c.toLowerCase()).contains(h))
|
||||
.toList();
|
||||
if (missingHeaders.isNotEmpty) {
|
||||
return PagosImportResult(
|
||||
rows: const [],
|
||||
parseErrors: [
|
||||
'Formato de archivo incorrecto. Columnas faltantes: ${missingHeaders.join(', ')}',
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
final headerIndex = {
|
||||
for (var i = 0; i < headerCells.length; i++) headerCells[i].toLowerCase(): i
|
||||
};
|
||||
|
||||
final rows = <PagosImportRow>[];
|
||||
for (var i = 1; i < lines.length; i++) {
|
||||
final cells = _splitCsvLine(lines[i]);
|
||||
if (cells.length < 4) continue;
|
||||
|
||||
int col(String name) => headerIndex[name] ?? -1;
|
||||
String get(String name) {
|
||||
final idx = col(name);
|
||||
return (idx >= 0 && idx < cells.length) ? cells[idx].trim() : '';
|
||||
}
|
||||
|
||||
final rowNum = i;
|
||||
final dni = get('dni');
|
||||
final mesRaw = get('anio_mes_pagado'); // YYYY-MM o YYYY-MM-DD
|
||||
final montoStr = get('monto_total');
|
||||
final metodo = get('metodo');
|
||||
final fechaRaw = get('fecha_pago');
|
||||
|
||||
// Validaciones
|
||||
String? error;
|
||||
if (dni.isEmpty) {
|
||||
error = 'DNI vacío';
|
||||
} else if (mesRaw.isEmpty || !RegExp(r'^\d{4}-\d{2}').hasMatch(mesRaw)) {
|
||||
error = 'Mes inválido: "$mesRaw"';
|
||||
} else if (double.tryParse(montoStr) == null ||
|
||||
(double.tryParse(montoStr) ?? 0) <= 0) {
|
||||
error = 'Monto inválido: "$montoStr"';
|
||||
} else if (metodo.isEmpty) {
|
||||
error = 'Método vacío';
|
||||
}
|
||||
|
||||
// Normalizar anio_mes_pagado a YYYY-MM-01
|
||||
final anioMes = mesRaw.length >= 7
|
||||
? '${mesRaw.substring(0, 7)}-01'
|
||||
: mesRaw;
|
||||
|
||||
// Normalizar fecha_pago (aceptar YYYY-MM-DD, dejar null si vacío/inválido)
|
||||
String? fechaFinal;
|
||||
if (fechaRaw.isNotEmpty &&
|
||||
RegExp(r'^\d{4}-\d{2}-\d{2}$').hasMatch(fechaRaw)) {
|
||||
fechaFinal = fechaRaw;
|
||||
}
|
||||
|
||||
rows.add(PagosImportRow(
|
||||
rowNumber: rowNum,
|
||||
dni: dni,
|
||||
anioMesPagado: anioMes,
|
||||
montoTotal: double.tryParse(montoStr) ?? 0,
|
||||
metodoNombre: metodo,
|
||||
fechaPago: fechaFinal,
|
||||
validationError: error,
|
||||
));
|
||||
}
|
||||
|
||||
return PagosImportResult(rows: rows);
|
||||
}
|
||||
|
||||
/// Divide una línea CSV respetando celdas entre comillas.
|
||||
List<String> _splitCsvLine(String line) {
|
||||
final result = <String>[];
|
||||
final buf = StringBuffer();
|
||||
var inQuotes = false;
|
||||
|
||||
for (var i = 0; i < line.length; i++) {
|
||||
final ch = line[i];
|
||||
if (ch == '"') {
|
||||
if (inQuotes && i + 1 < line.length && line[i + 1] == '"') {
|
||||
buf.write('"');
|
||||
i++;
|
||||
} else {
|
||||
inQuotes = !inQuotes;
|
||||
}
|
||||
} else if (ch == ',' && !inQuotes) {
|
||||
result.add(buf.toString());
|
||||
buf.clear();
|
||||
} else {
|
||||
buf.write(ch);
|
||||
}
|
||||
}
|
||||
result.add(buf.toString());
|
||||
return result;
|
||||
}
|
||||
Reference in New Issue
Block a user