Files
lab-app/lib/features/lab/jobs/lab_job_detail_screen.dart
T
2026-06-20 18:24:40 +03:00

927 lines
31 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:go_router/go_router.dart';
import '../../../core/providers/auth_provider.dart';
import '../../../core/services/realtime_service.dart';
import '../../../core/theme/app_theme.dart';
import '../../../models/job.dart';
import '../../../models/job_file.dart';
import '../../../features/shared/job_files_repository.dart';
import '../../../features/shared/job_files_panel.dart';
import '../../../core/services/job_history_service.dart';
import 'lab_jobs_repository.dart';
// ── Adaptive sheet helper ────────────────────────────────────────────────────
void _showAdaptive(BuildContext context, Widget content) {
final isDesktop =
MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint;
if (isDesktop) {
showDialog(
context: context,
builder: (_) => Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: content,
),
),
);
} else {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => content,
);
}
}
class LabJobDetailScreen extends ConsumerStatefulWidget {
const LabJobDetailScreen({super.key, required this.jobId});
final String jobId;
@override
ConsumerState<LabJobDetailScreen> createState() => _LabJobDetailScreenState();
}
class _LabJobDetailScreenState extends ConsumerState<LabJobDetailScreen> {
Job? _job;
bool _loadingJob = false;
String? _loadError;
bool _isActing = false;
late Future<List<JobFile>> _filesFuture;
late Future<List<JobHistoryEntry>> _historyFuture;
final List<UnsubFn> _unsubs = [];
@override
void initState() {
super.initState();
_load();
_loadFiles();
_loadHistory();
_unsubs.add(RealtimeService.instance.watch(
'jobs',
topic: widget.jobId,
onEvent: (_) {
if (mounted && !_isActing) _load();
},
));
_unsubs.add(RealtimeService.instance.watch(
'job_files',
filter: 'job_id="${widget.jobId}"',
onEvent: (_) {
if (mounted) _loadFiles();
},
));
_unsubs.add(RealtimeService.instance.watch(
'job_status_history',
filter: 'job_id="${widget.jobId}"',
onEvent: (_) {
if (mounted) _loadHistory();
},
));
}
@override
void dispose() {
for (final unsub in _unsubs) {
unsub();
}
super.dispose();
}
Future<void> _load() async {
setState(() {
_loadingJob = true;
_loadError = null;
});
try {
final job = await LabJobsRepository.instance.getJob(widget.jobId);
if (mounted) {
setState(() {
_job = job;
_loadingJob = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_loadError = e.toString();
_loadingJob = false;
});
}
}
}
void _loadFiles() {
setState(() {
_filesFuture = JobFilesRepository.instance.listForJob(widget.jobId);
});
}
void _loadHistory() {
setState(() {
_historyFuture = JobHistoryService.instance.listForJob(widget.jobId);
});
}
Future<void> _cancelJob(Job job) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('İşi İptal Et'),
content: const Text(
'Bu iş geri alınamaz şekilde iptal edilir. Onaylıyor musunuz?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Vazgeç')),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: AppColors.cancelled),
onPressed: () => Navigator.pop(ctx, true),
child: const Text('İptal Et'),
),
],
),
);
if (confirmed != true || !mounted) return;
setState(() => _isActing = true);
try {
final updated = await LabJobsRepository.instance.cancelJob(job.id, job);
if (mounted) {
setState(() {
_job = _job!.copyWith(status: updated.status);
_isActing = false;
});
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text('İş iptal edildi.')));
}
} catch (e) {
if (mounted) {
setState(() => _isActing = false);
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text('Hata: $e')));
}
}
}
Future<void> _acceptJob(Job job) async {
setState(() => _isActing = true);
try {
final updated = await LabJobsRepository.instance.acceptJob(job);
if (mounted) {
setState(() {
_job = updated.copyWith(
clinicName: job.clinicName, labName: job.labName);
_isActing = false;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('İş kabul edildi')),
);
}
} catch (e) {
if (mounted) {
setState(() => _isActing = false);
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text('Hata: $e')));
}
}
}
void _showHandToClinicSheet(Job job) {
_showAdaptive(
context,
_HandToClinicSheet(
job: job,
onDone: (Job updated) {
if (mounted) {
setState(() => _job = updated.copyWith(
clinicName: job.clinicName, labName: job.labName));
}
},
),
);
}
Color _statusColor(JobStatus status) {
return switch (status) {
JobStatus.pending => AppColors.pending,
JobStatus.inProgress => AppColors.inProgress,
JobStatus.sent => AppColors.accent,
JobStatus.delivered => AppColors.success,
JobStatus.cancelled => AppColors.cancelled,
};
}
Color _statusBg(JobStatus status) {
return switch (status) {
JobStatus.pending => AppColors.pendingBg,
JobStatus.inProgress => AppColors.inProgressBg,
JobStatus.sent => AppColors.inProgressBg,
JobStatus.delivered => AppColors.successBg,
JobStatus.cancelled => AppColors.cancelledBg,
};
}
String _formatDate(DateTime dt, {bool withTime = false}) {
final d =
'${dt.day.toString().padLeft(2, '0')}.${dt.month.toString().padLeft(2, '0')}.${dt.year}';
if (!withTime || (dt.hour == 0 && dt.minute == 0)) return d;
return '$d ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('İş Detayı'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.pop(),
),
),
body: _buildBody(),
);
}
Widget _buildBody() {
if (_loadingJob && _job == null) {
return const Center(
child: CircularProgressIndicator(color: AppColors.accent));
}
if (_loadError != null && _job == null) {
return Center(
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),
Text('Hata: $_loadError',
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'),
),
],
),
);
}
if (_job == null) return const SizedBox.shrink();
{
final job = _job!;
final membership = ref.read(authProvider).activeTenant;
final isDeliveryOnly = membership?.isDeliveryOnly ?? false;
final canCancelJobs = membership?.canCancelJobs ?? true;
final canSendToClinic = !isDeliveryOnly &&
job.status == JobStatus.inProgress &&
job.location == JobLocation.atLab;
final canAccept = !isDeliveryOnly && job.status == JobStatus.pending;
return ListView(
padding: const EdgeInsets.all(16),
children: [
// Header card
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.05),
blurRadius: 12,
offset: const Offset(0, 4))
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
job.patientName?.isNotEmpty == true
? job.patientName!
: job.patientCode,
style: Theme.of(context)
.textTheme
.headlineSmall
?.copyWith(
fontWeight: FontWeight.bold,
color: AppColors.textPrimary),
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 5),
decoration: BoxDecoration(
color: _statusBg(job.status),
borderRadius: BorderRadius.circular(10),
),
child: Text(
job.status.label,
style: TextStyle(
color: _statusColor(job.status),
fontWeight: FontWeight.w600,
fontSize: 13,
),
),
),
],
),
const SizedBox(height: 12),
_InfoRow(
icon: Icons.business,
label: 'Klinik',
value: job.clinicName ?? '-'),
if (job.patientName != null && job.patientName!.isNotEmpty)
_InfoRow(
icon: Icons.person_outline,
label: 'Hasta',
value: job.patientName!,
),
_InfoRow(
icon: Icons.tag_outlined,
label: 'Protokol No',
value: job.patientCode,
),
_InfoRow(
icon: Icons.medical_services_outlined,
label: 'Protez Tipi',
value: job.prostheticType.label),
if (job.prostheticName != null &&
job.prostheticName!.isNotEmpty)
_InfoRow(
icon: Icons.category_outlined,
label: 'Ürün',
value: job.prostheticName!,
),
if (job.workflowType != null)
_InfoRow(
icon: Icons.tune_rounded,
label: 'İş Tipi',
value: job.workflowType!.label,
),
_InfoRow(
icon: Icons.route_outlined,
label: 'Akış',
value: job.workflowPreset.title,
),
_InfoRow(
icon: Icons.fact_check_outlined,
label: 'Prova',
value: job.provaRequired ? 'Provalı' : 'Provasız',
),
_InfoRow(
icon: Icons.format_list_numbered,
label: 'Üye Sayısı',
value: '${job.memberCount} üye'),
if (job.color != null)
_InfoRow(
icon: Icons.color_lens_outlined,
label: 'Renk',
value: job.color!),
if (job.dueDate != null)
_InfoRow(
icon: Icons.calendar_today,
label: 'Teslim Tarihi',
value: _formatDate(job.dueDate!, withTime: true),
valueColor: job.dueDate!.isBefore(DateTime.now())
? AppColors.cancelled
: null),
_InfoRow(
icon: Icons.add_circle_outline,
label: 'Oluşturulma',
value: _formatDate(job.dateCreated)),
if (job.price != null && job.currency != null)
_InfoRow(
icon: Icons.attach_money,
label: 'Fiyat',
value:
'${job.price!.toStringAsFixed(2)} ${job.currency}'),
if (job.description != null && job.description!.isNotEmpty)
_InfoRow(
icon: Icons.notes,
label: 'Açıklama',
value: job.description!),
],
),
),
const SizedBox(height: 16),
// Stepper
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.05),
blurRadius: 12,
offset: const Offset(0, 4))
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'İş Adımları',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: AppColors.textPrimary),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: job.provaRequired
? AppColors.inProgressBg
: AppColors.successBg,
borderRadius: BorderRadius.circular(6),
),
child: Text(
job.provaRequired ? 'Provalı' : 'Provasız',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: job.provaRequired
? AppColors.inProgress
: AppColors.success,
),
),
),
],
),
const SizedBox(height: 16),
_JobStepper(
steps: job.stepTemplate,
currentStep: job.currentStep,
isDelivered: job.status == JobStatus.delivered,
historyFuture: _historyFuture,
),
],
),
),
const SizedBox(height: 24),
// Action buttons
if (_isActing)
const Padding(
padding: EdgeInsets.symmetric(vertical: 8),
child: Center(
child: CircularProgressIndicator(color: AppColors.accent)),
)
else ...[
if (canAccept)
FilledButton.icon(
onPressed: () => _acceptJob(job),
icon: const Icon(Icons.check_circle_outline),
label: const Text('Kabul Et'),
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(52),
backgroundColor: AppColors.success,
),
),
if (canSendToClinic)
FilledButton.icon(
onPressed: () => _showHandToClinicSheet(job),
icon: const Icon(Icons.send_outlined),
label: Text(
(job.isLastStep)
? 'Son Prova - Teslime Gönder'
: 'Prova için Kliniğe Gönder',
),
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(52),
backgroundColor: (job.isLastStep)
? AppColors.success
: AppColors.inProgress,
),
),
if (canCancelJobs && job.status == JobStatus.pending) ...[
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: () => _cancelJob(job),
icon: const Icon(Icons.close_rounded),
label: const Text('İşi İptal Et'),
style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(50),
foregroundColor: AppColors.cancelled,
side: const BorderSide(color: AppColors.cancelled),
),
),
],
],
const SizedBox(height: 20),
JobFilesPanel(
job: job,
filesFuture: _filesFuture,
onRefresh: _loadFiles,
),
const SizedBox(height: 16),
],
);
}
}
}
// ── Hand to Clinic Sheet ─────────────────────────────────────────────────────
class _HandToClinicSheet extends StatefulWidget {
const _HandToClinicSheet({required this.job, required this.onDone});
final Job job;
final void Function(Job updatedJob) onDone;
@override
State<_HandToClinicSheet> createState() => _HandToClinicSheetState();
}
class _HandToClinicSheetState extends State<_HandToClinicSheet> {
final _noteController = TextEditingController();
bool _sending = false;
@override
void dispose() {
_noteController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final isDesktop =
MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint;
final currentStep = widget.job.currentStep;
final isLast = widget.job.isLastStep;
final stepLabel = currentStep?.label ?? '';
final requiresClinicApproval = currentStep?.requiresClinicApproval ?? true;
final buttonLabel = isLast
? (widget.job.provaRequired
? 'Son Prova · Teslime Gönder'
: 'Teslime Gönder')
: requiresClinicApproval
? '$stepLabel için Kliniğe Gönder'
: '$stepLabel tamamlandı, sonraki adıma geç';
final buttonColor = isLast ? AppColors.success : AppColors.inProgress;
return Container(
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.vertical(
top: isDesktop ? Radius.zero : const Radius.circular(20),
),
),
padding: EdgeInsets.only(
left: 20,
right: 20,
top: 24,
bottom: isDesktop ? 24 : MediaQuery.of(context).viewInsets.bottom + 24,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
buttonLabel,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold, color: AppColors.textPrimary),
),
const SizedBox(height: 8),
Text(
isLast
? 'İş teslim edilecek olarak işaretlenecek.'
: requiresClinicApproval
? 'İş klinikteki prova veya onay için gönderilecek.'
: 'Bu iç adım tamamlanacak ve iş laboratuvarda ilerleyecek.',
style: const TextStyle(color: AppColors.textSecondary),
),
const SizedBox(height: 16),
TextField(
controller: _noteController,
decoration: const InputDecoration(
labelText: 'Not (isteğe bağlı)',
hintText: 'Klinik için not ekleyin...',
),
maxLines: 3,
),
const SizedBox(height: 16),
FilledButton(
onPressed: _sending
? null
: () async {
setState(() => _sending = true);
final navigator = Navigator.of(context);
final messenger = ScaffoldMessenger.of(context);
try {
final updated =
await LabJobsRepository.instance.handToClinic(
widget.job.id,
widget.job,
note: _noteController.text.trim().isEmpty
? null
: _noteController.text.trim(),
);
navigator.pop();
messenger.showSnackBar(
SnackBar(
content: Text(isLast
? 'İş teslim için gönderildi'
: requiresClinicApproval
? 'Onay için kliniğe gönderildi'
: 'İş bir sonraki iç adıma geçirildi')),
);
if (context.mounted) widget.onDone(updated);
} catch (e) {
if (context.mounted) {
setState(() => _sending = false);
messenger.showSnackBar(
SnackBar(content: Text('Hata: $e')),
);
}
}
},
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48),
backgroundColor: buttonColor,
),
child: _sending
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white),
)
: Text(buttonLabel),
),
],
),
);
}
}
// ── Info Row ─────────────────────────────────────────────────────────────────
class _InfoRow extends StatelessWidget {
const _InfoRow({
required this.icon,
required this.label,
required this.value,
this.valueColor,
});
final IconData icon;
final String label;
final String value;
final Color? valueColor;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 18, color: AppColors.textMuted),
const SizedBox(width: 10),
SizedBox(
width: 110,
child: Text(
label,
style:
const TextStyle(color: AppColors.textSecondary, fontSize: 13),
),
),
Expanded(
child: Text(
value,
style: TextStyle(
fontWeight: FontWeight.w500,
color: valueColor ?? AppColors.textPrimary,
fontSize: 14,
),
),
),
],
),
);
}
}
// ── Job Stepper ───────────────────────────────────────────────────────────────
class _JobStepper extends StatelessWidget {
const _JobStepper({
required this.steps,
required this.currentStep,
required this.isDelivered,
required this.historyFuture,
});
final List<JobStep> steps;
final JobStep? currentStep;
final bool isDelivered;
final Future<List<JobHistoryEntry>> historyFuture;
@override
Widget build(BuildContext context) {
return FutureBuilder<List<JobHistoryEntry>>(
future: historyFuture,
builder: (ctx, snap) {
final history = snap.data ?? [];
// Revizyon sayısı per adım
final Map<JobStep, int> revisionCounts = {};
final Map<JobStep, List<JobHistoryEntry>> notesByStep = {};
for (final e in history) {
if (e.action == JobHistoryAction.revisionRequested &&
e.step != null) {
revisionCounts[e.step!] = (revisionCounts[e.step!] ?? 0) + 1;
}
if (e.step != null && e.note != null && e.note!.trim().isNotEmpty) {
notesByStep.putIfAbsent(e.step!, () => []).add(e);
}
}
final currentIndex =
currentStep != null ? steps.indexOf(currentStep!) : -1;
return Column(
children: List.generate(steps.length, (i) {
final step = steps[i];
final isCompleted = isDelivered || i < currentIndex;
final isCurrent = !isDelivered && i == currentIndex;
final isLastItem = i == steps.length - 1;
final revCount = revisionCounts[step] ?? 0;
final stepNotes = notesByStep[step] ?? const <JobHistoryEntry>[];
Color dotColor;
IconData dotIcon;
if (isCompleted) {
dotColor = AppColors.success;
dotIcon = Icons.check_circle;
} else if (isCurrent) {
dotColor = AppColors.inProgress;
dotIcon = Icons.radio_button_checked;
} else {
dotColor = AppColors.muted;
dotIcon = Icons.radio_button_unchecked;
}
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Column(
children: [
Icon(dotIcon, color: dotColor, size: 24),
if (!isLastItem)
Container(
width: 2,
height: 44,
color: isDelivered || i < currentIndex
? AppColors.success.withValues(alpha: 0.35)
: AppColors.border,
),
],
),
const SizedBox(width: 12),
Expanded(
child: Padding(
padding: const EdgeInsets.only(top: 2, bottom: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
step.label,
style: TextStyle(
fontWeight: isCurrent
? FontWeight.bold
: FontWeight.normal,
color: isCompleted
? AppColors.success
: isCurrent
? AppColors.inProgress
: AppColors.textMuted,
fontSize: 15,
),
),
if (revCount > 0) ...[
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: AppColors.cancelledBg,
borderRadius: BorderRadius.circular(6),
),
child: Text(
'$revCount revizyon',
style: const TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
color: AppColors.cancelled,
),
),
),
],
],
),
if (isCurrent)
Text(
step.description,
style: const TextStyle(
fontSize: 12,
color: AppColors.textSecondary,
),
),
if (stepNotes.isNotEmpty) ...[
const SizedBox(height: 8),
...stepNotes
.map((entry) => _StepNoteCard(entry: entry)),
],
],
),
),
),
],
);
}),
);
},
);
}
}
class _StepNoteCard extends StatelessWidget {
const _StepNoteCard({required this.entry});
final JobHistoryEntry entry;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
margin: const EdgeInsets.only(top: 6),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
color: AppColors.surfaceVariant,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppColors.border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_label(entry.action),
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: AppColors.textSecondary,
),
),
const SizedBox(height: 4),
Text(
entry.note!,
style: const TextStyle(
fontSize: 12,
color: AppColors.textPrimary,
),
),
],
),
);
}
String _label(JobHistoryAction action) {
return switch (action) {
JobHistoryAction.revisionRequested => 'Revizyon Notu',
JobHistoryAction.stepCompleted => 'İç Adım Notu',
JobHistoryAction.handedToClinic => 'Laboratuvar Notu',
JobHistoryAction.approved => 'Onay Notu',
JobHistoryAction.delivered => 'Teslim Notu',
JobHistoryAction.accepted => 'Kabul Notu',
JobHistoryAction.cancelled => 'İptal Notu',
};
}
}