Add pricing entry flow and platform admin foundations
This commit is contained in:
@@ -20,39 +20,70 @@ class ClinicJobDetailScreen extends ConsumerStatefulWidget {
|
||||
_ClinicJobDetailScreenState();
|
||||
}
|
||||
|
||||
class _ClinicJobDetailScreenState
|
||||
extends ConsumerState<ClinicJobDetailScreen> {
|
||||
class _ClinicJobDetailScreenState extends ConsumerState<ClinicJobDetailScreen> {
|
||||
Job? _job;
|
||||
String? _loadError;
|
||||
late Future<List<JobFile>> _filesFuture;
|
||||
late Future<List<JobHistoryEntry>> _historyFuture;
|
||||
bool _isActing = false;
|
||||
late UnsubFn _unsub;
|
||||
final List<UnsubFn> _unsubs = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
_loadFiles();
|
||||
_unsub = RealtimeService.instance.watch(
|
||||
_loadHistory();
|
||||
_unsubs.add(RealtimeService.instance.watch(
|
||||
'jobs',
|
||||
topic: widget.jobId,
|
||||
onEvent: (_) { if (mounted && !_isActing) _load(); },
|
||||
);
|
||||
onEvent: (_) {
|
||||
if (mounted && !_isActing) _load();
|
||||
},
|
||||
));
|
||||
_unsubs.add(RealtimeService.instance.watch(
|
||||
'job_files',
|
||||
filter: 'job_id="${widget.jobId}"',
|
||||
onEvent: (_) {
|
||||
if (mounted) _loadFiles();
|
||||
},
|
||||
));
|
||||
_unsubs.add(RealtimeService.instance.watch(
|
||||
'job_status_history',
|
||||
filter: 'job_id="${widget.jobId}"',
|
||||
onEvent: (_) {
|
||||
if (mounted) _loadHistory();
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_unsub();
|
||||
for (final unsub in _unsubs) {
|
||||
unsub();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
if (mounted) setState(() { _loadError = null; });
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_loadError = null;
|
||||
});
|
||||
}
|
||||
try {
|
||||
final job = await ClinicJobsRepository.instance.getJob(widget.jobId);
|
||||
if (mounted) setState(() { _job = job; });
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_job = job;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) setState(() { _loadError = e.toString(); });
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_loadError = e.toString();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,12 +93,23 @@ class _ClinicJobDetailScreenState
|
||||
});
|
||||
}
|
||||
|
||||
void _loadHistory() {
|
||||
setState(() {
|
||||
_historyFuture = JobHistoryService.instance.listForJob(widget.jobId);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _approve(Job job) async {
|
||||
setState(() => _isActing = true);
|
||||
try {
|
||||
final updated = await ClinicJobsRepository.instance.approveAtClinic(job.id, job);
|
||||
final updated =
|
||||
await ClinicJobsRepository.instance.approveAtClinic(job.id, job);
|
||||
if (mounted) {
|
||||
setState(() { _job = updated.copyWith(clinicName: job.clinicName, labName: job.labName); _isActing = false; });
|
||||
setState(() {
|
||||
_job = updated.copyWith(
|
||||
clinicName: job.clinicName, labName: job.labName);
|
||||
_isActing = false;
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('İş onaylandı.')),
|
||||
);
|
||||
@@ -87,9 +129,12 @@ class _ClinicJobDetailScreenState
|
||||
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?'),
|
||||
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ç')),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('Vazgeç')),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(backgroundColor: AppColors.cancelled),
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
@@ -101,15 +146,21 @@ class _ClinicJobDetailScreenState
|
||||
if (confirmed != true || !mounted) return;
|
||||
setState(() => _isActing = true);
|
||||
try {
|
||||
final updated = await ClinicJobsRepository.instance.cancelJob(job.id, job);
|
||||
final updated =
|
||||
await ClinicJobsRepository.instance.cancelJob(job.id, job);
|
||||
if (mounted) {
|
||||
setState(() { _job = _job!.copyWith(status: updated.status); _isActing = false; });
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('İş iptal edildi.')));
|
||||
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')));
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text('Hata: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -157,7 +208,11 @@ class _ClinicJobDetailScreenState
|
||||
note: noteController.text.trim(),
|
||||
);
|
||||
if (mounted) {
|
||||
setState(() { _job = updated.copyWith(clinicName: job.clinicName, labName: job.labName); _isActing = false; });
|
||||
setState(() {
|
||||
_job = updated.copyWith(
|
||||
clinicName: job.clinicName, labName: job.labName);
|
||||
_isActing = false;
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Revizyon talebi gönderildi.')),
|
||||
);
|
||||
@@ -212,10 +267,16 @@ class _ClinicJobDetailScreenState
|
||||
|
||||
setState(() => _isActing = true);
|
||||
try {
|
||||
final note = noteCtrl.text.trim().isNotEmpty ? noteCtrl.text.trim() : null;
|
||||
final updated = await ClinicJobsRepository.instance.markDelivered(job.id, job, note: note);
|
||||
final note =
|
||||
noteCtrl.text.trim().isNotEmpty ? noteCtrl.text.trim() : null;
|
||||
final updated = await ClinicJobsRepository.instance
|
||||
.markDelivered(job.id, job, note: note);
|
||||
if (mounted) {
|
||||
setState(() { _job = updated.copyWith(clinicName: job.clinicName, labName: job.labName); _isActing = false; });
|
||||
setState(() {
|
||||
_job = updated.copyWith(
|
||||
clinicName: job.clinicName, labName: job.labName);
|
||||
_isActing = false;
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('İş teslim alındı olarak işaretlendi.')),
|
||||
);
|
||||
@@ -241,7 +302,8 @@ class _ClinicJobDetailScreenState
|
||||
|
||||
Widget _buildBody() {
|
||||
if (_job == null && _loadError == null) {
|
||||
return const Center(child: CircularProgressIndicator(color: AppColors.accent));
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: AppColors.accent));
|
||||
}
|
||||
if (_loadError != null && _job == null) {
|
||||
return Center(
|
||||
@@ -270,22 +332,28 @@ class _ClinicJobDetailScreenState
|
||||
),
|
||||
);
|
||||
}
|
||||
if (_job == null) return const Center(child: CircularProgressIndicator(color: AppColors.accent));
|
||||
if (_job == null) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: AppColors.accent));
|
||||
}
|
||||
final job = _job!;
|
||||
final membership = ref.read(authProvider).activeTenant;
|
||||
final canDeliver = membership?.canDeliverJobs ?? true;
|
||||
final canCancel = membership?.canCancelJobs ?? true;
|
||||
final canManage = !(membership?.isDeliveryOnly ?? false);
|
||||
final canCancel = membership?.canCancelJobs ?? true;
|
||||
final canManage = !(membership?.isDeliveryOnly ?? false);
|
||||
return _JobDetailBody(
|
||||
job: job,
|
||||
filesFuture: _filesFuture,
|
||||
historyFuture: _historyFuture,
|
||||
isActing: _isActing,
|
||||
canDeliver: canDeliver,
|
||||
canManage: canManage,
|
||||
onApprove: canManage ? () => _approve(job) : () {},
|
||||
onRevision: canManage ? () => _requestRevision(job) : () {},
|
||||
onDelivered: () => _markDelivered(job),
|
||||
onCancel: (canCancel && job.status == JobStatus.pending) ? () => _cancelJob(job) : null,
|
||||
onCancel: (canCancel && job.status == JobStatus.pending)
|
||||
? () => _cancelJob(job)
|
||||
: null,
|
||||
onFilesRefresh: _loadFiles,
|
||||
);
|
||||
}
|
||||
@@ -295,6 +363,7 @@ class _JobDetailBody extends StatelessWidget {
|
||||
const _JobDetailBody({
|
||||
required this.job,
|
||||
required this.filesFuture,
|
||||
required this.historyFuture,
|
||||
required this.isActing,
|
||||
required this.canDeliver,
|
||||
required this.canManage,
|
||||
@@ -307,6 +376,7 @@ class _JobDetailBody extends StatelessWidget {
|
||||
|
||||
final Job job;
|
||||
final Future<List<JobFile>> filesFuture;
|
||||
final Future<List<JobHistoryEntry>> historyFuture;
|
||||
final bool isActing;
|
||||
final bool canDeliver;
|
||||
final bool canManage;
|
||||
@@ -355,7 +425,9 @@ class _JobDetailBody extends StatelessWidget {
|
||||
job.patientName?.isNotEmpty == true
|
||||
? job.patientName!
|
||||
: job.patientCode,
|
||||
style: Theme.of(context).textTheme.headlineSmall
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.headlineSmall
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.textPrimary),
|
||||
@@ -369,7 +441,7 @@ class _JobDetailBody extends StatelessWidget {
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Patient + Lab
|
||||
_SectionLabel(title: 'Hasta & Laboratuvar'),
|
||||
const _SectionLabel(title: 'Hasta & Laboratuvar'),
|
||||
if (job.patientName != null && job.patientName!.isNotEmpty)
|
||||
_InfoRow(label: 'Hasta', value: job.patientName!),
|
||||
_InfoRow(label: 'Protokol No', value: job.patientCode),
|
||||
@@ -380,12 +452,13 @@ class _JobDetailBody extends StatelessWidget {
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Prosthetic
|
||||
_SectionLabel(title: 'Protez Bilgisi'),
|
||||
const _SectionLabel(title: 'Protez Bilgisi'),
|
||||
_InfoRow(label: 'Tür', value: job.prostheticType.label),
|
||||
if (job.prostheticName != null && job.prostheticName!.isNotEmpty)
|
||||
_InfoRow(label: 'Ürün', value: job.prostheticName!),
|
||||
if (job.workflowType != null)
|
||||
_InfoRow(label: 'İş Tipi', value: job.workflowType!.label),
|
||||
_InfoRow(label: 'Akış', value: job.workflowPreset.title),
|
||||
_InfoRow(
|
||||
label: 'Prova',
|
||||
value: job.provaRequired ? 'Provalı' : 'Provasız',
|
||||
@@ -398,7 +471,9 @@ class _JobDetailBody extends StatelessWidget {
|
||||
if (job.description != null && job.description!.isNotEmpty)
|
||||
_InfoRow(label: 'Açıklama', value: job.description!),
|
||||
if (job.dueDate != null)
|
||||
_InfoRow(label: 'Son Tarih', value: _formatDate(job.dueDate!, withTime: true)),
|
||||
_InfoRow(
|
||||
label: 'Son Tarih',
|
||||
value: _formatDate(job.dueDate!, withTime: true)),
|
||||
if (job.price != null)
|
||||
_InfoRow(
|
||||
label: 'Fiyat',
|
||||
@@ -438,7 +513,8 @@ class _JobDetailBody extends StatelessWidget {
|
||||
_StepperWidget(
|
||||
steps: steps,
|
||||
currentStepIndex: currentStepIndex,
|
||||
historyFuture: JobHistoryService.instance.listForJob(job.id),
|
||||
isDelivered: job.status == JobStatus.delivered,
|
||||
historyFuture: historyFuture,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -516,7 +592,8 @@ class _JobDetailBody extends StatelessWidget {
|
||||
}
|
||||
|
||||
String _formatDate(DateTime d, {bool withTime = false}) {
|
||||
final s = '${d.day.toString().padLeft(2, '0')}.${d.month.toString().padLeft(2, '0')}.${d.year}';
|
||||
final s =
|
||||
'${d.day.toString().padLeft(2, '0')}.${d.month.toString().padLeft(2, '0')}.${d.year}';
|
||||
if (!withTime || (d.hour == 0 && d.minute == 0)) return s;
|
||||
return '$s ${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
@@ -526,11 +603,13 @@ class _StepperWidget extends StatelessWidget {
|
||||
const _StepperWidget({
|
||||
required this.steps,
|
||||
required this.currentStepIndex,
|
||||
required this.isDelivered,
|
||||
required this.historyFuture,
|
||||
});
|
||||
|
||||
final List<JobStep> steps;
|
||||
final int currentStepIndex;
|
||||
final bool isDelivered;
|
||||
final Future<List<JobHistoryEntry>> historyFuture;
|
||||
|
||||
@override
|
||||
@@ -542,7 +621,8 @@ class _StepperWidget extends StatelessWidget {
|
||||
final Map<JobStep, int> revisionCounts = {};
|
||||
final Map<JobStep, List<JobHistoryEntry>> notesByStep = {};
|
||||
for (final e in history) {
|
||||
if (e.action == JobHistoryAction.revisionRequested && e.step != null) {
|
||||
if (e.action == JobHistoryAction.revisionRequested &&
|
||||
e.step != null) {
|
||||
revisionCounts[e.step!] = (revisionCounts[e.step!] ?? 0) + 1;
|
||||
}
|
||||
if (e.step != null && e.note != null && e.note!.trim().isNotEmpty) {
|
||||
@@ -554,8 +634,8 @@ class _StepperWidget extends StatelessWidget {
|
||||
children: steps.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final step = entry.value;
|
||||
final isCompleted = index < currentStepIndex;
|
||||
final isCurrent = index == currentStepIndex;
|
||||
final isCompleted = isDelivered || index < currentStepIndex;
|
||||
final isCurrent = !isDelivered && index == currentStepIndex;
|
||||
final revCount = revisionCounts[step] ?? 0;
|
||||
final stepNotes = notesByStep[step] ?? const <JobHistoryEntry>[];
|
||||
|
||||
@@ -582,7 +662,7 @@ class _StepperWidget extends StatelessWidget {
|
||||
Container(
|
||||
width: 2,
|
||||
height: 44,
|
||||
color: index < currentStepIndex
|
||||
color: isDelivered || index < currentStepIndex
|
||||
? AppColors.success.withValues(alpha: 0.35)
|
||||
: AppColors.border,
|
||||
),
|
||||
@@ -642,7 +722,8 @@ class _StepperWidget extends StatelessWidget {
|
||||
),
|
||||
if (stepNotes.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
...stepNotes.map((entry) => _StepNoteCard(entry: entry)),
|
||||
...stepNotes
|
||||
.map((entry) => _StepNoteCard(entry: entry)),
|
||||
],
|
||||
],
|
||||
),
|
||||
@@ -700,6 +781,7 @@ class _StepNoteCard extends StatelessWidget {
|
||||
String _label(JobHistoryAction action) {
|
||||
return switch (action) {
|
||||
JobHistoryAction.revisionRequested => 'Revizyon Notu',
|
||||
JobHistoryAction.stepCompleted => 'İç Adım Notu',
|
||||
JobHistoryAction.handedToClinic => 'Laboratuvar Notu',
|
||||
JobHistoryAction.approved => 'Onay Notu',
|
||||
JobHistoryAction.delivered => 'Teslim Notu',
|
||||
@@ -745,8 +827,8 @@ class _InfoRow extends StatelessWidget {
|
||||
width: 110,
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 13, color: AppColors.textSecondary),
|
||||
style:
|
||||
const TextStyle(fontSize: 13, color: AppColors.textSecondary),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
|
||||
@@ -35,17 +35,18 @@ class ClinicJobsRepository {
|
||||
}
|
||||
|
||||
final result = await _pb.collection('jobs').getList(
|
||||
page: page,
|
||||
perPage: limit,
|
||||
filter: filterParts.join(' && '),
|
||||
expand: _listExpand,
|
||||
);
|
||||
page: page,
|
||||
perPage: limit,
|
||||
filter: filterParts.join(' && '),
|
||||
expand: _listExpand,
|
||||
);
|
||||
return (result.items.map((r) => Job.fromJson(r.toJson())).toList()
|
||||
..sort((a, b) => b.dateCreated.compareTo(a.dateCreated)));
|
||||
}
|
||||
|
||||
Future<Job> getJob(String jobId) async {
|
||||
final record = await _pb.collection('jobs').getOne(jobId, expand: _detailExpand);
|
||||
final record =
|
||||
await _pb.collection('jobs').getOne(jobId, expand: _detailExpand);
|
||||
return Job.fromJson(record.toJson());
|
||||
}
|
||||
|
||||
@@ -66,13 +67,15 @@ class ClinicJobsRepository {
|
||||
String? currency,
|
||||
JobWorkflowType? workflowType,
|
||||
bool provaRequired = true,
|
||||
List<String> workflowSteps = const [],
|
||||
}) async {
|
||||
final record = await _pb.collection('jobs').create(body: {
|
||||
'clinic_tenant_id': clinicTenantId,
|
||||
'lab_tenant_id': labTenantId,
|
||||
'patient_code': patientCode,
|
||||
if (patientId != null) 'patient_id': patientId,
|
||||
if (prostheticId != null && prostheticId.isNotEmpty) 'prosthetic_id': prostheticId,
|
||||
if (prostheticId != null && prostheticId.isNotEmpty)
|
||||
'prosthetic_id': prostheticId,
|
||||
'prosthetic_type': prostheticType.value,
|
||||
'member_count': teeth.length,
|
||||
'teeth': teeth,
|
||||
@@ -82,6 +85,7 @@ class ClinicJobsRepository {
|
||||
if (price != null) 'price': price,
|
||||
if (currency != null && currency.isNotEmpty) 'currency': currency,
|
||||
if (workflowType != null) 'workflow_type': workflowType.value,
|
||||
if (workflowSteps.isNotEmpty) 'workflow_steps': workflowSteps,
|
||||
'status': 'pending',
|
||||
'location': 'at_clinic',
|
||||
'prova_required': provaRequired,
|
||||
@@ -126,7 +130,8 @@ class ClinicJobsRepository {
|
||||
return updated;
|
||||
}
|
||||
|
||||
Future<Job> requestRevision(String jobId, Job job, {required String note}) async {
|
||||
Future<Job> requestRevision(String jobId, Job job,
|
||||
{required String note}) async {
|
||||
final record = await _pb.collection('jobs').update(jobId, body: {
|
||||
'location': 'at_lab',
|
||||
});
|
||||
@@ -170,33 +175,42 @@ class ClinicJobsRepository {
|
||||
return Job.fromJson(record.toJson());
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> listApprovedLabs(String clinicTenantId) async {
|
||||
Future<List<Map<String, dynamic>>> listApprovedLabs(
|
||||
String clinicTenantId) async {
|
||||
final result = await _pb.collection('connections').getList(
|
||||
filter: 'clinic_tenant_id = "$clinicTenantId" && status = "approved"',
|
||||
expand: 'lab_tenant_id',
|
||||
perPage: 100,
|
||||
);
|
||||
filter: 'clinic_tenant_id = "$clinicTenantId" && status = "approved"',
|
||||
expand: 'lab_tenant_id',
|
||||
perPage: 100,
|
||||
);
|
||||
return result.items.map((r) {
|
||||
final expand = r.toJson()['expand'] as Map<String, dynamic>?;
|
||||
return expand?['lab_tenant_id'] as Map<String, dynamic>? ?? {'id': r.data['lab_tenant_id']};
|
||||
return expand?['lab_tenant_id'] as Map<String, dynamic>? ??
|
||||
{'id': r.data['lab_tenant_id']};
|
||||
}).toList();
|
||||
}
|
||||
|
||||
Future<List<Job>> listJobsByPatient(String patientId, {int limit = 50}) async {
|
||||
Future<List<Job>> listJobsByPatient(String patientId,
|
||||
{int limit = 50}) async {
|
||||
final result = await _pb.collection('jobs').getList(
|
||||
filter: 'patient_id = "$patientId"',
|
||||
perPage: limit,
|
||||
expand: _listExpand,
|
||||
);
|
||||
filter: 'patient_id = "$patientId"',
|
||||
perPage: limit,
|
||||
expand: _listExpand,
|
||||
);
|
||||
return (result.items.map((r) => Job.fromJson(r.toJson())).toList()
|
||||
..sort((a, b) => b.dateCreated.compareTo(a.dateCreated)));
|
||||
}
|
||||
|
||||
Future<int> countDelivered(String clinicTenantId, {DateTime? from, DateTime? to}) async {
|
||||
final parts = ['clinic_tenant_id = "$clinicTenantId"', 'status = "delivered"'];
|
||||
Future<int> countDelivered(String clinicTenantId,
|
||||
{DateTime? from, DateTime? to}) async {
|
||||
final parts = [
|
||||
'clinic_tenant_id = "$clinicTenantId"',
|
||||
'status = "delivered"'
|
||||
];
|
||||
if (from != null) parts.add('updated >= "${_date(from)}"');
|
||||
if (to != null) parts.add('updated < "${_date(to)}"');
|
||||
final r = await _pb.collection('jobs').getList(perPage: 1, filter: parts.join(' && '));
|
||||
final r = await _pb
|
||||
.collection('jobs')
|
||||
.getList(perPage: 1, filter: parts.join(' && '));
|
||||
return r.totalItems;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import '../../../core/theme/app_theme.dart';
|
||||
import '../../../models/job.dart';
|
||||
import '../../../models/patient.dart';
|
||||
import '../../../models/prosthetic_product.dart';
|
||||
import '../../../models/tenant.dart';
|
||||
import '../../lab/discounts/discount_repository.dart';
|
||||
import '../../lab/products/lab_products_repository.dart';
|
||||
import 'clinic_jobs_repository.dart';
|
||||
@@ -111,8 +112,7 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
||||
_labsError = null;
|
||||
});
|
||||
try {
|
||||
final tenantId =
|
||||
ref.read(authProvider).activeTenant!.tenant.id;
|
||||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||||
final labs =
|
||||
await ClinicJobsRepository.instance.listApprovedLabs(tenantId);
|
||||
setState(() {
|
||||
@@ -149,9 +149,8 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
||||
labId,
|
||||
isActive: true,
|
||||
);
|
||||
final matchingProducts = products
|
||||
.where((p) => p.prostheticType == ptValue)
|
||||
.toList();
|
||||
final matchingProducts =
|
||||
products.where((p) => p.prostheticType == ptValue).toList();
|
||||
|
||||
ProstheticProduct? product;
|
||||
if (_selectedProduct != null) {
|
||||
@@ -230,8 +229,7 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
||||
_availableProducts.isEmpty;
|
||||
|
||||
bool get _hasSelectedProductWithoutPrice =>
|
||||
_selectedProduct != null &&
|
||||
_selectedProduct!.unitPrice == null;
|
||||
_selectedProduct != null && _selectedProduct!.unitPrice == null;
|
||||
|
||||
bool get _canSubmitJob =>
|
||||
!_isSubmitting &&
|
||||
@@ -251,8 +249,7 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
||||
}
|
||||
setState(() => _patientSearchLoading = true);
|
||||
try {
|
||||
final tenantId =
|
||||
ref.read(authProvider).activeTenant!.tenant.id;
|
||||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||||
final results = await ClinicPatientsRepository.instance
|
||||
.listPatients(tenantId, search: normalizedQuery, limit: 10);
|
||||
if (!mounted || _patientSearchController.text.trim() != normalizedQuery) {
|
||||
@@ -315,8 +312,11 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_dueDate = DateTime(
|
||||
pickedDate.year, pickedDate.month, pickedDate.day,
|
||||
pickedTime?.hour ?? 17, pickedTime?.minute ?? 0,
|
||||
pickedDate.year,
|
||||
pickedDate.month,
|
||||
pickedDate.day,
|
||||
pickedTime?.hour ?? 17,
|
||||
pickedTime?.minute ?? 0,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -326,7 +326,8 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
||||
final date =
|
||||
'${now.year}${now.month.toString().padLeft(2, '0')}${now.day.toString().padLeft(2, '0')}';
|
||||
const chars = '0123456789ABCDEFGHJKLMNPQRSTUVWXYZ';
|
||||
final rand = List.generate(4, (_) => chars[Random().nextInt(chars.length)]).join();
|
||||
final rand =
|
||||
List.generate(4, (_) => chars[Random().nextInt(chars.length)]).join();
|
||||
return 'PR-$date-$rand';
|
||||
}
|
||||
|
||||
@@ -397,6 +398,13 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
||||
lastName: rawLastName.isNotEmpty ? rawLastName : null,
|
||||
);
|
||||
}
|
||||
final selectedLabTenant = Tenant.fromJson(_selectedLab!);
|
||||
final workflowSteps = buildJobWorkflowPreset(
|
||||
prostheticType: _selectedProstheticType!,
|
||||
workflowType: _selectedWorkflowType,
|
||||
provaRequired: _provaRequired,
|
||||
optionalSteps: selectedLabTenant.workflowOverrideSteps,
|
||||
).steps;
|
||||
final job = await ClinicJobsRepository.instance.createJob(
|
||||
clinicTenantId: tenantId,
|
||||
labTenantId: _selectedLab!['id'] as String,
|
||||
@@ -418,24 +426,29 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
||||
currency: _labProduct?.currency,
|
||||
workflowType: _selectedWorkflowType,
|
||||
provaRequired: _provaRequired,
|
||||
workflowSteps: workflowSteps.map((step) => step.value).toList(),
|
||||
);
|
||||
|
||||
// Upload pending files
|
||||
if (_pendingFiles.isNotEmpty) {
|
||||
final pb = PocketBaseClient.instance.pb;
|
||||
final token = pb.authStore.token;
|
||||
final uploaderId = (pb.authStore.record?.id) ?? (auth.profile?.id ?? '');
|
||||
final uploaderId =
|
||||
(pb.authStore.record?.id) ?? (auth.profile?.id ?? '');
|
||||
for (final file in _pendingFiles) {
|
||||
final bytes = file.bytes;
|
||||
if (bytes == null) continue;
|
||||
final ext = (file.extension ?? '').toLowerCase();
|
||||
final kind = (ext == 'stl' || ext == 'obj' || ext == 'ply')
|
||||
? 'scan'
|
||||
: (ext == 'pdf') ? 'document' : 'image';
|
||||
: (ext == 'pdf')
|
||||
? 'document'
|
||||
: 'image';
|
||||
final mimeType = _mimeFromExt(ext);
|
||||
final req = http.MultipartRequest(
|
||||
'POST',
|
||||
Uri.parse('https://pocket.kovaksoft.com/api/collections/job_files/records'),
|
||||
Uri.parse(
|
||||
'https://pocket.kovaksoft.com/api/collections/job_files/records'),
|
||||
)
|
||||
..headers['Authorization'] = 'Bearer $token'
|
||||
..fields['job_id'] = job.id
|
||||
@@ -483,7 +496,7 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
// Lab selection
|
||||
_SectionLabel(label: 'Laboratuvar *'),
|
||||
const _SectionLabel(label: 'Laboratuvar *'),
|
||||
if (_labsLoading)
|
||||
const Center(child: CircularProgressIndicator())
|
||||
else if (_labsError != null)
|
||||
@@ -523,7 +536,7 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
_SectionLabel(label: 'Hasta / Protokol'),
|
||||
const _SectionLabel(label: 'Hasta / Protokol'),
|
||||
const SizedBox(height: 8),
|
||||
SegmentedButton<_PatientEntryMode>(
|
||||
segments: const [
|
||||
@@ -566,7 +579,8 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
||||
dense: true,
|
||||
leading: Icon(Icons.info_outline),
|
||||
title: Text('Hasta bulunamadı'),
|
||||
subtitle: Text('İsterseniz "Yeni Hasta" modundan manuel ekleyebilirsiniz.'),
|
||||
subtitle: Text(
|
||||
'İsterseniz "Yeni Hasta" modundan manuel ekleyebilirsiniz.'),
|
||||
),
|
||||
..._patientResults.map(
|
||||
(p) => ListTile(
|
||||
@@ -668,7 +682,7 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Prosthetic type
|
||||
_SectionLabel(label: 'Protez Türü *'),
|
||||
const _SectionLabel(label: 'Protez Türü *'),
|
||||
DropdownButtonFormField<ProstheticType>(
|
||||
initialValue: _selectedProstheticType,
|
||||
decoration: const InputDecoration(
|
||||
@@ -689,12 +703,11 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
||||
});
|
||||
_refreshProductsAndPrice();
|
||||
},
|
||||
validator: (val) =>
|
||||
val == null ? 'Protez türü zorunludur' : null,
|
||||
validator: (val) => val == null ? 'Protez türü zorunludur' : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
_SectionLabel(label: 'Ürün'),
|
||||
const _SectionLabel(label: 'Ürün'),
|
||||
DropdownButtonFormField<ProstheticProduct>(
|
||||
initialValue: _selectedProduct,
|
||||
decoration: InputDecoration(
|
||||
@@ -716,7 +729,8 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: (_selectedProstheticType == null || _availableProducts.isEmpty)
|
||||
onChanged: (_selectedProstheticType == null ||
|
||||
_availableProducts.isEmpty)
|
||||
? null
|
||||
: (val) {
|
||||
setState(() => _selectedProduct = val);
|
||||
@@ -733,14 +747,15 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
||||
const SizedBox(height: 8),
|
||||
_InlineInfoBanner(
|
||||
message: _productAvailabilityMessage!,
|
||||
tone: _hasMissingProductForType || _hasSelectedProductWithoutPrice
|
||||
? _InfoBannerTone.warning
|
||||
: _InfoBannerTone.info,
|
||||
tone:
|
||||
_hasMissingProductForType || _hasSelectedProductWithoutPrice
|
||||
? _InfoBannerTone.warning
|
||||
: _InfoBannerTone.info,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
|
||||
_SectionLabel(label: 'İş Tipi'),
|
||||
const _SectionLabel(label: 'İş Tipi'),
|
||||
DropdownButtonFormField<JobWorkflowType>(
|
||||
initialValue: _selectedWorkflowType,
|
||||
decoration: const InputDecoration(
|
||||
@@ -754,19 +769,22 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: (val) =>
|
||||
setState(() => _selectedWorkflowType = val),
|
||||
validator: (val) =>
|
||||
val == null ? 'Lütfen iş tipi seçin' : null,
|
||||
onChanged: (val) => setState(() => _selectedWorkflowType = val),
|
||||
validator: (val) => val == null ? 'Lütfen iş tipi seçin' : null,
|
||||
),
|
||||
// Price preview
|
||||
if (_priceLoading)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 8),
|
||||
child: Row(children: [
|
||||
SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 1.5)),
|
||||
SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(strokeWidth: 1.5)),
|
||||
SizedBox(width: 8),
|
||||
Text('Fiyat yükleniyor...', style: TextStyle(fontSize: 12, color: AppColors.textMuted)),
|
||||
Text('Fiyat yükleniyor...',
|
||||
style:
|
||||
TextStyle(fontSize: 12, color: AppColors.textMuted)),
|
||||
]),
|
||||
)
|
||||
else if (_labProduct != null && _effectivePrice != null) ...[
|
||||
@@ -784,6 +802,10 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
||||
_ProvaToggle(
|
||||
value: _provaRequired,
|
||||
prostheticType: _selectedProstheticType,
|
||||
workflowType: _selectedWorkflowType,
|
||||
optionalSteps: _selectedLab != null
|
||||
? Tenant.fromJson(_selectedLab!).workflowOverrideSteps
|
||||
: const [],
|
||||
onChanged: (v) => setState(() => _provaRequired = v),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
@@ -809,7 +831,10 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
||||
},
|
||||
onSelectUpper: () {
|
||||
setState(() {
|
||||
final upper = {...[for (int i = 11; i <= 18; i++) i], ...[for (int i = 21; i <= 28; i++) i]};
|
||||
final upper = {
|
||||
...[for (int i = 11; i <= 18; i++) i],
|
||||
...[for (int i = 21; i <= 28; i++) i]
|
||||
};
|
||||
if (upper.every(_selectedTeeth.contains)) {
|
||||
_selectedTeeth.removeAll(upper);
|
||||
} else {
|
||||
@@ -820,7 +845,10 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
||||
},
|
||||
onSelectLower: () {
|
||||
setState(() {
|
||||
final lower = {...[for (int i = 31; i <= 38; i++) i], ...[for (int i = 41; i <= 48; i++) i]};
|
||||
final lower = {
|
||||
...[for (int i = 31; i <= 38; i++) i],
|
||||
...[for (int i = 41; i <= 48; i++) i]
|
||||
};
|
||||
if (lower.every(_selectedTeeth.contains)) {
|
||||
_selectedTeeth.removeAll(lower);
|
||||
} else {
|
||||
@@ -851,7 +879,7 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Color (optional)
|
||||
_SectionLabel(label: 'Renk (İsteğe Bağlı)'),
|
||||
const _SectionLabel(label: 'Renk (İsteğe Bağlı)'),
|
||||
TextFormField(
|
||||
controller: _colorController,
|
||||
decoration: const InputDecoration(
|
||||
@@ -861,7 +889,7 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Description (optional)
|
||||
_SectionLabel(label: 'Açıklama (İsteğe Bağlı)'),
|
||||
const _SectionLabel(label: 'Açıklama (İsteğe Bağlı)'),
|
||||
TextFormField(
|
||||
controller: _descriptionController,
|
||||
decoration: const InputDecoration(
|
||||
@@ -873,7 +901,7 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Due date (optional)
|
||||
_SectionLabel(label: 'Son Tarih (İsteğe Bağlı)'),
|
||||
const _SectionLabel(label: 'Son Tarih (İsteğe Bağlı)'),
|
||||
InkWell(
|
||||
onTap: _pickDueDate,
|
||||
child: InputDecorator(
|
||||
@@ -895,7 +923,7 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// File attachments (optional)
|
||||
_SectionLabel(label: 'Dosya Ekle (İsteğe Bağlı)'),
|
||||
const _SectionLabel(label: 'Dosya Ekle (İsteğe Bağlı)'),
|
||||
_FilePicker(
|
||||
files: _pendingFiles,
|
||||
onAdd: () async {
|
||||
@@ -958,7 +986,9 @@ class _InlineInfoBanner extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
isWarning ? Icons.warning_amber_rounded : Icons.info_outline_rounded,
|
||||
isWarning
|
||||
? Icons.warning_amber_rounded
|
||||
: Icons.info_outline_rounded,
|
||||
size: 18,
|
||||
color: isWarning ? AppColors.pending : AppColors.textSecondary,
|
||||
),
|
||||
@@ -995,12 +1025,18 @@ class _TeethBulkBar extends StatelessWidget {
|
||||
final VoidCallback onClear;
|
||||
|
||||
bool _allUpperSelected() {
|
||||
final upper = [for (int i = 11; i <= 18; i++) i, for (int i = 21; i <= 28; i++) i];
|
||||
final upper = [
|
||||
for (int i = 11; i <= 18; i++) i,
|
||||
for (int i = 21; i <= 28; i++) i
|
||||
];
|
||||
return upper.every(selectedTeeth.contains);
|
||||
}
|
||||
|
||||
bool _allLowerSelected() {
|
||||
final lower = [for (int i = 31; i <= 38; i++) i, for (int i = 41; i <= 48; i++) i];
|
||||
final lower = [
|
||||
for (int i = 31; i <= 38; i++) i,
|
||||
for (int i = 41; i <= 48; i++) i
|
||||
];
|
||||
return lower.every(selectedTeeth.contains);
|
||||
}
|
||||
|
||||
@@ -1094,9 +1130,7 @@ class _BulkChip extends StatelessWidget {
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: color),
|
||||
fontSize: 12, fontWeight: FontWeight.w600, color: color),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -1247,7 +1281,8 @@ class _FilePicker extends StatelessWidget {
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.attach_file, size: 16, color: AppColors.textSecondary),
|
||||
const Icon(Icons.attach_file,
|
||||
size: 16, color: AppColors.textSecondary),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
@@ -1265,7 +1300,8 @@ class _FilePicker extends StatelessWidget {
|
||||
const SizedBox(width: 4),
|
||||
GestureDetector(
|
||||
onTap: () => onRemove(i),
|
||||
child: const Icon(Icons.close, size: 16, color: AppColors.textSecondary),
|
||||
child: const Icon(Icons.close,
|
||||
size: 16, color: AppColors.textSecondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -1334,21 +1370,30 @@ class _PricePreviewChip extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
'${product.name} — ${effectivePrice.toStringAsFixed(2)} $currency',
|
||||
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w700, color: AppColors.success),
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.success),
|
||||
),
|
||||
Text(
|
||||
'${unitPrice.toStringAsFixed(2)} $currency x $units $unitLabel',
|
||||
style: TextStyle(fontSize: 11, color: AppColors.success.withValues(alpha: 0.75)),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: AppColors.success.withValues(alpha: 0.75)),
|
||||
),
|
||||
if (hasDiscount)
|
||||
Text(
|
||||
'Liste: ${baseAmount.toStringAsFixed(2)} $currency · İndirim uygulandı',
|
||||
style: TextStyle(fontSize: 11, color: AppColors.success.withValues(alpha: 0.75)),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: AppColors.success.withValues(alpha: 0.75)),
|
||||
)
|
||||
else
|
||||
Text(
|
||||
'Liste fiyatı · İndirim yok',
|
||||
style: TextStyle(fontSize: 11, color: AppColors.success.withValues(alpha: 0.75)),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: AppColors.success.withValues(alpha: 0.75)),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -1381,18 +1426,28 @@ class _ProvaToggle extends StatelessWidget {
|
||||
const _ProvaToggle({
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
required this.optionalSteps,
|
||||
this.prostheticType,
|
||||
this.workflowType,
|
||||
});
|
||||
|
||||
final bool value;
|
||||
final ValueChanged<bool> onChanged;
|
||||
final ProstheticType? prostheticType;
|
||||
final JobWorkflowType? workflowType;
|
||||
final List<JobStep> optionalSteps;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final steps = prostheticType != null
|
||||
? jobStepTemplate(prostheticType!, value)
|
||||
: <JobStep>[];
|
||||
final preset = prostheticType != null
|
||||
? buildJobWorkflowPreset(
|
||||
prostheticType: prostheticType!,
|
||||
workflowType: workflowType,
|
||||
provaRequired: value,
|
||||
optionalSteps: optionalSteps,
|
||||
)
|
||||
: null;
|
||||
final steps = preset?.steps ?? <JobStep>[];
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
@@ -1400,7 +1455,9 @@ class _ProvaToggle extends StatelessWidget {
|
||||
color: value ? AppColors.inProgressBg : AppColors.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: value ? AppColors.inProgress.withValues(alpha: 0.3) : AppColors.border,
|
||||
color: value
|
||||
? AppColors.inProgress.withValues(alpha: 0.3)
|
||||
: AppColors.border,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
@@ -1422,14 +1479,17 @@ class _ProvaToggle extends StatelessWidget {
|
||||
value ? 'Provalı İş' : 'Provasız İş',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: value ? AppColors.inProgress : AppColors.textPrimary,
|
||||
color: value
|
||||
? AppColors.inProgress
|
||||
: AppColors.textPrimary,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
value
|
||||
? 'Lab her adımda klinik onayı bekler'
|
||||
: 'Lab doğrudan üretip teslime gönderir',
|
||||
preset?.title ??
|
||||
(value
|
||||
? 'Lab her adımda klinik onayı bekler'
|
||||
: 'Lab doğrudan üretip teslime gönderir'),
|
||||
style: const TextStyle(
|
||||
fontSize: 12, color: AppColors.textSecondary),
|
||||
),
|
||||
@@ -1439,27 +1499,41 @@ class _ProvaToggle extends StatelessWidget {
|
||||
Switch(
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
activeColor: AppColors.inProgress,
|
||||
activeThumbColor: AppColors.inProgress,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (steps.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
if (preset != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(
|
||||
preset.summary,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
children: steps.map((s) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Text(
|
||||
s.label,
|
||||
style: const TextStyle(
|
||||
fontSize: 11, color: AppColors.textSecondary),
|
||||
),
|
||||
)).toList(),
|
||||
children: steps
|
||||
.map((s) => Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Text(
|
||||
s.label,
|
||||
style: const TextStyle(
|
||||
fontSize: 11, color: AppColors.textSecondary),
|
||||
),
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user