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,40 @@
|
||||
import 'package:pocketbase/pocketbase.dart';
|
||||
import '../../../core/api/pocketbase_client.dart';
|
||||
import '../../../models/connection.dart';
|
||||
|
||||
class ClinicConnectionsRepository {
|
||||
ClinicConnectionsRepository._();
|
||||
static final instance = ClinicConnectionsRepository._();
|
||||
|
||||
PocketBase get _pb => PocketBaseClient.instance.pb;
|
||||
|
||||
Future<List<Connection>> listConnections(String clinicTenantId) async {
|
||||
final result = await _pb.collection('connections').getList(
|
||||
filter: 'clinic_tenant_id = "$clinicTenantId"',
|
||||
expand: 'lab_tenant_id,clinic_tenant_id',
|
||||
perPage: 100,
|
||||
);
|
||||
return (result.items.map((r) => Connection.fromJson(r.toJson())).toList()
|
||||
..sort((a, b) => (b.dateCreated ?? '').compareTo(a.dateCreated ?? '')));
|
||||
}
|
||||
|
||||
Future<Connection> requestConnection({
|
||||
required String clinicTenantId,
|
||||
required String labTenantId,
|
||||
}) async {
|
||||
final record = await _pb.collection('connections').create(body: {
|
||||
'clinic_tenant_id': clinicTenantId,
|
||||
'lab_tenant_id': labTenantId,
|
||||
'status': 'pending',
|
||||
});
|
||||
return Connection.fromJson(record.toJson());
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> searchLabs(String query) async {
|
||||
final result = await _pb.collection('tenants').getList(
|
||||
filter: 'kind = "lab" && company_name ~ "$query"',
|
||||
perPage: 20,
|
||||
);
|
||||
return result.items.map((r) => r.toJson()).toList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/providers/auth_provider.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../models/connection.dart';
|
||||
import 'clinic_connections_repository.dart';
|
||||
|
||||
class ClinicConnectionsScreen extends ConsumerStatefulWidget {
|
||||
const ClinicConnectionsScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ClinicConnectionsScreen> createState() =>
|
||||
_ClinicConnectionsScreenState();
|
||||
}
|
||||
|
||||
class _ClinicConnectionsScreenState
|
||||
extends ConsumerState<ClinicConnectionsScreen> {
|
||||
late Future<List<Connection>> _future;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
void _load() {
|
||||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||||
setState(() {
|
||||
_future = ClinicConnectionsRepository.instance
|
||||
.listConnections(tenantId);
|
||||
});
|
||||
}
|
||||
|
||||
void _showSearchDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => _LabSearchDialog(
|
||||
onRequested: (labId, labName) async {
|
||||
Navigator.of(ctx).pop();
|
||||
final tenantId =
|
||||
ref.read(authProvider).activeTenant!.tenant.id;
|
||||
try {
|
||||
await ClinicConnectionsRepository.instance.requestConnection(
|
||||
clinicTenantId: tenantId,
|
||||
labTenantId: labId,
|
||||
);
|
||||
_load();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'$labName\'a bağlantı talebi gönderildi.')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Hata: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Bağlantılar'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add_link),
|
||||
tooltip: 'Laboratuvar Bul',
|
||||
onPressed: _showSearchDialog,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
color: AppColors.accent,
|
||||
onRefresh: () async => _load(),
|
||||
child: FutureBuilder<List<Connection>>(
|
||||
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 connections = snap.data!;
|
||||
if (connections.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.link_off,
|
||||
color: AppColors.inProgress, size: 32),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Henüz bağlantı yok',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
FilledButton.icon(
|
||||
onPressed: _showSearchDialog,
|
||||
icon: const Icon(Icons.search),
|
||||
label: const Text('Laboratuvar Bul'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
|
||||
itemCount: connections.length,
|
||||
itemBuilder: (context, index) {
|
||||
final conn = connections[index];
|
||||
final statusColor = _statusColor(conn.status);
|
||||
final statusBg = _statusBg(conn.status);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
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.science_outlined,
|
||||
color: statusColor, size: 22),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
conn.labName ?? 'Laboratuvar',
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary),
|
||||
),
|
||||
if (conn.dateCreated != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
_formatDate(conn.dateCreated!),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
_StatusChip(status: conn.status),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: _showSearchDialog,
|
||||
backgroundColor: AppColors.accent,
|
||||
foregroundColor: Colors.white,
|
||||
icon: const Icon(Icons.search),
|
||||
label: const Text('Laboratuvar Bul'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _statusColor(ConnectionStatus s) {
|
||||
switch (s) {
|
||||
case ConnectionStatus.pending:
|
||||
return AppColors.pending;
|
||||
case ConnectionStatus.approved:
|
||||
return AppColors.success;
|
||||
case ConnectionStatus.rejected:
|
||||
return AppColors.cancelled;
|
||||
}
|
||||
}
|
||||
|
||||
Color _statusBg(ConnectionStatus s) {
|
||||
switch (s) {
|
||||
case ConnectionStatus.pending:
|
||||
return AppColors.pendingBg;
|
||||
case ConnectionStatus.approved:
|
||||
return AppColors.successBg;
|
||||
case ConnectionStatus.rejected:
|
||||
return AppColors.cancelledBg;
|
||||
}
|
||||
}
|
||||
|
||||
String _formatDate(String dateStr) {
|
||||
try {
|
||||
final d = DateTime.parse(dateStr);
|
||||
return '${d.day.toString().padLeft(2, '0')}.${d.month.toString().padLeft(2, '0')}.${d.year}';
|
||||
} catch (_) {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusChip extends StatelessWidget {
|
||||
const _StatusChip({required this.status});
|
||||
final ConnectionStatus status;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = _color(status);
|
||||
final bg = _bg(status);
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
status.label,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _color(ConnectionStatus s) {
|
||||
switch (s) {
|
||||
case ConnectionStatus.pending:
|
||||
return AppColors.pending;
|
||||
case ConnectionStatus.approved:
|
||||
return AppColors.success;
|
||||
case ConnectionStatus.rejected:
|
||||
return AppColors.cancelled;
|
||||
}
|
||||
}
|
||||
|
||||
Color _bg(ConnectionStatus s) {
|
||||
switch (s) {
|
||||
case ConnectionStatus.pending:
|
||||
return AppColors.pendingBg;
|
||||
case ConnectionStatus.approved:
|
||||
return AppColors.successBg;
|
||||
case ConnectionStatus.rejected:
|
||||
return AppColors.cancelledBg;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _LabSearchDialog extends StatefulWidget {
|
||||
const _LabSearchDialog({required this.onRequested});
|
||||
final void Function(String labId, String labName) onRequested;
|
||||
|
||||
@override
|
||||
State<_LabSearchDialog> createState() => _LabSearchDialogState();
|
||||
}
|
||||
|
||||
class _LabSearchDialogState extends State<_LabSearchDialog> {
|
||||
final _searchController = TextEditingController();
|
||||
List<Map<String, dynamic>> _results = [];
|
||||
bool _isLoading = false;
|
||||
bool _searched = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _search() async {
|
||||
final query = _searchController.text.trim();
|
||||
if (query.isEmpty) return;
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_searched = true;
|
||||
});
|
||||
try {
|
||||
final results =
|
||||
await ClinicConnectionsRepository.instance.searchLabs(query);
|
||||
setState(() {
|
||||
_results = results;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() => _isLoading = false);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Hata: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Laboratuvar Bul'),
|
||||
content: SizedBox(
|
||||
width: double.maxFinite,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Lab adı ile arayın...',
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 8),
|
||||
),
|
||||
onSubmitted: (_) => _search(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton(
|
||||
onPressed: _search,
|
||||
child: const Text('Ara'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (_isLoading)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: CircularProgressIndicator(color: AppColors.accent),
|
||||
)
|
||||
else if (_searched && _results.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Text('Sonuç bulunamadı',
|
||||
style: TextStyle(color: AppColors.textSecondary)),
|
||||
)
|
||||
else if (_results.isNotEmpty)
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 240),
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: _results.length,
|
||||
itemBuilder: (context, index) {
|
||||
final lab = _results[index];
|
||||
final name =
|
||||
lab['company_name'] as String? ?? 'Lab';
|
||||
return ListTile(
|
||||
dense: true,
|
||||
leading: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.inProgressBg,
|
||||
borderRadius: BorderRadius.circular(8)),
|
||||
child: const Icon(Icons.science_outlined,
|
||||
color: AppColors.inProgress, size: 18),
|
||||
),
|
||||
title: Text(name,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary)),
|
||||
subtitle: lab['member_number'] != null
|
||||
? Text('No: ${lab['member_number']}',
|
||||
style: const TextStyle(
|
||||
color: AppColors.textSecondary))
|
||||
: null,
|
||||
onTap: () =>
|
||||
widget.onRequested(lab['id'] as String, name),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('İptal'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
||||
import 'package:pocketbase/pocketbase.dart';
|
||||
import '../../../core/api/pocketbase_client.dart';
|
||||
import '../../../models/finance_entry.dart';
|
||||
|
||||
class ClinicFinanceRepository {
|
||||
ClinicFinanceRepository._();
|
||||
static final instance = ClinicFinanceRepository._();
|
||||
|
||||
PocketBase get _pb => PocketBaseClient.instance.pb;
|
||||
|
||||
Future<List<FinanceEntry>> listEntries(
|
||||
String tenantId, {
|
||||
String? status,
|
||||
int page = 1,
|
||||
int limit = 30,
|
||||
}) async {
|
||||
final filterParts = ['tenant_id = "$tenantId"', 'type = "payable"'];
|
||||
if (status != null) filterParts.add('status = "$status"');
|
||||
|
||||
final result = await _pb.collection('finance_entries').getList(
|
||||
page: page,
|
||||
perPage: limit,
|
||||
filter: filterParts.join(' && '),
|
||||
expand: 'job_id',
|
||||
);
|
||||
return (result.items.map((r) => FinanceEntry.fromJson(r.toJson())).toList()
|
||||
..sort((a, b) => (b.dateCreated ?? '').compareTo(a.dateCreated ?? '')));
|
||||
}
|
||||
|
||||
Future<Map<String, double>> summary(String tenantId) async {
|
||||
final all = await listEntries(tenantId, limit: 200);
|
||||
double pending = 0, paid = 0;
|
||||
for (final e in all) {
|
||||
if (e.status == FinanceStatus.pending) {
|
||||
pending += e.amount;
|
||||
} else {
|
||||
paid += e.amount;
|
||||
}
|
||||
}
|
||||
return {'pending': pending, 'paid': paid};
|
||||
}
|
||||
|
||||
Future<void> markPaid(String entryId) async {
|
||||
await _pb.collection('finance_entries').update(entryId, body: {
|
||||
'status': 'paid',
|
||||
'paid_at': DateTime.now().toIso8601String(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,534 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/providers/auth_provider.dart';
|
||||
import '../../../core/providers/locale_provider.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../core/utils/currency_formatter.dart';
|
||||
import '../../../core/widgets/gradient_app_bar.dart';
|
||||
import '../../../core/widgets/pill_tabs.dart';
|
||||
import '../../../models/finance_entry.dart';
|
||||
import 'clinic_finance_repository.dart';
|
||||
|
||||
enum _FinanceSort { newestFirst, byAmountDesc, byAmountAsc }
|
||||
|
||||
class ClinicFinanceScreen extends ConsumerStatefulWidget {
|
||||
const ClinicFinanceScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ClinicFinanceScreen> createState() =>
|
||||
_ClinicFinanceScreenState();
|
||||
}
|
||||
|
||||
class _ClinicFinanceScreenState extends ConsumerState<ClinicFinanceScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
late Future<Map<String, double>> _summaryFuture;
|
||||
_FinanceSort _sort = _FinanceSort.newestFirst;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 2, vsync: this);
|
||||
_tabController.addListener(() {
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
_loadSummary();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _loadSummary() {
|
||||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||||
setState(() {
|
||||
_summaryFuture =
|
||||
ClinicFinanceRepository.instance.summary(tenantId);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _showSortOptions() async {
|
||||
final s = ref.read(stringsProvider);
|
||||
final result = await showSortSheet(
|
||||
context,
|
||||
title: s.sort,
|
||||
options: [s.sortNewest, s.sortAmountDesc, s.sortAmountAsc],
|
||||
current: _sort.index,
|
||||
);
|
||||
if (result != null) {
|
||||
setState(() => _sort = _FinanceSort.values[result]);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isSortActive = _sort != _FinanceSort.newestFirst;
|
||||
final s = ref.watch(stringsProvider);
|
||||
final currencyCode =
|
||||
ref.watch(authProvider).activeTenant?.tenant.defaultCurrency ?? 'TRY';
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
appBar: GradientAppBar(
|
||||
title: s.finance,
|
||||
category: s.clinicCategory,
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: _showSortOptions,
|
||||
tooltip: 'Sırala',
|
||||
icon: Badge(
|
||||
isLabelVisible: isSortActive,
|
||||
smallSize: 8,
|
||||
backgroundColor: AppColors.accent,
|
||||
child: const Icon(Icons.sort_rounded),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
FutureBuilder<Map<String, double>>(
|
||||
future: _summaryFuture,
|
||||
builder: (ctx, snap) {
|
||||
if (snap.connectionState == ConnectionState.waiting) {
|
||||
return const LinearProgressIndicator(
|
||||
color: AppColors.accent);
|
||||
}
|
||||
final summary = snap.data ?? {'pending': 0.0, 'paid': 0.0};
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _SummaryCard(
|
||||
label: s.pendingReceivable,
|
||||
amount: summary['pending'] ?? 0.0,
|
||||
currencyCode: currencyCode,
|
||||
color: AppColors.pending,
|
||||
bgColor: AppColors.pendingBg,
|
||||
icon: Icons.hourglass_empty_rounded,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _SummaryCard(
|
||||
label: s.collected,
|
||||
amount: summary['paid'] ?? 0.0,
|
||||
currencyCode: currencyCode,
|
||||
color: AppColors.success,
|
||||
bgColor: AppColors.successBg,
|
||||
icon: Icons.check_circle_outline,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
PillTabs(
|
||||
tabs: [s.pending, s.collected],
|
||||
selected: _tabController.index,
|
||||
onSelect: (i) => _tabController.animateTo(i),
|
||||
),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
_FinanceTab(
|
||||
status: 'pending',
|
||||
sort: _sort,
|
||||
onPaymentMade: _loadSummary,
|
||||
currencyCode: currencyCode,
|
||||
),
|
||||
_FinanceTab(
|
||||
status: 'paid',
|
||||
sort: _sort,
|
||||
onPaymentMade: _loadSummary,
|
||||
currencyCode: currencyCode,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SummaryCard extends StatelessWidget {
|
||||
const _SummaryCard({
|
||||
required this.label,
|
||||
required this.amount,
|
||||
required this.currencyCode,
|
||||
required this.color,
|
||||
required this.bgColor,
|
||||
required this.icon,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final double amount;
|
||||
final String currencyCode;
|
||||
final Color color;
|
||||
final Color bgColor;
|
||||
final IconData icon;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return 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.06),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4))
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor,
|
||||
borderRadius: BorderRadius.circular(12)),
|
||||
child: Icon(icon, color: color, size: 22),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
CurrencyFormatter.format(amount, currencyCode),
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: color,
|
||||
height: 1),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(label,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary,
|
||||
fontWeight: FontWeight.w500)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FinanceTab extends ConsumerStatefulWidget {
|
||||
const _FinanceTab({
|
||||
required this.status,
|
||||
required this.sort,
|
||||
required this.onPaymentMade,
|
||||
required this.currencyCode,
|
||||
});
|
||||
|
||||
final String status;
|
||||
final _FinanceSort sort;
|
||||
final VoidCallback onPaymentMade;
|
||||
final String currencyCode;
|
||||
|
||||
@override
|
||||
ConsumerState<_FinanceTab> createState() => _FinanceTabState();
|
||||
}
|
||||
|
||||
class _FinanceTabState extends ConsumerState<_FinanceTab> {
|
||||
late Future<List<FinanceEntry>> _future;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
void _load() {
|
||||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||||
setState(() {
|
||||
_future = ClinicFinanceRepository.instance.listEntries(
|
||||
tenantId,
|
||||
status: widget.status,
|
||||
limit: 100,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
List<FinanceEntry> _sorted(List<FinanceEntry> entries) {
|
||||
final list = List<FinanceEntry>.from(entries);
|
||||
switch (widget.sort) {
|
||||
case _FinanceSort.newestFirst:
|
||||
list.sort((a, b) {
|
||||
final da = a.dateCreated != null
|
||||
? DateTime.tryParse(a.dateCreated!)
|
||||
: null;
|
||||
final db = b.dateCreated != null
|
||||
? DateTime.tryParse(b.dateCreated!)
|
||||
: null;
|
||||
if (da == null && db == null) return 0;
|
||||
if (da == null) return 1;
|
||||
if (db == null) return -1;
|
||||
return db.compareTo(da);
|
||||
});
|
||||
case _FinanceSort.byAmountDesc:
|
||||
list.sort((a, b) => b.amount.compareTo(a.amount));
|
||||
case _FinanceSort.byAmountAsc:
|
||||
list.sort((a, b) => a.amount.compareTo(b.amount));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
Future<void> _markPaid(FinanceEntry entry) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Ödeme Onayı'),
|
||||
content: Text(
|
||||
'${entry.counterpartyName ?? "Bu kayıt"} için '
|
||||
'${CurrencyFormatter.format(entry.amount, widget.currencyCode)} tutarındaki borcu '
|
||||
'ödendi olarak işaretlemek istiyor musunuz?',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('İptal'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('Ödendi'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true || !mounted) return;
|
||||
try {
|
||||
await ClinicFinanceRepository.instance.markPaid(entry.id);
|
||||
_load();
|
||||
widget.onPaymentMade();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Ödeme kaydedildi.')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Hata: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return RefreshIndicator(
|
||||
color: AppColors.accent,
|
||||
onRefresh: () async => _load(),
|
||||
child: FutureBuilder<List<FinanceEntry>>(
|
||||
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 entries = _sorted(snap.data!);
|
||||
if (entries.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.receipt_long_outlined,
|
||||
color: AppColors.inProgress, size: 32),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Kayıt bulunamadı',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
|
||||
itemCount: entries.length,
|
||||
itemBuilder: (context, index) {
|
||||
final entry = entries[index];
|
||||
final isPending = entry.status == FinanceStatus.pending;
|
||||
final statusColor =
|
||||
isPending ? AppColors.pending : AppColors.success;
|
||||
final statusBg =
|
||||
isPending ? AppColors.pendingBg : AppColors.successBg;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: Material(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: InkWell(
|
||||
onTap: isPending ? () => _markPaid(entry) : null,
|
||||
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(
|
||||
isPending
|
||||
? Icons.hourglass_empty_rounded
|
||||
: Icons.check_circle_outline,
|
||||
color: statusColor,
|
||||
size: 22,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
entry.counterpartyName ??
|
||||
'Bilinmiyor',
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
CurrencyFormatter.format(
|
||||
entry.amount, widget.currencyCode),
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: statusColor),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (entry.patientCode != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Protokol: ${entry.patientCode}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary),
|
||||
),
|
||||
],
|
||||
if (entry.dateCreated != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
_formatDate(entry.dateCreated!),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textMuted),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isPending) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.pending,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Text(
|
||||
'Öde',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDate(String dateStr) {
|
||||
try {
|
||||
final d = DateTime.parse(dateStr);
|
||||
return '${d.day.toString().padLeft(2, '0')}.${d.month.toString().padLeft(2, '0')}.${d.year}';
|
||||
} catch (_) {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,749 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/providers/auth_provider.dart';
|
||||
import '../../../core/services/realtime_service.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../models/job.dart';
|
||||
import '../../../models/job_file.dart';
|
||||
import '../../../features/shared/job_files_repository.dart';
|
||||
import '../../../features/shared/job_files_panel.dart';
|
||||
import '../../../core/services/job_history_service.dart';
|
||||
import 'clinic_jobs_repository.dart';
|
||||
|
||||
class ClinicJobDetailScreen extends ConsumerStatefulWidget {
|
||||
const ClinicJobDetailScreen({super.key, required this.jobId});
|
||||
final String jobId;
|
||||
|
||||
@override
|
||||
ConsumerState<ClinicJobDetailScreen> createState() =>
|
||||
_ClinicJobDetailScreenState();
|
||||
}
|
||||
|
||||
class _ClinicJobDetailScreenState
|
||||
extends ConsumerState<ClinicJobDetailScreen> {
|
||||
Job? _job;
|
||||
String? _loadError;
|
||||
late Future<List<JobFile>> _filesFuture;
|
||||
bool _isActing = false;
|
||||
late UnsubFn _unsub;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
_loadFiles();
|
||||
_unsub = RealtimeService.instance.watch(
|
||||
'jobs',
|
||||
topic: widget.jobId,
|
||||
onEvent: (_) { if (mounted && !_isActing) _load(); },
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_unsub();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
if (mounted) setState(() { _loadError = null; });
|
||||
try {
|
||||
final job = await ClinicJobsRepository.instance.getJob(widget.jobId);
|
||||
if (mounted) setState(() { _job = job; });
|
||||
} catch (e) {
|
||||
if (mounted) setState(() { _loadError = e.toString(); });
|
||||
}
|
||||
}
|
||||
|
||||
void _loadFiles() {
|
||||
setState(() {
|
||||
_filesFuture = JobFilesRepository.instance.listForJob(widget.jobId);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _approve(Job job) async {
|
||||
setState(() => _isActing = true);
|
||||
try {
|
||||
final updated = await ClinicJobsRepository.instance.approveAtClinic(job.id, job);
|
||||
if (mounted) {
|
||||
setState(() { _job = updated.copyWith(clinicName: job.clinicName, labName: job.labName); _isActing = false; });
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('İş onaylandı.')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _isActing = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Hata: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _cancelJob(Job job) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('İşi İptal Et'),
|
||||
content: const Text('Bu iş geri alınamaz şekilde iptal edilir. Onaylıyor musunuz?'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Vazgeç')),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(backgroundColor: AppColors.cancelled),
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('İptal Et'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true || !mounted) return;
|
||||
setState(() => _isActing = true);
|
||||
try {
|
||||
final updated = await ClinicJobsRepository.instance.cancelJob(job.id, job);
|
||||
if (mounted) {
|
||||
setState(() { _job = _job!.copyWith(status: updated.status); _isActing = false; });
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('İş iptal edildi.')));
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _isActing = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Hata: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _requestRevision(Job job) async {
|
||||
final noteController = TextEditingController();
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Revizyon Talebi'),
|
||||
content: TextField(
|
||||
controller: noteController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Açıklama',
|
||||
hintText: 'Revizyon sebebini belirtin...',
|
||||
),
|
||||
minLines: 3,
|
||||
maxLines: 5,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('İptal'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('Gönder'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true || !mounted) return;
|
||||
if (noteController.text.trim().isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Lütfen bir açıklama girin.')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isActing = true);
|
||||
try {
|
||||
final updated = await ClinicJobsRepository.instance.requestRevision(
|
||||
job.id,
|
||||
job,
|
||||
note: noteController.text.trim(),
|
||||
);
|
||||
if (mounted) {
|
||||
setState(() { _job = updated.copyWith(clinicName: job.clinicName, labName: job.labName); _isActing = false; });
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Revizyon talebi gönderildi.')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _isActing = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Hata: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _markDelivered(Job job) async {
|
||||
final noteCtrl = TextEditingController();
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Teslim Alındı'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Bu işi teslim aldığınızı onaylıyor musunuz?'),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: noteCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Teslimat notu (isteğe bağlı)',
|
||||
hintText: 'Teslim eden kişi, durum vb...',
|
||||
isDense: true,
|
||||
),
|
||||
maxLines: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('İptal'),
|
||||
),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(backgroundColor: AppColors.success),
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('Teslim Alındı'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true || !mounted) return;
|
||||
|
||||
setState(() => _isActing = true);
|
||||
try {
|
||||
final note = noteCtrl.text.trim().isNotEmpty ? noteCtrl.text.trim() : null;
|
||||
final updated = await ClinicJobsRepository.instance.markDelivered(job.id, job, note: note);
|
||||
if (mounted) {
|
||||
setState(() { _job = updated.copyWith(clinicName: job.clinicName, labName: job.labName); _isActing = false; });
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('İş teslim alındı olarak işaretlendi.')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _isActing = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Hata: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
appBar: AppBar(title: const Text('İş Detayı')),
|
||||
body: _buildBody(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody() {
|
||||
if (_job == null && _loadError == null) {
|
||||
return const Center(child: CircularProgressIndicator(color: AppColors.accent));
|
||||
}
|
||||
if (_loadError != null && _job == null) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cancelledBg,
|
||||
borderRadius: BorderRadius.circular(16)),
|
||||
child: const Icon(Icons.wifi_off_rounded,
|
||||
color: AppColors.cancelled, size: 30),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text('Hata: $_loadError',
|
||||
style: const TextStyle(color: AppColors.textSecondary)),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.icon(
|
||||
onPressed: _load,
|
||||
icon: const Icon(Icons.refresh_rounded, size: 18),
|
||||
label: const Text('Tekrar Dene'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
if (_job == null) return const Center(child: CircularProgressIndicator(color: AppColors.accent));
|
||||
final job = _job!;
|
||||
final membership = ref.read(authProvider).activeTenant;
|
||||
final canDeliver = membership?.canDeliverJobs ?? true;
|
||||
final canCancel = membership?.canCancelJobs ?? true;
|
||||
final canManage = !(membership?.isDeliveryOnly ?? false);
|
||||
return _JobDetailBody(
|
||||
job: job,
|
||||
filesFuture: _filesFuture,
|
||||
isActing: _isActing,
|
||||
canDeliver: canDeliver,
|
||||
canManage: canManage,
|
||||
onApprove: canManage ? () => _approve(job) : () {},
|
||||
onRevision: canManage ? () => _requestRevision(job) : () {},
|
||||
onDelivered: () => _markDelivered(job),
|
||||
onCancel: (canCancel && job.status == JobStatus.pending) ? () => _cancelJob(job) : null,
|
||||
onFilesRefresh: _loadFiles,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _JobDetailBody extends StatelessWidget {
|
||||
const _JobDetailBody({
|
||||
required this.job,
|
||||
required this.filesFuture,
|
||||
required this.isActing,
|
||||
required this.canDeliver,
|
||||
required this.canManage,
|
||||
required this.onApprove,
|
||||
required this.onRevision,
|
||||
required this.onDelivered,
|
||||
required this.onFilesRefresh,
|
||||
this.onCancel,
|
||||
});
|
||||
|
||||
final Job job;
|
||||
final Future<List<JobFile>> filesFuture;
|
||||
final bool isActing;
|
||||
final bool canDeliver;
|
||||
final bool canManage;
|
||||
final VoidCallback onApprove;
|
||||
final VoidCallback onRevision;
|
||||
final VoidCallback onDelivered;
|
||||
final VoidCallback? onCancel;
|
||||
final VoidCallback onFilesRefresh;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final steps = job.stepTemplate;
|
||||
final currentStepIndex =
|
||||
job.currentStep != null ? steps.indexOf(job.currentStep!) : -1;
|
||||
|
||||
final canApproveOrRevise = canManage &&
|
||||
job.location == JobLocation.atClinic &&
|
||||
job.status == JobStatus.inProgress;
|
||||
final canMarkDelivered = canDeliver && job.status == JobStatus.sent;
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
// Info card
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4))
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Patient code + status
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
job.patientCode,
|
||||
style: Theme.of(context).textTheme.headlineSmall
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.textPrimary),
|
||||
),
|
||||
),
|
||||
_StatusBadge(status: job.status),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Divider(height: 1, color: AppColors.border),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Patient + Lab
|
||||
_SectionLabel(title: 'Hasta & Laboratuvar'),
|
||||
_InfoRow(label: 'Protokol No', value: job.patientCode),
|
||||
if (job.patientId != null)
|
||||
_InfoRow(label: 'Hasta ID', value: job.patientId!),
|
||||
_InfoRow(
|
||||
label: 'Laboratuvar', value: job.labName ?? 'Bilinmiyor'),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Prosthetic
|
||||
_SectionLabel(title: 'Protez Bilgisi'),
|
||||
_InfoRow(label: 'Tür', value: job.prostheticType.label),
|
||||
_InfoRow(label: 'Üye Sayısı', value: '${job.memberCount}'),
|
||||
if (job.teeth.isNotEmpty)
|
||||
_InfoRow(label: 'Dişler', value: job.teeth.join(', ')),
|
||||
if (job.color != null && job.color!.isNotEmpty)
|
||||
_InfoRow(label: 'Renk', value: job.color!),
|
||||
if (job.description != null && job.description!.isNotEmpty)
|
||||
_InfoRow(label: 'Açıklama', value: job.description!),
|
||||
if (job.dueDate != null)
|
||||
_InfoRow(label: 'Son Tarih', value: _formatDate(job.dueDate!, withTime: true)),
|
||||
if (job.price != null)
|
||||
_InfoRow(
|
||||
label: 'Fiyat',
|
||||
value:
|
||||
'${job.price!.toStringAsFixed(2)} ${job.currency ?? 'TRY'}'),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Stepper card
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4))
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'İş Adımları',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_StepperWidget(
|
||||
steps: steps,
|
||||
currentStepIndex: currentStepIndex,
|
||||
historyFuture: JobHistoryService.instance.listForJob(job.id),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Action buttons
|
||||
if (isActing)
|
||||
const Center(
|
||||
child: CircularProgressIndicator(color: AppColors.accent))
|
||||
else if (canApproveOrRevise) ...[
|
||||
FilledButton.icon(
|
||||
onPressed: onApprove,
|
||||
icon: const Icon(Icons.check_circle_outline),
|
||||
label: const Text('Onayla'),
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size(double.infinity, 50),
|
||||
backgroundColor: AppColors.success,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton.icon(
|
||||
onPressed: onRevision,
|
||||
icon: const Icon(Icons.replay_outlined),
|
||||
label: const Text('Revizyon İste'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size(double.infinity, 50),
|
||||
foregroundColor: AppColors.pending,
|
||||
side: const BorderSide(color: AppColors.pending),
|
||||
),
|
||||
),
|
||||
] else if (canMarkDelivered)
|
||||
FilledButton.icon(
|
||||
onPressed: onDelivered,
|
||||
icon: const Icon(Icons.inventory_2_outlined),
|
||||
label: const Text('Teslim Aldım'),
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size(double.infinity, 50),
|
||||
),
|
||||
),
|
||||
|
||||
if (onCancel != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton.icon(
|
||||
onPressed: onCancel,
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
label: const Text('İşi İptal Et'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size(double.infinity, 50),
|
||||
foregroundColor: AppColors.cancelled,
|
||||
side: const BorderSide(color: AppColors.cancelled),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Files panel
|
||||
JobFilesPanel(
|
||||
job: job,
|
||||
filesFuture: filesFuture,
|
||||
onRefresh: onFilesRefresh,
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Oluşturulma: ${_formatDate(job.dateCreated)}',
|
||||
style: const TextStyle(fontSize: 12, color: AppColors.textMuted),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDate(DateTime d, {bool withTime = false}) {
|
||||
final s = '${d.day.toString().padLeft(2, '0')}.${d.month.toString().padLeft(2, '0')}.${d.year}';
|
||||
if (!withTime || (d.hour == 0 && d.minute == 0)) return s;
|
||||
return '$s ${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
}
|
||||
|
||||
class _StepperWidget extends StatelessWidget {
|
||||
const _StepperWidget({
|
||||
required this.steps,
|
||||
required this.currentStepIndex,
|
||||
required this.historyFuture,
|
||||
});
|
||||
|
||||
final List<JobStep> steps;
|
||||
final int currentStepIndex;
|
||||
final Future<List<JobHistoryEntry>> historyFuture;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<List<JobHistoryEntry>>(
|
||||
future: historyFuture,
|
||||
builder: (ctx, snap) {
|
||||
final history = snap.data ?? [];
|
||||
final Map<JobStep, int> revisionCounts = {};
|
||||
for (final e in history) {
|
||||
if (e.action == JobHistoryAction.revisionRequested && e.step != null) {
|
||||
revisionCounts[e.step!] = (revisionCounts[e.step!] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: steps.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final step = entry.value;
|
||||
final isCompleted = index < currentStepIndex;
|
||||
final isCurrent = index == currentStepIndex;
|
||||
final revCount = revisionCounts[step] ?? 0;
|
||||
|
||||
Color dotColor;
|
||||
IconData dotIcon;
|
||||
if (isCompleted) {
|
||||
dotColor = AppColors.success;
|
||||
dotIcon = Icons.check_circle;
|
||||
} else if (isCurrent) {
|
||||
dotColor = AppColors.inProgress;
|
||||
dotIcon = Icons.radio_button_checked;
|
||||
} else {
|
||||
dotColor = AppColors.muted;
|
||||
dotIcon = Icons.radio_button_unchecked;
|
||||
}
|
||||
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
Icon(dotIcon, color: dotColor, size: 24),
|
||||
if (index < steps.length - 1)
|
||||
Container(
|
||||
width: 2,
|
||||
height: 44,
|
||||
color: index < currentStepIndex
|
||||
? AppColors.success.withValues(alpha: 0.35)
|
||||
: AppColors.border,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 2, bottom: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
step.label,
|
||||
style: TextStyle(
|
||||
fontWeight: isCurrent
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
color: isCompleted
|
||||
? AppColors.success
|
||||
: isCurrent
|
||||
? AppColors.inProgress
|
||||
: AppColors.textMuted,
|
||||
fontSize: 15,
|
||||
),
|
||||
),
|
||||
if (revCount > 0) ...[
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cancelledBg,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
'$revCount revizyon',
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.cancelled,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
if (isCurrent)
|
||||
Text(
|
||||
step.description,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionLabel extends StatelessWidget {
|
||||
const _SectionLabel({required this.title});
|
||||
final String title;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.accent,
|
||||
letterSpacing: 0.5),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoRow extends StatelessWidget {
|
||||
const _InfoRow({required this.label, required this.value});
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 110,
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 13, color: AppColors.textSecondary),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.textPrimary),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusBadge extends StatelessWidget {
|
||||
const _StatusBadge({required this.status});
|
||||
final JobStatus status;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = _color(status);
|
||||
final bg = _bg(status);
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
status.label,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _color(JobStatus s) {
|
||||
switch (s) {
|
||||
case JobStatus.pending:
|
||||
return AppColors.pending;
|
||||
case JobStatus.inProgress:
|
||||
return AppColors.inProgress;
|
||||
case JobStatus.sent:
|
||||
return AppColors.accent;
|
||||
case JobStatus.delivered:
|
||||
return AppColors.success;
|
||||
case JobStatus.cancelled:
|
||||
return AppColors.cancelled;
|
||||
}
|
||||
}
|
||||
|
||||
Color _bg(JobStatus s) {
|
||||
switch (s) {
|
||||
case JobStatus.pending:
|
||||
return AppColors.pendingBg;
|
||||
case JobStatus.inProgress:
|
||||
return AppColors.inProgressBg;
|
||||
case JobStatus.sent:
|
||||
return AppColors.inProgressBg;
|
||||
case JobStatus.delivered:
|
||||
return AppColors.successBg;
|
||||
case JobStatus.cancelled:
|
||||
return AppColors.cancelledBg;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import 'dart:async';
|
||||
import 'package:pocketbase/pocketbase.dart';
|
||||
import '../../../core/api/pocketbase_client.dart';
|
||||
import '../../../core/services/job_history_service.dart';
|
||||
import '../../../models/job.dart';
|
||||
|
||||
const _listExpand = 'clinic_tenant_id,lab_tenant_id';
|
||||
const _detailExpand = 'clinic_tenant_id,lab_tenant_id,patient_id,prosthetic_id';
|
||||
|
||||
class ClinicJobsRepository {
|
||||
ClinicJobsRepository._();
|
||||
static final instance = ClinicJobsRepository._();
|
||||
|
||||
PocketBase get _pb => PocketBaseClient.instance.pb;
|
||||
|
||||
Future<List<Job>> listOutbound(
|
||||
String clinicTenantId, {
|
||||
List<String>? statuses,
|
||||
String? location,
|
||||
String? filterExtra,
|
||||
int page = 1,
|
||||
int limit = 30,
|
||||
}) async {
|
||||
final filterParts = ['clinic_tenant_id = "$clinicTenantId"'];
|
||||
if (statuses != null && statuses.isNotEmpty) {
|
||||
final statusFilter = statuses.map((s) => 'status = "$s"').join(' || ');
|
||||
filterParts.add('($statusFilter)');
|
||||
}
|
||||
if (location != null) {
|
||||
filterParts.add('location = "$location"');
|
||||
}
|
||||
if (filterExtra != null) {
|
||||
filterParts.add('($filterExtra)');
|
||||
}
|
||||
|
||||
final result = await _pb.collection('jobs').getList(
|
||||
page: page,
|
||||
perPage: limit,
|
||||
filter: filterParts.join(' && '),
|
||||
expand: _listExpand,
|
||||
);
|
||||
return (result.items.map((r) => Job.fromJson(r.toJson())).toList()
|
||||
..sort((a, b) => b.dateCreated.compareTo(a.dateCreated)));
|
||||
}
|
||||
|
||||
Future<Job> getJob(String jobId) async {
|
||||
final record = await _pb.collection('jobs').getOne(jobId, expand: _detailExpand);
|
||||
return Job.fromJson(record.toJson());
|
||||
}
|
||||
|
||||
Future<Job> createJob({
|
||||
required String clinicTenantId,
|
||||
required String labTenantId,
|
||||
required String patientCode,
|
||||
required String prostheticId,
|
||||
required ProstheticType prostheticType,
|
||||
required List<String> teeth,
|
||||
String? patientId,
|
||||
String? color,
|
||||
String? description,
|
||||
String? dueDate,
|
||||
bool provaRequired = true,
|
||||
}) async {
|
||||
final record = await _pb.collection('jobs').create(body: {
|
||||
'clinic_tenant_id': clinicTenantId,
|
||||
'lab_tenant_id': labTenantId,
|
||||
'patient_code': patientCode,
|
||||
if (patientId != null) 'patient_id': patientId,
|
||||
'prosthetic_id': prostheticId,
|
||||
'prosthetic_type': prostheticType.value,
|
||||
'member_count': teeth.length,
|
||||
'teeth': teeth,
|
||||
if (color != null) 'color': color,
|
||||
if (description != null) 'description': description,
|
||||
if (dueDate != null) 'due_date': dueDate,
|
||||
'status': 'pending',
|
||||
'location': 'at_clinic',
|
||||
'prova_required': provaRequired,
|
||||
});
|
||||
return Job.fromJson(record.toJson());
|
||||
}
|
||||
|
||||
Future<Job> approveAtClinic(String jobId, Job job, {String? note}) async {
|
||||
final nextStep = job.nextStep;
|
||||
if (nextStep == null) throw Exception('Bu aşamadan ileri gidilemez.');
|
||||
|
||||
final record = await _pb.collection('jobs').update(jobId, body: {
|
||||
'current_step': nextStep.value,
|
||||
'location': 'at_lab',
|
||||
});
|
||||
final updated = Job.fromJson(record.toJson());
|
||||
unawaited(JobHistoryService.instance.append(
|
||||
jobId: jobId,
|
||||
clinicTenantId: job.clinicTenantId,
|
||||
labTenantId: job.labTenantId,
|
||||
action: JobHistoryAction.approved,
|
||||
step: job.currentStep,
|
||||
note: note,
|
||||
));
|
||||
return updated;
|
||||
}
|
||||
|
||||
Future<Job> requestRevision(String jobId, Job job, {required String note}) async {
|
||||
final record = await _pb.collection('jobs').update(jobId, body: {
|
||||
'location': 'at_lab',
|
||||
});
|
||||
final updated = Job.fromJson(record.toJson());
|
||||
unawaited(JobHistoryService.instance.append(
|
||||
jobId: jobId,
|
||||
clinicTenantId: job.clinicTenantId,
|
||||
labTenantId: job.labTenantId,
|
||||
action: JobHistoryAction.revisionRequested,
|
||||
step: job.currentStep,
|
||||
note: note,
|
||||
));
|
||||
return updated;
|
||||
}
|
||||
|
||||
Future<Job> markDelivered(String jobId, Job job, {String? note}) async {
|
||||
final record = await _pb.collection('jobs').update(jobId, body: {
|
||||
'status': 'delivered',
|
||||
});
|
||||
unawaited(JobHistoryService.instance.append(
|
||||
jobId: jobId,
|
||||
clinicTenantId: job.clinicTenantId,
|
||||
labTenantId: job.labTenantId,
|
||||
action: JobHistoryAction.delivered,
|
||||
note: note,
|
||||
));
|
||||
return Job.fromJson(record.toJson());
|
||||
}
|
||||
|
||||
Future<Job> cancelJob(String jobId, Job job) async {
|
||||
final record = await _pb.collection('jobs').update(jobId, body: {
|
||||
'status': 'cancelled',
|
||||
});
|
||||
unawaited(JobHistoryService.instance.append(
|
||||
jobId: jobId,
|
||||
clinicTenantId: job.clinicTenantId,
|
||||
labTenantId: job.labTenantId,
|
||||
action: JobHistoryAction.cancelled,
|
||||
));
|
||||
return Job.fromJson(record.toJson());
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> listApprovedLabs(String clinicTenantId) async {
|
||||
final result = await _pb.collection('connections').getList(
|
||||
filter: 'clinic_tenant_id = "$clinicTenantId" && status = "approved"',
|
||||
expand: 'lab_tenant_id',
|
||||
perPage: 100,
|
||||
);
|
||||
return result.items.map((r) {
|
||||
final expand = r.toJson()['expand'] as Map<String, dynamic>?;
|
||||
return expand?['lab_tenant_id'] as Map<String, dynamic>? ?? {'id': r.data['lab_tenant_id']};
|
||||
}).toList();
|
||||
}
|
||||
|
||||
Future<List<Job>> listJobsByPatient(String patientId, {int limit = 50}) async {
|
||||
final result = await _pb.collection('jobs').getList(
|
||||
filter: 'patient_id = "$patientId"',
|
||||
perPage: limit,
|
||||
expand: _listExpand,
|
||||
);
|
||||
return (result.items.map((r) => Job.fromJson(r.toJson())).toList()
|
||||
..sort((a, b) => b.dateCreated.compareTo(a.dateCreated)));
|
||||
}
|
||||
|
||||
Future<int> countDelivered(String clinicTenantId, {DateTime? from, DateTime? to}) async {
|
||||
final parts = ['clinic_tenant_id = "$clinicTenantId"', 'status = "delivered"'];
|
||||
if (from != null) parts.add('updated >= "${_date(from)}"');
|
||||
if (to != null) parts.add('updated < "${_date(to)}"');
|
||||
final r = await _pb.collection('jobs').getList(perPage: 1, filter: parts.join(' && '));
|
||||
return r.totalItems;
|
||||
}
|
||||
|
||||
static String _date(DateTime d) => d.toIso8601String().split('T').first;
|
||||
}
|
||||
@@ -0,0 +1,570 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../core/providers/auth_provider.dart';
|
||||
import '../../../core/router/app_router.dart';
|
||||
import '../../../core/services/realtime_service.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../core/widgets/gradient_app_bar.dart';
|
||||
import '../../../core/widgets/pill_tabs.dart';
|
||||
import '../../../models/job.dart';
|
||||
import 'clinic_jobs_repository.dart';
|
||||
|
||||
enum _JobSort { newestFirst, oldestFirst, byDueDate, byType }
|
||||
|
||||
const _kSortLabels = [
|
||||
'Yeniden Eskiye',
|
||||
'Eskiden Yeniye',
|
||||
'Vade Tarihine Göre',
|
||||
'Türe Göre',
|
||||
];
|
||||
|
||||
class ClinicJobsScreen extends ConsumerStatefulWidget {
|
||||
const ClinicJobsScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ClinicJobsScreen> createState() => _ClinicJobsScreenState();
|
||||
}
|
||||
|
||||
class _ClinicJobsScreenState extends ConsumerState<ClinicJobsScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
final _searchController = TextEditingController();
|
||||
String _searchQuery = '';
|
||||
_JobSort _sort = _JobSort.newestFirst;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 5, vsync: this);
|
||||
_tabController.addListener(() {
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onSearchChanged(String value) {
|
||||
setState(() => _searchQuery = value);
|
||||
}
|
||||
|
||||
Future<void> _showSortOptions() async {
|
||||
final result = await showSortSheet(
|
||||
context,
|
||||
title: 'Sıralama',
|
||||
options: _kSortLabels,
|
||||
current: _sort.index,
|
||||
);
|
||||
if (result != null) {
|
||||
setState(() => _sort = _JobSort.values[result]);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isSortActive = _sort != _JobSort.newestFirst;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
appBar: GradientAppBar(
|
||||
title: 'İşlerim',
|
||||
category: 'KLİNİK',
|
||||
searchController: _searchController,
|
||||
onSearchChanged: _onSearchChanged,
|
||||
searchHint: 'Protokol, laboratuvar veya tür ara...',
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: _showSortOptions,
|
||||
tooltip: 'Sırala',
|
||||
icon: Badge(
|
||||
isLabelVisible: isSortActive,
|
||||
smallSize: 8,
|
||||
backgroundColor: AppColors.accent,
|
||||
child: const Icon(Icons.sort_rounded),
|
||||
),
|
||||
),
|
||||
if (ref.watch(authProvider).activeTenant?.canCreateJobs ?? true)
|
||||
IconButton(
|
||||
onPressed: () => context.push(routeClinicJobNew),
|
||||
tooltip: 'Yeni İş',
|
||||
icon: const Icon(Icons.add_rounded),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
PillTabs(
|
||||
tabs: const ['Tümü', 'Onay Bekleyen', 'Lab\'da', 'Teslimat', 'Teslim Alındı'],
|
||||
selected: _tabController.index,
|
||||
onSelect: (i) => _tabController.animateTo(i),
|
||||
),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
_JobsTab(
|
||||
statuses: const ['pending', 'in_progress', 'sent', 'delivered'],
|
||||
searchQuery: _searchQuery,
|
||||
sort: _sort,
|
||||
),
|
||||
_JobsTab(
|
||||
statuses: const ['in_progress'],
|
||||
location: 'at_clinic',
|
||||
searchQuery: _searchQuery,
|
||||
sort: _sort,
|
||||
),
|
||||
_JobsTab(
|
||||
filterExtra: 'status = "pending" || (status = "in_progress" && location = "at_lab")',
|
||||
searchQuery: _searchQuery,
|
||||
sort: _sort,
|
||||
),
|
||||
_JobsTab(
|
||||
statuses: const ['sent'],
|
||||
searchQuery: _searchQuery,
|
||||
sort: _sort,
|
||||
),
|
||||
_JobsTab(
|
||||
statuses: const ['delivered'],
|
||||
searchQuery: _searchQuery,
|
||||
sort: _sort,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _JobsTab extends ConsumerStatefulWidget {
|
||||
const _JobsTab({
|
||||
this.statuses,
|
||||
this.location,
|
||||
this.filterExtra,
|
||||
required this.searchQuery,
|
||||
required this.sort,
|
||||
});
|
||||
|
||||
final List<String>? statuses;
|
||||
final String? location;
|
||||
final String? filterExtra;
|
||||
final String searchQuery;
|
||||
final _JobSort sort;
|
||||
|
||||
@override
|
||||
ConsumerState<_JobsTab> createState() => _JobsTabState();
|
||||
}
|
||||
|
||||
class _JobsTabState extends ConsumerState<_JobsTab> {
|
||||
final List<Job> _jobs = [];
|
||||
bool _isLoading = false;
|
||||
bool _hasMore = true;
|
||||
int _page = 1;
|
||||
static const _limit = 20;
|
||||
String? _error;
|
||||
late UnsubFn _unsub;
|
||||
|
||||
final _scrollController = ScrollController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
_scrollController.addListener(_onScroll);
|
||||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||||
_unsub = RealtimeService.instance.watch(
|
||||
'jobs',
|
||||
filter: 'clinic_tenant_id="$tenantId"',
|
||||
onEvent: (_) { if (mounted) _load(); },
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_unsub();
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
if (_scrollController.position.pixels >=
|
||||
_scrollController.position.maxScrollExtent - 200 &&
|
||||
!_isLoading &&
|
||||
_hasMore) {
|
||||
_loadMore();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
if (_isLoading) return;
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
_page = 1;
|
||||
_jobs.clear();
|
||||
_hasMore = true;
|
||||
});
|
||||
await _fetch();
|
||||
}
|
||||
|
||||
Future<void> _loadMore() async {
|
||||
if (_isLoading || !_hasMore) return;
|
||||
_page++;
|
||||
await _fetch();
|
||||
}
|
||||
|
||||
Future<void> _fetch() async {
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||||
final results = await ClinicJobsRepository.instance.listOutbound(
|
||||
tenantId,
|
||||
statuses: widget.statuses,
|
||||
location: widget.location,
|
||||
filterExtra: widget.filterExtra,
|
||||
page: _page,
|
||||
limit: _limit,
|
||||
);
|
||||
setState(() {
|
||||
_jobs.addAll(results);
|
||||
_hasMore = results.length == _limit;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_error = e.toString();
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
List<Job> get _filtered {
|
||||
var list = _jobs.toList();
|
||||
|
||||
final q = widget.searchQuery.toLowerCase().trim();
|
||||
if (q.isNotEmpty) {
|
||||
list = list.where((j) {
|
||||
return j.patientCode.toLowerCase().contains(q) ||
|
||||
(j.labName?.toLowerCase().contains(q) ?? false) ||
|
||||
j.prostheticType.label.toLowerCase().contains(q);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
switch (widget.sort) {
|
||||
case _JobSort.newestFirst:
|
||||
list.sort((a, b) => b.dateCreated.compareTo(a.dateCreated));
|
||||
case _JobSort.oldestFirst:
|
||||
list.sort((a, b) => a.dateCreated.compareTo(b.dateCreated));
|
||||
case _JobSort.byDueDate:
|
||||
list.sort((a, b) {
|
||||
if (a.dueDate == null && b.dueDate == null) return 0;
|
||||
if (a.dueDate == null) return 1;
|
||||
if (b.dueDate == null) return -1;
|
||||
return a.dueDate!.compareTo(b.dueDate!);
|
||||
});
|
||||
case _JobSort.byType:
|
||||
list.sort(
|
||||
(a, b) => a.prostheticType.label.compareTo(b.prostheticType.label));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_error != null && _jobs.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cancelledBg,
|
||||
borderRadius: BorderRadius.circular(16)),
|
||||
child: const Icon(Icons.wifi_off_rounded,
|
||||
color: AppColors.cancelled, size: 30),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text('Hata: $_error',
|
||||
style: const TextStyle(color: AppColors.textSecondary)),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.icon(
|
||||
onPressed: _load,
|
||||
icon: const Icon(Icons.refresh_rounded, size: 18),
|
||||
label: const Text('Tekrar Dene'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_isLoading && _jobs.isEmpty) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: AppColors.accent));
|
||||
}
|
||||
|
||||
final filtered = _filtered;
|
||||
|
||||
if (filtered.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 72,
|
||||
height: 72,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.inProgressBg,
|
||||
borderRadius: BorderRadius.circular(20)),
|
||||
child: const Icon(Icons.work_off_outlined,
|
||||
color: AppColors.inProgress, size: 32),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
widget.searchQuery.isNotEmpty
|
||||
? 'Sonuç bulunamadı'
|
||||
: 'Henüz iş yok',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
color: AppColors.accent,
|
||||
onRefresh: _load,
|
||||
child: ListView.builder(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
|
||||
itemCount:
|
||||
filtered.length + (_hasMore && widget.searchQuery.isEmpty ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
if (index == filtered.length) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(color: AppColors.accent)),
|
||||
);
|
||||
}
|
||||
final job = filtered[index];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: _JobListCard(
|
||||
job: job,
|
||||
onTap: () => context.push('/clinic/jobs/${job.id}'),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _JobListCard extends StatelessWidget {
|
||||
const _JobListCard({required this.job, required this.onTap});
|
||||
|
||||
final Job job;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final statusColor = _statusColor(job);
|
||||
final statusBg = _statusBg(job);
|
||||
final isOverdue =
|
||||
job.dueDate != null && job.dueDate!.isBefore(DateTime.now());
|
||||
|
||||
return Semantics(
|
||||
label: job.patientCode,
|
||||
button: true,
|
||||
excludeSemantics: true,
|
||||
child: Material(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.04),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2))
|
||||
]),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: statusBg,
|
||||
borderRadius: BorderRadius.circular(12)),
|
||||
child: Icon(Icons.medical_services_outlined,
|
||||
color: statusColor, size: 20),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
job.patientCode,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary),
|
||||
),
|
||||
),
|
||||
_StatusBadge(status: job.status, location: job.location),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(job.prostheticType.label,
|
||||
style: const TextStyle(
|
||||
fontSize: 12, color: AppColors.textSecondary)),
|
||||
if (job.labName != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
job.labName!,
|
||||
style: const TextStyle(
|
||||
fontSize: 12, color: AppColors.textMuted),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
if (job.dueDate != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.calendar_today_outlined,
|
||||
size: 11,
|
||||
color: isOverdue
|
||||
? AppColors.cancelled
|
||||
: AppColors.textMuted),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
_fmt(job.dueDate!),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: isOverdue
|
||||
? AppColors.cancelled
|
||||
: AppColors.textMuted,
|
||||
fontWeight: isOverdue
|
||||
? FontWeight.w600
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Icon(Icons.chevron_right,
|
||||
color: AppColors.textMuted, size: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _fmt(DateTime d) =>
|
||||
'${d.day.toString().padLeft(2, '0')}.${d.month.toString().padLeft(2, '0')}.${d.year}';
|
||||
|
||||
Color _statusColor(Job job) {
|
||||
if (job.status == JobStatus.inProgress && job.location == JobLocation.atClinic) return AppColors.pending;
|
||||
switch (job.status) {
|
||||
case JobStatus.pending: return AppColors.pending;
|
||||
case JobStatus.inProgress: return AppColors.inProgress;
|
||||
case JobStatus.sent: return AppColors.accent;
|
||||
case JobStatus.delivered: return AppColors.success;
|
||||
case JobStatus.cancelled: return AppColors.cancelled;
|
||||
}
|
||||
}
|
||||
|
||||
Color _statusBg(Job job) {
|
||||
if (job.status == JobStatus.inProgress && job.location == JobLocation.atClinic) return AppColors.pendingBg;
|
||||
switch (job.status) {
|
||||
case JobStatus.pending: return AppColors.pendingBg;
|
||||
case JobStatus.inProgress: return AppColors.inProgressBg;
|
||||
case JobStatus.sent: return AppColors.inProgressBg;
|
||||
case JobStatus.delivered: return AppColors.successBg;
|
||||
case JobStatus.cancelled: return AppColors.cancelledBg;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusBadge extends StatelessWidget {
|
||||
const _StatusBadge({required this.status, required this.location});
|
||||
|
||||
final JobStatus status;
|
||||
final JobLocation location;
|
||||
|
||||
String get _label {
|
||||
if (status == JobStatus.inProgress && location == JobLocation.atClinic) return 'Onay Bekliyor';
|
||||
if (status == JobStatus.sent) return 'Teslimat Bekliyor';
|
||||
return status.label;
|
||||
}
|
||||
|
||||
Color get _color {
|
||||
if (status == JobStatus.inProgress && location == JobLocation.atClinic) return AppColors.pending;
|
||||
switch (status) {
|
||||
case JobStatus.pending: return AppColors.pending;
|
||||
case JobStatus.inProgress: return AppColors.inProgress;
|
||||
case JobStatus.sent: return AppColors.accent;
|
||||
case JobStatus.delivered: return AppColors.success;
|
||||
case JobStatus.cancelled: return AppColors.cancelled;
|
||||
}
|
||||
}
|
||||
|
||||
Color get _bg {
|
||||
if (status == JobStatus.inProgress && location == JobLocation.atClinic) return AppColors.pendingBg;
|
||||
switch (status) {
|
||||
case JobStatus.pending: return AppColors.pendingBg;
|
||||
case JobStatus.inProgress: return AppColors.inProgressBg;
|
||||
case JobStatus.sent: return AppColors.inProgressBg;
|
||||
case JobStatus.delivered: return AppColors.successBg;
|
||||
case JobStatus.cancelled: return AppColors.cancelledBg;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: _bg,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
_label,
|
||||
style: TextStyle(
|
||||
color: _color,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,717 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../models/job.dart';
|
||||
import '../../../models/patient.dart';
|
||||
import '../jobs/clinic_jobs_repository.dart';
|
||||
import 'clinic_patients_repository.dart';
|
||||
|
||||
class ClinicPatientDetailScreen extends ConsumerStatefulWidget {
|
||||
const ClinicPatientDetailScreen({super.key, required this.patientId});
|
||||
final String patientId;
|
||||
|
||||
@override
|
||||
ConsumerState<ClinicPatientDetailScreen> createState() =>
|
||||
_ClinicPatientDetailScreenState();
|
||||
}
|
||||
|
||||
class _ClinicPatientDetailScreenState
|
||||
extends ConsumerState<ClinicPatientDetailScreen> {
|
||||
late Future<Patient> _future;
|
||||
late Future<List<Job>> _jobsFuture;
|
||||
bool _editMode = false;
|
||||
bool _isSaving = false;
|
||||
|
||||
final _patientCodeController = TextEditingController();
|
||||
final _firstNameController = TextEditingController();
|
||||
final _lastNameController = TextEditingController();
|
||||
final _phoneController = TextEditingController();
|
||||
final _notesController = TextEditingController();
|
||||
String? _birthDate;
|
||||
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_patientCodeController.dispose();
|
||||
_firstNameController.dispose();
|
||||
_lastNameController.dispose();
|
||||
_phoneController.dispose();
|
||||
_notesController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _load() {
|
||||
setState(() {
|
||||
_future = ClinicPatientsRepository.instance
|
||||
.getPatient(widget.patientId)
|
||||
.then((p) {
|
||||
_populateControllers(p);
|
||||
return p;
|
||||
});
|
||||
_jobsFuture = ClinicJobsRepository.instance
|
||||
.listJobsByPatient(widget.patientId);
|
||||
});
|
||||
}
|
||||
|
||||
void _populateControllers(Patient p) {
|
||||
_patientCodeController.text = p.patientCode;
|
||||
_firstNameController.text = p.firstName ?? '';
|
||||
_lastNameController.text = p.lastName ?? '';
|
||||
_phoneController.text = p.phone ?? '';
|
||||
_notesController.text = p.notes ?? '';
|
||||
_birthDate = p.birthDate;
|
||||
}
|
||||
|
||||
Future<void> _pickBirthDate() async {
|
||||
DateTime initial = DateTime(1990);
|
||||
if (_birthDate != null) {
|
||||
try {
|
||||
initial = DateTime.parse(_birthDate!);
|
||||
} catch (_) {}
|
||||
}
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: initial,
|
||||
firstDate: DateTime(1900),
|
||||
lastDate: DateTime.now(),
|
||||
);
|
||||
if (picked != null) {
|
||||
setState(() {
|
||||
_birthDate = picked.toIso8601String().split('T').first;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
setState(() => _isSaving = true);
|
||||
try {
|
||||
final patch = <String, dynamic>{
|
||||
'patient_code': _patientCodeController.text.trim(),
|
||||
'first_name': _firstNameController.text.trim().isNotEmpty
|
||||
? _firstNameController.text.trim()
|
||||
: null,
|
||||
'last_name': _lastNameController.text.trim().isNotEmpty
|
||||
? _lastNameController.text.trim()
|
||||
: null,
|
||||
'phone': _phoneController.text.trim().isNotEmpty
|
||||
? _phoneController.text.trim()
|
||||
: null,
|
||||
'birth_date': _birthDate,
|
||||
'notes': _notesController.text.trim().isNotEmpty
|
||||
? _notesController.text.trim()
|
||||
: null,
|
||||
};
|
||||
final updated = await ClinicPatientsRepository.instance
|
||||
.updatePatient(widget.patientId, patch);
|
||||
_populateControllers(updated);
|
||||
setState(() {
|
||||
_editMode = false;
|
||||
_future = Future.value(updated);
|
||||
});
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Hasta bilgileri güncellendi.')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Hata: $e')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isSaving = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _delete() async {
|
||||
// Check for existing jobs first
|
||||
List<Job>? jobs;
|
||||
try {
|
||||
jobs = await ClinicJobsRepository.instance
|
||||
.listJobsByPatient(widget.patientId, limit: 1);
|
||||
} catch (_) {
|
||||
jobs = null;
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
final hasJobs = (jobs?.isNotEmpty) ?? false;
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) => AlertDialog(
|
||||
title: const Text('Hastayı Sil'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Bu hastayı silmek istediğinizden emin misiniz?'),
|
||||
if (hasJobs) ...[
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cancelledBg,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.warning_amber_rounded,
|
||||
color: AppColors.cancelled, size: 18),
|
||||
SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Bu hastaya ait işler bulunmaktadır. Hasta silinirse bu bağlantı kopar.',
|
||||
style: TextStyle(
|
||||
fontSize: 13, color: AppColors.cancelled),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Vazgeç')),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
style:
|
||||
FilledButton.styleFrom(backgroundColor: AppColors.cancelled),
|
||||
child: const Text('Sil'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed != true || !mounted) return;
|
||||
|
||||
try {
|
||||
await ClinicPatientsRepository.instance.deletePatient(widget.patientId);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Hasta silindi.')),
|
||||
);
|
||||
Navigator.of(context).pop(true); // signal that a delete happened
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Silme hatası: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Hasta Detayı'),
|
||||
actions: [
|
||||
if (!_editMode) ...[
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
tooltip: 'Düzenle',
|
||||
onPressed: () => setState(() => _editMode = true),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outline, color: AppColors.cancelled),
|
||||
tooltip: 'Sil',
|
||||
onPressed: _delete,
|
||||
),
|
||||
] else ...[
|
||||
if (_isSaving)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
)
|
||||
else ...[
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
setState(() => _editMode = false);
|
||||
_future.then(_populateControllers);
|
||||
},
|
||||
child: const Text('İptal'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: _save,
|
||||
child: const Text('Kaydet'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
body: FutureBuilder<Patient>(
|
||||
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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_editMode) {
|
||||
return _EditForm(
|
||||
formKey: _formKey,
|
||||
patientCodeController: _patientCodeController,
|
||||
firstNameController: _firstNameController,
|
||||
lastNameController: _lastNameController,
|
||||
phoneController: _phoneController,
|
||||
notesController: _notesController,
|
||||
birthDate: _birthDate,
|
||||
onPickBirthDate: _pickBirthDate,
|
||||
);
|
||||
}
|
||||
|
||||
final patient = snap.data!;
|
||||
return _PatientView(patient: patient, jobsFuture: _jobsFuture);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── View ───────────────────────────────────────────────────────────────────
|
||||
|
||||
class _PatientView extends StatelessWidget {
|
||||
const _PatientView({required this.patient, required this.jobsFuture});
|
||||
final Patient patient;
|
||||
final Future<List<Job>> jobsFuture;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
// Avatar + name header
|
||||
Center(
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 72,
|
||||
height: 72,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.inProgressBg,
|
||||
borderRadius: BorderRadius.circular(20)),
|
||||
child: Center(
|
||||
child: Text(
|
||||
patient.displayName.isNotEmpty
|
||||
? patient.displayName[0].toUpperCase()
|
||||
: '?',
|
||||
style: const TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.inProgress),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
patient.displayName,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
patient.patientCode,
|
||||
style: const TextStyle(
|
||||
fontSize: 13, color: AppColors.textSecondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
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: [
|
||||
_DetailRow(label: 'Hasta Kodu', value: patient.patientCode),
|
||||
if (patient.firstName != null)
|
||||
_DetailRow(label: 'Ad', value: patient.firstName!),
|
||||
if (patient.lastName != null)
|
||||
_DetailRow(label: 'Soyad', value: patient.lastName!),
|
||||
if (patient.phone != null && patient.phone!.isNotEmpty)
|
||||
_DetailRow(label: 'Telefon', value: patient.phone!),
|
||||
if (patient.birthDate != null && patient.birthDate!.isNotEmpty)
|
||||
_DetailRow(
|
||||
label: 'Doğum Tarihi', value: patient.birthDate!),
|
||||
if (patient.notes != null && patient.notes!.isNotEmpty)
|
||||
_DetailRow(label: 'Notlar', value: patient.notes!),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Job history
|
||||
_JobHistory(jobsFuture: jobsFuture),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Job history ────────────────────────────────────────────────────────────
|
||||
|
||||
class _JobHistory extends StatelessWidget {
|
||||
const _JobHistory({required this.jobsFuture});
|
||||
final Future<List<Job>> jobsFuture;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'İŞ GEÇMİŞİ',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textSecondary,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
FutureBuilder<List<Job>>(
|
||||
future: jobsFuture,
|
||||
builder: (ctx, snap) {
|
||||
if (snap.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (snap.hasError) {
|
||||
return Text('Yüklenemedi: ${snap.error}',
|
||||
style:
|
||||
const TextStyle(color: AppColors.textSecondary));
|
||||
}
|
||||
final jobs = snap.data ?? [];
|
||||
if (jobs.isEmpty) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'Henüz iş kaydı yok.',
|
||||
style: TextStyle(color: AppColors.textSecondary),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Container(
|
||||
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(
|
||||
children: jobs.asMap().entries.map((entry) {
|
||||
final i = entry.key;
|
||||
final job = entry.value;
|
||||
final isLast = i == jobs.length - 1;
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: _statusBg(job.status),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Icon(
|
||||
_statusIcon(job.status),
|
||||
color: _statusColor(job.status),
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
job.patientCode,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${job.prostheticType.label} · ${_statusLabel(job.status)}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_formatDate(job.dateCreated),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (!isLast)
|
||||
const Divider(
|
||||
height: 1,
|
||||
indent: 68,
|
||||
color: AppColors.border),
|
||||
],
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
static Color _statusBg(JobStatus s) => switch (s) {
|
||||
JobStatus.delivered => AppColors.successBg,
|
||||
JobStatus.cancelled => AppColors.cancelledBg,
|
||||
JobStatus.inProgress => AppColors.inProgressBg,
|
||||
_ => AppColors.pendingBg,
|
||||
};
|
||||
|
||||
static Color _statusColor(JobStatus s) => switch (s) {
|
||||
JobStatus.delivered => AppColors.success,
|
||||
JobStatus.cancelled => AppColors.cancelled,
|
||||
JobStatus.inProgress => AppColors.inProgress,
|
||||
_ => AppColors.pending,
|
||||
};
|
||||
|
||||
static IconData _statusIcon(JobStatus s) => switch (s) {
|
||||
JobStatus.delivered => Icons.check_circle_outline,
|
||||
JobStatus.cancelled => Icons.cancel_outlined,
|
||||
JobStatus.inProgress => Icons.autorenew,
|
||||
_ => Icons.hourglass_empty_outlined,
|
||||
};
|
||||
|
||||
static String _statusLabel(JobStatus s) => switch (s) {
|
||||
JobStatus.pending => 'Bekliyor',
|
||||
JobStatus.inProgress => 'Üretimde',
|
||||
JobStatus.sent => 'Gönderildi',
|
||||
JobStatus.delivered => 'Teslim Edildi',
|
||||
JobStatus.cancelled => 'İptal',
|
||||
};
|
||||
|
||||
static String _formatDate(DateTime d) {
|
||||
return '${d.day.toString().padLeft(2, '0')}.${d.month.toString().padLeft(2, '0')}.${d.year}';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
class _DetailRow extends StatelessWidget {
|
||||
const _DetailRow({required this.label, required this.value});
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 120,
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 13, color: AppColors.textSecondary),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.textPrimary),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Edit form ──────────────────────────────────────────────────────────────
|
||||
|
||||
class _EditForm extends StatelessWidget {
|
||||
const _EditForm({
|
||||
required this.formKey,
|
||||
required this.patientCodeController,
|
||||
required this.firstNameController,
|
||||
required this.lastNameController,
|
||||
required this.phoneController,
|
||||
required this.notesController,
|
||||
required this.birthDate,
|
||||
required this.onPickBirthDate,
|
||||
});
|
||||
|
||||
final GlobalKey<FormState> formKey;
|
||||
final TextEditingController patientCodeController;
|
||||
final TextEditingController firstNameController;
|
||||
final TextEditingController lastNameController;
|
||||
final TextEditingController phoneController;
|
||||
final TextEditingController notesController;
|
||||
final String? birthDate;
|
||||
final VoidCallback onPickBirthDate;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Form(
|
||||
key: formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: patientCodeController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Hasta Kodu *',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
validator: (val) =>
|
||||
(val == null || val.trim().isEmpty)
|
||||
? 'Hasta kodu zorunludur'
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: firstNameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Ad',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: lastNameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Soyad',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: phoneController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Telefon',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: TextInputType.phone,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
InkWell(
|
||||
onTap: onPickBirthDate,
|
||||
child: InputDecorator(
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Doğum Tarihi',
|
||||
border: OutlineInputBorder(),
|
||||
suffixIcon: Icon(Icons.calendar_today),
|
||||
),
|
||||
child: Text(
|
||||
birthDate ?? 'Tarih seçin',
|
||||
style: birthDate != null
|
||||
? null
|
||||
: TextStyle(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: notesController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Notlar',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
minLines: 3,
|
||||
maxLines: 5,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import 'package:pocketbase/pocketbase.dart';
|
||||
import '../../../core/api/pocketbase_client.dart';
|
||||
import '../../../models/patient.dart';
|
||||
|
||||
class ClinicPatientsRepository {
|
||||
ClinicPatientsRepository._();
|
||||
static final instance = ClinicPatientsRepository._();
|
||||
|
||||
PocketBase get _pb => PocketBaseClient.instance.pb;
|
||||
|
||||
Future<List<Patient>> listPatients(
|
||||
String tenantId, {
|
||||
String? search,
|
||||
int page = 1,
|
||||
int limit = 30,
|
||||
}) async {
|
||||
final filterParts = ['tenant_id = "$tenantId"'];
|
||||
if (search != null && search.isNotEmpty) {
|
||||
filterParts.add(
|
||||
'(patient_code ~ "$search" || first_name ~ "$search" || last_name ~ "$search")',
|
||||
);
|
||||
}
|
||||
|
||||
final result = await _pb.collection('patients').getList(
|
||||
page: page,
|
||||
perPage: limit,
|
||||
filter: filterParts.join(' && '),
|
||||
);
|
||||
return (result.items.map((r) => Patient.fromJson(r.toJson())).toList()
|
||||
..sort((a, b) => a.patientCode.compareTo(b.patientCode)));
|
||||
}
|
||||
|
||||
Future<Patient> getPatient(String patientId) async {
|
||||
final record = await _pb.collection('patients').getOne(patientId);
|
||||
return Patient.fromJson(record.toJson());
|
||||
}
|
||||
|
||||
Future<Patient> createPatient({
|
||||
required String tenantId,
|
||||
required String patientCode,
|
||||
String? firstName,
|
||||
String? lastName,
|
||||
String? birthDate,
|
||||
String? phone,
|
||||
String? notes,
|
||||
}) async {
|
||||
final record = await _pb.collection('patients').create(body: {
|
||||
'tenant_id': tenantId,
|
||||
'patient_code': patientCode,
|
||||
if (firstName != null) 'first_name': firstName,
|
||||
if (lastName != null) 'last_name': lastName,
|
||||
if (birthDate != null) 'birth_date': birthDate,
|
||||
if (phone != null) 'phone': phone,
|
||||
if (notes != null) 'notes': notes,
|
||||
});
|
||||
return Patient.fromJson(record.toJson());
|
||||
}
|
||||
|
||||
Future<Patient> updatePatient(String patientId, Map<String, dynamic> patch) async {
|
||||
final record = await _pb.collection('patients').update(patientId, body: patch);
|
||||
return Patient.fromJson(record.toJson());
|
||||
}
|
||||
|
||||
Future<void> deletePatient(String patientId) async {
|
||||
await _pb.collection('patients').delete(patientId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,576 @@
|
||||
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/theme/app_theme.dart';
|
||||
import '../../../core/widgets/gradient_app_bar.dart';
|
||||
import '../../../models/patient.dart';
|
||||
import 'clinic_patients_repository.dart';
|
||||
|
||||
enum _PatientSort { nameAZ, nameZA, byCode }
|
||||
|
||||
const _kSortLabels = [
|
||||
'Ada Göre (A → Z)',
|
||||
'Ada Göre (Z → A)',
|
||||
'Hasta Koduna Göre',
|
||||
];
|
||||
|
||||
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 ClinicPatientsScreen extends ConsumerStatefulWidget {
|
||||
const ClinicPatientsScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ClinicPatientsScreen> createState() =>
|
||||
_ClinicPatientsScreenState();
|
||||
}
|
||||
|
||||
class _ClinicPatientsScreenState extends ConsumerState<ClinicPatientsScreen> {
|
||||
late Future<List<Patient>> _future;
|
||||
final _searchController = TextEditingController();
|
||||
String _searchQuery = '';
|
||||
_PatientSort _sort = _PatientSort.nameAZ;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _load([String? search]) {
|
||||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||||
setState(() {
|
||||
_future = ClinicPatientsRepository.instance.listPatients(
|
||||
tenantId,
|
||||
search: search?.trim().isNotEmpty == true ? search : null,
|
||||
limit: 100,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void _onSearchChanged(String value) {
|
||||
setState(() => _searchQuery = value);
|
||||
_load(value);
|
||||
}
|
||||
|
||||
Future<void> _showSortOptions() async {
|
||||
final result = await showSortSheet(
|
||||
context,
|
||||
title: 'Sıralama',
|
||||
options: _kSortLabels,
|
||||
current: _sort.index,
|
||||
);
|
||||
if (result != null) {
|
||||
setState(() => _sort = _PatientSort.values[result]);
|
||||
}
|
||||
}
|
||||
|
||||
List<Patient> _sorted(List<Patient> patients) {
|
||||
final list = List<Patient>.from(patients);
|
||||
switch (_sort) {
|
||||
case _PatientSort.nameAZ:
|
||||
list.sort((a, b) => a.displayName.compareTo(b.displayName));
|
||||
case _PatientSort.nameZA:
|
||||
list.sort((a, b) => b.displayName.compareTo(a.displayName));
|
||||
case _PatientSort.byCode:
|
||||
list.sort((a, b) => a.patientCode.compareTo(b.patientCode));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
void _showNewPatientSheet() {
|
||||
_showAdaptive(
|
||||
context,
|
||||
_NewPatientSheet(
|
||||
onCreated: () {
|
||||
_load(_searchQuery);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Hasta oluşturuldu.')),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isSortActive = _sort != _PatientSort.nameAZ;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
appBar: GradientAppBar(
|
||||
title: 'Hastalar',
|
||||
category: 'KLİNİK',
|
||||
searchController: _searchController,
|
||||
onSearchChanged: _onSearchChanged,
|
||||
searchHint: 'Ad, soyad veya kod ile arayın...',
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: _showSortOptions,
|
||||
tooltip: 'Sırala',
|
||||
icon: Badge(
|
||||
isLabelVisible: isSortActive,
|
||||
smallSize: 8,
|
||||
backgroundColor: AppColors.accent,
|
||||
child: const Icon(Icons.sort_rounded),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: _showNewPatientSheet,
|
||||
tooltip: 'Yeni Hasta',
|
||||
icon: const Icon(Icons.person_add_outlined),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: RefreshIndicator(
|
||||
color: AppColors.accent,
|
||||
onRefresh: () async => _load(_searchQuery),
|
||||
child: FutureBuilder<List<Patient>>(
|
||||
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(_searchQuery),
|
||||
icon: const Icon(Icons.refresh_rounded, size: 18),
|
||||
label: const Text('Tekrar Dene'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
final patients = _sorted(snap.data!);
|
||||
if (patients.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.people_outline,
|
||||
color: AppColors.inProgress, size: 32),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_searchQuery.isNotEmpty
|
||||
? 'Sonuç bulunamadı'
|
||||
: 'Henüz hasta yok',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary),
|
||||
),
|
||||
if (_searchQuery.isEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Yeni hasta eklemek için + düğmesine dokunun',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.textSecondary),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
|
||||
itemCount: patients.length,
|
||||
itemBuilder: (context, index) {
|
||||
final patient = patients[index];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: _PatientCard(
|
||||
patient: patient,
|
||||
onTap: () => context
|
||||
.push('/clinic/patients/${patient.id}'),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PatientCard extends StatelessWidget {
|
||||
const _PatientCard({required this.patient, required this.onTap});
|
||||
|
||||
final Patient patient;
|
||||
final VoidCallback onTap;
|
||||
|
||||
String get _initials {
|
||||
final name = patient.displayName;
|
||||
if (name.isEmpty) return '?';
|
||||
final parts = name.trim().split(' ');
|
||||
if (parts.length >= 2) {
|
||||
return '${parts.first[0]}${parts.last[0]}'.toUpperCase();
|
||||
}
|
||||
return name[0].toUpperCase();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return 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: 46,
|
||||
height: 46,
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFF1E3A5F), Color(0xFF0369A1)],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(13),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
_initials,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
patient.displayName.isNotEmpty
|
||||
? patient.displayName
|
||||
: patient.patientCode,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
patient.patientCode,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: AppColors.textSecondary,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (patient.phone != null &&
|
||||
patient.phone!.isNotEmpty) ...[
|
||||
const SizedBox(width: 8),
|
||||
const Icon(Icons.phone_outlined,
|
||||
size: 11, color: AppColors.textMuted),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
patient.phone!,
|
||||
style: const TextStyle(
|
||||
fontSize: 11, color: AppColors.textMuted),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Icon(Icons.chevron_right,
|
||||
color: AppColors.textMuted, size: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── New Patient Sheet ─────────────────────────────────────────────────────────
|
||||
|
||||
class _NewPatientSheet extends ConsumerStatefulWidget {
|
||||
const _NewPatientSheet({required this.onCreated});
|
||||
|
||||
final VoidCallback onCreated;
|
||||
|
||||
@override
|
||||
ConsumerState<_NewPatientSheet> createState() => _NewPatientSheetState();
|
||||
}
|
||||
|
||||
class _NewPatientSheetState extends ConsumerState<_NewPatientSheet> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _patientCodeController = TextEditingController();
|
||||
final _firstNameController = TextEditingController();
|
||||
final _lastNameController = TextEditingController();
|
||||
final _phoneController = TextEditingController();
|
||||
final _notesController = TextEditingController();
|
||||
String? _birthDate;
|
||||
bool _isSubmitting = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_patientCodeController.dispose();
|
||||
_firstNameController.dispose();
|
||||
_lastNameController.dispose();
|
||||
_phoneController.dispose();
|
||||
_notesController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _pickBirthDate() async {
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: DateTime(1990),
|
||||
firstDate: DateTime(1900),
|
||||
lastDate: DateTime.now(),
|
||||
);
|
||||
if (picked != null) {
|
||||
setState(() {
|
||||
_birthDate = picked.toIso8601String().split('T').first;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
setState(() => _isSubmitting = true);
|
||||
try {
|
||||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||||
await ClinicPatientsRepository.instance.createPatient(
|
||||
tenantId: tenantId,
|
||||
patientCode: _patientCodeController.text.trim(),
|
||||
firstName: _firstNameController.text.trim().isNotEmpty
|
||||
? _firstNameController.text.trim()
|
||||
: null,
|
||||
lastName: _lastNameController.text.trim().isNotEmpty
|
||||
? _lastNameController.text.trim()
|
||||
: null,
|
||||
phone: _phoneController.text.trim().isNotEmpty
|
||||
? _phoneController.text.trim()
|
||||
: null,
|
||||
birthDate: _birthDate,
|
||||
notes: _notesController.text.trim().isNotEmpty
|
||||
? _notesController.text.trim()
|
||||
: null,
|
||||
);
|
||||
widget.onCreated();
|
||||
// Pop using form's own context (inside dialog) to ensure correct Navigator
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Hata: $e')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isSubmitting = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDesktop =
|
||||
MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint;
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.vertical(
|
||||
top: isDesktop ? Radius.zero : const Radius.circular(20),
|
||||
),
|
||||
),
|
||||
padding: EdgeInsets.only(
|
||||
bottom: isDesktop ? 0 : MediaQuery.of(context).viewInsets.bottom,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Yeni Hasta',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleLarge
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _patientCodeController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Hasta Kodu *',
|
||||
hintText: 'Ör: P-001',
|
||||
),
|
||||
validator: (val) =>
|
||||
(val == null || val.trim().isEmpty)
|
||||
? 'Hasta kodu zorunludur'
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _firstNameController,
|
||||
decoration: const InputDecoration(labelText: 'Ad'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _lastNameController,
|
||||
decoration: const InputDecoration(labelText: 'Soyad'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _phoneController,
|
||||
decoration: const InputDecoration(labelText: 'Telefon'),
|
||||
keyboardType: TextInputType.phone,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
InkWell(
|
||||
onTap: _pickBirthDate,
|
||||
child: InputDecorator(
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Doğum Tarihi',
|
||||
suffixIcon: Icon(Icons.calendar_today),
|
||||
),
|
||||
child: Text(
|
||||
_birthDate ?? 'Tarih seçin',
|
||||
style: _birthDate != null
|
||||
? null
|
||||
: const TextStyle(color: AppColors.textMuted),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _notesController,
|
||||
decoration: const InputDecoration(labelText: 'Notlar'),
|
||||
minLines: 2,
|
||||
maxLines: 3,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
if (_isSubmitting)
|
||||
const Center(
|
||||
child: CircularProgressIndicator(color: AppColors.accent))
|
||||
else
|
||||
FilledButton(
|
||||
onPressed: _submit,
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size(double.infinity, 48),
|
||||
),
|
||||
child: const Text('Hasta Oluştur'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,685 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../core/l10n/app_strings.dart';
|
||||
import '../../../core/providers/auth_provider.dart';
|
||||
import '../../../core/providers/locale_provider.dart';
|
||||
import '../../../core/router/app_router.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../models/tenant.dart';
|
||||
import '../../shared/tenant_team_screen.dart';
|
||||
import '../connections/clinic_connections_screen.dart';
|
||||
|
||||
class ClinicSettingsScreen extends ConsumerWidget {
|
||||
const ClinicSettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final auth = ref.watch(authProvider);
|
||||
final s = ref.watch(stringsProvider);
|
||||
final profile = auth.profile;
|
||||
final membership = auth.activeTenant;
|
||||
final tenant = membership?.tenant;
|
||||
final canEdit = membership?.isAdmin ?? false;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(s.settings)),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
// User card
|
||||
_SectionHeader(title: s.userInfo),
|
||||
_UserCard(profile: profile),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Clinic info
|
||||
_SectionHeader(
|
||||
title: s.clinicInfo,
|
||||
action: canEdit
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.edit_outlined,
|
||||
size: 18, color: AppColors.accent),
|
||||
tooltip: s.edit,
|
||||
onPressed: () => _showEditSheet(context, ref, tenant, s),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
_InfoCard(children: [
|
||||
_InfoTile(
|
||||
icon: Icons.business,
|
||||
label: s.clinicName,
|
||||
value: tenant?.companyName ?? '-',
|
||||
),
|
||||
_InfoTile(
|
||||
icon: Icons.category_outlined,
|
||||
label: s.type,
|
||||
value: _tenantKindLabel(tenant?.kind, s),
|
||||
),
|
||||
_InfoTile(
|
||||
icon: Icons.star_outline,
|
||||
label: s.role,
|
||||
value: _roleLabel(membership?.role, s),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Connections
|
||||
if (membership?.showConnections ?? false) ...[
|
||||
_SectionHeader(title: s.connections),
|
||||
_InfoCard(children: [
|
||||
_NavTile(
|
||||
icon: Icons.link_rounded,
|
||||
iconColor: AppColors.inProgress,
|
||||
iconBg: AppColors.inProgressBg,
|
||||
title: s.labConnections,
|
||||
subtitle: s.labConnectionsSub,
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const ClinicConnectionsScreen()),
|
||||
),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
|
||||
// Other memberships
|
||||
if (auth.memberships.length > 1) ...[
|
||||
_SectionHeader(title: s.otherMemberships),
|
||||
_InfoCard(children: [
|
||||
for (final m
|
||||
in auth.memberships.where((m) => m.id != membership?.id))
|
||||
_NavTile(
|
||||
icon: Icons.switch_account_outlined,
|
||||
iconColor: AppColors.inProgress,
|
||||
iconBg: AppColors.inProgressBg,
|
||||
title: m.tenant.companyName,
|
||||
subtitle: _tenantKindLabel(m.tenant.kind, s),
|
||||
onTap: () {
|
||||
ref.read(authProvider.notifier).setActiveTenant(m);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(s.tenantSelected(m.tenant.companyName))),
|
||||
);
|
||||
},
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
|
||||
// Team management + Reports
|
||||
if (membership?.canManageUsers ?? false) ...[
|
||||
_SectionHeader(title: s.management),
|
||||
_InfoCard(children: [
|
||||
_NavTile(
|
||||
icon: Icons.group_outlined,
|
||||
iconColor: AppColors.inProgress,
|
||||
iconBg: AppColors.inProgressBg,
|
||||
title: s.team,
|
||||
subtitle: s.teamSub,
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const TenantTeamScreen()),
|
||||
),
|
||||
),
|
||||
_NavTile(
|
||||
icon: Icons.bar_chart_rounded,
|
||||
iconColor: AppColors.accent,
|
||||
iconBg: AppColors.inProgressBg,
|
||||
title: s.reports,
|
||||
subtitle: s.reportsSub,
|
||||
onTap: () => context.push(routeClinicReports),
|
||||
),
|
||||
_NavTile(
|
||||
icon: Icons.auto_awesome_outlined,
|
||||
iconColor: const Color(0xFF7C3AED),
|
||||
iconBg: const Color(0xFFF3E8FF),
|
||||
title: s.aiAssistant,
|
||||
subtitle: s.aiAssistantSub,
|
||||
onTap: () => context.push(routeClinicAi),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
|
||||
// Preferences (language)
|
||||
_SectionHeader(title: s.preferences),
|
||||
_InfoCard(children: [
|
||||
_NavTile(
|
||||
icon: Icons.language_outlined,
|
||||
iconColor: AppColors.accent,
|
||||
iconBg: AppColors.inProgressBg,
|
||||
title: s.appLanguage,
|
||||
subtitle: _currentLanguageLabel(ref.watch(localeProvider).languageCode, s),
|
||||
onTap: () => _showLanguagePicker(context, ref, s),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Sign out
|
||||
_SignOutCard(ref: ref, s: s),
|
||||
const SizedBox(height: 32),
|
||||
const Center(
|
||||
child: Text('DLS — Dental Lab System',
|
||||
style: TextStyle(fontSize: 12, color: AppColors.textMuted)),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
const Center(
|
||||
child: Text('Geliştirici: kovakyazilim.com',
|
||||
style: TextStyle(fontSize: 12, color: AppColors.textMuted)),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showEditSheet(BuildContext context, WidgetRef ref, Tenant? tenant, AppStrings s) {
|
||||
if (tenant == null) return;
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (_) => _EditTenantSheet(
|
||||
tenant: tenant,
|
||||
s: s,
|
||||
onSave: (name) async {
|
||||
await ref
|
||||
.read(authProvider.notifier)
|
||||
.updateTenantInfo(tenantId: tenant.id, companyName: name);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showLanguagePicker(BuildContext context, WidgetRef ref, AppStrings s) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (_) => _LanguagePickerSheet(s: s, ref: ref),
|
||||
);
|
||||
}
|
||||
|
||||
static String _currentLanguageLabel(String code, AppStrings s) => switch (code) {
|
||||
'en' => s.languageEnglish,
|
||||
'ru' => s.languageRussian,
|
||||
'ar' => s.languageArabic,
|
||||
'de' => s.languageGerman,
|
||||
_ => s.languageTurkish,
|
||||
};
|
||||
|
||||
static String _tenantKindLabel(TenantKind? kind, AppStrings s) =>
|
||||
switch (kind) {
|
||||
TenantKind.clinic => s.tenantKindClinic,
|
||||
TenantKind.lab => s.tenantKindLab,
|
||||
null => '-',
|
||||
};
|
||||
|
||||
static String _roleLabel(TenantRole? role, AppStrings s) => switch (role) {
|
||||
TenantRole.owner => s.roleOwner,
|
||||
TenantRole.admin => s.roleAdmin,
|
||||
TenantRole.technician => s.roleTechnician,
|
||||
TenantRole.delivery => s.roleDelivery,
|
||||
TenantRole.finance => s.roleFinance,
|
||||
TenantRole.doctor => s.roleDoctor,
|
||||
TenantRole.member => s.roleMember,
|
||||
null => '-',
|
||||
};
|
||||
}
|
||||
|
||||
// ── Language picker sheet ─────────────────────────────────────────────────────
|
||||
|
||||
class _LanguagePickerSheet extends ConsumerWidget {
|
||||
const _LanguagePickerSheet({required this.s, required this.ref});
|
||||
final AppStrings s;
|
||||
final WidgetRef ref;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef _) {
|
||||
final currentLocale = ref.watch(localeProvider);
|
||||
final options = [
|
||||
('tr', '🇹🇷', s.languageTurkish),
|
||||
('en', '🇬🇧', s.languageEnglish),
|
||||
('ru', '🇷🇺', s.languageRussian),
|
||||
('ar', '🇸🇦', s.languageArabic),
|
||||
('de', '🇩🇪', s.languageGerman),
|
||||
];
|
||||
|
||||
return Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.border,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
s.languageSelection,
|
||||
style: const TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
for (final (code, flag, label) in options)
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
leading: Text(flag, style: const TextStyle(fontSize: 24)),
|
||||
title: Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
trailing: currentLocale.languageCode == code
|
||||
? const Icon(Icons.check_circle_rounded,
|
||||
color: AppColors.accent)
|
||||
: null,
|
||||
onTap: () {
|
||||
ref.read(localeProvider.notifier).setLocale(Locale(code));
|
||||
ref.read(authProvider.notifier).updateLanguage(code);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
SizedBox(height: MediaQuery.paddingOf(context).bottom + 4),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Edit sheet ────────────────────────────────────────────────────────────────
|
||||
|
||||
class _EditTenantSheet extends StatefulWidget {
|
||||
const _EditTenantSheet({
|
||||
required this.tenant,
|
||||
required this.s,
|
||||
required this.onSave,
|
||||
});
|
||||
final Tenant tenant;
|
||||
final AppStrings s;
|
||||
final Future<void> Function(String companyName) onSave;
|
||||
|
||||
@override
|
||||
State<_EditTenantSheet> createState() => _EditTenantSheetState();
|
||||
}
|
||||
|
||||
class _EditTenantSheetState extends State<_EditTenantSheet> {
|
||||
late final TextEditingController _nameController;
|
||||
bool _saving = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_nameController = TextEditingController(text: widget.tenant.companyName);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
final name = _nameController.text.trim();
|
||||
if (name.isEmpty) return;
|
||||
setState(() => _saving = true);
|
||||
final navigator = Navigator.of(context);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
try {
|
||||
await widget.onSave(name);
|
||||
navigator.pop();
|
||||
} catch (e) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('${widget.s.errorPrefix}: $e')));
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = widget.s;
|
||||
return Padding(
|
||||
padding:
|
||||
EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
|
||||
child: Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.border,
|
||||
borderRadius: BorderRadius.circular(2)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(s.editClinicInfo,
|
||||
style: const TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary)),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
decoration: InputDecoration(
|
||||
labelText: s.clinicName,
|
||||
hintText: s.clinicNameHint,
|
||||
),
|
||||
textCapitalization: TextCapitalization.words,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
if (_saving)
|
||||
const Center(
|
||||
child: CircularProgressIndicator(color: AppColors.accent))
|
||||
else
|
||||
FilledButton(
|
||||
onPressed: _submit,
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size(double.infinity, 48)),
|
||||
child: Text(s.save),
|
||||
),
|
||||
SizedBox(height: MediaQuery.paddingOf(context).bottom + 4),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Reusable UI pieces ────────────────────────────────────────────────────────
|
||||
|
||||
class _UserCard extends StatelessWidget {
|
||||
const _UserCard({required this.profile});
|
||||
final dynamic profile;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final displayName = (profile?.displayName?.isNotEmpty == true)
|
||||
? profile!.displayName as String
|
||||
: 'Kullanıcı';
|
||||
final initial = (profile?.displayName?.isNotEmpty == true
|
||||
? (profile!.displayName as String)[0]
|
||||
: (profile?.email as String?)?[0] ?? '?')
|
||||
.toUpperCase();
|
||||
|
||||
return 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: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.inProgressBg,
|
||||
borderRadius: BorderRadius.circular(14)),
|
||||
child: Center(
|
||||
child: Text(initial,
|
||||
style: const TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.inProgress)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(displayName,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary)),
|
||||
const SizedBox(height: 2),
|
||||
Text(profile?.email as String? ?? '',
|
||||
style: const TextStyle(
|
||||
fontSize: 13, color: AppColors.textSecondary)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
const _SectionHeader({required this.title, this.action});
|
||||
final String title;
|
||||
final Widget? action;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.accent,
|
||||
letterSpacing: 0.3),
|
||||
),
|
||||
),
|
||||
if (action != null) action!,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoCard extends StatelessWidget {
|
||||
const _InfoCard({required this.children});
|
||||
final List<Widget> children;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
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(
|
||||
children: [
|
||||
for (int i = 0; i < children.length; i++) ...[
|
||||
children[i],
|
||||
if (i < children.length - 1)
|
||||
const Divider(
|
||||
height: 1, indent: 16, endIndent: 16, color: AppColors.border),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoTile extends StatelessWidget {
|
||||
const _InfoTile(
|
||||
{required this.icon, required this.label, required this.value});
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 18, color: AppColors.textSecondary),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label,
|
||||
style: const TextStyle(
|
||||
fontSize: 11, color: AppColors.textMuted)),
|
||||
const SizedBox(height: 2),
|
||||
Text(value,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.textPrimary)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NavTile extends StatelessWidget {
|
||||
const _NavTile({
|
||||
required this.icon,
|
||||
required this.iconColor,
|
||||
required this.iconBg,
|
||||
required this.title,
|
||||
required this.onTap,
|
||||
this.subtitle,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final Color iconColor;
|
||||
final Color iconBg;
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
|
||||
leading: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: iconBg, borderRadius: BorderRadius.circular(9)),
|
||||
child: Icon(icon, color: iconColor, size: 18),
|
||||
),
|
||||
title: Text(title,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
|
||||
subtitle: subtitle != null
|
||||
? Text(subtitle!,
|
||||
style: const TextStyle(color: AppColors.textSecondary))
|
||||
: null,
|
||||
trailing:
|
||||
const Icon(Icons.chevron_right, color: AppColors.textSecondary),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SignOutCard extends StatelessWidget {
|
||||
const _SignOutCard({required this.ref, required this.s});
|
||||
final WidgetRef ref;
|
||||
final AppStrings s;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.cancelledBg),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4))
|
||||
],
|
||||
),
|
||||
child: ListTile(
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
|
||||
leading: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cancelledBg,
|
||||
borderRadius: BorderRadius.circular(9)),
|
||||
child: const Icon(Icons.logout,
|
||||
color: AppColors.cancelled, size: 18),
|
||||
),
|
||||
title: Text(s.signOut,
|
||||
style: const TextStyle(
|
||||
color: AppColors.cancelled, fontWeight: FontWeight.w600)),
|
||||
onTap: () async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(s.signOutTitle),
|
||||
content: Text(s.signOutConfirm),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: Text(s.cancel)),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.cancelled),
|
||||
child: Text(s.signOut),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed == true) {
|
||||
await ref.read(authProvider.notifier).signOut();
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user