- tambah Elysia Swagger di /docs dengan deskripsi lengkap semua endpoint - tambah API key auth (X-API-Key) untuk klien eksternal di POST /api/bugs - tambah normalisasi BugSource: SYSTEM/USER untuk eksternal, QC/SYSTEM/USER untuk dashboard - perbaiki source schema jadi optional string agar tidak reject nilai unknown dari klien lama - hapus field status dari form create bug (selalu OPEN) - perbaiki typo desa_plus → appId di apps.$appId.errors.tsx - tambah toggle hide/show stack trace di bug-reports.tsx dan apps.$appId.errors.tsx - perbaiki grafik desa (width(-1)/height(-1)) dengan minWidth: 0 pada grid item - perbaiki error &[data-active] inline style di DashboardLayout → pindah ke CSS class - update CLAUDE.md dengan arsitektur lengkap Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
311 lines
9.9 KiB
TypeScript
311 lines
9.9 KiB
TypeScript
import { APP_CONFIGS } from '@/frontend/config/appMenus'
|
|
import { useLogout, useSession } from '@/frontend/hooks/useAuth'
|
|
import {
|
|
ActionIcon,
|
|
AppShell,
|
|
Avatar,
|
|
Box,
|
|
Burger,
|
|
Button,
|
|
Group,
|
|
Loader,
|
|
Menu,
|
|
NavLink,
|
|
Select,
|
|
Stack,
|
|
Text,
|
|
ThemeIcon,
|
|
useComputedColorScheme,
|
|
useMantineColorScheme
|
|
} from '@mantine/core'
|
|
import { useDisclosure } from '@mantine/hooks'
|
|
import { useQuery } from '@tanstack/react-query'
|
|
import { Link, useLocation, useMatches, useNavigate, useParams } from '@tanstack/react-router'
|
|
import {
|
|
TbAlertTriangle,
|
|
TbApps,
|
|
TbArrowLeft,
|
|
TbChevronRight,
|
|
TbDashboard,
|
|
TbDeviceMobile,
|
|
TbHistory,
|
|
TbLogout,
|
|
TbMoon,
|
|
TbSettings,
|
|
TbSun,
|
|
TbUser,
|
|
TbUserCircle
|
|
} from 'react-icons/tb'
|
|
|
|
interface DashboardLayoutProps {
|
|
children: React.ReactNode
|
|
}
|
|
|
|
export function DashboardLayout({ children }: DashboardLayoutProps) {
|
|
const [mobileOpened, { toggle: toggleMobile }] = useDisclosure()
|
|
const [desktopOpened, { toggle: toggleDesktop }] = useDisclosure(true)
|
|
const { toggleColorScheme } = useMantineColorScheme()
|
|
const computedColorScheme = useComputedColorScheme('light', { getInitialValueInEffect: true })
|
|
const location = useLocation()
|
|
const navigate = useNavigate()
|
|
const { appId } = useParams({ strict: false }) as { appId?: string }
|
|
|
|
const matches = useMatches()
|
|
const currentPath = matches[matches.length - 1]?.pathname
|
|
|
|
// ─── Connect to auth system ──────────────────────────
|
|
const { data: sessionData } = useSession()
|
|
const user = sessionData?.user
|
|
const logout = useLogout()
|
|
|
|
// ─── Fetch registered apps from database ─────────────
|
|
const { data: appsData } = useQuery({
|
|
queryKey: ['apps'],
|
|
queryFn: () => fetch('/api/apps', { credentials: 'include' }).then((r) => r.json()),
|
|
staleTime: 60_000,
|
|
})
|
|
|
|
// ─── Fetch system status from database ───────────────
|
|
const { data: systemStatus } = useQuery({
|
|
queryKey: ['system', 'status'],
|
|
queryFn: () => fetch('/api/system/status', { credentials: 'include' }).then((r) => r.json()),
|
|
refetchInterval: 30_000, // refresh every 30 seconds
|
|
staleTime: 15_000,
|
|
})
|
|
|
|
const globalNav = [
|
|
{ label: 'Dashboard', icon: TbDashboard, to: '/dashboard' },
|
|
{ label: 'Applications', icon: TbApps, to: '/apps' },
|
|
{ label: 'Log Activity', icon: TbHistory, to: '/logs' },
|
|
{ label: 'Error Reports', icon: TbAlertTriangle, to: '/bug-reports' },
|
|
{ label: 'Users', icon: TbUser, to: '/users' },
|
|
]
|
|
|
|
const activeApp = appId ? APP_CONFIGS[appId] : null
|
|
const navLinks = activeApp ? activeApp.menus : globalNav
|
|
|
|
// Build app selector data from API
|
|
const appSelectData = (appsData || []).map((app: any) => ({
|
|
value: app.id,
|
|
label: app.name,
|
|
}))
|
|
|
|
// System status indicator
|
|
const isOperational = systemStatus?.status === 'operational'
|
|
const statusColor = isOperational ? '#10b981' : '#f59e0b'
|
|
const statusText = isOperational ? 'All Systems Operational' : 'System Degraded'
|
|
|
|
const handleLogout = () => {
|
|
logout.mutate()
|
|
}
|
|
|
|
return (
|
|
<AppShell
|
|
header={{ height: 70 }}
|
|
navbar={{
|
|
width: 260,
|
|
breakpoint: 'sm',
|
|
collapsed: { mobile: !mobileOpened, desktop: !desktopOpened },
|
|
}}
|
|
padding="xl"
|
|
styles={(theme) => ({
|
|
main: {
|
|
backgroundColor: computedColorScheme === 'dark' ? theme.colors.dark[9] : theme.colors.gray[0],
|
|
transition: 'background-color 0.2s ease',
|
|
},
|
|
})}
|
|
>
|
|
<AppShell.Header px="xl">
|
|
<Group h="100%" justify="space-between">
|
|
<Group>
|
|
<Burger opened={mobileOpened} onClick={toggleMobile} hiddenFrom="sm" size="sm" />
|
|
<Burger opened={desktopOpened} onClick={toggleDesktop} visibleFrom="sm" size="sm" />
|
|
<Group gap="xs">
|
|
<ThemeIcon
|
|
size={34}
|
|
radius="md"
|
|
variant="gradient"
|
|
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
|
|
>
|
|
<TbDeviceMobile size={18} />
|
|
</ThemeIcon>
|
|
<Text
|
|
size="lg"
|
|
fw={700}
|
|
className="gradient-text"
|
|
style={{ letterSpacing: '-0.5px' }}
|
|
>
|
|
Monitoring System
|
|
</Text>
|
|
</Group>
|
|
</Group>
|
|
|
|
<Group gap="md">
|
|
<ActionIcon
|
|
onClick={() => toggleColorScheme()}
|
|
variant="default"
|
|
size="lg"
|
|
aria-label="Toggle color scheme"
|
|
>
|
|
{computedColorScheme === 'dark' ? <TbSun size={18} /> : <TbMoon size={18} />}
|
|
</ActionIcon>
|
|
<Menu shadow="md" width={200} position="bottom-end">
|
|
<Menu.Target>
|
|
<Avatar
|
|
src={undefined}
|
|
alt={user?.name || 'User'}
|
|
color="brand-blue"
|
|
radius="xl"
|
|
style={{ cursor: 'pointer' }}
|
|
>
|
|
{user?.name?.charAt(0).toUpperCase()}
|
|
</Avatar>
|
|
</Menu.Target>
|
|
|
|
<Menu.Dropdown>
|
|
{user && (
|
|
<>
|
|
<Menu.Label>
|
|
<Text size="sm" fw={600} truncate>{user.name}</Text>
|
|
<Text size="xs" c="dimmed" truncate>{user.email}</Text>
|
|
</Menu.Label>
|
|
<Menu.Divider />
|
|
</>
|
|
)}
|
|
<Menu.Label>Application</Menu.Label>
|
|
<Menu.Item
|
|
leftSection={<TbUserCircle size={16} />}
|
|
onClick={() => navigate({ to: '/profile' })}
|
|
>
|
|
Profile
|
|
</Menu.Item>
|
|
<Menu.Item
|
|
leftSection={<TbSettings size={16} />}
|
|
onClick={() => navigate({ to: '/dashboard' })}
|
|
>
|
|
Settings
|
|
</Menu.Item>
|
|
<Menu.Divider />
|
|
<Menu.Label>Danger Zone</Menu.Label>
|
|
<Menu.Item
|
|
color="red"
|
|
leftSection={<TbLogout size={16} />}
|
|
onClick={handleLogout}
|
|
disabled={logout.isPending}
|
|
>
|
|
{logout.isPending ? 'Logging out...' : 'Logout'}
|
|
</Menu.Item>
|
|
</Menu.Dropdown>
|
|
</Menu>
|
|
</Group>
|
|
</Group>
|
|
</AppShell.Header>
|
|
|
|
<AppShell.Navbar p="md">
|
|
<Stack gap="xs" mt="md">
|
|
{activeApp && (
|
|
<NavLink
|
|
label="Back to Dashboard"
|
|
leftSection={<TbArrowLeft size={20} />}
|
|
component={Link}
|
|
to="/dashboard"
|
|
styles={(theme) => ({
|
|
root: {
|
|
borderRadius: theme.radius.md,
|
|
opacity: 0.7,
|
|
'&:hover': { opacity: 1 },
|
|
},
|
|
})}
|
|
/>
|
|
)}
|
|
|
|
{
|
|
activeApp &&
|
|
<Select
|
|
label="Selected Application"
|
|
value={appId}
|
|
data={appSelectData.length > 0 ? appSelectData : [
|
|
{ value: 'desa-plus', label: 'Desa+' },
|
|
]}
|
|
onChange={(val) => val && navigate({ to: '/apps/$appId', params: { appId: val } })}
|
|
radius="md"
|
|
size="sm"
|
|
w={220}
|
|
mb={"md"}
|
|
variant="filled"
|
|
styles={(theme) => ({
|
|
input: { border: computedColorScheme === 'dark' ? '1px solid rgba(255,255,255,0.1)' : '1px solid rgba(0,0,0,0.1)' }
|
|
})}
|
|
/>
|
|
}
|
|
|
|
|
|
|
|
{/* {activeApp && (
|
|
<Text size="xs" fw={700} c="dimmed" px="md" mb="xs" style={{ textTransform: 'uppercase' }}>
|
|
{activeApp.name} Context
|
|
</Text>
|
|
)} */}
|
|
|
|
{navLinks.map((link: any) => {
|
|
const isActive = currentPath === link.to
|
|
|
|
return (
|
|
<NavLink
|
|
key={link.label}
|
|
component={Link}
|
|
to={link.to}
|
|
activeOptions={{ exact: true }}
|
|
label={link.label}
|
|
leftSection={<link.icon size={20} />}
|
|
rightSection={<TbChevronRight size={14} />}
|
|
active={isActive}
|
|
variant="filled"
|
|
color="brand-blue"
|
|
className="sidebar-nav-item"
|
|
/>
|
|
)
|
|
})}
|
|
</Stack>
|
|
|
|
<Box style={{ marginTop: 'auto' }}>
|
|
<Stack gap="xs">
|
|
<Box
|
|
p="md"
|
|
className="glass"
|
|
style={{ borderRadius: '12px', border: computedColorScheme === 'dark' ? '1px solid rgba(255,255,255,0.05)' : '1px solid rgba(0,0,0,0.05)' }}
|
|
>
|
|
<Text size="xs" c="dimmed" fw={600} mb="xs">SYSTEM STATUS</Text>
|
|
<Group gap="xs">
|
|
<Box style={{ width: 8, height: 8, borderRadius: '50%', background: statusColor, boxShadow: `0 0 6px ${statusColor}` }} />
|
|
<Text size="sm" fw={500}>{statusText}</Text>
|
|
</Group>
|
|
{systemStatus && (
|
|
<Text size="xs" c="dimmed" mt={4}>
|
|
{systemStatus.activeSessions} active session{systemStatus.activeSessions !== 1 ? 's' : ''}
|
|
</Text>
|
|
)}
|
|
</Box>
|
|
|
|
<Button
|
|
variant="light"
|
|
color="red"
|
|
fullWidth
|
|
leftSection={logout.isPending ? <Loader size={16} color="red" /> : <TbLogout size={16} />}
|
|
mt="md"
|
|
onClick={handleLogout}
|
|
disabled={logout.isPending}
|
|
>
|
|
{logout.isPending ? 'Logging out...' : 'Log out'}
|
|
</Button>
|
|
</Stack>
|
|
</Box>
|
|
</AppShell.Navbar>
|
|
|
|
<AppShell.Main>
|
|
{children}
|
|
</AppShell.Main>
|
|
</AppShell>
|
|
)
|
|
}
|