Initial commit: DLS - Dental Lab System
- 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
This commit is contained in:
@@ -0,0 +1,896 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,764 @@
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
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 '../../../core/widgets/gradient_app_bar.dart';
|
||||
import '../../../models/job.dart';
|
||||
import 'lab_jobs_repository.dart';
|
||||
|
||||
class LabJobsInboundScreen extends ConsumerStatefulWidget {
|
||||
const LabJobsInboundScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<LabJobsInboundScreen> createState() =>
|
||||
_LabJobsInboundScreenState();
|
||||
}
|
||||
|
||||
class _LabJobsInboundScreenState extends ConsumerState<LabJobsInboundScreen> {
|
||||
late Future<List<Job>> _future;
|
||||
bool _acceptingAll = false;
|
||||
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: 'pending');
|
||||
});
|
||||
}
|
||||
|
||||
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')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _bulkAccept() async {
|
||||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||||
setState(() => _acceptingAll = true);
|
||||
try {
|
||||
await LabJobsRepository.instance.bulkAcceptPending(tenantId);
|
||||
_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(() => _acceptingAll = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
appBar: GradientAppBar(
|
||||
title: 'Gelen İşler',
|
||||
category: 'LABORATUVAR',
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: _acceptingAll ? null : _bulkAccept,
|
||||
icon: _acceptingAll
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: Colors.white),
|
||||
)
|
||||
: const Icon(Icons.done_all),
|
||||
label:
|
||||
Text(_acceptingAll ? 'Kabul ediliyor...' : 'Tümünü Kabul Et'),
|
||||
backgroundColor: AppColors.pending,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: () async => _load(),
|
||||
child: FutureBuilder<List<Job>>(
|
||||
future: _future,
|
||||
builder: (ctx, snap) {
|
||||
if (snap.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (snap.hasError) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('Hata: ${snap.error}'),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton(
|
||||
onPressed: _load,
|
||||
child: const Text('Tekrar Dene'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final jobs = snap.data!;
|
||||
|
||||
if (jobs.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.inbox_outlined, size: 64, color: AppColors.textMuted),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Bekleyen iş yok',
|
||||
style: TextStyle(
|
||||
fontSize: 16, color: AppColors.textSecondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 80),
|
||||
itemCount: jobs.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
final job = jobs[i];
|
||||
return _InboundJobCard(
|
||||
job: job,
|
||||
onAccept: () => _acceptJob(job),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InboundJobCard extends StatefulWidget {
|
||||
const _InboundJobCard({required this.job, required this.onAccept});
|
||||
final Job job;
|
||||
final VoidCallback onAccept;
|
||||
|
||||
@override
|
||||
State<_InboundJobCard> createState() => _InboundJobCardState();
|
||||
}
|
||||
|
||||
class _InboundJobCardState extends State<_InboundJobCard> {
|
||||
bool _accepting = false;
|
||||
|
||||
String _formatDate(DateTime dt) =>
|
||||
'${dt.day.toString().padLeft(2, '0')}.${dt.month.toString().padLeft(2, '0')}.${dt.year}';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final job = widget.job;
|
||||
return Semantics(
|
||||
label: job.patientCode,
|
||||
button: true,
|
||||
excludeSemantics: true,
|
||||
child: Dismissible(
|
||||
key: ValueKey(job.id),
|
||||
direction: DismissDirection.endToStart,
|
||||
background: Container(
|
||||
alignment: Alignment.centerRight,
|
||||
padding: const EdgeInsets.only(right: 20),
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.success,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.check, color: Colors.white, size: 28),
|
||||
SizedBox(height: 4),
|
||||
Text('Kabul Et',
|
||||
style: TextStyle(color: Colors.white, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
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: Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
backgroundColor: AppColors.pendingBg,
|
||||
child: const Icon(Icons.assignment_outlined,
|
||||
color: AppColors.pending),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
job.patientCode,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
job.clinicName ?? 'Klinik',
|
||||
style: TextStyle(
|
||||
color: AppColors.textSecondary, fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
_Chip(
|
||||
label: job.prostheticType.label,
|
||||
color: AppColors.inProgressBg,
|
||||
textColor: AppColors.inProgress,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
_Chip(
|
||||
label: '${job.memberCount} üye',
|
||||
color: AppColors.background,
|
||||
textColor: AppColors.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_formatDate(job.dateCreated),
|
||||
style: TextStyle(
|
||||
color: AppColors.textMuted, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_accepting
|
||||
? const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: FilledButton(
|
||||
onPressed: widget.onAccept,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.success,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 8),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
child: const Text('Kabul Et',
|
||||
style: TextStyle(fontSize: 13)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Chip extends StatelessWidget {
|
||||
const _Chip(
|
||||
{required this.label,
|
||||
required this.color,
|
||||
required this.textColor});
|
||||
final String label;
|
||||
final Color color;
|
||||
final Color textColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(label,
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
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';
|
||||
|
||||
class LabJobsRepository {
|
||||
LabJobsRepository._();
|
||||
static final instance = LabJobsRepository._();
|
||||
|
||||
PocketBase get _pb => PocketBaseClient.instance.pb;
|
||||
|
||||
Future<List<Job>> listInbound(
|
||||
String labTenantId, {
|
||||
String? status,
|
||||
int page = 1,
|
||||
int limit = 30,
|
||||
}) async {
|
||||
final filterParts = ['lab_tenant_id = "$labTenantId"'];
|
||||
if (status != null) filterParts.add('status = "$status"');
|
||||
|
||||
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<List<Job>> listInProgress(String labTenantId, {int limit = 50, String? location}) async {
|
||||
final filterParts = ['lab_tenant_id = "$labTenantId"', 'status = "in_progress"'];
|
||||
if (location != null) filterParts.add('location = "$location"');
|
||||
final result = await _pb.collection('jobs').getList(
|
||||
perPage: limit,
|
||||
filter: filterParts.join(' && '),
|
||||
expand: _listExpand,
|
||||
);
|
||||
return (result.items.map((r) => Job.fromJson(r.toJson())).toList()
|
||||
..sort((a, b) {
|
||||
if (a.dueDate == null && b.dueDate == null) return b.dateCreated.compareTo(a.dateCreated);
|
||||
if (a.dueDate == null) return 1;
|
||||
if (b.dueDate == null) return -1;
|
||||
final cmp = a.dueDate!.compareTo(b.dueDate!);
|
||||
return cmp != 0 ? cmp : 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> acceptJob(Job pendingJob) async {
|
||||
final firstStep = pendingJob.stepTemplate.first;
|
||||
final record = await _pb.collection('jobs').update(pendingJob.id, body: {
|
||||
'status': 'in_progress',
|
||||
'current_step': firstStep.value,
|
||||
'location': 'at_lab',
|
||||
});
|
||||
final job = Job.fromJson(record.toJson());
|
||||
unawaited(JobHistoryService.instance.append(
|
||||
jobId: pendingJob.id,
|
||||
clinicTenantId: job.clinicTenantId,
|
||||
labTenantId: job.labTenantId,
|
||||
action: JobHistoryAction.accepted,
|
||||
step: firstStep,
|
||||
));
|
||||
return job;
|
||||
}
|
||||
|
||||
Future<Job> handToClinic(String jobId, Job job, {String? note}) async {
|
||||
final isFinal = job.currentStep == JobStep.cilaBitim;
|
||||
final patch = isFinal
|
||||
? {'status': 'sent', 'location': 'at_clinic'}
|
||||
: {'location': 'at_clinic'};
|
||||
|
||||
final record = await _pb.collection('jobs').update(jobId, body: patch);
|
||||
final updated = Job.fromJson(record.toJson());
|
||||
unawaited(JobHistoryService.instance.append(
|
||||
jobId: jobId,
|
||||
clinicTenantId: job.clinicTenantId,
|
||||
labTenantId: job.labTenantId,
|
||||
action: JobHistoryAction.handedToClinic,
|
||||
step: job.currentStep,
|
||||
note: note,
|
||||
));
|
||||
return updated;
|
||||
}
|
||||
|
||||
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,
|
||||
step: job.currentStep,
|
||||
));
|
||||
return Job.fromJson(record.toJson());
|
||||
}
|
||||
|
||||
Future<void> bulkAcceptPending(String labTenantId) async {
|
||||
final pending = await listInbound(labTenantId, status: 'pending', limit: 200);
|
||||
await Future.wait(pending.map((j) => acceptJob(j)));
|
||||
}
|
||||
|
||||
Future<int> countByStatus(String labTenantId, String? status) async {
|
||||
final filter = status != null
|
||||
? 'lab_tenant_id = "$labTenantId" && status = "$status"'
|
||||
: 'lab_tenant_id = "$labTenantId"';
|
||||
final r = await _pb.collection('jobs').getList(perPage: 1, filter: filter);
|
||||
return r.totalItems;
|
||||
}
|
||||
|
||||
Future<int> countDelivered(String labTenantId, {DateTime? from, DateTime? to}) async {
|
||||
final parts = ['lab_tenant_id = "$labTenantId"', '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;
|
||||
}
|
||||
Reference in New Issue
Block a user