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:
Emre Emir
2026-06-11 15:57:31 +03:00
commit 8bbc9dbff2
226 changed files with 31308 additions and 0 deletions
@@ -0,0 +1,581 @@
import 'package:flutter/material.dart';
import '../../../core/theme/app_theme.dart';
import '../../../models/connection.dart';
import '../../../models/job.dart';
import 'connection_stats_repository.dart';
class ConnectionDetailScreen extends StatefulWidget {
const ConnectionDetailScreen({
super.key,
required this.connection,
required this.labTenantId,
});
final Connection connection;
final String labTenantId;
@override
State<ConnectionDetailScreen> createState() => _ConnectionDetailScreenState();
}
class _ConnectionDetailScreenState extends State<ConnectionDetailScreen> {
late Future<ConnectionStats> _future;
@override
void initState() {
super.initState();
_load();
}
void _load() {
setState(() {
_future = ConnectionStatsRepository.instance.fetchStats(
labTenantId: widget.labTenantId,
clinicTenantId: widget.connection.clinicTenantId,
);
});
}
@override
Widget build(BuildContext context) {
final conn = widget.connection;
final clinicName = conn.clinicName ?? 'Klinik';
return Scaffold(
backgroundColor: AppColors.background,
body: CustomScrollView(
slivers: [
SliverAppBar(
pinned: true,
expandedHeight: 140,
backgroundColor: AppColors.primary,
foregroundColor: Colors.white,
flexibleSpace: FlexibleSpaceBar(
background: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF0F172A), AppColors.primary],
),
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 48, 20, 16),
child: Row(
children: [
Container(
width: 52,
height: 52,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: Colors.white.withValues(alpha: 0.2)),
),
child: const Icon(Icons.local_hospital_outlined, color: Colors.white, size: 26),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
clinicName,
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w700,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 3),
_StatusBadge(status: conn.status),
],
),
),
],
),
),
),
),
),
),
SliverToBoxAdapter(
child: FutureBuilder<ConnectionStats>(
future: _future,
builder: (ctx, snap) {
if (snap.connectionState == ConnectionState.waiting) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 80),
child: Center(child: CircularProgressIndicator(color: AppColors.accent)),
);
}
if (snap.hasError) {
return Padding(
padding: const EdgeInsets.all(32),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.wifi_off_rounded, color: AppColors.cancelled, size: 40),
const SizedBox(height: 12),
Text('Hata: ${snap.error}', style: const TextStyle(color: AppColors.textSecondary), textAlign: TextAlign.center),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: _load,
icon: const Icon(Icons.refresh_rounded, size: 16),
label: const Text('Tekrar Dene'),
),
],
),
),
);
}
final stats = snap.data!;
return _StatsBody(stats: stats);
},
),
),
],
),
);
}
}
// ── Stats body ────────────────────────────────────────────────────────────────
class _StatsBody extends StatelessWidget {
const _StatsBody({required this.stats});
final ConnectionStats stats;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// KPI row
Row(
children: [
Expanded(child: _KpiCard(label: 'Toplam İş', value: '${stats.totalJobs}', icon: Icons.work_outline_rounded, color: AppColors.accent)),
const SizedBox(width: 10),
Expanded(child: _KpiCard(label: 'Aktif', value: '${stats.activeJobs}', icon: Icons.pending_outlined, color: AppColors.inProgress)),
const SizedBox(width: 10),
Expanded(child: _KpiCard(label: 'Teslim', value: '${stats.deliveredJobs}', icon: Icons.check_circle_outline, color: AppColors.success)),
],
),
const SizedBox(height: 10),
Row(
children: [
Expanded(child: _KpiCard(label: 'Bu Ay', value: '${stats.thisMonthJobs}', icon: Icons.calendar_month_outlined, color: AppColors.accent)),
const SizedBox(width: 10),
Expanded(child: _KpiCard(label: 'Geçen Ay', value: '${stats.lastMonthJobs}', icon: Icons.history_rounded, color: AppColors.textSecondary)),
const SizedBox(width: 10),
Expanded(child: _KpiCard(label: 'İptal', value: '${stats.cancelledJobs}', icon: Icons.cancel_outlined, color: AppColors.cancelled)),
],
),
const SizedBox(height: 16),
// Revenue
_SectionTitle('Finans'),
const SizedBox(height: 8),
_RevenueCard(stats: stats),
const SizedBox(height: 16),
// Prosthetic type breakdown
if (stats.byType.isNotEmpty) ...[
_SectionTitle('Ürün Tipi Dağılımı'),
const SizedBox(height: 8),
_TypeBreakdownCard(byType: stats.byType, total: stats.totalJobs),
const SizedBox(height: 16),
],
// Monthly trend
_SectionTitle('Aylık Karşılaştırma'),
const SizedBox(height: 8),
_MonthCompareCard(stats: stats),
const SizedBox(height: 16),
// Recent jobs
if (stats.recentJobs.isNotEmpty) ...[
_SectionTitle('Son İşler'),
const SizedBox(height: 8),
_RecentJobsCard(jobs: stats.recentJobs),
const SizedBox(height: 16),
],
const SizedBox(height: 32),
],
),
);
}
}
// ── KPI card ──────────────────────────────────────────────────────────────────
class _KpiCard extends StatelessWidget {
const _KpiCard({required this.label, required this.value, required this.icon, required this.color});
final String label;
final String value;
final IconData icon;
final Color color;
@override
Widget build(BuildContext context) {
return 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.03), blurRadius: 6, offset: const Offset(0, 2))],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 18, color: color),
const SizedBox(height: 8),
Text(value, style: TextStyle(fontSize: 22, fontWeight: FontWeight.w800, color: color)),
const SizedBox(height: 2),
Text(label, style: const TextStyle(fontSize: 11, color: AppColors.textMuted, fontWeight: FontWeight.w500)),
],
),
);
}
}
// ── Revenue card ──────────────────────────────────────────────────────────────
class _RevenueCard extends StatelessWidget {
const _RevenueCard({required this.stats});
final ConnectionStats stats;
@override
Widget build(BuildContext context) {
final collected = stats.totalRevenue - stats.pendingRevenue;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.border),
),
child: Column(
children: [
_RevRow(
label: 'Toplam Gelir',
value: _fmt(stats.totalRevenue),
color: AppColors.textPrimary,
bold: true,
),
const Divider(height: 20),
_RevRow(label: 'Tahsil Edilen', value: _fmt(collected), color: AppColors.success),
const SizedBox(height: 8),
_RevRow(label: 'Bekleyen Alacak', value: _fmt(stats.pendingRevenue), color: AppColors.pending),
],
),
);
}
String _fmt(double v) => '${v.toStringAsFixed(0)} TL';
}
class _RevRow extends StatelessWidget {
const _RevRow({required this.label, required this.value, required this.color, this.bold = false});
final String label;
final String value;
final Color color;
final bool bold;
@override
Widget build(BuildContext context) {
final style = TextStyle(
fontSize: bold ? 15 : 14,
fontWeight: bold ? FontWeight.w700 : FontWeight.w500,
color: color,
);
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: style.copyWith(color: bold ? AppColors.textPrimary : AppColors.textSecondary)),
Text(value, style: style),
],
);
}
}
// ── Type breakdown ────────────────────────────────────────────────────────────
class _TypeBreakdownCard extends StatelessWidget {
const _TypeBreakdownCard({required this.byType, required this.total});
final Map<String, int> byType;
final int total;
@override
Widget build(BuildContext context) {
final sorted = byType.entries.toList()..sort((a, b) => b.value.compareTo(a.value));
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.border),
),
child: Column(
children: [
for (int i = 0; i < sorted.length; i++) ...[
if (i > 0) const SizedBox(height: 10),
_TypeBar(
label: _typeLabel(sorted[i].key),
count: sorted[i].value,
total: total,
),
],
],
),
);
}
String _typeLabel(String key) => switch (key) {
'metal_porselen' => 'Metal Porselen',
'zirkonyum' => 'Zirkonyum',
'implant_ustu_zirkonyum'=> 'İmplant Üstü Zirkonyum',
'gecici' => 'Geçici',
'e_max' => 'E-Max',
'tam_protez' => 'Tam Protez',
'parsiyel' => 'Parsiyel Protez',
_ => 'Diğer',
};
}
class _TypeBar extends StatelessWidget {
const _TypeBar({required this.label, required this.count, required this.total});
final String label;
final int count;
final int total;
@override
Widget build(BuildContext context) {
final pct = total > 0 ? count / total : 0.0;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: AppColors.textPrimary)),
Text('$count adet · ${(pct * 100).toStringAsFixed(0)}%',
style: const TextStyle(fontSize: 12, color: AppColors.textMuted)),
],
),
const SizedBox(height: 5),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: pct,
minHeight: 7,
backgroundColor: AppColors.border,
valueColor: const AlwaysStoppedAnimation<Color>(AppColors.accent),
),
),
],
);
}
}
// ── Monthly compare ───────────────────────────────────────────────────────────
class _MonthCompareCard extends StatelessWidget {
const _MonthCompareCard({required this.stats});
final ConnectionStats stats;
@override
Widget build(BuildContext context) {
final thisMonth = stats.thisMonthJobs;
final lastMonth = stats.lastMonthJobs;
final maxBar = (thisMonth > lastMonth ? thisMonth : lastMonth).clamp(1, 999);
final trend = lastMonth == 0
? null
: ((thisMonth - lastMonth) / lastMonth * 100).toStringAsFixed(0);
final isUp = thisMonth >= lastMonth;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (trend != null) ...[
Row(
children: [
Icon(
isUp ? Icons.trending_up_rounded : Icons.trending_down_rounded,
size: 18,
color: isUp ? AppColors.success : AppColors.cancelled,
),
const SizedBox(width: 6),
Text(
isUp ? '+$trend% geçen aya göre' : '$trend% geçen aya göre',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: isUp ? AppColors.success : AppColors.cancelled,
),
),
],
),
const SizedBox(height: 12),
],
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
_BarColumn(label: 'Geçen Ay', count: lastMonth, maxCount: maxBar, color: AppColors.border),
const SizedBox(width: 16),
_BarColumn(label: 'Bu Ay', count: thisMonth, maxCount: maxBar, color: AppColors.accent),
],
),
],
),
);
}
}
class _BarColumn extends StatelessWidget {
const _BarColumn({required this.label, required this.count, required this.maxCount, required this.color});
final String label;
final int count;
final int maxCount;
final Color color;
@override
Widget build(BuildContext context) {
final height = maxCount > 0 ? (count / maxCount * 80).clamp(4.0, 80.0) : 4.0;
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text('$count', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: color == AppColors.border ? AppColors.textSecondary : AppColors.accent)),
const SizedBox(height: 4),
Container(
width: 40,
height: height,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(height: 4),
Text(label, style: const TextStyle(fontSize: 11, color: AppColors.textMuted)),
],
);
}
}
// ── Recent jobs ───────────────────────────────────────────────────────────────
class _RecentJobsCard extends StatelessWidget {
const _RecentJobsCard({required this.jobs});
final List<Job> jobs;
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.border),
),
child: Column(
children: [
for (int i = 0; i < jobs.length; i++) ...[
if (i > 0) const Divider(height: 1),
_RecentJobRow(job: jobs[i]),
],
],
),
);
}
}
class _RecentJobRow extends StatelessWidget {
const _RecentJobRow({required this.job});
final Job job;
@override
Widget build(BuildContext context) {
final color = job.status == JobStatus.delivered ? AppColors.success : AppColors.inProgress;
final bg = job.status == JobStatus.delivered ? AppColors.successBg : AppColors.inProgressBg;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11),
child: Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(10)),
child: Center(child: Icon(Icons.assignment_outlined, size: 18, color: color)),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(job.patientCode, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
Text(job.prostheticType.label, style: const TextStyle(fontSize: 12, color: AppColors.textSecondary)),
],
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(6)),
child: Text(job.status.label, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: color)),
),
],
),
);
}
}
// ── Helpers ───────────────────────────────────────────────────────────────────
class _SectionTitle extends StatelessWidget {
const _SectionTitle(this.text);
final String text;
@override
Widget build(BuildContext context) {
return Text(text, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w700, color: AppColors.textMuted, letterSpacing: 0.5));
}
}
class _StatusBadge extends StatelessWidget {
const _StatusBadge({required this.status});
final ConnectionStatus status;
@override
Widget build(BuildContext context) {
final (label, color) = switch (status) {
ConnectionStatus.approved => ('Onaylı', AppColors.success),
ConnectionStatus.pending => ('Bekliyor', AppColors.pending),
ConnectionStatus.rejected => ('Reddedildi', AppColors.cancelled),
};
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(6),
),
child: Text(label, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: color)),
);
}
}
@@ -0,0 +1,124 @@
import 'package:pocketbase/pocketbase.dart';
import '../../../core/api/pocketbase_client.dart';
import '../../../models/job.dart';
class ConnectionStats {
const ConnectionStats({
required this.totalJobs,
required this.byStatus,
required this.byType,
required this.totalRevenue,
required this.pendingRevenue,
required this.thisMonthJobs,
required this.lastMonthJobs,
required this.revisionCount,
required this.recentJobs,
});
final int totalJobs;
final Map<String, int> byStatus; // 'in_progress', 'sent', 'delivered', 'cancelled'
final Map<String, int> byType; // prosthetic_type -> count
final double totalRevenue;
final double pendingRevenue;
final int thisMonthJobs;
final int lastMonthJobs;
final int revisionCount;
final List<Job> recentJobs;
int get deliveredJobs => byStatus['delivered'] ?? 0;
int get activeJobs => (byStatus['in_progress'] ?? 0) + (byStatus['sent'] ?? 0);
int get cancelledJobs => byStatus['cancelled'] ?? 0;
double get revisionRate => totalJobs > 0 ? revisionCount / totalJobs * 100 : 0;
double get completionRate => totalJobs > 0 ? deliveredJobs / totalJobs * 100 : 0;
}
class ConnectionStatsRepository {
ConnectionStatsRepository._();
static final instance = ConnectionStatsRepository._();
PocketBase get _pb => PocketBaseClient.instance.pb;
Future<ConnectionStats> fetchStats({
required String labTenantId,
required String clinicTenantId,
}) async {
final now = DateTime.now();
final thisMonthStart = DateTime(now.year, now.month, 1);
final lastMonthStart = DateTime(now.year, now.month - 1, 1);
final lastMonthEnd = thisMonthStart.subtract(const Duration(seconds: 1));
final filter = 'lab_tenant_id = "$labTenantId" && clinic_tenant_id = "$clinicTenantId"';
final results = await Future.wait([
_pb.collection('jobs').getList(
filter: filter,
perPage: 500,
expand: 'clinic_tenant_id,lab_tenant_id',
),
_pb.collection('finance_entries').getList(
filter: 'tenant_id = "$labTenantId" && job_id.clinic_tenant_id = "$clinicTenantId"',
perPage: 500,
),
]);
final jobsResult = results[0];
final financeResult = results[1];
final allJobs = jobsResult.items
.map((r) => Job.fromJson(r.toJson()))
.toList();
// Status breakdown
final byStatus = <String, int>{};
final byType = <String, int>{};
int thisMonthJobs = 0;
int lastMonthJobs = 0;
int revisionCount = 0;
for (final job in allJobs) {
// Status
final s = job.status.value;
byStatus[s] = (byStatus[s] ?? 0) + 1;
// Type
final t = job.prostheticType.value;
byType[t] = (byType[t] ?? 0) + 1;
// Monthly
final created = job.dateCreated;
if (created.isAfter(thisMonthStart)) thisMonthJobs++;
else if (created.isAfter(lastMonthStart) && created.isBefore(lastMonthEnd)) lastMonthJobs++;
// Revision
if (job.status == JobStatus.inProgress && job.currentStep == null) revisionCount++;
}
// Finance
double totalRevenue = 0;
double pendingRevenue = 0;
for (final r in financeResult.items) {
final j = r.toJson();
final amount = (j['amount'] as num?)?.toDouble() ?? 0;
totalRevenue += amount;
if (j['status'] == 'pending') pendingRevenue += amount;
}
// Recent jobs (last 5 delivered or sent)
final recent = allJobs
.where((j) => j.status == JobStatus.delivered || j.status == JobStatus.sent)
.toList()
..sort((a, b) => b.dateCreated.compareTo(a.dateCreated));
return ConnectionStats(
totalJobs: allJobs.length,
byStatus: byStatus,
byType: byType,
totalRevenue: totalRevenue,
pendingRevenue: pendingRevenue,
thisMonthJobs: thisMonthJobs,
lastMonthJobs: lastMonthJobs,
revisionCount: revisionCount,
recentJobs: recent.take(5).toList(),
);
}
}
@@ -0,0 +1,30 @@
import 'package:pocketbase/pocketbase.dart';
import '../../../core/api/pocketbase_client.dart';
import '../../../models/connection.dart';
class LabConnectionsRepository {
LabConnectionsRepository._();
static final instance = LabConnectionsRepository._();
PocketBase get _pb => PocketBaseClient.instance.pb;
Future<List<Connection>> listConnections(String labTenantId) async {
final result = await _pb.collection('connections').getList(
filter: 'lab_tenant_id = "$labTenantId"',
expand: 'clinic_tenant_id,lab_tenant_id',
perPage: 100,
);
return (result.items.map((r) => Connection.fromJson(r.toJson())).toList()
..sort((a, b) => (b.dateCreated ?? '').compareTo(a.dateCreated ?? '')));
}
Future<Connection> respondToRequest({
required String connectionId,
required bool approve,
}) async {
final record = await _pb.collection('connections').update(connectionId, body: {
'status': approve ? 'approved' : 'rejected',
});
return Connection.fromJson(record.toJson());
}
}
@@ -0,0 +1,453 @@
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 '../../../core/widgets/gradient_app_bar.dart';
import '../../../models/connection.dart';
import 'connection_detail_screen.dart';
import 'lab_connections_repository.dart';
class LabConnectionsScreen extends ConsumerStatefulWidget {
const LabConnectionsScreen({super.key});
@override
ConsumerState<LabConnectionsScreen> createState() =>
_LabConnectionsScreenState();
}
class _LabConnectionsScreenState extends ConsumerState<LabConnectionsScreen> {
late Future<List<Connection>> _future;
final _searchController = TextEditingController();
String _searchQuery = '';
@override
void initState() {
super.initState();
_load();
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
void _load() {
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
setState(() {
_future = LabConnectionsRepository.instance.listConnections(tenantId);
});
}
Future<void> _respond(String connectionId, bool approve) async {
try {
await LabConnectionsRepository.instance.respondToRequest(
connectionId: connectionId,
approve: approve,
);
_load();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
approve ? 'Bağlantı onaylandı' : 'Bağlantı reddedildi'),
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Hata: $e')),
);
}
}
}
Color _statusColor(ConnectionStatus status) {
return switch (status) {
ConnectionStatus.pending => AppColors.pending,
ConnectionStatus.approved => AppColors.success,
ConnectionStatus.rejected => AppColors.cancelled,
};
}
Color _statusBg(ConnectionStatus status) {
return switch (status) {
ConnectionStatus.pending => AppColors.pendingBg,
ConnectionStatus.approved => AppColors.successBg,
ConnectionStatus.rejected => AppColors.cancelledBg,
};
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.background,
appBar: GradientAppBar(
title: 'Bağlantılar',
category: 'LABORATUVAR',
searchController: _searchController,
onSearchChanged: (v) => setState(() => _searchQuery = v),
searchHint: 'Klinik adı ara...',
),
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 allConnections = snap.data!;
final q = _searchQuery.toLowerCase().trim();
final connections = q.isEmpty
? allConnections
: allConnections.where((c) =>
(c.clinicName ?? '').toLowerCase().contains(q)).toList();
if (allConnections.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),
const Text(
'Kliniklerden gelen istekler burada görünür.',
style: TextStyle(
color: AppColors.textSecondary, fontSize: 13),
textAlign: TextAlign.center,
),
],
),
);
}
final pending = connections
.where((c) => c.status == ConnectionStatus.pending)
.toList();
final others = connections
.where((c) => c.status != ConnectionStatus.pending)
.toList();
return ListView(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
children: [
if (pending.isNotEmpty) ...[
_SectionHeader(
label: 'Bekleyen İstekler',
count: pending.length,
countColor: AppColors.pending,
countBg: AppColors.pendingBg,
),
const SizedBox(height: 8),
...pending.map((c) => Padding(
padding: const EdgeInsets.only(bottom: 10),
child: _ConnectionCard(
connection: c,
statusColor: _statusColor(c.status),
statusBg: _statusBg(c.status),
onApprove: () => _respond(c.id, true),
onReject: () => _respond(c.id, false),
),
)),
const SizedBox(height: 8),
],
if (others.isNotEmpty) ...[
_SectionHeader(
label: 'Bağlantılar',
count: others.length,
countColor: AppColors.textSecondary,
countBg: AppColors.surfaceVariant,
),
const SizedBox(height: 8),
...others.map((c) {
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: _ConnectionCard(
connection: c,
statusColor: _statusColor(c.status),
statusBg: _statusBg(c.status),
onTap: c.status == ConnectionStatus.approved
? () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ConnectionDetailScreen(
connection: c,
labTenantId: tenantId,
),
),
)
: null,
),
);
}),
],
],
);
},
),
),
);
}
}
class _SectionHeader extends StatelessWidget {
const _SectionHeader({
required this.label,
required this.count,
required this.countColor,
required this.countBg,
});
final String label;
final int count;
final Color countColor;
final Color countBg;
@override
Widget build(BuildContext context) {
return Row(
children: [
Text(
label,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.accent,
letterSpacing: 0.3,
),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: countBg,
borderRadius: BorderRadius.circular(10),
),
child: Text(
'$count',
style: TextStyle(
color: countColor,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
),
],
);
}
}
class _ConnectionCard extends StatefulWidget {
const _ConnectionCard({
required this.connection,
required this.statusColor,
required this.statusBg,
this.onApprove,
this.onReject,
this.onTap,
});
final Connection connection;
final Color statusColor;
final Color statusBg;
final VoidCallback? onApprove;
final VoidCallback? onReject;
final VoidCallback? onTap;
@override
State<_ConnectionCard> createState() => _ConnectionCardState();
}
class _ConnectionCardState extends State<_ConnectionCard> {
bool _loading = false;
Future<void> _act(VoidCallback? cb) async {
if (cb == null) return;
setState(() => _loading = true);
cb();
await Future.delayed(const Duration(milliseconds: 500));
if (mounted) setState(() => _loading = false);
}
String _formatDate(String? raw) {
if (raw == null) return '';
try {
final dt = DateTime.parse(raw);
return '${dt.day.toString().padLeft(2, '0')}.${dt.month.toString().padLeft(2, '0')}.${dt.year}';
} catch (_) {
return '';
}
}
@override
Widget build(BuildContext context) {
final c = widget.connection;
final isPending = c.status == ConnectionStatus.pending;
return Material(
color: AppColors.surface,
borderRadius: BorderRadius.circular(14),
child: InkWell(
onTap: widget.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: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: widget.statusBg,
borderRadius: BorderRadius.circular(12)),
child: Icon(Icons.business_outlined,
color: widget.statusColor, size: 22),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
c.clinicName ?? 'Klinik',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary),
),
if (c.dateCreated != null) ...[
const SizedBox(height: 2),
Text(
_formatDate(c.dateCreated),
style: const TextStyle(
color: AppColors.textMuted, fontSize: 12),
),
],
],
),
),
Container(
padding:
const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: widget.statusBg,
borderRadius: BorderRadius.circular(8),
),
child: Text(
c.status.label,
style: TextStyle(
color: widget.statusColor,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
),
if (widget.onTap != null) ...[
const SizedBox(width: 4),
const Icon(Icons.chevron_right_rounded,
size: 18, color: AppColors.textMuted),
],
],
),
if (isPending) ...[
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed:
_loading ? null : () => _act(widget.onReject),
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.cancelled,
side: const BorderSide(color: AppColors.cancelled)),
child: const Text('Reddet'),
),
),
const SizedBox(width: 8),
Expanded(
child: FilledButton(
onPressed:
_loading ? null : () => _act(widget.onApprove),
style: FilledButton.styleFrom(
backgroundColor: AppColors.success),
child: _loading
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white),
)
: const Text('Onayla'),
),
),
],
),
],
],
),
),
),
);
}
}
@@ -0,0 +1,883 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_animate/flutter_animate.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/theme/app_theme.dart';
import '../../../core/widgets/tooth_logo.dart';
import '../../../core/services/realtime_service.dart';
import '../../../models/job.dart';
import '../jobs/lab_jobs_repository.dart';
class LabDashboardScreen extends ConsumerStatefulWidget {
const LabDashboardScreen({super.key});
@override
ConsumerState<LabDashboardScreen> createState() => _LabDashboardScreenState();
}
class _LabDashboardScreenState extends ConsumerState<LabDashboardScreen> {
late Future<_DashboardData> _future;
bool _acceptingAll = false;
late UnsubFn _unsub;
@override
void initState() {
super.initState();
_load();
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
_unsub = RealtimeService.instance.watch(
'jobs',
filter: "lab_tenant_id='$tenantId'",
onEvent: (_) { if (mounted) _load(); },
);
}
@override
void dispose() {
_unsub();
super.dispose();
}
void _load() {
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
final now = DateTime.now();
final thisMonthStart = DateTime(now.year, now.month, 1);
final lastMonthStart = DateTime(now.year, now.month - 1, 1);
setState(() {
_future = Future.wait([
Future.wait<List<Job>>([
LabJobsRepository.instance.listInbound(tenantId, status: 'pending'),
LabJobsRepository.instance.listInProgress(tenantId),
LabJobsRepository.instance.listInProgress(tenantId, location: 'at_lab'),
LabJobsRepository.instance.listInProgress(tenantId, location: 'at_clinic'),
LabJobsRepository.instance.listInbound(tenantId, status: 'sent', limit: 200),
LabJobsRepository.instance.listInbound(tenantId, status: 'delivered', limit: 200),
]),
LabJobsRepository.instance.countDelivered(tenantId, from: thisMonthStart),
LabJobsRepository.instance.countDelivered(tenantId, from: lastMonthStart, to: thisMonthStart),
]).then((r) {
final jobs = r[0] as List<List<Job>>;
return _DashboardData(
pendingJobs: jobs[0],
inProgressJobs: jobs[1],
atLabJobs: jobs[2],
atClinicJobs: jobs[3],
sentCount: jobs[4].length,
deliveredCount: jobs[5].length,
thisMonthDelivered: r[1] as int,
lastMonthDelivered: r[2] as int,
);
});
});
}
Future<void> _bulkAccept() async {
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
setState(() => _acceptingAll = true);
try {
await LabJobsRepository.instance.bulkAcceptPending(tenantId);
_load();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Hata: $e'), behavior: SnackBarBehavior.floating),
);
}
} finally {
if (mounted) setState(() => _acceptingAll = false);
}
}
@override
Widget build(BuildContext context) {
final companyName = ref.watch(authProvider).activeTenant?.tenant.companyName ?? '';
return Scaffold(
backgroundColor: AppColors.background,
body: LayoutBuilder(
builder: (context, constraints) {
const maxContent = 1040.0;
final hPad = constraints.maxWidth > maxContent
? (constraints.maxWidth - maxContent) / 2
: 16.0;
return RefreshIndicator(
color: AppColors.accent,
onRefresh: () async => _load(),
child: FutureBuilder<_DashboardData>(
future: _future,
builder: (ctx, snap) {
if (snap.connectionState == ConnectionState.waiting) {
return _DashboardSkeleton(companyName: companyName, hPad: hPad);
}
if (snap.hasError) return _ErrorBody(onRetry: _load);
final data = snap.data!;
final isDesktop = MediaQuery.sizeOf(ctx).width > AppLayout.sidebarBreakpoint;
return CustomScrollView(
slivers: [
_DashboardHeader(companyName: companyName),
if (isDesktop)
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
sliver: SliverToBoxAdapter(
child: _StatsRow(
pending: data.pendingJobs.length,
inProgress: data.inProgressJobs.length,
sent: data.sentCount,
delivered: data.deliveredCount,
),
),
),
if (data.pendingJobs.isNotEmpty)
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 0),
sliver: SliverToBoxAdapter(
child: _AcceptAllBanner(
count: data.pendingJobs.length,
loading: _acceptingAll,
onTap: _bulkAccept,
).animate().fadeIn(duration: 300.ms).slideY(begin: 0.1, end: 0),
),
),
if (isDesktop) ...[
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
sliver: SliverToBoxAdapter(
child: _MonthlyReportSection(data: data)
.animate().fadeIn(duration: 300.ms).slideY(begin: 0.08, end: 0),
),
),
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
sliver: SliverToBoxAdapter(
child: _GamificationRow(data: data)
.animate().fadeIn(duration: 300.ms, delay: 60.ms).slideY(begin: 0.08, end: 0),
),
),
],
// ── Yapılacaklar (at_lab) ────────────────────────────
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 20, 16, 4),
sliver: SliverToBoxAdapter(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Yapılacaklar', style: Theme.of(context).textTheme.titleMedium),
TextButton(
onPressed: () => context.go(routeLabJobsAll),
style: TextButton.styleFrom(foregroundColor: AppColors.accent, padding: const EdgeInsets.symmetric(horizontal: 8)),
child: const Text('Tümünü Gör'),
),
],
),
),
),
if (data.atLabJobs.isEmpty)
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.fromLTRB(16, 4, 16, 0),
child: _EmptySection(message: 'Yapılacak iş yok'),
),
)
else
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 0),
sliver: SliverList.separated(
itemCount: data.atLabJobs.take(5).length,
separatorBuilder: (_, __) => const SizedBox(height: 10),
itemBuilder: (ctx, i) => _JobCard(job: data.atLabJobs[i])
.animate(delay: (i * 60).ms)
.fadeIn(duration: 300.ms)
.slideY(begin: 0.12, end: 0),
),
),
// ── Klinikte Onay Bekliyor (at_clinic) ───────────────
if (data.atClinicJobs.isNotEmpty) ...[
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 20, 16, 4),
sliver: SliverToBoxAdapter(
child: Text('Klinikte Onay Bekliyor', style: Theme.of(context).textTheme.titleMedium),
),
),
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 0),
sliver: SliverList.separated(
itemCount: data.atClinicJobs.take(5).length,
separatorBuilder: (_, __) => const SizedBox(height: 10),
itemBuilder: (ctx, i) => _JobCard(job: data.atClinicJobs[i])
.animate(delay: (i * 60).ms)
.fadeIn(duration: 300.ms)
.slideY(begin: 0.12, end: 0),
),
),
],
const SliverToBoxAdapter(child: SizedBox(height: 24)),
],
);
},
),
);
},
),
);
}
}
class _DashboardHeader extends StatelessWidget {
const _DashboardHeader({required this.companyName});
final String companyName;
// Must stay in sync with _DesktopSidebar.headerHeight in app_router.dart
static const double _desktopToolbarHeight = 64;
@override
Widget build(BuildContext context) {
final isDesktop = MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint;
if (isDesktop) {
return SliverAppBar(
pinned: true,
toolbarHeight: _desktopToolbarHeight,
backgroundColor: AppColors.surface,
surfaceTintColor: Colors.transparent,
elevation: 0,
scrolledUnderElevation: 0,
centerTitle: false,
automaticallyImplyLeading: false,
titleSpacing: 0,
title: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('Genel Bakış', style: TextStyle(fontSize: 11, color: AppColors.textSecondary.withValues(alpha: 0.8), letterSpacing: 0.3)),
const Text('Bugünkü Durum', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700, color: AppColors.textPrimary)),
],
),
),
actions: [
IconButton(
onPressed: () => context.go(routeLabSettings),
icon: const Icon(Icons.settings_outlined, color: AppColors.textSecondary, size: 22),
),
const SizedBox(width: 8),
],
);
}
return SliverAppBar(
pinned: true,
expandedHeight: 148,
backgroundColor: AppColors.primary,
surfaceTintColor: Colors.transparent,
shadowColor: Colors.transparent,
systemOverlayStyle: SystemUiOverlayStyle.light,
centerTitle: false,
leadingWidth: 60,
leading: Padding(
padding: const EdgeInsets.only(left: 16, top: 8, bottom: 8),
child: Container(
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
),
child: const Center(child: ToothLogo(size: 20, color: Colors.white)),
),
),
titleSpacing: 8,
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('DLS', style: TextStyle(color: Colors.white.withValues(alpha: 0.65), fontSize: 11, fontWeight: FontWeight.w600, letterSpacing: 1.5)),
Text(companyName, style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w700), maxLines: 1, overflow: TextOverflow.ellipsis),
],
),
actions: [
IconButton(
onPressed: () => context.go(routeLabSettings),
icon: const Icon(Icons.settings_outlined, color: Colors.white, size: 22),
),
],
flexibleSpace: FlexibleSpaceBar(
collapseMode: CollapseMode.pin,
background: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [AppColors.primary, AppColors.accent],
),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.end,
children: [
Text('Genel Bakış', style: TextStyle(color: Colors.white.withValues(alpha: 0.65), fontSize: 12, fontWeight: FontWeight.w500, letterSpacing: 0.5)),
const SizedBox(height: 4),
const Text('Bugünkü Durum', style: TextStyle(color: Colors.white, fontSize: 24, fontWeight: FontWeight.w800, letterSpacing: -0.5)),
],
),
),
),
),
);
}
}
class _StatsRow extends StatelessWidget {
const _StatsRow({
required this.pending,
required this.inProgress,
required this.sent,
required this.delivered,
});
final int pending;
final int inProgress;
final int sent;
final int delivered;
@override
Widget build(BuildContext context) {
final isWideDesktop = MediaQuery.sizeOf(context).width >= AppLayout.wideBreakpoint;
final pendingCard = _StatCard(label: 'Bekleyen', value: '$pending', icon: Icons.hourglass_top_rounded, color: AppColors.pending, bgColor: AppColors.pendingBg)
.animate().fadeIn(duration: 350.ms).slideY(begin: 0.2, end: 0);
final inProgressCard = _StatCard(label: 'Devam Eden', value: '$inProgress', icon: Icons.autorenew_rounded, color: AppColors.inProgress, bgColor: AppColors.inProgressBg)
.animate(delay: 80.ms).fadeIn(duration: 350.ms).slideY(begin: 0.2, end: 0);
if (isWideDesktop) {
final sentCard = _StatCard(label: 'Klinik\'te', value: '$sent', icon: Icons.local_hospital_outlined, color: AppColors.accent, bgColor: AppColors.inProgressBg)
.animate(delay: 120.ms).fadeIn(duration: 350.ms).slideY(begin: 0.2, end: 0);
final deliveredCard = _StatCard(label: 'Tamamlanan', value: '$delivered', icon: Icons.task_alt, color: AppColors.success, bgColor: AppColors.successBg)
.animate(delay: 160.ms).fadeIn(duration: 350.ms).slideY(begin: 0.2, end: 0);
return Row(
children: [
Expanded(child: pendingCard),
const SizedBox(width: 12),
Expanded(child: inProgressCard),
const SizedBox(width: 12),
Expanded(child: sentCard),
const SizedBox(width: 12),
Expanded(child: deliveredCard),
],
);
}
return Row(
children: [
Expanded(child: pendingCard),
const SizedBox(width: 12),
Expanded(child: inProgressCard),
],
);
}
}
class _StatCard extends StatelessWidget {
const _StatCard({required this.label, required this.value, required this.icon, required this.color, required this.bgColor});
final String label;
final String value;
final IconData icon;
final Color color;
final Color bgColor;
@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),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(value, style: TextStyle(fontSize: 28, 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 _AcceptAllBanner extends StatelessWidget {
const _AcceptAllBanner({required this.count, required this.loading, required this.onTap});
final int count;
final bool loading;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Material(
color: AppColors.pendingBg,
borderRadius: BorderRadius.circular(14),
child: InkWell(
onTap: loading ? null : onTap,
borderRadius: BorderRadius.circular(14),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(borderRadius: BorderRadius.circular(14), border: Border.all(color: AppColors.pending.withValues(alpha: 0.35))),
child: Row(
children: [
Container(
width: 38, height: 38,
decoration: BoxDecoration(color: AppColors.pending.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(10)),
child: const Icon(Icons.notifications_active_outlined, color: AppColors.pending, size: 18),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('$count yeni iş bekliyor', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
const Text('Tümünü hızlıca kabul et', style: TextStyle(fontSize: 12, color: AppColors.textSecondary)),
],
),
),
loading
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: AppColors.pending))
: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
decoration: BoxDecoration(color: AppColors.pending, borderRadius: BorderRadius.circular(8)),
child: const Text('Kabul Et', style: TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.w600)),
),
],
),
),
),
);
}
}
class _JobCard extends StatelessWidget {
const _JobCard({required this.job});
final Job job;
@override
Widget build(BuildContext context) {
final due = job.dueDate;
final isOverdue = due != null && due.isBefore(DateTime.now());
final dueText = due != null ? '${due.day.toString().padLeft(2, '0')}.${due.month.toString().padLeft(2, '0')}.${due.year}' : null;
return Semantics(
label: job.patientCode,
button: true,
excludeSemantics: true,
child: Material(
color: AppColors.surface,
borderRadius: BorderRadius.circular(14),
child: InkWell(
onTap: () => context.push('/lab/jobs/${job.id}'),
borderRadius: BorderRadius.circular(14),
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(borderRadius: BorderRadius.circular(14), border: Border.all(color: AppColors.border)),
child: Row(
children: [
Container(
width: 46, height: 46,
decoration: BoxDecoration(color: AppColors.inProgressBg, borderRadius: BorderRadius.circular(12)),
child: Center(child: Text('${job.memberCount}', style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w800, color: AppColors.inProgress))),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(job.patientCode, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
const SizedBox(height: 2),
Text(job.clinicName ?? 'Klinik', style: const TextStyle(fontSize: 13, color: AppColors.textSecondary)),
const SizedBox(height: 6),
Wrap(
spacing: 6,
children: [
_Tag(label: job.prostheticType.label, color: AppColors.inProgress, bg: AppColors.inProgressBg),
if (job.currentStep != null) _Tag(label: job.currentStep!.label, color: AppColors.success, bg: AppColors.successBg),
],
),
],
),
),
if (dueText != null) ...[
const SizedBox(width: 8),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Icon(Icons.calendar_today_outlined, size: 13, color: isOverdue ? AppColors.cancelled : AppColors.textMuted),
const SizedBox(height: 3),
Text(dueText, style: TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: isOverdue ? AppColors.cancelled : AppColors.textSecondary)),
],
),
],
],
),
),
),
),
);
}
}
class _Tag extends StatelessWidget {
const _Tag({required this.label, required this.color, required this.bg});
final String label;
final Color color;
final Color bg;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(6)),
child: Text(label, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: color)),
);
}
}
class _EmptySection extends StatelessWidget {
const _EmptySection({required this.message});
final String message;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 16),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.border),
),
child: Row(
children: [
Icon(Icons.check_circle_outline_rounded, color: AppColors.textSecondary.withValues(alpha: 0.5), size: 20),
const SizedBox(width: 10),
Text(message, style: TextStyle(fontSize: 14, color: AppColors.textSecondary)),
],
),
);
}
}
class _ErrorBody extends StatelessWidget {
const _ErrorBody({required this.onRetry});
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
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),
const Text('Bağlantı hatası', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
const SizedBox(height: 12),
FilledButton.icon(onPressed: onRetry, icon: const Icon(Icons.refresh_rounded, size: 18), label: const Text('Tekrar Dene')),
],
),
),
);
}
}
class _DashboardSkeleton extends StatelessWidget {
const _DashboardSkeleton({required this.companyName, required this.hPad});
final String companyName;
final double hPad;
@override
Widget build(BuildContext context) {
return CustomScrollView(
physics: const NeverScrollableScrollPhysics(),
slivers: [
_DashboardHeader(companyName: companyName),
SliverPadding(
padding: EdgeInsets.fromLTRB(hPad, 16, hPad, 0),
sliver: const SliverToBoxAdapter(
child: Row(children: [
Expanded(child: _ShimmerBox(height: 84, radius: 16)),
SizedBox(width: 12),
Expanded(child: _ShimmerBox(height: 84, radius: 16)),
]),
),
),
SliverPadding(
padding: EdgeInsets.fromLTRB(hPad, 8, hPad, 0),
sliver: SliverList.builder(
itemCount: 4,
itemBuilder: (_, i) => const Padding(padding: EdgeInsets.only(bottom: 10), child: _ShimmerBox(height: 92, radius: 14)),
),
),
],
);
}
}
class _ShimmerBox extends StatefulWidget {
const _ShimmerBox({required this.height, required this.radius});
final double height;
final double radius;
@override
State<_ShimmerBox> createState() => _ShimmerBoxState();
}
class _ShimmerBoxState extends State<_ShimmerBox> with SingleTickerProviderStateMixin {
late AnimationController _ctrl;
late Animation<double> _anim;
@override
void initState() {
super.initState();
_ctrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 1100))..repeat(reverse: true);
_anim = CurvedAnimation(parent: _ctrl, curve: Curves.easeInOut);
}
@override
void dispose() { _ctrl.dispose(); super.dispose(); }
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _anim,
builder: (_, __) => Container(
height: widget.height,
decoration: BoxDecoration(borderRadius: BorderRadius.circular(widget.radius), color: Color.lerp(const Color(0xFFE2E8F0), const Color(0xFFF1F5F9), _anim.value)),
),
);
}
}
// ── Monthly Report ──────────────────────────────────────────────────────────
class _MonthlyReportSection extends StatelessWidget {
const _MonthlyReportSection({required this.data});
final _DashboardData data;
@override
Widget build(BuildContext context) {
final pct = data.changePercent;
final isUp = pct >= 0;
final pctStr = '${isUp ? '+' : ''}${pct.toStringAsFixed(0)}%';
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.bar_chart_rounded, size: 18, color: AppColors.accent),
const SizedBox(width: 6),
Text('Aylık Rapor', style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _MonthStat(
label: 'Bu Ay',
value: data.thisMonthDelivered,
highlighted: true,
),
),
const SizedBox(width: 12),
Expanded(
child: _MonthStat(
label: 'Geçen Ay',
value: data.lastMonthDelivered,
highlighted: false,
),
),
const SizedBox(width: 12),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: isUp ? AppColors.successBg : AppColors.cancelledBg,
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isUp ? Icons.trending_up_rounded : Icons.trending_down_rounded,
size: 16,
color: isUp ? AppColors.success : AppColors.cancelled,
),
const SizedBox(width: 4),
Text(
pctStr,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: isUp ? AppColors.success : AppColors.cancelled,
),
),
],
),
),
],
),
],
),
);
}
}
class _MonthStat extends StatelessWidget {
const _MonthStat({required this.label, required this.value, required this.highlighted});
final String label;
final int value;
final bool highlighted;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: highlighted ? AppColors.accent.withValues(alpha: 0.06) : AppColors.background,
borderRadius: BorderRadius.circular(8),
border: highlighted ? Border.all(color: AppColors.accent.withValues(alpha: 0.2)) : null,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: TextStyle(fontSize: 11, color: AppColors.textSecondary, fontWeight: FontWeight.w500)),
const SizedBox(height: 2),
Text(
'$value',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: highlighted ? AppColors.accent : AppColors.textPrimary,
),
),
],
),
);
}
}
// ── Gamification Row ─────────────────────────────────────────────────────────
const _monthlyGoal = 50;
class _GamificationRow extends StatelessWidget {
const _GamificationRow({required this.data});
final _DashboardData data;
@override
Widget build(BuildContext context) {
final progress = (data.thisMonthDelivered / _monthlyGoal).clamp(0.0, 1.0);
final remaining = (_monthlyGoal - data.thisMonthDelivered).clamp(0, _monthlyGoal);
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Text('🏆', style: TextStyle(fontSize: 16)),
const SizedBox(width: 6),
Text('Aylık Hedef', style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)),
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: AppColors.primary.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(6),
),
child: Text(
'${data.points} puan',
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: AppColors.primary),
),
),
],
),
const SizedBox(height: 10),
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: LinearProgressIndicator(
value: progress,
minHeight: 8,
backgroundColor: AppColors.background,
valueColor: AlwaysStoppedAnimation<Color>(
progress >= 1.0 ? AppColors.success : AppColors.accent,
),
),
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${data.thisMonthDelivered} / $_monthlyGoal iş teslim edildi',
style: TextStyle(fontSize: 12, color: AppColors.textSecondary),
),
Text(
progress >= 1.0 ? 'Hedef tamamlandı!' : '$remaining iş kaldı',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: progress >= 1.0 ? AppColors.success : AppColors.textSecondary,
),
),
],
),
],
),
);
}
}
// ── Data Model ───────────────────────────────────────────────────────────────
class _DashboardData {
final List<Job> pendingJobs;
final List<Job> inProgressJobs;
final List<Job> atLabJobs;
final List<Job> atClinicJobs;
final int sentCount;
final int deliveredCount;
final int thisMonthDelivered;
final int lastMonthDelivered;
const _DashboardData({
required this.pendingJobs,
required this.inProgressJobs,
required this.atLabJobs,
required this.atClinicJobs,
required this.sentCount,
required this.deliveredCount,
required this.thisMonthDelivered,
required this.lastMonthDelivered,
});
int get points => thisMonthDelivered * 10;
double get changePercent => lastMonthDelivered == 0
? (thisMonthDelivered > 0 ? 100 : 0)
: (thisMonthDelivered - lastMonthDelivered) / lastMonthDelivered * 100;
}
@@ -0,0 +1,92 @@
import 'package:pocketbase/pocketbase.dart';
import '../../../core/api/pocketbase_client.dart';
import '../../../models/clinic_discount.dart';
class DiscountRepository {
DiscountRepository._();
static final instance = DiscountRepository._();
PocketBase get _pb => PocketBaseClient.instance.pb;
Future<List<ClinicDiscount>> listDiscounts(String labTenantId) async {
final result = await _pb.collection('clinic_discounts').getList(
filter: 'lab_tenant_id = "$labTenantId"',
expand: 'clinic_tenant_id',
perPage: 200,
);
final list = result.items
.map((r) => ClinicDiscount.fromJson(r.toJson()))
.toList();
list.sort((a, b) {
// Active first, then by clinic name
if (a.isActive != b.isActive) return a.isActive ? -1 : 1;
final ca = a.clinicName ?? '';
final cb = b.clinicName ?? '';
return ca.compareTo(cb);
});
return list;
}
Future<ClinicDiscount> createDiscount({
required String labTenantId,
String? clinicTenantId,
String? prostheticType,
required DiscountType discountType,
required double discountValue,
int minQuantity = 0,
bool isActive = true,
String? notes,
}) async {
final body = <String, dynamic>{
'lab_tenant_id': labTenantId,
'discount_type': discountType.value,
'discount_value': discountValue,
'is_active': isActive,
};
if (clinicTenantId != null && clinicTenantId.isNotEmpty) {
body['clinic_tenant_id'] = clinicTenantId;
}
if (prostheticType != null && prostheticType.isNotEmpty) {
body['prosthetic_type'] = prostheticType;
}
if (minQuantity > 0) body['min_quantity'] = minQuantity;
if (notes != null && notes.isNotEmpty) body['notes'] = notes;
final record = await _pb.collection('clinic_discounts').create(
body: body,
expand: 'clinic_tenant_id',
);
return ClinicDiscount.fromJson(record.toJson());
}
Future<ClinicDiscount> updateDiscount(
String id, {
String? clinicTenantId,
String? prostheticType,
DiscountType? discountType,
double? discountValue,
int? minQuantity,
bool? isActive,
String? notes,
}) async {
final body = <String, dynamic>{};
if (clinicTenantId != null) body['clinic_tenant_id'] = clinicTenantId.isEmpty ? null : clinicTenantId;
if (prostheticType != null) body['prosthetic_type'] = prostheticType.isEmpty ? '' : prostheticType;
if (discountType != null) body['discount_type'] = discountType.value;
if (discountValue != null) body['discount_value'] = discountValue;
if (minQuantity != null) body['min_quantity'] = minQuantity;
if (isActive != null) body['is_active'] = isActive;
if (notes != null) body['notes'] = notes;
final record = await _pb.collection('clinic_discounts').update(
id,
body: body,
expand: 'clinic_tenant_id',
);
return ClinicDiscount.fromJson(record.toJson());
}
Future<void> deleteDiscount(String id) async {
await _pb.collection('clinic_discounts').delete(id);
}
}
@@ -0,0 +1,940 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/api/pocketbase_client.dart';
import '../../../core/providers/auth_provider.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/gradient_app_bar.dart';
import '../../../models/clinic_discount.dart';
import 'discount_repository.dart';
// Simple local record for clinic picker
class _ClinicOption {
const _ClinicOption({required this.id, required this.name});
final String id;
final String name;
}
class DiscountsScreen extends ConsumerStatefulWidget {
const DiscountsScreen({super.key});
@override
ConsumerState<DiscountsScreen> createState() => _DiscountsScreenState();
}
class _DiscountsScreenState extends ConsumerState<DiscountsScreen> {
late Future<List<ClinicDiscount>> _future;
String _searchQuery = '';
final _searchController = TextEditingController();
@override
void initState() {
super.initState();
_load();
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
void _load() {
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
setState(() {
_future = DiscountRepository.instance.listDiscounts(tenantId);
});
}
Future<void> _showSheet({ClinicDiscount? existing}) async {
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
final result = await showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => _DiscountSheet(
labTenantId: tenantId,
existing: existing,
),
);
if (result == true) _load();
}
Future<void> _delete(ClinicDiscount discount) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('İndirimi Sil'),
content: Text(
'${discount.clinicName ?? 'Tüm Klinikler'}${discount.displayValue} indirimi silinsin mi?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('İptal')),
FilledButton(
style:
FilledButton.styleFrom(backgroundColor: AppColors.cancelled),
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Sil'),
),
],
),
);
if (confirmed != true) return;
try {
await DiscountRepository.instance.deleteDiscount(discount.id);
_load();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Hata: $e'),
backgroundColor: AppColors.cancelled),
);
}
}
}
Future<void> _toggleActive(ClinicDiscount discount) async {
try {
await DiscountRepository.instance
.updateDiscount(discount.id, isActive: !discount.isActive);
_load();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Hata: $e'),
backgroundColor: AppColors.cancelled),
);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.background,
appBar: GradientAppBar(
title: 'İndirimler',
category: 'LABORATUVAR',
searchController: _searchController,
onSearchChanged: (v) => setState(() => _searchQuery = v),
searchHint: 'Klinik veya ürün tipi ara...',
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => _showSheet(),
backgroundColor: AppColors.accent,
foregroundColor: Colors.white,
icon: const Icon(Icons.add),
label: const Text('Yeni İndirim'),
),
body: FutureBuilder<List<ClinicDiscount>>(
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: [
const Icon(Icons.wifi_off_rounded,
color: AppColors.cancelled, size: 40),
const SizedBox(height: 12),
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: 16),
label: const Text('Tekrar Dene')),
],
),
);
}
final allDiscounts = snap.data!;
final q = _searchQuery.toLowerCase().trim();
final discounts = q.isEmpty
? allDiscounts
: allDiscounts
.where((d) =>
(d.clinicName ?? 'tüm klinikler')
.toLowerCase()
.contains(q) ||
d.prostheticLabel.toLowerCase().contains(q))
.toList();
if (allDiscounts.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.discount_outlined,
size: 32, color: AppColors.inProgress),
),
const SizedBox(height: 16),
const Text('Henüz indirim tanımlanmadı',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary)),
const SizedBox(height: 8),
const Text(
'Klinik ve ürün bazlı özel indirimler ekleyin.',
style: TextStyle(
color: AppColors.textSecondary, fontSize: 13),
textAlign: TextAlign.center),
const SizedBox(height: 20),
FilledButton.icon(
onPressed: () => _showSheet(),
icon: const Icon(Icons.add),
label: const Text('İlk İndirimi Ekle'),
),
],
),
);
}
if (discounts.isEmpty) {
return const Center(
child: Text('Sonuç bulunamadı',
style: TextStyle(color: AppColors.textSecondary)),
);
}
final active = discounts.where((d) => d.isActive).toList();
final inactive = discounts.where((d) => !d.isActive).toList();
return RefreshIndicator(
color: AppColors.accent,
onRefresh: () async => _load(),
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 100),
children: [
if (active.isNotEmpty) ...[
_GroupHeader('Aktif (${active.length})'),
for (final d in active)
_DiscountCard(
discount: d,
onEdit: () => _showSheet(existing: d),
onDelete: () => _delete(d),
onToggle: () => _toggleActive(d),
),
],
if (inactive.isNotEmpty) ...[
const SizedBox(height: 8),
_GroupHeader('Pasif (${inactive.length})'),
for (final d in inactive)
_DiscountCard(
discount: d,
onEdit: () => _showSheet(existing: d),
onDelete: () => _delete(d),
onToggle: () => _toggleActive(d),
),
],
],
),
);
},
),
);
}
}
// ── Group header ──────────────────────────────────────────────────────────────
class _GroupHeader extends StatelessWidget {
const _GroupHeader(this.text);
final String text;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 8, top: 4),
child: Text(text,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: AppColors.textMuted,
letterSpacing: 0.5)),
);
}
}
// ── Discount card ─────────────────────────────────────────────────────────────
class _DiscountCard extends StatelessWidget {
const _DiscountCard({
required this.discount,
required this.onEdit,
required this.onDelete,
required this.onToggle,
});
final ClinicDiscount discount;
final VoidCallback onEdit;
final VoidCallback onDelete;
final VoidCallback onToggle;
@override
Widget build(BuildContext context) {
final d = discount;
final isActive = d.isActive;
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Material(
color: isActive ? AppColors.surface : AppColors.surfaceVariant,
borderRadius: BorderRadius.circular(14),
child: InkWell(
onTap: onEdit,
borderRadius: BorderRadius.circular(14),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: isActive ? AppColors.border : AppColors.muted),
boxShadow: isActive
? [
BoxShadow(
color: Colors.black.withValues(alpha: 0.03),
blurRadius: 6,
offset: const Offset(0, 2))
]
: [],
),
child: IntrinsicHeight(
child: Row(
children: [
Container(
width: 4,
decoration: BoxDecoration(
color:
isActive ? AppColors.success : AppColors.border,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(14),
bottomLeft: Radius.circular(14),
),
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 4, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: isActive
? AppColors.successBg
: AppColors.background,
borderRadius: BorderRadius.circular(8),
),
child: Text(
'${d.displayValue} İndirim',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w800,
color: isActive
? AppColors.success
: AppColors.textMuted,
),
),
),
if (d.minQuantity > 0) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 7, vertical: 4),
decoration: BoxDecoration(
color: AppColors.pendingBg,
borderRadius: BorderRadius.circular(6),
),
child: Text(
'${d.minQuantity} adet',
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: AppColors.pending),
),
),
],
const Spacer(),
Transform.scale(
scale: 0.8,
child: Switch(
value: isActive,
onChanged: (_) => onToggle(),
activeColor: AppColors.success,
),
),
],
),
const SizedBox(height: 8),
Row(
children: [
_Tag(
icon: Icons.local_hospital_outlined,
label: d.appliesToAll
? 'Tüm Klinikler'
: (d.clinicName ?? 'Klinik'),
color: AppColors.inProgress,
),
const SizedBox(width: 6),
_Tag(
icon: Icons.science_outlined,
label: d.prostheticLabel,
color: AppColors.accent,
),
],
),
if (d.notes != null && d.notes!.isNotEmpty) ...[
const SizedBox(height: 6),
Text(d.notes!,
style: const TextStyle(
fontSize: 12,
color: AppColors.textMuted),
maxLines: 1,
overflow: TextOverflow.ellipsis),
],
],
),
),
),
IconButton(
onPressed: onDelete,
icon: const Icon(Icons.delete_outline_rounded,
size: 18, color: AppColors.cancelled),
tooltip: 'Sil',
),
],
),
),
),
),
),
);
}
}
class _Tag extends StatelessWidget {
const _Tag(
{required this.icon, required this.label, required this.color});
final IconData icon;
final String label;
final Color color;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(6),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 11, color: color),
const SizedBox(width: 4),
Text(label,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: color)),
],
),
);
}
}
// ── Discount sheet ────────────────────────────────────────────────────────────
class _DiscountSheet extends StatefulWidget {
const _DiscountSheet({required this.labTenantId, this.existing});
final String labTenantId;
final ClinicDiscount? existing;
@override
State<_DiscountSheet> createState() => _DiscountSheetState();
}
class _DiscountSheetState extends State<_DiscountSheet> {
final _valueCtrl = TextEditingController();
final _minQtyCtrl = TextEditingController();
final _notesCtrl = TextEditingController();
DiscountType _discountType = DiscountType.percentage;
String? _selectedClinicId;
String? _selectedType;
bool _isActive = true;
bool _saving = false;
List<_ClinicOption>? _clinics;
@override
void initState() {
super.initState();
final e = widget.existing;
if (e != null) {
_valueCtrl.text = e.discountValue.toStringAsFixed(
e.discountValue % 1 == 0 ? 0 : 2);
_minQtyCtrl.text =
e.minQuantity > 0 ? e.minQuantity.toString() : '';
_notesCtrl.text = e.notes ?? '';
_discountType = e.discountType;
_selectedClinicId = e.clinicTenantId;
_selectedType = e.prostheticType;
_isActive = e.isActive;
}
_loadClinics();
}
Future<void> _loadClinics() async {
try {
final pb = PocketBaseClient.instance.pb;
final result = await pb.collection('tenant_connections').getList(
filter:
'lab_tenant_id = "${widget.labTenantId}" && status = "approved"',
expand: 'clinic_tenant_id',
perPage: 100,
);
final clinics = result.items.map((r) {
final j = r.toJson();
final expand = j['expand'] as Map<String, dynamic>?;
final clinic =
expand?['clinic_tenant_id'] as Map<String, dynamic>?;
return _ClinicOption(
id: j['clinic_tenant_id'] as String? ?? '',
name: clinic?['company_name'] as String? ?? 'Klinik',
);
}).where((c) => c.id.isNotEmpty).toList();
if (mounted) setState(() => _clinics = clinics);
} catch (_) {
if (mounted) setState(() => _clinics = []);
}
}
@override
void dispose() {
_valueCtrl.dispose();
_minQtyCtrl.dispose();
_notesCtrl.dispose();
super.dispose();
}
Future<void> _save() async {
final valueStr = _valueCtrl.text.trim().replaceAll(',', '.');
final value = double.tryParse(valueStr);
if (value == null || value <= 0) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Geçerli bir indirim değeri girin.'),
backgroundColor: AppColors.cancelled),
);
return;
}
if (_discountType == DiscountType.percentage && value > 100) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("Yüzde indirim 100'ü geçemez."),
backgroundColor: AppColors.cancelled),
);
return;
}
final minQty = int.tryParse(_minQtyCtrl.text.trim()) ?? 0;
setState(() => _saving = true);
final navigator = Navigator.of(context);
final messenger = ScaffoldMessenger.of(context);
try {
if (widget.existing != null) {
await DiscountRepository.instance.updateDiscount(
widget.existing!.id,
clinicTenantId: _selectedClinicId ?? '',
prostheticType: _selectedType ?? '',
discountType: _discountType,
discountValue: value,
minQuantity: minQty,
isActive: _isActive,
notes: _notesCtrl.text.trim(),
);
} else {
await DiscountRepository.instance.createDiscount(
labTenantId: widget.labTenantId,
clinicTenantId: _selectedClinicId,
prostheticType: _selectedType,
discountType: _discountType,
discountValue: value,
minQuantity: minQty,
isActive: _isActive,
notes: _notesCtrl.text.trim(),
);
}
navigator.pop(true);
} catch (e) {
if (mounted) setState(() => _saving = false);
messenger.showSnackBar(
SnackBar(
content: Text('Hata: $e'),
backgroundColor: AppColors.cancelled),
);
}
}
static const _prostheticTypes = [
('', 'Tüm Türler'),
('metal_porselen', 'Metal Porselen'),
('zirkonyum', 'Zirkonyum'),
('implant_ustu_zirkonyum', 'İmplant Üstü Zirkonyum'),
('gecici', 'Geçici'),
('e_max', 'E-Max'),
('tam_protez', 'Tam Protez'),
('parsiyel', 'Parsiyel Protez'),
('diger', 'Diğer'),
];
@override
Widget build(BuildContext context) {
final bottom = MediaQuery.paddingOf(context).bottom;
return Container(
decoration: const BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
padding: EdgeInsets.only(bottom: bottom),
child: SingleChildScrollView(
padding: EdgeInsets.only(
left: 20,
right: 20,
top: 20,
bottom: MediaQuery.viewInsetsOf(context).bottom + 20,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: AppColors.border,
borderRadius: BorderRadius.circular(2)),
),
),
const SizedBox(height: 16),
Text(
widget.existing != null
? 'İndirimi Düzenle'
: 'Yeni İndirim',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary),
),
const SizedBox(height: 20),
const Text('İndirim Türü',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary)),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: _TypeButton(
label: 'Yüzde (%)',
icon: Icons.percent_rounded,
selected: _discountType == DiscountType.percentage,
onTap: () => setState(
() => _discountType = DiscountType.percentage),
),
),
const SizedBox(width: 10),
Expanded(
child: _TypeButton(
label: 'Sabit Tutar',
icon: Icons.currency_lira_rounded,
selected: _discountType == DiscountType.fixed,
onTap: () =>
setState(() => _discountType = DiscountType.fixed),
),
),
],
),
const SizedBox(height: 16),
const Text('İndirim Değeri',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary)),
const SizedBox(height: 8),
TextField(
controller: _valueCtrl,
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
decoration: InputDecoration(
hintText: _discountType == DiscountType.percentage
? 'Örn: 10'
: 'Örn: 150',
suffixText:
_discountType == DiscountType.percentage ? '%' : 'TL',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12)),
),
),
const SizedBox(height: 16),
const Text('Klinik',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary)),
const SizedBox(height: 8),
_ClinicDropdown(
selectedId: _selectedClinicId,
clinics: _clinics,
onChanged: (id, _) => setState(() {
_selectedClinicId = id;
}),
),
const SizedBox(height: 16),
const Text('Ürün Tipi',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary)),
const SizedBox(height: 8),
DropdownButtonFormField<String>(
value: _selectedType ?? '',
decoration: InputDecoration(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12)),
contentPadding: const EdgeInsets.symmetric(
horizontal: 14, vertical: 12),
),
items: _prostheticTypes
.map((t) =>
DropdownMenuItem(value: t.$1, child: Text(t.$2)))
.toList(),
onChanged: (v) =>
setState(() => _selectedType = v == '' ? null : v),
),
const SizedBox(height: 16),
const Text('Minimum Sipariş Adedi (İsteğe Bağlı)',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary)),
const SizedBox(height: 4),
const Text(
'Aylık bu adede ulaşılınca indirim devreye girer. 0 = koşulsuz.',
style:
TextStyle(fontSize: 11, color: AppColors.textMuted)),
const SizedBox(height: 8),
TextField(
controller: _minQtyCtrl,
keyboardType: TextInputType.number,
decoration: InputDecoration(
hintText: '0',
suffixText: 'adet',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12)),
),
),
const SizedBox(height: 16),
const Text('Not (İsteğe Bağlı)',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary)),
const SizedBox(height: 8),
TextField(
controller: _notesCtrl,
maxLines: 2,
decoration: InputDecoration(
hintText: 'Açıklama...',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12)),
),
),
const SizedBox(height: 16),
Row(
children: [
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Aktif',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary)),
Text('Pasif indirimler uygulanmaz.',
style: TextStyle(
fontSize: 12, color: AppColors.textMuted)),
],
),
),
Switch(
value: _isActive,
onChanged: (v) => setState(() => _isActive = v),
activeColor: AppColors.success,
),
],
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
height: 50,
child: FilledButton(
onPressed: _saving ? null : _save,
style: FilledButton.styleFrom(
backgroundColor: AppColors.accent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14)),
),
child: _saving
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white))
: Text(
widget.existing != null ? 'Güncelle' : 'Kaydet',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600)),
),
),
],
),
),
);
}
}
class _TypeButton extends StatelessWidget {
const _TypeButton({
required this.label,
required this.icon,
required this.selected,
required this.onTap,
});
final String label;
final IconData icon;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: selected ? AppColors.accent : AppColors.background,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: selected ? AppColors.accent : AppColors.border),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon,
size: 16,
color: selected ? Colors.white : AppColors.textSecondary),
const SizedBox(width: 6),
Text(label,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: selected
? Colors.white
: AppColors.textSecondary)),
],
),
),
);
}
}
class _ClinicDropdown extends StatelessWidget {
const _ClinicDropdown({
required this.selectedId,
required this.clinics,
required this.onChanged,
});
final String? selectedId;
final List<_ClinicOption>? clinics;
final void Function(String? id, String? name) onChanged;
@override
Widget build(BuildContext context) {
if (clinics == null) {
return Container(
height: 48,
decoration: BoxDecoration(
border: Border.all(color: AppColors.border),
borderRadius: BorderRadius.circular(12),
),
child: const Center(
child: SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2))),
);
}
final items = <DropdownMenuItem<String>>[
const DropdownMenuItem(value: '', child: Text('Tüm Klinikler')),
for (final c in clinics!)
DropdownMenuItem(value: c.id, child: Text(c.name)),
];
return DropdownButtonFormField<String>(
value: selectedId ?? '',
decoration: InputDecoration(
border:
OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
contentPadding:
const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
),
items: items,
onChanged: (v) {
if (v == null || v.isEmpty) {
onChanged(null, null);
} else {
final clinic = clinics!.firstWhere((c) => c.id == v);
onChanged(v, clinic.name);
}
},
);
}
}
@@ -0,0 +1,42 @@
import 'package:pocketbase/pocketbase.dart';
import '../../../core/api/pocketbase_client.dart';
import '../../../models/finance_entry.dart';
class LabFinanceRepository {
LabFinanceRepository._();
static final instance = LabFinanceRepository._();
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 = "receivable"'];
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};
}
}
@@ -0,0 +1,467 @@
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 'lab_finance_repository.dart';
enum _FinanceSort { newestFirst, byAmountDesc, byAmountAsc }
class LabFinanceScreen extends ConsumerStatefulWidget {
const LabFinanceScreen({super.key});
@override
ConsumerState<LabFinanceScreen> createState() => _LabFinanceScreenState();
}
class _LabFinanceScreenState extends ConsumerState<LabFinanceScreen>
with SingleTickerProviderStateMixin {
late TabController _tabController;
late Future<_FinanceData> _future;
_FinanceSort _sort = _FinanceSort.newestFirst;
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
_tabController.addListener(() {
if (mounted) setState(() {});
});
_load();
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
void _load() {
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
setState(() {
_future = Future.wait([
LabFinanceRepository.instance.listEntries(tenantId, status: 'pending'),
LabFinanceRepository.instance.listEntries(tenantId, status: 'paid'),
LabFinanceRepository.instance.summary(tenantId),
]).then((results) => _FinanceData(
pending: results[0] as List<FinanceEntry>,
paid: results[1] as List<FinanceEntry>,
summary: results[2] as Map<String, double>,
));
});
}
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]);
}
}
List<FinanceEntry> _sorted(List<FinanceEntry> entries) {
final list = List<FinanceEntry>.from(entries);
switch (_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;
}
String _formatDate(String? raw) {
if (raw == null) return '';
try {
final dt = DateTime.parse(raw);
return '${dt.day.toString().padLeft(2, '0')}.${dt.month.toString().padLeft(2, '0')}.${dt.year}';
} catch (_) {
return '';
}
}
@override
Widget build(BuildContext context) {
final isSortActive = _sort != _FinanceSort.newestFirst;
final s = ref.watch(stringsProvider);
final currencyCode =
ref.watch(authProvider).activeTenant?.tenant.defaultCurrency ?? 'TRY';
String formatAmount(double amount) =>
CurrencyFormatter.format(amount, currencyCode);
return Scaffold(
backgroundColor: AppColors.background,
appBar: GradientAppBar(
title: s.finance,
category: s.laboratoryCategory,
actions: [
IconButton(
onPressed: _showSortOptions,
tooltip: 'Sırala',
icon: Badge(
isLabelVisible: isSortActive,
smallSize: 8,
backgroundColor: AppColors.accent,
child: const Icon(Icons.sort_rounded),
),
),
],
),
body: RefreshIndicator(
color: AppColors.accent,
onRefresh: () async => _load(),
child: FutureBuilder<_FinanceData>(
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 data = snap.data!;
final pendingTotal = data.summary['pending'] ?? 0.0;
final paidTotal = data.summary['paid'] ?? 0.0;
final pending = _sorted(data.pending);
final paid = _sorted(data.paid);
return Column(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: _SummaryCard(
label: s.pendingReceivable,
amount: formatAmount(pendingTotal),
color: AppColors.pending,
bgColor: AppColors.pendingBg,
icon: Icons.hourglass_empty_rounded,
),
),
const SizedBox(width: 12),
Expanded(
child: _SummaryCard(
label: s.collected,
amount: formatAmount(paidTotal),
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),
counts: [pending.length, paid.length],
),
Expanded(
child: TabBarView(
controller: _tabController,
children: [
_EntriesList(
entries: pending,
emptyMessage: s.noPendingEntries,
emptyIcon: Icons.hourglass_empty_rounded,
formatDate: _formatDate,
formatAmount: formatAmount,
),
_EntriesList(
entries: paid,
emptyMessage: s.noPaidEntries,
emptyIcon: Icons.check_circle_outline,
formatDate: _formatDate,
formatAmount: formatAmount,
),
],
),
),
],
);
},
),
),
);
}
}
class _FinanceData {
const _FinanceData({
required this.pending,
required this.paid,
required this.summary,
});
final List<FinanceEntry> pending;
final List<FinanceEntry> paid;
final Map<String, double> summary;
}
class _SummaryCard extends StatelessWidget {
const _SummaryCard({
required this.label,
required this.amount,
required this.color,
required this.bgColor,
required this.icon,
});
final String label;
final String amount;
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(
amount,
style: TextStyle(
fontSize: 18,
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 _EntriesList extends StatelessWidget {
const _EntriesList({
required this.entries,
required this.emptyMessage,
required this.emptyIcon,
required this.formatDate,
required this.formatAmount,
});
final List<FinanceEntry> entries;
final String emptyMessage;
final IconData emptyIcon;
final String Function(String?) formatDate;
final String Function(double) formatAmount;
@override
Widget build(BuildContext context) {
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: Icon(emptyIcon, size: 32, color: AppColors.inProgress),
),
const SizedBox(height: 16),
Text(emptyMessage,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary)),
],
),
);
}
return ListView.builder(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
itemCount: entries.length,
itemBuilder: (ctx, i) {
final entry = entries[i];
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: 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(
isPending
? Icons.hourglass_empty_rounded
: Icons.check_circle_outline,
color: statusColor,
size: 22,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
entry.counterpartyName ?? 'Klinik',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary),
),
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),
),
],
],
),
),
const SizedBox(width: 8),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
formatAmount(entry.amount),
style: TextStyle(
fontWeight: FontWeight.w700,
color: statusColor,
fontSize: 15,
),
),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: statusBg,
borderRadius: BorderRadius.circular(8),
),
child: Text(
entry.status.label,
style: TextStyle(
color: statusColor,
fontSize: 11,
fontWeight: FontWeight.w600,
),
),
),
],
),
],
),
),
);
},
);
}
}
@@ -0,0 +1,896 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/providers/auth_provider.dart';
import '../../../core/services/realtime_service.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/gradient_app_bar.dart';
import '../../../core/widgets/pill_tabs.dart';
import '../../../models/job.dart';
import 'lab_jobs_repository.dart';
enum _JobSort { newestFirst, oldestFirst, byDueDate, byType }
const _kSortLabels = [
'Yeniden Eskiye',
'Eskiden Yeniye',
'Vade Tarihine Göre',
'Türe Göre',
];
class LabAllJobsScreen extends ConsumerStatefulWidget {
const LabAllJobsScreen({super.key});
@override
ConsumerState<LabAllJobsScreen> createState() => _LabAllJobsScreenState();
}
class _LabAllJobsScreenState extends ConsumerState<LabAllJobsScreen>
with SingleTickerProviderStateMixin {
late TabController _tabController;
final _searchController = TextEditingController();
String _searchQuery = '';
_JobSort _sort = _JobSort.newestFirst;
bool _bulkAccepting = false;
final Map<String, int?> _counts = {
'all': null,
'pending': null,
'in_progress': null,
'sent': null,
'delivered': null,
};
final _pendingTabKey = GlobalKey<_PendingJobsTabState>();
// null entry = Tümü (bütün statüsler)
static const List<String?> _statuses = [null, 'pending', 'in_progress', 'sent', 'delivered'];
static const _tabLabels = ['Tümü', 'Onay Bekleyen', 'Devam Eden', 'Gönderildi', 'Teslim Edildi'];
String _countKey(String? s) => s ?? 'all';
@override
void initState() {
super.initState();
final isDelivery = ref.read(authProvider).activeTenant?.isDeliveryOnly ?? false;
_tabController = TabController(length: 5, vsync: this, initialIndex: isDelivery ? 3 : 0);
_tabController.addListener(() {
if (mounted) setState(() {});
});
_fetchAllCounts();
}
Future<void> _fetchAllCounts() async {
final tenantId = ref.read(authProvider).activeTenant?.tenant.id;
if (tenantId == null) return;
final results = await Future.wait(
_statuses.map((s) => LabJobsRepository.instance.countByStatus(tenantId, s)),
);
if (!mounted) return;
setState(() {
for (var i = 0; i < _statuses.length; i++) {
_counts[_countKey(_statuses[i])] = results[i];
}
});
}
@override
void dispose() {
_tabController.dispose();
_searchController.dispose();
super.dispose();
}
void _onSearchChanged(String value) {
setState(() => _searchQuery = value);
}
Future<void> _showSortOptions() async {
final result = await showSortSheet(
context,
title: 'Sıralama',
options: _kSortLabels,
current: _sort.index,
);
if (result != null) {
setState(() => _sort = _JobSort.values[result]);
}
}
Future<void> _bulkAccept() async {
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
setState(() => _bulkAccepting = true);
try {
await LabJobsRepository.instance.bulkAcceptPending(tenantId);
_pendingTabKey.currentState?._load();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Tüm işler kabul edildi')),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Hata: $e')),
);
}
} finally {
if (mounted) setState(() => _bulkAccepting = false);
}
}
@override
Widget build(BuildContext context) {
final isSortActive = _sort != _JobSort.newestFirst;
final onPendingTab = _tabController.index == 1;
final pendingCount = _counts['pending'];
return Scaffold(
backgroundColor: AppColors.background,
appBar: GradientAppBar(
title: 'İşler',
category: 'LABORATUVAR',
searchController: _searchController,
onSearchChanged: _onSearchChanged,
searchHint: 'Protokol, klinik veya tür ara...',
actions: [
if (!onPendingTab)
IconButton(
onPressed: _showSortOptions,
tooltip: 'Sırala',
icon: Badge(
isLabelVisible: isSortActive,
smallSize: 8,
backgroundColor: AppColors.accent,
child: const Icon(Icons.sort_rounded),
),
),
],
),
floatingActionButton: onPendingTab && (pendingCount == null || pendingCount > 0)
? FloatingActionButton.extended(
onPressed: _bulkAccepting ? null : _bulkAccept,
icon: _bulkAccepting
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
)
: const Icon(Icons.done_all),
label: Text(_bulkAccepting ? 'Kabul ediliyor...' : 'Tümünü Kabul Et'),
backgroundColor: AppColors.pending,
foregroundColor: Colors.white,
)
: null,
body: Column(
children: [
PillTabs(
tabs: _tabLabels,
selected: _tabController.index,
onSelect: (i) => _tabController.animateTo(i),
counts: _statuses.map((s) => _counts[_countKey(s)]).toList(),
),
Expanded(
child: TabBarView(
controller: _tabController,
children: [
_LabJobsTab(
status: null,
searchQuery: _searchQuery,
sort: _sort,
onCountLoaded: (c) => setState(() => _counts['all'] = c),
),
_PendingJobsTab(
key: _pendingTabKey,
searchQuery: _searchQuery,
onCountLoaded: (c) => setState(() => _counts['pending'] = c),
),
_LabJobsTab(
status: 'in_progress',
searchQuery: _searchQuery,
sort: _sort,
onCountLoaded: (c) => setState(() => _counts['in_progress'] = c),
),
_LabJobsTab(
status: 'sent',
searchQuery: _searchQuery,
sort: _sort,
onCountLoaded: (c) => setState(() => _counts['sent'] = c),
),
_LabJobsTab(
status: 'delivered',
searchQuery: _searchQuery,
sort: _sort,
onCountLoaded: (c) => setState(() => _counts['delivered'] = c),
),
],
),
),
],
),
);
}
}
// ── Pending (Onay Bekleyen) tab ───────────────────────────────────────────────
class _PendingJobsTab extends ConsumerStatefulWidget {
const _PendingJobsTab({super.key, required this.searchQuery, this.onCountLoaded});
final String searchQuery;
final void Function(int)? onCountLoaded;
@override
ConsumerState<_PendingJobsTab> createState() => _PendingJobsTabState();
}
class _PendingJobsTabState extends ConsumerState<_PendingJobsTab> {
late Future<List<Job>> _future;
late UnsubFn _unsub;
@override
void initState() {
super.initState();
_load();
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
_unsub = RealtimeService.instance.watch(
'jobs',
filter: 'lab_tenant_id="$tenantId" && status="pending"',
onEvent: (_) { if (mounted) _load(); },
);
}
@override
void dispose() {
_unsub();
super.dispose();
}
void _load() {
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
setState(() {
_future = LabJobsRepository.instance.listInbound(tenantId, status: 'pending', limit: 50);
});
}
Future<void> _acceptJob(Job job) async {
try {
await LabJobsRepository.instance.acceptJob(job);
_load();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('İş kabul edildi')),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Hata: $e')),
);
}
}
}
List<Job> _filtered(List<Job> jobs) {
final q = widget.searchQuery.toLowerCase().trim();
if (q.isEmpty) return jobs;
return jobs.where((j) =>
j.patientCode.toLowerCase().contains(q) ||
(j.clinicName?.toLowerCase().contains(q) ?? false) ||
j.prostheticType.label.toLowerCase().contains(q)
).toList();
}
@override
Widget build(BuildContext context) {
return RefreshIndicator(
color: AppColors.accent,
onRefresh: () async => _load(),
child: FutureBuilder<List<Job>>(
future: _future,
builder: (ctx, snap) {
if (snap.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator(color: AppColors.accent));
}
if (snap.hasError) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: AppColors.cancelledBg,
borderRadius: BorderRadius.circular(16),
),
child: const Icon(Icons.wifi_off_rounded, color: AppColors.cancelled, size: 30),
),
const SizedBox(height: 16),
Text('Hata: ${snap.error}', style: const TextStyle(color: AppColors.textSecondary)),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: _load,
icon: const Icon(Icons.refresh_rounded, size: 18),
label: const Text('Tekrar Dene'),
),
],
),
);
}
final all = snap.data!;
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.onCountLoaded?.call(all.length);
});
final jobs = _filtered(all);
if (jobs.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 72,
height: 72,
decoration: BoxDecoration(
color: AppColors.successBg,
borderRadius: BorderRadius.circular(20),
),
child: const Icon(Icons.inbox_outlined, color: AppColors.success, size: 32),
),
const SizedBox(height: 16),
Text(
widget.searchQuery.isNotEmpty ? 'Sonuç bulunamadı' : 'Onay bekleyen iş yok',
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary),
),
if (widget.searchQuery.isEmpty) ...[
const SizedBox(height: 6),
const Text('Tüm işler kabul edildi', style: TextStyle(color: AppColors.textSecondary, fontSize: 13)),
],
],
),
);
}
return ListView.builder(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 100),
itemCount: jobs.length,
itemBuilder: (ctx, i) {
final job = jobs[i];
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: _PendingJobCard(
job: job,
onAccept: () => _acceptJob(job),
),
);
},
);
},
),
);
}
}
class _PendingJobCard extends StatefulWidget {
const _PendingJobCard({required this.job, required this.onAccept});
final Job job;
final VoidCallback onAccept;
@override
State<_PendingJobCard> createState() => _PendingJobCardState();
}
class _PendingJobCardState extends State<_PendingJobCard> {
bool _accepting = false;
@override
Widget build(BuildContext context) {
final job = widget.job;
return Dismissible(
key: ValueKey(job.id),
direction: DismissDirection.endToStart,
background: Container(
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
decoration: BoxDecoration(
color: AppColors.success,
borderRadius: BorderRadius.circular(14),
),
child: const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.check_rounded, color: Colors.white, size: 28),
SizedBox(height: 4),
Text('Kabul Et', style: TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.w600)),
],
),
),
confirmDismiss: (_) async {
setState(() => _accepting = true);
try {
await LabJobsRepository.instance.acceptJob(job);
return true;
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Hata: $e')),
);
}
return false;
} finally {
if (mounted) setState(() => _accepting = false);
}
},
child: Material(
color: AppColors.surface,
borderRadius: BorderRadius.circular(14),
child: InkWell(
onTap: () => context.push('/lab/jobs/${job.id}'),
borderRadius: BorderRadius.circular(14),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.border),
boxShadow: [
BoxShadow(color: Colors.black.withValues(alpha: 0.04), blurRadius: 8, offset: const Offset(0, 2)),
],
),
child: IntrinsicHeight(
child: Row(
children: [
Container(
width: 4,
decoration: const BoxDecoration(
color: AppColors.pending,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(14),
bottomLeft: Radius.circular(14),
),
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.all(14),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
job.patientCode,
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700, color: AppColors.textPrimary),
),
const SizedBox(height: 4),
Row(
children: [
const Icon(Icons.local_hospital_outlined, size: 12, color: AppColors.textMuted),
const SizedBox(width: 4),
Expanded(
child: Text(
job.clinicName ?? 'Klinik',
style: const TextStyle(fontSize: 12, color: AppColors.textSecondary),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 6),
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
decoration: BoxDecoration(
color: AppColors.pendingBg,
borderRadius: BorderRadius.circular(6),
),
child: Text(
job.prostheticType.label,
style: const TextStyle(fontSize: 11, color: AppColors.pending, fontWeight: FontWeight.w600),
),
),
if (job.dueDate != null) ...[
const SizedBox(width: 6),
const Icon(Icons.calendar_today_outlined, size: 11, color: AppColors.textMuted),
const SizedBox(width: 3),
Text(
'${job.dueDate!.day.toString().padLeft(2, '0')}.${job.dueDate!.month.toString().padLeft(2, '0')}.${job.dueDate!.year}',
style: const TextStyle(fontSize: 11, color: AppColors.textMuted),
),
],
],
),
],
),
),
const SizedBox(width: 8),
_accepting
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: AppColors.success),
)
: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7),
decoration: BoxDecoration(
color: AppColors.successBg,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppColors.success.withValues(alpha: 0.3)),
),
child: const Text(
'Kabul Et',
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w700, color: AppColors.success),
),
),
],
),
),
),
],
),
),
),
),
),
);
}
}
class _LabJobsTab extends ConsumerStatefulWidget {
const _LabJobsTab({
required this.status,
required this.searchQuery,
required this.sort,
this.onCountLoaded,
});
final String? status; // null = tüm statüsler
final String searchQuery;
final _JobSort sort;
final void Function(int)? onCountLoaded;
@override
ConsumerState<_LabJobsTab> createState() => _LabJobsTabState();
}
class _LabJobsTabState extends ConsumerState<_LabJobsTab> {
late Future<List<Job>> _future;
late UnsubFn _unsub;
@override
void initState() {
super.initState();
_load();
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
_unsub = RealtimeService.instance.watch(
'jobs',
filter: 'lab_tenant_id="$tenantId"',
onEvent: (_) { if (mounted) _load(); },
);
}
@override
void dispose() {
_unsub();
super.dispose();
}
void _load() {
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
setState(() {
_future = LabJobsRepository.instance
.listInbound(tenantId, status: widget.status, limit: 50);
});
}
List<Job> _applyFilters(List<Job> jobs) {
var list = jobs;
final q = widget.searchQuery.toLowerCase().trim();
if (q.isNotEmpty) {
list = list.where((j) {
return j.patientCode.toLowerCase().contains(q) ||
(j.clinicName?.toLowerCase().contains(q) ?? false) ||
j.prostheticType.label.toLowerCase().contains(q) ||
(j.currentStep?.label.toLowerCase().contains(q) ?? false);
}).toList();
}
final sorted = List<Job>.from(list);
switch (widget.sort) {
case _JobSort.newestFirst:
sorted.sort((a, b) => b.dateCreated.compareTo(a.dateCreated));
case _JobSort.oldestFirst:
sorted.sort((a, b) => a.dateCreated.compareTo(b.dateCreated));
case _JobSort.byDueDate:
sorted.sort((a, b) {
if (a.dueDate == null && b.dueDate == null) return 0;
if (a.dueDate == null) return 1;
if (b.dueDate == null) return -1;
return a.dueDate!.compareTo(b.dueDate!);
});
case _JobSort.byType:
sorted.sort(
(a, b) => a.prostheticType.label.compareTo(b.prostheticType.label));
}
return sorted;
}
@override
Widget build(BuildContext context) {
return RefreshIndicator(
color: AppColors.accent,
onRefresh: () async => _load(),
child: FutureBuilder<List<Job>>(
future: _future,
builder: (ctx, snap) {
if (snap.connectionState == ConnectionState.waiting) {
return const Center(
child: CircularProgressIndicator(color: AppColors.accent));
}
if (snap.hasError) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: AppColors.cancelledBg,
borderRadius: BorderRadius.circular(16)),
child: const Icon(Icons.wifi_off_rounded,
color: AppColors.cancelled, size: 30),
),
const SizedBox(height: 16),
Text('Hata: ${snap.error}',
style:
const TextStyle(color: AppColors.textSecondary)),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: _load,
icon: const Icon(Icons.refresh_rounded, size: 18),
label: const Text('Tekrar Dene'),
),
],
),
);
}
final all = snap.data!;
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.onCountLoaded?.call(all.length);
});
final jobs = _applyFilters(all);
if (jobs.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 72,
height: 72,
decoration: BoxDecoration(
color: AppColors.inProgressBg,
borderRadius: BorderRadius.circular(20)),
child: const Icon(Icons.work_off_outlined,
color: AppColors.inProgress, size: 32),
),
const SizedBox(height: 16),
Text(
widget.searchQuery.isNotEmpty
? 'Sonuç bulunamadı'
: 'Henüz iş yok',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary),
),
],
),
);
}
return ListView.builder(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
itemCount: jobs.length,
itemBuilder: (ctx, i) {
final job = jobs[i];
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: _LabJobCard(
job: job,
onTap: () => context.push('/lab/jobs/${job.id}'),
),
);
},
);
},
),
);
}
}
class _LabJobCard extends StatelessWidget {
const _LabJobCard({required this.job, required this.onTap});
final Job job;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final isOverdue =
job.dueDate != null && job.dueDate!.isBefore(DateTime.now());
final accentColor = _statusColor(job.status);
return Semantics(
label: job.patientCode,
button: true,
excludeSemantics: true,
child: Material(
color: AppColors.surface,
borderRadius: BorderRadius.circular(14),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(14),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.border),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.04),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: IntrinsicHeight(
child: Row(
children: [
Container(
width: 4,
decoration: BoxDecoration(
color: accentColor,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(14),
bottomLeft: Radius.circular(14),
),
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
job.patientCode,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
),
if (job.currentStep != null)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: AppColors.inProgressBg,
borderRadius: BorderRadius.circular(8),
),
child: Text(
job.currentStep!.label,
style: const TextStyle(
color: AppColors.inProgress,
fontSize: 11,
fontWeight: FontWeight.w600,
),
),
),
],
),
const SizedBox(height: 5),
Row(
children: [
const Icon(Icons.local_hospital_outlined,
size: 12, color: AppColors.textMuted),
const SizedBox(width: 4),
Expanded(
child: Text(
job.clinicName ?? 'Klinik',
style: const TextStyle(
fontSize: 12,
color: AppColors.textSecondary),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 5),
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: AppColors.surfaceVariant,
borderRadius: BorderRadius.circular(6),
),
child: Text(
job.prostheticType.label,
style: const TextStyle(
fontSize: 11,
color: AppColors.textSecondary,
fontWeight: FontWeight.w500,
),
),
),
if (job.dueDate != null) ...[
const SizedBox(width: 8),
Icon(Icons.calendar_today_outlined,
size: 11,
color: isOverdue
? AppColors.cancelled
: AppColors.textMuted),
const SizedBox(width: 3),
Text(
_fmt(job.dueDate!),
style: TextStyle(
fontSize: 11,
color: isOverdue
? AppColors.cancelled
: AppColors.textMuted,
fontWeight: isOverdue
? FontWeight.w600
: FontWeight.normal,
),
),
],
],
),
],
),
),
),
const Padding(
padding: EdgeInsets.only(right: 10),
child: Icon(Icons.chevron_right,
color: AppColors.textMuted, size: 20),
),
],
),
),
),
),
),
);
}
String _fmt(DateTime d) =>
'${d.day.toString().padLeft(2, '0')}.${d.month.toString().padLeft(2, '0')}.${d.year}';
Color _statusColor(JobStatus status) {
switch (status) {
case JobStatus.pending:
return AppColors.pending;
case JobStatus.inProgress:
return AppColors.inProgress;
case JobStatus.sent:
return AppColors.accent;
case JobStatus.delivered:
return AppColors.success;
case JobStatus.cancelled:
return AppColors.cancelled;
}
}
}
@@ -0,0 +1,764 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/providers/auth_provider.dart';
import '../../../core/services/realtime_service.dart';
import '../../../core/theme/app_theme.dart';
import '../../../models/job.dart';
import '../../../models/job_file.dart';
import '../../../features/shared/job_files_repository.dart';
import '../../../features/shared/job_files_panel.dart';
import '../../../core/services/job_history_service.dart';
import 'lab_jobs_repository.dart';
// ── Adaptive sheet helper ────────────────────────────────────────────────────
void _showAdaptive(BuildContext context, Widget content) {
final isDesktop = MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint;
if (isDesktop) {
showDialog(
context: context,
builder: (_) => Dialog(
shape:
RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: content,
),
),
);
} else {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => content,
);
}
}
class LabJobDetailScreen extends ConsumerStatefulWidget {
const LabJobDetailScreen({super.key, required this.jobId});
final String jobId;
@override
ConsumerState<LabJobDetailScreen> createState() => _LabJobDetailScreenState();
}
class _LabJobDetailScreenState extends ConsumerState<LabJobDetailScreen> {
Job? _job;
bool _loadingJob = false;
String? _loadError;
bool _isActing = false;
late Future<List<JobFile>> _filesFuture;
late UnsubFn _unsub;
@override
void initState() {
super.initState();
_load();
_loadFiles();
_unsub = RealtimeService.instance.watch(
'jobs',
topic: widget.jobId,
onEvent: (_) { if (mounted && !_isActing) _load(); },
);
}
@override
void dispose() {
_unsub();
super.dispose();
}
Future<void> _load() async {
setState(() { _loadingJob = true; _loadError = null; });
try {
final job = await LabJobsRepository.instance.getJob(widget.jobId);
if (mounted) setState(() { _job = job; _loadingJob = false; });
} catch (e) {
if (mounted) setState(() { _loadError = e.toString(); _loadingJob = false; });
}
}
void _loadFiles() {
setState(() {
_filesFuture = JobFilesRepository.instance.listForJob(widget.jobId);
});
}
Future<void> _cancelJob(Job job) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('İşi İptal Et'),
content: const Text('Bu iş geri alınamaz şekilde iptal edilir. Onaylıyor musunuz?'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Vazgeç')),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: AppColors.cancelled),
onPressed: () => Navigator.pop(ctx, true),
child: const Text('İptal Et'),
),
],
),
);
if (confirmed != true || !mounted) return;
setState(() => _isActing = true);
try {
final updated = await LabJobsRepository.instance.cancelJob(job.id, job);
if (mounted) {
setState(() { _job = _job!.copyWith(status: updated.status); _isActing = false; });
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('İş iptal edildi.')));
}
} catch (e) {
if (mounted) {
setState(() => _isActing = false);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Hata: $e')));
}
}
}
Future<void> _acceptJob(Job job) async {
setState(() => _isActing = true);
try {
final updated = await LabJobsRepository.instance.acceptJob(job);
if (mounted) {
setState(() { _job = updated.copyWith(clinicName: job.clinicName, labName: job.labName); _isActing = false; });
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('İş kabul edildi')),
);
}
} catch (e) {
if (mounted) {
setState(() => _isActing = false);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Hata: $e')));
}
}
}
void _showHandToClinicSheet(Job job) {
_showAdaptive(
context,
_HandToClinicSheet(
job: job,
onDone: (Job updated) {
if (mounted) setState(() => _job = updated.copyWith(clinicName: job.clinicName, labName: job.labName));
},
),
);
}
Color _statusColor(JobStatus status) {
return switch (status) {
JobStatus.pending => AppColors.pending,
JobStatus.inProgress => AppColors.inProgress,
JobStatus.sent => AppColors.accent,
JobStatus.delivered => AppColors.success,
JobStatus.cancelled => AppColors.cancelled,
};
}
Color _statusBg(JobStatus status) {
return switch (status) {
JobStatus.pending => AppColors.pendingBg,
JobStatus.inProgress => AppColors.inProgressBg,
JobStatus.sent => AppColors.inProgressBg,
JobStatus.delivered => AppColors.successBg,
JobStatus.cancelled => AppColors.cancelledBg,
};
}
String _formatDate(DateTime dt, {bool withTime = false}) {
final d = '${dt.day.toString().padLeft(2, '0')}.${dt.month.toString().padLeft(2, '0')}.${dt.year}';
if (!withTime || (dt.hour == 0 && dt.minute == 0)) return d;
return '$d ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('İş Detayı'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.pop(),
),
),
body: _buildBody(),
);
}
Widget _buildBody() {
if (_loadingJob && _job == null) {
return const Center(
child: CircularProgressIndicator(color: AppColors.accent));
}
if (_loadError != null && _job == null) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: AppColors.cancelledBg,
borderRadius: BorderRadius.circular(16)),
child: const Icon(Icons.wifi_off_rounded,
color: AppColors.cancelled, size: 30),
),
const SizedBox(height: 16),
Text('Hata: $_loadError',
style: const TextStyle(color: AppColors.textSecondary)),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: _load,
icon: const Icon(Icons.refresh_rounded, size: 18),
label: const Text('Tekrar Dene'),
),
],
),
);
}
if (_job == null) return const SizedBox.shrink();
{
final job = _job!;
final membership = ref.read(authProvider).activeTenant;
final isDeliveryOnly = membership?.isDeliveryOnly ?? false;
final canCancelJobs = membership?.canCancelJobs ?? true;
final canSendToClinic = !isDeliveryOnly &&
job.status == JobStatus.inProgress &&
job.location == JobLocation.atLab;
final canAccept = !isDeliveryOnly && job.status == JobStatus.pending;
return ListView(
padding: const EdgeInsets.all(16),
children: [
// Header card
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.border),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 12,
offset: const Offset(0, 4))
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
job.patientCode,
style: Theme.of(context)
.textTheme
.headlineSmall
?.copyWith(fontWeight: FontWeight.bold,
color: AppColors.textPrimary),
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 5),
decoration: BoxDecoration(
color: _statusBg(job.status),
borderRadius: BorderRadius.circular(10),
),
child: Text(
job.status.label,
style: TextStyle(
color: _statusColor(job.status),
fontWeight: FontWeight.w600,
fontSize: 13,
),
),
),
],
),
const SizedBox(height: 12),
_InfoRow(
icon: Icons.business,
label: 'Klinik',
value: job.clinicName ?? '-'),
_InfoRow(
icon: Icons.medical_services_outlined,
label: 'Protez Tipi',
value: job.prostheticType.label),
_InfoRow(
icon: Icons.format_list_numbered,
label: 'Üye Sayısı',
value: '${job.memberCount} üye'),
if (job.color != null)
_InfoRow(
icon: Icons.color_lens_outlined,
label: 'Renk',
value: job.color!),
if (job.dueDate != null)
_InfoRow(
icon: Icons.calendar_today,
label: 'Teslim Tarihi',
value: _formatDate(job.dueDate!, withTime: true),
valueColor: job.dueDate!.isBefore(DateTime.now())
? AppColors.cancelled
: null),
_InfoRow(
icon: Icons.add_circle_outline,
label: 'Oluşturulma',
value: _formatDate(job.dateCreated)),
if (job.price != null && job.currency != null)
_InfoRow(
icon: Icons.attach_money,
label: 'Fiyat',
value:
'${job.price!.toStringAsFixed(2)} ${job.currency}'),
if (job.description != null &&
job.description!.isNotEmpty)
_InfoRow(
icon: Icons.notes,
label: 'Açıklama',
value: job.description!),
],
),
),
const SizedBox(height: 16),
// Stepper
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.border),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 12,
offset: const Offset(0, 4))
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'İş Adımları',
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(fontWeight: FontWeight.w600,
color: AppColors.textPrimary),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: job.provaRequired
? AppColors.inProgressBg
: AppColors.successBg,
borderRadius: BorderRadius.circular(6),
),
child: Text(
job.provaRequired ? 'Provalı' : 'Provasız',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: job.provaRequired
? AppColors.inProgress
: AppColors.success,
),
),
),
],
),
const SizedBox(height: 16),
_JobStepper(
steps: job.stepTemplate,
currentStep: job.currentStep,
historyFuture: JobHistoryService.instance
.listForJob(job.id),
),
],
),
),
const SizedBox(height: 24),
// Action buttons
if (_isActing)
const Padding(
padding: EdgeInsets.symmetric(vertical: 8),
child: Center(child: CircularProgressIndicator(color: AppColors.accent)),
)
else ...[
if (canAccept)
FilledButton.icon(
onPressed: () => _acceptJob(job),
icon: const Icon(Icons.check_circle_outline),
label: const Text('Kabul Et'),
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(52),
backgroundColor: AppColors.success,
),
),
if (canSendToClinic)
FilledButton.icon(
onPressed: () => _showHandToClinicSheet(job),
icon: const Icon(Icons.send_outlined),
label: Text(
(job.isLastStep)
? 'Son Prova - Teslime Gönder'
: 'Prova için Kliniğe Gönder',
),
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(52),
backgroundColor: (job.isLastStep)
? AppColors.success
: AppColors.inProgress,
),
),
if (canCancelJobs && job.status == JobStatus.pending) ...[
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: () => _cancelJob(job),
icon: const Icon(Icons.close_rounded),
label: const Text('İşi İptal Et'),
style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(50),
foregroundColor: AppColors.cancelled,
side: const BorderSide(color: AppColors.cancelled),
),
),
],
],
const SizedBox(height: 20),
JobFilesPanel(
job: job,
filesFuture: _filesFuture,
onRefresh: _loadFiles,
),
const SizedBox(height: 16),
],
);
}
}
}
// ── Hand to Clinic Sheet ─────────────────────────────────────────────────────
class _HandToClinicSheet extends StatefulWidget {
const _HandToClinicSheet({required this.job, required this.onDone});
final Job job;
final void Function(Job updatedJob) onDone;
@override
State<_HandToClinicSheet> createState() => _HandToClinicSheetState();
}
class _HandToClinicSheetState extends State<_HandToClinicSheet> {
final _noteController = TextEditingController();
bool _sending = false;
@override
void dispose() {
_noteController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final isDesktop = MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint;
final isLast = widget.job.isLastStep;
final stepLabel = widget.job.currentStep?.label ?? '';
final buttonLabel = isLast
? (widget.job.provaRequired ? 'Son Prova · Teslime Gönder' : 'Teslime Gönder')
: '$stepLabel için Kliniğe Gönder';
final buttonColor = isLast ? AppColors.success : AppColors.inProgress;
return Container(
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.vertical(
top: isDesktop ? Radius.zero : const Radius.circular(20),
),
),
padding: EdgeInsets.only(
left: 20,
right: 20,
top: 24,
bottom: isDesktop
? 24
: MediaQuery.of(context).viewInsets.bottom + 24,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
buttonLabel,
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(fontWeight: FontWeight.bold,
color: AppColors.textPrimary),
),
const SizedBox(height: 8),
Text(
isLast
? 'İş teslim edilecek olarak işaretlenecek.'
: 'İş klinikteki prova için gönderilecek.',
style: const TextStyle(color: AppColors.textSecondary),
),
const SizedBox(height: 16),
TextField(
controller: _noteController,
decoration: const InputDecoration(
labelText: 'Not (isteğe bağlı)',
hintText: 'Klinik için not ekleyin...',
),
maxLines: 3,
),
const SizedBox(height: 16),
FilledButton(
onPressed: _sending
? null
: () async {
setState(() => _sending = true);
final navigator = Navigator.of(context);
final messenger = ScaffoldMessenger.of(context);
try {
final updated = await LabJobsRepository.instance.handToClinic(
widget.job.id,
widget.job,
note: _noteController.text.trim().isEmpty
? null
: _noteController.text.trim(),
);
navigator.pop();
messenger.showSnackBar(
SnackBar(
content: Text(isLast
? 'İş teslim için gönderildi'
: 'Prova için klinik\'e gönderildi')),
);
if (context.mounted) widget.onDone(updated);
} catch (e) {
if (context.mounted) {
setState(() => _sending = false);
messenger.showSnackBar(
SnackBar(content: Text('Hata: $e')),
);
}
}
},
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48),
backgroundColor: buttonColor,
),
child: _sending
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white),
)
: Text(buttonLabel),
),
],
),
);
}
}
// ── Info Row ─────────────────────────────────────────────────────────────────
class _InfoRow extends StatelessWidget {
const _InfoRow({
required this.icon,
required this.label,
required this.value,
this.valueColor,
});
final IconData icon;
final String label;
final String value;
final Color? valueColor;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 18, color: AppColors.textMuted),
const SizedBox(width: 10),
SizedBox(
width: 110,
child: Text(
label,
style: const TextStyle(color: AppColors.textSecondary, fontSize: 13),
),
),
Expanded(
child: Text(
value,
style: TextStyle(
fontWeight: FontWeight.w500,
color: valueColor ?? AppColors.textPrimary,
fontSize: 14,
),
),
),
],
),
);
}
}
// ── Job Stepper ───────────────────────────────────────────────────────────────
class _JobStepper extends StatelessWidget {
const _JobStepper({
required this.steps,
required this.currentStep,
required this.historyFuture,
});
final List<JobStep> steps;
final JobStep? currentStep;
final Future<List<JobHistoryEntry>> historyFuture;
@override
Widget build(BuildContext context) {
return FutureBuilder<List<JobHistoryEntry>>(
future: historyFuture,
builder: (ctx, snap) {
final history = snap.data ?? [];
// Revizyon sayısı per adım
final Map<JobStep, int> revisionCounts = {};
for (final e in history) {
if (e.action == JobHistoryAction.revisionRequested && e.step != null) {
revisionCounts[e.step!] = (revisionCounts[e.step!] ?? 0) + 1;
}
}
final currentIndex =
currentStep != null ? steps.indexOf(currentStep!) : -1;
return Column(
children: List.generate(steps.length, (i) {
final step = steps[i];
final isCompleted = i < currentIndex;
final isCurrent = i == currentIndex;
final isLastItem = i == steps.length - 1;
final revCount = revisionCounts[step] ?? 0;
Color dotColor;
IconData dotIcon;
if (isCompleted) {
dotColor = AppColors.success;
dotIcon = Icons.check_circle;
} else if (isCurrent) {
dotColor = AppColors.inProgress;
dotIcon = Icons.radio_button_checked;
} else {
dotColor = AppColors.muted;
dotIcon = Icons.radio_button_unchecked;
}
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Column(
children: [
Icon(dotIcon, color: dotColor, size: 24),
if (!isLastItem)
Container(
width: 2,
height: 44,
color: i < currentIndex
? AppColors.success.withValues(alpha: 0.35)
: AppColors.border,
),
],
),
const SizedBox(width: 12),
Expanded(
child: Padding(
padding: const EdgeInsets.only(top: 2, bottom: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
step.label,
style: TextStyle(
fontWeight: isCurrent
? FontWeight.bold
: FontWeight.normal,
color: isCompleted
? AppColors.success
: isCurrent
? AppColors.inProgress
: AppColors.textMuted,
fontSize: 15,
),
),
if (revCount > 0) ...[
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: AppColors.cancelledBg,
borderRadius: BorderRadius.circular(6),
),
child: Text(
'$revCount revizyon',
style: const TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
color: AppColors.cancelled,
),
),
),
],
],
),
if (isCurrent)
Text(
step.description,
style: const TextStyle(
fontSize: 12,
color: AppColors.textSecondary,
),
),
],
),
),
),
],
);
}),
);
},
);
}
}
@@ -0,0 +1,335 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/providers/auth_provider.dart';
import '../../../core/services/realtime_service.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/gradient_app_bar.dart';
import '../../../models/job.dart';
import 'lab_jobs_repository.dart';
class LabJobsInboundScreen extends ConsumerStatefulWidget {
const LabJobsInboundScreen({super.key});
@override
ConsumerState<LabJobsInboundScreen> createState() =>
_LabJobsInboundScreenState();
}
class _LabJobsInboundScreenState extends ConsumerState<LabJobsInboundScreen> {
late Future<List<Job>> _future;
bool _acceptingAll = false;
late UnsubFn _unsub;
@override
void initState() {
super.initState();
_load();
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
_unsub = RealtimeService.instance.watch(
'jobs',
filter: "lab_tenant_id='$tenantId'",
onEvent: (_) { if (mounted) _load(); },
);
}
@override
void dispose() {
_unsub();
super.dispose();
}
void _load() {
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
setState(() {
_future =
LabJobsRepository.instance.listInbound(tenantId, status: 'pending');
});
}
Future<void> _acceptJob(Job job) async {
try {
await LabJobsRepository.instance.acceptJob(job);
_load();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('İş kabul edildi')),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Hata: $e')),
);
}
}
}
Future<void> _bulkAccept() async {
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
setState(() => _acceptingAll = true);
try {
await LabJobsRepository.instance.bulkAcceptPending(tenantId);
_load();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Tüm işler kabul edildi')),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Hata: $e')),
);
}
} finally {
if (mounted) setState(() => _acceptingAll = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.background,
appBar: GradientAppBar(
title: 'Gelen İşler',
category: 'LABORATUVAR',
),
floatingActionButton: FloatingActionButton.extended(
onPressed: _acceptingAll ? null : _bulkAccept,
icon: _acceptingAll
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white),
)
: const Icon(Icons.done_all),
label:
Text(_acceptingAll ? 'Kabul ediliyor...' : 'Tümünü Kabul Et'),
backgroundColor: AppColors.pending,
foregroundColor: Colors.white,
),
body: RefreshIndicator(
onRefresh: () async => _load(),
child: FutureBuilder<List<Job>>(
future: _future,
builder: (ctx, snap) {
if (snap.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snap.hasError) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('Hata: ${snap.error}'),
const SizedBox(height: 12),
FilledButton(
onPressed: _load,
child: const Text('Tekrar Dene'),
),
],
),
);
}
final jobs = snap.data!;
if (jobs.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.inbox_outlined, size: 64, color: AppColors.textMuted),
const SizedBox(height: 16),
Text(
'Bekleyen iş yok',
style: TextStyle(
fontSize: 16, color: AppColors.textSecondary),
),
],
),
);
}
return ListView.builder(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 80),
itemCount: jobs.length,
itemBuilder: (ctx, i) {
final job = jobs[i];
return _InboundJobCard(
job: job,
onAccept: () => _acceptJob(job),
);
},
);
},
),
),
);
}
}
class _InboundJobCard extends StatefulWidget {
const _InboundJobCard({required this.job, required this.onAccept});
final Job job;
final VoidCallback onAccept;
@override
State<_InboundJobCard> createState() => _InboundJobCardState();
}
class _InboundJobCardState extends State<_InboundJobCard> {
bool _accepting = false;
String _formatDate(DateTime dt) =>
'${dt.day.toString().padLeft(2, '0')}.${dt.month.toString().padLeft(2, '0')}.${dt.year}';
@override
Widget build(BuildContext context) {
final job = widget.job;
return Semantics(
label: job.patientCode,
button: true,
excludeSemantics: true,
child: Dismissible(
key: ValueKey(job.id),
direction: DismissDirection.endToStart,
background: Container(
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
margin: const EdgeInsets.only(bottom: 8),
decoration: BoxDecoration(
color: AppColors.success,
borderRadius: BorderRadius.circular(12),
),
child: const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.check, color: Colors.white, size: 28),
SizedBox(height: 4),
Text('Kabul Et',
style: TextStyle(color: Colors.white, fontSize: 12)),
],
),
),
confirmDismiss: (_) async {
setState(() => _accepting = true);
try {
await LabJobsRepository.instance.acceptJob(job);
return true;
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Hata: $e')),
);
}
return false;
} finally {
if (mounted) setState(() => _accepting = false);
}
},
child: Card(
margin: const EdgeInsets.only(bottom: 8),
child: Padding(
padding: const EdgeInsets.all(14),
child: Row(
children: [
CircleAvatar(
backgroundColor: AppColors.pendingBg,
child: const Icon(Icons.assignment_outlined,
color: AppColors.pending),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
job.patientCode,
style: const TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 2),
Text(
job.clinicName ?? 'Klinik',
style: TextStyle(
color: AppColors.textSecondary, fontSize: 13),
),
const SizedBox(height: 4),
Row(
children: [
_Chip(
label: job.prostheticType.label,
color: AppColors.inProgressBg,
textColor: AppColors.inProgress,
),
const SizedBox(width: 6),
_Chip(
label: '${job.memberCount} üye',
color: AppColors.background,
textColor: AppColors.textSecondary,
),
],
),
const SizedBox(height: 4),
Text(
_formatDate(job.dateCreated),
style: TextStyle(
color: AppColors.textMuted, fontSize: 12),
),
],
),
),
const SizedBox(width: 8),
_accepting
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
)
: FilledButton(
onPressed: widget.onAccept,
style: FilledButton.styleFrom(
backgroundColor: AppColors.success,
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 8),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: const Text('Kabul Et',
style: TextStyle(fontSize: 13)),
),
],
),
),
),
),
);
}
}
class _Chip extends StatelessWidget {
const _Chip(
{required this.label,
required this.color,
required this.textColor});
final String label;
final Color color;
final Color textColor;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(10),
),
child: Text(label,
style: TextStyle(
color: textColor,
fontSize: 11,
fontWeight: FontWeight.w500)),
);
}
}
@@ -0,0 +1,131 @@
import 'dart:async';
import 'package:pocketbase/pocketbase.dart';
import '../../../core/api/pocketbase_client.dart';
import '../../../core/services/job_history_service.dart';
import '../../../models/job.dart';
const _listExpand = 'clinic_tenant_id,lab_tenant_id';
const _detailExpand = 'clinic_tenant_id,lab_tenant_id,patient_id';
class LabJobsRepository {
LabJobsRepository._();
static final instance = LabJobsRepository._();
PocketBase get _pb => PocketBaseClient.instance.pb;
Future<List<Job>> listInbound(
String labTenantId, {
String? status,
int page = 1,
int limit = 30,
}) async {
final filterParts = ['lab_tenant_id = "$labTenantId"'];
if (status != null) filterParts.add('status = "$status"');
final result = await _pb.collection('jobs').getList(
page: page,
perPage: limit,
filter: filterParts.join(' && '),
expand: _listExpand,
);
return (result.items.map((r) => Job.fromJson(r.toJson())).toList()
..sort((a, b) => b.dateCreated.compareTo(a.dateCreated)));
}
Future<List<Job>> listInProgress(String labTenantId, {int limit = 50, String? location}) async {
final filterParts = ['lab_tenant_id = "$labTenantId"', 'status = "in_progress"'];
if (location != null) filterParts.add('location = "$location"');
final result = await _pb.collection('jobs').getList(
perPage: limit,
filter: filterParts.join(' && '),
expand: _listExpand,
);
return (result.items.map((r) => Job.fromJson(r.toJson())).toList()
..sort((a, b) {
if (a.dueDate == null && b.dueDate == null) return b.dateCreated.compareTo(a.dateCreated);
if (a.dueDate == null) return 1;
if (b.dueDate == null) return -1;
final cmp = a.dueDate!.compareTo(b.dueDate!);
return cmp != 0 ? cmp : b.dateCreated.compareTo(a.dateCreated);
}));
}
Future<Job> getJob(String jobId) async {
final record = await _pb.collection('jobs').getOne(jobId, expand: _detailExpand);
return Job.fromJson(record.toJson());
}
Future<Job> acceptJob(Job pendingJob) async {
final firstStep = pendingJob.stepTemplate.first;
final record = await _pb.collection('jobs').update(pendingJob.id, body: {
'status': 'in_progress',
'current_step': firstStep.value,
'location': 'at_lab',
});
final job = Job.fromJson(record.toJson());
unawaited(JobHistoryService.instance.append(
jobId: pendingJob.id,
clinicTenantId: job.clinicTenantId,
labTenantId: job.labTenantId,
action: JobHistoryAction.accepted,
step: firstStep,
));
return job;
}
Future<Job> handToClinic(String jobId, Job job, {String? note}) async {
final isFinal = job.currentStep == JobStep.cilaBitim;
final patch = isFinal
? {'status': 'sent', 'location': 'at_clinic'}
: {'location': 'at_clinic'};
final record = await _pb.collection('jobs').update(jobId, body: patch);
final updated = Job.fromJson(record.toJson());
unawaited(JobHistoryService.instance.append(
jobId: jobId,
clinicTenantId: job.clinicTenantId,
labTenantId: job.labTenantId,
action: JobHistoryAction.handedToClinic,
step: job.currentStep,
note: note,
));
return updated;
}
Future<Job> cancelJob(String jobId, Job job) async {
final record = await _pb.collection('jobs').update(jobId, body: {
'status': 'cancelled',
});
unawaited(JobHistoryService.instance.append(
jobId: jobId,
clinicTenantId: job.clinicTenantId,
labTenantId: job.labTenantId,
action: JobHistoryAction.cancelled,
step: job.currentStep,
));
return Job.fromJson(record.toJson());
}
Future<void> bulkAcceptPending(String labTenantId) async {
final pending = await listInbound(labTenantId, status: 'pending', limit: 200);
await Future.wait(pending.map((j) => acceptJob(j)));
}
Future<int> countByStatus(String labTenantId, String? status) async {
final filter = status != null
? 'lab_tenant_id = "$labTenantId" && status = "$status"'
: 'lab_tenant_id = "$labTenantId"';
final r = await _pb.collection('jobs').getList(perPage: 1, filter: filter);
return r.totalItems;
}
Future<int> countDelivered(String labTenantId, {DateTime? from, DateTime? to}) async {
final parts = ['lab_tenant_id = "$labTenantId"', 'status = "delivered"'];
if (from != null) parts.add('updated >= "${_date(from)}"');
if (to != null) parts.add('updated < "${_date(to)}"');
final r = await _pb.collection('jobs').getList(perPage: 1, filter: parts.join(' && '));
return r.totalItems;
}
static String _date(DateTime d) => d.toIso8601String().split('T').first;
}
@@ -0,0 +1,39 @@
import 'package:pocketbase/pocketbase.dart';
import '../../../core/api/pocketbase_client.dart';
import '../../../models/prosthetic_product.dart';
class LabProductsRepository {
LabProductsRepository._();
static final instance = LabProductsRepository._();
PocketBase get _pb => PocketBaseClient.instance.pb;
Future<List<ProstheticProduct>> listProducts(
String labTenantId, {
bool? isActive,
}) async {
final filterParts = ['lab_tenant_id = "$labTenantId"'];
if (isActive != null) filterParts.add('is_active = $isActive');
final result = await _pb.collection('prosthetic_products').getList(
filter: filterParts.join(' && '),
perPage: 200,
);
return (result.items.map((r) => ProstheticProduct.fromJson(r.toJson())).toList()
..sort((a, b) => a.name.compareTo(b.name)));
}
Future<ProstheticProduct> createProduct(ProstheticProduct product) async {
final record = await _pb.collection('prosthetic_products').create(body: product.toJson());
return ProstheticProduct.fromJson(record.toJson());
}
Future<ProstheticProduct> updateProduct(String id, Map<String, dynamic> patch) async {
final record = await _pb.collection('prosthetic_products').update(id, body: patch);
return ProstheticProduct.fromJson(record.toJson());
}
Future<void> deleteProduct(String id) async {
await _pb.collection('prosthetic_products').delete(id);
}
}
@@ -0,0 +1,620 @@
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 '../../../core/widgets/gradient_app_bar.dart';
import '../../../models/prosthetic_product.dart';
import 'lab_products_repository.dart';
const _prostheticTypes = [
('metal_porselen', 'Metal Porselen'),
('zirkonyum', 'Zirkonyum'),
('implant_ustu_zirkonyum', 'İmplant Üstü Zirkonyum'),
('gecici', 'Geçici'),
('e_max', 'E-Max'),
('diger', 'Diğer'),
];
String _typeLabel(String value) {
for (final t in _prostheticTypes) {
if (t.$1 == value) return t.$2;
}
return value;
}
// ── Adaptive sheet helper ────────────────────────────────────────────────────
void _showAdaptive(BuildContext context, Widget content) {
final isDesktop = MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint;
if (isDesktop) {
showDialog(
context: context,
builder: (_) => Dialog(
shape:
RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: content,
),
),
);
} else {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => content,
);
}
}
class LabProductsScreen extends ConsumerStatefulWidget {
const LabProductsScreen({super.key});
@override
ConsumerState<LabProductsScreen> createState() => _LabProductsScreenState();
}
class _LabProductsScreenState extends ConsumerState<LabProductsScreen> {
late Future<List<ProstheticProduct>> _future;
final _searchController = TextEditingController();
String _searchQuery = '';
@override
void initState() {
super.initState();
_load();
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
void _load() {
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
setState(() {
_future = LabProductsRepository.instance.listProducts(tenantId);
});
}
Future<void> _toggleActive(ProstheticProduct product) async {
try {
await LabProductsRepository.instance
.updateProduct(product.id, {'is_active': !product.isActive});
_load();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Hata: $e')),
);
}
}
}
Future<void> _deleteProduct(ProstheticProduct product) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Ürünü Sil'),
content: Text(
'"${product.name}" ürününü silmek istediğinize emin misiniz?'),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('İptal'),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
style: FilledButton.styleFrom(
backgroundColor: AppColors.cancelled),
child: const Text('Sil'),
),
],
),
);
if (confirmed != true) return;
try {
await LabProductsRepository.instance.deleteProduct(product.id);
_load();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Ürün silindi')),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Hata: $e')),
);
}
}
}
void _showProductSheet({ProstheticProduct? existing}) {
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
_showAdaptive(
context,
_ProductForm(
labTenantId: tenantId,
existing: existing,
onSaved: () {
_load();
},
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.background,
appBar: GradientAppBar(
title: 'Ürün Kataloğu',
category: 'LABORATUVAR',
searchController: _searchController,
onSearchChanged: (v) => setState(() => _searchQuery = v),
searchHint: 'Ürün adı veya türü ara...',
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => _showProductSheet(),
backgroundColor: AppColors.accent,
foregroundColor: Colors.white,
icon: const Icon(Icons.add),
label: const Text('Yeni Ürün'),
),
body: RefreshIndicator(
color: AppColors.accent,
onRefresh: () async => _load(),
child: FutureBuilder<List<ProstheticProduct>>(
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 allProducts = snap.data!;
final q = _searchQuery.toLowerCase().trim();
final products = q.isEmpty
? allProducts
: allProducts.where((p) =>
p.name.toLowerCase().contains(q) ||
_typeLabel(p.prostheticType).toLowerCase().contains(q)).toList();
if (allProducts.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.inventory_2_outlined,
size: 32, color: AppColors.inProgress),
),
const SizedBox(height: 16),
Text(
q.isNotEmpty ? 'Sonuç bulunamadı' : 'Henüz ürün eklenmedi',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary),
),
const SizedBox(height: 12),
if (q.isEmpty) FilledButton.icon(
onPressed: () => _showProductSheet(),
icon: const Icon(Icons.add),
label: const Text('İlk Ürünü Ekle'),
),
],
),
);
}
if (products.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.search_off_rounded,
size: 32, color: AppColors.inProgress),
),
const SizedBox(height: 16),
const Text(
'Sonuç bulunamadı',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary),
),
],
),
);
}
return ListView.builder(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 80),
itemCount: products.length,
itemBuilder: (ctx, i) {
final product = products[i];
final statusColor =
product.isActive ? AppColors.inProgress : AppColors.textMuted;
final statusBg =
product.isActive ? AppColors.inProgressBg : AppColors.surfaceVariant;
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: GestureDetector(
onLongPress: () => _deleteProduct(product),
child: Material(
color: AppColors.surface,
borderRadius: BorderRadius.circular(14),
child: InkWell(
onTap: () => _showProductSheet(existing: product),
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: 22),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
product.name,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: product.isActive
? AppColors.textPrimary
: AppColors.textMuted),
),
const SizedBox(height: 2),
Text(
_typeLabel(product.prostheticType),
style: const TextStyle(
fontSize: 12,
color: AppColors.textSecondary),
),
if (product.unitPrice != null) ...[
const SizedBox(height: 2),
Text(
'${product.unitPrice!.toStringAsFixed(2)} ${product.currency ?? 'TRY'}',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColors.success),
),
],
],
),
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Switch(
value: product.isActive,
onChanged: (_) => _toggleActive(product),
activeTrackColor: AppColors.accent,
),
IconButton(
icon: const Icon(Icons.edit_outlined,
color: AppColors.textSecondary,
size: 20),
onPressed: () =>
_showProductSheet(existing: product),
),
],
),
],
),
),
),
),
),
);
},
);
},
),
),
);
}
}
// ── Product Form ─────────────────────────────────────────────────────────────
class _ProductForm extends StatefulWidget {
const _ProductForm({
required this.labTenantId,
required this.onSaved,
this.existing,
});
final String labTenantId;
final ProstheticProduct? existing;
final VoidCallback onSaved;
@override
State<_ProductForm> createState() => _ProductFormState();
}
class _ProductFormState extends State<_ProductForm> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _nameCtrl;
late final TextEditingController _priceCtrl;
late final TextEditingController _descCtrl;
late String _selectedType;
late String _currency;
late bool _isActive;
bool _saving = false;
@override
void initState() {
super.initState();
final p = widget.existing;
_nameCtrl = TextEditingController(text: p?.name ?? '');
_priceCtrl = TextEditingController(
text: p?.unitPrice != null ? p!.unitPrice!.toString() : '');
_descCtrl = TextEditingController(text: p?.description ?? '');
_selectedType = p?.prostheticType ?? _prostheticTypes.first.$1;
_currency = p?.currency ?? 'TRY';
_isActive = p?.isActive ?? true;
}
@override
void dispose() {
_nameCtrl.dispose();
_priceCtrl.dispose();
_descCtrl.dispose();
super.dispose();
}
Future<void> _save() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _saving = true);
final price = double.tryParse(_priceCtrl.text.trim());
final product = ProstheticProduct(
id: widget.existing?.id ?? '',
labTenantId: widget.labTenantId,
name: _nameCtrl.text.trim(),
prostheticType: _selectedType,
unitPrice: price,
currency: _currency,
isActive: _isActive,
description:
_descCtrl.text.trim().isEmpty ? null : _descCtrl.text.trim(),
);
try {
if (widget.existing != null) {
await LabProductsRepository.instance.updateProduct(
widget.existing!.id,
product.toJson(),
);
} else {
await LabProductsRepository.instance.createProduct(product);
}
widget.onSaved();
// Pop using form's own context to ensure correct Navigator inside dialog/sheet
if (mounted) Navigator.of(context).pop();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Hata: $e')),
);
}
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
final isDesktop = MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint;
final isEdit = widget.existing != null;
return Container(
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.vertical(
top: isDesktop ? Radius.zero : const Radius.circular(20),
),
),
padding: EdgeInsets.only(
left: 20,
right: 20,
top: 24,
bottom: isDesktop
? 24
: MediaQuery.of(context).viewInsets.bottom + 24,
),
child: Form(
key: _formKey,
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Expanded(
child: Text(
isEdit ? 'Ürünü Düzenle' : 'Yeni Ürün',
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(
fontWeight: FontWeight.bold,
color: AppColors.textPrimary),
),
),
IconButton(
icon: const Icon(Icons.close,
color: AppColors.textSecondary),
onPressed: () => Navigator.of(context).pop(),
),
],
),
const SizedBox(height: 16),
// Name
TextFormField(
controller: _nameCtrl,
decoration:
const InputDecoration(labelText: 'Ürün Adı *'),
validator: (v) =>
v == null || v.trim().isEmpty ? 'Ürün adı gerekli' : null,
),
const SizedBox(height: 12),
// Prosthetic type dropdown
DropdownButtonFormField<String>(
initialValue: _selectedType,
decoration:
const InputDecoration(labelText: 'Protez Tipi *'),
items: _prostheticTypes
.map((t) => DropdownMenuItem(
value: t.$1,
child: Text(t.$2),
))
.toList(),
onChanged: (v) => setState(() => _selectedType = v!),
),
const SizedBox(height: 12),
// Price + currency row
Row(
children: [
Expanded(
flex: 3,
child: TextFormField(
controller: _priceCtrl,
decoration:
const InputDecoration(labelText: 'Birim Fiyat'),
keyboardType: const TextInputType.numberWithOptions(
decimal: true),
validator: (v) {
if (v != null && v.isNotEmpty) {
if (double.tryParse(v) == null) {
return 'Geçerli fiyat girin';
}
}
return null;
},
),
),
const SizedBox(width: 8),
Expanded(
flex: 2,
child: DropdownButtonFormField<String>(
initialValue: _currency,
decoration:
const InputDecoration(labelText: 'Para Birimi'),
items: ['TRY', 'USD', 'EUR']
.map((c) => DropdownMenuItem(
value: c,
child: Text(c),
))
.toList(),
onChanged: (v) => setState(() => _currency = v!),
),
),
],
),
const SizedBox(height: 12),
// Description
TextFormField(
controller: _descCtrl,
decoration: const InputDecoration(
labelText: 'Açıklama (isteğe bağlı)'),
maxLines: 2,
),
const SizedBox(height: 12),
// Active toggle
SwitchListTile(
title: const Text('Aktif',
style: TextStyle(color: AppColors.textPrimary)),
value: _isActive,
onChanged: (v) => setState(() => _isActive = v),
contentPadding: EdgeInsets.zero,
activeTrackColor: AppColors.accent,
),
const SizedBox(height: 8),
FilledButton(
onPressed: _saving ? null : _save,
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48)),
child: _saving
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white),
)
: Text(isEdit ? 'Kaydet' : 'Ekle'),
),
],
),
),
),
);
}
}
@@ -0,0 +1,790 @@
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/lab_connections_screen.dart';
class LabSettingsScreen extends ConsumerWidget {
const LabSettingsScreen({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),
// Lab info
_SectionHeader(
title: s.labInfo,
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.science_outlined,
label: s.labName,
value: tenant?.companyName ?? '-',
),
_InfoTile(
icon: Icons.payments_outlined,
label: s.currency,
value: tenant?.defaultCurrency ?? 'TRY',
),
_InfoTileBadge(
icon: Icons.circle_outlined,
label: s.status,
value: tenant?.status == 'active' ? s.active : (tenant?.status ?? '-'),
badgeColor: AppColors.success,
badgeBg: AppColors.successBg,
),
_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.clinicConnections,
subtitle: s.clinicConnectionsSub,
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const LabConnectionsScreen()),
),
),
]),
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.discount_outlined,
iconColor: AppColors.success,
iconBg: AppColors.successBg,
title: s.discounts,
subtitle: s.discountsSub,
onTap: () => context.push(routeLabDiscounts),
),
_NavTile(
icon: Icons.bar_chart_rounded,
iconColor: AppColors.accent,
iconBg: AppColors.inProgressBg,
title: s.reports,
subtitle: s.reportsSub,
onTap: () => context.push(routeLabReports),
),
_NavTile(
icon: Icons.auto_awesome_outlined,
iconColor: const Color(0xFF7C3AED),
iconBg: const Color(0xFFF3E8FF),
title: s.aiAssistant,
subtitle: s.aiAssistantSub,
onTap: () => context.push(routeLabAi),
),
]),
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, currency) async {
await ref.read(authProvider.notifier).updateTenantInfo(
tenantId: tenant.id,
companyName: name,
defaultCurrency: currency,
);
},
),
);
}
void _showLanguagePicker(BuildContext context, WidgetRef ref, AppStrings s) {
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
builder: (_) => _LanguagePickerSheet(s: s, ref: ref),
);
}
static String _tenantKindLabel(TenantKind? kind, AppStrings s) =>
switch (kind) {
TenantKind.clinic => s.tenantKindClinic,
TenantKind.lab => s.tenantKindLab,
null => '-',
};
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 _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, String currency) onSave;
@override
State<_EditTenantSheet> createState() => _EditTenantSheetState();
}
class _EditTenantSheetState extends State<_EditTenantSheet> {
late final TextEditingController _nameController;
late String _selectedCurrency;
bool _saving = false;
static const _currencies = [
('TRY', '', 'Türk Lirası'),
('USD', '\$', 'US Dollar'),
('EUR', '', 'Euro'),
('GBP', '£', 'British Pound'),
('AED', 'د.إ', 'UAE Dirham'),
];
@override
void initState() {
super.initState();
_nameController = TextEditingController(text: widget.tenant.companyName);
_selectedCurrency = widget.tenant.defaultCurrency;
}
@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, _selectedCurrency);
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.editLabInfo,
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary)),
const SizedBox(height: 16),
TextFormField(
controller: _nameController,
decoration: InputDecoration(
labelText: s.labName,
hintText: s.labNameHint,
),
textCapitalization: TextCapitalization.words,
),
const SizedBox(height: 14),
Text(s.currency,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: AppColors.textSecondary)),
const SizedBox(height: 8),
DropdownButtonFormField<String>(
value: _selectedCurrency,
decoration: InputDecoration(
contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppColors.border)),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppColors.border)),
),
items: [
for (final (code, symbol, name) in _currencies)
DropdownMenuItem(
value: code,
child: Text('$symbol $name ($code)',
style: const TextStyle(fontSize: 14)),
),
],
onChanged: (v) {
if (v != null) setState(() => _selectedCurrency = v);
},
),
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 _InfoTileBadge extends StatelessWidget {
const _InfoTileBadge({
required this.icon,
required this.label,
required this.value,
required this.badgeColor,
required this.badgeBg,
});
final IconData icon;
final String label;
final String value;
final Color badgeColor;
final Color badgeBg;
@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: Text(label,
style: const TextStyle(
fontSize: 11, color: AppColors.textMuted)),
),
Container(
padding:
const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: badgeBg,
borderRadius: BorderRadius.circular(8),
),
child: Text(value,
style: TextStyle(
color: badgeColor,
fontSize: 12,
fontWeight: FontWeight.w600)),
),
],
),
);
}
}
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();
}
},
),
);
}
}