Files
desa-darmasaba/src/app/admin/layout.tsx
2026-02-26 22:10:28 +08:00

404 lines
13 KiB
TypeScript

'use client'
import { DarkModeToggle } from "@/components/admin/DarkModeToggle";
import { useDarkMode } from "@/state/darkModeStore";
import { authStore } from "@/store/authStore";
import { themeTokens } from "@/utils/themeTokens";
import {
ActionIcon,
AppShell,
AppShellHeader,
AppShellMain,
AppShellNavbar,
Burger,
Center,
Flex,
Group,
Image,
Loader,
NavLink,
ScrollArea,
Text,
Tooltip,
rem
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import {
IconChevronLeft,
IconChevronRight,
IconLogout2
} from "@tabler/icons-react";
import _ from "lodash";
import Link from "next/link";
import { useRouter, useSelectedLayoutSegments } from "next/navigation";
import { useEffect, useState } from "react";
import { getNavbar } from "./(dashboard)/user&role/_com/dynamicNavbar";
export default function Layout({ children }: { children: React.ReactNode }) {
const { isDark } = useDarkMode();
const tokens = themeTokens(isDark);
const [mounted, setMounted] = useState(false);
const [opened, { toggle, close }] = useDisclosure();
const [loading, setLoading] = useState(true);
const [isLoggingOut, setIsLoggingOut] = useState(false);
const [desktopOpened, { toggle: toggleDesktop }] = useDisclosure(true);
const router = useRouter();
const segments = useSelectedLayoutSegments().map((s) => _.lowerCase(s));
// Ensure component is mounted on client side
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
const fetchUser = async () => {
try {
const res = await fetch('/api/auth/me', {
credentials: 'include'
});
const data = await res.json();
if (data.user) {
if (!data.user.isActive) {
authStore.setUser(null);
router.replace('/waiting-room');
return;
}
const menuRes = await fetch(`/api/admin/user-menu-access?userId=${data.user.id}`, {
credentials: 'include'
});
const menuData = await menuRes.json();
const menuIds = menuData.success && Array.isArray(menuData.menuIds)
? [...menuData.menuIds]
: null;
authStore.setUser({
id: data.user.id,
name: data.user.name,
roleId: Number(data.user.roleId),
menuIds,
isActive: data.user.isActive
});
const currentPath = window.location.pathname;
if (currentPath === '/admin') {
const expectedPath = getRedirectPath(Number(data.user.roleId));
console.log('🔄 Redirecting from /admin to:', expectedPath);
router.replace(expectedPath);
}
} else {
authStore.setUser(null);
router.replace('/login');
}
} catch (error) {
console.error('Gagal memuat data pengguna:', error);
authStore.setUser(null);
router.replace('/login');
} finally {
setLoading(false);
}
};
fetchUser();
}, [router]);
const getRedirectPath = (roleId: number): string => {
switch (roleId) {
case 0:
case 1:
case 2:
return '/admin/landing-page/profil/program-inovasi';
case 3:
return '/admin/kesehatan/posyandu';
case 4:
return '/admin/pendidikan/info-sekolah/jenjang-pendidikan';
default:
return '/admin';
}
};
if (loading || !mounted) {
return (
<AppShell>
<AppShellMain>
<Center h="100vh" bg="#f6f9fc">
<Loader />
</Center>
</AppShellMain>
</AppShell>
);
}
const currentNav = authStore.user
? getNavbar({ roleId: authStore.user.roleId, menuIds: authStore.user.menuIds })
: [];
const handleLogout = async () => {
try {
setIsLoggingOut(true);
const response = await fetch('/api/auth/logout', {
method: 'POST',
credentials: 'include'
});
const result = await response.json();
if (result.success) {
authStore.setUser(null);
localStorage.removeItem('auth_nomor');
localStorage.removeItem('auth_kodeId');
localStorage.removeItem('auth_username');
window.location.href = '/login';
} else {
console.error('Logout failed:', result.message);
authStore.setUser(null);
window.location.href = '/login';
}
} catch (error) {
console.error('Error during logout:', error);
authStore.setUser(null);
window.location.href = '/login';
} finally {
setIsLoggingOut(false);
}
};
const handleNavClick = (path: string) => {
router.push(path);
close();
};
return (
<AppShell
suppressHydrationWarning
header={{ height: 64 }}
navbar={{
width: { base: 260, sm: 280, lg: 300 },
breakpoint: 'sm',
collapsed: {
mobile: !opened,
desktop: !desktopOpened,
},
}}
padding="md"
>
{/*
HEADER / TOPBAR
Spec: Background gradient, border bawah wajib
*/}
<AppShellHeader
style={{
background: mounted ? tokens.colors.bg.header : 'linear-gradient(90deg, #ffffff, #f9fbff)',
borderBottom: `1px solid ${mounted ? tokens.colors.border.soft : '#e9ecef'}`,
padding: '0 16px',
transition: 'background 0.3s ease, border-color 0.3s ease',
}}
px={{ base: 'sm', sm: 'md' }}
py={{ base: 'xs', sm: 'sm' }}
>
<Group w="100%" h="100%" justify="space-between" wrap="nowrap">
<Flex align="center" gap="sm">
<Image
src="/assets/images/darmasaba-icon.png"
alt="Logo Darmasaba"
w={{ base: 32, sm: 40 }}
h={{ base: 32, sm: 40 }}
radius="md"
loading="lazy"
style={{ minWidth: '32px', height: 'auto' }}
/>
<Text fw={700} c={mounted ? tokens.colors.text.brand : '#0A4E78'} fz={{ base: 'md', sm: 'xl' }}>
Admin Darmasaba
</Text>
</Flex>
<Group gap="xs">
{/* Dark Mode Toggle */}
<DarkModeToggle variant="light" size="lg" showTooltip tooltipPosition="bottom" />
{!desktopOpened && (
<Tooltip label="Buka Navigasi" position="bottom" withArrow>
<ActionIcon variant="light" radius="xl" size="lg" onClick={toggleDesktop} color={mounted ? tokens.colors.primary : '#3B82F6'}>
<IconChevronRight />
</ActionIcon>
</Tooltip>
)}
<Burger opened={opened} onClick={toggle} hiddenFrom="sm" size="md" color={mounted ? tokens.colors.text.brand : '#0A4E78'} mr="xs" />
<Tooltip label="Kembali ke Website Desa" position="bottom" withArrow>
<ActionIcon
onClick={() => router.push("/darmasaba")}
color={mounted ? tokens.colors.primary : '#3B82F6'}
radius="xl"
size="lg"
variant="gradient"
gradient={mounted ? tokens.colors.gradient : { from: '#3B82F6', to: '#60A5FA' }}
>
<Image src="/assets/images/darmasaba-icon.png" alt="Logo Darmasaba" w={20} h={20} radius="md" loading="lazy" style={{ minWidth: '20px', height: 'auto' }} />
</ActionIcon>
</Tooltip>
<Tooltip label="Keluar" position="bottom" withArrow>
<ActionIcon
onClick={handleLogout}
color={mounted ? tokens.colors.primary : '#3B82F6'}
radius="xl"
size="lg"
variant="gradient"
gradient={mounted ? tokens.colors.gradient : { from: '#3B82F6', to: '#60A5FA' }}
loading={isLoggingOut}
disabled={isLoggingOut}
>
<IconLogout2 size={22} />
</ActionIcon>
</Tooltip>
</Group>
</Group>
</AppShellHeader>
{/*
SIDEBAR / NAVBAR
Spec: Background --bg-app, active state dengan accent bar
*/}
<AppShellNavbar
component={ScrollArea}
style={{
background: mounted ? tokens.colors.bg.app : '#ffffff',
borderRight: `1px solid ${mounted ? tokens.colors.border.soft : '#e9ecef'}`,
transition: 'background 0.3s ease, border-color 0.3s ease',
}}
p={{ base: 'xs', sm: 'sm' }}
>
<AppShell.Section p="sm">
{currentNav.map((v, k) => {
const isParentActive = segments.includes(_.lowerCase(v.name));
return (
<NavLink
key={k}
defaultOpened={isParentActive}
c={mounted && isParentActive ? tokens.colors.primary : mounted && isDark ? '#E5E7EB' : tokens.colors.text.secondary}
label={
<Text
fw={isParentActive ? 600 : 400}
fz="sm"
style={{
color: mounted && isDark ? '#E5E7EB' : 'inherit',
transition: 'color 150ms ease',
}}
>
{v.name}
</Text>
}
style={{
borderRadius: rem(10),
marginBottom: rem(4),
transition: "background 150ms ease",
...(mounted && isParentActive && !isDark && {
borderLeft: `3px solid ${tokens.colors.primary}`,
}),
}}
styles={{
root: {
'&:hover': {
backgroundColor: mounted && isDark ? '#1E293B' : tokens.colors.bg.hover,
},
...(mounted && isParentActive && isDark && {
backgroundColor: 'rgba(59,130,246,0.25)',
borderLeft: `3px solid ${tokens.colors.primary}`,
}),
}
}}
variant="light"
active={isParentActive}
onClick={(e) => {
e.preventDefault();
if (v.path) handleNavClick(v.path);
}}
href={v.path || undefined}
>
{v.children?.map((child, key) => {
const isChildActive = segments.includes(_.lowerCase(child.name));
return (
<NavLink
key={key}
onClick={(e) => {
e.preventDefault();
handleNavClick(child.path);
}}
href={child.path}
c={mounted && isChildActive ? tokens.colors.primary : mounted && isDark ? '#E5E7EB' : tokens.colors.text.secondary}
label={
<Text
fw={isChildActive ? 600 : 400}
fz="sm"
style={{
color: mounted && isDark ? '#E5E7EB' : 'inherit',
transition: 'color 150ms ease',
}}
>
{child.name}
</Text>
}
styles={{
root: {
borderRadius: rem(8),
marginBottom: rem(2),
transition: 'background 150ms ease',
padding: '6px 12px',
'&:hover': {
backgroundColor: mounted && isDark ? 'rgba(255, 255, 255, 0.05)' : tokens.colors.bg.hover,
},
...(mounted && isChildActive && isDark && {
backgroundColor: 'rgba(59,130,246,0.15)',
borderLeft: `2px solid ${tokens.colors.primary}`,
}),
...(mounted && isChildActive && !isDark && {
backgroundColor: 'rgba(25, 113, 194, 0.1)',
borderLeft: `2px solid ${tokens.colors.primary}`,
}),
}
}}
active={isChildActive}
variant="subtle"
component={Link}
/>
);
})}
</NavLink>
);
})}
</AppShell.Section>
<AppShell.Section py="md">
<Group justify="end" pr="sm">
<Tooltip label={desktopOpened ? "Tutup Navigasi" : "Buka Navigasi"} position="top" withArrow>
<ActionIcon variant="light" radius="xl" size="lg" onClick={toggleDesktop} color={mounted ? tokens.colors.primary : '#3B82F6'}>
<IconChevronLeft />
</ActionIcon>
</Tooltip>
</Group>
</AppShell.Section>
</AppShellNavbar>
{/*
MAIN CONTENT
Spec: Background --bg-base
*/}
<AppShellMain
style={{
background: mounted ? tokens.colors.bg.base : '#f6f9fc',
minHeight: "100vh",
transition: 'background 0.3s ease',
}}
>
{children}
</AppShellMain>
</AppShell>
);
}