Initial commit — DLS lab-app Flutter project

This commit is contained in:
egecankomur
2026-06-10 23:22:15 +03:00
commit d1acc1d367
225 changed files with 31294 additions and 0 deletions
@@ -0,0 +1,749 @@
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;
}
}
}
@@ -0,0 +1,177 @@
import 'dart:async';
import 'package:pocketbase/pocketbase.dart';
import '../../../core/api/pocketbase_client.dart';
import '../../../core/services/job_history_service.dart';
import '../../../models/job.dart';
const _listExpand = 'clinic_tenant_id,lab_tenant_id';
const _detailExpand = 'clinic_tenant_id,lab_tenant_id,patient_id,prosthetic_id';
class ClinicJobsRepository {
ClinicJobsRepository._();
static final instance = ClinicJobsRepository._();
PocketBase get _pb => PocketBaseClient.instance.pb;
Future<List<Job>> listOutbound(
String clinicTenantId, {
List<String>? statuses,
String? location,
String? filterExtra,
int page = 1,
int limit = 30,
}) async {
final filterParts = ['clinic_tenant_id = "$clinicTenantId"'];
if (statuses != null && statuses.isNotEmpty) {
final statusFilter = statuses.map((s) => 'status = "$s"').join(' || ');
filterParts.add('($statusFilter)');
}
if (location != null) {
filterParts.add('location = "$location"');
}
if (filterExtra != null) {
filterParts.add('($filterExtra)');
}
final result = await _pb.collection('jobs').getList(
page: page,
perPage: limit,
filter: filterParts.join(' && '),
expand: _listExpand,
);
return (result.items.map((r) => Job.fromJson(r.toJson())).toList()
..sort((a, b) => b.dateCreated.compareTo(a.dateCreated)));
}
Future<Job> getJob(String jobId) async {
final record = await _pb.collection('jobs').getOne(jobId, expand: _detailExpand);
return Job.fromJson(record.toJson());
}
Future<Job> createJob({
required String clinicTenantId,
required String labTenantId,
required String patientCode,
required String prostheticId,
required ProstheticType prostheticType,
required List<String> teeth,
String? patientId,
String? color,
String? description,
String? dueDate,
bool provaRequired = true,
}) async {
final record = await _pb.collection('jobs').create(body: {
'clinic_tenant_id': clinicTenantId,
'lab_tenant_id': labTenantId,
'patient_code': patientCode,
if (patientId != null) 'patient_id': patientId,
'prosthetic_id': prostheticId,
'prosthetic_type': prostheticType.value,
'member_count': teeth.length,
'teeth': teeth,
if (color != null) 'color': color,
if (description != null) 'description': description,
if (dueDate != null) 'due_date': dueDate,
'status': 'pending',
'location': 'at_clinic',
'prova_required': provaRequired,
});
return Job.fromJson(record.toJson());
}
Future<Job> approveAtClinic(String jobId, Job job, {String? note}) async {
final nextStep = job.nextStep;
if (nextStep == null) throw Exception('Bu aşamadan ileri gidilemez.');
final record = await _pb.collection('jobs').update(jobId, body: {
'current_step': nextStep.value,
'location': 'at_lab',
});
final updated = Job.fromJson(record.toJson());
unawaited(JobHistoryService.instance.append(
jobId: jobId,
clinicTenantId: job.clinicTenantId,
labTenantId: job.labTenantId,
action: JobHistoryAction.approved,
step: job.currentStep,
note: note,
));
return updated;
}
Future<Job> requestRevision(String jobId, Job job, {required String note}) async {
final record = await _pb.collection('jobs').update(jobId, body: {
'location': 'at_lab',
});
final updated = Job.fromJson(record.toJson());
unawaited(JobHistoryService.instance.append(
jobId: jobId,
clinicTenantId: job.clinicTenantId,
labTenantId: job.labTenantId,
action: JobHistoryAction.revisionRequested,
step: job.currentStep,
note: note,
));
return updated;
}
Future<Job> markDelivered(String jobId, Job job, {String? note}) async {
final record = await _pb.collection('jobs').update(jobId, body: {
'status': 'delivered',
});
unawaited(JobHistoryService.instance.append(
jobId: jobId,
clinicTenantId: job.clinicTenantId,
labTenantId: job.labTenantId,
action: JobHistoryAction.delivered,
note: note,
));
return Job.fromJson(record.toJson());
}
Future<Job> cancelJob(String jobId, Job job) async {
final record = await _pb.collection('jobs').update(jobId, body: {
'status': 'cancelled',
});
unawaited(JobHistoryService.instance.append(
jobId: jobId,
clinicTenantId: job.clinicTenantId,
labTenantId: job.labTenantId,
action: JobHistoryAction.cancelled,
));
return Job.fromJson(record.toJson());
}
Future<List<Map<String, dynamic>>> listApprovedLabs(String clinicTenantId) async {
final result = await _pb.collection('connections').getList(
filter: 'clinic_tenant_id = "$clinicTenantId" && status = "approved"',
expand: 'lab_tenant_id',
perPage: 100,
);
return result.items.map((r) {
final expand = r.toJson()['expand'] as Map<String, dynamic>?;
return expand?['lab_tenant_id'] as Map<String, dynamic>? ?? {'id': r.data['lab_tenant_id']};
}).toList();
}
Future<List<Job>> listJobsByPatient(String patientId, {int limit = 50}) async {
final result = await _pb.collection('jobs').getList(
filter: 'patient_id = "$patientId"',
perPage: limit,
expand: _listExpand,
);
return (result.items.map((r) => Job.fromJson(r.toJson())).toList()
..sort((a, b) => b.dateCreated.compareTo(a.dateCreated)));
}
Future<int> countDelivered(String clinicTenantId, {DateTime? from, DateTime? to}) async {
final parts = ['clinic_tenant_id = "$clinicTenantId"', 'status = "delivered"'];
if (from != null) parts.add('updated >= "${_date(from)}"');
if (to != null) parts.add('updated < "${_date(to)}"');
final r = await _pb.collection('jobs').getList(perPage: 1, filter: parts.join(' && '));
return r.totalItems;
}
static String _date(DateTime d) => d.toIso8601String().split('T').first;
}
@@ -0,0 +1,570 @@
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/router/app_router.dart';
import '../../../core/services/realtime_service.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/gradient_app_bar.dart';
import '../../../core/widgets/pill_tabs.dart';
import '../../../models/job.dart';
import 'clinic_jobs_repository.dart';
enum _JobSort { newestFirst, oldestFirst, byDueDate, byType }
const _kSortLabels = [
'Yeniden Eskiye',
'Eskiden Yeniye',
'Vade Tarihine Göre',
'Türe Göre',
];
class ClinicJobsScreen extends ConsumerStatefulWidget {
const ClinicJobsScreen({super.key});
@override
ConsumerState<ClinicJobsScreen> createState() => _ClinicJobsScreenState();
}
class _ClinicJobsScreenState extends ConsumerState<ClinicJobsScreen>
with SingleTickerProviderStateMixin {
late TabController _tabController;
final _searchController = TextEditingController();
String _searchQuery = '';
_JobSort _sort = _JobSort.newestFirst;
@override
void initState() {
super.initState();
_tabController = TabController(length: 5, vsync: this);
_tabController.addListener(() {
if (mounted) setState(() {});
});
}
@override
void dispose() {
_tabController.dispose();
_searchController.dispose();
super.dispose();
}
void _onSearchChanged(String value) {
setState(() => _searchQuery = value);
}
Future<void> _showSortOptions() async {
final result = await showSortSheet(
context,
title: 'Sıralama',
options: _kSortLabels,
current: _sort.index,
);
if (result != null) {
setState(() => _sort = _JobSort.values[result]);
}
}
@override
Widget build(BuildContext context) {
final isSortActive = _sort != _JobSort.newestFirst;
return Scaffold(
backgroundColor: AppColors.background,
appBar: GradientAppBar(
title: 'İşlerim',
category: 'KLİNİK',
searchController: _searchController,
onSearchChanged: _onSearchChanged,
searchHint: 'Protokol, laboratuvar veya tür ara...',
actions: [
IconButton(
onPressed: _showSortOptions,
tooltip: 'Sırala',
icon: Badge(
isLabelVisible: isSortActive,
smallSize: 8,
backgroundColor: AppColors.accent,
child: const Icon(Icons.sort_rounded),
),
),
if (ref.watch(authProvider).activeTenant?.canCreateJobs ?? true)
IconButton(
onPressed: () => context.push(routeClinicJobNew),
tooltip: 'Yeni İş',
icon: const Icon(Icons.add_rounded),
),
],
),
body: Column(
children: [
PillTabs(
tabs: const ['Tümü', 'Onay Bekleyen', 'Lab\'da', 'Teslimat', 'Teslim Alındı'],
selected: _tabController.index,
onSelect: (i) => _tabController.animateTo(i),
),
Expanded(
child: TabBarView(
controller: _tabController,
children: [
_JobsTab(
statuses: const ['pending', 'in_progress', 'sent', 'delivered'],
searchQuery: _searchQuery,
sort: _sort,
),
_JobsTab(
statuses: const ['in_progress'],
location: 'at_clinic',
searchQuery: _searchQuery,
sort: _sort,
),
_JobsTab(
filterExtra: 'status = "pending" || (status = "in_progress" && location = "at_lab")',
searchQuery: _searchQuery,
sort: _sort,
),
_JobsTab(
statuses: const ['sent'],
searchQuery: _searchQuery,
sort: _sort,
),
_JobsTab(
statuses: const ['delivered'],
searchQuery: _searchQuery,
sort: _sort,
),
],
),
),
],
),
);
}
}
class _JobsTab extends ConsumerStatefulWidget {
const _JobsTab({
this.statuses,
this.location,
this.filterExtra,
required this.searchQuery,
required this.sort,
});
final List<String>? statuses;
final String? location;
final String? filterExtra;
final String searchQuery;
final _JobSort sort;
@override
ConsumerState<_JobsTab> createState() => _JobsTabState();
}
class _JobsTabState extends ConsumerState<_JobsTab> {
final List<Job> _jobs = [];
bool _isLoading = false;
bool _hasMore = true;
int _page = 1;
static const _limit = 20;
String? _error;
late UnsubFn _unsub;
final _scrollController = ScrollController();
@override
void initState() {
super.initState();
_load();
_scrollController.addListener(_onScroll);
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();
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200 &&
!_isLoading &&
_hasMore) {
_loadMore();
}
}
Future<void> _load() async {
if (_isLoading) return;
setState(() {
_isLoading = true;
_error = null;
_page = 1;
_jobs.clear();
_hasMore = true;
});
await _fetch();
}
Future<void> _loadMore() async {
if (_isLoading || !_hasMore) return;
_page++;
await _fetch();
}
Future<void> _fetch() async {
setState(() => _isLoading = true);
try {
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
final results = await ClinicJobsRepository.instance.listOutbound(
tenantId,
statuses: widget.statuses,
location: widget.location,
filterExtra: widget.filterExtra,
page: _page,
limit: _limit,
);
setState(() {
_jobs.addAll(results);
_hasMore = results.length == _limit;
_isLoading = false;
});
} catch (e) {
setState(() {
_error = e.toString();
_isLoading = false;
});
}
}
List<Job> get _filtered {
var list = _jobs.toList();
final q = widget.searchQuery.toLowerCase().trim();
if (q.isNotEmpty) {
list = list.where((j) {
return j.patientCode.toLowerCase().contains(q) ||
(j.labName?.toLowerCase().contains(q) ?? false) ||
j.prostheticType.label.toLowerCase().contains(q);
}).toList();
}
switch (widget.sort) {
case _JobSort.newestFirst:
list.sort((a, b) => b.dateCreated.compareTo(a.dateCreated));
case _JobSort.oldestFirst:
list.sort((a, b) => a.dateCreated.compareTo(b.dateCreated));
case _JobSort.byDueDate:
list.sort((a, b) {
if (a.dueDate == null && b.dueDate == null) return 0;
if (a.dueDate == null) return 1;
if (b.dueDate == null) return -1;
return a.dueDate!.compareTo(b.dueDate!);
});
case _JobSort.byType:
list.sort(
(a, b) => a.prostheticType.label.compareTo(b.prostheticType.label));
}
return list;
}
@override
Widget build(BuildContext context) {
if (_error != null && _jobs.isEmpty) {
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: $_error',
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 (_isLoading && _jobs.isEmpty) {
return const Center(
child: CircularProgressIndicator(color: AppColors.accent));
}
final filtered = _filtered;
if (filtered.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
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),
Text(
widget.searchQuery.isNotEmpty
? 'Sonuç bulunamadı'
: 'Henüz iş yok',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary),
),
],
),
);
}
return RefreshIndicator(
color: AppColors.accent,
onRefresh: _load,
child: ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
itemCount:
filtered.length + (_hasMore && widget.searchQuery.isEmpty ? 1 : 0),
itemBuilder: (context, index) {
if (index == filtered.length) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(
child: CircularProgressIndicator(color: AppColors.accent)),
);
}
final job = filtered[index];
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: _JobListCard(
job: job,
onTap: () => context.push('/clinic/jobs/${job.id}'),
),
);
},
),
);
}
}
class _JobListCard extends StatelessWidget {
const _JobListCard({required this.job, required this.onTap});
final Job job;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final statusColor = _statusColor(job);
final statusBg = _statusBg(job);
final isOverdue =
job.dueDate != null && job.dueDate!.isBefore(DateTime.now());
return Semantics(
label: job.patientCode,
button: true,
excludeSemantics: true,
child: Material(
color: AppColors.surface,
borderRadius: BorderRadius.circular(14),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(14),
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.border),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.04),
blurRadius: 8,
offset: const Offset(0, 2))
]),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: statusBg,
borderRadius: BorderRadius.circular(12)),
child: Icon(Icons.medical_services_outlined,
color: statusColor, size: 20),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
job.patientCode,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary),
),
),
_StatusBadge(status: job.status, location: job.location),
],
),
const SizedBox(height: 3),
Text(job.prostheticType.label,
style: const TextStyle(
fontSize: 12, color: AppColors.textSecondary)),
if (job.labName != null) ...[
const SizedBox(height: 2),
Text(
job.labName!,
style: const TextStyle(
fontSize: 12, color: AppColors.textMuted),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
if (job.dueDate != null) ...[
const SizedBox(height: 2),
Row(
children: [
Icon(Icons.calendar_today_outlined,
size: 11,
color: isOverdue
? AppColors.cancelled
: AppColors.textMuted),
const SizedBox(width: 3),
Text(
_fmt(job.dueDate!),
style: TextStyle(
fontSize: 12,
color: isOverdue
? AppColors.cancelled
: AppColors.textMuted,
fontWeight: isOverdue
? FontWeight.w600
: FontWeight.normal,
),
),
],
),
],
],
),
),
const SizedBox(width: 8),
const Icon(Icons.chevron_right,
color: AppColors.textMuted, size: 20),
],
),
),
),
),
);
}
String _fmt(DateTime d) =>
'${d.day.toString().padLeft(2, '0')}.${d.month.toString().padLeft(2, '0')}.${d.year}';
Color _statusColor(Job job) {
if (job.status == JobStatus.inProgress && job.location == JobLocation.atClinic) return AppColors.pending;
switch (job.status) {
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(Job job) {
if (job.status == JobStatus.inProgress && job.location == JobLocation.atClinic) return AppColors.pendingBg;
switch (job.status) {
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 _StatusBadge extends StatelessWidget {
const _StatusBadge({required this.status, required this.location});
final JobStatus status;
final JobLocation location;
String get _label {
if (status == JobStatus.inProgress && location == JobLocation.atClinic) return 'Onay Bekliyor';
if (status == JobStatus.sent) return 'Teslimat Bekliyor';
return status.label;
}
Color get _color {
if (status == JobStatus.inProgress && location == JobLocation.atClinic) return AppColors.pending;
switch (status) {
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 get _bg {
if (status == JobStatus.inProgress && location == JobLocation.atClinic) return AppColors.pendingBg;
switch (status) {
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;
}
}
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: _bg,
borderRadius: BorderRadius.circular(8),
),
child: Text(
_label,
style: TextStyle(
color: _color,
fontSize: 11,
fontWeight: FontWeight.w600,
),
),
);
}
}
File diff suppressed because it is too large Load Diff