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

765 lines
27 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 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 {
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);
});
}
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.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 ?? '-'),
_InfoRow(
icon: Icons.medical_services_outlined,
label: 'Protez Tipi',
value: job.prostheticType.label),
_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,
historyFuture: JobHistoryService.instance
.listForJob(job.id),
),
],
),
),
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 isLast = widget.job.isLastStep;
final stepLabel = widget.job.currentStep?.label ?? '';
final buttonLabel = isLast
? (widget.job.provaRequired ? 'Son Prova · Teslime Gönder' : 'Teslime Gönder')
: '$stepLabel için Kliniğe Gönder';
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.'
: 'İş klinikteki prova için gönderilecek.',
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'
: 'Prova için klinik\'e gönderildi')),
);
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.historyFuture,
});
final List<JobStep> steps;
final JobStep? currentStep;
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 = {};
for (final e in history) {
if (e.action == JobHistoryAction.revisionRequested && e.step != null) {
revisionCounts[e.step!] = (revisionCounts[e.step!] ?? 0) + 1;
}
}
final currentIndex =
currentStep != null ? steps.indexOf(currentStep!) : -1;
return Column(
children: List.generate(steps.length, (i) {
final step = steps[i];
final isCompleted = i < currentIndex;
final isCurrent = i == currentIndex;
final isLastItem = i == steps.length - 1;
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 (!isLastItem)
Container(
width: 2,
height: 44,
color: 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,
),
),
],
),
),
),
],
);
}),
);
},
);
}
}