feat: deploy infrastructure + disk/drive, calendar, presentation, workspaces, onboarding, demo user

This commit is contained in:
2026-05-19 16:26:26 +03:00
parent 688d043dad
commit 3529c39b22
304 changed files with 18826 additions and 1176 deletions
@@ -0,0 +1,544 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import '../../../core/api/client.dart';
import '../../../core/api/endpoints.dart';
import '../../../core/api/exceptions.dart';
import '../../../core/theme/app_theme.dart';
import '../../../models/admin.dart';
import '../../../models/tariff.dart';
import '../../../models/auth.dart';
import '../../../widgets/loading_indicator.dart';
import '../../../widgets/error_display.dart';
import '../../../widgets/empty_state.dart';
class AdminScreen extends ConsumerStatefulWidget {
const AdminScreen({super.key});
@override
ConsumerState<AdminScreen> createState() => _AdminScreenState();
}
class _AdminScreenState extends ConsumerState<AdminScreen>
with SingleTickerProviderStateMixin {
late TabController _tabController;
@override
void initState() {
super.initState();
_tabController = TabController(length: 7, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Админ-панель'),
bottom: TabBar(
controller: _tabController,
isScrollable: true,
tabs: const [
Tab(text: 'Статистика'),
Tab(text: 'Пользователи'),
Tab(text: 'Агенты'),
Tab(text: 'Логи'),
Tab(text: 'Тарифы'),
Tab(text: 'Бот'),
Tab(text: 'Сервисы'),
],
),
),
body: TabBarView(
controller: _tabController,
children: const [
_StatsTab(),
_UsersTab(),
_AgentsTab(),
_LogsTab(),
_TariffsTab(),
_BotTab(),
_ServicesTab(),
],
),
);
}
}
class _StatsTab extends ConsumerWidget {
const _StatsTab();
@override
Widget build(BuildContext context, WidgetRef ref) {
return _FutureBuilder<AdminStats>(
future: () async {
final api = ref.read(apiClientProvider);
final resp = await api.get(Endpoints.adminStats);
return AdminStats.fromJson(resp.data as Map<String, dynamic>);
},
builder: (stats) => GridView.count(
padding: const EdgeInsets.all(16),
crossAxisCount: 2,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 1.5,
children: [
_StatCard(label: 'Пользователи', value: '${stats.totalUsers}', icon: Icons.people),
_StatCard(label: 'Идеи', value: '${stats.totalIdeas}', icon: Icons.lightbulb),
_StatCard(label: 'Голосовые сессии', value: '${stats.totalVoiceSessions}', icon: Icons.mic),
_StatCard(label: 'Раб. пространства', value: '${stats.totalWorkspaces}', icon: Icons.workspaces),
_StatCard(label: 'Активные агенты', value: '${stats.activeAgents}', icon: Icons.smart_toy),
_StatCard(label: 'Уведомления', value: '${stats.totalNotifications}', icon: Icons.notifications),
],
),
);
}
}
class _StatCard extends StatelessWidget {
final String label;
final String value;
final IconData icon;
const _StatCard({
required this.label,
required this.value,
required this.icon,
});
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, color: VoIdeaColors.primary, size: 28),
const SizedBox(height: 8),
Text(value,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
)),
Text(label,
style: const TextStyle(fontSize: 12, color: VoIdeaColors.muted)),
],
),
),
);
}
}
class _UsersTab extends ConsumerWidget {
const _UsersTab();
@override
Widget build(BuildContext context, WidgetRef ref) {
return _FutureBuilder<List<User>>(
future: () async {
final api = ref.read(apiClientProvider);
final resp = await api.get(Endpoints.adminUsers);
return (resp.data as List<dynamic>)
.map((e) => User.fromJson(e as Map<String, dynamic>))
.toList();
},
builder: (users) => ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: users.length,
itemBuilder: (_, i) {
final u = users[i];
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: CircleAvatar(
backgroundColor: VoIdeaColors.primary.withOpacity(0.1),
child: Text(u.name.isNotEmpty ? u.name[0].toUpperCase() : '?',
style: const TextStyle(color: VoIdeaColors.primary)),
),
title: Text(u.name),
subtitle: Text('${u.email} · ${DateFormat('d MMM yyyy').format(u.createdAt)}',
style: const TextStyle(fontSize: 12)),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (u.isAdmin)
const Chip(label: Text('Admin', style: TextStyle(fontSize: 10))),
if (u.isTwoFactorEnabled)
const Padding(
padding: EdgeInsets.only(left: 4),
child: Icon(Icons.security, size: 16, color: VoIdeaColors.success),
),
],
),
),
);
},
),
);
}
}
class _AgentsTab extends ConsumerWidget {
const _AgentsTab();
@override
Widget build(BuildContext context, WidgetRef ref) {
return _FutureBuilder<List<AgentConfig>>(
future: () async {
final api = ref.read(apiClientProvider);
final resp = await api.get(Endpoints.adminAgents);
return (resp.data as List<dynamic>)
.map((e) => AgentConfig.fromJson(e as Map<String, dynamic>))
.toList();
},
builder: (agents) => Column(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: () => _runAllAgents(ref),
icon: const Icon(Icons.play_arrow),
label: const Text('Запустить всех'),
),
),
],
),
),
Expanded(
child: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: agents.length,
itemBuilder: (_, i) {
final agent = agents[i];
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ExpansionTile(
leading: Icon(
agent.isActive ? Icons.smart_toy : Icons.smart_toy_outlined,
color: agent.isActive ? VoIdeaColors.success : VoIdeaColors.muted,
),
title: Text(agent.name),
subtitle: Text(agent.description, maxLines: 1,
overflow: TextOverflow.ellipsis),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.play_arrow, size: 20),
onPressed: () => _runAgent(ref, agent.name),
),
const Icon(Icons.expand_more),
],
),
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Расписание: ${agent.schedule}',
style: const TextStyle(fontSize: 12)),
const SizedBox(height: 8),
Text('Активен: ${agent.isActive ? "Да" : "Нет"}',
style: const TextStyle(fontSize: 12)),
const SizedBox(height: 8),
Text('Конфиг: ${agent.config.toString()}',
style: const TextStyle(fontSize: 12)),
],
),
),
],
),
);
},
),
),
],
),
);
}
void _runAgent(WidgetRef ref, String name) async {
try {
final api = ref.read(apiClientProvider);
await api.post(Endpoints.adminAgentRun(name));
} catch (_) {}
}
void _runAllAgents(WidgetRef ref) async {
try {
final api = ref.read(apiClientProvider);
await api.post(Endpoints.adminAgentsRunAll);
} catch (_) {}
}
}
class _LogsTab extends ConsumerWidget {
const _LogsTab();
@override
Widget build(BuildContext context, WidgetRef ref) {
return _FutureBuilder<List<LogEntry>>(
future: () async {
final api = ref.read(apiClientProvider);
final resp = await api.get(Endpoints.adminLogs);
return (resp.data as List<dynamic>)
.map((e) => LogEntry.fromJson(e as Map<String, dynamic>))
.toList();
},
builder: (logs) => ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: logs.length,
itemBuilder: (_, i) {
final log = logs[i];
final color = log.level == 'ERROR'
? VoIdeaColors.error
: log.level == 'WARNING'
? VoIdeaColors.warning
: VoIdeaColors.muted;
return Card(
margin: const EdgeInsets.only(bottom: 4),
child: ListTile(
dense: true,
leading: Container(
width: 8,
height: 8,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
title: Text(log.message,
maxLines: 1, overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 13)),
subtitle: Row(
children: [
Text(log.level, style: TextStyle(fontSize: 11, color: color)),
const SizedBox(width: 8),
if (log.source != null)
Text(log.source!,
style: const TextStyle(fontSize: 11, color: VoIdeaColors.muted)),
const Spacer(),
Text(DateFormat('HH:mm:ss').format(log.createdAt),
style: const TextStyle(fontSize: 11, color: VoIdeaColors.muted)),
],
),
),
);
},
),
);
}
}
class _TariffsTab extends ConsumerWidget {
const _TariffsTab();
@override
Widget build(BuildContext context, WidgetRef ref) {
return _FutureBuilder<List<Tariff>>(
future: () async {
final api = ref.read(apiClientProvider);
final resp = await api.get(Endpoints.adminTariffs);
return (resp.data as List<dynamic>)
.map((e) => Tariff.fromJson(e as Map<String, dynamic>))
.toList();
},
builder: (tariffs) => ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: tariffs.length,
itemBuilder: (_, i) {
final t = tariffs[i];
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(t.name,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
)),
const Spacer(),
Text('${t.price} ${t.currency}/мес',
style: TextStyle(
color: VoIdeaColors.primary,
fontWeight: FontWeight.w600)),
],
),
const SizedBox(height: 8),
...t.features.entries.map((e) => Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Row(
children: [
Icon(Icons.check, size: 14, color: VoIdeaColors.success),
const SizedBox(width: 8),
Text('${e.key}: ${e.value}',
style: const TextStyle(fontSize: 13)),
],
),
)),
],
),
),
);
},
),
);
}
}
class _BotTab extends ConsumerWidget {
const _BotTab();
@override
Widget build(BuildContext context, WidgetRef ref) {
return _FutureBuilder<List<BotCommand>>(
future: () async {
final api = ref.read(apiClientProvider);
final resp = await api.get(Endpoints.adminBotCommands);
return (resp.data as List<dynamic>)
.map((e) => BotCommand.fromJson(e as Map<String, dynamic>))
.toList();
},
builder: (commands) => ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: commands.length,
itemBuilder: (_, i) {
final cmd = commands[i];
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: Icon(
cmd.isEnabled ? Icons.check_circle : Icons.cancel,
color: cmd.isEnabled ? VoIdeaColors.success : VoIdeaColors.error,
),
title: Text('/${cmd.command}'),
subtitle: Text(cmd.description, maxLines: 1,
overflow: TextOverflow.ellipsis),
trailing: Switch(
value: cmd.isEnabled,
onChanged: (v) => _toggleCommand(ref, cmd.id, v),
),
),
);
},
),
);
}
void _toggleCommand(WidgetRef ref, String id, bool enabled) async {
try {
final api = ref.read(apiClientProvider);
await api.patch('${Endpoints.adminBotCommands}/$id', data: {
'is_enabled': enabled,
});
} catch (_) {}
}
}
class _ServicesTab extends ConsumerWidget {
const _ServicesTab();
@override
Widget build(BuildContext context, WidgetRef ref) {
return _FutureBuilder<HealthStatus>(
future: () async {
final api = ref.read(apiClientProvider);
final resp = await api.get(Endpoints.adminHealth);
return HealthStatus.fromJson(resp.data as Map<String, dynamic>);
},
builder: (health) => ListView(
padding: const EdgeInsets.all(16),
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
health.status == 'ok'
? Icons.check_circle
: Icons.error,
color: health.status == 'ok'
? VoIdeaColors.success
: VoIdeaColors.error,
),
const SizedBox(width: 8),
Text('Статус: ${health.status}',
style: Theme.of(context).textTheme.titleMedium),
],
),
const SizedBox(height: 12),
Text('Версия: ${health.version}',
style: const TextStyle(fontSize: 13)),
const SizedBox(height: 4),
Text('Uptime: ${health.uptimeSeconds.toStringAsFixed(0)}с',
style: const TextStyle(fontSize: 13)),
],
),
),
),
const SizedBox(height: 16),
const Text('Сервисы',
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 16)),
const SizedBox(height: 8),
...health.services.entries.map((e) => Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: Icon(
e.value == 'ok' || e.value == true
? Icons.check_circle
: Icons.error,
color: e.value == 'ok' || e.value == true
? VoIdeaColors.success
: VoIdeaColors.error,
size: 20,
),
title: Text(e.key),
subtitle: Text('${e.value}'),
),
)),
],
),
);
}
}
class _FutureBuilder<T> extends StatelessWidget {
final Future<T> Function() future;
final Widget Function(T data) builder;
const _FutureBuilder({required this.future, required this.builder});
@override
Widget build(BuildContext context) {
return FutureBuilder<T>(
future: future(),
builder: (ctx, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const LoadingIndicator();
}
if (snapshot.hasError) {
return ErrorDisplay(message: snapshot.error.toString());
}
if (!snapshot.hasData) {
return const EmptyState(
icon: Icons.info_outline, title: 'Нет данных');
}
return builder(snapshot.data!);
},
);
}
}