import 'dart:async'; import 'package:enter_farma_plus_mobile/src/app/widgets/empty_state_panel.dart'; import 'package:enter_farma_plus_mobile/src/core/network/api_client.dart'; import 'package:enter_farma_plus_mobile/src/core/network/mobile_api_response.dart'; import 'package:enter_farma_plus_mobile/src/core/theme/app_tokens.dart'; import 'package:enter_farma_plus_mobile/src/core/widgets/paginated_list_mixin.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:url_launcher/url_launcher_string.dart'; class PurchasesScreen extends ConsumerStatefulWidget { const PurchasesScreen({super.key}); @override ConsumerState createState() => _PurchasesScreenState(); } class _PurchasesScreenState extends ConsumerState with PaginatedListMixin { final _searchController = TextEditingController(); final _scrollController = ScrollController(); Timer? _searchDebounce; bool _loading = true; String? _error; String _payFilter = 'all'; // all | paid | partial | pending @override void initState() { super.initState(); initPagination(_scrollController); onLoadPage = _fetchPage; _loadInitial(); } @override void dispose() { _searchDebounce?.cancel(); _searchController.dispose(); _scrollController.dispose(); disposePagination(); super.dispose(); } void _onSearchChanged(String value) { _searchDebounce?.cancel(); _searchDebounce = Timer(const Duration(milliseconds: 350), () { if (mounted) _loadInitial(); }); } Future>>> _fetchPage(int page) { return ref.read(apiClientProvider).purchases( page: page, perPage: 10, search: _searchController.text.trim(), ); } bool _handleAuthFailure(MobileApiResponse response) { if (response.errors['auth'] == true && mounted) { context.go('/login'); return true; } return false; } Future _loadInitial() async { setState(() { _loading = true; _error = null; }); final response = await _fetchPage(1); if (!mounted || _handleAuthFailure(response)) { return; } if (!response.success) { setState(() { _loading = false; _error = response.message ?? 'No se pudo cargar compras.'; }); return; } setState(() { _loading = false; resetPagination( firstPageItems: response.data ?? const [], firstPageMeta: response.meta, ); }); } Future _showDetail(Map record) async { final id = int.tryParse('${record['id']}') ?? 0; final response = await ref.read(apiClientProvider).purchaseDetail(id: id); if (!mounted || _handleAuthFailure(response)) return; if (!response.success) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(response.message ?? 'No se pudo obtener la compra.')), ); return; } final detail = Map.from(response.data ?? const {}); final items = List>.from(detail['items'] as List? ?? const []); final payments = List>.from(detail['payments'] as List? ?? const []); await showModalBottomSheet( context: context, isScrollControlled: true, builder: (context) => SafeArea( child: SingleChildScrollView( padding: const EdgeInsets.fromLTRB(20, 12, 20, 24), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( detail['number_full']?.toString() ?? 'Compra', style: Theme.of(context).textTheme.headlineSmall, ), const SizedBox(height: 6), Text( detail['supplier'] is Map ? '${(detail['supplier'] as Map)['name'] ?? '-'}' : '-', style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: AppTokens.secondary, ), ), const SizedBox(height: 12), Row( children: [ if ((detail['print_a4']?.toString() ?? '').isNotEmpty) Expanded( child: FilledButton.icon( onPressed: () => launchUrlString(detail['print_a4'].toString()), icon: const Icon(Icons.picture_as_pdf_rounded), label: const Text('PDF'), ), ), if ((detail['state_type_id']?.toString() ?? '') != '11') ...[ const SizedBox(width: 8), Expanded( child: OutlinedButton.icon( style: OutlinedButton.styleFrom( foregroundColor: AppTokens.danger, side: const BorderSide(color: AppTokens.danger), ), onPressed: () { Navigator.of(context).pop(); _confirmAnular(detail); }, icon: const Icon(Icons.block_rounded), label: const Text('Anular'), ), ), ], ], ), const SizedBox(height: 18), Text('Artículos', style: Theme.of(context).textTheme.titleLarge), ...items.map((item) => ListTile( dense: true, contentPadding: EdgeInsets.zero, title: Text( item['item'] is Map ? ((item['item'] as Map)['description']?.toString() ?? '-') : (item['description']?.toString() ?? '-'), ), subtitle: Text('Cantidad: ${item['quantity'] ?? '-'}'), trailing: Text('S/ ${item['unit_price'] ?? item['total'] ?? '0.00'}'), )), const SizedBox(height: 12), Builder(builder: (ctx) { // Calcular pendiente de forma robusta: preferir total_pending, // pero si viene 0/null y hay total real, usar total - total_paid // como fallback. Evita que una compra sin pagos pero con pending // mal calculado oculte el botón. final rawTotal = double.tryParse('${detail['total'] ?? 0}') ?? 0; final rawPaid = double.tryParse('${detail['total_paid'] ?? 0}') ?? 0; final rawPending = double.tryParse('${detail['total_pending'] ?? 0}') ?? 0; // Si el backend no calculó total_pending, usar total - paid. final detailPending = rawPending > 0 ? rawPending : (rawTotal - rawPaid); final detailVoided = (detail['state_type_id']?.toString() ?? '') == '11'; final canPay = !detailVoided && detailPending > 0.009; return Row( children: [ Expanded( child: Text('Pagos', style: Theme.of(context).textTheme.titleLarge), ), if (canPay) TextButton.icon( onPressed: () async { Navigator.of(context).pop(); await _showPaymentEditor(record); }, icon: const Icon(Icons.add_card_rounded), label: const Text('Registrar pago'), ) else if (detailVoided) Container( padding: const EdgeInsets.symmetric( horizontal: 10, vertical: 4), decoration: BoxDecoration( color: AppTokens.danger.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(12), ), child: const Text('Anulada', style: TextStyle( color: AppTokens.danger, fontWeight: FontWeight.w700)), ) else Container( padding: const EdgeInsets.symmetric( horizontal: 10, vertical: 4), decoration: BoxDecoration( color: AppTokens.success.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(12), ), child: const Text('Pagada', style: TextStyle( color: AppTokens.success, fontWeight: FontWeight.w700)), ), ], ); }), if (payments.isEmpty) const Text('Sin pagos registrados.') else ...payments.map((payment) => ListTile( dense: true, contentPadding: EdgeInsets.zero, title: Text( payment['payment_method_type_description']?.toString() ?? payment['payment_method_type_id']?.toString() ?? '-', ), subtitle: Text(payment['date_of_payment']?.toString() ?? '-'), trailing: Text('S/ ${payment['payment'] ?? '0.00'}'), )), ], ), ), ), ); } Future _showPaymentEditor(Map purchase) async { final id = int.tryParse('${purchase['id']}') ?? 0; if (id == 0) return; Map data = {}; List> paymentMethods = const []; List> destinations = const []; Future loadData() async { final resp = await ref.read(apiClientProvider).purchasePayments(id: id); if (!mounted || _handleAuthFailure(resp)) return false; if (!resp.success) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(resp.message ?? 'No se pudo cargar los datos de pago.')), ); } return false; } data = Map.from(resp.data ?? const {}); paymentMethods = List>.from( data['payment_method_types'] as List? ?? const [], ); destinations = List>.from( data['payment_destinations'] as List? ?? const [], ); return true; } if (!await loadData()) return; if (!mounted) return; final amountController = TextEditingController(); final dateController = TextEditingController( text: DateTime.now().toIso8601String().split('T').first, ); final referenceController = TextEditingController(); var methodId = paymentMethods.isNotEmpty ? '${paymentMethods.first['id']}' : '01'; String? destinationId = destinations.isNotEmpty ? '${destinations.first['id']}' : null; int? selectedFeeId; bool saving = false; String? formError; await showModalBottomSheet( context: context, isScrollControlled: true, builder: (context) => _SimpleFormSheet( title: 'Pagos de compra', child: StatefulBuilder( builder: (context, setSheetState) { final total = (data['total'] as num?)?.toDouble() ?? 0; final paid = (data['total_paid'] as num?)?.toDouble() ?? 0; final pending = (data['total_pending'] as num?)?.toDouble() ?? 0; final progress = total > 0 ? (paid / total).clamp(0.0, 1.0) : 0.0; final records = List>.from( data['records'] as List? ?? const []); final fees = List>.from( data['fees'] as List? ?? const []); final currency = '${data['currency_type_id'] ?? 'PEN'}' == 'USD' ? '\$' : 'S/'; Future refresh() async { if (await loadData()) { setSheetState(() {}); } } return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // Resumen Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: AppTokens.surface, border: Border.all(color: AppTokens.border), borderRadius: BorderRadius.circular(AppTokens.radiusSmall), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ _summaryCell('Total', '$currency ${total.toStringAsFixed(2)}', AppTokens.secondary), _summaryCell('Pagado', '$currency ${paid.toStringAsFixed(2)}', AppTokens.success), _summaryCell('Pendiente', '$currency ${pending.toStringAsFixed(2)}', pending > 0 ? AppTokens.danger : AppTokens.success), ], ), const SizedBox(height: 8), ClipRRect( borderRadius: BorderRadius.circular(6), child: LinearProgressIndicator( value: progress, minHeight: 8, backgroundColor: AppTokens.border, valueColor: AlwaysStoppedAnimation( pending <= 0.009 ? AppTokens.success : AppTokens.primary, ), ), ), ], ), ), const SizedBox(height: 14), // Cuotas programadas if (fees.isNotEmpty) ...[ Row( children: [ const Icon(Icons.event_rounded, size: 16, color: AppTokens.secondary), const SizedBox(width: 6), Text('Cuotas programadas', style: Theme.of(context).textTheme.titleSmall), ], ), const SizedBox(height: 6), ...fees.map((fee) { final feeId = int.tryParse('${fee['id']}'); final status = '${fee['status'] ?? 'pending'}'; final feePending = (fee['pending'] as num?)?.toDouble() ?? 0; final feeAmount = (fee['amount'] as num?)?.toDouble() ?? 0; final color = status == 'paid' ? AppTokens.success : status == 'partial' ? AppTokens.warning : AppTokens.secondary; final selected = selectedFeeId == feeId; return Container( margin: const EdgeInsets.only(bottom: 6), decoration: BoxDecoration( color: selected ? AppTokens.primary.withValues(alpha: 0.08) : AppTokens.surface, border: Border.all( color: selected ? AppTokens.primary : AppTokens.border, ), borderRadius: BorderRadius.circular(AppTokens.radiusSmall), ), child: ListTile( dense: true, leading: Icon(Icons.circle, size: 12, color: color), title: Text('${fee['date'] ?? '-'}'), subtitle: Text( '$currency ${feeAmount.toStringAsFixed(2)} · pendiente $currency ${feePending.toStringAsFixed(2)}', ), trailing: status == 'paid' ? const Icon(Icons.check_circle, color: AppTokens.success, size: 18) : TextButton( onPressed: feePending <= 0 ? null : () { setSheetState(() { selectedFeeId = selected ? null : feeId; amountController.text = feePending.toStringAsFixed(2); }); }, child: Text(selected ? 'Quitar' : 'Usar'), ), ), ); }), const SizedBox(height: 10), ], // Pagos previos if (records.isNotEmpty) ...[ Row( children: [ const Icon(Icons.receipt_long_rounded, size: 16, color: AppTokens.secondary), const SizedBox(width: 6), Text('Pagos realizados', style: Theme.of(context).textTheme.titleSmall), ], ), const SizedBox(height: 6), ...records.map((p) => Container( margin: const EdgeInsets.only(bottom: 4), padding: const EdgeInsets.symmetric( horizontal: 10, vertical: 6), decoration: BoxDecoration( color: AppTokens.surface, border: Border.all(color: AppTokens.border), borderRadius: BorderRadius.circular(6), ), child: Row( children: [ const Icon(Icons.check_circle, size: 14, color: AppTokens.success), const SizedBox(width: 6), Expanded( child: Text( '${p['date_of_payment'] ?? '-'} · ${p['payment_method_type_description'] ?? '-'}', style: Theme.of(context).textTheme.bodySmall, ), ), Text( '$currency ${((p['payment'] as num?)?.toDouble() ?? 0).toStringAsFixed(2)}', style: Theme.of(context) .textTheme .bodyMedium ?.copyWith(fontWeight: FontWeight.w700), ), ], ), )), const SizedBox(height: 10), ], // Formulario if (pending > 0.009) ...[ const Divider(), Row( children: [ const Icon(Icons.add_card_rounded, size: 16, color: AppTokens.primary), const SizedBox(width: 6), Text('Nuevo pago', style: Theme.of(context).textTheme.titleSmall), ], ), const SizedBox(height: 8), TextField( controller: dateController, decoration: const InputDecoration(labelText: 'Fecha de pago'), ), const SizedBox(height: 10), DropdownButtonFormField( initialValue: methodId, decoration: const InputDecoration(labelText: 'Método de pago'), items: paymentMethods .map((item) => DropdownMenuItem( value: '${item['id']}', child: Text(item['description']?.toString() ?? '-'), )) .toList(), onChanged: (value) => setSheetState(() => methodId = value ?? methodId), ), const SizedBox(height: 10), if (destinations.isNotEmpty) DropdownButtonFormField( initialValue: destinationId, decoration: const InputDecoration(labelText: 'Destino'), items: destinations .map((item) => DropdownMenuItem( value: '${item['id']}', child: Text( item['description']?.toString() ?? '-'), )) .toList(), onChanged: (value) => setSheetState( () => destinationId = value ?? destinationId), ), const SizedBox(height: 10), TextField( controller: amountController, keyboardType: const TextInputType.numberWithOptions( decimal: true), decoration: InputDecoration( labelText: 'Monto', prefixText: '$currency ', suffixIcon: TextButton( onPressed: () { setSheetState(() { amountController.text = pending.toStringAsFixed(2); }); }, child: const Text('Todo'), ), ), onChanged: (_) => setSheetState(() => formError = null), ), const SizedBox(height: 10), TextField( controller: referenceController, decoration: const InputDecoration(labelText: 'Referencia (opcional)'), ), if (formError != null) ...[ const SizedBox(height: 8), Text(formError!, style: const TextStyle(color: AppTokens.danger)), ], const SizedBox(height: 14), FilledButton.icon( onPressed: saving ? null : () async { final amount = double.tryParse( amountController.text.trim()) ?? 0; if (amount <= 0) { setSheetState(() => formError = 'Ingresa un monto mayor a cero.'); return; } if (amount > pending + 0.009) { setSheetState(() => formError = 'El monto excede lo pendiente ($currency ${pending.toStringAsFixed(2)}).'); return; } setSheetState(() { saving = true; formError = null; }); final payload = { 'date_of_payment': dateController.text.trim(), 'payment_method_type_id': methodId, 'payment': amount, 'reference': referenceController.text.trim(), }; if (destinationId != null && destinationId!.isNotEmpty) { payload['payment_destination_id'] = destinationId; } if (selectedFeeId != null) { payload['purchase_fee_id'] = selectedFeeId; // Compat con llaves antiguas enviadas a legacy. payload['purchase_payment_id'] = selectedFeeId; payload['fee_id'] = selectedFeeId; } final response = await ref .read(apiClientProvider) .createPurchasePayment(id: id, payload: payload); if (!context.mounted) return; if (_handleAuthFailure(response)) return; setSheetState(() => saving = false); if (!response.success) { setSheetState(() => formError = response.message ?? 'No se pudo registrar el pago.'); return; } amountController.clear(); referenceController.clear(); selectedFeeId = null; await refresh(); await _loadInitial(); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Pago registrado.')), ); } }, icon: saving ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) : const Icon(Icons.save_rounded), label: Text(saving ? 'Guardando...' : 'Guardar pago'), ), ] else ...[ const SizedBox(height: 8), Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: AppTokens.success.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(AppTokens.radiusSmall), ), child: const Row( children: [ Icon(Icons.verified_rounded, color: AppTokens.success), SizedBox(width: 8), Expanded(child: Text('Compra totalmente pagada.')), ], ), ), ], ], ); }, ), ), ); } Widget _summaryCell(String label, String value, Color color) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(label, style: const TextStyle( fontSize: 11, color: AppTokens.secondary)), const SizedBox(height: 2), Text(value, style: TextStyle( fontSize: 14, fontWeight: FontWeight.w800, color: color)), ], ); } Future _showPurchaseEditor() async { // ignore: avoid_print print('[EnterFarma] _showPurchaseEditor INICIO'); final suppliersResponse = await ref.read(apiClientProvider).suppliers(perPage: 30, page: 1); final itemsResponse = await ref.read(apiClientProvider).items( perPage: 40, page: 1, context: 'inventory', ); if (!mounted || _handleAuthFailure(suppliersResponse) || _handleAuthFailure(itemsResponse)) { return; } final suppliers = List>.from( suppliersResponse.data ?? const [], ); final items = List>.from(itemsResponse.data ?? const []); // ignore: avoid_print print('[EnterFarma] _showPurchaseEditor suppliers=${suppliers.length} items=${items.length}'); if (suppliers.isEmpty || items.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(suppliers.isEmpty ? 'No hay proveedores registrados. Crea uno primero.' : 'No hay productos disponibles.'), backgroundColor: AppTokens.danger, action: suppliers.isEmpty ? SnackBarAction( label: 'Proveedores', textColor: Colors.white, onPressed: () => context.go('/suppliers'), ) : null, ), ); return; } final seriesController = TextEditingController(text: 'F001'); final numberController = TextEditingController(); final dateController = TextEditingController( text: DateTime.now().toIso8601String().split('T').first, ); var supplierId = '${suppliers.first['id']}'; bool itemHasLots(String id) { final m = items.firstWhere( (it) => '${it['id']}' == id, orElse: () => const {}, ); return m['lots_enabled'] == true; } final lines = <_PurchaseLine>[ _PurchaseLine( itemId: '${items.first['id']}', unitPrice: (items.first['purchase_unit_price'] ?? items.first['sale_unit_price'] ?? 0) .toString(), ), ]; await showModalBottomSheet( context: context, isScrollControlled: true, builder: (context) => _SimpleFormSheet( title: 'Nueva compra', child: StatefulBuilder( builder: (context, setSheetState) { double total = 0; for (final l in lines) { total += (double.tryParse(l.quantityController.text) ?? 0) * (double.tryParse(l.unitPriceController.text) ?? 0); } return Column( children: [ DropdownButtonFormField( initialValue: supplierId, isExpanded: true, decoration: const InputDecoration(labelText: 'Proveedor'), items: suppliers .map((item) => DropdownMenuItem( value: '${item['id']}', child: Text( item['description']?.toString() ?? '-', maxLines: 1, overflow: TextOverflow.ellipsis, ), )) .toList(), onChanged: (value) => setSheetState(() => supplierId = value ?? supplierId), ), const SizedBox(height: 12), Row( children: [ Expanded( child: TextField( controller: seriesController, decoration: const InputDecoration(labelText: 'Serie'), ), ), const SizedBox(width: 12), Expanded( child: TextField( controller: numberController, keyboardType: TextInputType.number, decoration: const InputDecoration(labelText: 'Número'), ), ), ], ), const SizedBox(height: 12), TextField( controller: dateController, decoration: const InputDecoration(labelText: 'Fecha'), ), const SizedBox(height: 18), Row( children: [ const Icon(Icons.shopping_basket_rounded, size: 18, color: AppTokens.secondary), const SizedBox(width: 6), Text('Productos', style: Theme.of(context).textTheme.titleSmall), const Spacer(), TextButton.icon( onPressed: () { setSheetState(() { lines.add(_PurchaseLine( itemId: '${items.first['id']}', unitPrice: (items.first['purchase_unit_price'] ?? items.first['sale_unit_price'] ?? 0) .toString(), )); }); }, icon: const Icon(Icons.add_rounded, size: 18), label: const Text('Agregar'), ), ], ), const SizedBox(height: 4), for (var i = 0; i < lines.length; i++) ...[ Container( padding: const EdgeInsets.all(10), margin: const EdgeInsets.only(bottom: 8), decoration: BoxDecoration( color: AppTokens.surface, border: Border.all(color: AppTokens.border), borderRadius: BorderRadius.circular(AppTokens.radiusSmall), ), child: Column( children: [ Row( children: [ Expanded( child: DropdownButtonFormField( initialValue: lines[i].itemId, isExpanded: true, isDense: true, decoration: const InputDecoration( labelText: 'Producto', isDense: true, ), items: items .map((item) => DropdownMenuItem( value: '${item['id']}', child: Text( item['description']?.toString() ?? '-', maxLines: 1, overflow: TextOverflow.ellipsis, ), )) .toList(), onChanged: (value) { setSheetState(() { lines[i].itemId = value ?? lines[i].itemId; // Auto-actualizar precio del item final selected = items.firstWhere( (it) => '${it['id']}' == lines[i].itemId, orElse: () => items.first, ); lines[i].unitPriceController.text = (selected['purchase_unit_price'] ?? selected['sale_unit_price'] ?? 0) .toString(); }); }, ), ), if (lines.length > 1) IconButton( padding: EdgeInsets.zero, constraints: const BoxConstraints( minWidth: 32, minHeight: 32), onPressed: () { setSheetState(() => lines.removeAt(i)); }, icon: const Icon(Icons.delete_outline_rounded, color: AppTokens.danger, size: 20), ), ], ), const SizedBox(height: 8), Row( children: [ Expanded( child: TextField( controller: lines[i].quantityController, keyboardType: const TextInputType.numberWithOptions( decimal: true), decoration: const InputDecoration( labelText: 'Cantidad', isDense: true, ), onChanged: (_) => setSheetState(() {}), ), ), const SizedBox(width: 8), Expanded( child: TextField( controller: lines[i].unitPriceController, keyboardType: const TextInputType.numberWithOptions( decimal: true), decoration: const InputDecoration( labelText: 'Costo', prefixText: 'S/ ', isDense: true, ), onChanged: (_) => setSheetState(() {}), ), ), const SizedBox(width: 8), SizedBox( width: 80, child: Text( 'S/ ${((double.tryParse(lines[i].quantityController.text) ?? 0) * (double.tryParse(lines[i].unitPriceController.text) ?? 0)).toStringAsFixed(2)}', textAlign: TextAlign.right, style: Theme.of(context) .textTheme .titleSmall ?.copyWith( color: AppTokens.primary, fontWeight: FontWeight.w800, ), ), ), ], ), if (itemHasLots(lines[i].itemId)) ...[ const SizedBox(height: 8), Container( padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 6), decoration: BoxDecoration( color: AppTokens.warning.withValues(alpha: 0.10), borderRadius: BorderRadius.circular(6), ), child: Row( children: [ const Icon(Icons.label_important_rounded, size: 14, color: AppTokens.warning), const SizedBox(width: 6), Text( 'Producto con lote', style: Theme.of(context) .textTheme .labelSmall ?.copyWith( color: AppTokens.warning, fontWeight: FontWeight.w700, ), ), ], ), ), const SizedBox(height: 8), Row( children: [ Expanded( child: TextField( controller: lines[i].lotCodeController, decoration: const InputDecoration( labelText: 'Código de lote', isDense: true, ), ), ), const SizedBox(width: 8), Expanded( child: TextField( controller: lines[i].dateOfDueController, readOnly: true, decoration: const InputDecoration( labelText: 'Vence', hintText: 'YYYY-MM-DD', isDense: true, suffixIcon: Icon( Icons.calendar_today_rounded, size: 16), ), onTap: () async { final picked = await showDatePicker( context: context, initialDate: DateTime.now() .add(const Duration(days: 365)), firstDate: DateTime.now(), lastDate: DateTime.now() .add(const Duration(days: 365 * 10)), locale: const Locale('es'), helpText: 'Fecha de vencimiento', cancelText: 'Cancelar', confirmText: 'Aceptar', ); if (picked != null) { lines[i].dateOfDueController.text = '${picked.year.toString().padLeft(4, '0')}-${picked.month.toString().padLeft(2, '0')}-${picked.day.toString().padLeft(2, '0')}'; setSheetState(() {}); } }, ), ), ], ), ], ], ), ), ], const SizedBox(height: 8), Container( padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 10), decoration: BoxDecoration( color: AppTokens.primary.withValues(alpha: 0.10), borderRadius: BorderRadius.circular(AppTokens.radiusSmall), ), child: Row( children: [ const Text('Total'), const Spacer(), Text( 'S/ ${total.toStringAsFixed(2)}', style: Theme.of(context).textTheme.titleMedium?.copyWith( color: AppTokens.primary, fontWeight: FontWeight.w800, ), ), ], ), ), const SizedBox(height: 16), FilledButton.icon( onPressed: () async { final series = seriesController.text.trim(); if (series.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Ingresa una serie válida.')), ); return; } final number = int.tryParse(numberController.text.trim()) ?? 0; if (number <= 0) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Ingresa un número válido.')), ); return; } // Validar líneas final itemsPayload = >[]; for (final l in lines) { final qty = double.tryParse(l.quantityController.text.trim()) ?? 0; final price = double.tryParse( l.unitPriceController.text.trim()) ?? 0; if (qty <= 0 || price <= 0) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text( 'Cantidad y costo deben ser mayores a 0 en cada línea.')), ); return; } final hasLots = itemHasLots(l.itemId); final lotCode = l.lotCodeController.text.trim(); final dateOfDue = l.dateOfDueController.text.trim(); if (hasLots && (lotCode.isEmpty || dateOfDue.isEmpty)) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text( 'Los productos con lote requieren código y fecha de vencimiento.')), ); return; } final entry = { 'item_id': int.tryParse(l.itemId) ?? 0, 'quantity': qty, 'unit_price': price, }; if (hasLots) { entry['lot_code'] = lotCode; entry['date_of_due'] = dateOfDue; entry['lots_enabled'] = true; } itemsPayload.add(entry); } if (itemsPayload.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Agrega al menos un producto.')), ); return; } final response = await ref.read(apiClientProvider).createPurchase( payload: { 'supplier_id': int.tryParse(supplierId) ?? 0, 'series': series, 'number': number, 'date_of_issue': dateController.text.trim(), 'currency_type_id': 'PEN', 'items': itemsPayload, }, ); if (!context.mounted) return; if (_handleAuthFailure(response)) return; if (response.success && Navigator.of(context).canPop()) { Navigator.of(context).pop(); await _loadInitial(); } }, icon: const Icon(Icons.shopping_bag_rounded), label: const Text('Registrar compra'), ), ], ); }, ), ), ); } @override Widget build(BuildContext context) { final records = List>.from(paginatedItems); final filtered = _applyPayFilter(records); final totalSum = filtered.fold( 0, (s, r) => s + (double.tryParse('${r['total'] ?? 0}') ?? 0)); return SafeArea( child: Stack( children: [ RefreshIndicator( onRefresh: _loadInitial, child: ListView( controller: _scrollController, padding: AppTokens.pagePadding, children: [ // Buscador libre arriba. TextField( controller: _searchController, textInputAction: TextInputAction.search, onChanged: _onSearchChanged, onSubmitted: (_) => _loadInitial(), decoration: InputDecoration( labelText: 'Buscar por serie, número o proveedor', prefixIcon: const Icon(Icons.search_rounded), suffixIcon: _searchController.text.isNotEmpty ? IconButton( onPressed: () { _searchController.clear(); _loadInitial(); }, icon: const Icon(Icons.clear_rounded), ) : null, ), ), const SizedBox(height: 12), // Chips de filtro. SizedBox( height: 36, child: ListView( scrollDirection: Axis.horizontal, children: [ _PayFilterChip( label: 'Todas', active: _payFilter == 'all', onTap: () => setState(() => _payFilter = 'all'), ), const SizedBox(width: 8), _PayFilterChip( label: 'Pagadas', active: _payFilter == 'paid', color: AppTokens.success, onTap: () => setState(() => _payFilter = 'paid'), ), const SizedBox(width: 8), _PayFilterChip( label: 'Parcial', active: _payFilter == 'partial', color: AppTokens.warning, onTap: () => setState(() => _payFilter = 'partial'), ), const SizedBox(width: 8), _PayFilterChip( label: 'Pendientes', active: _payFilter == 'pending', color: AppTokens.danger, onTap: () => setState(() => _payFilter = 'pending'), ), ], ), ), const SizedBox(height: 8), // Conteo + total acumulado. if (!_loading && _error == null && filtered.isNotEmpty) Padding( padding: const EdgeInsets.only(bottom: 8), child: Text( '${filtered.length} compra(s) · S/ ${totalSum.toStringAsFixed(2)}', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: AppTokens.secondary, ), ), ), if (_loading) const Padding( padding: EdgeInsets.symmetric(vertical: 24), child: Center(child: CircularProgressIndicator()), ) else if (_error != null) EmptyStatePanel( icon: Icons.cloud_off_rounded, title: 'No disponible', subtitle: _error!, ) else if (filtered.isEmpty) Column( children: [ const EmptyStatePanel( icon: Icons.shopping_bag_outlined, title: 'Sin compras', subtitle: 'Aún no hay compras para el filtro seleccionado.', ), const SizedBox(height: 12), FilledButton.icon( onPressed: _showPurchaseEditor, icon: const Icon(Icons.add_rounded), label: const Text('Crear nueva compra'), ), ], ) else ...filtered.map(_buildPurchaseCard), buildLoadMoreIndicator(), const SizedBox(height: 80), ], ), ), Positioned( right: 20, bottom: 92, child: FloatingActionButton.extended( onPressed: _showPurchaseEditor, icon: const Icon(Icons.add_rounded), label: const Text('Nueva compra'), ), ), ], ), ); } List> _applyPayFilter(List> rows) { if (_payFilter == 'all') return rows; return rows.where((r) { final total = double.tryParse('${r['total'] ?? 0}') ?? 0; final pending = double.tryParse('${r['total_pending'] ?? 0}') ?? 0; final paid = total - pending; switch (_payFilter) { case 'paid': return pending <= 0.01 && total > 0; case 'partial': return paid > 0.01 && pending > 0.01; case 'pending': return paid <= 0.01 && total > 0; default: return true; } }).toList(); } Widget _buildPurchaseCard(Map record) { final supplier = Map.from(record['supplier'] as Map? ?? const {}); final supplierName = supplier['name']?.toString() ?? 'Sin proveedor'; final number = record['number_full']?.toString() ?? '-'; final date = record['date_of_issue']?.toString() ?? ''; final total = double.tryParse('${record['total'] ?? 0}') ?? 0; final pending = double.tryParse('${record['total_pending'] ?? 0}') ?? 0; final paid = total - pending; final docTypeId = record['document_type_id']?.toString() ?? ''; final docShort = _shortDocType(docTypeId); String payState; Color payColor; if (pending <= 0.01 && total > 0) { payState = 'Pagada'; payColor = AppTokens.success; } else if (paid > 0.01 && pending > 0.01) { payState = 'Parcial'; payColor = AppTokens.warning; } else { payState = 'Pendiente'; payColor = AppTokens.danger; } final initials = _initialsFor(supplierName); final avatarColor = _colorForName(supplierName); return Padding( padding: const EdgeInsets.only(bottom: 8), child: Material( color: AppTokens.surface, borderRadius: BorderRadius.circular(AppTokens.radiusSmall), clipBehavior: Clip.antiAlias, child: InkWell( onTap: () => _showDetail(record), onLongPress: () => _showPurchaseQuickActions(record), child: Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(AppTokens.radiusSmall), border: Border.all(color: AppTokens.border), ), child: IntrinsicHeight( child: Row( children: [ Container(width: 4, color: payColor), Expanded( child: Padding( padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 10), child: Row( children: [ CircleAvatar( radius: 22, backgroundColor: avatarColor.withValues(alpha: 0.15), foregroundColor: avatarColor, child: Text(initials, style: const TextStyle(fontWeight: FontWeight.w800)), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( supplierName, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w600, ), ), const SizedBox(height: 2), Row( children: [ if (docShort.isNotEmpty) ...[ Container( padding: const EdgeInsets.symmetric( horizontal: 6, vertical: 1), decoration: BoxDecoration( color: AppTokens.primary .withValues(alpha: 0.10), borderRadius: BorderRadius.circular(4), ), child: Text( docShort, style: Theme.of(context) .textTheme .labelSmall ?.copyWith( color: AppTokens.primary, fontWeight: FontWeight.w700, fontSize: 10, ), ), ), const SizedBox(width: 6), ], Expanded( child: Text( '$number${date.isNotEmpty ? " · $date" : ""}', maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context) .textTheme .labelSmall ?.copyWith(color: AppTokens.secondary), ), ), ], ), ], ), ), const SizedBox(width: 8), Column( crossAxisAlignment: CrossAxisAlignment.end, mainAxisSize: MainAxisSize.min, children: [ Text( 'S/ ${total.toStringAsFixed(2)}', style: Theme.of(context).textTheme.titleSmall?.copyWith( color: AppTokens.primary, fontWeight: FontWeight.w800, ), ), const SizedBox(height: 2), Container( padding: const EdgeInsets.symmetric( horizontal: 6, vertical: 2), decoration: BoxDecoration( color: payColor.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(999), ), child: Text( payState, style: Theme.of(context).textTheme.labelSmall?.copyWith( color: payColor, fontWeight: FontWeight.w700, fontSize: 10, ), ), ), ], ), ], ), ), ), ], ), ), ), ), ), ); } Future _showPurchaseQuickActions(Map record) async { final pending = double.tryParse('${record['total_pending'] ?? 0}') ?? 0; final stateTypeId = record['state_type_id']?.toString() ?? ''; final isVoided = stateTypeId == '11'; await showModalBottomSheet( context: context, builder: (ctx) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ ListTile( leading: const Icon(Icons.visibility_rounded), title: const Text('Ver detalle'), onTap: () { Navigator.pop(ctx); _showDetail(record); }, ), if (pending > 0.01 && !isVoided) ListTile( leading: const Icon(Icons.payments_rounded, color: AppTokens.success), title: const Text('Registrar pago'), subtitle: Text('Pendiente: S/ ${pending.toStringAsFixed(2)}'), onTap: () { Navigator.pop(ctx); _showPaymentEditor(record); }, ), if ((record['print_a4']?.toString() ?? '').isNotEmpty) ListTile( leading: const Icon(Icons.picture_as_pdf_rounded), title: const Text('PDF'), onTap: () { Navigator.pop(ctx); launchUrlString(record['print_a4'].toString()); }, ), if (!isVoided) ListTile( leading: const Icon(Icons.block_rounded, color: AppTokens.danger), title: const Text('Anular compra', style: TextStyle(color: AppTokens.danger)), subtitle: const Text('Devuelve stock y revierte kardex'), onTap: () { Navigator.pop(ctx); _confirmAnular(record); }, ), ], ), ), ); } Future _confirmAnular(Map record) async { final id = int.tryParse('${record['id']}') ?? 0; if (id <= 0) return; final number = record['number_full']?.toString() ?? '${record['series'] ?? ''}-${record['number'] ?? ''}'; final go = await showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('Anular compra'), content: Text( 'Se anulará $number, se revertirán los stocks ingresados y los lotes de esta compra. ¿Continuar?'), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancelar'), ), FilledButton( style: FilledButton.styleFrom(backgroundColor: AppTokens.danger), onPressed: () => Navigator.pop(ctx, true), child: const Text('Anular'), ), ], ), ); if (go != true || !mounted) return; final response = await ref.read(apiClientProvider).anularPurchase(id: id); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(response.message ?? (response.success ? 'OK' : 'Error')), backgroundColor: response.success ? AppTokens.success : AppTokens.danger, ), ); if (response.success) { await _loadInitial(); } } String _initialsFor(String name) { final parts = name.trim().split(RegExp(r'\s+')).where((s) => s.isNotEmpty).toList(); if (parts.isEmpty) return '?'; if (parts.length == 1) return parts.first.substring(0, 1).toUpperCase(); return (parts[0].substring(0, 1) + parts[1].substring(0, 1)).toUpperCase(); } Color _colorForName(String name) { const palette = [ Color(0xFF00796B), Color(0xFFF97316), Color(0xFF6366F1), Color(0xFFEC4899), Color(0xFF10B981), Color(0xFF8B5CF6), Color(0xFFEF4444), Color(0xFF0EA5E9), ]; final hash = name.codeUnits.fold(0, (acc, c) => acc + c); return palette[hash % palette.length]; } String _shortDocType(String id) { switch (id) { case '01': return 'Factura'; case '03': return 'Boleta'; case '07': return 'NC'; case '08': return 'ND'; default: return ''; } } } class _PurchaseLine { _PurchaseLine({ required this.itemId, String unitPrice = '0', String quantity = '1', }) : unitPriceController = TextEditingController(text: unitPrice), quantityController = TextEditingController(text: quantity), lotCodeController = TextEditingController(), dateOfDueController = TextEditingController(); String itemId; final TextEditingController unitPriceController; final TextEditingController quantityController; final TextEditingController lotCodeController; final TextEditingController dateOfDueController; } class _PayFilterChip extends StatelessWidget { const _PayFilterChip({ required this.label, required this.active, required this.onTap, this.color = AppTokens.primary, }); final String label; final bool active; final VoidCallback onTap; final Color color; @override Widget build(BuildContext context) { return Material( color: Colors.transparent, child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(999), child: Container( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), decoration: BoxDecoration( color: active ? color.withValues(alpha: 0.12) : AppTokens.surface, borderRadius: BorderRadius.circular(999), border: Border.all( color: active ? color : AppTokens.border, width: active ? 1.5 : 1, ), ), child: Text( label, style: Theme.of(context).textTheme.labelLarge?.copyWith( color: active ? color : AppTokens.secondary, fontWeight: active ? FontWeight.w700 : FontWeight.w500, ), ), ), ), ); } } class _SimpleFormSheet extends StatelessWidget { const _SimpleFormSheet({ required this.title, required this.child, }); final String title; final Widget child; @override Widget build(BuildContext context) { return SafeArea( child: SingleChildScrollView( padding: EdgeInsets.fromLTRB( 20, 12, 20, 24 + MediaQuery.of(context).viewInsets.bottom, ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(title, style: Theme.of(context).textTheme.headlineSmall), const SizedBox(height: 16), child, ], ), ), ); } }