544 lines
18 KiB
Dart
544 lines
18 KiB
Dart
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),
|
||
LabFinanceRepository.instance.byCounterparty(tenantId),
|
||
]).then((results) => _FinanceData(
|
||
pending: results[0] as List<FinanceEntry>,
|
||
paid: results[1] as List<FinanceEntry>,
|
||
summary: results[2] as Map<String, double>,
|
||
counterparties: results[3] as List<CounterpartyFinanceSummary>,
|
||
));
|
||
});
|
||
}
|
||
|
||
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,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
if (data.counterparties.isNotEmpty)
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||
child: _CounterpartySummaryList(
|
||
title: 'Klinik Bazlı Alacak',
|
||
items: data.counterparties,
|
||
formatAmount: formatAmount,
|
||
),
|
||
),
|
||
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,
|
||
required this.counterparties,
|
||
});
|
||
|
||
final List<FinanceEntry> pending;
|
||
final List<FinanceEntry> paid;
|
||
final Map<String, double> summary;
|
||
final List<CounterpartyFinanceSummary> counterparties;
|
||
}
|
||
|
||
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,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
class _CounterpartySummaryList extends StatelessWidget {
|
||
const _CounterpartySummaryList({
|
||
required this.title,
|
||
required this.items,
|
||
required this.formatAmount,
|
||
});
|
||
|
||
final String title;
|
||
final List<CounterpartyFinanceSummary> items;
|
||
final String Function(double) formatAmount;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Container(
|
||
padding: const EdgeInsets.all(14),
|
||
decoration: BoxDecoration(
|
||
color: AppColors.surface,
|
||
borderRadius: BorderRadius.circular(16),
|
||
border: Border.all(color: AppColors.border),
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
title,
|
||
style: const TextStyle(
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.w700,
|
||
color: AppColors.textPrimary,
|
||
),
|
||
),
|
||
const SizedBox(height: 10),
|
||
for (final item in items.take(5)) ...[
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: Text(
|
||
item.counterpartyName,
|
||
style: const TextStyle(
|
||
fontSize: 13,
|
||
color: AppColors.textPrimary,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
),
|
||
Text(
|
||
formatAmount(item.pendingAmount),
|
||
style: const TextStyle(
|
||
fontSize: 13,
|
||
color: AppColors.pending,
|
||
fontWeight: FontWeight.w700,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 8),
|
||
],
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|