krona-front/src/features/settings/SettingsModule.tsx

343 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useRef, useEffect } from 'react';
import { useDemoStore } from '../../mock/store';
import { CAPABILITIES_LIST } from '../../core/capabilities/types';
import { BUSINESS_PROFILES } from '../../core/profiles/profiles';
import { SYSTEM_ROLES, SystemRoleId } from '../../core/permissions/types';
import { Badge } from '../../shared/components/Badge';
import { MaturityBadge } from '../../shared/ui/MaturityBadge';
import { Button } from '../../shared/components/Button';
import { useToast } from '../../shared/ui/useToast';
import {
exportDatabaseSnapshot,
importDatabaseSnapshot,
resetDatabase,
DatabaseSnapshot,
} from '../../services/storage/indexedDb';
import { Sliders, Shield, Building, RefreshCw, Database, Download, Upload, RotateCcw } from 'lucide-react';
export const SettingsModule: React.FC = () => {
const {
currentProfileId,
setProfileId,
currentRoleId,
setRoleId,
customCapabilities,
toggleCapability,
resetCapabilitiesToProfile,
} = useDemoStore();
const { showToast } = useToast();
const [isResetting, setIsResetting] = useState(false);
const [isExporting, setIsExporting] = useState(false);
const [isImporting, setIsImporting] = useState(false);
const [snapshotStats, setSnapshotStats] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (typeof window !== 'undefined') {
(window as unknown as { __krona_db?: unknown }).__krona_db = {
exportDatabaseSnapshot,
importDatabaseSnapshot,
resetDatabase,
};
}
}, []);
const handleExportSnapshot = async () => {
try {
setIsExporting(true);
const snapshot = await exportDatabaseSnapshot();
const jsonStr = JSON.stringify(snapshot, null, 2);
const blob = new Blob([jsonStr], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `krona_db_snapshot_${Date.now()}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
const totalRecords =
snapshot.metadata?.totalRecords ??
Object.values(snapshot.stores).reduce(
(acc: number, curr) => acc + (Array.isArray(curr) ? curr.length : 0),
0
);
setSnapshotStats(`Экспортировано ${totalRecords} записей (v${snapshot.version})`);
showToast('Снимок базы данных успешно экспортирован!', 'success');
} catch (err) {
showToast(`Ошибка экспорта: ${err instanceof Error ? err.message : String(err)}`, 'error');
} finally {
setIsExporting(false);
}
};
const handleResetDatabase = async () => {
try {
setIsResetting(true);
await resetDatabase();
setSnapshotStats('База данных сброшена к начальному состоянию');
showToast('База данных успешно сброшена к начальному состоянию!', 'info');
} catch (err) {
showToast(`Ошибка сброса: ${err instanceof Error ? err.message : String(err)}`, 'error');
} finally {
setIsResetting(false);
}
};
const handleImportFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
try {
setIsImporting(true);
const text = await file.text();
const parsed = JSON.parse(text) as DatabaseSnapshot;
if (!parsed.version || !parsed.stores) {
throw new Error('Некорректный формат файла снимка');
}
await importDatabaseSnapshot(parsed);
const totalRecords =
parsed.metadata?.totalRecords ??
Object.values(parsed.stores).reduce(
(acc: number, curr) => acc + (Array.isArray(curr) ? curr.length : 0),
0
);
setSnapshotStats(`Импортировано ${totalRecords} записей`);
showToast('Снимок базы данных успешно импортирован!', 'success');
} catch (err) {
showToast(`Ошибка импорта: ${err instanceof Error ? err.message : String(err)}`, 'error');
} finally {
setIsImporting(false);
if (fileInputRef.current) fileInputRef.current.value = '';
}
};
return (
<div className="space-y-4 animate-fade-in">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 bg-krona-surface p-3.5 rounded-xl border border-krona-border shadow-krona-soft">
<div>
<div className="flex items-center gap-2">
<h2 className="text-sm font-bold text-krona-primary">
Krona Configuration Center & Capability Studio
</h2>
<MaturityBadge level="pilot1" size="sm" />
</div>
<p className="text-xs text-krona-secondary">
Управление архитектурой экосистемы, переключение бизнес-профилей и тонкая настройка Capabilities
</p>
</div>
<Button variant="secondary" size="sm" onClick={resetCapabilitiesToProfile}>
<RefreshCw className="w-3.5 h-3.5 mr-1" />
Сбросить к дефолту профиля
</Button>
</div>
{/* Configuration Engine / Entity Catalog Feature Banner */}
<div className="bg-krona-surface border border-krona-border rounded-xl p-4 shadow-krona-soft flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-krona-green/10 text-krona-green flex items-center justify-center border border-krona-green/20 shrink-0">
<Sliders className="w-5 h-5" />
</div>
<div>
<div className="flex items-center gap-2">
<h3 className="text-sm font-bold text-krona-primary">
Configuration Engine: Сущности и Конструкторы
</h3>
<Badge variant="gold" size="sm">Phase C Central</Badge>
</div>
<p className="text-xs text-krona-secondary">
Управление 39+ типами полей, визуальный редактор форм, карточек, фильтров и кастомных модулей.
</p>
</div>
</div>
<a
href="/settings/entities"
className="inline-flex items-center justify-center px-4 py-2 rounded-lg bg-krona-green text-white text-xs font-semibold hover:bg-krona-green/90 transition-colors shrink-0"
>
Открыть каталог сущностей →
</a>
</div>
{/* 1. Business Profile Selector */}
<div className="bg-krona-surface border border-krona-border rounded-xl p-4 shadow-krona-soft space-y-3">
<div className="flex items-center gap-2">
<Building className="w-4 h-4 text-krona-gold" />
<h3 className="text-xs font-bold text-krona-primary uppercase tracking-wider">
Активный бизнес-профиль
</h3>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2.5">
{Object.values(BUSINESS_PROFILES).map((p) => (
<button
key={p.id}
onClick={() => setProfileId(p.id)}
className={`p-3 rounded-xl border text-left flex flex-col justify-between transition-all ${
p.id === currentProfileId
? 'border-krona-gold bg-krona-gold/10 ring-2 ring-krona-gold/30'
: 'border-krona-border bg-krona-workspace hover:border-krona-border-strong'
}`}
>
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-xs font-bold text-krona-primary">{p.name}</span>
<Badge variant="neutral" size="sm">{p.category}</Badge>
</div>
<div className="text-[11px] text-krona-secondary">{p.companyName}</div>
</div>
<div className="text-[10px] text-krona-gold-text font-semibold pt-2">
{p.id === currentProfileId ? '✓ Активный профиль' : 'Выбрать →'}
</div>
</button>
))}
</div>
</div>
{/* 2. Capability Engine Studio (Live interactive toggles for all 21 capabilities!) */}
<div className="bg-krona-surface border border-krona-border rounded-xl p-4 shadow-krona-soft space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Sliders className="w-4 h-4 text-krona-green" />
<h3 className="text-xs font-bold text-krona-primary uppercase tracking-wider">
Capability Engine (21 системная возможность платформы)
</h3>
</div>
<span className="text-xs text-krona-secondary">
Тумблеры мгновенно перестраивают меню, разделы и табы
</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2.5">
{CAPABILITIES_LIST.map((cap) => {
const isEnabled = customCapabilities[cap.id];
return (
<div
key={cap.id}
onClick={() => toggleCapability(cap.id)}
className={`p-3 rounded-lg border cursor-pointer select-none transition-all flex items-center justify-between gap-2 ${
isEnabled
? 'border-krona-green/60 bg-krona-green/5'
: 'border-krona-border bg-krona-workspace opacity-60'
}`}
>
<div className="min-w-0">
<div className="font-bold text-xs text-krona-primary truncate">{cap.name}</div>
<div className="text-[10px] text-krona-secondary truncate">{cap.description}</div>
</div>
<div
className={`w-10 h-5 rounded-full p-0.5 transition-colors duration-200 ease-in-out shrink-0 ${
isEnabled ? 'bg-krona-green' : 'bg-[#D8D5CB] dark:bg-[#3A3D35]'
}`}
>
<div
className={`w-4 h-4 rounded-full bg-white transition-transform duration-200 ease-in-out shadow-sm ${
isEnabled ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</div>
</div>
);
})}
</div>
</div>
{/* 3. Role & RBAC selector */}
<div className="bg-krona-surface border border-krona-border rounded-xl p-4 shadow-krona-soft space-y-3">
<div className="flex items-center gap-2">
<Shield className="w-4 h-4 text-krona-info" />
<h3 className="text-xs font-bold text-krona-primary uppercase tracking-wider">
Матрица прав и ролей (RBAC)
</h3>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-2">
{Object.values(SYSTEM_ROLES).map((role) => (
<button
key={role.id}
data-testid={`settings-role-${role.id}`}
onClick={() => setRoleId(role.id as SystemRoleId)}
className={`p-2.5 rounded-lg border text-left transition-all ${
role.id === currentRoleId
? 'border-krona-green bg-krona-green/10 text-krona-green-text font-bold'
: 'border-krona-border bg-krona-workspace text-krona-secondary hover:text-krona-primary'
}`}
>
<div className="text-xs font-semibold">{role.label}</div>
<div className="text-[10px] opacity-75">{role.badge}</div>
</button>
))}
</div>
</div>
{/* 4. IndexedDB Snapshots & State Management */}
<div className="bg-krona-surface border border-krona-border rounded-xl p-4 shadow-krona-soft space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Database className="w-4 h-4 text-krona-green" />
<h3 className="text-xs font-bold text-krona-primary uppercase tracking-wider">
Управление снимками и базой данных (IndexedDB)
</h3>
</div>
{snapshotStats && (
<span className="text-xs font-medium text-krona-green">{snapshotStats}</span>
)}
</div>
<p className="text-xs text-krona-secondary">
Экспорт полного слепка данных (сделки, заказы, кастомные сущности, финансы, остатки) в JSON,
сброс к дефолтному демо-состоянию и мгновенное восстановление из файла.
</p>
<div className="flex flex-wrap items-center gap-3 pt-1">
<Button
variant="primary"
size="sm"
data-testid="export-snapshot-btn"
onClick={handleExportSnapshot}
disabled={isExporting}
>
<Download className="w-3.5 h-3.5 mr-1.5" />
{isExporting ? 'Экспорт...' : 'Экспорт снимка (JSON)'}
</Button>
<Button
variant="secondary"
size="sm"
data-testid="reset-database-btn"
onClick={handleResetDatabase}
disabled={isResetting}
>
<RotateCcw className="w-3.5 h-3.5 mr-1.5" />
{isResetting ? 'Сброс...' : 'Сбросить демо-данные'}
</Button>
<div>
<input
type="file"
ref={fileInputRef}
data-testid="import-snapshot-input"
accept=".json"
onChange={handleImportFile}
className="hidden"
/>
<Button
variant="outline"
size="sm"
data-testid="import-snapshot-btn"
onClick={() => fileInputRef.current?.click()}
disabled={isImporting}
>
<Upload className="w-3.5 h-3.5 mr-1.5" />
{isImporting ? 'Импорт...' : 'Импортировать снимок'}
</Button>
</div>
</div>
</div>
</div>
);
};