Files
lab-app/lib/features/lab/dashboard/lab_dashboard_screen.dart
T
2026-06-10 23:22:15 +03:00

884 lines
34 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/services.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/providers/auth_provider.dart';
import '../../../core/router/app_router.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/tooth_logo.dart';
import '../../../core/services/realtime_service.dart';
import '../../../models/job.dart';
import '../jobs/lab_jobs_repository.dart';
class LabDashboardScreen extends ConsumerStatefulWidget {
const LabDashboardScreen({super.key});
@override
ConsumerState<LabDashboardScreen> createState() => _LabDashboardScreenState();
}
class _LabDashboardScreenState extends ConsumerState<LabDashboardScreen> {
late Future<_DashboardData> _future;
bool _acceptingAll = false;
late UnsubFn _unsub;
@override
void initState() {
super.initState();
_load();
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
_unsub = RealtimeService.instance.watch(
'jobs',
filter: "lab_tenant_id='$tenantId'",
onEvent: (_) { if (mounted) _load(); },
);
}
@override
void dispose() {
_unsub();
super.dispose();
}
void _load() {
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
final now = DateTime.now();
final thisMonthStart = DateTime(now.year, now.month, 1);
final lastMonthStart = DateTime(now.year, now.month - 1, 1);
setState(() {
_future = Future.wait([
Future.wait<List<Job>>([
LabJobsRepository.instance.listInbound(tenantId, status: 'pending'),
LabJobsRepository.instance.listInProgress(tenantId),
LabJobsRepository.instance.listInProgress(tenantId, location: 'at_lab'),
LabJobsRepository.instance.listInProgress(tenantId, location: 'at_clinic'),
LabJobsRepository.instance.listInbound(tenantId, status: 'sent', limit: 200),
LabJobsRepository.instance.listInbound(tenantId, status: 'delivered', limit: 200),
]),
LabJobsRepository.instance.countDelivered(tenantId, from: thisMonthStart),
LabJobsRepository.instance.countDelivered(tenantId, from: lastMonthStart, to: thisMonthStart),
]).then((r) {
final jobs = r[0] as List<List<Job>>;
return _DashboardData(
pendingJobs: jobs[0],
inProgressJobs: jobs[1],
atLabJobs: jobs[2],
atClinicJobs: jobs[3],
sentCount: jobs[4].length,
deliveredCount: jobs[5].length,
thisMonthDelivered: r[1] as int,
lastMonthDelivered: r[2] as int,
);
});
});
}
Future<void> _bulkAccept() async {
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
setState(() => _acceptingAll = true);
try {
await LabJobsRepository.instance.bulkAcceptPending(tenantId);
_load();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Hata: $e'), behavior: SnackBarBehavior.floating),
);
}
} finally {
if (mounted) setState(() => _acceptingAll = false);
}
}
@override
Widget build(BuildContext context) {
final companyName = ref.watch(authProvider).activeTenant?.tenant.companyName ?? '';
return Scaffold(
backgroundColor: AppColors.background,
body: LayoutBuilder(
builder: (context, constraints) {
const maxContent = 1040.0;
final hPad = constraints.maxWidth > maxContent
? (constraints.maxWidth - maxContent) / 2
: 16.0;
return RefreshIndicator(
color: AppColors.accent,
onRefresh: () async => _load(),
child: FutureBuilder<_DashboardData>(
future: _future,
builder: (ctx, snap) {
if (snap.connectionState == ConnectionState.waiting) {
return _DashboardSkeleton(companyName: companyName, hPad: hPad);
}
if (snap.hasError) return _ErrorBody(onRetry: _load);
final data = snap.data!;
final isDesktop = MediaQuery.sizeOf(ctx).width > AppLayout.sidebarBreakpoint;
return CustomScrollView(
slivers: [
_DashboardHeader(companyName: companyName),
if (isDesktop)
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
sliver: SliverToBoxAdapter(
child: _StatsRow(
pending: data.pendingJobs.length,
inProgress: data.inProgressJobs.length,
sent: data.sentCount,
delivered: data.deliveredCount,
),
),
),
if (data.pendingJobs.isNotEmpty)
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 0),
sliver: SliverToBoxAdapter(
child: _AcceptAllBanner(
count: data.pendingJobs.length,
loading: _acceptingAll,
onTap: _bulkAccept,
).animate().fadeIn(duration: 300.ms).slideY(begin: 0.1, end: 0),
),
),
if (isDesktop) ...[
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
sliver: SliverToBoxAdapter(
child: _MonthlyReportSection(data: data)
.animate().fadeIn(duration: 300.ms).slideY(begin: 0.08, end: 0),
),
),
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
sliver: SliverToBoxAdapter(
child: _GamificationRow(data: data)
.animate().fadeIn(duration: 300.ms, delay: 60.ms).slideY(begin: 0.08, end: 0),
),
),
],
// ── Yapılacaklar (at_lab) ────────────────────────────
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 20, 16, 4),
sliver: SliverToBoxAdapter(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Yapılacaklar', style: Theme.of(context).textTheme.titleMedium),
TextButton(
onPressed: () => context.go(routeLabJobsAll),
style: TextButton.styleFrom(foregroundColor: AppColors.accent, padding: const EdgeInsets.symmetric(horizontal: 8)),
child: const Text('Tümünü Gör'),
),
],
),
),
),
if (data.atLabJobs.isEmpty)
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.fromLTRB(16, 4, 16, 0),
child: _EmptySection(message: 'Yapılacak iş yok'),
),
)
else
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 0),
sliver: SliverList.separated(
itemCount: data.atLabJobs.take(5).length,
separatorBuilder: (_, __) => const SizedBox(height: 10),
itemBuilder: (ctx, i) => _JobCard(job: data.atLabJobs[i])
.animate(delay: (i * 60).ms)
.fadeIn(duration: 300.ms)
.slideY(begin: 0.12, end: 0),
),
),
// ── Klinikte Onay Bekliyor (at_clinic) ───────────────
if (data.atClinicJobs.isNotEmpty) ...[
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 20, 16, 4),
sliver: SliverToBoxAdapter(
child: Text('Klinikte Onay Bekliyor', style: Theme.of(context).textTheme.titleMedium),
),
),
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 0),
sliver: SliverList.separated(
itemCount: data.atClinicJobs.take(5).length,
separatorBuilder: (_, __) => const SizedBox(height: 10),
itemBuilder: (ctx, i) => _JobCard(job: data.atClinicJobs[i])
.animate(delay: (i * 60).ms)
.fadeIn(duration: 300.ms)
.slideY(begin: 0.12, end: 0),
),
),
],
const SliverToBoxAdapter(child: SizedBox(height: 24)),
],
);
},
),
);
},
),
);
}
}
class _DashboardHeader extends StatelessWidget {
const _DashboardHeader({required this.companyName});
final String companyName;
// Must stay in sync with _DesktopSidebar.headerHeight in app_router.dart
static const double _desktopToolbarHeight = 64;
@override
Widget build(BuildContext context) {
final isDesktop = MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint;
if (isDesktop) {
return SliverAppBar(
pinned: true,
toolbarHeight: _desktopToolbarHeight,
backgroundColor: AppColors.surface,
surfaceTintColor: Colors.transparent,
elevation: 0,
scrolledUnderElevation: 0,
centerTitle: false,
automaticallyImplyLeading: false,
titleSpacing: 0,
title: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('Genel Bakış', style: TextStyle(fontSize: 11, color: AppColors.textSecondary.withValues(alpha: 0.8), letterSpacing: 0.3)),
const Text('Bugünkü Durum', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700, color: AppColors.textPrimary)),
],
),
),
actions: [
IconButton(
onPressed: () => context.go(routeLabSettings),
icon: const Icon(Icons.settings_outlined, color: AppColors.textSecondary, size: 22),
),
const SizedBox(width: 8),
],
);
}
return SliverAppBar(
pinned: true,
expandedHeight: 148,
backgroundColor: AppColors.primary,
surfaceTintColor: Colors.transparent,
shadowColor: Colors.transparent,
systemOverlayStyle: SystemUiOverlayStyle.light,
centerTitle: false,
leadingWidth: 60,
leading: Padding(
padding: const EdgeInsets.only(left: 16, top: 8, bottom: 8),
child: Container(
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
),
child: const Center(child: ToothLogo(size: 20, color: Colors.white)),
),
),
titleSpacing: 8,
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('DLS', style: TextStyle(color: Colors.white.withValues(alpha: 0.65), fontSize: 11, fontWeight: FontWeight.w600, letterSpacing: 1.5)),
Text(companyName, style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w700), maxLines: 1, overflow: TextOverflow.ellipsis),
],
),
actions: [
IconButton(
onPressed: () => context.go(routeLabSettings),
icon: const Icon(Icons.settings_outlined, color: Colors.white, size: 22),
),
],
flexibleSpace: FlexibleSpaceBar(
collapseMode: CollapseMode.pin,
background: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [AppColors.primary, AppColors.accent],
),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.end,
children: [
Text('Genel Bakış', style: TextStyle(color: Colors.white.withValues(alpha: 0.65), fontSize: 12, fontWeight: FontWeight.w500, letterSpacing: 0.5)),
const SizedBox(height: 4),
const Text('Bugünkü Durum', style: TextStyle(color: Colors.white, fontSize: 24, fontWeight: FontWeight.w800, letterSpacing: -0.5)),
],
),
),
),
),
);
}
}
class _StatsRow extends StatelessWidget {
const _StatsRow({
required this.pending,
required this.inProgress,
required this.sent,
required this.delivered,
});
final int pending;
final int inProgress;
final int sent;
final int delivered;
@override
Widget build(BuildContext context) {
final isWideDesktop = MediaQuery.sizeOf(context).width >= AppLayout.wideBreakpoint;
final pendingCard = _StatCard(label: 'Bekleyen', value: '$pending', icon: Icons.hourglass_top_rounded, color: AppColors.pending, bgColor: AppColors.pendingBg)
.animate().fadeIn(duration: 350.ms).slideY(begin: 0.2, end: 0);
final inProgressCard = _StatCard(label: 'Devam Eden', value: '$inProgress', icon: Icons.autorenew_rounded, color: AppColors.inProgress, bgColor: AppColors.inProgressBg)
.animate(delay: 80.ms).fadeIn(duration: 350.ms).slideY(begin: 0.2, end: 0);
if (isWideDesktop) {
final sentCard = _StatCard(label: 'Klinik\'te', value: '$sent', icon: Icons.local_hospital_outlined, color: AppColors.accent, bgColor: AppColors.inProgressBg)
.animate(delay: 120.ms).fadeIn(duration: 350.ms).slideY(begin: 0.2, end: 0);
final deliveredCard = _StatCard(label: 'Tamamlanan', value: '$delivered', icon: Icons.task_alt, color: AppColors.success, bgColor: AppColors.successBg)
.animate(delay: 160.ms).fadeIn(duration: 350.ms).slideY(begin: 0.2, end: 0);
return Row(
children: [
Expanded(child: pendingCard),
const SizedBox(width: 12),
Expanded(child: inProgressCard),
const SizedBox(width: 12),
Expanded(child: sentCard),
const SizedBox(width: 12),
Expanded(child: deliveredCard),
],
);
}
return Row(
children: [
Expanded(child: pendingCard),
const SizedBox(width: 12),
Expanded(child: inProgressCard),
],
);
}
}
class _StatCard extends StatelessWidget {
const _StatCard({required this.label, required this.value, required this.icon, required this.color, required this.bgColor});
final String label;
final String value;
final IconData icon;
final Color color;
final Color bgColor;
@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),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(value, style: TextStyle(fontSize: 28, 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 _AcceptAllBanner extends StatelessWidget {
const _AcceptAllBanner({required this.count, required this.loading, required this.onTap});
final int count;
final bool loading;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Material(
color: AppColors.pendingBg,
borderRadius: BorderRadius.circular(14),
child: InkWell(
onTap: loading ? null : onTap,
borderRadius: BorderRadius.circular(14),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(borderRadius: BorderRadius.circular(14), border: Border.all(color: AppColors.pending.withValues(alpha: 0.35))),
child: Row(
children: [
Container(
width: 38, height: 38,
decoration: BoxDecoration(color: AppColors.pending.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(10)),
child: const Icon(Icons.notifications_active_outlined, color: AppColors.pending, size: 18),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('$count yeni iş bekliyor', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
const Text('Tümünü hızlıca kabul et', style: TextStyle(fontSize: 12, color: AppColors.textSecondary)),
],
),
),
loading
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: AppColors.pending))
: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
decoration: BoxDecoration(color: AppColors.pending, borderRadius: BorderRadius.circular(8)),
child: const Text('Kabul Et', style: TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.w600)),
),
],
),
),
),
);
}
}
class _JobCard extends StatelessWidget {
const _JobCard({required this.job});
final Job job;
@override
Widget build(BuildContext context) {
final due = job.dueDate;
final isOverdue = due != null && due.isBefore(DateTime.now());
final dueText = due != null ? '${due.day.toString().padLeft(2, '0')}.${due.month.toString().padLeft(2, '0')}.${due.year}' : null;
return Semantics(
label: job.patientCode,
button: true,
excludeSemantics: true,
child: Material(
color: AppColors.surface,
borderRadius: BorderRadius.circular(14),
child: InkWell(
onTap: () => context.push('/lab/jobs/${job.id}'),
borderRadius: BorderRadius.circular(14),
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(borderRadius: BorderRadius.circular(14), border: Border.all(color: AppColors.border)),
child: Row(
children: [
Container(
width: 46, height: 46,
decoration: BoxDecoration(color: AppColors.inProgressBg, borderRadius: BorderRadius.circular(12)),
child: Center(child: Text('${job.memberCount}', style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w800, color: AppColors.inProgress))),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(job.patientCode, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
const SizedBox(height: 2),
Text(job.clinicName ?? 'Klinik', style: const TextStyle(fontSize: 13, color: AppColors.textSecondary)),
const SizedBox(height: 6),
Wrap(
spacing: 6,
children: [
_Tag(label: job.prostheticType.label, color: AppColors.inProgress, bg: AppColors.inProgressBg),
if (job.currentStep != null) _Tag(label: job.currentStep!.label, color: AppColors.success, bg: AppColors.successBg),
],
),
],
),
),
if (dueText != null) ...[
const SizedBox(width: 8),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Icon(Icons.calendar_today_outlined, size: 13, color: isOverdue ? AppColors.cancelled : AppColors.textMuted),
const SizedBox(height: 3),
Text(dueText, style: TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: isOverdue ? AppColors.cancelled : AppColors.textSecondary)),
],
),
],
],
),
),
),
),
);
}
}
class _Tag extends StatelessWidget {
const _Tag({required this.label, required this.color, required this.bg});
final String label;
final Color color;
final Color bg;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(6)),
child: Text(label, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: color)),
);
}
}
class _EmptySection extends StatelessWidget {
const _EmptySection({required this.message});
final String message;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 16),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.border),
),
child: Row(
children: [
Icon(Icons.check_circle_outline_rounded, color: AppColors.textSecondary.withValues(alpha: 0.5), size: 20),
const SizedBox(width: 10),
Text(message, style: TextStyle(fontSize: 14, color: AppColors.textSecondary)),
],
),
);
}
}
class _ErrorBody extends StatelessWidget {
const _ErrorBody({required this.onRetry});
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
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),
const Text('Bağlantı hatası', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
const SizedBox(height: 12),
FilledButton.icon(onPressed: onRetry, icon: const Icon(Icons.refresh_rounded, size: 18), label: const Text('Tekrar Dene')),
],
),
),
);
}
}
class _DashboardSkeleton extends StatelessWidget {
const _DashboardSkeleton({required this.companyName, required this.hPad});
final String companyName;
final double hPad;
@override
Widget build(BuildContext context) {
return CustomScrollView(
physics: const NeverScrollableScrollPhysics(),
slivers: [
_DashboardHeader(companyName: companyName),
SliverPadding(
padding: EdgeInsets.fromLTRB(hPad, 16, hPad, 0),
sliver: const SliverToBoxAdapter(
child: Row(children: [
Expanded(child: _ShimmerBox(height: 84, radius: 16)),
SizedBox(width: 12),
Expanded(child: _ShimmerBox(height: 84, radius: 16)),
]),
),
),
SliverPadding(
padding: EdgeInsets.fromLTRB(hPad, 8, hPad, 0),
sliver: SliverList.builder(
itemCount: 4,
itemBuilder: (_, i) => const Padding(padding: EdgeInsets.only(bottom: 10), child: _ShimmerBox(height: 92, radius: 14)),
),
),
],
);
}
}
class _ShimmerBox extends StatefulWidget {
const _ShimmerBox({required this.height, required this.radius});
final double height;
final double radius;
@override
State<_ShimmerBox> createState() => _ShimmerBoxState();
}
class _ShimmerBoxState extends State<_ShimmerBox> with SingleTickerProviderStateMixin {
late AnimationController _ctrl;
late Animation<double> _anim;
@override
void initState() {
super.initState();
_ctrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 1100))..repeat(reverse: true);
_anim = CurvedAnimation(parent: _ctrl, curve: Curves.easeInOut);
}
@override
void dispose() { _ctrl.dispose(); super.dispose(); }
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _anim,
builder: (_, __) => Container(
height: widget.height,
decoration: BoxDecoration(borderRadius: BorderRadius.circular(widget.radius), color: Color.lerp(const Color(0xFFE2E8F0), const Color(0xFFF1F5F9), _anim.value)),
),
);
}
}
// ── Monthly Report ──────────────────────────────────────────────────────────
class _MonthlyReportSection extends StatelessWidget {
const _MonthlyReportSection({required this.data});
final _DashboardData data;
@override
Widget build(BuildContext context) {
final pct = data.changePercent;
final isUp = pct >= 0;
final pctStr = '${isUp ? '+' : ''}${pct.toStringAsFixed(0)}%';
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.bar_chart_rounded, size: 18, color: AppColors.accent),
const SizedBox(width: 6),
Text('Aylık Rapor', style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _MonthStat(
label: 'Bu Ay',
value: data.thisMonthDelivered,
highlighted: true,
),
),
const SizedBox(width: 12),
Expanded(
child: _MonthStat(
label: 'Geçen Ay',
value: data.lastMonthDelivered,
highlighted: false,
),
),
const SizedBox(width: 12),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: isUp ? AppColors.successBg : AppColors.cancelledBg,
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isUp ? Icons.trending_up_rounded : Icons.trending_down_rounded,
size: 16,
color: isUp ? AppColors.success : AppColors.cancelled,
),
const SizedBox(width: 4),
Text(
pctStr,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: isUp ? AppColors.success : AppColors.cancelled,
),
),
],
),
),
],
),
],
),
);
}
}
class _MonthStat extends StatelessWidget {
const _MonthStat({required this.label, required this.value, required this.highlighted});
final String label;
final int value;
final bool highlighted;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: highlighted ? AppColors.accent.withValues(alpha: 0.06) : AppColors.background,
borderRadius: BorderRadius.circular(8),
border: highlighted ? Border.all(color: AppColors.accent.withValues(alpha: 0.2)) : null,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: TextStyle(fontSize: 11, color: AppColors.textSecondary, fontWeight: FontWeight.w500)),
const SizedBox(height: 2),
Text(
'$value',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: highlighted ? AppColors.accent : AppColors.textPrimary,
),
),
],
),
);
}
}
// ── Gamification Row ─────────────────────────────────────────────────────────
const _monthlyGoal = 50;
class _GamificationRow extends StatelessWidget {
const _GamificationRow({required this.data});
final _DashboardData data;
@override
Widget build(BuildContext context) {
final progress = (data.thisMonthDelivered / _monthlyGoal).clamp(0.0, 1.0);
final remaining = (_monthlyGoal - data.thisMonthDelivered).clamp(0, _monthlyGoal);
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Text('🏆', style: TextStyle(fontSize: 16)),
const SizedBox(width: 6),
Text('Aylık Hedef', style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)),
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: AppColors.primary.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(6),
),
child: Text(
'${data.points} puan',
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: AppColors.primary),
),
),
],
),
const SizedBox(height: 10),
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: LinearProgressIndicator(
value: progress,
minHeight: 8,
backgroundColor: AppColors.background,
valueColor: AlwaysStoppedAnimation<Color>(
progress >= 1.0 ? AppColors.success : AppColors.accent,
),
),
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${data.thisMonthDelivered} / $_monthlyGoal iş teslim edildi',
style: TextStyle(fontSize: 12, color: AppColors.textSecondary),
),
Text(
progress >= 1.0 ? 'Hedef tamamlandı!' : '$remaining iş kaldı',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: progress >= 1.0 ? AppColors.success : AppColors.textSecondary,
),
),
],
),
],
),
);
}
}
// ── Data Model ───────────────────────────────────────────────────────────────
class _DashboardData {
final List<Job> pendingJobs;
final List<Job> inProgressJobs;
final List<Job> atLabJobs;
final List<Job> atClinicJobs;
final int sentCount;
final int deliveredCount;
final int thisMonthDelivered;
final int lastMonthDelivered;
const _DashboardData({
required this.pendingJobs,
required this.inProgressJobs,
required this.atLabJobs,
required this.atClinicJobs,
required this.sentCount,
required this.deliveredCount,
required this.thisMonthDelivered,
required this.lastMonthDelivered,
});
int get points => thisMonthDelivered * 10;
double get changePercent => lastMonthDelivered == 0
? (thisMonthDelivered > 0 ? 100 : 0)
: (thisMonthDelivered - lastMonthDelivered) / lastMonthDelivered * 100;
}