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

750 lines
24 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 '../../../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 'clinic_jobs_repository.dart';
class ClinicJobDetailScreen extends ConsumerStatefulWidget {
const ClinicJobDetailScreen({super.key, required this.jobId});
final String jobId;
@override
ConsumerState<ClinicJobDetailScreen> createState() =>
_ClinicJobDetailScreenState();
}
class _ClinicJobDetailScreenState
extends ConsumerState<ClinicJobDetailScreen> {
Job? _job;
String? _loadError;
late Future<List<JobFile>> _filesFuture;
bool _isActing = false;
late UnsubFn _unsub;
@override
void initState() {
super.initState();
_load();
_loadFiles();
_unsub = RealtimeService.instance.watch(
'jobs',
topic: widget.jobId,
onEvent: (_) { if (mounted && !_isActing) _load(); },
);
}
@override
void dispose() {
_unsub();
super.dispose();
}
Future<void> _load() async {
if (mounted) setState(() { _loadError = null; });
try {
final job = await ClinicJobsRepository.instance.getJob(widget.jobId);
if (mounted) setState(() { _job = job; });
} catch (e) {
if (mounted) setState(() { _loadError = e.toString(); });
}
}
void _loadFiles() {
setState(() {
_filesFuture = JobFilesRepository.instance.listForJob(widget.jobId);
});
}
Future<void> _approve(Job job) async {
setState(() => _isActing = true);
try {
final updated = await ClinicJobsRepository.instance.approveAtClinic(job.id, job);
if (mounted) {
setState(() { _job = updated.copyWith(clinicName: job.clinicName, labName: job.labName); _isActing = false; });
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('İş onaylandı.')),
);
}
} catch (e) {
if (mounted) {
setState(() => _isActing = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Hata: $e')),
);
}
}
}
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 ClinicJobsRepository.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> _requestRevision(Job job) async {
final noteController = TextEditingController();
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Revizyon Talebi'),
content: TextField(
controller: noteController,
decoration: const InputDecoration(
labelText: 'Açıklama',
hintText: 'Revizyon sebebini belirtin...',
),
minLines: 3,
maxLines: 5,
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('İptal'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Gönder'),
),
],
),
);
if (confirmed != true || !mounted) return;
if (noteController.text.trim().isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Lütfen bir açıklama girin.')),
);
return;
}
setState(() => _isActing = true);
try {
final updated = await ClinicJobsRepository.instance.requestRevision(
job.id,
job,
note: noteController.text.trim(),
);
if (mounted) {
setState(() { _job = updated.copyWith(clinicName: job.clinicName, labName: job.labName); _isActing = false; });
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Revizyon talebi gönderildi.')),
);
}
} catch (e) {
if (mounted) {
setState(() => _isActing = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Hata: $e')),
);
}
}
}
Future<void> _markDelivered(Job job) async {
final noteCtrl = TextEditingController();
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Teslim Alındı'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Bu işi teslim aldığınızı onaylıyor musunuz?'),
const SizedBox(height: 12),
TextField(
controller: noteCtrl,
decoration: const InputDecoration(
labelText: 'Teslimat notu (isteğe bağlı)',
hintText: 'Teslim eden kişi, durum vb...',
isDense: true,
),
maxLines: 2,
),
],
),
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('Teslim Alındı'),
),
],
),
);
if (confirmed != true || !mounted) return;
setState(() => _isActing = true);
try {
final note = noteCtrl.text.trim().isNotEmpty ? noteCtrl.text.trim() : null;
final updated = await ClinicJobsRepository.instance.markDelivered(job.id, job, note: note);
if (mounted) {
setState(() { _job = updated.copyWith(clinicName: job.clinicName, labName: job.labName); _isActing = false; });
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('İş teslim alındı olarak işaretlendi.')),
);
}
} catch (e) {
if (mounted) {
setState(() => _isActing = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Hata: $e')),
);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.background,
appBar: AppBar(title: const Text('İş Detayı')),
body: _buildBody(),
);
}
Widget _buildBody() {
if (_job == null && _loadError == 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 Center(child: CircularProgressIndicator(color: AppColors.accent));
final job = _job!;
final membership = ref.read(authProvider).activeTenant;
final canDeliver = membership?.canDeliverJobs ?? true;
final canCancel = membership?.canCancelJobs ?? true;
final canManage = !(membership?.isDeliveryOnly ?? false);
return _JobDetailBody(
job: job,
filesFuture: _filesFuture,
isActing: _isActing,
canDeliver: canDeliver,
canManage: canManage,
onApprove: canManage ? () => _approve(job) : () {},
onRevision: canManage ? () => _requestRevision(job) : () {},
onDelivered: () => _markDelivered(job),
onCancel: (canCancel && job.status == JobStatus.pending) ? () => _cancelJob(job) : null,
onFilesRefresh: _loadFiles,
);
}
}
class _JobDetailBody extends StatelessWidget {
const _JobDetailBody({
required this.job,
required this.filesFuture,
required this.isActing,
required this.canDeliver,
required this.canManage,
required this.onApprove,
required this.onRevision,
required this.onDelivered,
required this.onFilesRefresh,
this.onCancel,
});
final Job job;
final Future<List<JobFile>> filesFuture;
final bool isActing;
final bool canDeliver;
final bool canManage;
final VoidCallback onApprove;
final VoidCallback onRevision;
final VoidCallback onDelivered;
final VoidCallback? onCancel;
final VoidCallback onFilesRefresh;
@override
Widget build(BuildContext context) {
final steps = job.stepTemplate;
final currentStepIndex =
job.currentStep != null ? steps.indexOf(job.currentStep!) : -1;
final canApproveOrRevise = canManage &&
job.location == JobLocation.atClinic &&
job.status == JobStatus.inProgress;
final canMarkDelivered = canDeliver && job.status == JobStatus.sent;
return ListView(
padding: const EdgeInsets.all(16),
children: [
// Info 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: [
// Patient code + status
Row(
children: [
Expanded(
child: Text(
job.patientCode,
style: Theme.of(context).textTheme.headlineSmall
?.copyWith(
fontWeight: FontWeight.bold,
color: AppColors.textPrimary),
),
),
_StatusBadge(status: job.status),
],
),
const SizedBox(height: 16),
const Divider(height: 1, color: AppColors.border),
const SizedBox(height: 12),
// Patient + Lab
_SectionLabel(title: 'Hasta & Laboratuvar'),
_InfoRow(label: 'Protokol No', value: job.patientCode),
if (job.patientId != null)
_InfoRow(label: 'Hasta ID', value: job.patientId!),
_InfoRow(
label: 'Laboratuvar', value: job.labName ?? 'Bilinmiyor'),
const SizedBox(height: 12),
// Prosthetic
_SectionLabel(title: 'Protez Bilgisi'),
_InfoRow(label: 'Tür', value: job.prostheticType.label),
_InfoRow(label: 'Üye Sayısı', value: '${job.memberCount}'),
if (job.teeth.isNotEmpty)
_InfoRow(label: 'Dişler', value: job.teeth.join(', ')),
if (job.color != null && job.color!.isNotEmpty)
_InfoRow(label: 'Renk', value: job.color!),
if (job.description != null && job.description!.isNotEmpty)
_InfoRow(label: 'Açıklama', value: job.description!),
if (job.dueDate != null)
_InfoRow(label: 'Son Tarih', value: _formatDate(job.dueDate!, withTime: true)),
if (job.price != null)
_InfoRow(
label: 'Fiyat',
value:
'${job.price!.toStringAsFixed(2)} ${job.currency ?? 'TRY'}'),
],
),
),
const SizedBox(height: 16),
// Stepper 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: [
const Text(
'İş Adımları',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary),
),
const SizedBox(height: 16),
_StepperWidget(
steps: steps,
currentStepIndex: currentStepIndex,
historyFuture: JobHistoryService.instance.listForJob(job.id),
),
],
),
),
const SizedBox(height: 24),
// Action buttons
if (isActing)
const Center(
child: CircularProgressIndicator(color: AppColors.accent))
else if (canApproveOrRevise) ...[
FilledButton.icon(
onPressed: onApprove,
icon: const Icon(Icons.check_circle_outline),
label: const Text('Onayla'),
style: FilledButton.styleFrom(
minimumSize: const Size(double.infinity, 50),
backgroundColor: AppColors.success,
),
),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: onRevision,
icon: const Icon(Icons.replay_outlined),
label: const Text('Revizyon İste'),
style: OutlinedButton.styleFrom(
minimumSize: const Size(double.infinity, 50),
foregroundColor: AppColors.pending,
side: const BorderSide(color: AppColors.pending),
),
),
] else if (canMarkDelivered)
FilledButton.icon(
onPressed: onDelivered,
icon: const Icon(Icons.inventory_2_outlined),
label: const Text('Teslim Aldım'),
style: FilledButton.styleFrom(
minimumSize: const Size(double.infinity, 50),
),
),
if (onCancel != null) ...[
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: onCancel,
icon: const Icon(Icons.close_rounded),
label: const Text('İşi İptal Et'),
style: OutlinedButton.styleFrom(
minimumSize: const Size(double.infinity, 50),
foregroundColor: AppColors.cancelled,
side: const BorderSide(color: AppColors.cancelled),
),
),
],
const SizedBox(height: 20),
// Files panel
JobFilesPanel(
job: job,
filesFuture: filesFuture,
onRefresh: onFilesRefresh,
),
const SizedBox(height: 12),
Text(
'Oluşturulma: ${_formatDate(job.dateCreated)}',
style: const TextStyle(fontSize: 12, color: AppColors.textMuted),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
],
);
}
String _formatDate(DateTime d, {bool withTime = false}) {
final s = '${d.day.toString().padLeft(2, '0')}.${d.month.toString().padLeft(2, '0')}.${d.year}';
if (!withTime || (d.hour == 0 && d.minute == 0)) return s;
return '$s ${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}';
}
}
class _StepperWidget extends StatelessWidget {
const _StepperWidget({
required this.steps,
required this.currentStepIndex,
required this.historyFuture,
});
final List<JobStep> steps;
final int currentStepIndex;
final Future<List<JobHistoryEntry>> historyFuture;
@override
Widget build(BuildContext context) {
return FutureBuilder<List<JobHistoryEntry>>(
future: historyFuture,
builder: (ctx, snap) {
final history = snap.data ?? [];
final Map<JobStep, int> revisionCounts = {};
for (final e in history) {
if (e.action == JobHistoryAction.revisionRequested && e.step != null) {
revisionCounts[e.step!] = (revisionCounts[e.step!] ?? 0) + 1;
}
}
return Column(
children: steps.asMap().entries.map((entry) {
final index = entry.key;
final step = entry.value;
final isCompleted = index < currentStepIndex;
final isCurrent = index == currentStepIndex;
final revCount = revisionCounts[step] ?? 0;
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 (index < steps.length - 1)
Container(
width: 2,
height: 44,
color: index < currentStepIndex
? 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,
),
),
],
),
),
),
],
);
}).toList(),
);
},
);
}
}
class _SectionLabel extends StatelessWidget {
const _SectionLabel({required this.title});
final String title;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Text(
title,
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: AppColors.accent,
letterSpacing: 0.5),
),
);
}
}
class _InfoRow extends StatelessWidget {
const _InfoRow({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 110,
child: Text(
label,
style: const TextStyle(
fontSize: 13, color: AppColors.textSecondary),
),
),
Expanded(
child: Text(
value,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.textPrimary),
),
),
],
),
);
}
}
class _StatusBadge extends StatelessWidget {
const _StatusBadge({required this.status});
final JobStatus status;
@override
Widget build(BuildContext context) {
final color = _color(status);
final bg = _bg(status);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(10),
),
child: Text(
status.label,
style: TextStyle(
color: color,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
);
}
Color _color(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 _bg(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;
}
}
}