Files
lab-app/lib/features/shared/reports_screen.dart
T
Emre Emir 8bbc9dbff2 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
2026-06-11 15:57:31 +03:00

691 lines
26 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 '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(),
),
);
}
}