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

571 lines
17 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/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,
),
),
);
}
}