Add pricing entry flow and platform admin foundations
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import 'package:pocketbase/pocketbase.dart';
|
||||
import '../../../core/api/pocketbase_client.dart';
|
||||
import '../../../core/services/finance_service.dart';
|
||||
import '../../../models/finance_entry.dart';
|
||||
|
||||
class LabFinanceRepository {
|
||||
@@ -15,14 +16,18 @@ class LabFinanceRepository {
|
||||
int limit = 30,
|
||||
}) async {
|
||||
final filterParts = ['tenant_id = "$tenantId"', 'type = "receivable"'];
|
||||
if (status != null) filterParts.add('status = "$status"');
|
||||
if (status == FinanceStatus.pending.value) {
|
||||
filterParts.add('(status = "pending" || status = "reported")');
|
||||
} else if (status != null) {
|
||||
filterParts.add('status = "$status"');
|
||||
}
|
||||
|
||||
final result = await _pb.collection('finance_entries').getList(
|
||||
page: page,
|
||||
perPage: limit,
|
||||
filter: filterParts.join(' && '),
|
||||
expand: 'job_id',
|
||||
);
|
||||
page: page,
|
||||
perPage: limit,
|
||||
filter: filterParts.join(' && '),
|
||||
expand: 'job_id',
|
||||
);
|
||||
return (result.items.map((r) => FinanceEntry.fromJson(r.toJson())).toList()
|
||||
..sort((a, b) => (b.dateCreated ?? '').compareTo(a.dateCreated ?? '')));
|
||||
}
|
||||
@@ -31,7 +36,7 @@ class LabFinanceRepository {
|
||||
final all = await listEntries(tenantId, limit: 200);
|
||||
double pending = 0, paid = 0;
|
||||
for (final e in all) {
|
||||
if (e.status == FinanceStatus.pending) {
|
||||
if (e.status.isOpen) {
|
||||
pending += e.amount;
|
||||
} else {
|
||||
paid += e.amount;
|
||||
@@ -40,15 +45,17 @@ class LabFinanceRepository {
|
||||
return {'pending': pending, 'paid': paid};
|
||||
}
|
||||
|
||||
Future<List<CounterpartyFinanceSummary>> byCounterparty(String tenantId) async {
|
||||
Future<List<CounterpartyFinanceSummary>> byCounterparty(
|
||||
String tenantId) async {
|
||||
final entries = await listEntries(tenantId, limit: 300);
|
||||
final map = <String, CounterpartyFinanceSummary>{};
|
||||
|
||||
for (final entry in entries) {
|
||||
final key = entry.counterpartyTenantId ?? entry.counterpartyName ?? 'unknown';
|
||||
final key =
|
||||
entry.counterpartyTenantId ?? entry.counterpartyName ?? 'unknown';
|
||||
final current = map[key];
|
||||
final pending = (current?.pendingAmount ?? 0) +
|
||||
(entry.status == FinanceStatus.pending ? entry.amount : 0);
|
||||
(entry.status.isOpen ? entry.amount : 0);
|
||||
final paid = (current?.paidAmount ?? 0) +
|
||||
(entry.status == FinanceStatus.paid ? entry.amount : 0);
|
||||
map[key] = CounterpartyFinanceSummary(
|
||||
@@ -65,4 +72,17 @@ class LabFinanceRepository {
|
||||
list.sort((a, b) => b.pendingAmount.compareTo(a.pendingAmount));
|
||||
return list;
|
||||
}
|
||||
|
||||
Future<void> confirmPayment(String entryId) async {
|
||||
final record = await _pb.collection('finance_entries').getOne(entryId);
|
||||
final jobId = record.data['job_id']?.toString();
|
||||
if (jobId == null || jobId.isEmpty) {
|
||||
await _pb.collection('finance_entries').update(entryId, body: {
|
||||
'status': 'paid',
|
||||
'paid_at': DateTime.now().toIso8601String(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await FinanceService.instance.confirmJobPayment(jobId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,8 +76,10 @@ class _LabFinanceScreenState extends ConsumerState<LabFinanceScreen>
|
||||
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;
|
||||
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;
|
||||
@@ -101,6 +103,49 @@ class _LabFinanceScreenState extends ConsumerState<LabFinanceScreen>
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _confirmPayment(
|
||||
FinanceEntry entry,
|
||||
String Function(double) formatAmount,
|
||||
) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Ödeme Onayla'),
|
||||
content: Text(
|
||||
'${entry.counterpartyName ?? "Bu kayıt"} için '
|
||||
'${formatAmount(entry.amount)} tutarındaki ödemenin '
|
||||
'hesabınıza ulaştığını onaylıyor musunuz?',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('İptal'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('Onayla'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true || !mounted) return;
|
||||
try {
|
||||
await LabFinanceRepository.instance.confirmPayment(entry.id);
|
||||
_load();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Ödeme onaylandı.')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Hata: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isSortActive = _sort != _FinanceSort.newestFirst;
|
||||
@@ -154,8 +199,7 @@ class _LabFinanceScreenState extends ConsumerState<LabFinanceScreen>
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text('Hata: ${snap.error}',
|
||||
style: const TextStyle(
|
||||
color: AppColors.textSecondary)),
|
||||
style: const TextStyle(color: AppColors.textSecondary)),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.icon(
|
||||
onPressed: _load,
|
||||
@@ -181,7 +225,7 @@ class _LabFinanceScreenState extends ConsumerState<LabFinanceScreen>
|
||||
children: [
|
||||
Expanded(
|
||||
child: _SummaryCard(
|
||||
label: s.pendingReceivable,
|
||||
label: 'Açık Alacak',
|
||||
amount: formatAmount(pendingTotal),
|
||||
color: AppColors.pending,
|
||||
bgColor: AppColors.pendingBg,
|
||||
@@ -191,7 +235,7 @@ class _LabFinanceScreenState extends ConsumerState<LabFinanceScreen>
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _SummaryCard(
|
||||
label: s.collected,
|
||||
label: 'Onaylanan Tahsilat',
|
||||
amount: formatAmount(paidTotal),
|
||||
color: AppColors.success,
|
||||
bgColor: AppColors.successBg,
|
||||
@@ -226,6 +270,8 @@ class _LabFinanceScreenState extends ConsumerState<LabFinanceScreen>
|
||||
emptyIcon: Icons.hourglass_empty_rounded,
|
||||
formatDate: _formatDate,
|
||||
formatAmount: formatAmount,
|
||||
onConfirmPayment: (entry) =>
|
||||
_confirmPayment(entry, formatAmount),
|
||||
),
|
||||
_EntriesList(
|
||||
entries: paid,
|
||||
@@ -334,6 +380,7 @@ class _EntriesList extends StatelessWidget {
|
||||
required this.emptyIcon,
|
||||
required this.formatDate,
|
||||
required this.formatAmount,
|
||||
this.onConfirmPayment,
|
||||
});
|
||||
|
||||
final List<FinanceEntry> entries;
|
||||
@@ -341,6 +388,7 @@ class _EntriesList extends StatelessWidget {
|
||||
final IconData emptyIcon;
|
||||
final String Function(String?) formatDate;
|
||||
final String Function(double) formatAmount;
|
||||
final Future<void> Function(FinanceEntry entry)? onConfirmPayment;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -374,103 +422,139 @@ class _EntriesList extends StatelessWidget {
|
||||
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;
|
||||
final isReported = entry.status == FinanceStatus.reported;
|
||||
final statusColor = isPending
|
||||
? AppColors.pending
|
||||
: isReported
|
||||
? AppColors.accent
|
||||
: AppColors.success;
|
||||
final statusBg = isPending
|
||||
? AppColors.pendingBg
|
||||
: isReported
|
||||
? AppColors.inProgressBg
|
||||
: AppColors.successBg;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
child: Material(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: InkWell(
|
||||
onTap: isReported && onConfirmPayment != null
|
||||
? () => onConfirmPayment!(entry)
|
||||
: null,
|
||||
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,
|
||||
),
|
||||
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))
|
||||
],
|
||||
),
|
||||
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,
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
formatAmount(entry.amount),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: statusBg,
|
||||
borderRadius: BorderRadius.circular(12)),
|
||||
child: Icon(
|
||||
isPending
|
||||
? Icons.hourglass_empty_rounded
|
||||
: isReported
|
||||
? Icons.verified_outlined
|
||||
: Icons.check_circle_outline,
|
||||
color: statusColor,
|
||||
fontSize: 15,
|
||||
size: 22,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: statusBg,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
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),
|
||||
),
|
||||
],
|
||||
if (isReported) ...[
|
||||
const SizedBox(height: 4),
|
||||
const Text(
|
||||
'Dokunarak ödeme onayı verebilirsiniz.',
|
||||
style: TextStyle(
|
||||
fontSize: 12, color: AppColors.textSecondary),
|
||||
),
|
||||
] else if (isPending) ...[
|
||||
const SizedBox(height: 4),
|
||||
const Text(
|
||||
'Klinikten ödeme bildirimi bekleniyor.',
|
||||
style: TextStyle(
|
||||
fontSize: 12, color: AppColors.textSecondary),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
child: Text(
|
||||
entry.status.label,
|
||||
style: TextStyle(
|
||||
color: statusColor,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user