QC Kak Inno 28 Okt

QC Kak Ayu 28 Okt
QC Keano 28 Okt
This commit is contained in:
2025-10-30 15:51:12 +08:00
parent a6663bbcee
commit 0befe6a3f2
26 changed files with 974 additions and 515 deletions

View File

@@ -67,16 +67,26 @@ function Page() {
<Text ta="center" fw={600} fz={{ base: "md", md: "lg" }} c="dimmed"> <Text ta="center" fw={600} fz={{ base: "md", md: "lg" }} c="dimmed">
Informasi & Pelayanan Potensi Desa Digital Informasi & Pelayanan Potensi Desa Digital
</Text> </Text>
{/* ✅ Bagian gambar dibuat konsisten tanpa CSS manual */}
<Box
w="100%"
h={{ base: 220, md: 400 }}
style={{
overflow: 'hidden',
borderRadius: 'var(--mantine-radius-lg)',
}}
>
<Image <Image
src={state.findUnique.data?.image?.link || ''} src={state.findUnique.data?.image?.link || ''}
alt={state.findUnique.data?.name || 'Potensi Desa'} alt={state.findUnique.data?.name || 'Potensi Desa'}
radius="lg"
fit="cover" fit="cover"
w="100%" w="100%"
h={{ base: 220, md: 400 }} h="100%"
fallbackSrc="https://placehold.co/800x400?text=Gambar+tidak+tersedia" fallbackSrc="https://placehold.co/800x400?text=Gambar+tidak+tersedia"
loading="lazy" loading="lazy"
radius="lg"
/> />
</Box>
<Text py="md" fz={{ base: "sm", md: "md" }} ta="justify" lh={1.8} dangerouslySetInnerHTML={{ __html: state.findUnique.data?.deskripsi || 'Belum ada deskripsi untuk potensi desa ini.' }} /> <Text py="md" fz={{ base: "sm", md: "md" }} ta="justify" lh={1.8} dangerouslySetInnerHTML={{ __html: state.findUnique.data?.deskripsi || 'Belum ada deskripsi untuk potensi desa ini.' }} />
</Stack> </Stack>
</Paper> </Paper>

View File

@@ -59,7 +59,7 @@ function LambangDesa() {
}} }}
> >
<Text <Text
fz={{ base: 'md', md: 'lg' }} fz={{ base: '1.125rem', md: '1.375rem' }}
lh={1.8} lh={1.8}
c="dark" c="dark"
ta="justify" ta="justify"

View File

