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
+930
View File
@@ -0,0 +1,930 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/providers/auth_provider.dart';
import '../../core/services/ai_actions.dart';
import '../../core/services/ai_context_builder.dart';
import '../../core/services/ai_service.dart';
import '../../core/theme/app_theme.dart';
import '../../core/utils/file_download_helper.dart';
import '../../models/job_file.dart';
import '../../models/tenant.dart';
class AiChatScreen extends ConsumerStatefulWidget {
const AiChatScreen({super.key});
@override
ConsumerState<AiChatScreen> createState() => _AiChatScreenState();
}
class _AiChatScreenState extends ConsumerState<AiChatScreen> {
final _inputController = TextEditingController();
final _scrollController = ScrollController();
final List<_Message> _messages = [];
String? _systemPrompt;
bool _loadingContext = true;
bool _streaming = false;
@override
void initState() {
super.initState();
_loadContext();
}
@override
void dispose() {
_inputController.dispose();
_scrollController.dispose();
super.dispose();
}
Future<void> _loadContext() async {
try {
final membership = ref.read(authProvider).activeTenant!;
final prompt = await AiContextBuilder.instance.build(membership);
if (mounted) setState(() { _systemPrompt = prompt; _loadingContext = false; });
} catch (_) {
if (mounted) setState(() => _loadingContext = false);
}
}
Future<void> _send([String? override]) async {
final text = (override ?? _inputController.text).trim();
if (text.isEmpty || _streaming || _systemPrompt == null) return;
_inputController.clear();
FocusScope.of(context).unfocus();
final apiMessages = [
..._messages.map((m) => {'role': m.isUser ? 'user' : 'assistant', 'content': m.rawText}),
{'role': 'user', 'content': text},
];
setState(() {
_messages.add(_Message.user(text));
_messages.add(_Message.assistantStreaming());
_streaming = true;
});
_scrollToBottom();
var accumulated = '';
try {
final stream = AiService.instance.streamChat(
systemPrompt: _systemPrompt!,
messages: apiMessages,
);
await for (final chunk in stream) {
if (!mounted) break;
accumulated += chunk;
setState(() => _messages[_messages.length - 1] = _Message.assistantStreaming(accumulated));
_scrollToBottom();
}
if (mounted) {
setState(() => _messages[_messages.length - 1] = _Message.assistantDone(accumulated));
}
} catch (e) {
if (mounted) {
setState(() => _messages[_messages.length - 1] = _Message.error('Bir hata olustu, tekrar deneyin.'));
}
} finally {
if (mounted) setState(() => _streaming = false);
}
}
void _scrollToBottom() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
}
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.background,
appBar: AppBar(
title: Row(
children: [
Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: AppColors.accent,
borderRadius: BorderRadius.circular(8),
),
child: const Icon(Icons.auto_awesome, size: 16, color: Colors.white),
),
const SizedBox(width: 10),
const Text('AI Asistan'),
],
),
actions: [
if (_messages.isNotEmpty)
IconButton(
onPressed: () => setState(() => _messages.clear()),
icon: const Icon(Icons.refresh_outlined, size: 20),
tooltip: 'Sohbeti temizle',
),
],
),
body: Column(
children: [
Expanded(
child: _loadingContext
? const _LoadingContext()
: _messages.isEmpty
? _WelcomeView(onSuggestion: _send)
: ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
itemCount: _messages.length,
itemBuilder: (_, i) => _MessageBubble(
message: _messages[i],
membership: ref.read(authProvider).activeTenant!,
),
),
),
_InputBar(
controller: _inputController,
enabled: !_loadingContext && !_streaming,
streaming: _streaming,
onSend: _send,
),
],
),
);
}
}
// ── Message model ─────────────────────────────────────────────────────────────
enum _MsgKind { user, streaming, done, error }
class _Message {
_Message._({required this.kind, required this.rawText});
factory _Message.user(String text) =>
_Message._(kind: _MsgKind.user, rawText: text);
factory _Message.assistantStreaming([String text = '']) =>
_Message._(kind: _MsgKind.streaming, rawText: text);
factory _Message.assistantDone(String text) =>
_Message._(kind: _MsgKind.done, rawText: text);
factory _Message.error(String text) =>
_Message._(kind: _MsgKind.error, rawText: text);
final _MsgKind kind;
final String rawText;
bool get isUser => kind == _MsgKind.user;
bool get isStreaming => kind == _MsgKind.streaming;
bool get isError => kind == _MsgKind.error;
}
// ── Welcome view ──────────────────────────────────────────────────────────────
class _WelcomeView extends StatelessWidget {
const _WelcomeView({required this.onSuggestion});
final void Function(String) onSuggestion;
static const _suggestions = [
'Bekleyen islerimin ozeti nedir?',
'Gecikmiş iş var mı?',
'Bu ay kac is tamamlandi?',
'Revizyon oranım ne durumda?',
'Finans durumumu ozetle.',
'Son yuklenen dosyalari goster.',
];
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 32, 20, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: AppColors.accent,
borderRadius: BorderRadius.circular(16),
),
child: const Icon(Icons.auto_awesome, size: 28, color: Colors.white),
),
const SizedBox(height: 16),
const Text(
'Merhaba! Size nasil yardimci olabilirim?',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.w700, color: AppColors.textPrimary),
),
const SizedBox(height: 6),
const Text(
'Isler, finans ve ekip hakkinda soru sorabilir, islem yapabilirsiniz.',
style: TextStyle(fontSize: 14, color: AppColors.textSecondary),
),
const SizedBox(height: 28),
const Text(
'ONERILER',
style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: AppColors.textMuted, letterSpacing: 0.8),
),
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: _suggestions
.map((s) => _SuggestionChip(label: s, onTap: () => onSuggestion(s)))
.toList(),
),
],
),
);
}
}
class _SuggestionChip extends StatelessWidget {
const _SuggestionChip({required this.label, required this.onTap});
final String label;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: AppColors.border),
),
child: Text(label, style: const TextStyle(fontSize: 13, color: AppColors.textPrimary)),
),
);
}
}
// ── Message bubble ────────────────────────────────────────────────────────────
class _MessageBubble extends StatelessWidget {
const _MessageBubble({required this.message, required this.membership});
final _Message message;
final TenantMembership membership;
@override
Widget build(BuildContext context) {
if (message.isUser) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Flexible(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: AppColors.accent,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
bottomLeft: Radius.circular(16),
bottomRight: Radius.circular(4),
),
),
child: Text(
message.rawText,
style: const TextStyle(fontSize: 14, height: 1.5, color: Colors.white),
),
),
),
],
),
);
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 28,
height: 28,
margin: const EdgeInsets.only(right: 8, top: 2),
decoration: BoxDecoration(color: AppColors.accent, borderRadius: BorderRadius.circular(8)),
child: const Icon(Icons.auto_awesome, size: 14, color: Colors.white),
),
Flexible(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(4),
topRight: Radius.circular(16),
bottomLeft: Radius.circular(16),
bottomRight: Radius.circular(16),
),
border: Border.all(color: AppColors.border),
),
child: message.isStreaming && message.rawText.isEmpty
? const _TypingDots()
: _MessageContent(message: message, membership: membership),
),
),
],
),
);
}
}
// ── Message content: markdown + action buttons ────────────────────────────────
class _MessageContent extends StatelessWidget {
const _MessageContent({required this.message, required this.membership});
final _Message message;
final TenantMembership membership;
@override
Widget build(BuildContext context) {
if (message.isError) {
return Text(
message.rawText,
style: const TextStyle(fontSize: 14, height: 1.5, color: AppColors.cancelled),
);
}
// During streaming: show raw text without parsing actions
if (message.isStreaming) {
return _MarkdownText(message.rawText, color: AppColors.textPrimary);
}
// Done: parse segments → text + action buttons
final segments = parseSegments(message.rawText);
if (segments.isEmpty) {
return _MarkdownText(message.rawText, color: AppColors.textPrimary);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: segments.map((seg) {
if (seg is TextSegment) {
return _MarkdownText(seg.text, color: AppColors.textPrimary);
}
if (seg is ActionSegment) {
return Padding(
padding: const EdgeInsets.only(top: 10),
child: _ActionCard(action: seg.action, membership: membership),
);
}
return const SizedBox.shrink();
}).toList(),
);
}
}
// ── Action card ───────────────────────────────────────────────────────────────
enum _ActionState { idle, confirming, loading, success, error, files }
class _ActionCard extends StatefulWidget {
const _ActionCard({required this.action, required this.membership});
final AiAction action;
final TenantMembership membership;
@override
State<_ActionCard> createState() => _ActionCardState();
}
class _ActionCardState extends State<_ActionCard> {
_ActionState _state = _ActionState.idle;
String _resultMsg = '';
List<JobFile> _files = [];
AiAction get action => widget.action;
Future<void> _execute() async {
setState(() => _state = _ActionState.loading);
final outcome = await AiActionExecutor.execute(action, widget.membership);
if (!mounted) return;
switch (outcome) {
case ActionSuccess(:final message):
setState(() { _state = _ActionState.success; _resultMsg = message; });
case ActionError(:final error):
setState(() { _state = _ActionState.error; _resultMsg = error; });
case ActionFiles(:final files):
setState(() { _state = _ActionState.files; _files = files; });
}
}
void _onTap() {
if (action.isDangerous) {
setState(() => _state = _ActionState.confirming);
} else {
_execute();
}
}
@override
Widget build(BuildContext context) {
return switch (_state) {
_ActionState.idle => _idleButton(),
_ActionState.confirming => _confirmCard(),
_ActionState.loading => _loadingCard(),
_ActionState.success => _resultCard(success: true),
_ActionState.error => _resultCard(success: false),
_ActionState.files => _filesCard(),
};
}
Widget _idleButton() {
final isDanger = action.isDangerous;
final isFile = action.isFileAction;
final color = isDanger
? AppColors.cancelled
: isFile
? AppColors.inProgress
: AppColors.accent;
final bgColor = isDanger
? AppColors.cancelledBg
: isFile
? AppColors.inProgressBg
: AppColors.accent.withValues(alpha: 0.1);
final icon = isDanger
? Icons.cancel_outlined
: isFile
? Icons.folder_outlined
: action.type == 'add_member'
? Icons.person_add_outlined
: Icons.check_circle_outline;
return GestureDetector(
onTap: _onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: color.withValues(alpha: 0.3)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 16, color: color),
const SizedBox(width: 8),
Flexible(
child: Text(
action.label,
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: color),
),
),
],
),
),
);
}
Widget _confirmCard() {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.cancelledBg,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppColors.cancelled.withValues(alpha: 0.3)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
const Icon(Icons.warning_amber_rounded, size: 16, color: AppColors.cancelled),
const SizedBox(width: 6),
const Expanded(
child: Text(
'Bu islem geri alinamayabilir.',
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: AppColors.cancelled),
),
),
],
),
const SizedBox(height: 4),
Text(action.label, style: const TextStyle(fontSize: 13, color: AppColors.textPrimary)),
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => setState(() => _state = _ActionState.idle),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 8),
side: BorderSide(color: AppColors.border),
),
child: const Text('Vazgec', style: TextStyle(fontSize: 13)),
),
),
const SizedBox(width: 8),
Expanded(
child: FilledButton(
onPressed: _execute,
style: FilledButton.styleFrom(
backgroundColor: AppColors.cancelled,
padding: const EdgeInsets.symmetric(vertical: 8),
),
child: const Text('Onayla', style: TextStyle(fontSize: 13)),
),
),
],
),
],
),
);
}
Widget _loadingCard() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: AppColors.background,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppColors.border),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2, color: AppColors.accent),
),
const SizedBox(width: 8),
Text('Isleniyor...', style: const TextStyle(fontSize: 13, color: AppColors.textSecondary)),
],
),
);
}
Widget _resultCard({required bool success}) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: success ? AppColors.successBg : AppColors.cancelledBg,
borderRadius: BorderRadius.circular(10),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
success ? Icons.check_circle_outline : Icons.error_outline,
size: 16,
color: success ? AppColors.success : AppColors.cancelled,
),
const SizedBox(width: 8),
Flexible(
child: Text(
_resultMsg,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: success ? AppColors.success : AppColors.cancelled,
),
),
),
],
),
);
}
Widget _filesCard() {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.inProgressBg,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppColors.inProgress.withValues(alpha: 0.2)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
const Icon(Icons.folder_open_outlined, size: 15, color: AppColors.inProgress),
const SizedBox(width: 6),
Text(
'${_files.length} dosya',
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w700, color: AppColors.inProgress),
),
],
),
const SizedBox(height: 8),
..._files.map((f) => _FileDownloadRow(file: f)),
],
),
);
}
}
// ── File download row ─────────────────────────────────────────────────────────
class _FileDownloadRow extends StatefulWidget {
const _FileDownloadRow({required this.file});
final JobFile file;
@override
State<_FileDownloadRow> createState() => _FileDownloadRowState();
}
class _FileDownloadRowState extends State<_FileDownloadRow> {
bool _downloading = false;
IconData get _icon => switch (widget.file.kind) {
JobFileKind.scan => Icons.view_in_ar_rounded,
JobFileKind.image => Icons.image_outlined,
JobFileKind.document => Icons.description_outlined,
};
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(
children: [
Icon(_icon, size: 14, color: AppColors.inProgress),
const SizedBox(width: 6),
Expanded(
child: Text(
widget.file.name,
style: const TextStyle(fontSize: 12, color: AppColors.textPrimary),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 4),
Text(
widget.file.sizeLabel,
style: const TextStyle(fontSize: 11, color: AppColors.textMuted),
),
const SizedBox(width: 6),
_downloading
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 1.5, color: AppColors.inProgress),
)
: GestureDetector(
onTap: () async {
setState(() => _downloading = true);
await FileDownloadHelper.download(context, widget.file);
if (mounted) setState(() => _downloading = false);
},
child: const Icon(Icons.download_outlined, size: 18, color: AppColors.inProgress),
),
],
),
);
}
}
// ── Simple markdown renderer ──────────────────────────────────────────────────
class _MarkdownText extends StatelessWidget {
const _MarkdownText(this.text, {required this.color});
final String text;
final Color color;
@override
Widget build(BuildContext context) {
if (text.isEmpty) return const SizedBox.shrink();
final lines = text.split('\n');
final widgets = <Widget>[];
bool prevEmpty = false;
for (final raw in lines) {
final line = raw.trimRight();
if (line.isEmpty) {
if (!prevEmpty) widgets.add(const SizedBox(height: 6));
prevEmpty = true;
continue;
}
prevEmpty = false;
// Header ## or ###
if (line.startsWith('## ') || line.startsWith('### ')) {
final content = line.replaceFirst(RegExp(r'^#{2,3}\s+'), '');
widgets.add(Padding(
padding: const EdgeInsets.only(top: 4, bottom: 2),
child: Text(
content,
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w700, color: color),
),
));
continue;
}
// Bullet
if (line.startsWith('- ') || line.startsWith('')) {
final content = line.substring(2);
widgets.add(Padding(
padding: const EdgeInsets.only(left: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('', style: TextStyle(color: color, height: 1.5, fontSize: 14)),
Expanded(child: _inlineText(content, color)),
],
),
));
continue;
}
widgets.add(_inlineText(line, color));
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: widgets,
);
}
Widget _inlineText(String text, Color baseColor) {
final spans = <InlineSpan>[];
final boldPattern = RegExp(r'\*\*(.+?)\*\*');
int last = 0;
for (final m in boldPattern.allMatches(text)) {
if (m.start > last) {
spans.add(TextSpan(text: text.substring(last, m.start)));
}
spans.add(TextSpan(
text: m.group(1),
style: const TextStyle(fontWeight: FontWeight.w700),
));
last = m.end;
}
if (last < text.length) spans.add(TextSpan(text: text.substring(last)));
return RichText(
text: TextSpan(
style: TextStyle(fontSize: 14, height: 1.5, color: baseColor),
children: spans,
),
);
}
}
// ── Typing dots ───────────────────────────────────────────────────────────────
class _TypingDots extends StatefulWidget {
const _TypingDots();
@override
State<_TypingDots> createState() => _TypingDotsState();
}
class _TypingDotsState extends State<_TypingDots> with SingleTickerProviderStateMixin {
late final AnimationController _ctrl;
@override
void initState() {
super.initState();
_ctrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 900))..repeat();
}
@override
void dispose() { _ctrl.dispose(); super.dispose(); }
@override
Widget build(BuildContext context) {
return SizedBox(
height: 18,
child: AnimatedBuilder(
animation: _ctrl,
builder: (_, __) => Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(3, (i) {
final t = ((_ctrl.value * 3) - i).clamp(0.0, 1.0);
final opacity = (t < 0.5 ? t * 2 : (1 - t) * 2).clamp(0.3, 1.0);
return Container(
width: 6, height: 6,
margin: const EdgeInsets.symmetric(horizontal: 2),
decoration: BoxDecoration(
color: AppColors.accent.withValues(alpha: opacity),
shape: BoxShape.circle,
),
);
}),
),
),
);
}
}
// ── Input bar ─────────────────────────────────────────────────────────────────
class _InputBar extends StatelessWidget {
const _InputBar({
required this.controller,
required this.enabled,
required this.streaming,
required this.onSend,
});
final TextEditingController controller;
final bool enabled;
final bool streaming;
final VoidCallback onSend;
@override
Widget build(BuildContext context) {
final bottom = MediaQuery.paddingOf(context).bottom;
return Container(
padding: EdgeInsets.fromLTRB(12, 8, 12, 8 + bottom),
decoration: BoxDecoration(
color: AppColors.surface,
border: Border(top: BorderSide(color: AppColors.border)),
),
child: Row(
children: [
Expanded(
child: TextField(
controller: controller,
enabled: enabled,
maxLines: 4,
minLines: 1,
textInputAction: TextInputAction.send,
onSubmitted: (_) => onSend(),
decoration: InputDecoration(
hintText: streaming ? 'Yanit bekleniyor...' : 'Bir sey sorun...',
hintStyle: const TextStyle(color: AppColors.textMuted),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(22),
borderSide: BorderSide(color: AppColors.border),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(22),
borderSide: BorderSide(color: AppColors.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(22),
borderSide: const BorderSide(color: AppColors.accent, width: 1.5),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
isDense: true,
),
),
),
const SizedBox(width: 8),
GestureDetector(
onTap: enabled ? onSend : null,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: 40,
height: 40,
decoration: BoxDecoration(
color: enabled ? AppColors.accent : AppColors.border,
shape: BoxShape.circle,
),
child: Center(
child: streaming
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
)
: const Icon(Icons.arrow_upward_rounded, color: Colors.white, size: 18),
),
),
),
],
),
);
}
}
// ── Loading context ───────────────────────────────────────────────────────────
class _LoadingContext extends StatelessWidget {
const _LoadingContext();
@override
Widget build(BuildContext context) {
return const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(color: AppColors.accent, strokeWidth: 2),
SizedBox(height: 12),
Text('Veriler yukleniyor...', style: TextStyle(color: AppColors.textSecondary, fontSize: 13)),
],
),
);
}
}
+619
View File
@@ -0,0 +1,619 @@
import 'dart:async';
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
import '../../core/api/pocketbase_client.dart';
import '../../core/theme/app_theme.dart';
import '../../models/job.dart';
import '../../models/job_file.dart';
import 'job_files_repository.dart';
const _maxFileSizeBytes = 50 * 1024 * 1024; // 50 MB per file
const _maxFilesPerJob = 10;
class JobFilesPanel extends StatefulWidget {
const JobFilesPanel({
super.key,
required this.job,
required this.filesFuture,
required this.onRefresh,
});
final Job job;
final Future<List<JobFile>> filesFuture;
final VoidCallback onRefresh;
@override
State<JobFilesPanel> createState() => _JobFilesPanelState();
}
class _JobFilesPanelState extends State<JobFilesPanel> {
_UploadState? _upload;
List<JobFile>? _files;
bool _loadingFiles = false;
String? _filesError;
@override
void initState() {
super.initState();
_subscribeToFuture(widget.filesFuture);
}
@override
void didUpdateWidget(JobFilesPanel oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.filesFuture != widget.filesFuture) {
_subscribeToFuture(widget.filesFuture);
}
}
void _subscribeToFuture(Future<List<JobFile>> future) {
setState(() { _loadingFiles = true; _filesError = null; });
future.then((files) {
if (mounted) setState(() { _files = files; _loadingFiles = false; });
}).catchError((e) {
if (mounted) setState(() { _filesError = _friendlyError(e); _loadingFiles = false; });
});
}
static String _friendlyError(Object e) {
final s = e.toString();
// Strip full ClientException URL dumps — show only the message part
final msgMatch = RegExp(r'message: ([^,}]+)').firstMatch(s);
if (msgMatch != null) return msgMatch.group(1)!.trim();
if (s.length > 100) return 'Sunucu hatası';
return s;
}
Future<void> _pickAndUpload() async {
final result = await FilePicker.platform.pickFiles(
allowMultiple: true,
withData: true,
type: FileType.custom,
allowedExtensions: [
'pdf', 'jpg', 'jpeg', 'png', 'webp',
'stl', 'obj', 'ply', 'zip', 'opus', 'mp3', 'mp4'
],
);
if (result == null || result.files.isEmpty || !mounted) return;
// Client-side size validation
for (final pf in result.files) {
if (pf.size > _maxFileSizeBytes) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('${pf.name} 50 MB sınırını aşıyor (${_formatSize(pf.size)}).'),
backgroundColor: AppColors.cancelled,
));
return;
}
}
final existingCount = _files?.length ?? 0;
final remaining = _maxFilesPerJob - existingCount;
if (result.files.length > remaining) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('Bu iş en fazla $_maxFilesPerJob dosya alabilir. Şu an $existingCount dosya var; en fazla $remaining dosya daha ekleyebilirsiniz.'),
backgroundColor: AppColors.cancelled,
));
return;
}
int uploadedCount = 0;
for (var i = 0; i < result.files.length; i++) {
final pf = result.files[i];
if (pf.bytes == null) continue;
setState(() {
_upload = _UploadState(
fileName: pf.name,
fileIndex: i + 1,
totalFiles: result.files.length,
progress: 0,
speedBytesPerSec: 0,
);
});
try {
await _uploadWithProgress(
pf: pf,
onProgress: (progress, speed) {
if (mounted) {
setState(() {
_upload = _upload?.copyWith(progress: progress, speedBytesPerSec: speed);
});
}
},
);
uploadedCount++;
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${pf.name} yüklenemedi: ${_friendlyError(e)}'), backgroundColor: AppColors.cancelled),
);
}
setState(() => _upload = null);
return;
}
}
setState(() => _upload = null);
widget.onRefresh();
if (mounted && uploadedCount > 0) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('$uploadedCount dosya yüklendi.')),
);
}
}
Future<void> _uploadWithProgress({
required PlatformFile pf,
required void Function(double progress, double speedBytesPerSec) onProgress,
}) async {
final bytes = pf.bytes!;
final pb = PocketBaseClient.instance.pb;
final baseUrl = 'https://pocket.kovaksoft.com';
final uri = Uri.parse('$baseUrl/api/collections/job_files/records');
final ext = (pf.extension ?? '').toLowerCase();
final kind = (ext == 'stl' || ext == 'obj' || ext == 'ply')
? JobFileKind.scan
: (ext == 'pdf') ? JobFileKind.document : JobFileKind.image;
final mimeType = _mimeFromExt(ext);
final currentUserId = (pb.authStore.record?.id) ?? '';
final token = pb.authStore.token;
final startTime = DateTime.now().millisecondsSinceEpoch;
int sentBytes = 0;
Stream<List<int>> progressStream(List<int> src) async* {
const chunkSize = 65536;
var offset = 0;
while (offset < src.length) {
final end = (offset + chunkSize).clamp(0, src.length);
final chunk = src.sublist(offset, end);
yield chunk;
offset = end;
sentBytes = offset;
final elapsedMs = DateTime.now().millisecondsSinceEpoch - startTime;
final speed = elapsedMs > 0 ? sentBytes / elapsedMs * 1000 : 0.0;
onProgress(sentBytes / src.length, speed);
// yield control so Flutter can rebuild
await Future<void>.delayed(Duration.zero);
}
}
final request = http.MultipartRequest('POST', uri)
..headers['Authorization'] = 'Bearer $token'
..fields['job_id'] = widget.job.id
..fields['clinic_tenant_id'] = widget.job.clinicTenantId
..fields['lab_tenant_id'] = widget.job.labTenantId
..fields['uploaded_by'] = currentUserId
..fields['kind'] = kind.value
..fields['name'] = pf.name
..fields['size'] = bytes.length.toString()
..fields['mime_type'] = mimeType
..files.add(http.MultipartFile(
'file',
http.ByteStream(progressStream(bytes)),
bytes.length,
filename: pf.name,
));
final streamed = await request.send();
final body = await streamed.stream.bytesToString();
if (streamed.statusCode < 200 || streamed.statusCode >= 300) {
String msg = 'HTTP ${streamed.statusCode}';
try {
final j = jsonDecode(body) as Map<String, dynamic>;
msg = j['message'] as String? ?? msg;
} catch (_) {}
throw Exception(msg);
}
}
Future<void> _bulkDownload(List<JobFile> files) async {
if (files.isEmpty) return;
final messenger = ScaffoldMessenger.of(context);
try {
final pb = PocketBaseClient.instance.pb;
final fileToken = await pb.files.getToken();
final dir = await getTemporaryDirectory();
await dir.create(recursive: true);
// Download all files in parallel
final results = await Future.wait(
files.where((f) => f.downloadUrl.isNotEmpty).map((file) async {
final uri = Uri.parse('${file.downloadUrl}?token=$fileToken');
final response = await http.get(uri);
if (response.statusCode != 200) return null;
final path = '${dir.path}/${file.name}';
await File(path).writeAsBytes(response.bodyBytes);
return XFile(path, mimeType: file.mimeType ?? 'application/octet-stream');
}),
);
final xFiles = results.whereType<XFile>().toList();
if (xFiles.isEmpty) return;
await Share.shareXFiles(
xFiles,
subject: '${widget.job.patientCode} dosyaları',
sharePositionOrigin: const Rect.fromLTWH(100, 100, 200, 1),
);
} catch (e) {
if (mounted) {
messenger.showSnackBar(
SnackBar(content: Text('İndirilemedi: $e'), backgroundColor: AppColors.cancelled),
);
}
}
}
Future<void> _deleteFile(JobFile file) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Dosyayı Sil'),
content: Text(file.name),
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 || !mounted) return;
// Optimistic: remove immediately from local list
setState(() => _files = _files?.where((f) => f.id != file.id).toList());
try {
await JobFilesRepository.instance.deleteFile(file.id);
} catch (e) {
final is404 = e.toString().contains('404') || e.toString().contains('wasn\'t found');
if (!is404) {
// Revert only on transient errors (network, 500) — not when already deleted
setState(() => _files = [...?_files, file]);
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(is404 ? 'Dosya zaten silinmiş.' : 'Silinemedi: ${_friendlyError(e)}'),
backgroundColor: AppColors.cancelled,
),
);
}
}
}
Future<void> _downloadFile(JobFile file, Rect shareOrigin) async {
if (file.downloadUrl.isEmpty) return;
final messenger = ScaffoldMessenger.of(context);
try {
final pb = PocketBaseClient.instance.pb;
final fileToken = await pb.files.getToken();
final uri = Uri.parse('${file.downloadUrl}?token=$fileToken');
final response = await http.get(uri);
if (response.statusCode != 200) {
throw Exception('HTTP ${response.statusCode}');
}
final dir = await getTemporaryDirectory();
await dir.create(recursive: true);
final path = '${dir.path}/${file.name}';
await File(path).writeAsBytes(response.bodyBytes);
await Share.shareXFiles(
[XFile(path, mimeType: file.mimeType ?? 'application/octet-stream')],
subject: file.name,
sharePositionOrigin: shareOrigin,
);
} catch (e) {
if (mounted) {
messenger.showSnackBar(
SnackBar(
content: Text('İndirilemedi: $e'),
backgroundColor: AppColors.cancelled,
),
);
}
}
}
@override
Widget build(BuildContext context) {
final uploading = _upload != null;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.attach_file_rounded, size: 18, color: AppColors.accent),
const SizedBox(width: 6),
const Expanded(
child: Text('Dosyalar', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14)),
),
if (!uploading) ...[
if ((_files?.length ?? 0) >= 2)
TextButton.icon(
onPressed: () => _bulkDownload(_files!),
icon: const Icon(Icons.download_for_offline_outlined, size: 16),
label: const Text('Hepsini İndir'),
style: TextButton.styleFrom(
foregroundColor: AppColors.accent,
padding: const EdgeInsets.symmetric(horizontal: 8),
),
),
TextButton.icon(
onPressed: _pickAndUpload,
icon: const Icon(Icons.upload_rounded, size: 16),
label: const Text('Yükle'),
style: TextButton.styleFrom(
foregroundColor: AppColors.accent,
padding: const EdgeInsets.symmetric(horizontal: 8),
),
),
],
],
),
if (_upload != null) ...[
const SizedBox(height: 10),
_UploadProgressBar(state: _upload!),
],
const SizedBox(height: 8),
Builder(builder: (ctx) {
if (_loadingFiles && _files == null) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 12),
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
);
}
if (_filesError != null && _files == null) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
'Dosyalar yüklenemedi: $_filesError',
style: const TextStyle(color: AppColors.cancelled, fontSize: 12),
),
);
}
final files = _files ?? [];
if (files.isEmpty && !uploading) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 8),
child: Text(
'Henüz dosya eklenmemiş.',
style: TextStyle(color: AppColors.textMuted, fontSize: 13),
),
);
}
return Column(
children: files
.map((f) => _FileRow(
file: f,
onDelete: () => _deleteFile(f),
onDownload: (origin) => _downloadFile(f, origin),
))
.toList(),
);
},
),
],
),
);
}
static String _formatSize(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
static String _mimeFromExt(String ext) => switch (ext) {
'jpg' || 'jpeg' => 'image/jpeg',
'png' => 'image/png',
'webp' => 'image/webp',
'pdf' => 'application/pdf',
'stl' => 'model/stl',
'zip' => 'application/zip',
'mp3' => 'audio/mpeg',
'mp4' => 'video/mp4',
'opus' => 'audio/opus',
_ => 'application/octet-stream',
};
}
// ── Upload Progress Bar ───────────────────────────────────────────────────────
class _UploadProgressBar extends StatelessWidget {
const _UploadProgressBar({required this.state});
final _UploadState state;
@override
Widget build(BuildContext context) {
final pct = (state.progress * 100).toStringAsFixed(0);
final speed = state.speedBytesPerSec;
final speedLabel = speed >= 1024 * 1024
? '${(speed / 1024 / 1024).toStringAsFixed(1)} MB/s'
: '${(speed / 1024).toStringAsFixed(0)} KB/s';
final fileLabel = state.totalFiles > 1
? '${state.fileIndex}/${state.totalFiles}${state.fileName}'
: state.fileName;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
fileLabel,
style: const TextStyle(fontSize: 12, color: AppColors.textSecondary),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
Text(
'$pct% · $speedLabel',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColors.accent,
fontFeatures: [FontFeature.tabularFigures()],
),
),
],
),
const SizedBox(height: 6),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: state.progress,
minHeight: 6,
backgroundColor: AppColors.background,
valueColor: const AlwaysStoppedAnimation<Color>(AppColors.accent),
),
),
],
);
}
}
// ── File Row ──────────────────────────────────────────────────────────────────
class _FileRow extends StatefulWidget {
const _FileRow({
required this.file,
required this.onDelete,
required this.onDownload,
});
final JobFile file;
final VoidCallback onDelete;
final Future<void> Function(Rect) onDownload;
@override
State<_FileRow> createState() => _FileRowState();
}
class _FileRowState extends State<_FileRow> {
bool _downloading = false;
IconData get _icon => switch (widget.file.kind) {
JobFileKind.scan => Icons.view_in_ar_rounded,
JobFileKind.image => Icons.image_outlined,
JobFileKind.document => Icons.description_outlined,
};
final _downloadKey = GlobalKey();
Future<void> _handleDownload() async {
setState(() => _downloading = true);
final box = _downloadKey.currentContext?.findRenderObject() as RenderBox?;
final origin = box != null
? box.localToGlobal(Offset.zero) & box.size
: const Rect.fromLTWH(100, 100, 200, 1);
try {
await widget.onDownload(origin);
} finally {
if (mounted) setState(() => _downloading = false);
}
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 5),
child: Row(
children: [
Icon(_icon, size: 18, color: AppColors.textMuted),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.file.name,
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
'${widget.file.kind.label} · ${widget.file.sizeLabel}',
style: const TextStyle(fontSize: 11, color: AppColors.textMuted),
),
],
),
),
if (_downloading)
const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2, color: AppColors.accent),
)
else
IconButton(
key: _downloadKey,
onPressed: _handleDownload,
icon: const Icon(Icons.download_outlined, size: 18, color: AppColors.accent),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
tooltip: 'İndir',
),
const SizedBox(width: 4),
IconButton(
onPressed: widget.onDelete,
icon: const Icon(Icons.delete_outline_rounded, size: 18, color: AppColors.cancelled),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
tooltip: 'Sil',
),
],
),
);
}
}
// ── Upload State ──────────────────────────────────────────────────────────────
class _UploadState {
const _UploadState({
required this.fileName,
required this.fileIndex,
required this.totalFiles,
required this.progress,
required this.speedBytesPerSec,
});
final String fileName;
final int fileIndex;
final int totalFiles;
final double progress;
final double speedBytesPerSec;
_UploadState copyWith({double? progress, double? speedBytesPerSec}) =>
_UploadState(
fileName: fileName,
fileIndex: fileIndex,
totalFiles: totalFiles,
progress: progress ?? this.progress,
speedBytesPerSec: speedBytesPerSec ?? this.speedBytesPerSec,
);
}
@@ -0,0 +1,59 @@
import 'package:http/http.dart' as http;
import 'package:pocketbase/pocketbase.dart';
import '../../core/api/pocketbase_client.dart';
import '../../models/job_file.dart';
class JobFilesRepository {
JobFilesRepository._();
static final instance = JobFilesRepository._();
PocketBase get _pb => PocketBaseClient.instance.pb;
static const _baseUrl = 'https://pocket.kovaksoft.com';
String get _currentUserId => (_pb.authStore.record?.id) ?? '';
Future<List<JobFile>> listForJob(String jobId) async {
final result = await _pb.collection('job_files').getList(
filter: 'job_id = "$jobId"',
perPage: 200,
);
final files = result.items.map((r) => JobFile.fromJson(r.toJson(), _baseUrl)).toList()
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
return files;
}
Future<JobFile> uploadFile({
required String jobId,
required String clinicTenantId,
required String labTenantId,
required JobFileKind kind,
required String name,
required int size,
required List<int> bytes,
String? mimeType,
}) async {
final multipartFile = http.MultipartFile.fromBytes(
'file',
bytes,
filename: name,
);
final record = await _pb.collection('job_files').create(
body: {
'job_id': jobId,
'clinic_tenant_id': clinicTenantId,
'lab_tenant_id': labTenantId,
'uploaded_by': _currentUserId,
'kind': kind.value,
'name': name,
'size': size,
if (mimeType != null) 'mime_type': mimeType,
},
files: [multipartFile],
);
return JobFile.fromJson(record.toJson(), _baseUrl);
}
Future<void> deleteFile(String fileId) async {
await _pb.collection('job_files').delete(fileId);
}
}
+325
View File
@@ -0,0 +1,325 @@
import 'package:pocketbase/pocketbase.dart';
import '../../core/api/pocketbase_client.dart';
import '../../models/tenant.dart';
// ── Value objects ─────────────────────────────────────────────────────────────
const _monthLabels = ['Oca','Şub','Mar','Nis','May','Haz','Tem','Ağu','Eyl','Eki','Kas','Ara'];
class MonthlyCount {
const MonthlyCount({required this.year, required this.month, required this.count});
final int year, month, count;
String get label => _monthLabels[month - 1];
}
class MonthlyAmount {
const MonthlyAmount({required this.year, required this.month, required this.amount});
final int year, month;
final double amount;
String get label => _monthLabels[month - 1];
}
class CounterpartStat {
const CounterpartStat({required this.name, required this.jobCount, required this.pendingRevenue, required this.paidRevenue});
final String name;
final int jobCount;
final double pendingRevenue, paidRevenue;
double get totalRevenue => pendingRevenue + paidRevenue;
}
class ActivityItem {
const ActivityItem({required this.jobId, this.patientCode, required this.action, required this.createdAt, this.note});
final String jobId, action;
final String? patientCode, note;
final DateTime createdAt;
String get actionLabel => switch (action) {
'accepted' => 'İş kabul edildi',
'handed_to_clinic' => 'Provaya gönderildi',
'approved' => 'Onaylandı',
'revision_requested' => 'Revizyon istendi',
'delivered' => 'Teslim edildi',
'cancelled' => 'İptal edildi',
_ => action,
};
bool get isNegative => action == 'revision_requested' || action == 'cancelled';
bool get isPositive => action == 'delivered' || action == 'approved' || action == 'accepted';
}
// ── Aggregated metrics ────────────────────────────────────────────────────────
class ReportMetrics {
const ReportMetrics({
required this.activeJobs,
required this.completedThisMonth,
required this.overdueJobs,
required this.revisionRate,
required this.avgCompletionDays,
required this.totalRevenue,
required this.pendingRevenue,
required this.currency,
required this.jobsByStatus,
required this.monthlyCounts,
required this.monthlyRevenue,
required this.byProstheticType,
required this.counterpartStats,
required this.recentActivity,
});
final int activeJobs;
final int completedThisMonth;
final int overdueJobs;
final double revisionRate; // 0-100
final double avgCompletionDays;
final double totalRevenue;
final double pendingRevenue;
final String currency;
final Map<String, int> jobsByStatus;
final List<MonthlyCount> monthlyCounts;
final List<MonthlyAmount> monthlyRevenue;
final Map<String, int> byProstheticType;
final List<CounterpartStat> counterpartStats;
final List<ActivityItem> recentActivity;
}
// ── Repository ────────────────────────────────────────────────────────────────
class ReportsRepository {
ReportsRepository._();
static final instance = ReportsRepository._();
PocketBase get _pb => PocketBaseClient.instance.pb;
Future<ReportMetrics> load(String tenantId, TenantKind kind) async {
final jobFilter = kind == TenantKind.lab
? 'lab_tenant_id = "$tenantId"'
: 'clinic_tenant_id = "$tenantId"';
final historyFilter = kind == TenantKind.lab
? 'lab_tenant_id = "$tenantId"'
: 'clinic_tenant_id = "$tenantId"';
final results = await Future.wait([
_pb.collection('jobs').getList(
filter: jobFilter,
perPage: 500,
expand: kind == TenantKind.lab ? 'clinic_tenant_id' : 'lab_tenant_id',
fields: 'id,status,prosthetic_type,clinic_tenant_id,lab_tenant_id,created,updated,due_date,price,currency,expand',
).catchError((_) => ResultList<RecordModel>()),
_pb.collection('finance_entries').getList(
filter: 'tenant_id = "$tenantId"',
perPage: 300,
fields: 'id,amount,currency,status,created,counterparty_name',
).catchError((_) => ResultList<RecordModel>()),
_pb.collection('job_status_history').getList(
filter: historyFilter,
perPage: 100,
expand: 'job_id',
fields: 'id,action_type,created,note,job_id,expand',
).catchError((_) => ResultList<RecordModel>()),
]);
final jobRecords = (results[0] as ResultList<RecordModel>).items;
final financeRecords = (results[1] as ResultList<RecordModel>).items;
final historyRecords = (results[2] as ResultList<RecordModel>).items;
return _aggregate(tenantId, kind, jobRecords, financeRecords, historyRecords);
}
ReportMetrics _aggregate(
String tenantId,
TenantKind kind,
List<RecordModel> jobRecords,
List<RecordModel> financeRecords,
List<RecordModel> historyRecords,
) {
final now = DateTime.now();
final thisMonthStart = DateTime(now.year, now.month, 1);
// ── Parse jobs ────────────────────────────────────────────────────────────
String _s(Map<String, dynamic> j, String k) {
final v = j[k];
if (v == null || v == '') return '';
return v.toString();
}
final jobs = jobRecords.map((r) {
final j = r.toJson();
final exp = j['expand'] as Map<String, dynamic>?;
final cpKey = kind == TenantKind.lab ? 'clinic_tenant_id' : 'lab_tenant_id';
final cpExp = exp?[cpKey] as Map<String, dynamic>?;
return _RawJob(
id: _s(j, 'id'),
status: _s(j, 'status'),
prostheticType: _s(j, 'prosthetic_type'),
clinicTenantId: _s(j, 'clinic_tenant_id'),
labTenantId: _s(j, 'lab_tenant_id'),
created: _parseDate(j['created']),
updated: _parseDate(j['updated']),
dueDate: j['due_date'] != null && j['due_date'] != '' ? _parseDate(j['due_date']) : null,
currency: _s(j, 'currency').isNotEmpty ? _s(j, 'currency') : 'TRY',
counterpartName: cpExp?['company_name'] as String? ?? '',
);
}).toList();
// ── Parse finance ─────────────────────────────────────────────────────────
String topCurrency = 'TRY';
final financeList = financeRecords.map((r) {
final j = r.toJson();
final cur = (j['currency'] as String?) ?? 'TRY';
if (cur.isNotEmpty) topCurrency = cur;
return _RawFinance(
status: (j['status'] as String?) ?? '',
amount: (j['amount'] as num?)?.toDouble() ?? 0,
created: _parseDate(j['created']),
counterpartyName: j['counterparty_name'] as String?,
);
}).toList();
// ── Parse history ─────────────────────────────────────────────────────────
final activity = historyRecords.map((r) {
final j = r.toJson();
final exp = j['expand'] as Map<String, dynamic>?;
final jobExp = exp?['job_id'] as Map<String, dynamic>?;
return ActivityItem(
jobId: (j['job_id'] as String?) ?? '',
patientCode: jobExp?['patient_code'] as String?,
action: (j['action_type'] as String?) ?? '',
createdAt: _parseDate(j['created']),
note: j['note'] as String?,
);
}).toList()
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
// ── KPI metrics ───────────────────────────────────────────────────────────
final activeJobs = jobs.where((j) => j.status == 'in_progress' || j.status == 'pending').length;
final completedThisMonth = jobs.where((j) => j.status == 'delivered' && j.updated.isAfter(thisMonthStart)).length;
final overdueJobs = jobs.where((j) =>
j.dueDate != null &&
j.dueDate!.isBefore(now) &&
(j.status == 'in_progress' || j.status == 'pending')).length;
final revisions = activity.where((a) => a.action == 'revision_requested').length;
final revisionRate = activity.isNotEmpty ? revisions / activity.length * 100 : 0.0;
final deliveredJobs = jobs.where((j) => j.status == 'delivered').toList();
final avgCompletionDays = deliveredJobs.isNotEmpty
? deliveredJobs
.fold<int>(0, (s, j) => s + j.updated.difference(j.created).inDays) /
deliveredJobs.length
: 0.0;
// ── Finance totals ────────────────────────────────────────────────────────
final totalRevenue = financeList.where((f) => f.status == 'paid').fold<double>(0, (s, f) => s + f.amount);
final pendingRevenue = financeList.where((f) => f.status == 'pending').fold<double>(0, (s, f) => s + f.amount);
// ── Job status distribution ───────────────────────────────────────────────
final Map<String, int> jobsByStatus = {};
for (final j in jobs) {
jobsByStatus[j.status] = (jobsByStatus[j.status] ?? 0) + 1;
}
// ── Monthly job counts (last 6 months) ────────────────────────────────────
final monthKeys = List.generate(6, (i) {
final d = DateTime(now.year, now.month - 5 + i, 1);
return '${d.year}-${d.month}';
});
final monthMap = {for (final k in monthKeys) k: 0};
for (final j in jobs) {
final key = '${j.created.year}-${j.created.month}';
if (monthMap.containsKey(key)) monthMap[key] = monthMap[key]! + 1;
}
final monthlyCounts = monthKeys.map((k) {
final parts = k.split('-');
return MonthlyCount(year: int.parse(parts[0]), month: int.parse(parts[1]), count: monthMap[k]!);
}).toList();
// ── Monthly revenue (last 6 months) ──────────────────────────────────────
final revMap = {for (final k in monthKeys) k: 0.0};
for (final f in financeList) {
if (f.status == 'paid') {
final key = '${f.created.year}-${f.created.month}';
if (revMap.containsKey(key)) revMap[key] = revMap[key]! + f.amount;
}
}
final monthlyRevenue = monthKeys.map((k) {
final parts = k.split('-');
return MonthlyAmount(year: int.parse(parts[0]), month: int.parse(parts[1]), amount: revMap[k]!);
}).toList();
// ── By prosthetic type ────────────────────────────────────────────────────
final Map<String, int> byType = {};
for (final j in jobs) {
if (j.prostheticType.isNotEmpty) {
byType[j.prostheticType] = (byType[j.prostheticType] ?? 0) + 1;
}
}
// ── By counterpart ────────────────────────────────────────────────────────
final Map<String, int> cpCount = {};
final Map<String, double> cpPending = {}, cpPaid = {};
for (final j in jobs) {
final name = j.counterpartName.isNotEmpty ? j.counterpartName : '';
cpCount[name] = (cpCount[name] ?? 0) + 1;
}
for (final f in financeList) {
final name = f.counterpartyName ?? '';
if (f.status == 'pending') cpPending[name] = (cpPending[name] ?? 0) + f.amount;
if (f.status == 'paid') cpPaid[name] = (cpPaid[name] ?? 0) + f.amount;
}
final counterparts = cpCount.entries
.map((e) => CounterpartStat(
name: e.key,
jobCount: e.value,
pendingRevenue: cpPending[e.key] ?? 0,
paidRevenue: cpPaid[e.key] ?? 0,
))
.toList()
..sort((a, b) => b.jobCount.compareTo(a.jobCount));
return ReportMetrics(
activeJobs: activeJobs,
completedThisMonth: completedThisMonth,
overdueJobs: overdueJobs,
revisionRate: revisionRate,
avgCompletionDays: avgCompletionDays,
totalRevenue: totalRevenue,
pendingRevenue: pendingRevenue,
currency: topCurrency,
jobsByStatus: jobsByStatus,
monthlyCounts: monthlyCounts,
monthlyRevenue: monthlyRevenue,
byProstheticType: byType,
counterpartStats: counterparts.take(5).toList(),
recentActivity: activity.take(30).toList(),
);
}
static DateTime _parseDate(dynamic v) {
if (v == null || v == '') return DateTime(2000);
return DateTime.tryParse(v.toString()) ?? DateTime(2000);
}
}
// ── Internal raw models ───────────────────────────────────────────────────────
class _RawJob {
const _RawJob({
required this.id, required this.status, required this.prostheticType,
required this.clinicTenantId, required this.labTenantId,
required this.created, required this.updated,
required this.currency, required this.counterpartName, this.dueDate,
});
final String id, status, prostheticType, clinicTenantId, labTenantId, currency, counterpartName;
final DateTime created, updated;
final DateTime? dueDate;
}
class _RawFinance {
const _RawFinance({required this.status, required this.amount, required this.created, this.counterpartyName});
final String status;
final double amount;
final DateTime created;
final String? counterpartyName;
}
+690
View File
@@ -0,0 +1,690 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import '../../core/providers/auth_provider.dart';
import '../../core/theme/app_theme.dart';
import '../../core/widgets/gradient_app_bar.dart';
import '../../models/job.dart';
import '../../models/tenant.dart';
import 'reports_repository.dart';
class ReportsScreen extends ConsumerStatefulWidget {
const ReportsScreen({super.key});
@override
ConsumerState<ReportsScreen> createState() => _ReportsScreenState();
}
class _ReportsScreenState extends ConsumerState<ReportsScreen>
with SingleTickerProviderStateMixin {
late TabController _tabController;
late Future<ReportMetrics> _future;
@override
void initState() {
super.initState();
_tabController = TabController(length: 4, vsync: this);
_tabController.addListener(() { if (mounted) setState(() {}); });
_load();
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
void _load() {
final auth = ref.read(authProvider);
final tenantId = auth.activeTenant!.tenant.id;
final kind = auth.activeTenant!.tenant.kind;
setState(() {
_future = ReportsRepository.instance.load(tenantId, kind);
});
}
@override
Widget build(BuildContext context) {
final kind = ref.watch(authProvider).activeTenant?.tenant.kind ?? TenantKind.lab;
final counterpartLabel = kind == TenantKind.lab ? 'Klinikler' : 'Laboratuvarlar';
return Scaffold(
backgroundColor: AppColors.background,
appBar: GradientAppBar(
title: 'Raporlar',
category: 'YÖNETİCİ',
actions: [
IconButton(
icon: const Icon(Icons.refresh_rounded),
onPressed: _load,
tooltip: 'Yenile',
),
],
),
body: FutureBuilder<ReportMetrics>(
future: _future,
builder: (context, 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.error_outline_rounded, color: AppColors.cancelled, size: 48),
const SizedBox(height: 12),
Text('Veriler yüklenemedi', 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 m = snap.data!;
return Column(
children: [
// Tab bar
Container(
color: AppColors.surface,
child: TabBar(
controller: _tabController,
labelColor: AppColors.accent,
unselectedLabelColor: AppColors.textSecondary,
indicatorColor: AppColors.accent,
indicatorSize: TabBarIndicatorSize.tab,
labelStyle: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
tabs: [
const Tab(text: 'Özet'),
const Tab(text: 'Finans'),
const Tab(text: 'Aktivite'),
Tab(text: counterpartLabel),
],
),
),
Expanded(
child: TabBarView(
controller: _tabController,
children: [
_SummaryTab(metrics: m),
_FinanceTab(metrics: m),
_ActivityTab(metrics: m),
_CounterpartTab(metrics: m, label: counterpartLabel),
],
),
),
],
);
},
),
);
}
}
// ── Shared layout helpers ─────────────────────────────────────────────────────
class _TabBody extends StatelessWidget {
const _TabBody({required this.children});
final List<Widget> children;
@override
Widget build(BuildContext context) => ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
children: children,
);
}
class _Card extends StatelessWidget {
const _Card({required this.child, this.padding = const EdgeInsets.all(16)});
final Widget child;
final EdgeInsets padding;
@override
Widget build(BuildContext context) => Container(
margin: const EdgeInsets.only(bottom: 12),
padding: padding,
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.border),
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.03), blurRadius: 8, offset: const Offset(0, 2))],
),
child: child,
);
}
class _SectionHeader extends StatelessWidget {
const _SectionHeader(this.title, {this.subtitle});
final String title;
final String? subtitle;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.only(bottom: 10, top: 4),
child: Row(
children: [
Expanded(
child: Text(title, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700, color: AppColors.textPrimary)),
),
if (subtitle != null)
Text(subtitle!, style: const TextStyle(fontSize: 12, color: AppColors.textMuted)),
],
),
);
}
// ── KPI Chips ─────────────────────────────────────────────────────────────────
class _KpiRow extends StatelessWidget {
const _KpiRow({required this.metrics});
final ReportMetrics metrics;
@override
Widget build(BuildContext context) {
final fmt = NumberFormat.currency(locale: 'tr_TR', symbol: metrics.currency, decimalDigits: 0);
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
_Kpi(label: 'Aktif İşler', value: '${metrics.activeJobs}', icon: Icons.work_outline_rounded, color: AppColors.inProgress),
const SizedBox(width: 10),
_Kpi(label: 'Bu Ay Tamamlandı', value: '${metrics.completedThisMonth}', icon: Icons.check_circle_outline_rounded, color: AppColors.success),
const SizedBox(width: 10),
_Kpi(label: 'Bekleyen Gelir', value: fmt.format(metrics.pendingRevenue), icon: Icons.hourglass_empty_rounded, color: AppColors.pending),
const SizedBox(width: 10),
_Kpi(label: 'Ort. Süre', value: '${metrics.avgCompletionDays.toStringAsFixed(1)} gün', icon: Icons.timer_outlined, color: AppColors.accent),
const SizedBox(width: 10),
_Kpi(label: 'Revizyon Oranı', value: '%${metrics.revisionRate.toStringAsFixed(0)}', icon: Icons.loop_rounded, color: metrics.revisionRate > 20 ? AppColors.cancelled : AppColors.textSecondary),
if (metrics.overdueJobs > 0) ...[
const SizedBox(width: 10),
_Kpi(label: 'Gecikmiş', value: '${metrics.overdueJobs}', icon: Icons.schedule_rounded, color: AppColors.cancelled),
],
],
),
);
}
}
class _Kpi extends StatelessWidget {
const _Kpi({required this.label, required this.value, required this.icon, required this.color});
final String label, value;
final IconData icon;
final Color color;
@override
Widget build(BuildContext context) => Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
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)],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: color),
const SizedBox(width: 4),
Text(label, style: const TextStyle(fontSize: 11, color: AppColors.textMuted)),
],
),
const SizedBox(height: 6),
Text(value, style: TextStyle(fontSize: 20, fontWeight: FontWeight.w800, color: color)),
],
),
);
}
// ── Özet Tab ──────────────────────────────────────────────────────────────────
class _SummaryTab extends StatelessWidget {
const _SummaryTab({required this.metrics});
final ReportMetrics metrics;
@override
Widget build(BuildContext context) {
return _TabBody(children: [
_KpiRow(metrics: metrics),
const _SectionHeader('İş Durumu Dağılımı'),
_Card(
child: Column(
children: _statusOrder.where((s) => metrics.jobsByStatus.containsKey(s)).map((s) {
final count = metrics.jobsByStatus[s] ?? 0;
final total = metrics.jobsByStatus.values.fold(0, (a, b) => a + b);
return _HBarRow(
label: _statusLabel(s),
value: count,
max: total,
color: _statusColor(s),
);
}).toList(),
),
),
const _SectionHeader('Son 6 Aylık İş Trendi'),
_Card(child: _VBarChart(data: metrics.monthlyCounts, color: AppColors.accent)),
]);
}
static const _statusOrder = ['in_progress', 'pending', 'sent', 'delivered', 'cancelled'];
static String _statusLabel(String s) => switch (s) {
'pending' => 'Bekliyor',
'in_progress' => 'İşlemde',
'sent' => 'Gönderildi',
'delivered' => 'Teslim',
'cancelled' => 'İptal',
_ => s,
};
static Color _statusColor(String s) => switch (s) {
'pending' => AppColors.pending,
'in_progress' => AppColors.inProgress,
'sent' => AppColors.accent,
'delivered' => AppColors.success,
'cancelled' => AppColors.cancelled,
_ => AppColors.textMuted,
};
}
// ── Finans Tab ────────────────────────────────────────────────────────────────
class _FinanceTab extends StatelessWidget {
const _FinanceTab({required this.metrics});
final ReportMetrics metrics;
@override
Widget build(BuildContext context) {
final fmt = NumberFormat.currency(locale: 'tr_TR', symbol: metrics.currency, decimalDigits: 0);
final total = metrics.totalRevenue + metrics.pendingRevenue;
return _TabBody(children: [
const _SectionHeader('Gelir Özeti'),
Row(
children: [
Expanded(
child: _Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Container(width: 8, height: 8, decoration: BoxDecoration(color: AppColors.success, shape: BoxShape.circle)),
const SizedBox(width: 6),
const Text('Tahsil Edildi', style: TextStyle(fontSize: 12, color: AppColors.textMuted)),
]),
const SizedBox(height: 6),
Text(fmt.format(metrics.totalRevenue),
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w800, color: AppColors.success)),
],
),
),
),
const SizedBox(width: 10),
Expanded(
child: _Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Container(width: 8, height: 8, decoration: BoxDecoration(color: AppColors.pending, shape: BoxShape.circle)),
const SizedBox(width: 6),
const Text('Bekleyen', style: TextStyle(fontSize: 12, color: AppColors.textMuted)),
]),
const SizedBox(height: 6),
Text(fmt.format(metrics.pendingRevenue),
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w800, color: AppColors.pending)),
],
),
),
),
],
),
if (total > 0) _Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Tahsilat Oranı', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
const SizedBox(height: 10),
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: LinearProgressIndicator(
value: total > 0 ? metrics.totalRevenue / total : 0,
minHeight: 12,
backgroundColor: AppColors.pendingBg,
valueColor: const AlwaysStoppedAnimation<Color>(AppColors.success),
),
),
const SizedBox(height: 6),
Text('${(metrics.totalRevenue / total * 100).toStringAsFixed(0)}% tahsil edildi',
style: const TextStyle(fontSize: 12, color: AppColors.textMuted)),
],
),
),
const SizedBox(height: 4),
const _SectionHeader('Aylık Gelir Trendi'),
_Card(child: _VBarChart(
data: metrics.monthlyRevenue.map((m) => MonthlyCount(year: m.year, month: m.month, count: m.amount.round())).toList(),
color: AppColors.success,
formatValue: (v) => NumberFormat.compactCurrency(locale: 'tr_TR', symbol: metrics.currency, decimalDigits: 0).format(v),
)),
]);
}
}
// ── Aktivite Tab ──────────────────────────────────────────────────────────────
class _ActivityTab extends StatelessWidget {
const _ActivityTab({required this.metrics});
final ReportMetrics metrics;
@override
Widget build(BuildContext context) {
final items = metrics.recentActivity;
if (items.isEmpty) {
return const Center(
child: Text('Henüz aktivite kaydı yok.', style: TextStyle(color: AppColors.textMuted)),
);
}
return _TabBody(children: [
const _SectionHeader('Son İşlemler'),
_Card(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Column(
children: items.asMap().entries.map((entry) {
final i = entry.key;
final item = entry.value;
return _ActivityRow(item: item, isLast: i == items.length - 1);
}).toList(),
),
),
]);
}
}
class _ActivityRow extends StatelessWidget {
const _ActivityRow({required this.item, required this.isLast});
final ActivityItem item;
final bool isLast;
@override
Widget build(BuildContext context) {
final color = item.isNegative ? AppColors.cancelled : item.isPositive ? AppColors.success : AppColors.accent;
final icon = switch (item.action) {
'accepted' => Icons.check_circle_outline_rounded,
'handed_to_clinic' => Icons.send_rounded,
'approved' => Icons.thumb_up_outlined,
'revision_requested' => Icons.loop_rounded,
'delivered' => Icons.local_shipping_outlined,
'cancelled' => Icons.cancel_outlined,
_ => Icons.history_rounded,
};
final df = DateFormat('dd.MM.yy HH:mm');
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Timeline line + dot
SizedBox(
width: 28,
child: Column(
children: [
Container(
width: 24, height: 24,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
shape: BoxShape.circle,
),
child: Icon(icon, size: 13, color: color),
),
if (!isLast)
Expanded(child: Container(width: 1.5, color: AppColors.border)),
],
),
),
const SizedBox(width: 10),
Expanded(
child: Padding(
padding: EdgeInsets.only(bottom: isLast ? 0 : 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(item.actionLabel,
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
if (item.patientCode != null && item.patientCode!.isNotEmpty)
Text(item.patientCode!, style: const TextStyle(fontSize: 11, color: AppColors.accent)),
if (item.note != null && item.note!.isNotEmpty)
Text(item.note!, style: const TextStyle(fontSize: 11, color: AppColors.textMuted)),
const SizedBox(height: 2),
Text(df.format(item.createdAt), style: const TextStyle(fontSize: 10, color: AppColors.textMuted)),
],
),
),
),
],
),
);
}
}
// ── Counterpart Tab ───────────────────────────────────────────────────────────
class _CounterpartTab extends StatelessWidget {
const _CounterpartTab({required this.metrics, required this.label});
final ReportMetrics metrics;
final String label;
@override
Widget build(BuildContext context) {
final stats = metrics.counterpartStats;
if (stats.isEmpty) {
return Center(
child: Text('$label henüz yok.', style: const TextStyle(color: AppColors.textMuted)),
);
}
final fmt = NumberFormat.currency(locale: 'tr_TR', symbol: metrics.currency, decimalDigits: 0);
final maxJobs = stats.fold(0, (m, s) => s.jobCount > m ? s.jobCount : m);
return _TabBody(children: [
const _SectionHeader('Protez Türü Dağılımı'),
_Card(
child: Column(
children: _buildTypeRows(metrics.byProstheticType),
),
),
_SectionHeader('En Aktif $label'),
_Card(
child: Column(
children: stats.asMap().entries.map((entry) {
final i = entry.key;
final s = entry.value;
return Padding(
padding: EdgeInsets.only(bottom: i < stats.length - 1 ? 12 : 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 22, height: 22,
decoration: BoxDecoration(
color: AppColors.inProgressBg,
shape: BoxShape.circle,
),
child: Center(
child: Text('${i + 1}',
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: AppColors.inProgress)),
),
),
const SizedBox(width: 8),
Expanded(
child: Text(s.name,
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: AppColors.textPrimary),
maxLines: 1, overflow: TextOverflow.ellipsis),
),
Text('${s.jobCount}',
style: const TextStyle(fontSize: 12, color: AppColors.textSecondary, fontWeight: FontWeight.w500)),
],
),
const SizedBox(height: 6),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: maxJobs > 0 ? s.jobCount / maxJobs : 0,
minHeight: 6,
backgroundColor: AppColors.surfaceVariant,
valueColor: const AlwaysStoppedAnimation<Color>(AppColors.accent),
),
),
if (s.totalRevenue > 0) Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
'${fmt.format(s.paidRevenue)} tahsil · ${fmt.format(s.pendingRevenue)} bekliyor',
style: const TextStyle(fontSize: 11, color: AppColors.textMuted),
),
),
],
),
);
}).toList(),
),
),
]);
}
List<Widget> _buildTypeRows(Map<String, int> byType) {
if (byType.isEmpty) return [const Text('Veri yok', style: TextStyle(color: AppColors.textMuted))];
final sorted = byType.entries.toList()..sort((a, b) => b.value.compareTo(a.value));
final max = sorted.first.value;
return sorted.map((e) => _HBarRow(
label: _typeLabel(e.key),
value: e.value,
max: max,
color: AppColors.accent,
)).toList();
}
static String _typeLabel(String s) => switch (s) {
'metal_porselen' => 'Metal Porselen',
'zirkonyum' => 'Zirkonyum',
'implant_ustu_zirkonyum'=> 'İmplant Üstü',
'gecici' => 'Geçici',
'e_max' => 'E-Max',
'tam_protez' => 'Tam Protez',
'parsiyel' => 'Parsiyel',
_ => 'Diğer',
};
}
// ── Chart widgets ─────────────────────────────────────────────────────────────
class _HBarRow extends StatelessWidget {
const _HBarRow({required this.label, required this.value, required this.max, required this.color});
final String label;
final int value, max;
final Color color;
@override
Widget build(BuildContext context) {
final fraction = max > 0 ? value / max : 0.0;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 5),
child: Row(
children: [
SizedBox(
width: 100,
child: Text(label,
style: const TextStyle(fontSize: 12, color: AppColors.textSecondary),
maxLines: 1, overflow: TextOverflow.ellipsis),
),
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Stack(
children: [
Container(height: 22, color: AppColors.surfaceVariant),
FractionallySizedBox(
widthFactor: fraction,
child: Container(
height: 22,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.8),
borderRadius: BorderRadius.circular(4),
),
),
),
],
),
),
),
const SizedBox(width: 8),
SizedBox(
width: 28,
child: Text('$value',
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w700, color: AppColors.textPrimary),
textAlign: TextAlign.right),
),
],
),
);
}
}
class _VBarChart extends StatelessWidget {
const _VBarChart({required this.data, required this.color, this.formatValue});
final List<MonthlyCount> data;
final Color color;
final String Function(int)? formatValue;
@override
Widget build(BuildContext context) {
if (data.isEmpty) return const SizedBox.shrink();
final maxVal = data.fold(0, (m, e) => e.count > m ? e.count : m);
final fmt = formatValue ?? (v) => '$v';
return SizedBox(
height: 140,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: data.map((d) {
final fraction = maxVal > 0 ? d.count / maxVal : 0.0;
return Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (d.count > 0)
Text(fmt(d.count),
style: const TextStyle(fontSize: 9, color: AppColors.textMuted),
textAlign: TextAlign.center),
const SizedBox(height: 2),
AnimatedContainer(
duration: const Duration(milliseconds: 600),
curve: Curves.easeOut,
height: (fraction * 90).clamp(2, 90),
decoration: BoxDecoration(
color: color,
borderRadius: const BorderRadius.vertical(top: Radius.circular(4)),
),
),
const SizedBox(height: 4),
Text(d.label,
style: const TextStyle(fontSize: 10, color: AppColors.textMuted),
textAlign: TextAlign.center),
],
),
),
);
}).toList(),
),
);
}
}
@@ -0,0 +1,82 @@
import 'package:pocketbase/pocketbase.dart';
import '../../core/api/pocketbase_client.dart';
import '../../models/tenant.dart';
import '../../models/user_profile.dart';
class TeamMember {
const TeamMember({
required this.memberId,
required this.user,
required this.role,
required this.joinedAt,
});
final String memberId;
final UserProfile user;
final TenantRole role;
final DateTime joinedAt;
}
class TenantTeamRepository {
TenantTeamRepository._();
static final instance = TenantTeamRepository._();
PocketBase get _pb => PocketBaseClient.instance.pb;
Future<List<TeamMember>> listMembers(String tenantId) async {
final result = await _pb.collection('tenant_members').getList(
filter: 'tenant_id = "$tenantId"',
expand: 'user_id',
perPage: 200,
);
return (result.items.map((r) {
final j = r.toJson();
final userExp = (j['expand'] as Map?)?['user_id'] as Map<String, dynamic>?;
return TeamMember(
memberId: j['id'] as String,
user: UserProfile.fromJson(userExp ?? {'id': j['user_id'], 'email': ''}),
role: TenantMembership.parseRole(j['role'] as String),
joinedAt: DateTime.parse(j['created'] as String),
);
}).toList()..sort((a, b) => a.joinedAt.compareTo(b.joinedAt)));
}
Future<TeamMember> addMember({
required String tenantId,
required String email,
required String password,
required String firstName,
required String lastName,
required TenantRole role,
}) async {
final userRecord = await _pb.collection('users').create(body: {
'email': email.trim().toLowerCase(),
'password': password,
'passwordConfirm': password,
'first_name': firstName.trim(),
'last_name': lastName.trim(),
'emailVisibility': true,
});
final memberRecord = await _pb.collection('tenant_members').create(body: {
'tenant_id': tenantId,
'user_id': userRecord.id,
'role': role.value,
});
final j = memberRecord.toJson();
return TeamMember(
memberId: j['id'] as String,
user: UserProfile.fromJson(userRecord.toJson()),
role: role,
joinedAt: DateTime.parse(j['created'] as String),
);
}
Future<void> changeMemberRole(String memberId, TenantRole newRole) async {
await _pb.collection('tenant_members').update(memberId, body: {
'role': newRole.value,
});
}
Future<void> removeMember(String memberId) async {
await _pb.collection('tenant_members').delete(memberId);
}
}
+742
View File
@@ -0,0 +1,742 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/providers/auth_provider.dart';
import '../../core/theme/app_theme.dart';
import '../../models/tenant.dart';
import 'tenant_team_repository.dart';
class TenantTeamScreen extends ConsumerStatefulWidget {
const TenantTeamScreen({super.key});
@override
ConsumerState<TenantTeamScreen> createState() => _TenantTeamScreenState();
}
class _TenantTeamScreenState extends ConsumerState<TenantTeamScreen> {
List<TeamMember> _members = [];
bool _loading = true;
String? _error;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() {
_loading = true;
_error = null;
});
try {
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
final members = await TenantTeamRepository.instance.listMembers(tenantId);
if (mounted) {
setState(() {
_members = members;
_loading = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_error = e.toString();
_loading = false;
});
}
}
}
bool get _canManage =>
ref.read(authProvider).activeTenant?.canManageUsers ?? false;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Ekip'),
actions: [
if (_canManage)
TextButton.icon(
onPressed: () => _showAddMemberSheet(context),
icon: const Icon(Icons.person_add_outlined, size: 18),
label: const Text('Üye Ekle'),
),
],
),
body: _loading
? const Center(child: CircularProgressIndicator())
: _error != null
? _ErrorView(error: _error!, onRetry: _load)
: RefreshIndicator(
onRefresh: _load,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
_SectionHeader(
title: 'Üyeler',
count: _members.length,
),
const SizedBox(height: 8),
_MembersList(
members: _members,
canManage: _canManage,
currentUserId:
ref.read(authProvider).profile?.id ?? '',
onRoleChange: _changeRole,
onRemove: _removeMember,
),
const SizedBox(height: 24),
],
),
),
);
}
Future<void> _showAddMemberSheet(BuildContext context) async {
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: AppColors.background,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (_) => _AddMemberSheet(
onAdd: (firstName, lastName, email, password, role) async {
await TenantTeamRepository.instance.addMember(
tenantId: tenantId,
email: email,
password: password,
firstName: firstName,
lastName: lastName,
role: role,
);
await _load();
},
),
);
}
Future<void> _changeRole(TeamMember member, TenantRole newRole) async {
try {
await TenantTeamRepository.instance.changeMemberRole(
member.memberId, newRole);
await _load();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text('Hata: $e')));
}
}
}
Future<void> _removeMember(TeamMember member) async {
final name = member.user.displayName.isNotEmpty
? member.user.displayName
: member.user.email;
final confirmed = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text('Üyeyi Çıkar'),
content: Text('$name adlı üyeyi ekipten çıkarmak istiyor musunuz?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Vazgeç')),
FilledButton(
onPressed: () => Navigator.pop(context, true),
style: FilledButton.styleFrom(backgroundColor: AppColors.cancelled),
child: const Text('Çıkar'),
),
],
),
);
if (confirmed != true) return;
try {
await TenantTeamRepository.instance.removeMember(member.memberId);
await _load();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text('Hata: $e')));
}
}
}
}
// ── Section header ─────────────────────────────────────────────────────────
class _SectionHeader extends StatelessWidget {
const _SectionHeader({required this.title, required this.count});
final String title;
final int count;
@override
Widget build(BuildContext context) {
return Row(
children: [
Text(
title,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppColors.textSecondary,
letterSpacing: 0.5,
),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: AppColors.inProgressBg,
borderRadius: BorderRadius.circular(20),
),
child: Text(
'$count',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: AppColors.inProgress,
),
),
),
],
);
}
}
// ── Members list ───────────────────────────────────────────────────────────
class _MembersList extends StatelessWidget {
const _MembersList({
required this.members,
required this.canManage,
required this.currentUserId,
required this.onRoleChange,
required this.onRemove,
});
final List<TeamMember> members;
final bool canManage;
final String currentUserId;
final Future<void> Function(TeamMember, TenantRole) onRoleChange;
final Future<void> Function(TeamMember) onRemove;
@override
Widget build(BuildContext context) {
if (members.isEmpty) {
return const _EmptyCard(message: 'Henüz üye yok.');
}
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: members.asMap().entries.map((entry) {
final i = entry.key;
final m = entry.value;
final isLast = i == members.length - 1;
return _MemberTile(
member: m,
isSelf: m.user.id == currentUserId,
canManage: canManage && m.role != TenantRole.owner,
showDivider: !isLast,
onRoleChange: (role) => onRoleChange(m, role),
onRemove: () => onRemove(m),
);
}).toList(),
),
);
}
}
class _MemberTile extends StatelessWidget {
const _MemberTile({
required this.member,
required this.isSelf,
required this.canManage,
required this.showDivider,
required this.onRoleChange,
required this.onRemove,
});
final TeamMember member;
final bool isSelf;
final bool canManage;
final bool showDivider;
final void Function(TenantRole) onRoleChange;
final VoidCallback onRemove;
@override
Widget build(BuildContext context) {
final name = member.user.displayName.isNotEmpty
? member.user.displayName
: member.user.email;
final initials = name.trim().isNotEmpty
? name.trim()[0].toUpperCase()
: '?';
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: AppColors.inProgressBg,
borderRadius: BorderRadius.circular(20),
),
child: Center(
child: Text(
initials,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: AppColors.inProgress,
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
name,
style: const TextStyle(
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
if (isSelf) ...[
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: AppColors.successBg,
borderRadius: BorderRadius.circular(6),
),
child: const Text(
'Sen',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
color: AppColors.success,
),
),
),
],
],
),
Text(
member.user.email,
style: const TextStyle(
fontSize: 12,
color: AppColors.textSecondary,
),
),
],
),
),
if (canManage) ...[
_RoleChip(
role: member.role,
onChanged: onRoleChange,
),
const SizedBox(width: 4),
IconButton(
onPressed: onRemove,
icon: const Icon(Icons.remove_circle_outline,
color: AppColors.cancelled, size: 20),
tooltip: 'Çıkar',
constraints: const BoxConstraints(),
padding: const EdgeInsets.all(6),
),
] else
_RoleBadge(role: member.role),
],
),
),
if (showDivider)
const Divider(height: 1, indent: 68, color: AppColors.border),
],
);
}
}
class _RoleChip extends StatelessWidget {
const _RoleChip({required this.role, required this.onChanged});
final TenantRole role;
final void Function(TenantRole) onChanged;
static const _selectableRoles = [
TenantRole.admin,
TenantRole.technician,
TenantRole.delivery,
TenantRole.finance,
TenantRole.doctor,
TenantRole.member,
];
@override
Widget build(BuildContext context) {
return PopupMenuButton<TenantRole>(
initialValue: role,
onSelected: onChanged,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
itemBuilder: (_) => _selectableRoles
.map((r) => PopupMenuItem(
value: r,
child: Text(r.label),
))
.toList(),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: _roleBg(role),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
role.label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: _roleColor(role),
),
),
const SizedBox(width: 4),
Icon(Icons.arrow_drop_down, size: 16, color: _roleColor(role)),
],
),
),
);
}
}
class _RoleBadge extends StatelessWidget {
const _RoleBadge({required this.role});
final TenantRole role;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: _roleBg(role),
borderRadius: BorderRadius.circular(8),
),
child: Text(
role.label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: _roleColor(role),
),
),
);
}
}
Color _roleBg(TenantRole r) => switch (r) {
TenantRole.owner => AppColors.inProgressBg,
TenantRole.admin => AppColors.inProgressBg,
TenantRole.doctor => AppColors.successBg,
_ => AppColors.surface,
};
Color _roleColor(TenantRole r) => switch (r) {
TenantRole.owner => AppColors.inProgress,
TenantRole.admin => AppColors.inProgress,
TenantRole.doctor => AppColors.success,
_ => AppColors.textSecondary,
};
// ── Add member sheet ────────────────────────────────────────────────────────
class _AddMemberSheet extends StatefulWidget {
const _AddMemberSheet({required this.onAdd});
final Future<void> Function(
String firstName,
String lastName,
String email,
String password,
TenantRole role,
) onAdd;
@override
State<_AddMemberSheet> createState() => _AddMemberSheetState();
}
class _AddMemberSheetState extends State<_AddMemberSheet> {
final _formKey = GlobalKey<FormState>();
final _firstNameController = TextEditingController();
final _lastNameController = TextEditingController();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
TenantRole _selectedRole = TenantRole.member;
bool _saving = false;
bool _obscurePassword = true;
static const _selectableRoles = [
TenantRole.admin,
TenantRole.technician,
TenantRole.delivery,
TenantRole.finance,
TenantRole.doctor,
TenantRole.member,
];
@override
void dispose() {
_firstNameController.dispose();
_lastNameController.dispose();
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _saving = true);
try {
await widget.onAdd(
_firstNameController.text.trim(),
_lastNameController.text.trim(),
_emailController.text.trim(),
_passwordController.text,
_selectedRole,
);
if (mounted) Navigator.pop(context);
} catch (e) {
if (mounted) {
final msg = _friendlyError(e);
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(msg)));
}
} finally {
if (mounted) setState(() => _saving = false);
}
}
static String _friendlyError(Object e) {
final s = e.toString();
if (s.contains('email') && s.contains('unique')) {
return 'Bu e-posta adresi zaten kayıtlı.';
}
final msgMatch = RegExp(r'message: ([^,}]+)').firstMatch(s);
if (msgMatch != null) return msgMatch.group(1)!.trim();
if (s.length > 120) return 'Sunucu hatası';
return s;
}
@override
Widget build(BuildContext context) {
final bottom = MediaQuery.of(context).viewInsets.bottom;
return Padding(
padding: EdgeInsets.fromLTRB(24, 24, 24, 24 + bottom),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Text(
'Üye Ekle',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
const Spacer(),
IconButton(
onPressed: () => Navigator.pop(context),
icon: const Icon(Icons.close),
),
],
),
const SizedBox(height: 20),
Row(
children: [
Expanded(
child: TextFormField(
controller: _firstNameController,
textCapitalization: TextCapitalization.words,
textInputAction: TextInputAction.next,
decoration: const InputDecoration(
labelText: 'İsim',
prefixIcon: Icon(Icons.person_outline),
),
validator: (val) {
if (val == null || val.trim().isEmpty) return 'Zorunlu';
return null;
},
),
),
const SizedBox(width: 12),
Expanded(
child: TextFormField(
controller: _lastNameController,
textCapitalization: TextCapitalization.words,
textInputAction: TextInputAction.next,
decoration: const InputDecoration(
labelText: 'Soyisim',
),
validator: (val) {
if (val == null || val.trim().isEmpty) return 'Zorunlu';
return null;
},
),
),
],
),
const SizedBox(height: 16),
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
autocorrect: false,
decoration: const InputDecoration(
labelText: 'E-posta',
hintText: 'ornek@email.com',
prefixIcon: Icon(Icons.email_outlined),
),
validator: (val) {
if (val == null || val.trim().isEmpty) return 'E-posta zorunludur';
if (!val.contains('@')) return 'Geçerli bir e-posta girin';
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
obscureText: _obscurePassword,
textInputAction: TextInputAction.done,
decoration: InputDecoration(
labelText: 'Şifre',
prefixIcon: const Icon(Icons.lock_outline),
suffixIcon: IconButton(
icon: Icon(_obscurePassword
? Icons.visibility_outlined
: Icons.visibility_off_outlined),
onPressed: () =>
setState(() => _obscurePassword = !_obscurePassword),
),
),
validator: (val) {
if (val == null || val.isEmpty) return 'Şifre zorunludur';
if (val.length < 8) return 'En az 8 karakter olmalı';
return null;
},
),
const SizedBox(height: 16),
DropdownButtonFormField<TenantRole>(
value: _selectedRole,
decoration: const InputDecoration(
labelText: 'Rol',
prefixIcon: Icon(Icons.badge_outlined),
),
items: _selectableRoles
.map((r) => DropdownMenuItem(
value: r,
child: Text(r.label),
))
.toList(),
onChanged: (v) {
if (v != null) setState(() => _selectedRole = v);
},
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: _saving ? null : _submit,
icon: _saving
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white),
)
: const Icon(Icons.person_add_outlined, size: 18),
label: const Text('Üye Ekle'),
),
),
],
),
),
);
}
}
// ── Helpers ────────────────────────────────────────────────────────────────
class _EmptyCard extends StatelessWidget {
const _EmptyCard({required this.message});
final String message;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.border),
),
child: Center(
child: Text(
message,
style: const TextStyle(color: AppColors.textSecondary),
),
),
);
}
}
class _ErrorView extends StatelessWidget {
const _ErrorView({required this.error, required this.onRetry});
final String error;
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, color: AppColors.cancelled, size: 40),
const SizedBox(height: 12),
Text(error,
style: const TextStyle(color: AppColors.textSecondary),
textAlign: TextAlign.center),
const SizedBox(height: 12),
TextButton(onPressed: onRetry, child: const Text('Tekrar Dene')),
],
),
);
}
}