import 'dart:async'; import 'package:sysfarma_mobile/src/app/widgets/app_loader.dart'; import 'package:sysfarma_mobile/src/app/widgets/app_panel.dart'; import 'package:sysfarma_mobile/src/app/widgets/empty_state_panel.dart'; import 'package:sysfarma_mobile/src/app/widgets/fade_in_entry.dart'; import 'package:sysfarma_mobile/src/core/network/api_client.dart'; import 'package:sysfarma_mobile/src/core/network/mobile_api_response.dart'; import 'package:sysfarma_mobile/src/core/printing/bluetooth_print_service.dart'; import 'package:sysfarma_mobile/src/core/storage/tenant_session_store.dart'; import 'package:sysfarma_mobile/src/core/theme/app_tokens.dart'; import 'package:sysfarma_mobile/src/core/utils/pdf_helper.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.dart'; import 'package:sysfarma_mobile/src/features/settings/presentation/printer_settings_screen.dart'; enum _SalesTab { documents, saleNotes } class SalesHistoryScreen extends ConsumerStatefulWidget { const SalesHistoryScreen({super.key}); @override ConsumerState createState() => _SalesHistoryScreenState(); } class _SalesHistoryScreenState extends ConsumerState { static const int _pageSize = 12; _SalesTab _activeTab = _SalesTab.documents; String get _modelKey => _activeTab == _SalesTab.documents ? 'document' : 'sale_note'; final _searchController = TextEditingController(); Timer? _searchDebounce; void _switchTab(_SalesTab tab) { if (tab == _activeTab) return; setState(() => _activeTab = tab); _loadInitial(); } final List> _items = []; int _currentPage = 1; bool _hasMore = true; bool _isLoading = true; bool _isLoadingMore = false; String? _error; @override void initState() { super.initState(); _loadInitial(); } @override void dispose() { _searchDebounce?.cancel(); _searchController.dispose(); super.dispose(); } Future>>> _fetchPage(int page) { return ref.read(apiClientProvider).documents( perPage: _pageSize, page: page, model: _modelKey, search: _searchController.text.trim().isEmpty ? null : _searchController.text.trim(), ); } Future _loadInitial() async { setState(() { _isLoading = true; _error = null; _items.clear(); _currentPage = 1; _hasMore = true; }); final response = await _fetchPage(1); if (!mounted) return; if (response.errors['auth'] == true) { context.go('/login'); return; } if (!response.success) { setState(() { _isLoading = false; _error = response.message ?? 'No se pudo cargar el listado.'; }); return; } setState(() { _isLoading = false; _items.addAll(response.data ?? const []); _hasMore = response.meta['has_more'] == true; }); } Future _loadMore() async { if (_isLoadingMore || !_hasMore) return; setState(() => _isLoadingMore = true); final response = await _fetchPage(_currentPage + 1); if (!mounted) return; if (response.success) { setState(() { _items.addAll(response.data ?? const []); _currentPage++; _hasMore = response.meta['has_more'] == true; _isLoadingMore = false; }); } else { setState(() => _isLoadingMore = false); } } Future _refresh() async => _loadInitial(); void _onSearchChanged(String value) { _searchDebounce?.cancel(); _searchDebounce = Timer(const Duration(milliseconds: 350), () { if (mounted) _loadInitial(); }); } @override Widget build(BuildContext context) { return SafeArea( child: RefreshIndicator( onRefresh: _refresh, child: ListView( padding: AppTokens.pagePadding, children: [ Container( decoration: BoxDecoration( color: AppTokens.surface, borderRadius: BorderRadius.circular(AppTokens.radiusSmall), border: Border.all(color: AppTokens.border), ), child: Row( children: [ Expanded( child: _TabButton( label: 'Comprobantes', icon: Icons.description_rounded, active: _activeTab == _SalesTab.documents, onTap: () => _switchTab(_SalesTab.documents), ), ), Expanded( child: _TabButton( label: 'Notas de venta', icon: Icons.receipt_rounded, active: _activeTab == _SalesTab.saleNotes, onTap: () => _switchTab(_SalesTab.saleNotes), ), ), ], ), ), const SizedBox(height: 14), TextField( controller: _searchController, textInputAction: TextInputAction.search, onChanged: _onSearchChanged, onSubmitted: (_) => _loadInitial(), decoration: InputDecoration( labelText: 'Buscar por serie, número o cliente', hintText: 'Búsqueda automática al escribir', 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: 14), if (_isLoading) const Padding( padding: EdgeInsets.symmetric(vertical: 48), child: AppLoader(), ) else if (_error != null) AppPanel( title: 'Error', subtitle: _error, child: FilledButton.icon( onPressed: _loadInitial, icon: const Icon(Icons.refresh_rounded), label: const Text('Reintentar'), ), ) else if (_items.isEmpty) EmptyStatePanel( icon: _activeTab == _SalesTab.documents ? Icons.description_outlined : Icons.receipt_long_outlined, title: _activeTab == _SalesTab.documents ? 'Sin comprobantes' : 'Sin notas de venta', subtitle: _searchController.text.isNotEmpty ? 'No se encontraron resultados para tu búsqueda.' : 'Aún no hay registros.', ) else AppPanel( title: _activeTab == _SalesTab.documents ? 'Comprobantes emitidos' : 'Notas de venta', subtitle: 'Toca un registro para ver el detalle.', trailing: Text( '${_items.length}', style: Theme.of(context).textTheme.titleMedium?.copyWith( color: AppTokens.primary, fontWeight: FontWeight.w700, ), ), child: Column( children: [ for (final entry in _items.asMap().entries) ...[ FadeInEntry( key: ValueKey(entry.value['id'] ?? entry.key), index: entry.key, child: _SalesHistoryRow( record: entry.value, onTap: () => _showDetail(entry.value), ), ), if (entry.value != _items.last) const Divider(height: 20), ], ], ), ), if (!_isLoading && _hasMore && _items.isNotEmpty) ...[ const SizedBox(height: 14), Center( child: OutlinedButton.icon( onPressed: _isLoadingMore ? null : _loadMore, icon: _isLoadingMore ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.expand_more_rounded), label: Text(_isLoadingMore ? 'Cargando...' : 'Cargar más'), ), ), ], if (!_isLoading && !_hasMore && _items.isNotEmpty) ...[ const SizedBox(height: 14), Center( child: Text( 'No hay más registros', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: AppTokens.secondary, ), ), ), ], ], ), ), ); } Future _showDetail(Map record) async { final id = int.tryParse('${record['id']}') ?? 0; if (id <= 0) return; final api = ref.read(apiClientProvider); final response = await api.documentDetail(id: id, model: _modelKey); if (!mounted) return; if (!response.success) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(response.message ?? 'No se pudo obtener el detalle.'), backgroundColor: AppTokens.danger, ), ); return; } final session = await ref.read(tenantSessionStoreProvider).readSession(); if (!mounted) return; final canAnularPermission = (session?.annularSale ?? true) || (session?.isAdmin ?? true); final detail = response.data ?? const {}; final customerPhone = detail['customer']?['telephone']?.toString() ?? detail['customer_telephone']?.toString() ?? ''; final numberFull = detail['number_full']?.toString() ?? '${detail['series'] ?? ''}-${detail['number'] ?? ''}'; String prefix; if (_activeTab == _SalesTab.documents) { final docTypeId = detail['document_type_id']?.toString() ?? ''; const shortNames = { '01': 'Factura', '03': 'Boleta', '07': 'Nota de crédito', '08': 'Nota de débito', '09': 'Guía', }; prefix = shortNames[docTypeId] ?? 'Comprobante'; } else { prefix = 'Nota de venta'; } final title = '$prefix $numberFull'; await showModalBottomSheet( context: context, isScrollControlled: true, builder: (sheetCtx) { return SafeArea( child: SingleChildScrollView( padding: const EdgeInsets.fromLTRB(20, 8, 20, 24), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( title, style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 16), _DetailLine(label: 'Fecha', value: detail['date_of_issue']?.toString() ?? '-'), const Divider(height: 20), _DetailLine(label: 'Cliente', value: detail['customer_name']?.toString() ?? detail['customer']?['name']?.toString() ?? '-'), const Divider(height: 20), _DetailLine(label: 'Total', value: 'S/ ${detail['total']?.toString() ?? '0.00'}'), const Divider(height: 20), _DetailLine(label: 'Estado', value: detail['state_type_description']?.toString() ?? '-'), const SizedBox(height: 20), Row( children: [ Expanded( child: FilledButton.icon( onPressed: () => _viewPdf(id, title), icon: const Icon(Icons.picture_as_pdf_rounded), label: const Text('Ver PDF'), ), ), const SizedBox(width: 10), Expanded( child: OutlinedButton.icon( onPressed: () { Navigator.of(sheetCtx).pop(); _printTicket(id); }, icon: const Icon(Icons.print_rounded), label: const Text('Imprimir'), ), ), ], ), const SizedBox(height: 10), SizedBox( width: double.infinity, child: OutlinedButton.icon( onPressed: () => _sendWhatsApp(id, title, customerPhone), icon: const Icon(Icons.chat_rounded, color: Color(0xFF25D366)), label: const Text('Enviar por WhatsApp'), ), ), if (_activeTab == _SalesTab.documents && _needsSunatSend(detail) && detail['state_type_id']?.toString() != '13') ...[ const SizedBox(height: 10), SizedBox( width: double.infinity, child: FilledButton.icon( style: FilledButton.styleFrom( backgroundColor: AppTokens.warning), onPressed: () { Navigator.of(sheetCtx).pop(); _sendToSunat(id); }, icon: const Icon(Icons.cloud_upload_rounded), label: const Text('Enviar a SUNAT'), ), ), ], if (detail['state_type_id']?.toString() == '13') ...[ const SizedBox(height: 10), SizedBox( width: double.infinity, child: FilledButton.icon( style: FilledButton.styleFrom( backgroundColor: AppTokens.warning), onPressed: () { Navigator.of(sheetCtx).pop(); context.go('/pending-voided'); }, icon: const Icon(Icons.hourglass_top_rounded), label: const Text('Ir a "Por anular"'), ), ), ], if (_canEmitCreditNote(detail)) ...[ const SizedBox(height: 10), SizedBox( width: double.infinity, child: OutlinedButton.icon( style: OutlinedButton.styleFrom( foregroundColor: AppTokens.danger, side: const BorderSide(color: AppTokens.danger), ), onPressed: () { Navigator.of(sheetCtx).pop(); _confirmCreditNote(id, title); }, icon: const Icon(Icons.assignment_return_rounded), label: const Text('Emitir NC total'), ), ), const SizedBox(height: 8), SizedBox( width: double.infinity, child: OutlinedButton.icon( style: OutlinedButton.styleFrom( foregroundColor: AppTokens.warning, side: const BorderSide(color: AppTokens.warning), ), onPressed: () { Navigator.of(sheetCtx).pop(); _openPartialCreditSheet(id, title, detail); }, icon: const Icon(Icons.rule_rounded), label: const Text('Emitir NC parcial (items)'), ), ), const SizedBox(height: 8), SizedBox( width: double.infinity, child: OutlinedButton.icon( style: OutlinedButton.styleFrom( foregroundColor: AppTokens.cta, side: const BorderSide(color: AppTokens.cta), ), onPressed: () { Navigator.of(sheetCtx).pop(); _openGlobalDiscountSheet(id, title, detail); }, icon: const Icon(Icons.percent_rounded), label: const Text('Emitir NC descuento global'), ), ), const SizedBox(height: 8), SizedBox( width: double.infinity, child: OutlinedButton.icon( style: OutlinedButton.styleFrom( foregroundColor: AppTokens.primary, side: const BorderSide(color: AppTokens.primary), ), onPressed: () { Navigator.of(sheetCtx).pop(); _openDebitSheet(id, title, detail); }, icon: const Icon(Icons.trending_up_rounded), label: const Text('Emitir nota de debito'), ), ), ], if (canAnularPermission && _canAnular(detail)) ...[ const SizedBox(height: 10), SizedBox( width: double.infinity, child: OutlinedButton.icon( style: OutlinedButton.styleFrom( foregroundColor: AppTokens.danger, side: const BorderSide(color: AppTokens.danger), ), onPressed: () { Navigator.of(sheetCtx).pop(); _confirmAnular(id, title); }, icon: const Icon(Icons.cancel_rounded), label: const Text('Marcar por anular'), ), ), ], ], ), ), ); }, ); } bool _canAnular(Map detail) { final state = detail['state_type_id']?.toString() ?? ''; if (_activeTab == _SalesTab.saleNotes) { // Las notas de venta se pueden anular siempre que no estén ya anuladas. return state != '11'; } // Comprobantes: solo los aceptados por SUNAT (05) se pueden anular. return state == '05'; } Future _confirmAnular(int id, String title) async { final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('Marcar por anular'), content: Text( 'Se marcara por anular: $title. El stock de los productos se devolvera al inventario.'), 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('Marcar'), ), ], ), ); if (confirmed != true || !mounted) return; final messenger = ScaffoldMessenger.of(context); messenger.showSnackBar( const SnackBar( content: Text('Marcando por anular...'), duration: Duration(seconds: 2), ), ); final response = await ref.read(apiClientProvider).anularDocument(id: id, model: _modelKey); if (!mounted) return; if (response.success) { messenger.showSnackBar( SnackBar( content: Text(response.message ?? 'Marcado por anular.'), backgroundColor: AppTokens.success, ), ); await _loadInitial(); } else { messenger.showSnackBar( SnackBar( content: Text(response.message ?? 'No se pudo anular.'), backgroundColor: AppTokens.danger, ), ); } } bool _canEmitCreditNote(Map detail) { if (_activeTab != _SalesTab.documents) return false; final state = detail['state_type_id']?.toString() ?? ''; final docType = detail['document_type_id']?.toString() ?? ''; return state == '05' && (docType == '01' || docType == '03'); } Future _confirmCreditNote(int id, String title) async { final reasonController = TextEditingController(text: 'Anulación de la operación'); final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('Emitir Nota de Crédito'), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('Se emitirá una NC para anular $title.'), const SizedBox(height: 12), TextField( controller: reasonController, maxLines: 2, decoration: const InputDecoration( labelText: 'Motivo', border: OutlineInputBorder(), ), ), ], ), 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('Emitir NC'), ), ], ), ); if (confirmed != true || !mounted) { reasonController.dispose(); return; } final reason = reasonController.text.trim().isEmpty ? 'Anulación de la operación' : reasonController.text.trim(); reasonController.dispose(); final messenger = ScaffoldMessenger.of(context); messenger.showSnackBar( const SnackBar( content: Text('Emitiendo nota de crédito...'), duration: Duration(seconds: 2), ), ); final response = await ref .read(apiClientProvider) .emitCreditNote(id: id, reason: reason); if (!mounted) return; if (response.success) { messenger.showSnackBar( SnackBar( content: Text(response.message ?? 'Nota de crédito emitida.'), backgroundColor: AppTokens.success, ), ); await _loadInitial(); } else { messenger.showSnackBar( SnackBar( content: Text(response.message ?? 'No se pudo emitir la NC.'), backgroundColor: AppTokens.danger, ), ); } } /// Sheet de NC parcial: muestra items con toggle + stepper de cantidad. Future _openPartialCreditSheet( int id, String title, Map detail) async { final rawItems = detail['items'] is List ? List>.from( (detail['items'] as List).map((e) => Map.from(e as Map))) : >[]; if (rawItems.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Este comprobante no tiene ítems.')), ); return; } final selections = {}; final maxQty = {}; final unitValue = {}; final itemName = {}; for (final it in rawItems) { final itemId = int.tryParse('${it['item_id']}') ?? 0; if (itemId <= 0) continue; final qty = double.tryParse('${it['quantity'] ?? 0}') ?? 0; // El JSON de mobile expone unit_price (con IGV) y total_value (base). // Para mostrar el total al usuario usamos unit_price. final up = double.tryParse('${it['unit_price'] ?? 0}') ?? 0; final name = it['item']?['description']?.toString() ?? it['description']?.toString() ?? 'Ítem'; maxQty[itemId] = qty; unitValue[itemId] = up; itemName[itemId] = name; } final reasonController = TextEditingController(text: 'Devolución parcial por ítem'); await showModalBottomSheet( context: context, isScrollControlled: true, builder: (sheetCtx) { return StatefulBuilder( builder: (sheetCtx, setSheetState) { double total = 0; selections.forEach((k, v) { total += v * (unitValue[k] ?? 0); // ya incluye IGV }); return SafeArea( child: SingleChildScrollView( padding: EdgeInsets.fromLTRB( 20, 16, 20, 24 + MediaQuery.of(sheetCtx).viewInsets.bottom, ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text('NC parcial · $title', style: Theme.of(context).textTheme.titleLarge), const SizedBox(height: 6), Text( 'Selecciona los ítems y cantidades a devolver. El stock se ajusta automáticamente.', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: AppTokens.secondary, ), ), const SizedBox(height: 14), for (final itemId in maxQty.keys) ...[ _buildPartialItemRow( itemId: itemId, name: itemName[itemId] ?? 'Ítem', maxQty: maxQty[itemId]!, unitValue: unitValue[itemId]!, selected: selections[itemId] ?? 0, onChange: (v) => setSheetState(() { if (v <= 0) { selections.remove(itemId); } else { selections[itemId] = v; } }), ), const Divider(height: 16), ], TextField( controller: reasonController, decoration: const InputDecoration( labelText: 'Motivo', border: OutlineInputBorder(), ), maxLines: 2, ), const SizedBox(height: 14), Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: AppTokens.primary.withValues(alpha: 0.08), borderRadius: BorderRadius.circular(10), ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const Text('Total estimado'), Text( 'S/ ${total.toStringAsFixed(2)}', style: const TextStyle( fontWeight: FontWeight.w800, color: AppTokens.primary), ), ], ), ), const SizedBox(height: 14), FilledButton.icon( style: FilledButton.styleFrom( backgroundColor: AppTokens.warning), onPressed: selections.isEmpty ? null : () async { final items = selections.entries .map((e) => { 'item_id': e.key, 'quantity': e.value, }) .toList(); Navigator.of(sheetCtx).pop(); await _submitCreditNotePartial( id, items, reasonController.text.trim()); }, icon: const Icon(Icons.assignment_return_rounded), label: const Text('Emitir NC parcial'), ), ], ), ), ); }, ); }, ); } Widget _buildPartialItemRow({ required int itemId, required String name, required double maxQty, required double unitValue, required double selected, required ValueChanged onChange, }) { final isChecked = selected > 0; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Checkbox( value: isChecked, onChanged: (v) => onChange(v == true ? maxQty : 0), ), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(name, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( fontWeight: FontWeight.w600)), Text( 'Máx: ${maxQty.toStringAsFixed(0)} · S/ ${unitValue.toStringAsFixed(2)}', style: const TextStyle( fontSize: 12, color: AppTokens.secondary), ), ], ), ), ], ), if (isChecked) Padding( padding: const EdgeInsets.fromLTRB(40, 0, 0, 8), child: Row( children: [ IconButton( icon: const Icon(Icons.remove_circle_outline), onPressed: selected > 1 ? () => onChange(selected - 1) : null, ), Text(selected.toStringAsFixed(0), style: const TextStyle( fontSize: 18, fontWeight: FontWeight.w700)), IconButton( icon: const Icon(Icons.add_circle_outline), onPressed: selected < maxQty ? () => onChange(selected + 1) : null, ), ], ), ), ], ); } Future _submitCreditNotePartial( int id, List> items, String reason) async { final messenger = ScaffoldMessenger.of(context); messenger.showSnackBar( const SnackBar(content: Text('Emitiendo NC parcial...')), ); final response = await ref.read(apiClientProvider).emitCreditNote( id: id, reason: reason.isEmpty ? 'Devolución parcial por ítem' : reason, items: items, noteCreditTypeId: '07', ); if (!mounted) return; messenger.showSnackBar( SnackBar( content: Text(response.message ?? (response.success ? 'OK' : 'Error')), backgroundColor: response.success ? AppTokens.success : AppTokens.danger, ), ); if (response.success) await _loadInitial(); } /// Sheet de NC descuento global (SUNAT tipo 04): /// usuario ingresa monto de descuento, se emite NC por ese monto. Future _openGlobalDiscountSheet( int id, String title, Map detail) async { final originalTotal = double.tryParse('${detail['total'] ?? 0}') ?? 0; if (originalTotal <= 0) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('El documento no tiene total válido.')), ); return; } final amountController = TextEditingController(); final reasonController = TextEditingController(text: 'Descuento global'); await showModalBottomSheet( context: context, isScrollControlled: true, builder: (sheetCtx) { return StatefulBuilder( builder: (sheetCtx, setSheetState) { final amount = double.tryParse(amountController.text.trim()) ?? 0; final valid = amount > 0 && amount <= originalTotal; final percent = originalTotal > 0 ? (amount / originalTotal * 100) : 0; return SafeArea( child: SingleChildScrollView( padding: EdgeInsets.fromLTRB( 20, 16, 20, 24 + MediaQuery.of(sheetCtx).viewInsets.bottom, ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( children: [ CircleAvatar( backgroundColor: AppTokens.cta.withValues(alpha: 0.12), foregroundColor: AppTokens.cta, child: const Icon(Icons.percent_rounded), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'NC descuento global', style: Theme.of(context).textTheme.titleLarge, ), Text( title, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context) .textTheme .bodySmall ?.copyWith(color: AppTokens.secondary), ), ], ), ), ], ), const SizedBox(height: 14), Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: AppTokens.primary.withValues(alpha: 0.08), borderRadius: BorderRadius.circular(10), ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const Text('Total original'), Text('S/ ${originalTotal.toStringAsFixed(2)}', style: const TextStyle( fontWeight: FontWeight.w800)), ], ), ), const SizedBox(height: 14), TextField( controller: amountController, keyboardType: const TextInputType.numberWithOptions( decimal: true), decoration: const InputDecoration( labelText: 'Monto del descuento (S/)', border: OutlineInputBorder(), prefixIcon: Icon(Icons.payments_rounded), ), onChanged: (_) => setSheetState(() {}), ), if (amount > 0) Padding( padding: const EdgeInsets.only(top: 6), child: Text( valid ? '${percent.toStringAsFixed(1)}% del total' : 'El monto supera el total original', style: TextStyle( fontSize: 12, color: valid ? AppTokens.success : AppTokens.danger, ), ), ), const SizedBox(height: 14), TextField( controller: reasonController, maxLines: 2, decoration: const InputDecoration( labelText: 'Motivo', border: OutlineInputBorder(), ), ), const SizedBox(height: 18), FilledButton.icon( style: FilledButton.styleFrom( backgroundColor: AppTokens.cta), onPressed: valid ? () async { Navigator.of(sheetCtx).pop(); await _submitGlobalDiscount( id, amount, reasonController.text.trim(), ); } : null, icon: const Icon(Icons.check_circle_rounded), label: Text(valid ? 'Emitir NC por S/ ${amount.toStringAsFixed(2)}' : 'Emitir'), ), ], ), ), ); }, ); }, ); } Future _submitGlobalDiscount( int id, double amount, String reason) async { final messenger = ScaffoldMessenger.of(context); messenger.showSnackBar( const SnackBar(content: Text('Emitiendo NC descuento global...')), ); final response = await ref.read(apiClientProvider).emitCreditNote( id: id, reason: reason.isEmpty ? 'Descuento global' : reason, noteCreditTypeId: '04', discountAmount: amount, ); if (!mounted) return; messenger.showSnackBar( SnackBar( content: Text(response.message ?? (response.success ? 'OK' : 'Error')), backgroundColor: response.success ? AppTokens.success : AppTokens.danger, ), ); if (response.success) await _loadInitial(); } /// Sheet de Nota de Débito: selector de motivo + items con cantidad+precio. Future _openDebitSheet( int id, String title, Map detail) async { final rawItems = detail['items'] is List ? List>.from( (detail['items'] as List).map((e) => Map.from(e as Map))) : >[]; if (rawItems.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Este comprobante no tiene ítems.')), ); return; } String motive = '01'; final reasonController = TextEditingController(); final lines = >[]; for (final it in rawItems) { final itemId = int.tryParse('${it['item_id']}') ?? 0; if (itemId <= 0) continue; final name = it['item']?['description']?.toString() ?? it['description']?.toString() ?? 'Ítem'; // ND: usar unit_price (con IGV) como valor base editable. // El backend recibe unit_value; como no tenemos el valor sin IGV, // mandamos unit_price y el backend lo usa como unit_value directo // (válido para ND de "aumento en valor" donde el usuario edita el total). lines.add({ 'item_id': itemId, 'name': name, 'qty_ctrl': TextEditingController(text: '1'), 'price_ctrl': TextEditingController(text: '${it['unit_price'] ?? 0}'), 'selected': false, }); } const motives = [ ('01', 'Intereses por mora'), ('02', 'Aumento en el valor'), ('03', 'Penalidades'), ]; await showModalBottomSheet( context: context, isScrollControlled: true, builder: (sheetCtx) { return StatefulBuilder( builder: (sheetCtx, setSheetState) { double total = 0; for (final line in lines) { if (line['selected'] == true) { final qty = double.tryParse( (line['qty_ctrl'] as TextEditingController).text) ?? 0; final price = double.tryParse( (line['price_ctrl'] as TextEditingController).text) ?? 0; total += qty * price * 1.18; } } return SafeArea( child: SingleChildScrollView( padding: EdgeInsets.fromLTRB( 20, 16, 20, 24 + MediaQuery.of(sheetCtx).viewInsets.bottom, ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text('Nota de Débito · $title', style: Theme.of(context).textTheme.titleLarge), const SizedBox(height: 10), const Text('Motivo', style: TextStyle(fontWeight: FontWeight.w600)), const SizedBox(height: 6), Wrap( spacing: 8, children: [ for (final m in motives) ChoiceChip( label: Text(m.$2), selected: motive == m.$1, onSelected: (_) => setSheetState(() => motive = m.$1), ), ], ), const SizedBox(height: 14), TextField( controller: reasonController, decoration: const InputDecoration( labelText: 'Descripción (opcional)', border: OutlineInputBorder(), ), ), const SizedBox(height: 14), const Text('Ítems a incluir', style: TextStyle(fontWeight: FontWeight.w600)), const SizedBox(height: 6), for (final line in lines) ...[ Row( children: [ Checkbox( value: line['selected'] == true, onChanged: (v) => setSheetState( () => line['selected'] = v == true), ), Expanded( child: Text( line['name'] as String, maxLines: 1, overflow: TextOverflow.ellipsis, ), ), ], ), if (line['selected'] == true) Padding( padding: const EdgeInsets.fromLTRB(40, 0, 0, 8), child: Row( children: [ Expanded( child: TextField( controller: line['qty_ctrl'] as TextEditingController, decoration: const InputDecoration( labelText: 'Cant', isDense: true), keyboardType: TextInputType.number, onChanged: (_) => setSheetState(() {}), ), ), const SizedBox(width: 8), Expanded( child: TextField( controller: line['price_ctrl'] as TextEditingController, decoration: const InputDecoration( labelText: 'V. unit', isDense: true), keyboardType: TextInputType.number, onChanged: (_) => setSheetState(() {}), ), ), ], ), ), const Divider(height: 8), ], const SizedBox(height: 8), Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: AppTokens.primary.withValues(alpha: 0.08), borderRadius: BorderRadius.circular(10), ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const Text('Total estimado (con IGV)'), Text('S/ ${total.toStringAsFixed(2)}', style: const TextStyle( fontWeight: FontWeight.w800, color: AppTokens.primary)), ], ), ), const SizedBox(height: 14), FilledButton.icon( onPressed: () async { final payloadItems = >[]; for (final line in lines) { if (line['selected'] != true) continue; final qty = double.tryParse( (line['qty_ctrl'] as TextEditingController) .text) ?? 0; final price = double.tryParse( (line['price_ctrl'] as TextEditingController) .text) ?? 0; if (qty <= 0 || price <= 0) continue; payloadItems.add({ 'item_id': line['item_id'], 'quantity': qty, 'unit_value': price, }); } if (payloadItems.isEmpty) { ScaffoldMessenger.of(sheetCtx).showSnackBar( const SnackBar( content: Text( 'Selecciona al menos un ítem válido.')), ); return; } Navigator.of(sheetCtx).pop(); await _submitDebitNote( id, payloadItems, motive, reasonController.text.trim(), ); }, icon: const Icon(Icons.trending_up_rounded), label: const Text('Emitir Nota de Débito'), ), ], ), ), ); }, ); }, ); } Future _submitDebitNote( int id, List> items, String motive, String reason) async { final messenger = ScaffoldMessenger.of(context); messenger.showSnackBar( const SnackBar(content: Text('Emitiendo nota de débito...')), ); final response = await ref.read(apiClientProvider).emitDebitNote( id: id, items: items, noteDebitTypeId: motive, reason: reason, ); if (!mounted) return; messenger.showSnackBar( SnackBar( content: Text(response.message ?? (response.success ? 'OK' : 'Error')), backgroundColor: response.success ? AppTokens.success : AppTokens.danger, ), ); if (response.success) await _loadInitial(); } bool _needsSunatSend(Map detail) { final stateId = detail['state_type_id']?.toString() ?? ''; // 05 = Aceptado SUNAT, 11 = Anulado, 13 = Por anular → no enviar. if (stateId.isEmpty) return false; const finalStates = {'05', '11', '13'}; return !finalStates.contains(stateId); } Future _sendToSunat(int id) async { final messenger = ScaffoldMessenger.of(context); messenger.showSnackBar( const SnackBar( content: Text('Enviando a SUNAT...'), duration: Duration(seconds: 2), ), ); final response = await ref.read(apiClientProvider).sendDocumentToSunat(id: id); if (!mounted) return; if (response.success) { messenger.showSnackBar( SnackBar( content: Text(response.message ?? 'Enviado a SUNAT.'), backgroundColor: AppTokens.success, ), ); await _loadInitial(); } else { messenger.showSnackBar( SnackBar( content: Text(response.message ?? 'No se pudo enviar.'), backgroundColor: AppTokens.danger, ), ); } } Future _viewPdf(int id, String title) async { final api = ref.read(apiClientProvider); final response = await api.documentPdf(id: id, model: _modelKey); if (!mounted) return; if (!response.success || response.data?['pdf_url'] == null) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(response.message ?? 'No se pudo generar el PDF.'), backgroundColor: AppTokens.danger, ), ); return; } await PdfHelper.openAuthenticatedPdf( context: context, apiClient: api, url: response.data!['pdf_url'].toString(), title: title, ); } Future _printTicket(int id) async { // ignore: avoid_print print('[EnterFarma] _printTicket id=$id INICIO'); final messenger = ScaffoldMessenger.of(context); messenger.showSnackBar( const SnackBar( content: Text('Preparando impresión...'), duration: Duration(seconds: 1), ), ); final api = ref.read(apiClientProvider); final response = await api.documentPrintPayload(id: id, model: _modelKey); // ignore: avoid_print print('[EnterFarma] _printTicket payload success=${response.success}'); if (!response.success || response.data == null) { messenger.showSnackBar( SnackBar( content: Text(response.message ?? 'No se pudo preparar la impresión.'), backgroundColor: AppTokens.danger, ), ); return; } try { await BluetoothPrintService().printTicket(response.data!); // ignore: avoid_print print('[EnterFarma] _printTicket OK'); messenger.showSnackBar( const SnackBar(content: Text('Ticket enviado a la impresora.')), ); } on StateError catch (e) { // ignore: avoid_print print('[EnterFarma] _printTicket StateError: $e'); if (!mounted) return; final goSetup = await showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('Impresora no conectada'), content: const Text( 'No hay una impresora Bluetooth conectada. ¿Deseas configurarla ahora?'), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancelar'), ), FilledButton( onPressed: () => Navigator.pop(ctx, true), child: const Text('Configurar'), ), ], ), ); if (!mounted) return; if (goSetup == true) { await showPrinterSettings(context, ref); if (!mounted) return; } } catch (e) { // ignore: avoid_print print('[EnterFarma] _printTicket EXCEPTION: $e'); messenger.showSnackBar( SnackBar( content: Text('Error al imprimir: $e'), backgroundColor: AppTokens.danger, ), ); } } Future _sendWhatsApp(int id, String title, String customerPhone) async { final api = ref.read(apiClientProvider); final response = await api.documentPdf(id: id, model: _modelKey); if (!mounted) return; final pdfUrl = response.data?['pdf_url']?.toString(); final text = pdfUrl != null && pdfUrl.isNotEmpty ? '$title\n$pdfUrl' : title; final phoneDigits = customerPhone.replaceAll(RegExp(r'\D'), ''); final waUrl = phoneDigits.isEmpty ? 'https://wa.me/?text=${Uri.encodeComponent(text)}' : 'https://wa.me/$phoneDigits?text=${Uri.encodeComponent(text)}'; final uri = Uri.parse(waUrl); if (!await launchUrl(uri, mode: LaunchMode.externalApplication)) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('No se pudo abrir WhatsApp.')), ); } } } class _TabButton extends StatelessWidget { const _TabButton({ required this.label, required this.icon, required this.active, required this.onTap, }); final String label; final IconData icon; final bool active; final VoidCallback onTap; @override Widget build(BuildContext context) { return Material( color: Colors.transparent, child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(AppTokens.radiusSmall), child: Container( padding: const EdgeInsets.symmetric(vertical: 14), decoration: BoxDecoration( color: active ? AppTokens.primary.withValues(alpha: 0.10) : Colors.transparent, borderRadius: BorderRadius.circular(AppTokens.radiusSmall), ), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( icon, size: 18, color: active ? AppTokens.primary : AppTokens.secondary, ), const SizedBox(width: 8), Text( label, style: Theme.of(context).textTheme.labelLarge?.copyWith( color: active ? AppTokens.primary : AppTokens.secondary, fontWeight: active ? FontWeight.w700 : FontWeight.w500, ), ), ], ), ), ), ); } } class _SalesHistoryRow extends StatelessWidget { const _SalesHistoryRow({ required this.record, required this.onTap, }); final Map record; final VoidCallback onTap; @override Widget build(BuildContext context) { final number = record['number_full']?.toString() ?? '${record['series'] ?? ''}-${record['number'] ?? ''}'; final customer = record['customer_name']?.toString() ?? record['customer']?['name']?.toString() ?? 'Cliente varios'; final total = record['total']?.toString() ?? '0.00'; final dateOfIssue = record['date_of_issue']?.toString() ?? '-'; final stateDescription = record['state_type_description']?.toString() ?? ''; final stateId = record['state_type_id']?.toString() ?? ''; Color stateColor; switch (stateId) { case '05': stateColor = AppTokens.success; break; case '11': case '09': stateColor = AppTokens.danger; break; default: stateColor = AppTokens.secondary; } return InkWell( onTap: onTap, borderRadius: BorderRadius.circular(AppTokens.radiusSmall), child: Padding( padding: const EdgeInsets.symmetric(vertical: 6), child: Row( children: [ CircleAvatar( radius: 20, backgroundColor: AppTokens.primary.withValues(alpha: 0.10), foregroundColor: AppTokens.primary, child: const Icon(Icons.receipt_long_rounded, size: 20), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( number, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.titleSmall, ), const SizedBox(height: 2), Text( customer, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: AppTokens.secondary, ), ), const SizedBox(height: 2), Row( children: [ Text( dateOfIssue, style: Theme.of(context).textTheme.labelSmall?.copyWith( color: AppTokens.secondary, ), ), if (stateDescription.isNotEmpty) ...[ const SizedBox(width: 6), Text( '·', style: Theme.of(context).textTheme.labelSmall?.copyWith( color: AppTokens.secondary, ), ), const SizedBox(width: 6), Text( stateDescription, style: Theme.of(context).textTheme.labelSmall?.copyWith( color: stateColor, fontWeight: FontWeight.w700, ), ), ], ], ), ], ), ), const SizedBox(width: 8), Text( 'S/ $total', style: Theme.of(context).textTheme.titleMedium?.copyWith( color: AppTokens.textPrimary, fontWeight: FontWeight.w700, ), ), ], ), ), ); } } class _DetailLine extends StatelessWidget { const _DetailLine({required this.label, required this.value}); final String label; final String value; @override Widget build(BuildContext context) { return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: Text( label, style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: AppTokens.secondary, ), ), ), const SizedBox(width: 12), Expanded( child: Text( value, textAlign: TextAlign.end, style: Theme.of(context).textTheme.titleSmall, ), ), ], ); } }