Initial commit: DLS - Dental Lab System
- Flutter + PocketBase dental lab management system - Clinic & lab dashboards, job tracking, patient management - Product catalog, finance tracking, multi-language support - AI assistant integration, realtime notifications - Windows installer (Inno Setup) included - Developed by kovakyazilim.com
This commit is contained in:
@@ -0,0 +1,534 @@
|
||||
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 'clinic_finance_repository.dart';
|
||||
|
||||
enum _FinanceSort { newestFirst, byAmountDesc, byAmountAsc }
|
||||
|
||||
class ClinicFinanceScreen extends ConsumerStatefulWidget {
|
||||
const ClinicFinanceScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ClinicFinanceScreen> createState() =>
|
||||
_ClinicFinanceScreenState();
|
||||
}
|
||||
|
||||
class _ClinicFinanceScreenState extends ConsumerState<ClinicFinanceScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
late Future<Map<String, double>> _summaryFuture;
|
||||
_FinanceSort _sort = _FinanceSort.newestFirst;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 2, vsync: this);
|
||||
_tabController.addListener(() {
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
_loadSummary();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _loadSummary() {
|
||||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||||
setState(() {
|
||||
_summaryFuture =
|
||||
ClinicFinanceRepository.instance.summary(tenantId);
|
||||
});
|
||||
}
|
||||
|
||||
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]);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isSortActive = _sort != _FinanceSort.newestFirst;
|
||||
final s = ref.watch(stringsProvider);
|
||||
final currencyCode =
|
||||
ref.watch(authProvider).activeTenant?.tenant.defaultCurrency ?? 'TRY';
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
appBar: GradientAppBar(
|
||||
title: s.finance,
|
||||
category: s.clinicCategory,
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: _showSortOptions,
|
||||
tooltip: 'Sırala',
|
||||
icon: Badge(
|
||||
isLabelVisible: isSortActive,
|
||||
smallSize: 8,
|
||||
backgroundColor: AppColors.accent,
|
||||
child: const Icon(Icons.sort_rounded),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
FutureBuilder<Map<String, double>>(
|
||||
future: _summaryFuture,
|
||||
builder: (ctx, snap) {
|
||||
if (snap.connectionState == ConnectionState.waiting) {
|
||||
return const LinearProgressIndicator(
|
||||
color: AppColors.accent);
|
||||
}
|
||||
final summary = snap.data ?? {'pending': 0.0, 'paid': 0.0};
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _SummaryCard(
|
||||
label: s.pendingReceivable,
|
||||
amount: summary['pending'] ?? 0.0,
|
||||
currencyCode: currencyCode,
|
||||
color: AppColors.pending,
|
||||
bgColor: AppColors.pendingBg,
|
||||
icon: Icons.hourglass_empty_rounded,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _SummaryCard(
|
||||
label: s.collected,
|
||||
amount: summary['paid'] ?? 0.0,
|
||||
currencyCode: currencyCode,
|
||||
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),
|
||||
),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
_FinanceTab(
|
||||
status: 'pending',
|
||||
sort: _sort,
|
||||
onPaymentMade: _loadSummary,
|
||||
currencyCode: currencyCode,
|
||||
),
|
||||
_FinanceTab(
|
||||
status: 'paid',
|
||||
sort: _sort,
|
||||
onPaymentMade: _loadSummary,
|
||||
currencyCode: currencyCode,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SummaryCard extends StatelessWidget {
|
||||
const _SummaryCard({
|
||||
required this.label,
|
||||
required this.amount,
|
||||
required this.currencyCode,
|
||||
required this.color,
|
||||
required this.bgColor,
|
||||
required this.icon,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final double amount;
|
||||
final String currencyCode;
|
||||
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(
|
||||
CurrencyFormatter.format(amount, currencyCode),
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
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 _FinanceTab extends ConsumerStatefulWidget {
|
||||
const _FinanceTab({
|
||||
required this.status,
|
||||
required this.sort,
|
||||
required this.onPaymentMade,
|
||||
required this.currencyCode,
|
||||
});
|
||||
|
||||
final String status;
|
||||
final _FinanceSort sort;
|
||||
final VoidCallback onPaymentMade;
|
||||
final String currencyCode;
|
||||
|
||||
@override
|
||||
ConsumerState<_FinanceTab> createState() => _FinanceTabState();
|
||||
}
|
||||
|
||||
class _FinanceTabState extends ConsumerState<_FinanceTab> {
|
||||
late Future<List<FinanceEntry>> _future;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
void _load() {
|
||||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||||
setState(() {
|
||||
_future = ClinicFinanceRepository.instance.listEntries(
|
||||
tenantId,
|
||||
status: widget.status,
|
||||
limit: 100,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
List<FinanceEntry> _sorted(List<FinanceEntry> entries) {
|
||||
final list = List<FinanceEntry>.from(entries);
|
||||
switch (widget.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;
|
||||
}
|
||||
|
||||
Future<void> _markPaid(FinanceEntry entry) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Ödeme Onayı'),
|
||||
content: Text(
|
||||
'${entry.counterpartyName ?? "Bu kayıt"} için '
|
||||
'${CurrencyFormatter.format(entry.amount, widget.currencyCode)} tutarındaki borcu '
|
||||
'ödendi olarak işaretlemek istiyor musunuz?',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('İptal'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('Ödendi'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true || !mounted) return;
|
||||
try {
|
||||
await ClinicFinanceRepository.instance.markPaid(entry.id);
|
||||
_load();
|
||||
widget.onPaymentMade();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Ödeme kaydedildi.')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Hata: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return RefreshIndicator(
|
||||
color: AppColors.accent,
|
||||
onRefresh: () async => _load(),
|
||||
child: FutureBuilder<List<FinanceEntry>>(
|
||||
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 entries = _sorted(snap.data!);
|
||||
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: const Icon(Icons.receipt_long_outlined,
|
||||
color: AppColors.inProgress, size: 32),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Kayıt bulunamadı',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
|
||||
itemCount: entries.length,
|
||||
itemBuilder: (context, index) {
|
||||
final entry = entries[index];
|
||||
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: Material(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: InkWell(
|
||||
onTap: isPending ? () => _markPaid(entry) : null,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
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: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
entry.counterpartyName ??
|
||||
'Bilinmiyor',
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
CurrencyFormatter.format(
|
||||
entry.amount, widget.currencyCode),
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: statusColor),
|
||||
),
|
||||
],
|
||||
),
|
||||
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 (isPending) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.pending,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Text(
|
||||
'Öde',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDate(String dateStr) {
|
||||
try {
|
||||
final d = DateTime.parse(dateStr);
|
||||
return '${d.day.toString().padLeft(2, '0')}.${d.month.toString().padLeft(2, '0')}.${d.year}';
|
||||
} catch (_) {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user