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:
Emre Emir
2026-06-11 15:57:31 +03:00
commit 8bbc9dbff2
226 changed files with 31308 additions and 0 deletions
+690
View File
@@ -0,0 +1,690 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import '../../core/providers/auth_provider.dart';
import '../../core/theme/app_theme.dart';
import '../../core/widgets/gradient_app_bar.dart';
import '../../models/job.dart';
import '../../models/tenant.dart';
import 'reports_repository.dart';
class ReportsScreen extends ConsumerStatefulWidget {
const ReportsScreen({super.key});
@override
ConsumerState<ReportsScreen> createState() => _ReportsScreenState();
}
class _ReportsScreenState extends ConsumerState<ReportsScreen>
with SingleTickerProviderStateMixin {
late TabController _tabController;
late Future<ReportMetrics> _future;
@override
void initState() {
super.initState();
_tabController = TabController(length: 4, vsync: this);
_tabController.addListener(() { if (mounted) setState(() {}); });
_load();
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
void _load() {
final auth = ref.read(authProvider);
final tenantId = auth.activeTenant!.tenant.id;
final kind = auth.activeTenant!.tenant.kind;
setState(() {
_future = ReportsRepository.instance.load(tenantId, kind);
});
}
@override
Widget build(BuildContext context) {
final kind = ref.watch(authProvider).activeTenant?.tenant.kind ?? TenantKind.lab;
final counterpartLabel = kind == TenantKind.lab ? 'Klinikler' : 'Laboratuvarlar';
return Scaffold(
backgroundColor: AppColors.background,
appBar: GradientAppBar(
title: 'Raporlar',
category: 'YÖNETİCİ',
actions: [
IconButton(
icon: const Icon(Icons.refresh_rounded),
onPressed: _load,
tooltip: 'Yenile',
),
],
),
body: FutureBuilder<ReportMetrics>(
future: _future,
builder: (context, 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: [
const Icon(Icons.error_outline_rounded, color: AppColors.cancelled, size: 48),
const SizedBox(height: 12),
Text('Veriler yüklenemedi', 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 m = snap.data!;
return Column(
children: [
// Tab bar
Container(
color: AppColors.surface,
child: TabBar(
controller: _tabController,
labelColor: AppColors.accent,
unselectedLabelColor: AppColors.textSecondary,
indicatorColor: AppColors.accent,
indicatorSize: TabBarIndicatorSize.tab,
labelStyle: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
tabs: [
const Tab(text: 'Özet'),
const Tab(text: 'Finans'),
const Tab(text: 'Aktivite'),
Tab(text: counterpartLabel),
],
),
),
Expanded(
child: TabBarView(
controller: _tabController,
children: [
_SummaryTab(metrics: m),
_FinanceTab(metrics: m),
_ActivityTab(metrics: m),
_CounterpartTab(metrics: m, label: counterpartLabel),
],
),
),
],
);
},
),
);
}
}
// ── Shared layout helpers ─────────────────────────────────────────────────────
class _TabBody extends StatelessWidget {
const _TabBody({required this.children});
final List<Widget> children;
@override
Widget build(BuildContext context) => ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
children: children,
);
}
class _Card extends StatelessWidget {
const _Card({required this.child, this.padding = const EdgeInsets.all(16)});
final Widget child;
final EdgeInsets padding;
@override
Widget build(BuildContext context) => Container(
margin: const EdgeInsets.only(bottom: 12),
padding: padding,
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.border),
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.03), blurRadius: 8, offset: const Offset(0, 2))],
),
child: child,
);
}
class _SectionHeader extends StatelessWidget {
const _SectionHeader(this.title, {this.subtitle});
final String title;
final String? subtitle;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.only(bottom: 10, top: 4),
child: Row(
children: [
Expanded(
child: Text(title, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700, color: AppColors.textPrimary)),
),
if (subtitle != null)
Text(subtitle!, style: const TextStyle(fontSize: 12, color: AppColors.textMuted)),
],
),
);
}
// ── KPI Chips ─────────────────────────────────────────────────────────────────
class _KpiRow extends StatelessWidget {
const _KpiRow({required this.metrics});
final ReportMetrics metrics;
@override
Widget build(BuildContext context) {
final fmt = NumberFormat.currency(locale: 'tr_TR', symbol: metrics.currency, decimalDigits: 0);
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
_Kpi(label: 'Aktif İşler', value: '${metrics.activeJobs}', icon: Icons.work_outline_rounded, color: AppColors.inProgress),
const SizedBox(width: 10),
_Kpi(label: 'Bu Ay Tamamlandı', value: '${metrics.completedThisMonth}', icon: Icons.check_circle_outline_rounded, color: AppColors.success),
const SizedBox(width: 10),
_Kpi(label: 'Bekleyen Gelir', value: fmt.format(metrics.pendingRevenue), icon: Icons.hourglass_empty_rounded, color: AppColors.pending),
const SizedBox(width: 10),
_Kpi(label: 'Ort. Süre', value: '${metrics.avgCompletionDays.toStringAsFixed(1)} gün', icon: Icons.timer_outlined, color: AppColors.accent),
const SizedBox(width: 10),
_Kpi(label: 'Revizyon Oranı', value: '%${metrics.revisionRate.toStringAsFixed(0)}', icon: Icons.loop_rounded, color: metrics.revisionRate > 20 ? AppColors.cancelled : AppColors.textSecondary),
if (metrics.overdueJobs > 0) ...[
const SizedBox(width: 10),
_Kpi(label: 'Gecikmiş', value: '${metrics.overdueJobs}', icon: Icons.schedule_rounded, color: AppColors.cancelled),
],
],
),
);
}
}
class _Kpi extends StatelessWidget {
const _Kpi({required this.label, required this.value, required this.icon, required this.color});
final String label, value;
final IconData icon;
final Color color;
@override
Widget build(BuildContext context) => Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.border),
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.03), blurRadius: 6)],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: color),
const SizedBox(width: 4),
Text(label, style: const TextStyle(fontSize: 11, color: AppColors.textMuted)),
],
),
const SizedBox(height: 6),
Text(value, style: TextStyle(fontSize: 20, fontWeight: FontWeight.w800, color: color)),
],
),
);
}
// ── Özet Tab ──────────────────────────────────────────────────────────────────
class _SummaryTab extends StatelessWidget {
const _SummaryTab({required this.metrics});
final ReportMetrics metrics;
@override
Widget build(BuildContext context) {
return _TabBody(children: [
_KpiRow(metrics: metrics),
const _SectionHeader('İş Durumu Dağılımı'),
_Card(
child: Column(
children: _statusOrder.where((s) => metrics.jobsByStatus.containsKey(s)).map((s) {
final count = metrics.jobsByStatus[s] ?? 0;
final total = metrics.jobsByStatus.values.fold(0, (a, b) => a + b);
return _HBarRow(
label: _statusLabel(s),
value: count,
max: total,
color: _statusColor(s),
);
}).toList(),
),
),
const _SectionHeader('Son 6 Aylık İş Trendi'),
_Card(child: _VBarChart(data: metrics.monthlyCounts, color: AppColors.accent)),
]);
}
static const _statusOrder = ['in_progress', 'pending', 'sent', 'delivered', 'cancelled'];
static String _statusLabel(String s) => switch (s) {
'pending' => 'Bekliyor',
'in_progress' => 'İşlemde',
'sent' => 'Gönderildi',
'delivered' => 'Teslim',
'cancelled' => 'İptal',
_ => s,
};
static Color _statusColor(String s) => switch (s) {
'pending' => AppColors.pending,
'in_progress' => AppColors.inProgress,
'sent' => AppColors.accent,
'delivered' => AppColors.success,
'cancelled' => AppColors.cancelled,
_ => AppColors.textMuted,
};
}
// ── Finans Tab ────────────────────────────────────────────────────────────────
class _FinanceTab extends StatelessWidget {
const _FinanceTab({required this.metrics});
final ReportMetrics metrics;
@override
Widget build(BuildContext context) {
final fmt = NumberFormat.currency(locale: 'tr_TR', symbol: metrics.currency, decimalDigits: 0);
final total = metrics.totalRevenue + metrics.pendingRevenue;
return _TabBody(children: [
const _SectionHeader('Gelir Özeti'),
Row(
children: [
Expanded(
child: _Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Container(width: 8, height: 8, decoration: BoxDecoration(color: AppColors.success, shape: BoxShape.circle)),
const SizedBox(width: 6),
const Text('Tahsil Edildi', style: TextStyle(fontSize: 12, color: AppColors.textMuted)),
]),
const SizedBox(height: 6),
Text(fmt.format(metrics.totalRevenue),
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w800, color: AppColors.success)),
],
),
),
),
const SizedBox(width: 10),
Expanded(
child: _Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Container(width: 8, height: 8, decoration: BoxDecoration(color: AppColors.pending, shape: BoxShape.circle)),
const SizedBox(width: 6),
const Text('Bekleyen', style: TextStyle(fontSize: 12, color: AppColors.textMuted)),
]),
const SizedBox(height: 6),
Text(fmt.format(metrics.pendingRevenue),
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w800, color: AppColors.pending)),
],
),
),
),
],
),
if (total > 0) _Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Tahsilat Oranı', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
const SizedBox(height: 10),
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: LinearProgressIndicator(
value: total > 0 ? metrics.totalRevenue / total : 0,
minHeight: 12,
backgroundColor: AppColors.pendingBg,
valueColor: const AlwaysStoppedAnimation<Color>(AppColors.success),
),
),
const SizedBox(height: 6),
Text('${(metrics.totalRevenue / total * 100).toStringAsFixed(0)}% tahsil edildi',
style: const TextStyle(fontSize: 12, color: AppColors.textMuted)),
],
),
),
const SizedBox(height: 4),
const _SectionHeader('Aylık Gelir Trendi'),
_Card(child: _VBarChart(
data: metrics.monthlyRevenue.map((m) => MonthlyCount(year: m.year, month: m.month, count: m.amount.round())).toList(),
color: AppColors.success,
formatValue: (v) => NumberFormat.compactCurrency(locale: 'tr_TR', symbol: metrics.currency, decimalDigits: 0).format(v),
)),
]);
}
}
// ── Aktivite Tab ──────────────────────────────────────────────────────────────
class _ActivityTab extends StatelessWidget {
const _ActivityTab({required this.metrics});
final ReportMetrics metrics;
@override
Widget build(BuildContext context) {
final items = metrics.recentActivity;
if (items.isEmpty) {
return const Center(
child: Text('Henüz aktivite kaydı yok.', style: TextStyle(color: AppColors.textMuted)),
);
}
return _TabBody(children: [
const _SectionHeader('Son İşlemler'),
_Card(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Column(
children: items.asMap().entries.map((entry) {
final i = entry.key;
final item = entry.value;
return _ActivityRow(item: item, isLast: i == items.length - 1);
}).toList(),
),
),
]);
}
}
class _ActivityRow extends StatelessWidget {
const _ActivityRow({required this.item, required this.isLast});
final ActivityItem item;
final bool isLast;
@override
Widget build(BuildContext context) {
final color = item.isNegative ? AppColors.cancelled : item.isPositive ? AppColors.success : AppColors.accent;
final icon = switch (item.action) {
'accepted' => Icons.check_circle_outline_rounded,
'handed_to_clinic' => Icons.send_rounded,
'approved' => Icons.thumb_up_outlined,
'revision_requested' => Icons.loop_rounded,
'delivered' => Icons.local_shipping_outlined,
'cancelled' => Icons.cancel_outlined,
_ => Icons.history_rounded,
};
final df = DateFormat('dd.MM.yy HH:mm');
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Timeline line + dot
SizedBox(
width: 28,
child: Column(
children: [
Container(
width: 24, height: 24,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
shape: BoxShape.circle,
),
child: Icon(icon, size: 13, color: color),
),
if (!isLast)
Expanded(child: Container(width: 1.5, color: AppColors.border)),
],
),
),
const SizedBox(width: 10),
Expanded(
child: Padding(
padding: EdgeInsets.only(bottom: isLast ? 0 : 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(item.actionLabel,
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
if (item.patientCode != null && item.patientCode!.isNotEmpty)
Text(item.patientCode!, style: const TextStyle(fontSize: 11, color: AppColors.accent)),
if (item.note != null && item.note!.isNotEmpty)
Text(item.note!, style: const TextStyle(fontSize: 11, color: AppColors.textMuted)),
const SizedBox(height: 2),
Text(df.format(item.createdAt), style: const TextStyle(fontSize: 10, color: AppColors.textMuted)),
],
),
),
),
],
),
);
}
}
// ── Counterpart Tab ───────────────────────────────────────────────────────────
class _CounterpartTab extends StatelessWidget {
const _CounterpartTab({required this.metrics, required this.label});
final ReportMetrics metrics;
final String label;
@override
Widget build(BuildContext context) {
final stats = metrics.counterpartStats;
if (stats.isEmpty) {
return Center(
child: Text('$label henüz yok.', style: const TextStyle(color: AppColors.textMuted)),
);
}
final fmt = NumberFormat.currency(locale: 'tr_TR', symbol: metrics.currency, decimalDigits: 0);
final maxJobs = stats.fold(0, (m, s) => s.jobCount > m ? s.jobCount : m);
return _TabBody(children: [
const _SectionHeader('Protez Türü Dağılımı'),
_Card(
child: Column(
children: _buildTypeRows(metrics.byProstheticType),
),
),
_SectionHeader('En Aktif $label'),
_Card(
child: Column(
children: stats.asMap().entries.map((entry) {
final i = entry.key;
final s = entry.value;
return Padding(
padding: EdgeInsets.only(bottom: i < stats.length - 1 ? 12 : 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 22, height: 22,
decoration: BoxDecoration(
color: AppColors.inProgressBg,
shape: BoxShape.circle,
),
child: Center(
child: Text('${i + 1}',
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: AppColors.inProgress)),
),
),
const SizedBox(width: 8),
Expanded(
child: Text(s.name,
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: AppColors.textPrimary),
maxLines: 1, overflow: TextOverflow.ellipsis),
),
Text('${s.jobCount}',
style: const TextStyle(fontSize: 12, color: AppColors.textSecondary, fontWeight: FontWeight.w500)),
],
),
const SizedBox(height: 6),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: maxJobs > 0 ? s.jobCount / maxJobs : 0,
minHeight: 6,
backgroundColor: AppColors.surfaceVariant,
valueColor: const AlwaysStoppedAnimation<Color>(AppColors.accent),
),
),
if (s.totalRevenue > 0) Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
'${fmt.format(s.paidRevenue)} tahsil · ${fmt.format(s.pendingRevenue)} bekliyor',
style: const TextStyle(fontSize: 11, color: AppColors.textMuted),
),
),
],
),
);
}).toList(),
),
),
]);
}
List<Widget> _buildTypeRows(Map<String, int> byType) {
if (byType.isEmpty) return [const Text('Veri yok', style: TextStyle(color: AppColors.textMuted))];
final sorted = byType.entries.toList()..sort((a, b) => b.value.compareTo(a.value));
final max = sorted.first.value;
return sorted.map((e) => _HBarRow(
label: _typeLabel(e.key),
value: e.value,
max: max,
color: AppColors.accent,
)).toList();
}
static String _typeLabel(String s) => switch (s) {
'metal_porselen' => 'Metal Porselen',
'zirkonyum' => 'Zirkonyum',
'implant_ustu_zirkonyum'=> 'İmplant Üstü',
'gecici' => 'Geçici',
'e_max' => 'E-Max',
'tam_protez' => 'Tam Protez',
'parsiyel' => 'Parsiyel',
_ => 'Diğer',
};
}
// ── Chart widgets ─────────────────────────────────────────────────────────────
class _HBarRow extends StatelessWidget {
const _HBarRow({required this.label, required this.value, required this.max, required this.color});
final String label;
final int value, max;
final Color color;
@override
Widget build(BuildContext context) {
final fraction = max > 0 ? value / max : 0.0;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 5),
child: Row(
children: [
SizedBox(
width: 100,
child: Text(label,
style: const TextStyle(fontSize: 12, color: AppColors.textSecondary),
maxLines: 1, overflow: TextOverflow.ellipsis),
),
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Stack(
children: [
Container(height: 22, color: AppColors.surfaceVariant),
FractionallySizedBox(
widthFactor: fraction,
child: Container(
height: 22,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.8),
borderRadius: BorderRadius.circular(4),
),
),
),
],
),
),
),
const SizedBox(width: 8),
SizedBox(
width: 28,
child: Text('$value',
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w700, color: AppColors.textPrimary),
textAlign: TextAlign.right),
),
],
),
);
}
}
class _VBarChart extends StatelessWidget {
const _VBarChart({required this.data, required this.color, this.formatValue});
final List<MonthlyCount> data;
final Color color;
final String Function(int)? formatValue;
@override
Widget build(BuildContext context) {
if (data.isEmpty) return const SizedBox.shrink();
final maxVal = data.fold(0, (m, e) => e.count > m ? e.count : m);
final fmt = formatValue ?? (v) => '$v';
return SizedBox(
height: 140,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: data.map((d) {
final fraction = maxVal > 0 ? d.count / maxVal : 0.0;
return Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (d.count > 0)
Text(fmt(d.count),
style: const TextStyle(fontSize: 9, color: AppColors.textMuted),
textAlign: TextAlign.center),
const SizedBox(height: 2),
AnimatedContainer(
duration: const Duration(milliseconds: 600),
curve: Curves.easeOut,
height: (fraction * 90).clamp(2, 90),
decoration: BoxDecoration(
color: color,
borderRadius: const BorderRadius.vertical(top: Radius.circular(4)),
),
),
const SizedBox(height: 4),
Text(d.label,
style: const TextStyle(fontSize: 10, color: AppColors.textMuted),
textAlign: TextAlign.center),
],
),
),
);
}).toList(),
),
);
}
}