1231 lines
46 KiB
Dart
1231 lines
46 KiB
Dart
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/services/realtime_service.dart';
|
||
import '../../../core/theme/app_theme.dart';
|
||
import '../../../core/widgets/tooth_logo.dart';
|
||
import '../../../models/job.dart';
|
||
import '../jobs/clinic_jobs_repository.dart';
|
||
import '../patients/clinic_patients_repository.dart';
|
||
|
||
class ClinicDashboardScreen extends ConsumerStatefulWidget {
|
||
const ClinicDashboardScreen({super.key});
|
||
|
||
@override
|
||
ConsumerState<ClinicDashboardScreen> createState() =>
|
||
_ClinicDashboardScreenState();
|
||
}
|
||
|
||
class _ClinicDashboardScreenState extends ConsumerState<ClinicDashboardScreen> {
|
||
late Future<_DashboardData> _future;
|
||
late UnsubFn _unsub;
|
||
final Map<String, bool> _actingJobs = {};
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_load();
|
||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||
_unsub = RealtimeService.instance.watch(
|
||
'jobs',
|
||
filter: "clinic_tenant_id='$tenantId'",
|
||
onEvent: (_) { if (mounted) _load(); },
|
||
);
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_unsub();
|
||
super.dispose();
|
||
}
|
||
|
||
void _load() {
|
||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||
setState(() {
|
||
_future = _loadAll(tenantId);
|
||
});
|
||
}
|
||
|
||
Future<void> _approveAtClinic(Job job) async {
|
||
final confirmed = await showDialog<bool>(
|
||
context: context,
|
||
builder: (ctx) => AlertDialog(
|
||
title: Text(job.patientCode),
|
||
content: Text('${job.prostheticType.label} işini onaylıyor musunuz?'),
|
||
actions: [
|
||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('İptal')),
|
||
FilledButton(
|
||
style: FilledButton.styleFrom(backgroundColor: AppColors.success),
|
||
onPressed: () => Navigator.pop(ctx, true),
|
||
child: const Text('Onayla'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
if (confirmed != true || !mounted) return;
|
||
setState(() => _actingJobs[job.id] = true);
|
||
try {
|
||
await ClinicJobsRepository.instance.approveAtClinic(job.id, job);
|
||
_load();
|
||
} catch (e) {
|
||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Hata: $e')));
|
||
} finally {
|
||
if (mounted) setState(() => _actingJobs.remove(job.id));
|
||
}
|
||
}
|
||
|
||
Future<void> _markDelivered(Job job) async {
|
||
final confirmed = await showDialog<bool>(
|
||
context: context,
|
||
builder: (ctx) => AlertDialog(
|
||
title: Text(job.patientCode),
|
||
content: Text('${job.prostheticType.label} işi teslim alındı olarak işaretlensin mi?'),
|
||
actions: [
|
||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('İptal')),
|
||
FilledButton(
|
||
onPressed: () => Navigator.pop(ctx, true),
|
||
child: const Text('Teslim Aldım'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
if (confirmed != true || !mounted) return;
|
||
setState(() => _actingJobs[job.id] = true);
|
||
try {
|
||
await ClinicJobsRepository.instance.markDelivered(job.id, job);
|
||
_load();
|
||
} catch (e) {
|
||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Hata: $e')));
|
||
} finally {
|
||
if (mounted) setState(() => _actingJobs.remove(job.id));
|
||
}
|
||
}
|
||
|
||
Future<_DashboardData> _loadAll(String tenantId) async {
|
||
final now = DateTime.now();
|
||
final thisMonthStart = DateTime(now.year, now.month, 1);
|
||
final lastMonthStart = DateTime(now.year, now.month - 1, 1);
|
||
|
||
final results = await Future.wait([
|
||
ClinicJobsRepository.instance.listOutbound(tenantId, statuses: ['pending'], limit: 200),
|
||
ClinicJobsRepository.instance.listOutbound(tenantId, statuses: ['in_progress'], limit: 200),
|
||
ClinicJobsRepository.instance.listOutbound(tenantId, statuses: ['sent'], limit: 200),
|
||
ClinicJobsRepository.instance.listOutbound(tenantId, limit: 5),
|
||
ClinicPatientsRepository.instance.listPatients(tenantId, limit: 200),
|
||
]);
|
||
final thisMonth = await ClinicJobsRepository.instance.countDelivered(tenantId, from: thisMonthStart);
|
||
final lastMonth = await ClinicJobsRepository.instance.countDelivered(tenantId, from: lastMonthStart, to: thisMonthStart);
|
||
|
||
final inProgressJobs = results[1] as List<Job>;
|
||
final sentJobs = results[2] as List<Job>;
|
||
final provaAtClinic = inProgressJobs.where((j) => j.location == JobLocation.atClinic).toList();
|
||
final actionJobs = [...provaAtClinic, ...sentJobs];
|
||
|
||
return _DashboardData(
|
||
pendingCount: (results[0] as List).length,
|
||
inProgressCount: inProgressJobs.length,
|
||
sentCount: sentJobs.length,
|
||
patientCount: (results[4] as List).length,
|
||
recentJobs: results[3] as List<Job>,
|
||
thisMonthDelivered: thisMonth,
|
||
lastMonthDelivered: lastMonth,
|
||
actionJobs: actionJobs,
|
||
);
|
||
}
|
||
|
||
@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.pendingCount,
|
||
inProgress: data.inProgressCount,
|
||
sent: data.sentCount,
|
||
patients: data.patientCount,
|
||
),
|
||
),
|
||
),
|
||
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),
|
||
),
|
||
),
|
||
],
|
||
SliverPadding(
|
||
padding: EdgeInsets.fromLTRB(hPad, 16, hPad, 0),
|
||
sliver: SliverToBoxAdapter(
|
||
child: FilledButton.icon(
|
||
onPressed: () => context.push(routeClinicJobNew),
|
||
icon: const Icon(Icons.add_rounded, size: 20),
|
||
label: const Text('Yeni İş Oluştur'),
|
||
style: FilledButton.styleFrom(
|
||
minimumSize: const Size(double.infinity, 52),
|
||
backgroundColor: AppColors.accent,
|
||
),
|
||
).animate().fadeIn(duration: 300.ms).slideY(begin: 0.1, end: 0),
|
||
),
|
||
),
|
||
if (data.actionJobs.isNotEmpty)
|
||
SliverPadding(
|
||
padding: EdgeInsets.fromLTRB(hPad, 20, hPad, 0),
|
||
sliver: SliverToBoxAdapter(
|
||
child: _ActionSection(
|
||
jobs: data.actionJobs,
|
||
actingJobs: _actingJobs,
|
||
onApprove: _approveAtClinic,
|
||
onDeliver: _markDelivered,
|
||
).animate().fadeIn(duration: 300.ms).slideY(begin: 0.06, end: 0),
|
||
),
|
||
),
|
||
SliverPadding(
|
||
padding: EdgeInsets.fromLTRB(hPad, 20, hPad, 4),
|
||
sliver: SliverToBoxAdapter(
|
||
child: Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
Text('Son İşler',
|
||
style: Theme.of(context).textTheme.titleMedium),
|
||
TextButton(
|
||
onPressed: () => context.go(routeClinicJobs),
|
||
style: TextButton.styleFrom(
|
||
foregroundColor: AppColors.accent,
|
||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||
),
|
||
child: const Text('Tümünü Gör'),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
if (data.recentJobs.isEmpty)
|
||
const SliverFillRemaining(
|
||
hasScrollBody: false, child: _EmptyJobs())
|
||
else
|
||
SliverPadding(
|
||
padding: EdgeInsets.fromLTRB(hPad, 0, hPad, 24),
|
||
sliver: SliverList.separated(
|
||
itemCount: data.recentJobs.length,
|
||
separatorBuilder: (_, __) => const SizedBox(height: 10),
|
||
itemBuilder: (ctx, i) =>
|
||
_JobCard(job: data.recentJobs[i])
|
||
.animate(delay: (i * 60).ms)
|
||
.fadeIn(duration: 300.ms)
|
||
.slideY(begin: 0.12, end: 0),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
},
|
||
),
|
||
);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _DashboardData {
|
||
const _DashboardData({
|
||
required this.pendingCount,
|
||
required this.inProgressCount,
|
||
required this.sentCount,
|
||
required this.patientCount,
|
||
required this.recentJobs,
|
||
required this.thisMonthDelivered,
|
||
required this.lastMonthDelivered,
|
||
required this.actionJobs,
|
||
});
|
||
final int pendingCount;
|
||
final int inProgressCount;
|
||
final int sentCount;
|
||
final int patientCount;
|
||
final List<Job> recentJobs;
|
||
final int thisMonthDelivered;
|
||
final int lastMonthDelivered;
|
||
final List<Job> actionJobs;
|
||
|
||
int get points => thisMonthDelivered * 10;
|
||
double get changePercent => lastMonthDelivered == 0
|
||
? (thisMonthDelivered > 0 ? 100 : 0)
|
||
: (thisMonthDelivered - lastMonthDelivered) / lastMonthDelivered * 100;
|
||
}
|
||
|
||
// ── Action Section ───────────────────────────────────────────────────────────
|
||
|
||
class _ActionSection extends StatelessWidget {
|
||
const _ActionSection({
|
||
required this.jobs,
|
||
required this.actingJobs,
|
||
required this.onApprove,
|
||
required this.onDeliver,
|
||
});
|
||
|
||
final List<Job> jobs;
|
||
final Map<String, bool> actingJobs;
|
||
final Future<void> Function(Job) onApprove;
|
||
final Future<void> Function(Job) onDeliver;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Container(
|
||
width: 26, height: 26,
|
||
decoration: BoxDecoration(color: AppColors.pending, borderRadius: BorderRadius.circular(7)),
|
||
child: const Icon(Icons.priority_high_rounded, size: 15, color: Colors.white),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Text('Yapılacaklar', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)),
|
||
const SizedBox(width: 8),
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
|
||
decoration: BoxDecoration(color: AppColors.pending, borderRadius: BorderRadius.circular(10)),
|
||
child: Text('${jobs.length}', style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w800, color: Colors.white)),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 12),
|
||
...jobs.asMap().entries.map((entry) => Padding(
|
||
padding: const EdgeInsets.only(bottom: 10),
|
||
child: _ActionJobCard(
|
||
job: entry.value,
|
||
acting: actingJobs[entry.value.id] == true,
|
||
onApprove: () => onApprove(entry.value),
|
||
onDeliver: () => onDeliver(entry.value),
|
||
).animate(delay: (entry.key * 50).ms).fadeIn(duration: 250.ms).slideY(begin: 0.08, end: 0),
|
||
)),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
class _ActionJobCard extends StatelessWidget {
|
||
const _ActionJobCard({
|
||
required this.job,
|
||
required this.acting,
|
||
required this.onApprove,
|
||
required this.onDeliver,
|
||
});
|
||
|
||
final Job job;
|
||
final bool acting;
|
||
final VoidCallback onApprove;
|
||
final VoidCallback onDeliver;
|
||
|
||
bool get _isProva => job.status == JobStatus.inProgress && job.location == JobLocation.atClinic;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final isProva = _isProva;
|
||
final borderColor = isProva ? AppColors.pending : AppColors.accent;
|
||
final bgColor = isProva ? AppColors.pendingBg : AppColors.inProgressBg;
|
||
final iconColor = isProva ? AppColors.pending : AppColors.accent;
|
||
final icon = isProva ? Icons.rate_review_outlined : Icons.inventory_2_outlined;
|
||
final statusLabel = isProva ? 'Onay Bekliyor' : 'Teslimat Bekliyor';
|
||
|
||
return Semantics(
|
||
label: job.patientCode,
|
||
button: true,
|
||
excludeSemantics: true,
|
||
child: Material(
|
||
color: AppColors.surface,
|
||
borderRadius: BorderRadius.circular(14),
|
||
child: InkWell(
|
||
onTap: () => context.push('/clinic/jobs/${job.id}'),
|
||
borderRadius: BorderRadius.circular(14),
|
||
child: Container(
|
||
decoration: BoxDecoration(
|
||
borderRadius: BorderRadius.circular(14),
|
||
border: Border.all(color: borderColor.withValues(alpha: 0.45), width: 1.5),
|
||
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.05), blurRadius: 10, offset: const Offset(0, 3))],
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(14, 12, 14, 10),
|
||
child: Row(
|
||
children: [
|
||
Container(
|
||
width: 40, height: 40,
|
||
decoration: BoxDecoration(color: bgColor, borderRadius: BorderRadius.circular(11)),
|
||
child: Icon(icon, color: iconColor, size: 19),
|
||
),
|
||
const SizedBox(width: 10),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(job.patientCode, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w700, color: AppColors.textPrimary)),
|
||
const SizedBox(height: 2),
|
||
Text(
|
||
'${job.prostheticType.label} · ${job.labName ?? 'Lab'}',
|
||
style: const TextStyle(fontSize: 12, color: AppColors.textSecondary),
|
||
maxLines: 1, overflow: TextOverflow.ellipsis,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||
decoration: BoxDecoration(color: bgColor, borderRadius: BorderRadius.circular(8)),
|
||
child: Text(statusLabel, style: TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: iconColor)),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
Container(
|
||
decoration: BoxDecoration(
|
||
color: bgColor.withValues(alpha: 0.45),
|
||
borderRadius: const BorderRadius.only(bottomLeft: Radius.circular(13), bottomRight: Radius.circular(13)),
|
||
),
|
||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 10),
|
||
child: acting
|
||
? const Center(
|
||
child: Padding(
|
||
padding: EdgeInsets.symmetric(vertical: 4),
|
||
child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2.5, color: AppColors.accent)),
|
||
),
|
||
)
|
||
: isProva
|
||
? Row(children: [
|
||
Expanded(
|
||
child: FilledButton.icon(
|
||
onPressed: onApprove,
|
||
icon: const Icon(Icons.check_circle_outline, size: 15),
|
||
label: const Text('Onayla', style: TextStyle(fontSize: 13)),
|
||
style: FilledButton.styleFrom(
|
||
backgroundColor: AppColors.success,
|
||
minimumSize: const Size(0, 36),
|
||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
OutlinedButton.icon(
|
||
onPressed: () => context.push('/clinic/jobs/${job.id}'),
|
||
icon: const Icon(Icons.open_in_new_rounded, size: 14),
|
||
label: const Text('Detay', style: TextStyle(fontSize: 13)),
|
||
style: OutlinedButton.styleFrom(
|
||
minimumSize: const Size(0, 36),
|
||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||
foregroundColor: AppColors.pending,
|
||
side: BorderSide(color: AppColors.pending.withValues(alpha: 0.6)),
|
||
),
|
||
),
|
||
])
|
||
: Row(children: [
|
||
Expanded(
|
||
child: FilledButton.icon(
|
||
onPressed: onDeliver,
|
||
icon: const Icon(Icons.inventory_2_outlined, size: 15),
|
||
label: const Text('Teslim Aldım', style: TextStyle(fontSize: 13)),
|
||
style: FilledButton.styleFrom(
|
||
minimumSize: const Size(0, 36),
|
||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
OutlinedButton.icon(
|
||
onPressed: () => context.push('/clinic/jobs/${job.id}'),
|
||
icon: const Icon(Icons.open_in_new_rounded, size: 14),
|
||
label: const Text('Detay', style: TextStyle(fontSize: 13)),
|
||
style: OutlinedButton.styleFrom(
|
||
minimumSize: const Size(0, 36),
|
||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||
foregroundColor: AppColors.accent,
|
||
side: BorderSide(color: AppColors.accent.withValues(alpha: 0.6)),
|
||
),
|
||
),
|
||
]),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── 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 iş',
|
||
style: TextStyle(
|
||
fontSize: 18,
|
||
fontWeight: FontWeight.w700,
|
||
color: highlighted ? AppColors.accent : AppColors.textPrimary,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Gamification Row ─────────────────────────────────────────────────────────
|
||
|
||
const _monthlyGoal = 20;
|
||
|
||
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,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Header ──────────────────────────────────────────────────────────────────
|
||
|
||
class _DashboardHeader extends StatelessWidget {
|
||
const _DashboardHeader({required this.companyName});
|
||
final String companyName;
|
||
|
||
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(routeClinicSettings),
|
||
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(routeClinicSettings),
|
||
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)),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Stats ────────────────────────────────────────────────────────────────────
|
||
|
||
class _StatsRow extends StatelessWidget {
|
||
const _StatsRow({
|
||
required this.pending,
|
||
required this.inProgress,
|
||
required this.sent,
|
||
required this.patients,
|
||
});
|
||
final int pending;
|
||
final int inProgress;
|
||
final int sent;
|
||
final int patients;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final isWideDesktop = MediaQuery.sizeOf(context).width >= AppLayout.wideBreakpoint;
|
||
|
||
final c1 = _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 c2 = _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);
|
||
final c3 = _StatCard(label: 'Toplam Hasta', value: '$patients', icon: Icons.people_outline_rounded, color: AppColors.success, bgColor: AppColors.successBg)
|
||
.animate(delay: 160.ms).fadeIn(duration: 350.ms).slideY(begin: 0.2, end: 0);
|
||
|
||
// Wide desktop (≥ 1100px): 4 cards side by side — full lifecycle view.
|
||
if (isWideDesktop) {
|
||
final c4 = _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);
|
||
return Row(
|
||
children: [
|
||
Expanded(child: c1),
|
||
const SizedBox(width: 12),
|
||
Expanded(child: c2),
|
||
const SizedBox(width: 12),
|
||
Expanded(child: c4),
|
||
const SizedBox(width: 12),
|
||
Expanded(child: c3),
|
||
],
|
||
);
|
||
}
|
||
|
||
// Mobile + narrow sidebar (< 1100px): 2+1 column layout.
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Expanded(child: c1),
|
||
const SizedBox(width: 12),
|
||
Expanded(child: c2),
|
||
],
|
||
),
|
||
const SizedBox(height: 12),
|
||
c3,
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
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)),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Job Card ─────────────────────────────────────────────────────────────────
|
||
|
||
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;
|
||
|
||
final statusColor = _statusColor(job.status);
|
||
final statusBg = _statusBg(job.status);
|
||
|
||
return Semantics(
|
||
label: job.patientCode,
|
||
button: true,
|
||
excludeSemantics: true,
|
||
child: Material(
|
||
color: AppColors.surface,
|
||
borderRadius: BorderRadius.circular(14),
|
||
child: InkWell(
|
||
onTap: () => context.push('/clinic/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: statusBg, borderRadius: BorderRadius.circular(12)),
|
||
child: Icon(Icons.work_outline_rounded,
|
||
color: statusColor, size: 22),
|
||
),
|
||
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.labName ?? 'Laboratuvar',
|
||
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),
|
||
_Tag(
|
||
label: job.status.label,
|
||
color: statusColor,
|
||
bg: statusBg),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
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),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Color _statusColor(JobStatus s) {
|
||
switch (s) {
|
||
case JobStatus.pending:
|
||
return AppColors.pending;
|
||
case JobStatus.inProgress:
|
||
return AppColors.inProgress;
|
||
case JobStatus.sent:
|
||
return AppColors.accent;
|
||
case JobStatus.delivered:
|
||
return AppColors.success;
|
||
case JobStatus.cancelled:
|
||
return AppColors.cancelled;
|
||
}
|
||
}
|
||
|
||
Color _statusBg(JobStatus s) {
|
||
switch (s) {
|
||
case JobStatus.pending:
|
||
return AppColors.pendingBg;
|
||
case JobStatus.inProgress:
|
||
return AppColors.inProgressBg;
|
||
case JobStatus.sent:
|
||
return AppColors.inProgressBg;
|
||
case JobStatus.delivered:
|
||
return AppColors.successBg;
|
||
case JobStatus.cancelled:
|
||
return AppColors.cancelledBg;
|
||
}
|
||
}
|
||
}
|
||
|
||
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)),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Empty / Error / Skeleton ─────────────────────────────────────────────────
|
||
|
||
class _EmptyJobs extends StatelessWidget {
|
||
const _EmptyJobs();
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Padding(
|
||
padding: const EdgeInsets.all(40),
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
Container(
|
||
width: 72,
|
||
height: 72,
|
||
decoration: BoxDecoration(
|
||
color: AppColors.inProgressBg,
|
||
borderRadius: BorderRadius.circular(20)),
|
||
child: const Icon(Icons.work_off_outlined,
|
||
color: AppColors.inProgress, size: 32),
|
||
),
|
||
const SizedBox(height: 16),
|
||
const Text('Henüz iş yok',
|
||
style: TextStyle(
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.w600,
|
||
color: AppColors.textPrimary)),
|
||
const SizedBox(height: 6),
|
||
const Text(
|
||
'Yeni iş oluşturduğunuzda\nburada görünecek',
|
||
textAlign: TextAlign.center,
|
||
style:
|
||
TextStyle(fontSize: 13, 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: Column(
|
||
children: [
|
||
Row(children: [
|
||
Expanded(child: _ShimmerBox(height: 84, radius: 16)),
|
||
SizedBox(width: 12),
|
||
Expanded(child: _ShimmerBox(height: 84, radius: 16)),
|
||
]),
|
||
SizedBox(height: 12),
|
||
_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)),
|
||
),
|
||
);
|
||
}
|
||
}
|