@@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
'use client' 'use client'
import stateStrukturBumDes from '@/app/admin/(dashboard)/_state/ekonomi/struktur-organisasi/struktur-organisasi' import stateStrukturBumDes from '@/app/admin/(dashboard)/_state/ekonomi/struktur-organisasi/struktur-organisasi'
import colors from '@/con/colors' import colors from '@/con/colors'
import { import {
@@ -9,20 +9,28 @@ import {
Button, Button,
Card, Card,
Center, Center,
Container,
Group, Group,
Image, Image,
Loader, Loader,
Paper, Paper,
Stack, Stack,
Text, Text,
TextInput,
Title, Title,
Tooltip, Transition
Transition,
} from '@mantine/core' } from '@mantine/core'
import { IconRefresh, IconSearch, IconUsers } from '@tabler/icons-react' import {
IconArrowsMaximize,
IconArrowsMinimize,
IconRefresh,
IconSearch,
IconUsers,
IconZoomIn,
IconZoomOut,
} from '@tabler/icons-react'
import { debounce } from 'lodash'
import { OrganizationChart } from 'primereact/organizationchart' import { OrganizationChart } from 'primereact/organizationchart'
import { useEffect } from 'react' import { useEffect, useRef, useState } from 'react'
import { useProxy } from 'valtio/utils' import { useProxy } from 'valtio/utils'
import BackButton from '../../desa/layanan/_com/BackButto' import BackButton from '../../desa/layanan/_com/BackButto'
@@ -36,35 +44,40 @@ export default function Page() {
paddingBottom: 48, paddingBottom: 48,
}} }}
> >
<Container size="xl" py="xl"> <Box px={{ base: 'md', md: 100 }} py="xl">
<Box px={{ base: 'md', md: 100 }}>
<BackButton /> <BackButton />
</Box>
<Stack align="center" gap="xl" mt="xl"> <Stack align="center" gap="xl" mt="xl">
<Title <Title
order={1} order={1}
ta="center" ta="center"
c={colors['blue-button']} c={colors['blue-button']}
fz={{ base: 28, md: 36, lg: 44 }} fz={{ base: 28, md: 36, lg: 44 }}
> >
Struktur Organisasi Dan SK Pengurus BumDes Struktur Organisasi & SK Pengurus BumDes
</Title> </Title>
<Text ta="center" c="black" maw={800}> <Text ta="center" c="black" maw={800}>
Gambaran visual peran dan pegawai yang ditugaskan. Arahkan kursor Gambaran visual peran dan pengurus yang ditugaskan. Gunakan kontrol
untuk melihat detail atau klik node untuk fokus tampilan. di bawah untuk mencari, memperbesar, atau melihat lebih jelas.
</Text> </Text>
</Stack> </Stack>
<Box mt="lg"> <Box mt="lg">
<StrukturOrganisasiBumDes /> <StrukturOrganisasiBumDes />
</Box> </Box>
</Container> </Box>
</Box> </Box>
) )
} }
function StrukturOrganisasiBumDes() { function StrukturOrganisasiBumDes() {
const stateOrganisasi: any = useProxy(stateStrukturBumDes.pegawai) const stateOrganisasi: any = useProxy(stateStrukturBumDes.pegawai)
const chartContainerRef = useRef<HTMLDivElement>(null)
const [scale, setScale] = useState(1)
const [isFullscreen, setFullscreen] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const debouncedSearch = useRef(
debounce((value: string) => setSearchQuery(value), 400)
).current
useEffect(() => { useEffect(() => {
void stateOrganisasi.findMany.load() void stateOrganisasi.findMany.load()
@@ -81,17 +94,15 @@ function StrukturOrganisasiBumDes() {
<Loader size="lg" /> <Loader size="lg" />
<Text fw={600}>Memuat struktur organisasi</Text> <Text fw={600}>Memuat struktur organisasi</Text>
<Text c="dimmed" size="sm"> <Text c="dimmed" size="sm">
Mengambil data pegawai dan posisi. Mohon tunggu sebentar. Mengambil data pengurus dan posisi. Mohon tunggu sebentar.
</Text> </Text>
</Stack> </Stack>
</Center> </Center>
) )
} }
if ( const data = stateOrganisasi.findMany.data || []
!stateOrganisasi.findMany.data || if (data.length === 0) {
stateOrganisasi.findMany.data.length === 0
) {
return ( return (
<Center py={40}> <Center py={40}>
<Stack align="center" gap="md"> <Stack align="center" gap="md">
@@ -109,11 +120,10 @@ function StrukturOrganisasiBumDes() {
<IconUsers size={56} /> <IconUsers size={56} />
</Center> </Center>
<Title order={3} mt="md"> <Title order={3} mt="md">
Data pegawai belum tersedia Data pengurus belum tersedia
</Title> </Title>
<Text c="dimmed" mt="xs"> <Text c="dimmed" mt="xs">
Belum ada data pegawai yang tercatat untuk BumDes. Silakan coba Belum ada data pengurus yang tercatat untuk BumDes.
muat ulang atau periksa sumber data.
</Text> </Text>
<Group justify="center" mt="lg"> <Group justify="center" mt="lg">
<Button <Button
@@ -124,15 +134,6 @@ function StrukturOrganisasiBumDes() {
> >
Muat Ulang Muat Ulang
</Button> </Button>
<Button
leftSection={<IconSearch size={16} />}
variant="subtle"
onClick={() =>
stateOrganisasi.findMany.load({ query: { q: '' } })
}
>
Cari Pegawai
</Button>
</Group> </Group>
</Paper> </Paper>
</Stack> </Stack>
@@ -140,161 +141,232 @@ function StrukturOrganisasiBumDes() {
) )
} }
// 📊 susun struktur organisasi
const posisiMap = new Map<string, any>() const posisiMap = new Map<string, any>()
const aktifPegawai = data.filter((p: any) => p.isActive)
const aktifPegawai = stateOrganisasi.findMany.data.filter((p: any) => p.isActive);
for (const pegawai of aktifPegawai) { for (const pegawai of aktifPegawai) {
const posisiId = pegawai.posisi.id; const posisiId = pegawai.posisi.id
if (!posisiMap.has(posisiId)) { if (!posisiMap.has(posisiId)) {
posisiMap.set(posisiId, { posisiMap.set(posisiId, {
...pegawai.posisi, ...pegawai.posisi,
pegawaiList: [], pegawaiList: [],
children: [], children: [],
}); })
} }
posisiMap.get(posisiId)!.pegawaiList.push(pegawai); posisiMap.get(posisiId)!.pegawaiList.push(pegawai)
} }
// First, create a map of all unique positions const root: any[] = []
const allPositions = new Map(); posisiMap.forEach((posisi) => {
aktifPegawai.forEach((pegawai: any) => {
if (!allPositions.has(pegawai.posisi.id)) {
allPositions.set(pegawai.posisi.id, {
...pegawai.posisi,
pegawaiList: [],
children: []
});
}
});
// Then assign employees to their positions
aktifPegawai.forEach((pegawai: any) => {
const posisi = allPositions.get(pegawai.posisi.id);
if (posisi) {
posisi.pegawaiList.push(pegawai);
}
});
// Now build the hierarchy
const root = [];
for (const [_, posisi] of allPositions) {
if (posisi.parentId) { if (posisi.parentId) {
const parent = allPositions.get(posisi.parentId); const parent = posisiMap.get(posisi.parentId)
if (parent) { if (parent) parent.children.push(posisi)
parent.children.push(posisi); else root.push(posisi)
} else { } else root.push(posisi)
// Only add to root if it's a top-level position })
if (!posisi.parentId) {
root.push(posisi); const toOrgChartFormat = (node: any): any => {
} const pegawai = node.pegawaiList?.[0]
}
} else {
root.push(posisi);
}
}
function toOrgChartFormat(node: any): any {
return { return {
expanded: true, expanded: true,
type: 'person',
styleClass: 'p-person',
data: { data: {
name: node.pegawaiList?.[0]?.namaLengkap || 'Belum ditugaskan', id: pegawai?.id,
title: node.nama || 'Tanpa jabatan', name: pegawai?.namaLengkap || 'Belum Ditugaskan',
image: node.pegawaiList?.[0]?.image?.link || '/img/default.png', title: node.nama || 'Tanpa Jabatan',
image: pegawai?.image?.link || '/img/default.png',
description: node.deskripsi || '', description: node.deskripsi || '',
positionId: node.id || null,
}, },
children: node.children?.map(toOrgChartFormat) || [], children: node.children?.map(toOrgChartFormat) || [],
} }
} }
const chartData = root.map(toOrgChartFormat) let chartData = root.map(toOrgChartFormat)
// 🔍 filter by search
if (searchQuery) {
const filterNodes = (nodes: any[]): any[] =>
nodes
.map((n) => ({
...n,
children: filterNodes(n.children || []),
}))
.filter(
(n) =>
n.data.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
n.data.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
n.children.length > 0
)
chartData = filterNodes(chartData)
}
// 🔍 fullscreen dan zoom control
const toggleFullscreen = () => {
if (!document.fullscreenElement) {
chartContainerRef.current?.requestFullscreen()
setFullscreen(true)
} else {
document.exitFullscreen()
setFullscreen(false)
}
}
const handleZoomIn = () => setScale((s) => Math.min(s + 0.1, 2))
const handleZoomOut = () => setScale((s) => Math.max(s - 0.1, 0.5))
const resetZoom = () => setScale(1)
return ( return (
<Box py={16} > <Stack align="center" mt="xl">
<Paper {/* 🧭 Kontrol atas */}
radius="md" <Paper shadow="xs" p="md" radius="md" bg={colors['blue-button']}>
p="md" <Group gap="sm" wrap="wrap" justify="center">
<TextInput
placeholder="Cari nama atau jabatan..."
leftSection={<IconSearch size={16} />}
onChange={(e) => debouncedSearch(e.target.value)}
styles={{
input: {
minWidth: 250,
},
}}
/>
<Group gap="xs">
<Button
variant="light"
bg={colors['blue-button-2']}
c={colors['blue-button']}
size="sm"
onClick={handleZoomOut}
leftSection={<IconZoomOut size={16} />}
>
Zoom Out
</Button>
<Box
bg={colors['blue-button-2']}
c={colors['blue-button']}
px={16}
py={8}
style={{
fontSize: 14,
fontWeight: 700,
borderRadius: '8px',
minWidth: 70,
textAlign: 'center',
}}
>
{Math.round(scale * 100)}%
</Box>
<Button
variant="light"
bg={colors['blue-button-2']}
c={colors['blue-button']}
size="sm"
onClick={handleZoomIn}
leftSection={<IconZoomIn size={16} />}
>
Zoom In
</Button>
<Button
variant="light"
bg={colors['blue-button-2']}
c={colors['blue-button']}
size="sm"
onClick={resetZoom}
>
Reset
</Button>
<Button
variant="light"
bg={colors['blue-button-2']}
c={colors['blue-button']}
size="sm"
onClick={toggleFullscreen}
leftSection={
isFullscreen ? (
<IconArrowsMinimize size={16} />
) : (
<IconArrowsMaximize size={16} />
)
}
>
Fullscreen
</Button>
</Group>
</Group>
</Paper>
{/* 📊 Chart Container */}
<Center style={{ width: '100%' }}>
<Box
ref={chartContainerRef}
style={{ style={{
background: 'rgba(28,110,164,0.2)',
border: `1px solid rgba(255,255,255,0.1)`,
overflowX: 'auto', overflowX: 'auto',
overflowY: 'auto',
width: '100%',
padding: '32px 16px',
transition: 'transform 0.2s ease',
transform: `scale(${scale})`,
transformOrigin: 'center top',
}} }}
> >
<OrganizationChart <OrganizationChart
value={chartData} value={chartData}
nodeTemplate={nodeTemplate} nodeTemplate={(node) => <NodeCard node={node} />}
className="p-organizationchart p-organizationchart-horizontal"
/> />
</Paper>
</Box> </Box>
</Center>
</Stack>
) )
} }
function nodeTemplate(node: any) { function NodeCard({ node }: any) {
const imageSrc = node?.data?.image || '/img/default.png' const imageSrc = node?.data?.image || '/img/default.png'
const name = node?.data?.name || 'Tanpa Nama' const name = node?.data?.name || 'Tanpa Nama'
const title = node?.data?.title || 'Tanpa Jabatan' const title = node?.data?.title || 'Tanpa Jabatan'
const description = node?.data?.description || '' const description = node?.data?.description || ''
return ( return (
<Transition mounted transition="pop" duration={240}> <Transition mounted transition="pop" duration={300}>
{(styles) => ( {(styles) => (
<Card <Card
radius="lg" shadow="md"
radius="xl"
withBorder withBorder
style={{ style={{
...styles, ...styles,
width: 260, width: 240,
padding: 16, padding: 20,
background: 'rgba(28,110,164,0.3)', background:
borderColor: 'rgba(255,255,255,0.15)', 'linear-gradient(135deg, rgba(28,110,164,0.15) 0%, rgba(255,255,255,0.95) 100%)',
display: 'flex', borderColor: 'rgba(28, 110, 164, 0.3)',
flexDirection: 'column', transition: 'all 0.3s ease',
alignItems: 'center',
textAlign: 'center',
}} }}
> >
<Image <Stack align="center" gap={10}>
src={imageSrc} <Box
alt={name}
radius="md"
width={120}
height={120}
fit="cover"
style={{ style={{
objectFit: 'cover', width: 90,
border: '2px solid rgba(255,255,255,0.2)', height: 90,
marginBottom: 12, borderRadius: '50%',
overflow: 'hidden',
border: '3px solid rgba(28, 110, 164, 0.4)',
}} }}
loading='lazy' >
/> <Image src={imageSrc} alt={name} fit="cover" loading="lazy" />
<Text fw={700}>{name}</Text> </Box>
<Text size="sm" c="dimmed" mt={4}> <Text fw={700} size="sm" ta="center" c={colors['blue-button']}>
{name}
</Text>
<Text size="xs" c="dimmed" ta="center">
{title} {title}
</Text> </Text>
<Text size="xs" c="dimmed" mt={8} lineClamp={3}> <Text size="xs" c="dimmed" ta="center" lineClamp={3}>
{description || 'Belum ada deskripsi.'} {description || 'Belum ada deskripsi.'}
</Text> </Text>
<Tooltip label="Kembali ke struktur organisasi" withArrow position="bottom"> </Stack>
<Button
variant="light"
size="xs"
mt="md"
onClick={() => {
const id = node?.data?.positionId
if (id && (window as any).scrollTo) {
;(window as any).scrollTo({ top: 0, behavior: 'smooth' })
}
}}
>
Kembali
</Button>
</Tooltip>
</Card> </Card>
)} )}
</Transition> </Transition>
) )
} }

View File

@@ -57,7 +57,8 @@ function Page() {
/> />
</GridCol> </GridCol>
</Grid> </Grid>
<Text fz={'h4'}>Desa Darmasaba berkomitmen mengembangkan teknologi tepat guna yang sesuai dengan kebutuhan masyarakat, mendukung pembangunan berkelanjutan, dan meningkatkan kualitas hidup warga.</Text> <Text fz={'md'}>Desa Darmasaba berkomitmen mengembangkan teknologi tepat guna yang sesuai dengan kebutuhan masyarakat,</Text>
<Text fz={'md'}>mendukung pembangunan berkelanjutan, dan meningkatkan kualitas hidup warga.</Text>
</Box> </Box>
<Box px={{ base: "md", md: 100 }}> <Box px={{ base: "md", md: 100 }}>
<Stack gap={'lg'} p={'lg'}> <Stack gap={'lg'} p={'lg'}>

View File

@@ -0,0 +1,89 @@
'use client'
import { Box, Center, Image, Paper, Skeleton, Stack, Text } from '@mantine/core'
import { useShallowEffect } from '@mantine/hooks'
import { useParams } from 'next/navigation'
import { useProxy } from 'valtio/utils'
import keamananLingkunganState from '@/app/admin/(dashboard)/_state/keamanan/keamanan-lingkungan'
import colors from '@/con/colors'
import BackButton from '../../../desa/layanan/_com/BackButto'
function DetailKeamananLingkunganUser() {
const keamananState = useProxy(keamananLingkunganState)
const params = useParams()
// Ambil data berdasarkan ID dari URL
useShallowEffect(() => {
keamananState.findUnique.load(params?.id as string)
}, [])
// Loading state
if (!keamananState.findUnique.data) {
return (
<Stack py={40}>
<Skeleton height={500} radius="md" />
</Stack>
)
}
const data = keamananState.findUnique.data
return (
<Stack pos="relative" bg={colors.Bg} py="xl" gap="lg">
<Box px={{ base: 'md', md: 100 }}>
<BackButton />
{/* Wrapper Detail */}
<Paper
withBorder
w={{ base: '100%', md: '80%' }}
mx="auto"
bg={colors['white-1']}
p="xl"
radius="lg"
shadow="md"
>
<Stack gap="lg">
{/* Judul */}
<Text
ta="center"
fz={{ base: 'xl', md: '2xl' }}
fw={700}
c={colors['blue-button']}
>
{data?.name || 'Tanpa Judul'}
</Text>
{/* Gambar */}
<Center>
<Image
w={{ base: 250, sm: 400, md: 550 }}
src={data?.image?.link}
alt={data?.name || 'gambar keamanan lingkungan'}
radius="md"
loading="lazy"
fit="cover"
/>
</Center>
{/* Deskripsi */}
<Box>
<Text fz="lg" fw="bold" mb={5}>
Deskripsi
</Text>
<Text
fz="md"
c="dimmed"
dangerouslySetInnerHTML={{ __html: data?.deskripsi || '-' }}
style={{ wordBreak: 'break-word', whiteSpace: 'normal' }}
/>
</Box>
</Stack>
</Paper>
</Box>
</Stack>
)
}
export default DetailKeamananLingkunganUser

View File

@@ -1,17 +1,18 @@
'use client' 'use client'
import keamananLingkunganState from '@/app/admin/(dashboard)/_state/keamanan/keamanan-lingkungan'; import keamananLingkunganState from '@/app/admin/(dashboard)/_state/keamanan/keamanan-lingkungan';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Center, Grid, GridCol, Image, Pagination, Paper, SimpleGrid, Skeleton, Spoiler, Stack, Text, TextInput } from '@mantine/core'; import { Box, Button, Center, Grid, GridCol, Image, Pagination, Paper, SimpleGrid, Skeleton, Stack, Text, TextInput } from '@mantine/core';
import { useDebouncedValue, useShallowEffect } from '@mantine/hooks'; import { useDebouncedValue, useShallowEffect } from '@mantine/hooks';
import { IconSearch } from '@tabler/icons-react'; import { IconSearch } from '@tabler/icons-react';
import { useState } from 'react'; import { useState } from 'react';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import BackButton from '../../desa/layanan/_com/BackButto'; import BackButton from '../../desa/layanan/_com/BackButto';
import { useRouter } from 'next/navigation';
function Page() { function Page() {
const state = useProxy(keamananLingkunganState) const state = useProxy(keamananLingkunganState)
const [expandedMap, setExpandedMap] = useState<Record<number, boolean>>({}); const router = useRouter()
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [debouncedSearch] = useDebouncedValue(search, 500); // 500ms delay const [debouncedSearch] = useDebouncedValue(search, 500); // 500ms delay
const { const {
@@ -26,13 +27,6 @@ function Page() {
load(page, 3, debouncedSearch) load(page, 3, debouncedSearch)
}, [page, debouncedSearch]) }, [page, debouncedSearch])
const toggleExpanded = (index: number, value: boolean) => {
setExpandedMap((prev) => ({
...prev,
[index]: value,
}));
};
if (loading || !data) { if (loading || !data) {
return ( return (
<Box py={10}> <Box py={10}>
@@ -71,54 +65,94 @@ function Page() {
<Box px={{ base: "md", md: 100 }}> <Box px={{ base: "md", md: 100 }}>
<Stack gap={'lg'}> <Stack gap={'lg'}>
<SimpleGrid <SimpleGrid
pb={10} cols={{ base: 1, sm: 2, md: 3 }} spacing="xl" mt="lg">
cols={{ {data.map((v, k) => (
base: 1, <Paper
md: 3, key={k}
}}> radius="xl"
{data.map((v, k) => { shadow="md"
return ( withBorder
<Paper radius={10} key={k} bg={colors["white-trans-1"]}> p="lg"
<Stack gap={'xs'}> bg={colors['white-trans-1']}
<Center px={10} py={20}> style={{
<Image loading="lazy" src={v.image?.link} alt='' /> transition: 'all 200ms ease',
</Center> cursor: 'pointer',
<Box px={'lg'}> display: 'flex',
<Box pb={20}> flexDirection: 'column',
<Text pb={10} c={colors["blue-button"]} fw={"bold"} fz={"h3"}> justifyContent: 'space-between',
height: '100%',
}}
>
<Stack align="center" gap="sm" style={{ flexGrow: 1 }}>
<Box
style={{
width: '100%',
aspectRatio: '16/9',
borderRadius: '12px',
overflow: 'hidden',
position: 'relative',
}}
>
<Image
src={v.image?.link}
alt={v.name}
fit="cover"
loading="lazy"
style={{
width: '100%',
height: '100%',
transition: 'transform 0.4s ease',
}}
onMouseEnter={(e) => (e.currentTarget.style.transform = 'scale(1.05)')}
onMouseLeave={(e) => (e.currentTarget.style.transform = 'scale(1)')}
/>
</Box>
<Text ta="center" fw={700} fz="lg" c={colors['blue-button']}>
{v.name} {v.name}
</Text> </Text>
<Spoiler <Text
showLabel={ fz="sm"
<Text fw="bold" fz="sm" c={colors['blue-button']}> ta="justify"
Show more lineClamp={3}
</Text> lh={1.6}
} style={{
hideLabel={ minHeight: '4.8em',
<Text fw="bold" fz="sm" c={colors['blue-button']}> }}
Hide details
</Text>
}
expanded={expandedMap[k] || false}
onExpandedChange={(val) => toggleExpanded(k, val)}
> >
<Text pb={10} fz={"h4"} ta={'justify'} style={{ wordBreak: "break-word", whiteSpace: "normal" }} dangerouslySetInnerHTML={{ __html: v.deskripsi }} /> <span
</Spoiler> style={{ wordBreak: 'break-word', whiteSpace: 'normal' }}
</Box> dangerouslySetInnerHTML={{ __html: v.deskripsi }}
</Box> />
</Text>
</Stack> </Stack>
<Center mt="md">
<Button
variant="light"
onClick={() => {
router.push(`/darmasaba/keamanan/keamanan-lingkungan-pecalang-patwal/${v.id}`)
}}
>
Detail
</Button>
</Center>
</Paper> </Paper>
) ))}
})}
</SimpleGrid> </SimpleGrid>
</Stack> </Stack>
</Box> </Box>
<Center>
<Center mt="xl">
<Pagination <Pagination
value={page} value={page}
onChange={(newPage) => load(newPage)} // ini penting! onChange={(newPage) => load(newPage, 3, search)}
total={totalPages} total={totalPages}
my="md" size="lg"
radius="xl"
styles={{
control: {
border: `1px solid ${colors['blue-button']}`,
},
}}
/> />
</Center> </Center>
</Stack> </Stack>

View File

@@ -45,7 +45,7 @@ function Page() {
<Text fz={{ base: "h1", md: "2.5rem" }} c={colors["blue-button"]} fw={"bold"}> <Text fz={{ base: "h1", md: "2.5rem" }} c={colors["blue-button"]} fw={"bold"}>
Kontak Darurat Kontak Darurat
</Text> </Text>
<Text fz={{ base: "h4", md: "h3" }} > <Text fz="md" >
Desa Darmasaba, Kecamatan Abiansemal, Kabupaten Badung. Desa Darmasaba, Kecamatan Abiansemal, Kabupaten Badung.
</Text> </Text>
</Box> </Box>

View File

@@ -9,21 +9,16 @@ import { useProxy } from 'valtio/utils';
import BackButton from '../../desa/layanan/_com/BackButto'; import BackButton from '../../desa/layanan/_com/BackButto';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
function Page() { function Page() {
const state = useProxy(polsekTerdekatState.findFirst); const state = useProxy(polsekTerdekatState.findFirst);
const router = useRouter() const router = useRouter();
const { const { data, loading, load } = state;
data,
loading,
load,
} = state;
useEffect(() => { useEffect(() => {
load(); load();
}, []); }, []);
// kalau masih loading // Loading state
if (loading) { if (loading) {
return ( return (
<Stack py={10}> <Stack py={10}>
@@ -32,18 +27,18 @@ function Page() {
); );
} }
// kalau data kosong // Data kosong
if (!data) { if (!data) {
return ( return (
<Stack pos={"relative"} bg={colors.Bg} py={"xl"} gap={"22"}> <Stack pos="relative" bg={colors.Bg} py="xl" gap={22}>
<Box px={{ base: 'md', md: 100 }}> <Box px={{ base: 'md', md: 100 }}>
<BackButton /> <BackButton />
</Box> </Box>
<Box pb={10} px={{ base: 20, md: 100 }}> <Box pb={10} px={{ base: 20, md: 100 }}>
<Text fz={{ base: "h1", md: "2.5rem" }} c={colors["blue-button"]} fw={"bold"}> <Text fz={{ base: 'h1', md: '2.5rem' }} c={colors['blue-button']} fw="bold">
Kantor Polisi Terdekat Kantor Polisi Terdekat
</Text> </Text>
<Text pb={15} fz={'md'} > <Text pb={15} fz="md">
Desa Darmasaba, Kecamatan Abiansemal, Kabupaten Badung Desa Darmasaba, Kecamatan Abiansemal, Kabupaten Badung
</Text> </Text>
</Box> </Box>
@@ -57,79 +52,150 @@ function Page() {
} }
return ( return (
<Stack pos={"relative"} bg={colors.Bg} py={"xl"} gap={"22"}> <Stack pos="relative" bg={colors.Bg} py="xl" gap={22}>
<Box px={{ base: 'md', md: 100 }}> <Box px={{ base: 'md', md: 100 }}>
<BackButton /> <BackButton />
</Box> </Box>
<Box pb={10} px={{ base: 20, md: 100 }}> <Box pb={10} px={{ base: 20, md: 100 }}>
<Text fz={{ base: "h1", md: "2.5rem" }} c={colors["blue-button"]} fw={"bold"}> <Text fz={{ base: 'h1', md: '2.5rem' }} c={colors['blue-button']} fw="bold">
Kantor Polisi Terdekat Kantor Polisi Terdekat
</Text> </Text>
<Text pb={15} fz={'h4'} > <Text pb={15} fz="h4">
Desa Darmasaba, Kecamatan Abiansemal, Kabupaten Badung Desa Darmasaba, Kecamatan Abiansemal, Kabupaten Badung
</Text> </Text>
</Box> </Box>
<Box px={{ base: "md", md: 100 }}>
<Stack gap={'lg'}> <Box px={{ base: 'md', md: 100 }}>
<Paper radius={10} bg={colors["white-trans-1"]} p={'xl'}> <Stack gap="lg">
<Stack gap={'xs'}> <Paper radius={10} bg={colors['white-trans-1']} p="xl">
<SimpleGrid <Stack gap="xs">
cols={{ <SimpleGrid cols={{ base: 1, md: 2 }}>
base: 1,
md: 2,
}}
>
{/* Content Sebelah Kiri */}
{loading ? ( {loading ? (
<Center><Skeleton h={400} /></Center> <Center>
) : data ? ( <Skeleton h={400} />
</Center>
) : (
<> <>
{/* === KIRI === */}
<Box> <Box>
<Text c={colors["blue-button"]} fw={'bold'} fz={'h2'}>{data.nama}</Text> <Text c={colors['blue-button']} fw="bold" fz="h2">
<Text c={colors["blue-button"]} fz={'sm'}>{data.jarakKeDesa}</Text> {data.nama}
<Flex py={10} gap={9} align={'center'}> </Text>
<IconPin size={25} color={colors["blue-button"]} /> <Text c={colors['blue-button']} fz="sm">
<Text c={colors["blue-button"]} fz={'lg'}>{data.alamat}</Text> {data.jarakKeDesa}
</Flex> </Text>
<Flex gap={9} align={'center'}>
<IconPhone size={25} color={colors["blue-button"]} /> {/* Alamat */}
<Text c={colors["blue-button"]} fz={'lg'}>{data.nomorTelepon}</Text> <Flex
</Flex>
<Flex py={10} gap={9} align={'center'}>
<IconClock size={25} color={colors["blue-button"]} />
<Text c={colors["blue-button"]} fz={'lg'}>{data.jamOperasional}</Text>
</Flex>
<Box>
<Text c={colors["blue-button"]} fw={'bold'} fz={'h2'}>Layanan Yang Tersedia :</Text>
<SimpleGrid
py={10} py={10}
cols={{ gap={9}
base: 1, align="flex-start"
md: 2, wrap="wrap"
style={{ wordBreak: 'break-word' }}
>
<Box w={25} mt={3}>
<IconPin size={22} />
</Box>
<Text
fz="lg"
style={{
flex: 1,
wordBreak: 'break-word',
lineHeight: 1.4,
}} }}
> >
<Box> {data.alamat}
<Text c={colors["blue-button"]} fz={'lg'}>{data.layananPolsek.nama}</Text> </Text>
</Flex>
{/* Telepon */}
<Flex
gap={9}
align="flex-start"
wrap="wrap"
style={{ wordBreak: 'break-word' }}
>
<Box w={25} mt={3}>
<IconPhone size={22} />
</Box> </Box>
<Text fz="lg">
{data.nomorTelepon}
</Text>
</Flex>
{/* Jam Operasional */}
<Flex
py={10}
gap={9}
align="flex-start"
wrap="wrap"
style={{ wordBreak: 'break-word' }}
>
<Box w={25} mt={3}>
<IconClock size={22} />
</Box>
<Text fz="lg">
{data.jamOperasional}
</Text>
</Flex>
{/* Layanan */}
<Box>
<Text c={colors['blue-button']} fw="bold" fz="h2">
Layanan Yang Tersedia :
</Text>
<SimpleGrid py={10} cols={{ base: 1, md: 2 }}>
<Text fz="lg">
{data.layananPolsek.nama}
</Text>
</SimpleGrid> </SimpleGrid>
</Box> </Box>
<Box> <Box>
<Button bg={colors["blue-button"]} onClick={() => router.push(`/darmasaba/keamanan/polsek-terdekat/semua-polsek`)} rightSection={<IconArrowDown size={20} />}>Lihat Kantor Polisi Lainnya</Button> <Button
bg={colors['blue-button']}
onClick={() =>
router.push(`/darmasaba/keamanan/polsek-terdekat/semua-polsek`)
}
rightSection={<IconArrowDown size={20} />}
>
Lihat Kantor Polisi Lainnya
</Button>
</Box> </Box>
</Box> </Box>
<Box pos={'relative'}>
{/* === KANAN === */}
<Box pos="relative">
<Box style={{ position: 'absolute', top: 0, right: 0 }}> <Box style={{ position: 'absolute', top: 0, right: 0 }}>
<Badge size='lg' c={'#287407'} bg={'#A8EDC4'}>Buka</Badge> <Badge size="lg" c="#287407" bg="#A8EDC4">
Buka
</Badge>
</Box> </Box>
<Box pt={40}> <Box pt={40}>
<iframe style={{ border: 2, width: "100%" }} src={data.embedMapUrl} width="550" height="300" ></iframe> <iframe
style={{ border: 2, width: '100%' }}
src={data.embedMapUrl}
width="550"
height="300"
></iframe>
</Box> </Box>
<Box pt={20}> <Box pt={20}>
<Button onClick={() => router.push(data.linkPetunjukArah)} fullWidth bg={colors["blue-button"]} radius={10} leftSection={<IconNavigation size={20} />}>Petunjuk Arah</Button> <Button
onClick={() => router.push(data.linkPetunjukArah)}
fullWidth
bg={colors['blue-button']}
radius={10}
leftSection={<IconNavigation size={20} />}
>
Petunjuk Arah
</Button>
</Box> </Box>
</Box> </Box>
</> </>
) : null} )}
</SimpleGrid> </SimpleGrid>
</Stack> </Stack>
</Paper> </Paper>

View File

@@ -56,8 +56,11 @@ function Page() {
/> />
</GridCol> </GridCol>
</Grid> </Grid>
<Text px={{ base: 'md', md: 100 }} ta={"justify"} fz={{ base: "h4", md: "h3" }} > <Text px={{ base: 'md', md: 100 }} ta={"justify"} fz="md" >
Keamanan dan ketertiban lingkungan di Desa Darmasaba dijaga melalui peran aktif Pecalang dan Patwal (Patroli Pengawal). Mereka bertugas memastikan desa tetap aman, tertib, dan kondusif bagi seluruh warga. Keamanan dan ketertiban lingkungan di Desa Darmasaba dijaga melalui peran aktif Pecalang dan Patwal (Patroli Pengawal).
</Text>
<Text px={{ base: 'md', md: 100 }} ta={"justify"} fz="md" >
Mereka bertugas memastikan desa tetap aman, tertib, dan kondusif bagi seluruh warga.
</Text> </Text>
</Box> </Box>
<Box px={{ base: "md", md: 100 }}> <Box px={{ base: "md", md: 100 }}>

View File

@@ -98,9 +98,13 @@ function Page() {
style={{ style={{
transition: 'all 200ms ease', transition: 'all 200ms ease',
cursor: 'pointer', cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between', // ✅ biar button selalu di bawah
height: '100%', // ✅ bikin tinggi seragam
}} }}
> >
<Stack align="center" gap="sm"> <Stack align="center" gap="sm" style={{ flexGrow: 1 }}>
<Box <Box
style={{ style={{
width: '100%', width: '100%',
@@ -128,9 +132,25 @@ function Page() {
<Text ta="center" fw={700} fz="lg" c={colors['blue-button']}> <Text ta="center" fw={700} fz="lg" c={colors['blue-button']}>
{v.name} {v.name}
</Text> </Text>
<Text fz="sm" ta="center" lineClamp={3}>
<span style={{ wordBreak: "break-word", whiteSpace: "normal" }} dangerouslySetInnerHTML={{ __html: v.deskripsi }} /> <Text
fz="sm"
ta="center"
lineClamp={3}
lh={1.6}
style={{
minHeight: '4.8em', // tinggi tetap 3 baris
}}
>
<span
style={{ wordBreak: 'break-word', whiteSpace: 'normal' }}
dangerouslySetInnerHTML={{ __html: v.deskripsi }}
/>
</Text> </Text>
</Stack>
{/* ✅ Tombol selalu di bagian bawah card */}
<Center mt="md">
<Button <Button
variant="light" variant="light"
leftSection={<IconBrandWhatsapp size={18} />} leftSection={<IconBrandWhatsapp size={18} />}
@@ -138,9 +158,13 @@ function Page() {
href={`https://wa.me/${v.whatsapp.replace(/\D/g, '')}`} href={`https://wa.me/${v.whatsapp.replace(/\D/g, '')}`}
target="_blank" target="_blank"
aria-label="Hubungi WhatsApp" aria-label="Hubungi WhatsApp"
>WhatsApp</Button> >
</Stack> WhatsApp
</Button>
</Center>
</Paper> </Paper>
))} ))}
</SimpleGrid> </SimpleGrid>
)} )}

View File

@@ -1,15 +1,14 @@
'use client'; 'use client';
import penangananDarurat from '@/app/admin/(dashboard)/_state/kesehatan/penanganan-darurat/penangananDarurat'; import penangananDarurat from '@/app/admin/(dashboard)/_state/kesehatan/penanganan-darurat/penangananDarurat';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Image, Paper, Skeleton, Stack, Text } from '@mantine/core'; import { Box, Image, Paper, Skeleton, Stack, Text } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks'; import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack } from '@tabler/icons-react'; import { useParams } from 'next/navigation';
import { useParams, useRouter } from 'next/navigation';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import BackButton from '../../../desa/layanan/_com/BackButto';
function DetailPenangananDaruratUser() { function DetailPenangananDaruratUser() {
const state = useProxy(penangananDarurat); const state = useProxy(penangananDarurat);
const router = useRouter();
const params = useParams(); const params = useParams();
useShallowEffect(() => { useShallowEffect(() => {
@@ -32,14 +31,7 @@ function DetailPenangananDaruratUser() {
<Box py={20}> <Box py={20}>
{/* Tombol Back */} {/* Tombol Back */}
<Box px={{ base: 'md', md: 100 }}> <Box px={{ base: 'md', md: 100 }}>
<Button <BackButton/>
variant="subtle"
onClick={() => router.back()}
leftSection={<IconArrowBack size={22} color={colors['blue-button']} />}
mb={20}
>
Kembali
</Button>
</Box> </Box>
{/* Wrapper Detail */} {/* Wrapper Detail */}

View File

@@ -1,7 +1,7 @@
// Create a new component: components/EdukasiCard.tsx // Create a new component: components/EdukasiCard.tsx
'use client'; 'use client';
import { Box, Paper, Stack, Text, Tooltip } from '@mantine/core'; import { Box, Paper, Stack, Text } from '@mantine/core';
import { ReactNode } from 'react'; import { ReactNode } from 'react';
interface EdukasiCardProps { interface EdukasiCardProps {
@@ -31,7 +31,6 @@ export function EdukasiCard({ icon, title, description, color = '#1e88e5' }: Edu
<Box> <Box>
<Stack align="center" gap="xs" mb="md"> <Stack align="center" gap="xs" mb="md">
<Box style={{ color }}>{icon}</Box> <Box style={{ color }}>{icon}</Box>
<Tooltip label={title} maw={250} multiline withArrow position="top">
<Text <Text
fz={{ base: 'h5', md: 'h4' }} fz={{ base: 'h5', md: 'h4' }}
fw={700} fw={700}
@@ -47,7 +46,6 @@ export function EdukasiCard({ icon, title, description, color = '#1e88e5' }: Edu
}} }}
dangerouslySetInnerHTML={{ __html: title }} dangerouslySetInnerHTML={{ __html: title }}
/> />
</Tooltip>
</Stack> </Stack>
<Text <Text
size="sm" size="sm"

View File

@@ -62,7 +62,6 @@ export default function EdukasiLingkunganPage() {
</Text> </Text>
<Text <Text
fz={{ base: 'md', md: 'lg' }} fz={{ base: 'md', md: 'lg' }}
c="dimmed"
maw={800} maw={800}
mx="auto" mx="auto"
> >
@@ -78,21 +77,21 @@ export default function EdukasiLingkunganPage() {
verticalSpacing={{ base: 'md', md: 'xl' }} verticalSpacing={{ base: 'md', md: 'xl' }}
> >
<EdukasiCard <EdukasiCard
icon={<IconLeaf size={32} />} icon={<IconLeaf size={45} />}
title={tujuan.data?.judul || ''} title={tujuan.data?.judul || ''}
description={tujuan.data?.deskripsi || ''} description={tujuan.data?.deskripsi || ''}
color={colors['blue-button']} color={colors['blue-button']}
/> />
<EdukasiCard <EdukasiCard
icon={<IconRecycle size={32} />} icon={<IconRecycle size={45} />}
title={materi.data?.judul || ''} title={materi.data?.judul || ''}
description={materi.data?.deskripsi || ''} description={materi.data?.deskripsi || ''}
color={colors['blue-button']} color={colors['blue-button']}
/> />
<EdukasiCard <EdukasiCard
icon={<IconPlant2 size={32} />} icon={<IconPlant2 size={45} />}
title={contoh.data?.judul || ''} title={contoh.data?.judul || ''}
description={contoh.data?.deskripsi || ''} description={contoh.data?.deskripsi || ''}
color={colors['blue-button']} color={colors['blue-button']}

View File

@@ -44,13 +44,12 @@ function Page() {
<Box style={{ display: 'flex', height: '100%' }}> <Box style={{ display: 'flex', height: '100%' }}>
<Paper <Paper
p="lg" p="lg"
bg="linear-gradient(145deg, #DFE3E8FF 0%, #EFF1F4FF 100%)"
style={{ style={{
borderRadius: 16, borderRadius: 16,
boxShadow: '0 0 20px rgba(28, 110, 164, 0.5)',
width: '100%', width: '100%',
display: 'flex', display: 'flex',
flexDirection: 'column' flexDirection: 'column',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)'
}} }}
> >
<Stack gap="md" px={20} style={{ height: '100%' }}> <Stack gap="md" px={20} style={{ height: '100%' }}>
@@ -74,13 +73,12 @@ function Page() {
<Box style={{ display: 'flex', height: '100%' }}> <Box style={{ display: 'flex', height: '100%' }}>
<Paper <Paper
p="lg" p="lg"
bg="linear-gradient(145deg, #DFE3E8FF 0%, #EFF1F4FF 100%)"
style={{ style={{
borderRadius: 16, borderRadius: 16,
boxShadow: '0 0 20px rgba(28, 110, 164, 0.5)',
width: '100%', width: '100%',
display: 'flex', display: 'flex',
flexDirection: 'column' flexDirection: 'column',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)'
}} }}
> >
<Stack gap="md" px={20} style={{ height: '100%' }}> <Stack gap="md" px={20} style={{ height: '100%' }}>
@@ -105,13 +103,12 @@ function Page() {
<Box> <Box>
<Paper <Paper
p="lg" p="lg"
bg="linear-gradient(145deg, #DFE3E8FF 0%, #EFF1F4FF 100%)"
style={{ style={{
borderRadius: 16, borderRadius: 16,
boxShadow: '0 0 20px rgba(28, 110, 164, 0.5)',
width: '100%', width: '100%',
display: 'flex', display: 'flex',
flexDirection: 'column' flexDirection: 'column',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)'
}} }}
> >
<Stack gap="md" px={20} style={{ height: '100%' }}> <Stack gap="md" px={20} style={{ height: '100%' }}>

View File

@@ -91,7 +91,7 @@ function Page() {
<Box style={{ alignContent: 'center', alignItems: 'center' }}> <Box style={{ alignContent: 'center', alignItems: 'center' }}>
{iconMap[v.icon] ? React.createElement(iconMap[v.icon]) : null} {iconMap[v.icon] ? React.createElement(iconMap[v.icon]) : null}
</Box> </Box>
<Text fw={'bold'} fz={{ base: "lg", md: "xl" }} c={'black'}>{v.name}</Text> <Text fz={{ base: "lg", md: "xl" }} c={'black'}>{v.name}</Text>
</Flex> </Flex>
</Paper> </Paper>
</Box> </Box>

View File

@@ -68,7 +68,7 @@ function Page() {
<Image src="/darmasaba-icon.png" w={{ base: 70, md: 100 }} alt="Logo Desa Darmasaba" loading="lazy" /> <Image src="/darmasaba-icon.png" w={{ base: 70, md: 100 }} alt="Logo Desa Darmasaba" loading="lazy" />
</Center> </Center>
<Text ta="center" fz={{ base: "1.8rem", md: "2.5rem" }} c={colors["blue-button"]} fw="bold" lh={1.4}> <Text ta="center" fz={{ base: "1.8rem", md: "2.5rem" }} c={colors["blue-button"]} fw="bold" lh={1.4}>
Daftar Informasi Publik Desa Darmasaba Daftar Informasi Publik
</Text> </Text>
<Box px={{ base: "md", md: 100 }}> <Box px={{ base: "md", md: 100 }}>
<Stack gap="lg"> <Stack gap="lg">

View File

@@ -239,7 +239,7 @@ const state = useProxy(indeksKepuasanState.responden);
{/* Chart Rating */} {/* Chart Rating */}
<Paper bg={colors['white-1']} p="md" radius="md"> <Paper bg={colors['white-1']} p="md" radius="md">
<Stack> <Stack>
<Title order={4}>Pilihan</Title> <Title order={4}>Ulasan</Title>
{donutDataRating.every(item => item.value === 0) ? ( {donutDataRating.every(item => item.value === 0) ? (
<Text c="dimmed" ta="center" my="md"> <Text c="dimmed" ta="center" my="md">
Belum ada data untuk ditampilkan dalam grafik Belum ada data untuk ditampilkan dalam grafik
@@ -505,7 +505,7 @@ const state = useProxy(indeksKepuasanState.responden);
{/* Chart Rating */} {/* Chart Rating */}
<Paper bg={colors['white-1']} p="md" radius="md"> <Paper bg={colors['white-1']} p="md" radius="md">
<Stack> <Stack>
<Title order={4}>Pilihan</Title> <Title order={4}>Ulasan</Title>
{donutDataRating.every(item => item.value === 0) ? ( {donutDataRating.every(item => item.value === 0) ? (
<Text c="dimmed" ta="center" my="md"> <Text c="dimmed" ta="center" my="md">
Belum ada data untuk ditampilkan dalam grafik Belum ada data untuk ditampilkan dalam grafik

View File

@@ -39,9 +39,9 @@ function DetailPegawaiUser() {
const data = statePegawai.findUnique.data; const data = statePegawai.findUnique.data;
return ( return (
<Box px={{ base: 'sm', md: 'lg' }} py="xl"> <Box px={{ base: 'md', md: 100 }} py="xl">
{/* Back button */} {/* Back button */}
<Group mb="lg"> <Group mb="lg" px={{ base: 'md', md: 100 }}>
<Box <Box
onClick={() => router.back()} onClick={() => router.back()}
style={{ style={{

View File

@@ -1,7 +1,6 @@
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
'use client' 'use client'
import stateStrukturPPID from '@/app/admin/(dashboard)/_state/ppid/struktur_ppid/struktur_PPID' import stateStrukturPPID from '@/app/admin/(dashboard)/_state/ppid/struktur_ppid/struktur_PPID'
import ScrollToTopButton from '@/app/darmasaba/_com/scrollToTopButton' import ScrollToTopButton from '@/app/darmasaba/_com/scrollToTopButton'
import colors from '@/con/colors' import colors from '@/con/colors'
@@ -10,7 +9,6 @@ import {
Button, Button,
Card, Card,
Center, Center,
Container,
Group, Group,
Image, Image,
Loader, Loader,
@@ -36,6 +34,7 @@ import { OrganizationChart } from 'primereact/organizationchart'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useProxy } from 'valtio/utils' import { useProxy } from 'valtio/utils'
import BackButton from '../../desa/layanan/_com/BackButto' import BackButton from '../../desa/layanan/_com/BackButto'
import './struktur.css'
export default function Page() { export default function Page() {
return ( return (
@@ -47,10 +46,9 @@ export default function Page() {
paddingBottom: 48, paddingBottom: 48,
}} }}
> >
<Container size="xl" py="xl"> <Box px={{ base: 'md', md: 100 }} py={"xl"}>
<Box px={{ base: 'md', md: 100 }}>
<BackButton /> <BackButton />
</Box>
<Stack align="center" gap="xl" mt="xl"> <Stack align="center" gap="xl" mt="xl">
<Title <Title
order={1} order={1}
@@ -65,12 +63,12 @@ export default function Page() {
untuk melihat detail atau klik node untuk fokus tampilan. untuk melihat detail atau klik node untuk fokus tampilan.
</Text> </Text>
</Stack> </Stack>
<Box mt="lg"> <Box mt="lg">
<StrukturOrganisasiPPID /> <StrukturOrganisasiPPID />
</Box> </Box>
</Container> </Box>
{/* Tombol Scroll ke Atas */}
<ScrollToTopButton /> <ScrollToTopButton />
</Box> </Box>
) )
@@ -84,7 +82,7 @@ function StrukturOrganisasiPPID() {
const [isFullscreen, setFullscreen] = useState(false) const [isFullscreen, setFullscreen] = useState(false)
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
// debounce untuk pencarian // debounce pencarian
const debouncedSearch = useRef( const debouncedSearch = useRef(
debounce((value: string) => { debounce((value: string) => {
setSearchQuery(value) setSearchQuery(value)
@@ -112,7 +110,8 @@ function StrukturOrganisasiPPID() {
) )
} }
if (!stateOrganisasi.findMany.data || stateOrganisasi.findMany.data.length === 0) { const data = stateOrganisasi.findMany.data || []
if (data.length === 0) {
return ( return (
<Center py={40}> <Center py={40}>
<Stack align="center" gap="md"> <Stack align="center" gap="md">
@@ -151,9 +150,9 @@ function StrukturOrganisasiPPID() {
) )
} }
// Buat struktur organisasi // 🧩 buat struktur organisasi
const posisiMap = new Map<string, any>() const posisiMap = new Map<string, any>()
const aktifPegawai = stateOrganisasi.findMany.data.filter((p: any) => p.isActive) const aktifPegawai = data.filter((p: any) => p.isActive)
for (const pegawai of aktifPegawai) { for (const pegawai of aktifPegawai) {
const posisiId = pegawai.posisi.id const posisiId = pegawai.posisi.id
@@ -176,19 +175,15 @@ function StrukturOrganisasiPPID() {
} else root.push(posisi) } else root.push(posisi)
}) })
function toOrgChartFormat(node: any): any { const toOrgChartFormat = (node: any): any => {
const pegawai = node.pegawaiList?.[0] const pegawai = node.pegawaiList?.[0]
return { return {
expanded: true, expanded: true,
type: 'person',
styleClass: 'p-person',
data: { data: {
id: pegawai?.id || null, id: pegawai?.id,
name: pegawai?.namaLengkap || 'Belum ditugaskan', name: pegawai?.namaLengkap || 'Belum Ditugaskan',
title: node.nama || 'Tanpa jabatan', title: node.nama || 'Tanpa Jabatan',
image: pegawai?.image?.link || '/img/default.png', image: pegawai?.image?.link || '/img/default.png',
description: node.deskripsi || '',
positionId: node.id || null,
}, },
children: node.children?.map(toOrgChartFormat) || [], children: node.children?.map(toOrgChartFormat) || [],
} }
@@ -213,7 +208,7 @@ function StrukturOrganisasiPPID() {
chartData = filterNodes(chartData) chartData = filterNodes(chartData)
} }
// 🧭 fungsi fullscreen // 🎬 fullscreen & zoom control
const toggleFullscreen = () => { const toggleFullscreen = () => {
if (!document.fullscreenElement) { if (!document.fullscreenElement) {
chartContainerRef.current?.requestFullscreen() chartContainerRef.current?.requestFullscreen()
@@ -224,138 +219,249 @@ function StrukturOrganisasiPPID() {
} }
} }
// 🧭 fungsi zoom const handleZoomIn = () => setScale((s) => Math.min(s + 0.1, 2))
const handleZoomIn = () => setScale((prev) => Math.min(prev + 0.1, 2)) const handleZoomOut = () => setScale((s) => Math.max(s - 0.1, 0.5))
const handleZoomOut = () => setScale((prev) => Math.max(prev - 0.1, 0.5))
const resetZoom = () => setScale(1) const resetZoom = () => setScale(1)
return ( return (
<Stack align="center" mt="xl"> <Stack align="center" mt="xl">
{/* 🔍 Search + Zoom + Fullscreen controls */} {/* 🔍 Controls */}
<Group mb="md" justify="center" gap="sm" align="center"> <Paper
shadow="xs"
p="md"
radius="md"
style={{
background: colors['blue-button']
}}
>
<Group gap="sm" wrap="wrap" justify="center">
<TextInput <TextInput
placeholder="Cari nama atau jabatan..." placeholder="Cari nama atau jabatan..."
leftSection={<IconSearch size={16} />} leftSection={<IconSearch size={16} />}
onChange={(e) => debouncedSearch(e.target.value)} onChange={(e) => debouncedSearch(e.target.value)}
styles={{
input: {
minWidth: 250,
},
}}
/> />
<Button variant="light" size="sm" onClick={handleZoomOut}> <Group gap="xs">
<IconZoomOut size={16} /> <Button
variant="light"
bg={colors['blue-button-2']}
size="sm"
onClick={handleZoomOut}
leftSection={<IconZoomOut size={16} />}
c={colors['blue-button']}
>
Zoom Out
</Button> </Button>
{/* 🔍 Tambahkan indikator zoom di sini */}
{/* Floating Zoom Indicator */}
<Box <Box
bg="#C3D0E8" bg={colors['blue-button-2']}
c="blue" c={colors['blue-button']}
px={9} px={16}
py={8} py={8}
style={{ style={{
fontSize: 14, fontSize: 14,
fontWeight: 600, fontWeight: 700,
borderRadius: '5px', borderRadius: '8px',
minWidth: 70,
textAlign: 'center',
}} }}
> >
{Math.round(scale * 100)}% {Math.round(scale * 100)}%
</Box> </Box>
<Button
<Button variant="light" size="sm" onClick={handleZoomIn}> bg={colors['blue-button-2']}
<IconZoomIn size={16} /> c={colors['blue-button']}
variant="light"
size="sm"
onClick={handleZoomIn}
leftSection={<IconZoomIn size={16} />}
>
Zoom In
</Button> </Button>
<Button variant="light" size="sm" onClick={resetZoom}> <Button
bg={colors['blue-button-2']}
c={colors['blue-button']}
variant="light"
size="sm"
onClick={resetZoom}
>
Reset Reset
</Button> </Button>
<Button <Button
variant="light" bg={colors['blue-button-2']}
c={colors['blue-button']}
size="sm" size="sm"
onClick={toggleFullscreen} onClick={toggleFullscreen}
leftSection={ leftSection={
isFullscreen ? <IconArrowsMinimize size={16} /> : <IconArrowsMaximize size={16} /> isFullscreen ? (
<IconArrowsMinimize size={16} />
) : (
<IconArrowsMaximize size={16} />
)
} }
> >
{isFullscreen ? 'Keluar' : 'Fullscreen'} Fullscreen
</Button> </Button>
</Group> </Group>
</Group>
</Paper>
{/* 🧩 Chart Container */}
{/* Chart Container */} <Center style={{ width: '100%' }}>
<Box <Box
ref={chartContainerRef} ref={chartContainerRef}
style={{ style={{
overflow: 'auto', overflowX: 'auto',
overflowY: 'auto',
width: '100%',
maxWidth: '100%',
padding: '32px 16px',
transition: 'transform 0.2s ease',
transform: `scale(${scale})`, transform: `scale(${scale})`,
transformOrigin: 'center top', transformOrigin: 'center top',
transition: 'transform 0.25s ease',
}} }}
> >
<OrganizationChart <OrganizationChart
value={chartData} value={chartData}
nodeTemplate={(node) => nodeTemplate(node, router)} nodeTemplate={(node) => <NodeCard node={node} router={router} />}
className="p-organizationchart p-organizationchart-horizontal"
/> />
</Box> </Box>
</Center>
</Stack> </Stack>
) )
} }
function nodeTemplate(node: any, router: ReturnType<typeof useTransitionRouter>) { function NodeCard({ node, router }: any) {
const imageSrc = node?.data?.image || '/img/default.png' const imageSrc = node?.data?.image || '/img/default.png'
const name = node?.data?.name || 'Tanpa Nama' const name = node?.data?.name || 'Tanpa Nama'
const title = node?.data?.title || 'Tanpa Jabatan' const title = node?.data?.title || 'Tanpa Jabatan'
const description = node?.data?.description || '' const hasId = Boolean(node?.data?.id)
return ( return (
<Transition mounted transition="pop" duration={240}> <Transition mounted transition="pop" duration={300}>
{(styles) => ( {(styles) => (
<Card <Card
radius="lg" shadow="md"
radius="xl"
withBorder withBorder
style={{ style={{
...styles, ...styles,
width: 260, width: 240,
padding: 16, minHeight: 280,
background: 'rgba(28,110,164,0.3)', padding: 20,
borderColor: 'rgba(255,255,255,0.15)', background: 'linear-gradient(135deg, rgba(28,110,164,0.15) 0%, rgba(255,255,255,0.95) 100%)',
display: 'flex', borderColor: 'rgba(28, 110, 164, 0.3)',
flexDirection: 'column', borderWidth: 2,
alignItems: 'center', transition: 'all 0.3s ease',
textAlign: 'center', cursor: hasId ? 'pointer' : 'default',
}}
onMouseEnter={(e) => {
if (hasId) {
e.currentTarget.style.transform = 'translateY(-4px)'
e.currentTarget.style.boxShadow = '0 8px 24px rgba(28, 110, 164, 0.25)'
}
}}
onMouseLeave={(e) => {
if (hasId) {
e.currentTarget.style.transform = 'translateY(0)'
e.currentTarget.style.boxShadow = ''
}
}}
>
<Stack align="center" gap={12}>
{/* Photo */}
<Box
style={{
width: 96,
height: 96,
borderRadius: '50%',
overflow: 'hidden',
border: '3px solid rgba(28, 110, 164, 0.4)',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
background: 'white',
}} }}
> >
<Image <Image
src={imageSrc} src={imageSrc}
alt={name} alt={name}
radius="md" width={96}
width={60} height={96}
height={60}
fit="cover" fit="cover"
loading="lazy"
style={{ style={{
objectFit: 'cover', objectFit: 'cover',
border: '2px solid rgba(255,255,255,0.2)',
marginBottom: 12,
}} }}
loading="lazy"
/> />
<Text fw={700}>{name}</Text> </Box>
<Text size="sm" c="dimmed" mt={4}>
{/* Name */}
<Text
fw={700}
size="sm"
ta="center"
c={colors['blue-button']}
lineClamp={2}
style={{
minHeight: 40,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
wordBreak: 'break-word',
lineHeight: 1.3,
}}
>
{name}
</Text>
{/* Title/Position */}
<Text
size="xs"
c="dimmed"
ta="center"
fw={500}
lineClamp={2}
style={{
minHeight: 32,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
wordBreak: 'break-word',
lineHeight: 1.2,
}}
>
{title} {title}
</Text> </Text>
<Text size="xs" c="dimmed" mt={8} lineClamp={3}>
{description || 'Belum ada deskripsi.'} {/* Detail Button */}
</Text> {hasId && (
<Button <Button
variant="light" variant="gradient"
gradient={{ from: 'blue', to: 'cyan' }}
size="xs" size="xs"
mt="md" fullWidth
onClick={() => { mt={8}
const id = node?.data?.id radius="md"
router.push(`/darmasaba/ppid/struktur-ppid/${id}`) onClick={() =>
router.push(`/darmasaba/ppid/struktur-ppid/${node.data.id}`)
}
style={{
height: 32,
fontWeight: 600,
}} }}
> >
Lihat Detail Lihat Detail
</Button> </Button>
)}
</Stack>
</Card> </Card>
)} )}
</Transition> </Transition>

View File

@@ -0,0 +1,68 @@
/* ============================================
STRUKTUR ORGANISASI PPID - STYLING
============================================ */
/* Tabel chart selalu center */
.p-organizationchart-table {
margin: 0 auto !important;
}
/* Jarak vertikal antar level - lebih lega */
.p-organizationchart-line-down {
height: 32px !important;
}
/* Padding di dalam node - lebih rapi */
.p-organizationchart-node-content {
padding: 0 !important;
background: transparent !important;
border: none !important;
}
/* Garis connector antar node - lebih tebal dan jelas */
.p-organizationchart-line-down,
.p-organizationchart-line-left,
.p-organizationchart-line-right,
.p-organizationchart-line-top {
border-color: rgba(28, 110, 164, 0.4) !important;
border-width: 2px !important;
}
/* Garis horizontal */
.p-organizationchart-line-left,
.p-organizationchart-line-right {
border-top-width: 2px !important;
}
/* Jarak horizontal antar node - lebih proporsional */
.p-organizationchart-table > tbody > tr > td {
padding: 0 24px !important;
vertical-align: top !important;
}
/* Node container spacing */
.p-organizationchart-node {
padding: 8px !important;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.p-organizationchart-table > tbody > tr > td {
padding: 0 12px !important;
}
.p-organizationchart-line-down {
height: 24px !important;
}
}
/* Smooth transitions untuk zoom */
.p-organizationchart {
transition: transform 0.2s ease;
}
/* Fullscreen mode adjustments */
.p-organizationchart-table:fullscreen {
background: rgba(230, 240, 255, 0.98);
padding: 40px;
}

View File

@@ -116,7 +116,7 @@ function NavbarMobile({ listNavbar }: { listNavbar: MenuItem[] }) {
radius="md" radius="md"
p="sm" p="sm"
withBorder withBorder
bg={active ? "blue.0" : "gray.0"} bg={active ? colors["blue-button-2"] : "gray.0"}
onClick={() => { onClick={() => {
if (item.href) { if (item.href) {
router.push(item.href); router.push(item.href);
@@ -126,21 +126,21 @@ function NavbarMobile({ listNavbar }: { listNavbar: MenuItem[] }) {
style={{ style={{
cursor: item.href ? "pointer" : "default", cursor: item.href ? "pointer" : "default",
transition: "background 0.15s ease", transition: "background 0.15s ease",
borderLeft: active ? "4px solid #1e66f5" : "4px solid transparent", borderLeft: active ? `4px solid ${colors['blue-button']}` : "4px solid transparent",
}} }}
> >
<Group justify="space-between" align="center" wrap="nowrap"> <Group justify="space-between" align="center" wrap="nowrap">
<Text <Text
fw={active ? 700 : 600} fw={active ? 700 : 600}
fz="md" fz="md"
c={active ? "blue.7" : "dark.9"} c={active ? colors['blue-button'] : "dark.9"}
> >
{item.name} {item.name}
</Text> </Text>
{item.href && ( {item.href && (
<IconSquareArrowRight <IconSquareArrowRight
size={18} size={18}
color={active ? "#1e66f5" : "inherit"} color={active ? colors['blue-button'] : "inherit"}
/> />
)} )}
</Group> </Group>
@@ -167,21 +167,21 @@ function NavbarMobile({ listNavbar }: { listNavbar: MenuItem[] }) {
cursor: child.href ? "pointer" : "default", cursor: child.href ? "pointer" : "default",
opacity: child.href ? 1 : 0.8, opacity: child.href ? 1 : 0.8,
borderRadius: "0.5rem", borderRadius: "0.5rem",
backgroundColor: childActive ? "#e7f0ff" : "transparent", backgroundColor: childActive ? colors["blue-button-2"] : "transparent",
borderLeft: childActive ? "3px solid #1e66f5" : "3px solid transparent", borderLeft: childActive ? `3px solid ${colors['blue-button']}` : "3px solid transparent",
transition: "background 0.15s ease", transition: "background 0.15s ease",
}} }}
> >
<Text <Text
fz="sm" fz="sm"
fw={childActive ? 600 : 400} fw={childActive ? 600 : 400}
c={childActive ? "blue.7" : "dark.8"} c={childActive ? colors['blue-button'] : "dark.8"}
> >
{child.name} {child.name}
</Text> </Text>
<IconSquareArrowRight <IconSquareArrowRight
size={14} size={14}
color={childActive ? "#1e66f5" : "inherit"} color={childActive ? colors['blue-button'] : "inherit"}
/> />
</Group> </Group>
); );

View File

@@ -112,7 +112,7 @@ function MenuItemCom({ item, isActive = false }: { item: MenuItem, isActive?: bo
<MenuTarget> <MenuTarget>
<Button <Button
variant="subtle" variant="subtle"
color={isActive ? 'blue' : 'gray'} color={isActive ? colors['blue-button'] : 'gray'}
onClick={() => { onClick={() => {
if (item.href) { if (item.href) {
router.push(item.href); router.push(item.href);

View File

@@ -49,12 +49,12 @@ export function NavbarSubMenu({ item }: { item: MenuItem[] | null }) {
rightSection={<IconArrowRight size={18} />} rightSection={<IconArrowRight size={18} />}
styles={(theme) => ({ styles={(theme) => ({
root: { root: {
background: link.href && pathname.startsWith(link.href) ? theme.colors.blue[0] : 'transparent', background: link.href && pathname.startsWith(link.href) ? colors['blue-button-2'] : 'transparent',
color: link.href && pathname.startsWith(link.href) ? theme.colors.blue[7] : colors['blue-button'], color: link.href && pathname.startsWith(link.href) ? colors['blue-button'] : 'gray',
fontWeight: link.href && pathname.startsWith(link.href) ? 600 : 500, fontWeight: link.href && pathname.startsWith(link.href) ? 600 : 500,
transition: "all 0.2s ease", transition: "all 0.2s ease",
"&:hover": { "&:hover": {
background: link.href && pathname.startsWith(link.href) ? theme.colors.blue[1] : theme.colors.gray[0], background: link.href && pathname.startsWith(link.href) ? colors['blue-button-2'] : theme.colors.gray[0],
} }
}, },
})} })}

View File

@@ -245,7 +245,7 @@ function Kepuasan() {
{/* Chart Rating */} {/* Chart Rating */}
<Paper bg={colors['white-1']} p="md" radius="md"> <Paper bg={colors['white-1']} p="md" radius="md">
<Stack> <Stack>
<Title order={4}>Pilihan</Title> <Title order={4}>Ulasan</Title>
{donutDataRating.every(item => item.value === 0) ? ( {donutDataRating.every(item => item.value === 0) ? (
<Text c="dimmed" ta="center" my="md"> <Text c="dimmed" ta="center" my="md">
Belum ada data untuk ditampilkan dalam grafik Belum ada data untuk ditampilkan dalam grafik
@@ -517,7 +517,7 @@ function Kepuasan() {
{/* Chart Rating */} {/* Chart Rating */}
<Paper bg={colors['white-1']} p="md" radius="md"> <Paper bg={colors['white-1']} p="md" radius="md">
<Stack> <Stack>
<Title order={4}>Pilihan</Title> <Title order={4}>Ulasan</Title>
{donutDataRating.every(item => item.value === 0) ? ( {donutDataRating.every(item => item.value === 0) ? (
<Text c="dimmed" ta="center" my="md"> <Text c="dimmed" ta="center" my="md">
Belum ada data untuk ditampilkan dalam grafik Belum ada data untuk ditampilkan dalam grafik

View File

@@ -35,13 +35,13 @@ const navbarListMenu = [
}, },
{ {
id: "1.7", id: "1.7",
name: "Daftar Informasi Publik Desa Darmasaba", name: "Daftar Informasi Publik",
href: "/darmasaba/ppid/daftar-informasi-publik-desa-darmasaba" href: "/darmasaba/ppid/daftar-informasi-publik"
}, },
{ {
id: "1.8", id: "1.8",
name: "IKM Desa Darmasaba", name: "Indeks Kepuasan Masyarakat",
href: "/darmasaba/ppid/ikm-desa-darmasaba" href: "/darmasaba/ppid/indeks-kepuasan-masyarakat"
}, },
] ]