import 'package:enter_farma_plus_mobile/src/core/network/api_client.dart'; import 'package:enter_farma_plus_mobile/src/core/theme/app_tokens.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; class StocktakeSessionScreen extends ConsumerStatefulWidget { const StocktakeSessionScreen({super.key, required this.countId}); final int countId; @override ConsumerState createState() => _StocktakeSessionScreenState(); } class _StocktakeSessionScreenState extends ConsumerState { late Future?> _future; final _codeController = TextEditingController(); final _quantityController = TextEditingController(text: '1'); bool _submitting = false; bool _adding = false; @override void initState() { super.initState(); _future = _load(); } @override void dispose() { _codeController.dispose(); _quantityController.dispose(); super.dispose(); } Future?> _load() async { final response = await ref.read(apiClientProvider).physicalCount(widget.countId); if (!response.success) { _showMessage(response.message ?? 'No se pudo cargar el conteo.', isError: true); return null; } return response.data; } void _refresh() { setState(() { _future = _load(); }); } Future _scan() async { final value = await context.push('/scanner'); if (value == null || value.trim().isEmpty) return; _codeController.text = value.trim(); await _addTypedCode(source: 'scan'); } Future _addTypedCode({String source = 'manual'}) async { if (_adding) return; final value = _codeController.text.trim(); if (value.isEmpty) { _showMessage('Ingresa codigo, barra, nombre, laboratorio o lote.', isError: true); return; } setState(() => _adding = true); final response = await ref.read(apiClientProvider).searchPhysicalCountItems( widget.countId, search: value, ); if (!mounted) return; if (!response.success) { setState(() => _adding = false); _showMessage(response.message ?? 'No se pudo buscar el producto.', isError: true); return; } final results = response.data ?? const >[]; if (results.isEmpty) { setState(() => _adding = false); _showMessage('No se encontro ningun producto.', isError: true); return; } final selected = source == 'scan' ? _directScanResult(results, value) ?? await _pickSearchResult(results) : await _pickSearchResult(results); if (selected == null) { if (mounted) setState(() => _adding = false); return; } await _addSelectedItem( selected, source: source, barcodeValue: source == 'scan' ? value : null, ); if (mounted) _codeController.clear(); if (mounted) setState(() => _adding = false); } Map? _directScanResult( List> results, String value) { if (results.length != 1) return null; final item = results.first; final exactCode = [ item['barcode'], item['internal_id'], ].any((code) => code != null && code.toString() == value); final matchedBy = item['matched_by']?.toString(); if (exactCode || matchedBy == 'barcode' || matchedBy == 'codigo_interno' || matchedBy == 'barcode_presentacion') { return item; } return null; } Future _addSelectedItem(Map item, {required String source, String? barcodeValue}) async { final quantity = source == 'manual' ? await _confirmQuantity(item) : _currentQuantity(); if (quantity == null) return; final lot = await _resolveLotChoice(item); if (lot == null) return; final response = await ref.read(apiClientProvider).addPhysicalCountItem( widget.countId, barcode: barcodeValue, itemId: int.tryParse('${item['item_id']}'), itemUnitTypeId: int.tryParse('${item['matched_item_unit_type_id'] ?? ''}'), lotGroupId: lot.lotGroupId, lotCode: lot.lotCode, quantity: quantity <= 0 ? 1 : quantity, source: source, notes: lot.notes, ); if (!mounted) return; if (!response.success) { _showMessage(response.message ?? 'No se pudo agregar el producto.', isError: true); return; } _showMessage(response.message ?? 'Producto contado.'); _refresh(); } double _currentQuantity() { return double.tryParse( _quantityController.text.trim().replaceAll(',', '.')) ?? 1; } Future _confirmQuantity(Map item) async { final controller = TextEditingController(text: _currentQuantity().toString()); final result = await showModalBottomSheet( context: context, isScrollControlled: true, builder: (context) { return SafeArea( child: Padding( padding: EdgeInsets.fromLTRB( 20, 16, 20, 20 + MediaQuery.of(context).viewInsets.bottom), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('Confirmar conteo', style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: 8), Text(item['description']?.toString() ?? 'Producto'), const SizedBox(height: 16), TextField( controller: controller, keyboardType: const TextInputType.numberWithOptions(decimal: true), decoration: const InputDecoration( labelText: 'Cantidad contada', prefixText: '+ ', ), autofocus: true, ), const SizedBox(height: 16), SizedBox( width: double.infinity, child: FilledButton.icon( onPressed: () { final quantity = double.tryParse( controller.text.trim().replaceAll(',', '.')) ?? 1; Navigator.of(context).pop(quantity <= 0 ? 1 : quantity); }, icon: const Icon(Icons.check_rounded), label: const Text('Agregar al conteo'), ), ), ], ), ), ); }, ); controller.dispose(); return result; } Future<_LotChoice?> _resolveLotChoice(Map item) async { final lotsEnabled = item['lots_enabled'] == true; final matchedLotGroupId = int.tryParse('${item['matched_lot_group_id'] ?? ''}'); final matchedLotCode = item['matched_lot_code']?.toString(); if (!lotsEnabled) { return _LotChoice(lotGroupId: matchedLotGroupId, lotCode: matchedLotCode); } if (matchedLotGroupId != null) { return _LotChoice(lotGroupId: matchedLotGroupId, lotCode: matchedLotCode); } final lots = List>.from(item['lots'] as List? ?? const []); return showModalBottomSheet<_LotChoice>( context: context, isScrollControlled: true, builder: (context) { return SafeArea( child: DraggableScrollableSheet( expand: false, initialChildSize: lots.isEmpty ? 0.35 : 0.65, minChildSize: 0.3, maxChildSize: 0.9, builder: (context, scrollController) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.fromLTRB(20, 16, 20, 8), child: Text('Selecciona lote', style: Theme.of(context).textTheme.titleMedium), ), Expanded( child: lots.isEmpty ? const Center( child: Text( 'Este producto no tiene lotes registrados.')) : ListView.separated( controller: scrollController, itemCount: lots.length, separatorBuilder: (_, __) => const Divider(height: 1), itemBuilder: (context, index) { final lot = lots[index]; return ListTile( onTap: () => Navigator.of(context).pop(_LotChoice( lotGroupId: int.tryParse('${lot['id']}'), lotCode: lot['code']?.toString(), )), title: Text(lot['code']?.toString() ?? 'Lote'), subtitle: Text('Vence: ${lot['date_of_due'] ?? '-'}'), trailing: Text('Stock ${lot['quantity'] ?? 0}'), ); }, ), ), Padding( padding: const EdgeInsets.fromLTRB(20, 8, 20, 16), child: SizedBox( width: double.infinity, child: OutlinedButton.icon( onPressed: () async { final observed = await _observeMissingLot(); if (context.mounted && observed != null) { Navigator.of(context).pop(observed); } }, icon: const Icon(Icons.report_problem_outlined), label: const Text('Lote no registrado'), ), ), ), ], ); }, ), ); }, ); } Future<_LotChoice?> _observeMissingLot() async { final lot = TextEditingController(); final notes = TextEditingController(); final result = await showModalBottomSheet( context: context, isScrollControlled: true, builder: (context) { return SafeArea( child: Padding( padding: EdgeInsets.fromLTRB( 20, 16, 20, 20 + MediaQuery.of(context).viewInsets.bottom), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('Lote no registrado', style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: 12), TextField( controller: lot, decoration: const InputDecoration( labelText: 'Codigo de lote fisico', border: OutlineInputBorder(), ), ), const SizedBox(height: 12), TextField( controller: notes, decoration: const InputDecoration( labelText: 'Observacion', border: OutlineInputBorder(), ), ), const SizedBox(height: 16), Row( children: [ Expanded( child: OutlinedButton( onPressed: () => Navigator.of(context).pop(false), child: const Text('Cancelar'), ), ), const SizedBox(width: 12), Expanded( child: FilledButton( onPressed: () => Navigator.of(context).pop(true), child: const Text('Guardar'), ), ), ], ), ], ), ), ); }, ); if (result != true || lot.text.trim().isEmpty) return null; return _LotChoice( lotCode: lot.text.trim(), notes: notes.text.trim().isEmpty ? null : notes.text.trim(), ); } Future?> _pickSearchResult( List> results) { return showModalBottomSheet>( context: context, isScrollControlled: true, builder: (context) { return SafeArea( child: DraggableScrollableSheet( expand: false, initialChildSize: 0.75, minChildSize: 0.4, maxChildSize: 0.95, builder: (context, scrollController) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.fromLTRB(20, 16, 20, 8), child: Text( 'Selecciona producto', style: Theme.of(context).textTheme.titleMedium, ), ), Expanded( child: ListView.separated( controller: scrollController, itemCount: results.length, separatorBuilder: (_, __) => const Divider(height: 1), itemBuilder: (context, index) { final item = results[index]; final lot = item['matched_lot_code']?.toString(); final lotDate = item['matched_lot_date_of_due']?.toString(); final laboratory = item['laboratory']?.toString(); final matchedBy = _matchLabel(item['matched_by']?.toString()); return ListTile( onTap: () => Navigator.of(context).pop(item), title: Text( item['description']?.toString() ?? 'Producto'), subtitle: Text([ if ((item['internal_id']?.toString() ?? '') .isNotEmpty) 'Cod: ${item['internal_id']}', if ((item['barcode']?.toString() ?? '').isNotEmpty) 'Barra: ${item['barcode']}', if (laboratory != null && laboratory.isNotEmpty) 'Lab: $laboratory', if (lot != null && lot.isNotEmpty) 'Lote: $lot${lotDate != null && lotDate.isNotEmpty ? ' / $lotDate' : ''}', ].join(' | ')), trailing: Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.end, children: [ Text('Stock ${item['system_stock'] ?? 0}'), Text( matchedBy, style: Theme.of(context).textTheme.bodySmall, ), ], ), ); }, ), ), ], ); }, ), ); }, ); } String _matchLabel(String? value) { switch (value) { case 'barcode_presentacion': return 'Presentacion'; case 'codigo_interno': return 'Codigo'; case 'laboratorio': return 'Laboratorio'; case 'lote': return 'Lote'; case 'nombre': return 'Nombre'; case 'barcode': return 'Barra'; default: return 'Producto'; } } Future _editLine(Map line) async { final qty = TextEditingController(text: '${line['counted_qty_base'] ?? 0}'); final notes = TextEditingController(text: '${line['notes'] ?? ''}'); final lots = List>.from( line['lots'] as List? ?? const >[]); int? selectedLotGroupId = int.tryParse('${line['lot_group_id'] ?? ''}'); final result = await showModalBottomSheet( context: context, isScrollControlled: true, builder: (context) { return StatefulBuilder(builder: (context, setModalState) { return SafeArea( child: Padding( padding: EdgeInsets.fromLTRB( 20, 16, 20, 20 + MediaQuery.of(context).viewInsets.bottom, ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(line['description']?.toString() ?? 'Producto', style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: 12), TextField( controller: qty, keyboardType: const TextInputType.numberWithOptions(decimal: true), decoration: const InputDecoration( labelText: 'Cantidad real en unidades base', border: OutlineInputBorder(), ), ), if (line['lots_enabled'] == true) ...[ const SizedBox(height: 12), DropdownButtonFormField( initialValue: selectedLotGroupId, decoration: const InputDecoration( labelText: 'Lote registrado', border: OutlineInputBorder(), ), items: [ const DropdownMenuItem( value: null, child: Text('Pendiente de lote'), ), ...lots.map((lot) => DropdownMenuItem( value: int.tryParse('${lot['id']}'), child: Text( '${lot['code'] ?? 'Lote'} / ${lot['date_of_due'] ?? '-'} / Stock ${lot['quantity'] ?? 0}'), )), ], onChanged: (value) => setModalState(() => selectedLotGroupId = value), ), ], const SizedBox(height: 12), TextField( controller: notes, decoration: const InputDecoration( labelText: 'Observacion', border: OutlineInputBorder(), ), ), const SizedBox(height: 16), Row( children: [ Expanded( child: OutlinedButton( onPressed: () => Navigator.of(context).pop(false), child: const Text('Cancelar'), ), ), const SizedBox(width: 12), Expanded( child: FilledButton( onPressed: () => Navigator.of(context).pop(true), child: const Text('Guardar'), ), ), ], ), ], ), ), ); }); }, ); if (result != true) return; final lineId = int.tryParse('${line['id']}'); if (lineId == null) return; final response = await ref.read(apiClientProvider).updatePhysicalCountItem( widget.countId, lineId, countedQtyBase: double.tryParse(qty.text.trim().replaceAll(',', '.')) ?? 0, lotGroupId: selectedLotGroupId, notes: notes.text.trim().isEmpty ? null : notes.text.trim(), ); if (!mounted) return; if (!response.success) { _showMessage(response.message ?? 'No se pudo actualizar.', isError: true); return; } _refresh(); } Future _deleteLine(Map line) async { final lineId = int.tryParse('${line['id']}'); if (lineId == null) return; final ok = await showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Eliminar linea'), content: Text( 'Quitar ${line['description'] ?? 'este producto'} del conteo?'), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(false), child: const Text('Cancelar')), FilledButton( onPressed: () => Navigator.of(context).pop(true), child: const Text('Eliminar')), ], ), ); if (ok != true) return; final response = await ref .read(apiClientProvider) .removePhysicalCountItem(widget.countId, lineId); if (!mounted) return; if (!response.success) { _showMessage(response.message ?? 'No se pudo eliminar.', isError: true); return; } _showMessage(response.message ?? 'Linea eliminada.'); _refresh(); } Future _submit(int linesCount) async { if (linesCount <= 0) { _showMessage('Agrega al menos un producto antes de enviar el conteo.', isError: true); return; } final ok = await showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Finalizar conteo'), content: const Text('El conteo se enviara a revision web.'), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(false), child: const Text('Cancelar')), FilledButton( onPressed: () => Navigator.of(context).pop(true), child: const Text('Enviar')), ], ), ); if (ok != true) return; setState(() => _submitting = true); final response = await ref.read(apiClientProvider).submitPhysicalCount(widget.countId); if (!mounted) return; setState(() => _submitting = false); if (!response.success) { _showMessage(response.message ?? 'No se pudo finalizar.', isError: true); return; } context.go('/stocktake/${widget.countId}/report'); } void _showMessage(String message, {bool isError = false}) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(message), backgroundColor: isError ? AppTokens.danger : AppTokens.success, ), ); } @override Widget build(BuildContext context) { return FutureBuilder?>( future: _future, builder: (context, snapshot) { final count = snapshot.data; final lines = List>.from( count?['lines'] as List? ?? const []); final status = count?['status']?.toString() ?? 'draft'; final editable = status == 'draft'; return Scaffold( appBar: AppBar( title: Text('Conteo #${widget.countId}'), actions: [ IconButton( onPressed: _refresh, icon: const Icon(Icons.refresh_rounded)), ], ), body: ListView( padding: AppTokens.pagePadding, children: [ if (snapshot.connectionState == ConnectionState.waiting) const Center(child: CircularProgressIndicator()) else if (count == null) const Text('No se encontro el conteo.') else ...[ _SessionHeader(count: count), const SizedBox(height: 14), if (editable) _InputPanel( codeController: _codeController, quantityController: _quantityController, onScan: _scan, onAdd: () => _addTypedCode(), adding: _adding, ), const SizedBox(height: 16), Text('Productos contados', style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: 8), if (lines.isEmpty) const Text('Aun no hay productos contados.') else ...lines.map((line) => _LineTile( line: line, editable: editable, onTap: () => _editLine(line), onDelete: () => _deleteLine(line), )), const SizedBox(height: 88), ], ], ), bottomNavigationBar: editable && count != null ? SafeArea( child: Padding( padding: const EdgeInsets.fromLTRB(20, 8, 20, 16), child: FilledButton.icon( onPressed: _submitting ? null : () => _submit(lines.length), icon: _submitting ? const SizedBox( width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)) : const Icon(Icons.send_rounded), label: const Text('Enviar a revision'), ), ), ) : null, ); }, ); } } class _LotChoice { const _LotChoice({ this.lotGroupId, this.lotCode, this.notes, }); final int? lotGroupId; final String? lotCode; final String? notes; } class _SessionHeader extends StatelessWidget { const _SessionHeader({required this.count}); final Map count; @override Widget build(BuildContext context) { final summary = Map.from(count['summary'] as Map? ?? const {}); final total = summary['total_lines'] ?? 0; final diffs = summary['difference_lines'] ?? 0; final pending = summary['pending_lot_lines'] ?? 0; return Card( elevation: 0, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), side: BorderSide(color: Colors.grey.shade200), ), child: Padding( padding: const EdgeInsets.all(14), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon(Icons.warehouse_outlined, size: 16, color: Colors.grey.shade500), const SizedBox(width: 6), Expanded( child: Text( count['warehouse_name']?.toString() ?? 'Almacen', style: const TextStyle( fontWeight: FontWeight.w700, fontSize: 14), ), ), ], ), const SizedBox(height: 10), Row( children: [ _MetricBadge( label: 'Items', value: '$total', color: AppTokens.primary, ), const SizedBox(width: 8), _MetricBadge( label: 'Diferencias', value: '$diffs', color: diffs > 0 ? AppTokens.warning : AppTokens.success, ), if (pending > 0) ...[ const SizedBox(width: 8), _MetricBadge( label: 'Pend. lote', value: '$pending', color: AppTokens.danger, ), ], ], ), ], ), ), ); } } class _MetricBadge extends StatelessWidget { const _MetricBadge({ required this.label, required this.value, required this.color, }); final String label; final String value; final Color color; @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), decoration: BoxDecoration( color: color.withValues(alpha: 0.08), borderRadius: BorderRadius.circular(8), ), child: Column( children: [ Text(value, style: TextStyle( fontSize: 16, fontWeight: FontWeight.w800, color: color)), Text(label, style: TextStyle(fontSize: 10, color: Colors.grey.shade500)), ], ), ); } } class _InputPanel extends StatelessWidget { const _InputPanel({ required this.codeController, required this.quantityController, required this.onScan, required this.onAdd, required this.adding, }); final TextEditingController codeController; final TextEditingController quantityController; final VoidCallback onScan; final VoidCallback onAdd; final bool adding; @override Widget build(BuildContext context) { return Column( children: [ TextField( controller: codeController, decoration: InputDecoration( labelText: 'Barra, codigo, nombre, laboratorio o lote', border: const OutlineInputBorder(), suffixIcon: Row( mainAxisSize: MainAxisSize.min, children: [ IconButton( tooltip: 'Buscar', onPressed: adding ? null : onAdd, icon: adding ? const SizedBox( width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.search_rounded), ), IconButton( tooltip: 'Escanear', onPressed: adding ? null : onScan, icon: const Icon(Icons.qr_code_scanner_rounded), ), ], ), ), onSubmitted: (_) { if (!adding) onAdd(); }, ), const SizedBox(height: 10), TextField( controller: quantityController, keyboardType: const TextInputType.numberWithOptions(decimal: true), decoration: const InputDecoration( labelText: 'Cantidad a sumar', border: OutlineInputBorder(), prefixIcon: Icon(Icons.exposure_plus_1_rounded), ), ), ], ); } } class _LineTile extends StatelessWidget { const _LineTile({ required this.line, required this.editable, required this.onTap, required this.onDelete, }); final Map line; final bool editable; final VoidCallback onTap; final VoidCallback onDelete; @override Widget build(BuildContext context) { final diff = double.tryParse('${line['difference_qty']}') ?? 0; final lot = line['lot_code']?.toString() ?? ''; final lotDate = line['lot_date_of_due']?.toString() ?? ''; final pendingLot = line['pending_lot'] == true; final Color borderColor; final Color bgColor; if (pendingLot) { borderColor = AppTokens.danger.withValues(alpha: 0.4); bgColor = AppTokens.danger.withValues(alpha: 0.04); } else if (diff.abs() > 0.0001) { borderColor = AppTokens.warning.withValues(alpha: 0.4); bgColor = AppTokens.warning.withValues(alpha: 0.03); } else { borderColor = Colors.grey.shade200; bgColor = Colors.transparent; } final diffColor = pendingLot || diff < -0.0001 ? AppTokens.danger : diff > 0.0001 ? AppTokens.success : AppTokens.secondary; final diffLabel = pendingLot ? 'Lote pendiente' : diff == 0 ? 'OK' : (diff > 0 ? '+${diff.toStringAsFixed(2)}' : diff.toStringAsFixed(2)); return Container( margin: const EdgeInsets.only(bottom: 8), decoration: BoxDecoration( color: bgColor, borderRadius: BorderRadius.circular(12), border: Border.all(color: borderColor), ), child: InkWell( borderRadius: BorderRadius.circular(12), onTap: editable ? onTap : null, child: Padding( padding: const EdgeInsets.fromLTRB(12, 10, 8, 10), child: Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( line['description']?.toString() ?? 'Producto', style: const TextStyle( fontWeight: FontWeight.w600, fontSize: 13), ), const SizedBox(height: 3), Wrap( spacing: 6, children: [ if ((line['internal_id']?.toString() ?? '').isNotEmpty) _Tag('Cod: ${line['internal_id']}'), _Tag('${line['presentation'] ?? 'Unidad'} x${line['presentation_factor'] ?? 1}'), if (lot.isNotEmpty) _Tag('Lote: $lot'), if (lotDate.isNotEmpty) _Tag('Vence: $lotDate'), ], ), const SizedBox(height: 3), Row( children: [ Text( 'Sistema: ${line['system_qty_snapshot'] ?? 0}', style: TextStyle( fontSize: 11, color: Colors.grey.shade500), ), const SizedBox(width: 8), Text( 'Contado ${line['scanned_times'] ?? 0}x', style: TextStyle( fontSize: 11, color: Colors.grey.shade500), ), ], ), ], ), ), const SizedBox(width: 8), Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( '${line['counted_qty_base'] ?? 0}', style: const TextStyle( fontSize: 18, fontWeight: FontWeight.w800), ), Container( padding: const EdgeInsets.symmetric( horizontal: 7, vertical: 2), decoration: BoxDecoration( color: diffColor.withValues(alpha: 0.10), borderRadius: BorderRadius.circular(10), ), child: Text( diffLabel, style: TextStyle( fontSize: 10, fontWeight: FontWeight.w700, color: diffColor), ), ), if (editable) ...[ const SizedBox(height: 4), GestureDetector( onTap: onDelete, child: Icon(Icons.delete_outline_rounded, size: 18, color: Colors.grey.shade400), ), ], ], ), ], ), ), ), ); } } class _Tag extends StatelessWidget { const _Tag(this.text); final String text; @override Widget build(BuildContext context) { return Text( text, style: TextStyle(fontSize: 11, color: Colors.grey.shade500), ); } }