Files
lab-app/lib/features/lab/connections/connection_detail_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

582 lines
21 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 '../../../core/theme/app_theme.dart';
import '../../../models/connection.dart';
import '../../../models/job.dart';
import 'connection_stats_repository.dart';
class ConnectionDetailScreen extends StatefulWidget {
const ConnectionDetailScreen({
super.key,
required this.connection,
required this.labTenantId,
});
final Connection connection;
final String labTenantId;
@override
State<ConnectionDetailScreen> createState() => _ConnectionDetailScreenState();
}
class _ConnectionDetailScreenState extends State<ConnectionDetailScreen> {
late Future<ConnectionStats> _future;
@override
void initState() {
super.initState();
_load();
}
void _load() {
setState(() {
_future = ConnectionStatsRepository.instance.fetchStats(
labTenantId: widget.labTenantId,
clinicTenantId: widget.connection.clinicTenantId,
);
});
}
@override
Widget build(BuildContext context) {
final conn = widget.connection;
final clinicName = conn.clinicName ?? 'Klinik';
return Scaffold(
backgroundColor: AppColors.background,
body: CustomScrollView(
slivers: [
SliverAppBar(
pinned: true,
expandedHeight: 140,
backgroundColor: AppColors.primary,
foregroundColor: Colors.white,
flexibleSpace: FlexibleSpaceBar(
background: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF0F172A), AppColors.primary],
),
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 48, 20, 16),
child: Row(
children: [
Container(
width: 52,
height: 52,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: Colors.white.withValues(alpha: 0.2)),
),
child: const Icon(Icons.local_hospital_outlined, color: Colors.white, size: 26),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
clinicName,
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w700,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 3),
_StatusBadge(status: conn.status),
],
),
),
],
),
),
),
),
),
),
SliverToBoxAdapter(
child: FutureBuilder<ConnectionStats>(
future: _future,
builder: (ctx, snap) {
if (snap.connectionState == ConnectionState.waiting) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 80),
child: Center(child: CircularProgressIndicator(color: AppColors.accent)),
);
}
if (snap.hasError) {
return Padding(
padding: const EdgeInsets.all(32),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.wifi_off_rounded, color: AppColors.cancelled, size: 40),
const SizedBox(height: 12),
Text('Hata: ${snap.error}', style: const TextStyle(color: AppColors.textSecondary), textAlign: TextAlign.center),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: _load,
icon: const Icon(Icons.refresh_rounded, size: 16),
label: const Text('Tekrar Dene'),
),
],
),
),
);
}
final stats = snap.data!;
return _StatsBody(stats: stats);
},
),
),
],
),
);
}
}
// ── Stats body ────────────────────────────────────────────────────────────────
class _StatsBody extends StatelessWidget {
const _StatsBody({required this.stats});
final ConnectionStats stats;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// KPI row
Row(
children: [
Expanded(child: _KpiCard(label: 'Toplam İş', value: '${stats.totalJobs}', icon: Icons.work_outline_rounded, color: AppColors.accent)),
const SizedBox(width: 10),
Expanded(child: _KpiCard(label: 'Aktif', value: '${stats.activeJobs}', icon: Icons.pending_outlined, color: AppColors.inProgress)),
const SizedBox(width: 10),
Expanded(child: _KpiCard(label: 'Teslim', value: '${stats.deliveredJobs}', icon: Icons.check_circle_outline, color: AppColors.success)),
],
),
const SizedBox(height: 10),
Row(
children: [
Expanded(child: _KpiCard(label: 'Bu Ay', value: '${stats.thisMonthJobs}', icon: Icons.calendar_month_outlined, color: AppColors.accent)),
const SizedBox(width: 10),
Expanded(child: _KpiCard(label: 'Geçen Ay', value: '${stats.lastMonthJobs}', icon: Icons.history_rounded, color: AppColors.textSecondary)),
const SizedBox(width: 10),
Expanded(child: _KpiCard(label: 'İptal', value: '${stats.cancelledJobs}', icon: Icons.cancel_outlined, color: AppColors.cancelled)),
],
),
const SizedBox(height: 16),
// Revenue
_SectionTitle('Finans'),
const SizedBox(height: 8),
_RevenueCard(stats: stats),
const SizedBox(height: 16),
// Prosthetic type breakdown
if (stats.byType.isNotEmpty) ...[
_SectionTitle('Ürün Tipi Dağılımı'),
const SizedBox(height: 8),
_TypeBreakdownCard(byType: stats.byType, total: stats.totalJobs),
const SizedBox(height: 16),
],
// Monthly trend
_SectionTitle('Aylık Karşılaştırma'),
const SizedBox(height: 8),
_MonthCompareCard(stats: stats),
const SizedBox(height: 16),
// Recent jobs
if (stats.recentJobs.isNotEmpty) ...[
_SectionTitle('Son İşler'),
const SizedBox(height: 8),
_RecentJobsCard(jobs: stats.recentJobs),
const SizedBox(height: 16),
],
const SizedBox(height: 32),
],
),
);
}
}
// ── KPI card ──────────────────────────────────────────────────────────────────
class _KpiCard extends StatelessWidget {
const _KpiCard({required this.label, required this.value, required this.icon, required this.color});
final String label;
final String value;
final IconData icon;
final Color color;
@override
Widget build(BuildContext context) {
return 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.03), blurRadius: 6, offset: const Offset(0, 2))],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 18, color: color),
const SizedBox(height: 8),
Text(value, style: TextStyle(fontSize: 22, fontWeight: FontWeight.w800, color: color)),
const SizedBox(height: 2),
Text(label, style: const TextStyle(fontSize: 11, color: AppColors.textMuted, fontWeight: FontWeight.w500)),
],
),
);
}
}
// ── Revenue card ──────────────────────────────────────────────────────────────
class _RevenueCard extends StatelessWidget {
const _RevenueCard({required this.stats});
final ConnectionStats stats;
@override
Widget build(BuildContext context) {
final collected = stats.totalRevenue - stats.pendingRevenue;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.border),
),
child: Column(
children: [
_RevRow(
label: 'Toplam Gelir',
value: _fmt(stats.totalRevenue),
color: AppColors.textPrimary,
bold: true,
),
const Divider(height: 20),
_RevRow(label: 'Tahsil Edilen', value: _fmt(collected), color: AppColors.success),
const SizedBox(height: 8),
_RevRow(label: 'Bekleyen Alacak', value: _fmt(stats.pendingRevenue), color: AppColors.pending),
],
),
);
}
String _fmt(double v) => '${v.toStringAsFixed(0)} TL';
}
class _RevRow extends StatelessWidget {
const _RevRow({required this.label, required this.value, required this.color, this.bold = false});
final String label;
final String value;
final Color color;
final bool bold;
@override
Widget build(BuildContext context) {
final style = TextStyle(
fontSize: bold ? 15 : 14,
fontWeight: bold ? FontWeight.w700 : FontWeight.w500,
color: color,
);
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: style.copyWith(color: bold ? AppColors.textPrimary : AppColors.textSecondary)),
Text(value, style: style),
],
);
}
}
// ── Type breakdown ────────────────────────────────────────────────────────────
class _TypeBreakdownCard extends StatelessWidget {
const _TypeBreakdownCard({required this.byType, required this.total});
final Map<String, int> byType;
final int total;
@override
Widget build(BuildContext context) {
final sorted = byType.entries.toList()..sort((a, b) => b.value.compareTo(a.value));
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.border),
),
child: Column(
children: [
for (int i = 0; i < sorted.length; i++) ...[
if (i > 0) const SizedBox(height: 10),
_TypeBar(
label: _typeLabel(sorted[i].key),
count: sorted[i].value,
total: total,
),
],
],
),
);
}
String _typeLabel(String key) => switch (key) {
'metal_porselen' => 'Metal Porselen',
'zirkonyum' => 'Zirkonyum',
'implant_ustu_zirkonyum'=> 'İmplant Üstü Zirkonyum',
'gecici' => 'Geçici',
'e_max' => 'E-Max',
'tam_protez' => 'Tam Protez',
'parsiyel' => 'Parsiyel Protez',
_ => 'Diğer',
};
}
class _TypeBar extends StatelessWidget {
const _TypeBar({required this.label, required this.count, required this.total});
final String label;
final int count;
final int total;
@override
Widget build(BuildContext context) {
final pct = total > 0 ? count / total : 0.0;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: AppColors.textPrimary)),
Text('$count adet · ${(pct * 100).toStringAsFixed(0)}%',
style: const TextStyle(fontSize: 12, color: AppColors.textMuted)),
],
),
const SizedBox(height: 5),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: pct,
minHeight: 7,
backgroundColor: AppColors.border,
valueColor: const AlwaysStoppedAnimation<Color>(AppColors.accent),
),
),
],
);
}
}
// ── Monthly compare ───────────────────────────────────────────────────────────
class _MonthCompareCard extends StatelessWidget {
const _MonthCompareCard({required this.stats});
final ConnectionStats stats;
@override
Widget build(BuildContext context) {
final thisMonth = stats.thisMonthJobs;
final lastMonth = stats.lastMonthJobs;
final maxBar = (thisMonth > lastMonth ? thisMonth : lastMonth).clamp(1, 999);
final trend = lastMonth == 0
? null
: ((thisMonth - lastMonth) / lastMonth * 100).toStringAsFixed(0);
final isUp = thisMonth >= lastMonth;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (trend != null) ...[
Row(
children: [
Icon(
isUp ? Icons.trending_up_rounded : Icons.trending_down_rounded,
size: 18,
color: isUp ? AppColors.success : AppColors.cancelled,
),
const SizedBox(width: 6),
Text(
isUp ? '+$trend% geçen aya göre' : '$trend% geçen aya göre',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: isUp ? AppColors.success : AppColors.cancelled,
),
),
],
),
const SizedBox(height: 12),
],
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
_BarColumn(label: 'Geçen Ay', count: lastMonth, maxCount: maxBar, color: AppColors.border),
const SizedBox(width: 16),
_BarColumn(label: 'Bu Ay', count: thisMonth, maxCount: maxBar, color: AppColors.accent),
],
),
],
),
);
}
}
class _BarColumn extends StatelessWidget {
const _BarColumn({required this.label, required this.count, required this.maxCount, required this.color});
final String label;
final int count;
final int maxCount;
final Color color;
@override
Widget build(BuildContext context) {
final height = maxCount > 0 ? (count / maxCount * 80).clamp(4.0, 80.0) : 4.0;
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text('$count', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: color == AppColors.border ? AppColors.textSecondary : AppColors.accent)),
const SizedBox(height: 4),
Container(
width: 40,
height: height,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(height: 4),
Text(label, style: const TextStyle(fontSize: 11, color: AppColors.textMuted)),
],
);
}
}
// ── Recent jobs ───────────────────────────────────────────────────────────────
class _RecentJobsCard extends StatelessWidget {
const _RecentJobsCard({required this.jobs});
final List<Job> jobs;
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.border),
),
child: Column(
children: [
for (int i = 0; i < jobs.length; i++) ...[
if (i > 0) const Divider(height: 1),
_RecentJobRow(job: jobs[i]),
],
],
),
);
}
}
class _RecentJobRow extends StatelessWidget {
const _RecentJobRow({required this.job});
final Job job;
@override
Widget build(BuildContext context) {
final color = job.status == JobStatus.delivered ? AppColors.success : AppColors.inProgress;
final bg = job.status == JobStatus.delivered ? AppColors.successBg : AppColors.inProgressBg;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11),
child: Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(10)),
child: Center(child: Icon(Icons.assignment_outlined, size: 18, color: color)),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(job.patientCode, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
Text(job.prostheticType.label, style: const TextStyle(fontSize: 12, color: AppColors.textSecondary)),
],
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(6)),
child: Text(job.status.label, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: color)),
),
],
),
);
}
}
// ── Helpers ───────────────────────────────────────────────────────────────────
class _SectionTitle extends StatelessWidget {
const _SectionTitle(this.text);
final String text;
@override
Widget build(BuildContext context) {
return Text(text, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w700, color: AppColors.textMuted, letterSpacing: 0.5));
}
}
class _StatusBadge extends StatelessWidget {
const _StatusBadge({required this.status});
final ConnectionStatus status;
@override
Widget build(BuildContext context) {
final (label, color) = switch (status) {
ConnectionStatus.approved => ('Onaylı', AppColors.success),
ConnectionStatus.pending => ('Bekliyor', AppColors.pending),
ConnectionStatus.rejected => ('Reddedildi', AppColors.cancelled),
};
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(6),
),
child: Text(label, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: color)),
);
}
}