Files
lab-app/lib/features/lab/finance/lab_finance_screen.dart
T
2026-06-10 23:22:15 +03:00

468 lines
15 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/providers/auth_provider.dart';
import '../../../core/providers/locale_provider.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/utils/currency_formatter.dart';
import '../../../core/widgets/gradient_app_bar.dart';
import '../../../core/widgets/pill_tabs.dart';
import '../../../models/finance_entry.dart';
import 'lab_finance_repository.dart';
enum _FinanceSort { newestFirst, byAmountDesc, byAmountAsc }
class LabFinanceScreen extends ConsumerStatefulWidget {
const LabFinanceScreen({super.key});
@override
ConsumerState<LabFinanceScreen> createState() => _LabFinanceScreenState();
}
class _LabFinanceScreenState extends ConsumerState<LabFinanceScreen>
with SingleTickerProviderStateMixin {
late TabController _tabController;
late Future<_FinanceData> _future;
_FinanceSort _sort = _FinanceSort.newestFirst;
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
_tabController.addListener(() {
if (mounted) setState(() {});
});
_load();
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
void _load() {
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
setState(() {
_future = Future.wait([
LabFinanceRepository.instance.listEntries(tenantId, status: 'pending'),
LabFinanceRepository.instance.listEntries(tenantId, status: 'paid'),
LabFinanceRepository.instance.summary(tenantId),
]).then((results) => _FinanceData(
pending: results[0] as List<FinanceEntry>,
paid: results[1] as List<FinanceEntry>,
summary: results[2] as Map<String, double>,
));
});
}
Future<void> _showSortOptions() async {
final s = ref.read(stringsProvider);
final result = await showSortSheet(
context,
title: s.sort,
options: [s.sortNewest, s.sortAmountDesc, s.sortAmountAsc],
current: _sort.index,
);
if (result != null) {
setState(() => _sort = _FinanceSort.values[result]);
}
}
List<FinanceEntry> _sorted(List<FinanceEntry> entries) {
final list = List<FinanceEntry>.from(entries);
switch (_sort) {
case _FinanceSort.newestFirst:
list.sort((a, b) {
final da = a.dateCreated != null ? DateTime.tryParse(a.dateCreated!) : null;
final db = b.dateCreated != null ? DateTime.tryParse(b.dateCreated!) : null;
if (da == null && db == null) return 0;
if (da == null) return 1;
if (db == null) return -1;
return db.compareTo(da);
});
case _FinanceSort.byAmountDesc:
list.sort((a, b) => b.amount.compareTo(a.amount));
case _FinanceSort.byAmountAsc:
list.sort((a, b) => a.amount.compareTo(b.amount));
}
return list;
}
String _formatDate(String? raw) {
if (raw == null) return '';
try {
final dt = DateTime.parse(raw);
return '${dt.day.toString().padLeft(2, '0')}.${dt.month.toString().padLeft(2, '0')}.${dt.year}';
} catch (_) {
return '';
}
}
@override
Widget build(BuildContext context) {
final isSortActive = _sort != _FinanceSort.newestFirst;
final s = ref.watch(stringsProvider);
final currencyCode =
ref.watch(authProvider).activeTenant?.tenant.defaultCurrency ?? 'TRY';
String formatAmount(double amount) =>
CurrencyFormatter.format(amount, currencyCode);
return Scaffold(
backgroundColor: AppColors.background,
appBar: GradientAppBar(
title: s.finance,
category: s.laboratoryCategory,
actions: [
IconButton(
onPressed: _showSortOptions,
tooltip: 'Sırala',
icon: Badge(
isLabelVisible: isSortActive,
smallSize: 8,
backgroundColor: AppColors.accent,
child: const Icon(Icons.sort_rounded),
),
),
],
),
body: RefreshIndicator(
color: AppColors.accent,
onRefresh: () async => _load(),
child: FutureBuilder<_FinanceData>(
future: _future,
builder: (ctx, snap) {
if (snap.connectionState == ConnectionState.waiting) {
return const Center(
child: CircularProgressIndicator(color: AppColors.accent));
}
if (snap.hasError) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: AppColors.cancelledBg,
borderRadius: BorderRadius.circular(16)),
child: const Icon(Icons.wifi_off_rounded,
color: AppColors.cancelled, size: 30),
),
const SizedBox(height: 16),
Text('Hata: ${snap.error}',
style: const TextStyle(
color: AppColors.textSecondary)),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: _load,
icon: const Icon(Icons.refresh_rounded, size: 18),
label: const Text('Tekrar Dene'),
),
],
),
);
}
final data = snap.data!;
final pendingTotal = data.summary['pending'] ?? 0.0;
final paidTotal = data.summary['paid'] ?? 0.0;
final pending = _sorted(data.pending);
final paid = _sorted(data.paid);
return Column(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: _SummaryCard(
label: s.pendingReceivable,
amount: formatAmount(pendingTotal),
color: AppColors.pending,
bgColor: AppColors.pendingBg,
icon: Icons.hourglass_empty_rounded,
),
),
const SizedBox(width: 12),
Expanded(
child: _SummaryCard(
label: s.collected,
amount: formatAmount(paidTotal),
color: AppColors.success,
bgColor: AppColors.successBg,
icon: Icons.check_circle_outline,
),
),
],
),
),
PillTabs(
tabs: [s.pending, s.collected],
selected: _tabController.index,
onSelect: (i) => _tabController.animateTo(i),
counts: [pending.length, paid.length],
),
Expanded(
child: TabBarView(
controller: _tabController,
children: [
_EntriesList(
entries: pending,
emptyMessage: s.noPendingEntries,
emptyIcon: Icons.hourglass_empty_rounded,
formatDate: _formatDate,
formatAmount: formatAmount,
),
_EntriesList(
entries: paid,
emptyMessage: s.noPaidEntries,
emptyIcon: Icons.check_circle_outline,
formatDate: _formatDate,
formatAmount: formatAmount,
),
],
),
),
],
);
},
),
),
);
}
}
class _FinanceData {
const _FinanceData({
required this.pending,
required this.paid,
required this.summary,
});
final List<FinanceEntry> pending;
final List<FinanceEntry> paid;
final Map<String, double> summary;
}
class _SummaryCard extends StatelessWidget {
const _SummaryCard({
required this.label,
required this.amount,
required this.color,
required this.bgColor,
required this.icon,
});
final String label;
final String amount;
final Color color;
final Color bgColor;
final IconData icon;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.border),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.06),
blurRadius: 12,
offset: const Offset(0, 4))
],
),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: bgColor, borderRadius: BorderRadius.circular(12)),
child: Icon(icon, color: color, size: 22),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
amount,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w800,
color: color,
height: 1),
),
const SizedBox(height: 3),
Text(label,
style: const TextStyle(
fontSize: 12,
color: AppColors.textSecondary,
fontWeight: FontWeight.w500)),
],
),
),
],
),
);
}
}
class _EntriesList extends StatelessWidget {
const _EntriesList({
required this.entries,
required this.emptyMessage,
required this.emptyIcon,
required this.formatDate,
required this.formatAmount,
});
final List<FinanceEntry> entries;
final String emptyMessage;
final IconData emptyIcon;
final String Function(String?) formatDate;
final String Function(double) formatAmount;
@override
Widget build(BuildContext context) {
if (entries.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 72,
height: 72,
decoration: BoxDecoration(
color: AppColors.inProgressBg,
borderRadius: BorderRadius.circular(20)),
child: Icon(emptyIcon, size: 32, color: AppColors.inProgress),
),
const SizedBox(height: 16),
Text(emptyMessage,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary)),
],
),
);
}
return ListView.builder(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
itemCount: entries.length,
itemBuilder: (ctx, i) {
final entry = entries[i];
final isPending = entry.status == FinanceStatus.pending;
final statusColor = isPending ? AppColors.pending : AppColors.success;
final statusBg = isPending ? AppColors.pendingBg : AppColors.successBg;
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.border),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.04),
blurRadius: 8,
offset: const Offset(0, 2))
],
),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: statusBg,
borderRadius: BorderRadius.circular(12)),
child: Icon(
isPending
? Icons.hourglass_empty_rounded
: Icons.check_circle_outline,
color: statusColor,
size: 22,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
entry.counterpartyName ?? 'Klinik',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary),
),
if (entry.patientCode != null) ...[
const SizedBox(height: 2),
Text(
'Protokol: ${entry.patientCode}',
style: const TextStyle(
fontSize: 12, color: AppColors.textSecondary),
),
],
if (entry.dateCreated != null) ...[
const SizedBox(height: 2),
Text(
formatDate(entry.dateCreated),
style: const TextStyle(
fontSize: 12, color: AppColors.textMuted),
),
],
],
),
),
const SizedBox(width: 8),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
formatAmount(entry.amount),
style: TextStyle(
fontWeight: FontWeight.w700,
color: statusColor,
fontSize: 15,
),
),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: statusBg,
borderRadius: BorderRadius.circular(8),
),
child: Text(
entry.status.label,
style: TextStyle(
color: statusColor,
fontSize: 11,
fontWeight: FontWeight.w600,
),
),
),
],
),
],
),
),
);
},
);
}
}