8bbc9dbff2
- Flutter + PocketBase dental lab management system - Clinic & lab dashboards, job tracking, patient management - Product catalog, finance tracking, multi-language support - AI assistant integration, realtime notifications - Windows installer (Inno Setup) included - Developed by kovakyazilim.com
897 lines
32 KiB
Dart
897 lines
32 KiB
Dart
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 '../../../core/widgets/gradient_app_bar.dart';
|
||
import '../../../core/widgets/pill_tabs.dart';
|
||
import '../../../models/job.dart';
|
||
import 'lab_jobs_repository.dart';
|
||
|
||
enum _JobSort { newestFirst, oldestFirst, byDueDate, byType }
|
||
|
||
const _kSortLabels = [
|
||
'Yeniden Eskiye',
|
||
'Eskiden Yeniye',
|
||
'Vade Tarihine Göre',
|
||
'Türe Göre',
|
||
];
|
||
|
||
class LabAllJobsScreen extends ConsumerStatefulWidget {
|
||
const LabAllJobsScreen({super.key});
|
||
|
||
@override
|
||
ConsumerState<LabAllJobsScreen> createState() => _LabAllJobsScreenState();
|
||
}
|
||
|
||
class _LabAllJobsScreenState extends ConsumerState<LabAllJobsScreen>
|
||
with SingleTickerProviderStateMixin {
|
||
late TabController _tabController;
|
||
final _searchController = TextEditingController();
|
||
String _searchQuery = '';
|
||
_JobSort _sort = _JobSort.newestFirst;
|
||
bool _bulkAccepting = false;
|
||
final Map<String, int?> _counts = {
|
||
'all': null,
|
||
'pending': null,
|
||
'in_progress': null,
|
||
'sent': null,
|
||
'delivered': null,
|
||
};
|
||
final _pendingTabKey = GlobalKey<_PendingJobsTabState>();
|
||
|
||
// null entry = Tümü (bütün statüsler)
|
||
static const List<String?> _statuses = [null, 'pending', 'in_progress', 'sent', 'delivered'];
|
||
static const _tabLabels = ['Tümü', 'Onay Bekleyen', 'Devam Eden', 'Gönderildi', 'Teslim Edildi'];
|
||
String _countKey(String? s) => s ?? 'all';
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
final isDelivery = ref.read(authProvider).activeTenant?.isDeliveryOnly ?? false;
|
||
_tabController = TabController(length: 5, vsync: this, initialIndex: isDelivery ? 3 : 0);
|
||
_tabController.addListener(() {
|
||
if (mounted) setState(() {});
|
||
});
|
||
_fetchAllCounts();
|
||
}
|
||
|
||
Future<void> _fetchAllCounts() async {
|
||
final tenantId = ref.read(authProvider).activeTenant?.tenant.id;
|
||
if (tenantId == null) return;
|
||
final results = await Future.wait(
|
||
_statuses.map((s) => LabJobsRepository.instance.countByStatus(tenantId, s)),
|
||
);
|
||
if (!mounted) return;
|
||
setState(() {
|
||
for (var i = 0; i < _statuses.length; i++) {
|
||
_counts[_countKey(_statuses[i])] = results[i];
|
||
}
|
||
});
|
||
}
|
||
|
||
@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]);
|
||
}
|
||
}
|
||
|
||
Future<void> _bulkAccept() async {
|
||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||
setState(() => _bulkAccepting = true);
|
||
try {
|
||
await LabJobsRepository.instance.bulkAcceptPending(tenantId);
|
||
_pendingTabKey.currentState?._load();
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(content: Text('Tüm işler kabul edildi')),
|
||
);
|
||
}
|
||
} catch (e) {
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text('Hata: $e')),
|
||
);
|
||
}
|
||
} finally {
|
||
if (mounted) setState(() => _bulkAccepting = false);
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final isSortActive = _sort != _JobSort.newestFirst;
|
||
final onPendingTab = _tabController.index == 1;
|
||
final pendingCount = _counts['pending'];
|
||
|
||
return Scaffold(
|
||
backgroundColor: AppColors.background,
|
||
appBar: GradientAppBar(
|
||
title: 'İşler',
|
||
category: 'LABORATUVAR',
|
||
searchController: _searchController,
|
||
onSearchChanged: _onSearchChanged,
|
||
searchHint: 'Protokol, klinik veya tür ara...',
|
||
actions: [
|
||
if (!onPendingTab)
|
||
IconButton(
|
||
onPressed: _showSortOptions,
|
||
tooltip: 'Sırala',
|
||
icon: Badge(
|
||
isLabelVisible: isSortActive,
|
||
smallSize: 8,
|
||
backgroundColor: AppColors.accent,
|
||
child: const Icon(Icons.sort_rounded),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
floatingActionButton: onPendingTab && (pendingCount == null || pendingCount > 0)
|
||
? FloatingActionButton.extended(
|
||
onPressed: _bulkAccepting ? null : _bulkAccept,
|
||
icon: _bulkAccepting
|
||
? const SizedBox(
|
||
width: 18,
|
||
height: 18,
|
||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
||
)
|
||
: const Icon(Icons.done_all),
|
||
label: Text(_bulkAccepting ? 'Kabul ediliyor...' : 'Tümünü Kabul Et'),
|
||
backgroundColor: AppColors.pending,
|
||
foregroundColor: Colors.white,
|
||
)
|
||
: null,
|
||
body: Column(
|
||
children: [
|
||
PillTabs(
|
||
tabs: _tabLabels,
|
||
selected: _tabController.index,
|
||
onSelect: (i) => _tabController.animateTo(i),
|
||
counts: _statuses.map((s) => _counts[_countKey(s)]).toList(),
|
||
),
|
||
Expanded(
|
||
child: TabBarView(
|
||
controller: _tabController,
|
||
children: [
|
||
_LabJobsTab(
|
||
status: null,
|
||
searchQuery: _searchQuery,
|
||
sort: _sort,
|
||
onCountLoaded: (c) => setState(() => _counts['all'] = c),
|
||
),
|
||
_PendingJobsTab(
|
||
key: _pendingTabKey,
|
||
searchQuery: _searchQuery,
|
||
onCountLoaded: (c) => setState(() => _counts['pending'] = c),
|
||
),
|
||
_LabJobsTab(
|
||
status: 'in_progress',
|
||
searchQuery: _searchQuery,
|
||
sort: _sort,
|
||
onCountLoaded: (c) => setState(() => _counts['in_progress'] = c),
|
||
),
|
||
_LabJobsTab(
|
||
status: 'sent',
|
||
searchQuery: _searchQuery,
|
||
sort: _sort,
|
||
onCountLoaded: (c) => setState(() => _counts['sent'] = c),
|
||
),
|
||
_LabJobsTab(
|
||
status: 'delivered',
|
||
searchQuery: _searchQuery,
|
||
sort: _sort,
|
||
onCountLoaded: (c) => setState(() => _counts['delivered'] = c),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Pending (Onay Bekleyen) tab ───────────────────────────────────────────────
|
||
|
||
class _PendingJobsTab extends ConsumerStatefulWidget {
|
||
const _PendingJobsTab({super.key, required this.searchQuery, this.onCountLoaded});
|
||
final String searchQuery;
|
||
final void Function(int)? onCountLoaded;
|
||
|
||
@override
|
||
ConsumerState<_PendingJobsTab> createState() => _PendingJobsTabState();
|
||
}
|
||
|
||
class _PendingJobsTabState extends ConsumerState<_PendingJobsTab> {
|
||
late Future<List<Job>> _future;
|
||
late UnsubFn _unsub;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_load();
|
||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||
_unsub = RealtimeService.instance.watch(
|
||
'jobs',
|
||
filter: 'lab_tenant_id="$tenantId" && status="pending"',
|
||
onEvent: (_) { if (mounted) _load(); },
|
||
);
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_unsub();
|
||
super.dispose();
|
||
}
|
||
|
||
void _load() {
|
||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||
setState(() {
|
||
_future = LabJobsRepository.instance.listInbound(tenantId, status: 'pending', limit: 50);
|
||
});
|
||
}
|
||
|
||
Future<void> _acceptJob(Job job) async {
|
||
try {
|
||
await LabJobsRepository.instance.acceptJob(job);
|
||
_load();
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(content: Text('İş kabul edildi')),
|
||
);
|
||
}
|
||
} catch (e) {
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text('Hata: $e')),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
List<Job> _filtered(List<Job> jobs) {
|
||
final q = widget.searchQuery.toLowerCase().trim();
|
||
if (q.isEmpty) return jobs;
|
||
return jobs.where((j) =>
|
||
j.patientCode.toLowerCase().contains(q) ||
|
||
(j.clinicName?.toLowerCase().contains(q) ?? false) ||
|
||
j.prostheticType.label.toLowerCase().contains(q)
|
||
).toList();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return RefreshIndicator(
|
||
color: AppColors.accent,
|
||
onRefresh: () async => _load(),
|
||
child: FutureBuilder<List<Job>>(
|
||
future: _future,
|
||
builder: (ctx, snap) {
|
||
if (snap.connectionState == ConnectionState.waiting) {
|
||
return const Center(child: CircularProgressIndicator(color: AppColors.accent));
|
||
}
|
||
if (snap.hasError) {
|
||
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: ${snap.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'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
final all = snap.data!;
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
widget.onCountLoaded?.call(all.length);
|
||
});
|
||
|
||
final jobs = _filtered(all);
|
||
|
||
if (jobs.isEmpty) {
|
||
return Center(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Container(
|
||
width: 72,
|
||
height: 72,
|
||
decoration: BoxDecoration(
|
||
color: AppColors.successBg,
|
||
borderRadius: BorderRadius.circular(20),
|
||
),
|
||
child: const Icon(Icons.inbox_outlined, color: AppColors.success, size: 32),
|
||
),
|
||
const SizedBox(height: 16),
|
||
Text(
|
||
widget.searchQuery.isNotEmpty ? 'Sonuç bulunamadı' : 'Onay bekleyen iş yok',
|
||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary),
|
||
),
|
||
if (widget.searchQuery.isEmpty) ...[
|
||
const SizedBox(height: 6),
|
||
const Text('Tüm işler kabul edildi', style: TextStyle(color: AppColors.textSecondary, fontSize: 13)),
|
||
],
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
return ListView.builder(
|
||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 100),
|
||
itemCount: jobs.length,
|
||
itemBuilder: (ctx, i) {
|
||
final job = jobs[i];
|
||
return Padding(
|
||
padding: const EdgeInsets.only(bottom: 10),
|
||
child: _PendingJobCard(
|
||
job: job,
|
||
onAccept: () => _acceptJob(job),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _PendingJobCard extends StatefulWidget {
|
||
const _PendingJobCard({required this.job, required this.onAccept});
|
||
final Job job;
|
||
final VoidCallback onAccept;
|
||
|
||
@override
|
||
State<_PendingJobCard> createState() => _PendingJobCardState();
|
||
}
|
||
|
||
class _PendingJobCardState extends State<_PendingJobCard> {
|
||
bool _accepting = false;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final job = widget.job;
|
||
return Dismissible(
|
||
key: ValueKey(job.id),
|
||
direction: DismissDirection.endToStart,
|
||
background: Container(
|
||
alignment: Alignment.centerRight,
|
||
padding: const EdgeInsets.only(right: 20),
|
||
decoration: BoxDecoration(
|
||
color: AppColors.success,
|
||
borderRadius: BorderRadius.circular(14),
|
||
),
|
||
child: const Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
Icon(Icons.check_rounded, color: Colors.white, size: 28),
|
||
SizedBox(height: 4),
|
||
Text('Kabul Et', style: TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.w600)),
|
||
],
|
||
),
|
||
),
|
||
confirmDismiss: (_) async {
|
||
setState(() => _accepting = true);
|
||
try {
|
||
await LabJobsRepository.instance.acceptJob(job);
|
||
return true;
|
||
} catch (e) {
|
||
if (context.mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text('Hata: $e')),
|
||
);
|
||
}
|
||
return false;
|
||
} finally {
|
||
if (mounted) setState(() => _accepting = false);
|
||
}
|
||
},
|
||
child: Material(
|
||
color: AppColors.surface,
|
||
borderRadius: BorderRadius.circular(14),
|
||
child: InkWell(
|
||
onTap: () => context.push('/lab/jobs/${job.id}'),
|
||
borderRadius: BorderRadius.circular(14),
|
||
child: Container(
|
||
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: IntrinsicHeight(
|
||
child: Row(
|
||
children: [
|
||
Container(
|
||
width: 4,
|
||
decoration: const BoxDecoration(
|
||
color: AppColors.pending,
|
||
borderRadius: BorderRadius.only(
|
||
topLeft: Radius.circular(14),
|
||
bottomLeft: Radius.circular(14),
|
||
),
|
||
),
|
||
),
|
||
Expanded(
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(14),
|
||
child: Row(
|
||
children: [
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
job.patientCode,
|
||
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700, color: AppColors.textPrimary),
|
||
),
|
||
const SizedBox(height: 4),
|
||
Row(
|
||
children: [
|
||
const Icon(Icons.local_hospital_outlined, size: 12, color: AppColors.textMuted),
|
||
const SizedBox(width: 4),
|
||
Expanded(
|
||
child: Text(
|
||
job.clinicName ?? 'Klinik',
|
||
style: const TextStyle(fontSize: 12, color: AppColors.textSecondary),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 6),
|
||
Row(
|
||
children: [
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
|
||
decoration: BoxDecoration(
|
||
color: AppColors.pendingBg,
|
||
borderRadius: BorderRadius.circular(6),
|
||
),
|
||
child: Text(
|
||
job.prostheticType.label,
|
||
style: const TextStyle(fontSize: 11, color: AppColors.pending, fontWeight: FontWeight.w600),
|
||
),
|
||
),
|
||
if (job.dueDate != null) ...[
|
||
const SizedBox(width: 6),
|
||
const Icon(Icons.calendar_today_outlined, size: 11, color: AppColors.textMuted),
|
||
const SizedBox(width: 3),
|
||
Text(
|
||
'${job.dueDate!.day.toString().padLeft(2, '0')}.${job.dueDate!.month.toString().padLeft(2, '0')}.${job.dueDate!.year}',
|
||
style: const TextStyle(fontSize: 11, color: AppColors.textMuted),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
_accepting
|
||
? const SizedBox(
|
||
width: 20,
|
||
height: 20,
|
||
child: CircularProgressIndicator(strokeWidth: 2, color: AppColors.success),
|
||
)
|
||
: Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7),
|
||
decoration: BoxDecoration(
|
||
color: AppColors.successBg,
|
||
borderRadius: BorderRadius.circular(8),
|
||
border: Border.all(color: AppColors.success.withValues(alpha: 0.3)),
|
||
),
|
||
child: const Text(
|
||
'Kabul Et',
|
||
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w700, color: AppColors.success),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _LabJobsTab extends ConsumerStatefulWidget {
|
||
const _LabJobsTab({
|
||
required this.status,
|
||
required this.searchQuery,
|
||
required this.sort,
|
||
this.onCountLoaded,
|
||
});
|
||
|
||
final String? status; // null = tüm statüsler
|
||
final String searchQuery;
|
||
final _JobSort sort;
|
||
final void Function(int)? onCountLoaded;
|
||
|
||
@override
|
||
ConsumerState<_LabJobsTab> createState() => _LabJobsTabState();
|
||
}
|
||
|
||
class _LabJobsTabState extends ConsumerState<_LabJobsTab> {
|
||
late Future<List<Job>> _future;
|
||
late UnsubFn _unsub;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_load();
|
||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||
_unsub = RealtimeService.instance.watch(
|
||
'jobs',
|
||
filter: 'lab_tenant_id="$tenantId"',
|
||
onEvent: (_) { if (mounted) _load(); },
|
||
);
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_unsub();
|
||
super.dispose();
|
||
}
|
||
|
||
void _load() {
|
||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||
setState(() {
|
||
_future = LabJobsRepository.instance
|
||
.listInbound(tenantId, status: widget.status, limit: 50);
|
||
});
|
||
}
|
||
|
||
List<Job> _applyFilters(List<Job> jobs) {
|
||
var list = jobs;
|
||
|
||
final q = widget.searchQuery.toLowerCase().trim();
|
||
if (q.isNotEmpty) {
|
||
list = list.where((j) {
|
||
return j.patientCode.toLowerCase().contains(q) ||
|
||
(j.clinicName?.toLowerCase().contains(q) ?? false) ||
|
||
j.prostheticType.label.toLowerCase().contains(q) ||
|
||
(j.currentStep?.label.toLowerCase().contains(q) ?? false);
|
||
}).toList();
|
||
}
|
||
|
||
final sorted = List<Job>.from(list);
|
||
switch (widget.sort) {
|
||
case _JobSort.newestFirst:
|
||
sorted.sort((a, b) => b.dateCreated.compareTo(a.dateCreated));
|
||
case _JobSort.oldestFirst:
|
||
sorted.sort((a, b) => a.dateCreated.compareTo(b.dateCreated));
|
||
case _JobSort.byDueDate:
|
||
sorted.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:
|
||
sorted.sort(
|
||
(a, b) => a.prostheticType.label.compareTo(b.prostheticType.label));
|
||
}
|
||
return sorted;
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return RefreshIndicator(
|
||
color: AppColors.accent,
|
||
onRefresh: () async => _load(),
|
||
child: FutureBuilder<List<Job>>(
|
||
future: _future,
|
||
builder: (ctx, snap) {
|
||
if (snap.connectionState == ConnectionState.waiting) {
|
||
return const Center(
|
||
child: CircularProgressIndicator(color: AppColors.accent));
|
||
}
|
||
if (snap.hasError) {
|
||
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: ${snap.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'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
final all = snap.data!;
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
widget.onCountLoaded?.call(all.length);
|
||
});
|
||
|
||
final jobs = _applyFilters(all);
|
||
|
||
if (jobs.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 ListView.builder(
|
||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
|
||
itemCount: jobs.length,
|
||
itemBuilder: (ctx, i) {
|
||
final job = jobs[i];
|
||
return Padding(
|
||
padding: const EdgeInsets.only(bottom: 10),
|
||
child: _LabJobCard(
|
||
job: job,
|
||
onTap: () => context.push('/lab/jobs/${job.id}'),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _LabJobCard extends StatelessWidget {
|
||
const _LabJobCard({required this.job, required this.onTap});
|
||
|
||
final Job job;
|
||
final VoidCallback onTap;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final isOverdue =
|
||
job.dueDate != null && job.dueDate!.isBefore(DateTime.now());
|
||
final accentColor = _statusColor(job.status);
|
||
|
||
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(
|
||
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: IntrinsicHeight(
|
||
child: Row(
|
||
children: [
|
||
Container(
|
||
width: 4,
|
||
decoration: BoxDecoration(
|
||
color: accentColor,
|
||
borderRadius: const BorderRadius.only(
|
||
topLeft: Radius.circular(14),
|
||
bottomLeft: Radius.circular(14),
|
||
),
|
||
),
|
||
),
|
||
Expanded(
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(14),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: Text(
|
||
job.patientCode,
|
||
style: const TextStyle(
|
||
fontSize: 15,
|
||
fontWeight: FontWeight.w700,
|
||
color: AppColors.textPrimary,
|
||
),
|
||
),
|
||
),
|
||
if (job.currentStep != null)
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 8, vertical: 3),
|
||
decoration: BoxDecoration(
|
||
color: AppColors.inProgressBg,
|
||
borderRadius: BorderRadius.circular(8),
|
||
),
|
||
child: Text(
|
||
job.currentStep!.label,
|
||
style: const TextStyle(
|
||
color: AppColors.inProgress,
|
||
fontSize: 11,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 5),
|
||
Row(
|
||
children: [
|
||
const Icon(Icons.local_hospital_outlined,
|
||
size: 12, color: AppColors.textMuted),
|
||
const SizedBox(width: 4),
|
||
Expanded(
|
||
child: Text(
|
||
job.clinicName ?? 'Klinik',
|
||
style: const TextStyle(
|
||
fontSize: 12,
|
||
color: AppColors.textSecondary),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 5),
|
||
Row(
|
||
children: [
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 6, vertical: 2),
|
||
decoration: BoxDecoration(
|
||
color: AppColors.surfaceVariant,
|
||
borderRadius: BorderRadius.circular(6),
|
||
),
|
||
child: Text(
|
||
job.prostheticType.label,
|
||
style: const TextStyle(
|
||
fontSize: 11,
|
||
color: AppColors.textSecondary,
|
||
fontWeight: FontWeight.w500,
|
||
),
|
||
),
|
||
),
|
||
if (job.dueDate != null) ...[
|
||
const SizedBox(width: 8),
|
||
Icon(Icons.calendar_today_outlined,
|
||
size: 11,
|
||
color: isOverdue
|
||
? AppColors.cancelled
|
||
: AppColors.textMuted),
|
||
const SizedBox(width: 3),
|
||
Text(
|
||
_fmt(job.dueDate!),
|
||
style: TextStyle(
|
||
fontSize: 11,
|
||
color: isOverdue
|
||
? AppColors.cancelled
|
||
: AppColors.textMuted,
|
||
fontWeight: isOverdue
|
||
? FontWeight.w600
|
||
: FontWeight.normal,
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
const Padding(
|
||
padding: EdgeInsets.only(right: 10),
|
||
child: 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(JobStatus status) {
|
||
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;
|
||
}
|
||
}
|
||
}
|