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
+171
View File
@@ -0,0 +1,171 @@
import '../../features/shared/job_files_repository.dart';
import '../../features/shared/tenant_team_repository.dart';
import '../../models/job_file.dart';
import '../../models/tenant.dart';
import '../api/pocketbase_client.dart';
// ── Message segments ──────────────────────────────────────────────────────────
sealed class MessageSegment {}
class TextSegment extends MessageSegment {
TextSegment(this.text);
final String text;
}
class ActionSegment extends MessageSegment {
ActionSegment(this.action);
final AiAction action;
}
// ── Action model ──────────────────────────────────────────────────────────────
class AiAction {
const AiAction({
required this.type,
required this.params,
required this.label,
});
final String type;
final Map<String, String> params;
final String label;
bool get isDangerous => type == 'cancel_job';
bool get isFileAction => type == 'job_files';
}
// ── Action outcome ────────────────────────────────────────────────────────────
sealed class ActionOutcome {}
class ActionSuccess extends ActionOutcome {
ActionSuccess(this.message);
final String message;
}
class ActionError extends ActionOutcome {
ActionError(this.error);
final String error;
}
class ActionFiles extends ActionOutcome {
ActionFiles(this.files);
final List<JobFile> files;
}
// ── Parser ────────────────────────────────────────────────────────────────────
List<MessageSegment> parseSegments(String text) {
// Strip code fences wrapping <dls-action> tags that the AI sometimes emits.
// Handles: ```xml\n<dls-action .../>\n``` and ```\n<dls-action .../>\n```
text = text.replaceAllMapped(
RegExp(r'```(?:xml)?\s*\n(\s*<dls-action\s[^>]*/>)\s*\n\s*```'),
(m) => m.group(1)!,
);
// Also handle inline variant: ```xml <dls-action .../> ```
text = text.replaceAllMapped(
RegExp(r'```(?:xml)?\s*(<dls-action\s[^>]*/>)\s*```'),
(m) => m.group(1)!,
);
final pattern = RegExp(r'<dls-action\s([^/]*?)/>', dotAll: true);
final segments = <MessageSegment>[];
int last = 0;
for (final m in pattern.allMatches(text)) {
final before = text.substring(last, m.start).trim();
if (before.isNotEmpty) segments.add(TextSegment(before));
final attrs = _parseAttrs(m.group(1) ?? '');
segments.add(ActionSegment(AiAction(
type: attrs['type'] ?? '',
params: attrs,
label: attrs['label'] ?? attrs['type'] ?? 'İşlem',
)));
last = m.end;
}
final rest = text.substring(last).trim();
if (rest.isNotEmpty) segments.add(TextSegment(rest));
return segments;
}
Map<String, String> _parseAttrs(String s) {
final result = <String, String>{};
for (final m in RegExp(r'(\w+)="([^"]*)"').allMatches(s)) {
result[m.group(1)!] = m.group(2)!;
}
return result;
}
// ── Executor ──────────────────────────────────────────────────────────────────
class AiActionExecutor {
static final _pb = PocketBaseClient.instance.pb;
static Future<ActionOutcome> execute(
AiAction action,
TenantMembership membership,
) async {
try {
return switch (action.type) {
'cancel_job' => await _cancelJob(action.params),
'mark_delivered' => await _markDelivered(action.params),
'job_files' => await _jobFiles(action.params),
'add_member' => await _addMember(action.params, membership),
_ => ActionError('Bilinmeyen işlem türü: ${action.type}'),
};
} catch (e) {
final msg = e.toString();
if (msg.length > 120) return ActionError('Sunucu hatası');
return ActionError(msg);
}
}
static Future<ActionOutcome> _cancelJob(Map<String, String> p) async {
final id = p['job_id'];
if (id == null || id.isEmpty) return ActionError('İş ID bulunamadı.');
await _pb.collection('jobs').update(id, body: {'status': 'cancelled'});
return ActionSuccess('İş başarıyla iptal edildi.');
}
static Future<ActionOutcome> _markDelivered(Map<String, String> p) async {
final id = p['job_id'];
if (id == null || id.isEmpty) return ActionError('İş ID bulunamadı.');
await _pb.collection('jobs').update(id, body: {'status': 'delivered'});
return ActionSuccess('İş teslim edildi olarak işaretlendi.');
}
static Future<ActionOutcome> _jobFiles(Map<String, String> p) async {
final id = p['job_id'];
if (id == null || id.isEmpty) return ActionError('İş ID bulunamadı.');
final files = await JobFilesRepository.instance.listForJob(id);
if (files.isEmpty) return ActionSuccess('Bu iş için henüz dosya yüklenmemiş.');
return ActionFiles(files);
}
static Future<ActionOutcome> _addMember(
Map<String, String> p,
TenantMembership membership,
) async {
final email = p['email'];
final firstName = p['first_name'];
final lastName = p['last_name'] ?? '';
final role = p['role'];
final password = p['password'];
if (email == null || firstName == null || role == null || password == null) {
return ActionError('Eksik bilgi: e-posta, ad, rol ve şifre gerekli.');
}
await TenantTeamRepository.instance.addMember(
tenantId: membership.tenant.id,
email: email,
password: password,
firstName: firstName,
lastName: lastName,
role: TenantMembership.parseRole(role),
);
return ActionSuccess('$firstName $lastName ekibe eklendi.');
}
}
+226
View File
@@ -0,0 +1,226 @@
import 'package:pocketbase/pocketbase.dart';
import '../api/pocketbase_client.dart';
import '../../models/tenant.dart';
class AiContextBuilder {
AiContextBuilder._();
static final instance = AiContextBuilder._();
PocketBase get _pb => PocketBaseClient.instance.pb;
Future<String> build(TenantMembership membership) async {
final tenant = membership.tenant;
final tenantId = tenant.id;
final isLab = tenant.kind == TenantKind.lab;
final now = DateTime.now();
final dateStr = '${now.day}.${now.month}.${now.year}';
final results = await Future.wait([
_fetchActiveJobs(tenantId, isLab),
_fetchRecentDelivered(tenantId, isLab),
_fetchFinance(tenantId, isLab),
_fetchTeam(tenantId),
]);
final actions = _actionsPrompt(isLab);
return 'Sen DLS (Dental Lab System) uygulamasinin akilli asistanisin.\n'
'${tenant.companyName} adli ${isLab ? 'dental laboratuvarinin' : 'dis kliniginin'} verilerine erisebilirsin.\n'
'Kullanici rolu: ${isLab ? 'LABORATUVAR' : 'KLINIK'}\n'
'\n'
'Tarih: $dateStr\n'
'\n'
'${results[0]}\n'
'\n'
'${results[1]}\n'
'\n'
'${results[2]}\n'
'\n'
'${results[3]}\n'
'\n'
'$actions\n'
'\n'
'Yanit kurallari:\n'
'- Turkce, kisa ve net yaz\n'
'- Sadece yukaridaki verilerden hareketle yorum yap\n'
'- Listelerde madde isareti (- ) kullan\n'
'- Onemli bilgileri **kalin** yaz\n'
'- Aksiyon etiketlerini HERZAMAN metnin sonuna koy\n'
'- ${isLab ? 'Is kodlari icin [ID:...] formatini kullan' : 'Hasta kodlari ve is durumlarini net belirt'}\n';
}
static String _actionsPrompt(bool isLab) {
final buf = StringBuffer();
buf.writeln('## EYLEM YETKILERIN');
buf.writeln('Kullanici bir islem yapmak istediginde asagidaki XML etiketlerini yanita ekle:');
buf.writeln('');
buf.writeln('Is dosyalarini gostermek:');
buf.writeln('<dls-action type="job_files" job_id="JOB_ID" label="AB001 dosyalarini goster"/>');
buf.writeln('');
buf.writeln('Is iptal etmek:');
buf.writeln('<dls-action type="cancel_job" job_id="JOB_ID" label="AB001 isini iptal et"/>');
if (!isLab) {
buf.writeln('');
buf.writeln('Teslim edildi isaretlemek (sadece klinik):');
buf.writeln('<dls-action type="mark_delivered" job_id="JOB_ID" label="AB001 teslim edildi"/>');
}
buf.writeln('');
buf.writeln('Ekip uyesi eklemek (TUM bilgiler alindiktan sonra):');
buf.writeln('<dls-action type="add_member" email="..." first_name="..." last_name="..." role="technician|admin|doctor|delivery|finance|member" password="..." label="Ad Soyad ekle"/>');
buf.writeln('');
buf.writeln('KURALLAR:');
buf.writeln('- Etiketi SADECE kullanici acikca islem istediginde ekle');
buf.writeln('- Sifre sorulursa kullanicidan al, ASLA uydurma');
buf.writeln('- iptal gibi geri alinmaz islemleri acikca belirt');
buf.writeln('- Etiket icindeki job_id degerini yukaridaki is listesinden al');
buf.writeln('- <dls-action> etiketlerini KESINLİKLE kod blogu (```xml veya ```) icine ALMA, duz metin olarak yaz');
return buf.toString();
}
Future<String> _fetchActiveJobs(String tenantId, bool isLab) async {
try {
final tenantField = isLab ? 'lab_tenant_id' : 'clinic_tenant_id';
final counterpartField = isLab ? 'clinic_tenant_id' : 'lab_tenant_id';
final result = await _pb.collection('jobs').getList(
filter: '$tenantField = "$tenantId" && status != "delivered" && status != "cancelled"',
perPage: 60,
sort: '-created',
expand: counterpartField,
);
if (result.items.isEmpty) return '## Aktif Isler\nSu an aktif is yok.';
final counterpartLabel = isLab ? 'Klinik' : 'Lab';
final lines = result.items.map((r) {
final j = r.toJson();
final jobId = j['id'] as String? ?? '';
final expand = j['expand'] as Map<String, dynamic>?;
final counterpart =
(expand?[counterpartField] as Map?)?['company_name'] as String? ?? '-';
final status = _statusTr(j['status'] as String? ?? '');
final prosthetic = j['prosthetic_type'] as String? ?? '-';
final patient = j['patient_code'] as String? ?? '-';
final step = j['current_step'] as String?;
final stepPart = (step != null && step.isNotEmpty) ? ' | Adim: $step' : '';
final due = j['due_date'] as String? ?? '';
final duePart = due.isNotEmpty ? ' | Termin: ${due.substring(0, 10)}' : '';
return '- [ID:$jobId] Hasta: $patient | $prosthetic | $status$stepPart | $counterpartLabel: $counterpart$duePart';
}).join('\n');
return '## Aktif Isler (${result.items.length})\n$lines';
} catch (e) {
return '## Aktif Isler\n(Veri alinamadi: $e)';
}
}
Future<String> _fetchRecentDelivered(String tenantId, bool isLab) async {
try {
final tenantField = isLab ? 'lab_tenant_id' : 'clinic_tenant_id';
final counterpartField = isLab ? 'clinic_tenant_id' : 'lab_tenant_id';
final result = await _pb.collection('jobs').getList(
filter: '$tenantField = "$tenantId" && status = "delivered"',
perPage: 10,
sort: '-updated',
expand: counterpartField,
);
if (result.items.isEmpty) return '## Son Teslim Edilenler\nHenuz teslim edilen is yok.';
final counterpartLabel = isLab ? 'Klinik' : 'Lab';
final lines = result.items.map((r) {
final j = r.toJson();
final jobId = j['id'] as String? ?? '';
final expand = j['expand'] as Map<String, dynamic>?;
final counterpart =
(expand?[counterpartField] as Map?)?['company_name'] as String? ?? '-';
final prosthetic = j['prosthetic_type'] as String? ?? '-';
final patient = j['patient_code'] as String? ?? '-';
final updated = (j['updated'] as String? ?? '');
final datePart = updated.length >= 10 ? updated.substring(0, 10) : '';
return '- [ID:$jobId] Hasta: $patient | $prosthetic | $counterpartLabel: $counterpart${datePart.isNotEmpty ? ' | Tarih: $datePart' : ''}';
}).join('\n');
return '## Son Teslim Edilenler (son 10)\n$lines';
} catch (_) {
return '## Son Teslim Edilenler\n(Veri alinamadi)';
}
}
Future<String> _fetchFinance(String tenantId, bool isLab) async {
try {
final type = isLab ? 'receivable' : 'payable';
final result = await _pb.collection('finance_entries').getList(
filter: 'tenant_id = "$tenantId" && type = "$type"',
perPage: 200,
);
double pending = 0, paid = 0;
for (final r in result.items) {
final j = r.toJson();
final amount = (j['amount'] as num?)?.toDouble() ?? 0;
if (j['status'] == 'pending') {
pending += amount;
} else {
paid += amount;
}
}
final label = isLab ? 'alacak' : 'borc';
return '## Finans\n'
'- Bekleyen $label: ${pending.toStringAsFixed(0)} TL\n'
'- Tahsil edilen: ${paid.toStringAsFixed(0)} TL';
} catch (_) {
return '## Finans\n(Veri alinamadi)';
}
}
Future<String> _fetchTeam(String tenantId) async {
try {
final result = await _pb.collection('tenant_members').getList(
filter: 'tenant_id = "$tenantId"',
expand: 'user_id',
perPage: 50,
);
if (result.items.isEmpty) return '## Ekip\nUye yok.';
final lines = result.items.map((r) {
final j = r.toJson();
final expand = j['expand'] as Map<String, dynamic>?;
final user = expand?['user_id'] as Map<String, dynamic>?;
final first = (user?['first_name'] as String?) ?? '';
final last = (user?['last_name'] as String?) ?? '';
final email = (user?['email'] as String?) ?? '';
final name =
'$first $last'.trim().isNotEmpty ? '$first $last'.trim() : email;
final role = _roleTr(j['role'] as String? ?? '');
return '- $name ($role)';
}).join('\n');
return '## Ekip (${result.items.length} uye)\n$lines';
} catch (_) {
return '## Ekip\n(Veri alinamadi)';
}
}
static String _statusTr(String s) => switch (s) {
'pending' => 'Bekliyor',
'in_progress' => 'Devam ediyor',
'sent' => 'Gonderildi',
'revision' => 'Revizyon',
'delivered' => 'Teslim edildi',
'cancelled' => 'Iptal',
_ => s,
};
static String _roleTr(String s) => switch (s) {
'owner' => 'Sahibi',
'admin' => 'Yonetici',
'technician' => 'Teknisyen',
'delivery' => 'Teslimat',
'finance' => 'Finans',
'doctor' => 'Hekim',
_ => 'Uye',
};
}
+71
View File
@@ -0,0 +1,71 @@
import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
class AiService {
static const _baseUrl = 'https://api.featherless.ai/v1';
static const _apiKey =
'rc_e10f49aaa4f7af03dcd9da115cfc12cc1988665e895955c11f77788ee5ad93c6';
static const _model = 'Qwen/Qwen2.5-7B-Instruct';
AiService._();
static final instance = AiService._();
Stream<String> streamChat({
required String systemPrompt,
required List<Map<String, String>> messages,
}) async* {
final client = http.Client();
try {
final request = http.Request(
'POST',
Uri.parse('$_baseUrl/chat/completions'),
);
request.headers.addAll({
'Authorization': 'Bearer $_apiKey',
'Content-Type': 'application/json',
});
request.body = jsonEncode({
'model': _model,
'messages': [
{'role': 'system', 'content': systemPrompt},
...messages,
],
'stream': true,
'max_tokens': 2048,
'temperature': 0.7,
});
final response = await client.send(request);
if (response.statusCode != 200) {
final body = await response.stream.bytesToString();
String msg = 'API hatası ${response.statusCode}';
try {
final j = jsonDecode(body) as Map<String, dynamic>;
msg = (j['error'] as Map?)?['message'] as String? ?? msg;
} catch (_) {}
throw Exception(msg);
}
final lines = response.stream
.transform(utf8.decoder)
.transform(const LineSplitter());
await for (final line in lines) {
if (!line.startsWith('data: ')) continue;
final payload = line.substring(6).trim();
if (payload == '[DONE]') break;
try {
final j = jsonDecode(payload) as Map<String, dynamic>;
final choices = j['choices'] as List?;
if (choices == null || choices.isEmpty) continue;
final delta = choices.first['delta'] as Map<String, dynamic>?;
final content = delta?['content'] as String?;
if (content != null && content.isNotEmpty) yield content;
} catch (_) {}
}
} finally {
client.close();
}
}
}
+117
View File
@@ -0,0 +1,117 @@
import 'package:pocketbase/pocketbase.dart';
import '../api/pocketbase_client.dart';
import '../../models/job.dart';
class JobHistoryEntry {
const JobHistoryEntry({
required this.id,
required this.action,
required this.createdAt,
this.step,
this.note,
});
final String id;
final JobHistoryAction action;
final JobStep? step;
final String? note;
final DateTime createdAt;
}
enum JobHistoryAction {
accepted,
handedToClinic,
approved,
revisionRequested,
delivered,
cancelled,
}
extension JobHistoryActionExt on JobHistoryAction {
String get value => switch (this) {
JobHistoryAction.accepted => 'accepted',
JobHistoryAction.handedToClinic => 'handed_to_clinic',
JobHistoryAction.approved => 'approved',
JobHistoryAction.revisionRequested => 'revision_requested',
JobHistoryAction.delivered => 'delivered',
JobHistoryAction.cancelled => 'cancelled',
};
}
class JobHistoryService {
JobHistoryService._();
static final instance = JobHistoryService._();
PocketBase get _pb => PocketBaseClient.instance.pb;
String get _currentUserId =>
(_pb.authStore.record?.id) ?? (_pb.authStore.model as dynamic)?.id as String? ?? '';
Future<List<JobHistoryEntry>> listForJob(String jobId) async {
try {
final result = await _pb.collection('job_status_history').getList(
filter: 'job_id = "$jobId"',
perPage: 200,
);
return (result.items.map((r) {
final j = r.toJson();
String? str(dynamic v) {
final s = v as String?;
return (s == null || s.isEmpty) ? null : s;
}
return JobHistoryEntry(
id: j['id'] as String,
action: _parseAction(j['action_type'] as String? ?? ''),
step: str(j['step']) != null ? _parseStep(j['step'] as String) : null,
note: str(j['note']),
createdAt: DateTime.parse(j['created'] as String),
);
}).toList()..sort((a, b) => a.createdAt.compareTo(b.createdAt)));
} catch (_) {
return [];
}
}
static JobHistoryAction _parseAction(String s) => switch (s) {
'accepted' => JobHistoryAction.accepted,
'handed_to_clinic' => JobHistoryAction.handedToClinic,
'approved' => JobHistoryAction.approved,
'revision_requested' => JobHistoryAction.revisionRequested,
'delivered' => JobHistoryAction.delivered,
_ => JobHistoryAction.cancelled,
};
static JobStep _parseStep(String s) => switch (s) {
'alt_yapi_prova' => JobStep.altYapiProva,
'ust_yapi_prova' => JobStep.ustYapiProva,
'mum_prova' => JobStep.mumProva,
'disler_prova' => JobStep.dislerProva,
'dayanak_prova' => JobStep.dayanakProva,
'kron_prova' => JobStep.kronProva,
'cila_bitim' => JobStep.cilaBitim,
_ => JobStep.olcu,
};
Future<void> append({
required String jobId,
required String clinicTenantId,
required String labTenantId,
required JobHistoryAction action,
JobStep? step,
String? note,
String? userId,
}) async {
try {
await _pb.collection('job_status_history').create(body: {
'job_id': jobId,
'clinic_tenant_id': clinicTenantId,
'lab_tenant_id': labTenantId,
'completed_by': userId ?? _currentUserId,
'action_type': action.value,
if (step != null) 'step': step.value,
if (note != null && note.isNotEmpty) 'note': note,
});
} catch (_) {
// history failures must never block the main mutation
}
}
}
@@ -0,0 +1,64 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:go_router/go_router.dart';
import 'package:onesignal_flutter/onesignal_flutter.dart';
// ─── Replace with your OneSignal App ID from onesignal.com ──────────────────
const _kOneSignalAppId = '524cb6d8-2640-4f85-bb24-c9c762233de7';
// ────────────────────────────────────────────────────────────────────────────
class NotificationService {
NotificationService._();
static GoRouter? _router;
static bool _initialized = false;
static void setRouter(GoRouter router) => _router = router;
static bool get _supported =>
!kIsWeb && (Platform.isIOS || Platform.isAndroid || Platform.isMacOS);
static Future<void> init() async {
if (!_supported || _initialized) return;
_initialized = true;
OneSignal.initialize(_kOneSignalAppId);
await OneSignal.Notifications.requestPermission(true);
// Show notification even when app is in foreground
OneSignal.Notifications.addForegroundWillDisplayListener((event) {
event.notification.display();
});
// Tap → navigate to job detail
OneSignal.Notifications.addClickListener((event) {
final data = event.notification.additionalData;
if (data == null) return;
final jobId = data['job_id'] as String?;
final tenantType = data['tenant_type'] as String?;
if (jobId == null || _router == null) return;
if (tenantType == 'lab') {
_router!.push('/lab/jobs/$jobId');
} else {
_router!.push('/clinic/jobs/$jobId');
}
});
}
/// Call after successful login. Links the OneSignal player to this user.
static Future<void> loginUser(String userId, {bool isLab = false}) async {
if (!_supported) return;
try {
await OneSignal.login(userId);
OneSignal.User.addTagWithKey('tenant_type', isLab ? 'lab' : 'clinic');
} catch (_) {}
}
/// Call on logout.
static Future<void> logoutUser() async {
if (!_supported) return;
try {
await OneSignal.logout();
} catch (_) {}
}
}
+37
View File
@@ -0,0 +1,37 @@
import 'package:pocketbase/pocketbase.dart';
import '../api/pocketbase_client.dart';
typedef UnsubFn = Future<void> Function();
class RealtimeService {
RealtimeService._();
static final instance = RealtimeService._();
final _pb = PocketBaseClient.instance.pb;
UnsubFn watch(
String collection, {
String topic = '*',
String filter = '',
required void Function(RecordSubscriptionEvent) onEvent,
}) {
UnsubFn? cancel;
_pb.collection(collection).subscribe(topic, onEvent, filter: filter).then((fn) {
cancel = fn;
});
return () async {
try {
final fn = cancel;
if (fn != null) {
await fn();
} else {
await _pb.collection(collection).unsubscribe(topic);
}
} catch (_) {
await _pb.collection(collection).unsubscribe(topic);
}
};
}
}