Agrego frontend app

This commit is contained in:
Pablo
2026-08-22 19:08:19 -03:00
parent 824e092b29
commit 2d0797e627
254 changed files with 37231 additions and 0 deletions
@@ -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;
}