247 lines
13 KiB
React
247 lines
13 KiB
React
// [TSM.ID].[11031972] -- All Rights Reserved. Proprietary & Confidential.
|
|
// Module Manager — Supreme Admin CRUD for XCU Module Registry
|
|
// Dynamic: Add, Deactivate, Search modules. Data from xcu_iam PostgreSQL.
|
|
|
|
import { useState } from 'react';
|
|
import { useModuleRegistry } from '../context/ModuleRegistryContext';
|
|
import DangerModal from './DangerModal';
|
|
|
|
export default function ModuleManager() {
|
|
const { registry, allModules, totalModules, loading, source, addModule, deleteModule, refreshRegistry } = useModuleRegistry();
|
|
|
|
const [showAddForm, setShowAddForm] = useState(false);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [deleteTarget, setDeleteTarget] = useState(null);
|
|
|
|
// Add module form state
|
|
const [newId, setNewId] = useState('');
|
|
const [newName, setNewName] = useState('');
|
|
const [newGroupId, setNewGroupId] = useState('group1');
|
|
const [newGroupName, setNewGroupName] = useState('');
|
|
const [newSortOrder, setNewSortOrder] = useState(100);
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
|
|
// Derive available groups
|
|
const groups = Object.entries(registry).map(([key, val]) => ({
|
|
id: key,
|
|
name: val.name
|
|
}));
|
|
|
|
const handleAddModule = async () => {
|
|
if (!newId || !newName) return;
|
|
setIsSubmitting(true);
|
|
|
|
const groupName = newGroupName || (groups.find(g => g.id === newGroupId)?.name || newGroupId);
|
|
const success = await addModule({
|
|
id: newId,
|
|
name: newName,
|
|
group_id: newGroupId,
|
|
group_name: groupName,
|
|
sort_order: Number(newSortOrder)
|
|
});
|
|
|
|
if (success) {
|
|
setNewId('');
|
|
setNewName('');
|
|
setNewSortOrder(totalModules + 1);
|
|
setNewGroupName('');
|
|
setShowAddForm(false);
|
|
}
|
|
setIsSubmitting(false);
|
|
};
|
|
|
|
const handleDeleteConfirm = async () => {
|
|
if (!deleteTarget) return;
|
|
await deleteModule(deleteTarget);
|
|
setDeleteTarget(null);
|
|
};
|
|
|
|
// Filter modules
|
|
const q = searchQuery.toLowerCase();
|
|
const filteredGroups = Object.entries(registry).map(([key, group]) => ({
|
|
key,
|
|
name: group.name,
|
|
modules: (group.modules || []).filter(m =>
|
|
!q || m.id.toLowerCase().includes(q) || m.name.toLowerCase().includes(q)
|
|
)
|
|
})).filter(g => g.modules.length > 0);
|
|
|
|
const filteredCount = filteredGroups.reduce((sum, g) => sum + g.modules.length, 0);
|
|
|
|
if (loading) {
|
|
return (
|
|
<div style={{display: 'flex', alignItems: 'center', justifyContent: 'center', height: '300px', color: 'var(--accent-cyan)', fontFamily: 'monospace'}}>
|
|
LOADING SOVEREIGN MODULE REGISTRY...
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div style={{animation: 'fadeIn 0.5s ease-out'}}>
|
|
<DangerModal
|
|
isOpen={!!deleteTarget}
|
|
title="DEACTIVATE MODULE"
|
|
message={`Anda akan menonaktifkan modul ${deleteTarget}. Modul tidak dihapus permanen, tapi tidak akan muncul di registry aktif. Lanjut?`}
|
|
onConfirm={handleDeleteConfirm}
|
|
onCancel={() => setDeleteTarget(null)}
|
|
confirmText="DEACTIVATE MODULE"
|
|
/>
|
|
|
|
{/* Header */}
|
|
<div style={{display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '30px', gap: '20px', flexWrap: 'wrap'}}>
|
|
<div>
|
|
<h2 style={{color: 'var(--text-main)', fontFamily: 'monospace', letterSpacing: '2px', marginBottom: '10px'}}>
|
|
SOVEREIGN MODULE REGISTRY
|
|
</h2>
|
|
<p style={{color: 'var(--text-muted)', fontSize: '0.9rem'}}>
|
|
Dynamic Module CRUD — Source: <span style={{color: source === 'API' ? 'var(--accent-green)' : 'var(--accent-yellow)', fontWeight: 'bold'}}>{source === 'API' ? 'xcu_iam PostgreSQL' : 'Static Fallback'}</span>
|
|
</p>
|
|
</div>
|
|
|
|
<div style={{display: 'flex', gap: '10px', alignItems: 'center'}}>
|
|
{/* Stats */}
|
|
<div style={{
|
|
background: 'rgba(0, 243, 255, 0.1)', border: '1px solid var(--accent-cyan)',
|
|
padding: '10px 20px', borderRadius: '8px', fontFamily: 'monospace', textAlign: 'center'
|
|
}}>
|
|
<div style={{color: 'var(--accent-cyan)', fontSize: '1.8rem', fontWeight: 'bold'}}>{totalModules}</div>
|
|
<div style={{color: 'var(--text-muted)', fontSize: '0.7rem', letterSpacing: '1px'}}>ACTIVE MODULES</div>
|
|
</div>
|
|
<div style={{
|
|
background: 'rgba(168, 85, 247, 0.1)', border: '1px solid var(--accent-purple)',
|
|
padding: '10px 20px', borderRadius: '8px', fontFamily: 'monospace', textAlign: 'center'
|
|
}}>
|
|
<div style={{color: 'var(--accent-purple)', fontSize: '1.8rem', fontWeight: 'bold'}}>{Object.keys(registry).length}</div>
|
|
<div style={{color: 'var(--text-muted)', fontSize: '0.7rem', letterSpacing: '1px'}}>GROUPS</div>
|
|
</div>
|
|
|
|
<button onClick={refreshRegistry} style={{
|
|
padding: '10px 20px', background: 'transparent', border: '1px solid var(--accent-green)',
|
|
color: 'var(--accent-green)', borderRadius: '8px', cursor: 'pointer', fontFamily: 'monospace'
|
|
}}>↻ REFRESH</button>
|
|
|
|
<button onClick={() => { setShowAddForm(!showAddForm); setNewSortOrder(totalModules + 1); setNewId(`m${totalModules + 1}`); }} style={{
|
|
padding: '10px 20px', background: showAddForm ? 'var(--accent-cyan)' : 'transparent',
|
|
border: '1px solid var(--accent-cyan)', borderRadius: '8px', cursor: 'pointer', fontFamily: 'monospace',
|
|
color: showAddForm ? '#000' : 'var(--accent-cyan)', fontWeight: 'bold'
|
|
}}>+ ADD MODULE</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Add Module Form */}
|
|
{showAddForm && (
|
|
<div className="glass-panel" style={{
|
|
padding: '25px', marginBottom: '30px', borderLeft: '4px solid var(--accent-cyan)',
|
|
animation: 'fadeIn 0.3s ease-out'
|
|
}}>
|
|
<h3 style={{color: 'var(--accent-cyan)', marginBottom: '20px', fontFamily: 'monospace'}}>FORGE NEW MODULE</h3>
|
|
<div style={{display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '15px'}}>
|
|
<div>
|
|
<label style={{color: 'var(--text-muted)', fontSize: '0.75rem', display: 'block', marginBottom: '5px'}}>MODULE ID</label>
|
|
<input value={newId} onChange={e => setNewId(e.target.value)} placeholder="m100"
|
|
style={{width: '100%', padding: '10px', background: 'var(--hover-bg)', border: '1px solid var(--table-border)', color: 'var(--text-main)', borderRadius: '6px', fontFamily: 'monospace'}} />
|
|
</div>
|
|
<div>
|
|
<label style={{color: 'var(--text-muted)', fontSize: '0.75rem', display: 'block', marginBottom: '5px'}}>MODULE NAME</label>
|
|
<input value={newName} onChange={e => setNewName(e.target.value)} placeholder="Quantum Flux Capacitor"
|
|
style={{width: '100%', padding: '10px', background: 'var(--hover-bg)', border: '1px solid var(--table-border)', color: 'var(--text-main)', borderRadius: '6px', fontFamily: 'monospace'}} />
|
|
</div>
|
|
<div>
|
|
<label style={{color: 'var(--text-muted)', fontSize: '0.75rem', display: 'block', marginBottom: '5px'}}>GROUP</label>
|
|
<select value={newGroupId} onChange={e => setNewGroupId(e.target.value)}
|
|
style={{width: '100%', padding: '10px', background: 'var(--hover-bg)', border: '1px solid var(--table-border)', color: 'var(--text-main)', borderRadius: '6px', fontFamily: 'monospace'}}>
|
|
{groups.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
|
|
<option value="new">+ NEW GROUP</option>
|
|
</select>
|
|
</div>
|
|
{newGroupId === 'new' && (
|
|
<div>
|
|
<label style={{color: 'var(--text-muted)', fontSize: '0.75rem', display: 'block', marginBottom: '5px'}}>NEW GROUP NAME</label>
|
|
<input value={newGroupName} onChange={e => setNewGroupName(e.target.value)} placeholder="KELOMPOK IX: ..."
|
|
style={{width: '100%', padding: '10px', background: 'var(--hover-bg)', border: '1px solid var(--table-border)', color: 'var(--text-main)', borderRadius: '6px', fontFamily: 'monospace'}} />
|
|
</div>
|
|
)}
|
|
<div>
|
|
<label style={{color: 'var(--text-muted)', fontSize: '0.75rem', display: 'block', marginBottom: '5px'}}>SORT ORDER</label>
|
|
<input type="number" value={newSortOrder} onChange={e => setNewSortOrder(e.target.value)}
|
|
style={{width: '100%', padding: '10px', background: 'var(--hover-bg)', border: '1px solid var(--table-border)', color: 'var(--text-main)', borderRadius: '6px', fontFamily: 'monospace'}} />
|
|
</div>
|
|
</div>
|
|
<div style={{marginTop: '20px', display: 'flex', gap: '10px'}}>
|
|
<button onClick={handleAddModule} disabled={isSubmitting || !newId || !newName} style={{
|
|
padding: '10px 30px', background: 'var(--accent-cyan)', color: '#000', border: 'none',
|
|
borderRadius: '6px', cursor: 'pointer', fontFamily: 'monospace', fontWeight: 'bold',
|
|
opacity: (!newId || !newName || isSubmitting) ? 0.5 : 1
|
|
}}>{isSubmitting ? 'FORGING...' : 'FORGE MODULE'}</button>
|
|
<button onClick={() => setShowAddForm(false)} style={{
|
|
padding: '10px 30px', background: 'transparent', color: 'var(--text-muted)',
|
|
border: '1px solid var(--table-border)', borderRadius: '6px', cursor: 'pointer', fontFamily: 'monospace'
|
|
}}>CANCEL</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Search */}
|
|
<div style={{marginBottom: '20px', position: 'relative'}}>
|
|
<input
|
|
value={searchQuery} onChange={e => setSearchQuery(e.target.value)}
|
|
placeholder="Search modules by ID or name..."
|
|
style={{
|
|
width: '100%', padding: '12px 20px', background: 'var(--hover-bg)',
|
|
border: '1px solid var(--table-border)', color: 'var(--text-main)',
|
|
borderRadius: '8px', fontFamily: 'monospace', fontSize: '0.9rem', outline: 'none'
|
|
}}
|
|
/>
|
|
{searchQuery && (
|
|
<span style={{position: 'absolute', right: '15px', top: '12px', color: 'var(--text-muted)', fontFamily: 'monospace', fontSize: '0.8rem'}}>
|
|
{filteredCount} / {totalModules}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Module Grid */}
|
|
<div style={{display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(450px, 1fr))', gap: '20px'}}>
|
|
{filteredGroups.map(group => {
|
|
const groupColors = {
|
|
group1: '#00f3ff', group2: '#ffea00', group3: '#ff003c', group4: '#a855f7',
|
|
group5: '#10b981', group6: '#3b82f6', group7: '#f97316', group8: '#ef4444'
|
|
};
|
|
const color = groupColors[group.key] || '#00f3ff';
|
|
|
|
return (
|
|
<div key={group.key} className="glass-panel" style={{
|
|
padding: '20px', borderTop: `3px solid ${color}`,
|
|
background: 'var(--panel-bg)', borderRadius: '10px'
|
|
}}>
|
|
<h3 style={{color, fontSize: '0.85rem', marginBottom: '15px', fontFamily: 'monospace', letterSpacing: '1px',
|
|
borderBottom: '1px solid var(--table-border)', paddingBottom: '10px', display: 'flex', justifyContent: 'space-between'}}>
|
|
<span>{group.name}</span>
|
|
<span style={{color: 'var(--text-muted)'}}>[{group.modules.length}]</span>
|
|
</h3>
|
|
<div style={{display: 'flex', flexDirection: 'column', gap: '6px', maxHeight: '400px', overflowY: 'auto', paddingRight: '5px'}} className="custom-scrollbar">
|
|
{group.modules.map(mod => (
|
|
<div key={mod.id} style={{
|
|
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
|
padding: '8px 12px', background: 'var(--hover-bg)', borderRadius: '6px',
|
|
border: '1px solid var(--table-border)', transition: 'all 0.2s'
|
|
}}>
|
|
<div>
|
|
<span style={{color: 'var(--text-main)', fontSize: '0.85rem'}}>{mod.name}</span>
|
|
<span style={{color: 'var(--text-muted)', fontSize: '0.7rem', marginLeft: '8px', fontFamily: 'monospace'}}>{mod.id}</span>
|
|
</div>
|
|
<button onClick={() => setDeleteTarget(mod.id)} title="Deactivate module" style={{
|
|
background: 'transparent', border: '1px solid rgba(255,0,60,0.3)', color: 'var(--accent-red)',
|
|
padding: '4px 10px', borderRadius: '4px', cursor: 'pointer', fontFamily: 'monospace', fontSize: '0.7rem',
|
|
transition: 'all 0.2s'
|
|
}}>✕</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|