343 lines
14 KiB
TypeScript
343 lines
14 KiB
TypeScript
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>
|
||
);
|
||
};
|